Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/mandatory-block-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@platforma-open/milaboratories.rarefaction.kind': minor
'@platforma-open/milaboratories.rarefaction.model': minor
'@platforma-open/milaboratories.rarefaction': minor
---

Add the mandatory block kind and its init-params contract

The block now declares a `kind/` package carrying its identity and its
init-params contract — the fields a project template supplies to seed a new
instance: `datasetRef`, `numPoints`, `numIterations`, `extrapolation`, `mem`,
`cpu`, `datasetLabel` and `customBlockLabel`. The model consumes them in `init`
and projects the same set back out via `templateParams`, so export and apply are
inverses. Every field is optional and keeps a default, since a block may be
created by hand rather than from a template.

`tableState` and `graphState` stay out of the contract: they are view state, and
a template that carried them would restore one user's scroll position onto
another user's block.

Also upgrades the SDK catalog to the current published versions.
7 changes: 6 additions & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
with:
app-name: 'Block: Rarefaction'
app-name-slug: 'block-rarefaction'
node-version: '20.x'
node-version: '22.x'
gha-runner-label: hz-ubuntu-dind
build-script-name: 'build:dev-local'
build-before-publish-script-name: 'build:release'
Expand All @@ -39,6 +39,11 @@ jobs:
package-path: 'block'
create-tag: 'true'

# Require the published `block` package to be bumped by a changeset on
# PRs (empty changeset or the `skip-changelog` label waives it). Needs
# the input to exist on the pinned `@v4` reusable workflow.
require-package-path-bump: true

npmrc-config: |
{
"registries": {
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/mark-stable.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
uses: milaboratory/github-ci/.github/workflows/block-mark-stable.yaml@v4
with:
app-name: 'Block: Rarefaction - Mark Stable'
node-version: '20.x'
node-version: '22.x'
npmrc-config: |
{
"registries": {
Expand Down
1 change: 1 addition & 0 deletions block/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"devDependencies": {
"@milaboratories/ts-builder": "catalog:",
"@milaboratories/ts-configs": "catalog:",
"@platforma-open/milaboratories.rarefaction.kind": "workspace:*",
"@platforma-open/milaboratories.rarefaction.model": "workspace:*",
"@platforma-open/milaboratories.rarefaction.ui": "workspace:*",
"@platforma-open/milaboratories.rarefaction.workflow": "workspace:*",
Expand Down
4 changes: 4 additions & 0 deletions kind/.oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": ["node_modules/@milaboratories/ts-builder/configs/oxfmt.json"],
"ignorePatterns": ["dist", "coverage", "CHANGELOG.md"]
}
3 changes: 3 additions & 0 deletions kind/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["node_modules/@milaboratories/ts-builder/dist/configs/oxlint-node.json"]
}
39 changes: 39 additions & 0 deletions kind/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@platforma-open/milaboratories.rarefaction.kind",
"version": "1.0.0",
"private": true,
"description": "Block kind for the rarefaction block",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"sources": "./src/index.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
},
"scripts": {
"fmt": "ts-builder format",
"watch": "ts-builder build --target block-kind --watch",
"build": "ts-builder build --target block-kind && block-tools build-kind-manifest",
"check": "ts-builder check --target block-kind"
},
"dependencies": {
"@platforma-sdk/block-kind": "catalog:",
"@platforma-sdk/model": "catalog:",
"es-toolkit": "catalog:"
},
"devDependencies": {
"@milaboratories/ts-builder": "catalog:",
"@milaboratories/ts-configs": "catalog:",
"@platforma-sdk/block-tools": "catalog:"
},
"peerDependencies": {
"@types/node": "*",
"typescript": "*"
}
}
15 changes: 15 additions & 0 deletions kind/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineBlockKind } from "@platforma-sdk/block-kind";
import { name, version } from "../package.json" with { type: "json" };
import { parseInitializationParams } from "./params";
import type { BlockParams } from "./types";

export type { BlockParams } from "./types";

