Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ed7b821
MILAB-6648: migrate the example blocks to BlockModelV3
AStaroverov Aug 10, 2026
d5200cc
MILAB-6648: a block kind's identity, as the model reads it
AStaroverov Aug 10, 2026
bf60d7b
MILAB-6648: remap the block ids buried inside a column id
AStaroverov Aug 10, 2026
c7714e3
MILAB-6648: pin the template-v1 file format
AStaroverov Aug 10, 2026
966c6c6
MILAB-6648: the block-kind package
AStaroverov Aug 10, 2026
6e7ee73
MILAB-6648: make kind a mandatory block component
AStaroverov Aug 10, 2026
77f7b03
MILAB-6648: publish and resolve kinds in the registry
AStaroverov Aug 10, 2026
312fb20
MILAB-6648: a block's kind and params contract in BlockModelV3
AStaroverov Aug 10, 2026
4d2ee5c
MILAB-6648: give every example block a kind package
AStaroverov Aug 10, 2026
b387460
MILAB-6648: register the new kind packages in the workspace
AStaroverov Aug 10, 2026
fd960b5
MILAB-6648: export a project as a template
AStaroverov Aug 10, 2026
e01fd67
MILAB-6648: read, check and resolve a template
AStaroverov Aug 10, 2026
570da4a
MILAB-6648: apply a template to a project
AStaroverov Aug 10, 2026
457b62e
MILAB-6648: cover the export/apply round trip against a live backend
AStaroverov Aug 10, 2026
80a9963
MILAB-6648: changesets for block kinds, templates and the example blocks
AStaroverov Aug 10, 2026
2677325
MILAB-6648: design notes for block kinds and project templates
AStaroverov Aug 10, 2026
05e901e
MILAB-6648: keep the reference system out of the template engine
AStaroverov Aug 13, 2026
5d810ec
MILAB-6648: mark the references for the block, instead of asking it to
AStaroverov Aug 13, 2026
9a65555
MILAB-6648: write down how a template carries references
AStaroverov Aug 13, 2026
fe52e96
MILAB-6648: fold the apply orchestrator into the operation it drove
AStaroverov Aug 13, 2026
29741cc
MILAB-6648: say what the kind-overview accumulator actually does
AStaroverov Aug 13, 2026
7cc5b23
MILAB-6648: derive the kind-definition input from the descriptor
AStaroverov Aug 13, 2026
3178b78
MILAB-6648: rename parseTemplateParams to parseInitializationParams
AStaroverov Aug 13, 2026
fe85b7f
MILAB-6648: name the kind wiring the migration chain threads
AStaroverov Aug 13, 2026
2d9cfe2
MILAB-6648: make the kind comments readable without the spec
AStaroverov Aug 13, 2026
527677c
MILAB-6648: make an apply all-or-nothing
AStaroverov Aug 13, 2026
aa28e61
MILAB-6648: settle an entry's params at the parser
AStaroverov Aug 13, 2026
ddccd3d
MILAB-6648: put the locator exclusion in the type
AStaroverov Aug 13, 2026
6e9bd19
MILAB-6648: rename the two params facade callbacks to match
AStaroverov Aug 13, 2026
481ea52
MILAB-6648: document the locator override at its fields
AStaroverov Aug 17, 2026
6459b53
MILAB-6648: say what the pre-apply check actually checks
AStaroverov Aug 17, 2026
e38aa34
MILAB-6648: read the template document without zod
AStaroverov Aug 17, 2026
2c5d8ff
MILAB-6648: check an entry's kind against the block that was prepared
AStaroverov Aug 17, 2026
4ed42e8
MILAB-6648: plain TypeScript, not zod, as a kind's default params check
AStaroverov Aug 17, 2026
c17bfea
MILAB-6648: update export behavior for references to deleted blocks i…
AStaroverov Aug 17, 2026
aab4082
MILAB-6648: remove tmp docs
AStaroverov Aug 17, 2026
3c6dfd9
MILAB-6648: check a kind's required params, not its whole key set
AStaroverov Aug 17, 2026
59c4eeb
MILAB-6648: relocate a template's references in the block, not in the…
AStaroverov Aug 18, 2026
9f1f155
MILAB-6648: relocate and initialize in one call into the block
AStaroverov Aug 18, 2026
45d578d
MILAB-6648: say which side is stale when the id map does not arrive
AStaroverov Aug 18, 2026
c291fe2
MILAB-6648: a readable spelling for a reference in a template file
AStaroverov Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
93 changes: 93 additions & 0 deletions .changeset/milab-6648-block-kinds-and-templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
"@platforma-sdk/block-kind": minor
"@milaboratories/pl-model-common": minor
"@milaboratories/pl-model-middle-layer": minor
"@milaboratories/pl-middle-layer": minor
"@platforma-sdk/block-tools": minor
"@milaboratories/ts-builder": minor
"@platforma-sdk/model": minor
---

