MILAB-6648: Project template - #1767
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
🦋 Changeset detectedLatest commit: c291fe2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 29 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1767 +/- ##
==========================================
+ Coverage 52.94% 52.97% +0.03%
==========================================
Files 375 412 +37
Lines 20026 21322 +1296
Branches 4437 4761 +324
==========================================
+ Hits 10602 11296 +694
- Misses 8117 8665 +548
- Partials 1307 1361 +54 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
9e79ae2 to
866d82b
Compare
Mechanical, and a prerequisite rather than part of the feature: a kind is a mandatory component of a V3 block, so the workspace cannot hold V2 example blocks once the following commits land. The `-v3` suffixed duplicates that existed for migration testing are removed and their non-suffixed counterparts take their place, so every reference to `enter-numbers-v3` / `sum-numbers-v3` in tests moves with them. `ml-legacy.test.ts` and `v2.test.ts` covered the V2 path only and are dropped; the two cases from `ml-legacy.test.ts` still worth keeping are carried into `ml-v3.test.ts`. Each block's model also projects its params, which only compiles once the params contract lands — see the sdk/model commit later in this branch.
`BlockKindReference` is the `{name}@{version}` string a published block advertises,
and `formatKindRef` is the one place it is built from a compiled kind. The reference
sits on the config container beside `code`, not inside a render envelope: which kind
a block implements is orthogonal to how it renders, and the container is what a
reader already has in hand.
The read side stays optional. Blocks published before kinds existed carry no
reference, and the middle layer must keep reading them.
A PColumn id carries the block id of the block that produced it, sometimes several levels down: under wrappers, in every hop of a discovered path, and in the keys of a qualifications map. Template export and apply both have to rewrite those ids, so the walk lives beside the id codec rather than in either caller. Rewriting is by value: an id that does not change comes back as the very same string rather than a re-serialized equivalent, so a caller can tell "nothing to do" from "rewritten to the same thing".
The schema a template file is parsed against, and the codec that converts the
references inside an entry's params between the two forms they take: a live `PlRef`
naming a block, and the two-key mapping a file uses to name another entry.
Two decisions the schema makes, both load-bearing:
- `kind` is required on every entry, because the kind carries the params contract —
an entry without one describes params nothing can check.
- a kind selector is its own three-tier grammar (exact, patch-float, minor-float)
rather than an npm range, so that below 1.0.0 the tiers stay distinguishable,
where npm's `~` and `^` collapse into the same thing.
An entry may pin a version or pin a place, never both: a pinned place is what makes a
template resolvable without a registry, and a pinned version is what makes it
portable, so a file that claims both has said nothing definite.
A kind is a block's identity plus its init-params contract, declared in its own tiny package so that two blocks can implement the same kind and a template can name one without naming an implementation. The params type is recovered from the declaration (`InferBlockParams`), and a kind with wider params is deliberately not assignable to one with narrower params — a block must not widen the contract it claims to implement. `ts-builder` gets the rolldown and tsconfig pair a kind package builds with; a kind ships as its own artifact because a block bundles its dependencies once and the kind has to be readable without unpacking the block.
The structurer gains `kind/` as a fourth component beside model, workflow and ui, so `block-tools` scaffolds it, validates its package.json, and refuses a block without one. New blocks get a params parser stub that deliberately does not build: a kind whose contract was never written should fail loudly at build time, not silently accept anything. A kind is built twice — once as its own publishable artifact, once bundled into the block — because the registry must be able to read a kind's contract without unpacking any implementation, while the block still needs it at runtime. `checkKindVersionMatch` is the gate publishing runs before anything is written: the kind a block claims and the kind that was built have to agree on name, org and version, with an incidental npm-scope difference normalized away.
Publishing is kind-first: the version-match gate, then the kind, then the block. A kind version's content folder is immutable — republishing identical content is an idempotent no-op, republishing the same version with different content is a hard failure, because a template that pinned that version would otherwise silently mean something else. Reconcile derives a per-kind overview (which kind versions exist, which blocks implement each, in which channels) in the same pass that already reconciles packages, rather than in a second reconciler. The overview is a derived view, so a channel change after the fact re-derives it. `resolveKind` maps a selector tier to an explicit semver range and picks the newest matching kind version with a stable implementer, falling back to the any channel only when the caller allows unstable. The block-repo tests carry this on the same axes block publishing is already covered on, and across two kind versions — with a single version on the registry every selector picks the same block whether or not the ranges are right.
Three things arrive together because the type parameter that carries the kind's params
threads through all of them:
- `create()` now takes a kind, and the authoring API can no longer produce a
kind-less block. The kind handed to the data model and the one handed to `create()`
are cross-checked, so there is one source rather than a precedence order.
- `templateParams()` is required. It is the inverse of the data model's `init`:
`init` builds data from params, this recovers the params that would rebuild the
current data. A block whose state carries nothing worth restoring returns `{}` and
says so, rather than exporting an entry that silently applies as a default block.
- `parseTemplateParams` is read off the compiled kind, never declared per block.
The contract belongs to the kind, so two blocks implementing it cannot disagree
about what a valid params object is.
There is no unchecked path: what flows on to `init` is the parser's output, not its
input, so params the kind rejects never reach a block.
A known hole, left explicit in the tests: an extra field the kind does not declare is
not rejected, because a kind's params are a TypeScript type with no runtime schema of
their own beyond what the parser chooses to check.
Generated, and uniform by construction: one `kind/` package per example block, four config files and a params declaration each. Nothing here is a design decision — the contracts these kinds declare are whatever the corresponding block model already projects in its `templateParams()`. Worth skimming rather than reading, with one exception: the params type in each `kind/src/index.ts` is the contract a template file is checked against, so it is the one line per block that carries meaning.
Generated. `pnpm-workspace.yaml` picks up the `kind/` glob and the lockfile follows.
A dependency-order walk over the project structure, then a serializer that renders the result as `template-v1` YAML. Structure order is already instantiation order, so the walk sorts nothing. All or nothing: no partial YAML is ever produced. The walk collects every problem and the serializer adds its own, and they are reported together in one pass, because a half-written template is worse than none. The central check is that no live block id escapes into the file. A `PlRef` the codec failed to rewrite is caught, and so is one hidden inside a string (the `EnrichmentRef` case) or double-stringified. A column id naming a block the template describes is a wire and is fine; one naming a block it does not describe is a fault. A block cannot name itself, since its own id is only published once it has been written. `mutator/project.ts` gains the kind reference on `BlockInfo` — `extractConfig` normalizes the render envelope one level below where the kind sits, so it has to be read off the container during the load that is already happening. The `initialStorage` seam added here is what the apply path later fills in.
The three steps before anything is created: parse the YAML, check the document on its own, then resolve each entry to a concrete block pack. Parse errors are located the way the file is written, and every schema problem is listed rather than just the first, because a hand-edited template usually has more than one thing wrong with it. Validation runs before creation, not during: forward references, self-references, references to ids the file does not define, and — the case that only shows up with a file from another machine — params still carrying a live block id from the project they were exported from, including one hidden inside a string. Resolution has two routes. An entry that names a place is read from that place and consults no registry at all, which is the whole point of naming one; its declared kind is then verified against what was actually found there. An entry that names a kind goes to the configured registries in order, first hit winning, and when they disagree the failure that got furthest is the one reported — a registry that has never heard of the kind is less informative than one that has it but only unstable. Every entry is attempted before anything is reported, so a document that half resolves says so about both halves.
Creation, in file order, with an id map recording which block each entry became so the
next entry's references can point at blocks that already exist. A forward reference is
impossible by construction here rather than merely rejected.
Every entry goes through the params path, including one with no params, which is
applied as `{}`. There is no second, unchecked way in — that is what makes "the kind
checked these params" true of every block a template creates.
Params are turned into storage in the model VM before the transaction is touched, so a
kind rejecting them is a value the caller can report rather than a half-applied
project. Apply stops at the first entry it cannot add and reports what already landed:
partial is visible, never silent. A rejected entry does not become a reference target.
`MiddleLayer` gains both public entry points. They take a project id rather than an
open project, because exporting and applying are properties of the stored project, not
of a session with it — the export command lives on a project card where the project is
usually closed, and the import flow has just created the project and has no session yet.
Export and apply are inverses. The two halves only a live project can exercise are deriving every block's params in the model VM off stored state, and resolving, installing and placing blocks against a real backend; everything between them is unit tested in the middle layer, so what is proven here is that the two ends compose. Applying goes through the YAML text rather than the in-memory document, so what a user would actually save is what the importer reads. Equivalence is stated up to renaming, since applying creates new blocks with new ids. The fidelity boundary is asserted rather than avoided: a field the kind declares survives, a field it does not is lost and the test says so. Transport is held constant — both blocks are installed from a folder on disk, with `resolveKind` stubbed to throw, so a located entry reaching a registry would fail the test rather than pass quietly.
The working documents this branch was built from: the kind and lifecycle design, the export and import designs, and the resolved-question log each carries. Kept because the decisions behind several non-obvious choices — the selector grammar below 1.0.0, kind built twice, params checked by the kind rather than the block — are recorded here and nowhere else. Separated into its own commit so the code can be reviewed without it, and so it can be dropped or relocated without touching anything else.
866d82b to
2677325
Compare
| // Seed every existing kind overview empty so kinds orphaned by | ||
| // migration/removal are rewritten (or deleted) this pass. The block scan | ||
| // above already re-enumerates every live kind ref (refs live inside block | ||
| // manifests); this LIST exists solely to reset orphans. | ||
| const kindPaths = await this.storage.listFiles(KindsPrefix); | ||
| for (const rel of kindPaths) { | ||
| if (!KindOverviewPathPattern.test(rel)) continue; | ||
| touchedKinds.set(KindsPrefix + rel, { touched: null, add: [] }); | ||
| } |
There was a problem hiding this comment.
This is an expensive operation that is O(N) from the number of kinds == number of blocks, let's do it only for mode == "force" only. Please check that I am not missing something, if my understanding is correct it should work okay, even for deletion, if it is not due to real deletion of the block, which anyway is processed only for the force mode.
There was a problem hiding this comment.
If we have to read all kinds each time we do this sync, let's rethink how we approach it.
There was a problem hiding this comment.
-
It is already force-only. The block sits inside if (mode === "force") — opened at L219, closed at L239. The incremental pass never runs this LIST.
-
Kinds ≠ blocks. One overview file per kind package (kinds/{org}/{name}/overview.json), and many block versions implement one kind — so it is far fewer than blocks. Also, listFiles(MainPrefix) at L221, in the same branch, enumerates every version of every package: strictly more than this one.
-
One LIST, no reads. No getKindOverviewAt per kind — for a rebuild plan the existing content is discarded, so it is not fetched at all (that GET was dropped in 29741cc).
-
Deletion — your understanding is right, with one addition. A seeded version whose manifest is gone IS handled incrementally: its entry is marked replaced at L299, then the manifest read continues at L312, so nothing re-adds it. What the incremental pass cannot see is deletion with no seed — direct removal, migration, a kind rename — which is exactly what this pre-seed exists for.
-
Worth fixing anyway: 20 lines separate if (mode === "force") from here, so "force-only" is invisible while reading. Happy to extract forceAllVersionsToBeUpdated() and seedKindOverviewsForRebuild() so the gating reads in one line.
The engine parsed column identifiers to move block ids between projects, which meant it carried a model of the whole reference system: five key forms, nesting by string, canonicalization, ids in map keys. Every time that system grows, the engine would have to grow with it or silently drop what it did not recognize. It no longer parses anything. A block marks the params values that carry block ids with `toTemplateRef`, the document stores what is inside verbatim, and apply redirects the ids textually — whole JSON string tokens, with however many backslashes escape their quotes, which is what makes nesting depth irrelevant. The wrapper comes off before the kind sees params, so a params contract stays written in live terms. Gone with it: the params codec (`toTemplateForm` / `fromTemplateForm`, `TemplateForm`) and the column-id remapper added earlier on this branch, which had no callers left. `peelJsonLayers` stays — the project's own reference detector is built on it — and now has tests of its own. Three things the engine can no longer report, each pinned by a test that states the boundary rather than the behaviour: a reference a block forgot to wrap, a reference to a block the project no longer contains, and a reference to an id the file does not define. All three need knowing which values are identifiers.
`templateParams()` had to wrap every value carrying a block id in `toTemplateRef` itself. That put a template concern into every block's own code, and made a forgotten wrapper a silent bug: the reference would be written out as data and the applied block wired to nothing. The SDK does it now. `wrapTemplateRefs` walks whatever the lambda returned and marks each column identifier it recognizes — either spelling, at any depth, at whatever escape layer it sits. It runs inside the block's bundle, which is the last place that knows the reference system, and past it nothing looks into a wrapper again. So the lambda is back to returning plain `Params`, and `TemplateParamsOf` is gone. Wrapping precisely rather than per field also narrows what a redirect can touch: a string that merely looks like a block id is no longer inside a payload just because it sat next to a real reference. `toTemplateRef` stays exported as the escape hatch for a carrier the walk cannot recognize — a foreign document holding a reference — and the walk leaves an already wrapped value alone.
The note was drafted against the shape this branch tried first — a document-level `columns` dictionary with `$ref` pointers and an `as` marker per use site — and that shape is gone. Rewritten against what shipped: the SDK marks column identifiers inside the block's bundle, the document stores the payloads verbatim, and apply redirects the block ids textually. Carries the three things the engine deliberately cannot report, each with the test that pins it, and the two questions still open — canonical key order when an identifier is a map key, and whether the facade version should be raised so a block built before this change is refused rather than exporting the old shape.
`TemplateApplyApi` was an interface with one implementation and one caller, and
`applyProjectTemplateV1` behind it was a for-loop with an early return. The stated
reason for the indirection was a future template-supplied orchestrator running in a
sandbox — and that does not hold up: a host function exposed to the synchronous
quickjs context cannot await, so preparation has to finish before the script starts,
so the script can only reorder and re-parametrize a set of blocks someone else already
chose. Anything worth sandboxing needs to choose the set, which needs async
preparation, which needs a declare-then-execute shape this interface is not.
So it is one function now: `applyTemplateEntries({document, placer, projectHelper,
entries})`, reading top to bottom — facade check, assign, redirect, storage, place,
record. `TemplateApplyApi`, `AddBlockRequest`, `AddBlockOutcome` and the factory are
gone; the result types stay, since the middle layer's public surface and the desktop
read them.
The tests move with it: what asserted the indirection is dropped, what asserted
observable behaviour (file order, stopping at the first failure, keeping what landed,
an empty document) now runs against real block code through a real model VM instead of
a recording fake.
`touched: Set<string> | null` named neither its contents nor its purpose. It holds block-pack ids *with version*, and what happens to them is that their existing `implementers` entries get replaced by the freshly scanned ones — so it is `replacedBlockIds`, and the accumulator is a planned rewrite of one overview file, not a "touch" of anything. `null` meanwhile carried the mode: force mode rebuilds from the scan, so there is nothing to filter. As an absent collection that made "rebuild everything" and "replace nothing" look identical at the use site, and needed a `!` to read. Now a discriminant, which also removes the branch that built an empty `KindOverview` just to filter its empty `implementers`.
`BlockKindMeta` restated `CompiledBlockKindV1` field for field, minus `kindSchema` —
same names, same types, and the same prose about `name` being the full npm package
name, with `parseTemplateParams` pointing at the descriptor for what it must do. Two
declarations of one shape, free to drift.
It is now `Omit<CompiledBlockKindV1<P>, "kindSchema" | "__PHANTOM_BLOCK_PARAMS__">`:
the descriptor minus what a caller cannot supply — the schema version this package
stamps, and the phantom slot the factory does not copy. A field added to the descriptor
now has to be supplied by callers, which is the right default.
The phantom slot keeps doing its job: `CompiledBlockKind<{ref, k}>` still does not
assign to `CompiledBlockKind<{ref}>`.
The parser checks the params a block is initialized with. A template is one source
of such params — today the only one — but naming the slot after it says the kind's
contract belongs to the template engine, which is the opposite of where this PR has
been heading. The sibling facade step already reads `storage_initialFromParams`, so
this also stops the pair from disagreeing.
Renamed at every site (68), including the 13 workspace kind packages and the kind
scaffold in block-tools, so the concept has one name. Two docstrings that framed the
slot as template-only are reworded: the descriptor now says a template is the current
case rather than the reason, and the builder's params-erasure note no longer claims the
template engine is the only caller that can supply params.
Also fixes two stale expectations in sdk/model that still described the old
`{ block, output }` reference form — they predate the `$ref` wrapper and were missed
because that change was verified against pl-model-common and pl-middle-layer only.
`kindRef` and its parser were declared four times — in `BuilderState` and in all three
chain constructors, each with its own copy of the docstring. They are one thing: a
reference with no parser cannot check the params it names, and a parser with no
reference has nothing to check them against. So `KindWiring`, once, and the
constructors intersect it.
`RecoverState` already existed and was already used by `recoverState()`, but its two
fields were re-declared in `BuilderState` and in the recover chain's constructor as
well; those now intersect it too, and the note about `recoverFromIndex` moved to the
type instead of living in a copy.
Answering the review point that asked for a generic parameter here: there is nothing
for one to bind. None of these fields mentions `Current`, `Transfers` or `Params`, and
typing the parser as `(value: unknown) => Params` collides with the chain's
`Params = never` default — a real parser is not assignable to it. What made the
constructors hard to read was the anonymous repetition, which is what this removes.
`steps` and `transferSteps` are deliberately left per site: their optionality genuinely
differs — no steps yet at the public entry, no transfers until one is declared — and
folding that into one type would need the `{a?: T} & {a: T}` trick, which trades the
duplication for a rule the reader has to know.
Comments cited the mispec by atom id — `Q-0004`, `Q-0005`, `A-0052`, `A-0053` — and
by section, `doc §3` / `doc §5`. None of that resolves for someone reading the code,
and the citation usually stood where the fact itself belonged.
Each is replaced by what it said:
- `block_kind_ref.ts` — the open decision is named ("whether the name segment has to
be org-qualified for global uniqueness") instead of pointed at.
- `resolve-refs.ts` — the module note now states that a kind is a fourth component
alongside model/ui/workflow, that the facade depends on it directly, and why the
version comes from the kind's built manifest rather than from the dependency range.
Dropped "the tetrad", which is spec vocabulary.
- `resolve-refs.ts` — the "RECONCILIATION vs. doc §4 pseudocode" note becomes a plain
statement of where the ref lives and why (`description.kind`, lifted to the top level
when the manifest is written); the drifting `registry.ts:301` pointer is gone.
- `publish-block.ts` — "Q-0004 quarantined inside resolve-refs" becomes what the
quarantine is for: every assumption about how the facade names and locates its kind
lives in that module.
- `registry.ts` — the one-line `see doc §5` now says the reason and points at the
method's own note, which already spells it out.
- `block_model.ts` — `see doc §3` becomes why the guard has to exist at runtime:
nothing ties the two kind objects together at the type level.
Untouched: `A-0023` in `package-builder-lib`, which predates this branch and cites a
different spec.
Per review: no half-created projects, and `assign` records the id itself.
A rejected entry now throws `TemplateEntryRejected` instead of returning a problem.
The throw leaves the caller's transaction uncommitted, so the project keeps none of
the blocks the apply had placed — `problems` non-empty now implies `added` empty at
every stage, not just the first three. A half-applied project was never a useful
result: its tail is missing, so whatever the absent entries fed is wired to nothing,
and the user cannot tell which of the blocks present the file configured.
What that removed:
- `template_ids.ts` and its test — gone. `assign`/`record` existed to keep an entry out
of the id map until its block landed, which mattered only because a later entry could
survive in a partially applied project. Nothing survives now, so id creation and the
map write are one line in the loop, which is what the review asked for. The map is a
local `Map` and the injectable id source is a `newBlockId` dep.
- `TemplateApplyOutcome` — success is total, so the loop returns `AppliedEntry[]`.
- `TemplateParamsRewrite`'s `{ ok: false }` arm and the `if (!live.ok)` check — dead since
references stopped being parsed: `resolveTemplateRefs` leaves an id it was not given
alone rather than failing, so the rewrite had no failure to report.
- the `stop()` helper — one throw type replaces four early returns.
`liveParamsForCheck` moved to `template_apply.ts`, its old module having been named for
ids it no longer holds.
Note for the desktop, which consumes the published middle layer: its
`added.length === 0 && problems.length > 0` branch now covers every failure, which is
the intended path. The other branch's `problems` is unreachable — a "partially applied"
report can no longer occur. Nothing there breaks; the dead arm is that repo's cleanup.
`params` was optional all the way down, so every reader normalized it and one of
them would eventually forget. A file may still omit the key — that is terseness for
whoever writes one by hand — but the parser now reads the omission as `{}`, and the
document type says so: `params: Record<string, unknown>`, required.
The two normalizations that existed downstream are gone with it: `entry.params ?? {}`
in the apply loop and in the pre-flight check. Nothing distinguished absent from `{}`
anyway — our own exporter always wrote the key.
Also removes an impossible state on the export side. `TemplateExportEntry.params` was
`Record | undefined`, documented as "written as an entry with no `params` key" for a
block built against an older SDK — but the walk's object check rejects a non-mapping
first, so `undefined` never reached an entry. The type and both docstrings that
described that path now say what actually happens: a block that projects no mapping is
reported as that block's problem.
Left alone: `params ?? {}` at the VM boundary in `project_helper`, where the parameter
is `unknown` and the guard is about an untyped caller, and the block-side `init({ params })`
default, where a block genuinely can be created with no params at all.
`block` and `location` answer different questions — which version to install, and
where to install it from — and an entry carrying both states two things with no way to
reconcile them. The parser rejected that, but the type allowed it, so the rule was only
true at runtime.
It is now `BlockPackLocatorOverride`, two arms that each forbid the other's field by
typing it `never`, intersected into the entry. `{ block, location }` matches neither arm;
either field alone, or neither, matches one. Readers are unaffected — both arms declare
both keys, so `entry.block` and `entry.location` stay readable without narrowing.
The zod schema keeps the `superRefine` as the runtime check and narrows with a transform
at the end, because a `z.union` of the two arms would report every arm's failure: a
misspelled `kind` would come back as two unrelated complaints about `block` and
`location`.
This immediately caught something: `template_resolve.test.ts`'s entry helper took
`Partial<Pick<Entry, "kind" | "block" | "location">>`, which let a test construct an
entry with both. It takes the locator type now.
The identifier became `parseInitializationParams`, but the wire names still said template: `__pl_templateParams_derive` and `__pl_templateParams_validate`. They are the last place the old word survived, and they sat next to `__pl_storage_initialFromParams`, which already reads the new way. Now `__pl_initializationParams_derive` and `__pl_initializationParams_validate`, with the constants (`InitializationParamsDerive`, `InitializationParamsValidate`) and the two result types renamed with them. Safe despite the facade's freeze rule — "NEVER change the signature of existing callbacks, NEVER remove existing callbacks" — because both were added on this branch (`312fb2025`) and nothing published registers them. The checked-in-looking block packs under `tests/block-repo/work/` that carry the old string are generated and gitignored. Also dropped "facade callback #7" from a docstring: the callback list it counted into has no numbering, so the pointer was already unresolvable. Left as they are, deliberately: `.templateParams()` on the block builder, which is block-author-facing API and its own decision, and the SDK/middle-layer function names that implement these callbacks.
Moving `block` and `location` into `BlockPackLocatorOverride` left their meaning behind in the entry's docstring, so the type that declares them said only how the exclusion is encoded, and a reader hovering `entry.block` got nothing. Each field now carries what it answers and what it costs: `block` names WHICH VERSION and stays portable, is exact-only, and is hand-written because export already records the exact version a block implements; `location` names WHICH PLACE, is the only answer for a block that is built but not published, and buys that by being meaningless anywhere the place does not exist — which is why export writes it for a filesystem-installed block and never for a registry one. The entry's docstring keeps only what belongs to the entry: at most one override, and either one skips kind resolution — verified against `template_resolve.ts`, where both branches return before `entry.kind` is read at all.
The docstring claimed "everything wrong with a template document that can be known from the document alone". It checks one thing: that a reference names an entry declared earlier. Several things knowable from the document alone are not checked at all — some never were, and one was deliberately removed with the reference guards — so the sentence read as a guarantee the function does not give. It now names the check, and lists what it cannot report and why: a reference to an id the file does not define (detection asks which known ids appear in a payload, so an unknown one is indistinguishable from text), a block id outside a wrapper (nothing here models a reference beyond the wrapper), and anything needing a registry or the block's own model. The body collapsed with it. Regrouping problems into a per-entry map and re-walking `blocks` existed to interleave two sources of findings in file order; there is one source now, and `findProjectTemplateV1ReferenceProblems` already walks `blocks` itself — so it is a `map` over what that returns.
A template is a file a person edits, so the reader's output is a bug report. That means
owning the wording, which a schema library gives away: zod produced "Invalid literal
value, expected \"template-v1\"" where the sentence to say is which file was picked, and
"Unrecognized key(s) in object" where it is which key was misspelled.
`readProjectTemplateV1(value)` now walks the value by hand and returns
`{ ok: true, document } | { ok: false, issues }`, each issue carrying a path and a message.
`parseProjectTemplateV1` stays as the throwing form for export, which asserts on every run
that what it wrote reads back.
The schema was carrying the trivial half anyway. The reference grammars are functions —
`parseKindSelectorReference`, `parseBlockPackReference`, `parseBlockPackLocation` — and were
called from `superRefine`; the locator exclusion is a rule about two fields and needed a
refine plus a cast to reach the type; `params` needed a `.default`; branded fields needed a
`.transform` each, which is why the pegs were `BoundaryParser<T>` rather than `z.ZodType<T>`.
All of that goes.
Behaviour kept, and pinned by the tests that already covered it: every problem reported at
once, paths as `blocks[2].kind`, unknown keys refused rather than dropped, ids unique,
`params` settled to `{}`. Two things improved: a grammar's own message is now reported as it
comes instead of being wrapped, and the throw carries the issue lines in `message`, so one
that escapes to a log is still readable.
`formatTemplateParseIssue` is shared between that message and the middle layer's file-level
report, so a problem reads the same either way.
zod stays a dependency of pl-model-common — `PlRef`, specs, blob and code models all use it.
This is about the document reader only.
A template entry's kind was only verified on the `location` route, inside resolution. The other two routes went unchecked: `byExactVersion` returns a spec with no kind at all, so a pinned version installed whatever package it named — and the params check that follows runs the INSTALLED block's kind parser, holding params written for one contract against another. The check moves to the preparation loop, where all three routes have the block's compiled config and hence its kind. Resolution stops knowing about kinds of blocks it found: `LocationResolution.kind` and the `FoundPack.kind` behind it are gone, and locating a block now answers "is there a block here", not "is it the right one". One call site, deliberately. Resolution could answer it for a location, since `byLocation` reads the block's description anyway, but that would be two homes for one invariant with the pinned-version route still uncovered. `kindMismatch` therefore lives beside `liveParamsForCheck` in template_apply.ts — the other per-entry question asked of a prepared block — and the caller appends whichever locator the entry carried, so a pinned version is now named in the message too. Behaviour change worth noting: a kind mismatch is reported one stage later, so it no longer arrives together with resolution problems for other entries. Both stages create nothing, so an apply is unaffected.
A kind's `parseInitializationParams` is a plain function by design — this package carries no validation library and an author picks their own tool — but the scaffold undercut that: it seeded `zod` into every new kind's dependencies and generated a parser written against it. What is meant to be one option among several was the default in practice, and all thirteen workspace kinds inherited it. The half every kind actually shares is the shape check: the value has to be an object, and its keys have to be keys the kind declares. That is now `assertDeclaredParams` in `@platforma-sdk/block-kind` — an assertion, so a kind reads its fields off the value it was handed, with no schema and no copy. Per-field checks stay in the kind as ordinary TypeScript, which is where they belonged: `sum-numbers` now holds references to `pl-model-common`'s own `isPlRef` instead of restating the shape as a zod schema. Refusing an undeclared key is preserved, since it was `.strict()`'s whole purpose: a file saying `number:` for `numbers:` is rejected naming the key, rather than having it dropped and applying a block that looks configured and is not. All thirteen kinds converted and `zod` removed from each; twelve of them no longer inline it into the bundle the registry publishes. `sum-numbers` still does, through `pl-model-common`, exactly as it did before.
…n template export
`assertDeclaredParams(value, ["numbers"])` refused any key the kind did not list. It bought one thing — a typo in a hand-written template file being named rather than ignored — and paid for it with a list of field names as strings that nothing held in step with the type: a field added to the contract and read by the parser but missed in the list turns the kind into one that refuses correct files. A false rejection at apply time is worse than a typo that surfaces as a blank block. So the key set is no longer the kind's business. `assertParamsObject(value)` establishes only what no kind can check safely on its own — that the value is an object. It is worth sharing because the naive version is wrong three ways: `typeof null === "object"`, `Object.keys(5)` is `[]`, and `Object.keys(["a"])` is `["0"]`, so null, a number and an array all read as empty params. Required fields are still checked, in the kinds, as plain TypeScript — that never moved. An unexpected key is dropped rather than refused, which is what a parser returning the params to use already did with it. What such a key can actually mean is a contract from a different version of the kind, and that is guarded by the version in the entry's kind reference. The four kinds that argued for strictness in prose now say what is true instead, including that `enter-numbers` invites the `number:`/`numbers:` typo and does not catch it.
… engine The engine no longer knows what a reference is. A block's params travel into a template exactly as the block projected them — no `$ref` wrapper, no marker of any kind — and on the way back in the block's own bundle is handed the ids of the project being built and repoints them itself. Which values carry block ids is knowledge of the reference system, and it now lives in one place: the package the block imports. `__pl_initializationParams_relocate` is the new facade callback, an extension rather than a change, called once per entry at construction with the entries created so far. A block that does not declare it is refused: applying its params as written would wire it to the ids of the project the template came from. Rewriting is structural, and that fixes two defects the textual pass had. A value that merely equals a block id — a `domain` entry, an axis filter — is no longer rewritten with the reference beside it. And an identifier is rebuilt canonically, so a qualifications map keyed by identifier comes back sorted the way a fresh project would have built it, which a string substitution could not do. `remapColumnIdBlockIds` is restored from bf60d7b, where it was deleted as dead. It walks all five key forms, every linker hop, and the keys of a qualifications map. Deleted: template_ref.ts and template_wrap.ts entirely (`TemplateRef`, `toTemplateRef`, `isTemplateRef`, `remapRefPayload`, `resolveTemplateRefs`, `referencedBlockIds`), template_validate.ts, `findProjectTemplateV1ReferenceProblems` and its export-side use, and `liveParamsForCheck` — the pre-flight check now passes the file's params through unchanged, because they are already live. The cost, stated where it lands: self and forward references are no longer refused before the work starts. Reading which entries an entry references means reading its params, and no block exists until resolution has fetched one. Such a file applies, and the block it creates reports itself as missing references — the same way a reference to a deleted block already behaves. An id the map does not name is left alone, which is what makes that degradation uniform. Also fixes a live-project bug found on the way: inferAllReferencedBlocks walked object values and dropped keys, so a discovered column qualified by another block's column lost that upstream edge from the dependency graph.
Relocation was a callback of its own, invoked immediately before the params-to-storage one, on the same entry, with the same config, and its result read by nothing but the very next line. Two things made that worse than untidy: `executeSingleLambda` builds a fresh QuickJS runtime and re-evaluates the whole model bundle per call, so every entry parsed its block twice to produce an intermediate value; and neither callback has ever shipped — `StorageInitialFromParams`, `InitializationParamsValidate` and `InitializationParamsDerive` are all new on this branch, absent from origin/main, so the facade's "extend, never change" rule had nothing to protect here. So `__pl_storage_initialFromParams` takes the id map and does both, in the order it already implied: repoint the references, check them against the kind, hand them to the block's init factory. `InitializationParamsRelocate` and `relocateTemplateParamsInVM` are gone. Refusing a block that cannot be created from a template still works by the same mechanism — the callback's absence — and now covers both duties at once, which is the honest shape: a block that cannot repoint references cannot be created from a template either.
A real failure from the desktop app: "params are not valid JSON: unexpected token:
'undefined'", against an entry whose params were `{numbers: [3]}` and held no reference at
all. The params were fine. What was missing was the second argument — the id map — because
the middle layer applying the template was packed before it learned to send one, while the
block it invoked had been rebuilt after. `JSON.parse(undefined)` throws exactly that in
QuickJS, and folding both reads into one try sent the reader to the file to look for a
mistake that was not there.
The two arguments now fail separately, because they fail for unrelated reasons: params come
from the file and a bad one is the author's mistake, while the id map comes from the caller,
so the only way it arrives unreadable is a caller too old to send it. That message says so,
and names the fix — refresh the build.
A block stores `{ __isRef: true, blockId, name }` and an export writes exactly that, which is
right for a machine and miserable to type. `TemplatePlRef` — `{ block, name }` — says the same
thing without the marker, and is expanded into a `PlRef` on the way in, inside the block's own
bundle, before the kind's parser and before init. So a kind's params contract stays written
against `PlRef` alone and never learns this type exists, and both spellings reach a block
through the same step and cannot diverge.
`block` is a template-local entry id and nothing else. An earlier draft also took a number, as
a position in `blocks`, and it did not survive contact: a position needs the document's order,
which meant carrying the whole entry list into the VM, turning the id map into an ordered list
covering entries not yet created, and adding that argument to the pre-flight callback too — for
a spelling that saves nothing a hand-written file cannot already say. A readable form has to
carry the same information as the form it stands for; then expanding it is a rewrite, not a
lookup, and needs nothing but the value.
`expandTemplateRefs` is named for the job rather than today's only case, and says what a second
readable form has to respect: expand bottom-up, because a wrapper key holds its source as a
canonical string, and canonicalize what it builds. `TemplateCUId` / `TemplateCUKey` for the
filtered, discovered and overridden keys are the intended next ones.
Expansion runs in the pre-flight check as well as at construction. Without it a kind declaring
a reference would reject every hand-written file: `{ block, name }` is not a `PlRef`.
Also clears four docstrings and two assertions still describing the `{ $ref: … }` wrapper,
which stopped existing two commits ago.
Greptile Summary
This PR introduces versioned block kinds and project-template import/export across the model SDK, middle layer, registry, build tooling, and example blocks.
@platforma-sdk/block-kind, kind references, manifests, publication, and registry resolution.DataCreateFn.templateParams()model callback and reference wrapping.kindpackage; the PR updates structure rules, scaffolds, package validation, and example blocks.Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains; the previously reported stale kind association, pre-1.0 selector, and dropped initialization-parameter paths are addressed in the current code.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart LR Kind[Block kind package] -->|validates init params| Model[BlockModelV3] Model -->|templateParams projection| Export[Template export] Export --> YAML[template-v1 YAML] YAML --> Parse[Parse and validate] Parse --> Resolve[Resolve kind implementations] Resolve --> Init[Create initial block storage] Init --> Project[Fresh project] Kind --> Publish[Kind-first publication] Publish --> Registry[Registry kind overview] Registry --> ResolveReviews (3): Last reviewed commit: "MILAB-6648: check a kind's required para..." | Re-trigger Greptile
Context used (3)