// Identity (`name`/`version`) comes from this package's own `package.json`, so
// the on-wire `{name}@{version}` reference can never drift from what npm
// publishes; the bundler inlines the JSON import.
export const kind = defineBlockKind<BlockParams>({
name,
version,
parseInitializationParams,
});
78 changes: 78 additions & 0 deletions kind/src/params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { assertParamsObject } from "@platforma-sdk/block-kind";
import { isPlRef } from "@platforma-sdk/model";
import { isBoolean, isString } from "es-toolkit";
import { isNumber } from "es-toolkit/compat";
import type { BlockParams } from "./types";

/**
* The contract at runtime, for params that arrive from a template file rather
* than from typed code.
*
* Each field the contract names is read and checked; a key it does not name is
* dropped by never being read, so it needs no rejection here. Params written
* against a different version of the contract are caught by the version in the
* template entry's `{name}@{selector}` reference, not by a key-set check.
*/
export function parseInitializationParams(value: unknown): BlockParams {
assertParamsObject(value);

const params: Record<string, unknown> = {};
for (const [field, { is, must }] of Object.entries(CONTRACT)) {
const raw = value[field];
if (raw === undefined) continue;
if (!is(raw)) throw new Error(`'${field}' must be ${must}.`);
params[field] = raw;
}
// Every value placed here passed its own field's guard, and `CONTRACT` is
// proven exhaustive over `BlockParams` by the `satisfies` below.
return params as BlockParams;
}

// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------

type Guard<T> = (value: unknown) => value is T;

/** A guard plus how to finish the sentence "'field' must be …". */
type Check<T> = { readonly is: Guard<T>; readonly must: string };

function check<T>(is: Guard<T>, must: string): Check<T> {
return { is, must };
}

const REF = "a reference to another block's output";

/**
* The contract, field by field, at runtime.
*
* `datasetRef` is checked with `isPlRef` rather than a restatement of the shape,
* so a hand-written entry is held to exactly what the rest of the system calls a
* reference — including the `__isRef` marker the block dependency tree is
* rebuilt from, whose absence would produce a block wired to nothing.
*
* `numPoints` and `numIterations` are checked only as strings, which is what the
* block stores: the UI binds them to text inputs, so a half-typed value is an
* ordinary state, and the block's own `args` is what holds them to whole numbers
* under 10000. Rejecting a non-numeric string here would refuse a state the
* editor reaches and can export.
*
* The `satisfies` clause is the drift guard: it demands an entry for every key
* `BlockParams` declares, and types each guard against that key's own type. Add
* a field to the contract and this stops compiling until the check exists —
* which matters here because every field is optional, so a parser that simply
* forgot one would otherwise return a valid `BlockParams` and say nothing.
*/
const CONTRACT = {
datasetRef: check(isPlRef, REF),

numPoints: check(isString, "a string"),
numIterations: check(isString, "a string"),
extrapolation: check(isBoolean, "a boolean"),

mem: check(isNumber, "a number"),
cpu: check(isNumber, "a number"),

datasetLabel: check(isString, "a string"),
customBlockLabel: check(isString, "a string"),
} satisfies { [K in keyof BlockParams]-?: Check<NonNullable<BlockParams[K]>> };
36 changes: 36 additions & 0 deletions kind/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { PlRef } from "@platforma-sdk/model";