Block kinds and project templates.

A **block kind** is a separately-versioned npm package declaring the typed init-params
contract a block is created from; many block versions implement one kind version. On top
of kinds sits the **template engine**: a project exports to `template-v1` YAML, and a
template — exported or hand-authored — applies into a fresh project.

**New package `@platforma-sdk/block-kind`.** `defineBlockKind<BlockParams>({ name,
version, parseInitializationParams })` returns a frozen `CompiledBlockKind`. Source `name` /
`version` from the kind's own `package.json` so the on-wire `{name}@{version}` cannot
drift from what npm publishes.

```ts
// <block>/kind/src/index.ts
import { defineBlockKind } from "@platforma-sdk/block-kind";
import { name, version } from "../package.json" with { type: "json" };

export type BlockParams = { numbers?: number[] };
const Params = z.object({ numbers: z.array(z.number()).optional() }).strict();

export const kind = defineBlockKind<BlockParams>({
name,
version,
parseInitializationParams: (value) => Params.parse(value),
});
```

**`@platforma-sdk/model`** — the kind is now part of the model:

```ts
const dataModel = new DataModelBuilder({ kind })
.from<BlockDataV1>("v1")
.init(({ params }) => ({ numbers: params?.numbers ?? [] }));

export const platforma = BlockModelV3.create({ dataModel, kind })
.templateParams((data) => ({ numbers: data.numbers }))
.args(...)
.done();
```

`init` receives the kind's `params` (optional — a block may be created without a
template) and builds the block's initial storage from them. `templateParams()` is the
inverse: it projects block state back to the kind's params for export. Both are written in
live terms — the SDK marks the column identifiers in what the lambda returned, so nothing about
templates leaks into a block's own code.

**`@milaboratories/pl-model-common`** — `BlockKindReference` + `formatKindRef`, the
`template-v1` document schema, the kind selector's semver ranges, and the `{ $ref: … }` wrapper
that marks a column identifier inside template params. `wrapTemplateRefs` puts those wrappers
on, in the block's own bundle where the reference system is already known; the template engine
stores what is inside verbatim and redirects the block ids textually, so it holds no model of
that system at all.

**`@milaboratories/pl-middle-layer`** — `MiddleLayer.exportProjectAsTemplate(id)` and
`MiddleLayer.applyTemplateToProject(id, document)`, backing "Export Project as
Template…" and "Create Project from Template…". The template import path is public:
`parseProjectTemplateV1Yaml`, `validateTemplateV1ForApply`, `resolveTemplateEntries`, plus the
`BlockPackProvider` seam deciding which registries to consult. Entries resolve against the configured registries, ids are mapped to the blocks
they become, and each entry's params are offered to the block's kind for a shape check
before anything is created.

**`@platforma-sdk/block-tools`** — the `kind` part in `.structure` with its own package
rules and scaffold, a `build-kind-manifest` command, kind-first publication (the kind
content is written to the registry's `kinds/` tree, source-hash guarded and idempotent,
before the facade — gated by a version-match check that hard-fails before any write),
and registry-side kind resolution.

**`@milaboratories/ts-builder`** — `block-kind` build target (rolldown config + tsconfig).

**BREAKING:**

- `BlockModelV3.create(dataModel)` → `BlockModelV3.create({ dataModel, kind })`. A block
cannot omit its kind.
- `new DataModelBuilder()` → `new DataModelBuilder({ kind })`, and `init` takes
`({ params })` rather than no argument.
- `templateParams()` is required — `done()` throws without it. A block whose state
cannot be reduced to params returns `{}` explicitly, rather than exporting an entry
that silently applies as a default-initialized block.
- Every kind must declare `parseInitializationParams`. A kind whose params are genuinely empty
still declares one; it just rejects everything but `{}`.
- Publishing a block whose model was compiled against a kind requires the facade to
declare that kind as a dependency, at a matching version. Blocks declaring no kind
publish exactly as before.
10 changes: 10 additions & 0 deletions .changeset/milab-6648-example-blocks-declare-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@milaboratories/milaboratories.monetization-test": patch
"@milaboratories/milaboratories.pool-explorer": patch
"@milaboratories/milaboratories.ui-examples": patch
---

Declare a block kind. Each block gains a `kind/` package holding its init-params
contract, and its model is built with `new DataModelBuilder({ kind })` /
`BlockModelV3.create({ dataModel, kind })` and projects its params back via
`templateParams()`.
2 changes: 1 addition & 1 deletion etc/blocks/blob-url-custom-protocol/.structure
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version":1}
{"version":2}
1 change: 1 addition & 0 deletions etc/blocks/blob-url-custom-protocol/block/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"dependencies": {},
"devDependencies": {
"@milaboratories/milaboratories.test-blob-url-custom-protocol.kind": "workspace:*",
"@milaboratories/milaboratories.test-blob-url-custom-protocol.model": "workspace:*",
"@milaboratories/milaboratories.test-blob-url-custom-protocol.ui": "workspace:*",
"@milaboratories/milaboratories.test-blob-url-custom-protocol.workflow": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"extends": ["node_modules/@milaboratories/ts-builder/dist/configs/oxlint-block-ui.json"]
"extends": ["node_modules/@milaboratories/ts-builder/dist/configs/oxlint-node.json"]
}
43 changes: 43 additions & 0 deletions etc/blocks/blob-url-custom-protocol/kind/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@milaboratories/milaboratories.test-blob-url-custom-protocol.kind",
"version": "1.0.0",
"private": true,
"description": "Block kind for the blob-url-custom-protocol block",
"files": [
"dist/**/*"
],
"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": {
"build": "ts-builder build --target block-kind && block-tools build-kind-manifest",
"check": "ts-builder check --target block-kind",
"formatter:check": "ts-builder formatter --check",
"linter:check": "ts-builder linter --check",
"types:check": "ts-builder type-check --target block-kind",
"fmt": "ts-builder format",
"watch": "ts-builder build --target block-kind --watch"
},
"dependencies": {
"@platforma-sdk/block-kind": "workspace:*"
},
"devDependencies": {
"@milaboratories/ts-builder": "workspace:*",
"@milaboratories/ts-configs": "workspace:*",
"@platforma-sdk/block-tools": "workspace:*"
},
"peerDependencies": {
"@types/node": "*",
"typescript": "*"
}
}
32 changes: 32 additions & 0 deletions etc/blocks/blob-url-custom-protocol/kind/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { assertParamsObject, defineBlockKind } from "@platforma-sdk/block-kind";
import { name, version } from "../package.json" with { type: "json" };

/**
* Init-params contract for the blob-url-custom-protocol block — deliberately
* empty. The block's whole `BlockData` is two `ImportFileHandle`s, and those are
* desktop-signed, machine- and session-local references produced by a real OS
* file-dialog gesture (see the upload flow). Nothing a creator or a project
* template could serialize ahead of time, so this block takes no init params and
* `init` always returns the unset defaults.
*/
export type BlockParams = Record<string, never>;

/**
* The same contract at runtime, for params that arrive from a template file rather
* than from typed code.
*
* An empty contract has nothing to check beyond the envelope: any field a file sets is a
* field this block does not read, so it is dropped rather than refused, and the block
* initializes exactly as it would with no params at all.
*/
function parseInitializationParams(value: unknown): BlockParams {
assertParamsObject(value);

return {};
}

export const kind = defineBlockKind<BlockParams>({
name,
version,
parseInitializationParams,
});
10 changes: 10 additions & 0 deletions etc/blocks/blob-url-custom-protocol/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 etc/blocks/blob-url-custom-protocol/model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"watch": "ts-builder build --target block-model --watch"
},
"dependencies": {
"@milaboratories/milaboratories.test-blob-url-custom-protocol.kind": "workspace:*",
"@platforma-sdk/model": "workspace:*",
"zod": "catalog:"
},
Expand Down
49 changes: 35 additions & 14 deletions etc/blocks/blob-url-custom-protocol/model/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import type { ImportFileHandle, InferHrefType, InferOutputsType } from "@platforma-sdk/model";
import {
BlockModel,
extractArchiveAndGetURL,
getResourceField,
MainOutputs,
} from "@platforma-sdk/model";
import { BlockModelV3, DataModelBuilder } from "@platforma-sdk/model";
import { kind } from "@milaboratories/milaboratories.test-blob-url-custom-protocol.kind";
import { z } from "zod";

export const ImportFileHandleSchema = z
Expand All @@ -14,26 +10,51 @@ export const ImportFileHandleSchema = z
((_a) => true) as (arg: string | undefined) => arg is ImportFileHandle | undefined,
);