/**
* This block's init-params contract — the shape a block of this kind receives
* at creation, and exactly what a project template serializes for it: which
* dataset the curves are drawn from, the knobs that decide what gets computed,
* and the naming shown on the block.
*
* Every field is optional. A block with no dataset picked is an ordinary state
* the UI reaches, so export has to be able to write it and apply has to be able
* to take it back; a contract that demanded `datasetRef` would make export and
* apply stop being inverses. Whether a configuration is runnable is settled by
* the model's `args` lambda, not here.
*
* `tableState` and `graphState` are absent: they are view state, which the
* projection never hands out.
*/
export type BlockParams = {
// Input wiring — a PlRef a template engine fills from an earlier entry's output.
datasetRef?: PlRef;

// Analysis configuration — the recipe a template exists to reproduce.
numPoints?: string;
numIterations?: string;
extrapolation?: boolean;

// Per-process resource limits — a deliberate user choice, set in the block's
// advanced settings, and part of the recipe a template reproduces. A block
// created without a template takes the model's defaults instead.
mem?: number;
cpu?: number;

// Display naming.
datasetLabel?: string;
customBlockLabel?: string;
};
10 changes: 10 additions & 0 deletions kind/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "@milaboratories/ts-configs/block/facade",
"compilerOptions": {
"outDir": "./dist",
"rootDir": ".",
"resolveJsonModule": true
},
"include": ["src/**/*", "package.json"],
"exclude": ["dist", "node_modules"]
}
1 change: 1 addition & 0 deletions model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"dependencies": {
"@milaboratories/graph-maker": "catalog:",
"@milaboratories/helpers": "catalog:",
"@platforma-open/milaboratories.rarefaction.kind": "workspace:*",
"@platforma-sdk/model": "catalog:"
},
"devDependencies": {
Expand Down
43 changes: 32 additions & 11 deletions model/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createPlDataTableV2,
DataModelBuilder,
} from "@platforma-sdk/model";
import { kind } from "@platforma-open/milaboratories.rarefaction.kind";
import { getDefaultBlockLabel } from "./label";
import type { BlockArgs, BlockData, LegacyBlockArgs, LegacyBlockUiState } from "./types";

Expand All @@ -17,7 +18,7 @@ const defaultGraphState = (): BlockData["graphState"] => ({
currentTab: "settings",
});

const blockDataModel = new DataModelBuilder()
const blockDataModel = new DataModelBuilder({ kind })
.from<BlockData>("V20260518")
.upgradeLegacy<LegacyBlockArgs, LegacyBlockUiState>(({ args, uiState }) => ({
customBlockLabel: args?.customBlockLabel ?? "",
Expand All @@ -33,15 +34,17 @@ const blockDataModel = new DataModelBuilder()
tableState: uiState?.tableState ?? createPlDataTableStateV2(),
graphState: uiState?.graphState ?? defaultGraphState(),
}))
.init(() => ({
customBlockLabel: "",
datasetRef: undefined,
datasetLabel: undefined,
numPoints: "20",
numIterations: "100",
extrapolation: true,
mem: 8,
cpu: 4,
// `params` is absent when a block is created by hand rather than from a
// template, so every field the contract carries keeps its own default.
.init(({ params }) => ({
customBlockLabel: params?.customBlockLabel ?? "",
datasetRef: params?.datasetRef,
datasetLabel: params?.datasetLabel,
numPoints: params?.numPoints ?? "20",
numIterations: params?.numIterations ?? "100",
extrapolation: params?.extrapolation ?? true,
mem: params?.mem ?? 8,
cpu: params?.cpu ?? 4,
tableState: createPlDataTableStateV2(),
graphState: defaultGraphState(),
}));
Expand All @@ -63,7 +66,25 @@ function deriveSubtitle(data: BlockData): string {
);
}

export const platforma = BlockModelV3.create(blockDataModel)
export const platforma = BlockModelV3.create({ dataModel: blockDataModel, kind })

// Inverse of `init` — the same fields, projected back out for template export,
// handed back exactly as they sit in live state. `tableState` and `graphState`
// are view state and never cross the boundary. `datasetRef` carries a block id,
// which the SDK rewrites on the way out and resolves again on the way in.
.templateParams((data) => ({
datasetRef: data.datasetRef,

numPoints: data.numPoints,
numIterations: data.numIterations,
extrapolation: data.extrapolation,

mem: data.mem,
cpu: data.cpu,

datasetLabel: data.datasetLabel,
customBlockLabel: data.customBlockLabel,
}))

.args<BlockArgs>((data) => {
if (data.datasetRef === undefined) throw new Error("Dataset is required");
Expand Down
Loading
Loading