export const BlockArgs = z.object({
export const BlockData = z.object({
inputTgzHandle: ImportFileHandleSchema,
inputZipHandle: ImportFileHandleSchema,
});

export type BlockArgs = z.infer<typeof BlockArgs>;
export type BlockData = z.infer<typeof BlockData>;

export const platforma = BlockModel.create("Heavy")
/** What the workflow consumes — projected from {@link BlockData} by the args lambda. */
export type BlockArgs = {
inputTgzHandle: ImportFileHandle | undefined;
inputZipHandle: ImportFileHandle | undefined;
};

.withArgs({
inputTgzHandle: undefined,
inputZipHandle: undefined,
})
// This block takes no init params (its kind declares `Record<string, never>`):
// both fields are desktop-signed `ImportFileHandle`s, which no template can
// pre-wire. So `init` ignores params and returns the unset defaults.
const dataModel = new DataModelBuilder({ kind })
.from<BlockData>("v1")
.init(() => ({ inputTgzHandle: undefined, inputZipHandle: undefined }));

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

.args<BlockArgs>((data) => ({
inputTgzHandle: data.inputTgzHandle,
inputZipHandle: data.inputZipHandle,
}))

// Nothing to project: the kind takes no params, because both handles are signed,
// session-local references from an OS file-dialog gesture and would not resolve in
// the project a template is applied into.
.templateParams(() => ({}))

.output("handleTgz", (ctx) => ctx.outputs?.resolve("handleTgz")?.getImportProgress())
.output("handleZip", (ctx) => ctx.outputs?.resolve("handleZip")?.getImportProgress())

.output("tgz_content", extractArchiveAndGetURL(getResourceField(MainOutputs, "siteTgz"), "tgz"))
// Both archive outputs use the accessor form. V1 drove `tgz_content` through
// the config-based `extractArchiveAndGetURL(getResourceField(MainOutputs, …))`
// helpers so the block covered both surfaces; those helpers return a
// `TypedConfig`, which only V1's `output()` accepts — `BlockModelV3.output()`
// takes render lambdas only. The config surface is therefore gone here, and
// the two outputs differ solely in the archive format they extract.
.output("tgz_content", (ctx) => ctx.outputs?.resolve("siteTgz")?.extractArchiveAndGetURL("tgz"))

.output("zip_content", (ctx) => ctx.outputs?.resolve("siteZip")?.extractArchiveAndGetURL("zip"))

.sections((_ctx) => {
return [{ type: "link", href: "/", label: "Main" }];
})
Expand Down
8 changes: 4 additions & 4 deletions etc/blocks/blob-url-custom-protocol/ui/src/MainPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ const app = useApp();

<template>
<PlBlockPage style="max-width: 100%">
<PlFileInput v-model="app.model.args.inputTgzHandle" label="Select tgz file to import" />
<PlFileInput v-model="app.model.args.inputZipHandle" label="Select zip file to import" />
<PlFileInput v-model="app.model.data.inputTgzHandle" label="Select tgz file to import" />
<PlFileInput v-model="app.model.data.inputZipHandle" label="Select zip file to import" />

<PlAlert type="success">
Blob tgz content:
Expand All @@ -25,8 +25,8 @@ const app = useApp();
</PlAlert>

<fieldset>
<legend>Args (app.model.args)</legend>
{{ app.model.args }}
<legend>Data (app.model.data)</legend>
{{ app.model.data }}
</fieldset>
<h3>app.model</h3>
<code>{{ app.model }}</code>
Expand Down
10 changes: 5 additions & 5 deletions etc/blocks/blob-url-custom-protocol/ui/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import type { Equal, Expect } from "@milaboratories/helpers";
import { platforma } from "@milaboratories/milaboratories.test-blob-url-custom-protocol.model";
import { defineApp } from "@platforma-sdk/ui-vue";
import { defineAppV3 } from "@platforma-sdk/ui-vue";
import { computed, reactive } from "vue";
import MainPage from "./MainPage.vue";

export const sdkPlugin = defineApp(platforma, (base) => {
export const sdkPlugin = defineAppV3(platforma, (base) => {
// Additional data
const data = reactive({
counter: 0,
});

const argsAsJson = computed(() => JSON.stringify(base.snapshot.args));
const dataAsJson = computed(() => JSON.stringify(base.snapshot.blockStorage));

return {
data,
argsAsJson,
dataAsJson,
routes: {
"/": () => MainPage,
},
Expand All @@ -25,7 +25,7 @@ type App = ReturnType<typeof sdkPlugin.useApp>;

type __cases = [
Expect<Equal<App["data"], { counter: number }>>,
Expect<Equal<App["argsAsJson"], string>>,
Expect<Equal<App["dataAsJson"], string>>,
];

export const useApp = sdkPlugin.useApp;
2 changes: 1 addition & 1 deletion etc/blocks/download-file/.structure
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version":1}
{"version":2}
1 change: 1 addition & 0 deletions etc/blocks/download-file/block/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"dependencies": {},
"devDependencies": {
"@milaboratories/milaboratories.test-download-file.kind": "workspace:*",
"@milaboratories/milaboratories.test-download-file.model": "workspace:*",
"@milaboratories/milaboratories.test-download-file.ui": "workspace:*",
"@milaboratories/milaboratories.test-download-file.workflow": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"extends": ["node_modules/@milaboratories/ts-builder/dist/configs/oxlint-block-ui.json"]
"extends": ["node_modules/@milaboratories/ts-builder/dist/configs/oxlint-node.json"]
}
Loading
Loading