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
14 changes: 14 additions & 0 deletions .changeset/22256-composed-load-path-conversions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/cli': patch
'@objectstack/spec': patch
---

fix(cli): `os migrate meta` lists and writes a conversion the load already applies inside a composed project's package body (#22256)

Clause-②: no

- **What was wrong.** In a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, each package body is assembled from the stack its input's `defineStack` call returns. That call runs the load-time ADR-0087 conversions, both when the schema accepts the input and when `os migrate meta` produces a refused input again in `strict: false` mode. So a spelling the load still converts inside a package body, such as `datasources[].driver: 'mongo'`, was never listed in `applied`, and `--write` never wrote it. Every later load kept printing `converted at load … Update the source to the canonical shape`, and the run exited 0.
- **What changed.** `os migrate meta` now produces every input of the composition again from what its author wrote, with the load-time conversions skipped, before the inputs are composed. Composition then assembles each package body from the authored source. The conversion is listed under the body's path (for example `packages[0].manifest.datasources[0].driver`), `--write` writes it into the file that authored that input, and the next load converts nothing. This holds whether the input's `defineStack` call accepted its argument or was refused.
- **`@objectstack/spec`.** `defineStack` reads one internal parameter for this, which only the CLI passes: an undeclared, symbol-keyed entry on its options, honoured only together with `strict: false`, that skips the conversion pass. It is not part of `DefineStackOptions` and nothing new is exported. A strict call ignores it, and every call without it behaves exactly as before.
- **What did not change.** A project with nothing to convert gives the same `--json` summary and the same `--write` result and written bytes as before. This was measured on the four example apps, one of them composed, at `--from 16` and `--from 17`. Every other command still reads the stacks `defineStack` builds and the composition they make.
- **The `--out` snapshot of a composed project.** Each package body in it is now the body as written, with the chain's changes applied. That is what the snapshot of a one-package stack already is. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys.
1 change: 0 additions & 1 deletion .changeset/22256-migrate-meta-load-path-conversions.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/cli': patch
---
Expand All @@ -10,4 +10,3 @@
- **What changed.** The chain now starts from the argument the stack's `defineStack` call was given. It already did this for a stack the schema refuses. The conversion is listed, `--write` writes it into the source, and the next load converts nothing.
- **What did not change.** A stack with nothing to convert gives the same `--json` summary and the same `--write` result as before. This was measured on the four example apps at `--from 16` and `--from 17`. Every other command still reads the stack `defineStack` builds.
- **The `--out` snapshot.** It is now the stack as written, with the chain's changes applied. That is what it already was for a stack the schema refuses. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys and actions merged into their objects.
- **Known limit, unchanged.** Inside a `composeStacks([…])` project, each package body is assembled from the stack its input's `defineStack` call returned, whether that call accepted its input or was produced again in `strict: false` mode. So a conversion the load still applies inside a package body is still not listed, and `--write` still does not write it.
3 changes: 1 addition & 2 deletions .changeset/22289-migrate-meta-composed-project.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/cli': patch
---
Expand All @@ -6,7 +6,6 @@

Clause-②: no

- **What changed.** On a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, `os migrate meta --from N` used to exit 1 with `STACK_PROVENANCE_MISSING`, telling the author to wrap each input in `defineStack`, as soon as any input carried a spelling the current schema refuses. Every input already was wrapped. The command now loads such a project: an input whose `defineStack` call the current schema refuses is handed to `composeStacks` as `defineStack(input, { strict: false })` returns it, and composition runs as it does at build time.
- **What changed.** On a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, `os migrate meta --from N` used to exit 1 with `STACK_PROVENANCE_MISSING`, telling the author to wrap each input in `defineStack`, as soon as any input carried a spelling the current schema refuses. Every input already was wrapped. The command now loads such a project: an input whose `defineStack` call the current schema refuses is produced again by `defineStack(input, { strict: false })` with the load-time conversions skipped (#22256) before it is handed to `composeStacks`, and composition runs as it does at build time.
- **The package bodies are migrated.** A composed artifact keeps each definition under the package that owns it (`packages[i].manifest`), and the migration chain used to reach only the stack's own top level. It now also runs over each package body, and lists each change under the body's path, such as `packages[0].manifest.objects[0].fields.starts_at.defaultValue`. `--write` writes a change in package i into the file that authored input i only when input i and every input before it in the `composeStacks([…])` list is a stack literal (a `defineStack({ … })` call on an object literal, written in place or reached through a `const` or an import) that writes its own `manifest` and no `packages`. Only then is package i that one input's body. Otherwise `--write` lists the change with the reason, as it does for any site it cannot trace.
- **What did not change.** A one-package project loads, migrates and writes exactly as before. An input that was never built by `defineStack` (a plain object, or a spread of a built stack) is still refused with `STACK_PROVENANCE_MISSING`.
- **Known limit.** Inside a composed project, a conversion the load still applies (for example `datasources[].driver: 'mongo'`) is applied while the input is composed. So the chain does not list it and `--write` does not write it.
5 changes: 3 additions & 2 deletions packages/cli/src/commands/migrate/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1167,8 +1167,9 @@ export default class MigrateMeta extends Command {
// call runs the D2 pass inside the config module whenever the schema
// accepts its input, so `config` is the raw source only because the
// load starts it from the argument that call was given (`loadConfig`,
// `authoredSource`). That holds for a one-package stack; a composed
// project's package bodies are assembled from converted stacks.
// `authoredSource`). A composed project's inputs are produced again from
// their authored arguments with that pass skipped before they are
// composed, so its package bodies are raw source too (#22256).
const normalized = normalizeStackInput(config as Record<string, unknown>, { convert: false });

if (!flags.json) printStep(`Replaying chain: protocol ${fromMajor} → ${toMajor}…`);
Expand Down
102 changes: 72 additions & 30 deletions packages/cli/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,25 @@ const HANDED_THROUGH_NAMESPACE = 'objectstack-authored-source-record';
*/
const AUTHORED_ARGUMENT_KEY = '@objectstack/cli:authored-source/accepted-argument';

/**
* The global-registry symbol name under which a non-strict {@link STACK_PRODUCER}
* call's options ask the producer for its input AS AUTHORED: normalised and
* marked as every `strict: false` call is, with its load-time ADR-0087 D2
* conversion pass skipped (#22256).
*
* Owned by `@objectstack/spec`, ⛔ not by this CLI: it is the producer's one
* internal parameter (`stack.zod.ts`, beside `defineStack`), deliberately
* neither declared on `DefineStackOptions` nor exported, so this module spells
* its `Symbol.for` name the way the producer's provenance mark is read across
* two copies of the package. A drifted spelling is not silent: the producer
* then converts as before, and the composed pins in
* `test/migrate-meta-composed-load-conversions.test.ts` go red on the very
* conversion this key exists to keep visible.
*
* Read by the `composeStacks` wrap alone ({@link authoredSourcePlugin}).
*/
const SPEC_AUTHORED_INPUT_OPTION = 'objectstack.stack.authoredInput';

/**
* One strict authoring factory that is not a `define*` helper: the function
* `member` of the exported value `owner`, which validates its argument AT THE
Expand Down Expand Up @@ -448,16 +467,23 @@ async function authoredSourceHelpersOf(
*
* `__recordStack` is the hand-over hook the `defineStack` wrap alone passes:
* it records the stack it hands through, with the options it was called
* with, in the load's one hand-through record. `__composable` is what the
* `composeStacks` wrap maps its inputs through: a recorded stack is produced
* again by the real `defineStack` in its `strict: false` mode, and every other
* input reaches the real `composeStacks` as it was passed.
* with, in the load's one hand-through record.
*
* `__keepAuthored` is the other `defineStack`-only hook, for the call that
* SUCCEEDS: it keeps the argument beside the stack the real call returned,
* under {@link AUTHORED_ARGUMENT_KEY}. It runs outside the `try`, so it can
* never turn an accepted call into a refused one. A call takes exactly one of
* the two arms, so no argument is both handed through and kept.
*
* `__composable` is what the `composeStacks` wrap maps its inputs through, and
* it gives both arms one shape: what the author wrote, produced again by the
* real `defineStack` in its `strict: false` mode with its conversion pass
* skipped ({@link SPEC_AUTHORED_INPUT_OPTION}). A recorded stack is that
* argument already; a kept one is followed to the end, as
* {@link authoredArgumentOf} follows it, so a nested `defineStack(defineStack(
* …))` starts from the inner literal. Every other input — a nested
* composition, a plain object the author never wrapped — reaches the real
* `composeStacks` as it was passed.
*/
const AUTHORED_SOURCE_PRELUDE: readonly string[] = [
`const __refusal = (label, error) =>`,
Expand Down Expand Up @@ -492,9 +518,24 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [
` if (Object.prototype.hasOwnProperty.call(built, __authoredKey)) return;`,
` Object.defineProperty(built, __authoredKey, { value: source, enumerable: false, writable: false, configurable: false });`,
`};`,
`const __composable = (stack) => (__handedThrough.has(stack)`,
` ? __specRoot.${STACK_PRODUCER}(stack, { ...__handedThrough.get(stack), strict: false })`,
` : stack);`,
`const __authoredInput = Symbol.for(${JSON.stringify(SPEC_AUTHORED_INPUT_OPTION)});`,
`const __keptArgumentOf = (stack) => {`,
` const seen = new Set();`,
` let current = stack;`,
` while (current !== null && typeof current === 'object' && !seen.has(current)`,
` && Object.prototype.hasOwnProperty.call(current, __authoredKey)) {`,
` seen.add(current);`,
` current = current[__authoredKey];`,
` }`,
` return current;`,
`};`,
`const __composable = (stack) => {`,
` if (__handedThrough.has(stack)) {`,
` return __specRoot.${STACK_PRODUCER}(stack, { ...__handedThrough.get(stack), strict: false, [__authoredInput]: true });`,
` }`,
` const authored = __keptArgumentOf(stack);`,
` return authored === stack ? stack : __specRoot.${STACK_PRODUCER}(authored, { strict: false, [__authoredInput]: true });`,
`};`,
`const __tolerantMembers = (owner, ownerName, members) => {`,
` const wrapped = new Map(members.map((member) => [`,
` member,`,
Expand Down Expand Up @@ -589,14 +630,12 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [
* its argument through as it is, so the two arms leave the chain one input
* shape: the stack the author wrote.
*
* ⚠️ A composed input does not get this. `composeStacks` builds each package
* body from the stack the input's `defineStack` call RETURNED, so the body is
* converted before the chain sees it. Recovering the authored body would mean
* assembling it again outside the producer: a second copy of composition's
* rule. Inside a composed project, a conversion the load still applies is
* still applied before the chain runs.
* A composed input cannot be substituted this way: `composeStacks` builds each
* package body from the stack its input's producer RETURNED, and rebuilding the
* body from the argument here would be a second copy of composition's rule.
* The next section is how a composed project reaches the same shape.
*
* ## A composed project: the handed-through stack is produced again
* ## A composed project: every wrapped input is produced again, as authored
*
* `composeStacks` is not a `define*` helper, so it runs for real, and its
* first step refuses every input no producer built (`STACK_PROVENANCE_MISSING`,
Expand All @@ -606,22 +645,25 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [
* retired spelling in ANY input was refused with a prescription the author had
* already followed.
*
* So the shim records each stack it hands through for `defineStack`, and wraps
* `composeStacks` to hand each RECORDED input to the real `defineStack` again
* in its own `strict: false` mode before the real `composeStacks` runs. The
* producer marks its own output (`strict: false` is a choice made inside it),
* so nothing here writes the mark; and an input the shim did not hand through
* — a plain object the author never wrapped, a spread of a built stack —
* reaches the real `composeStacks` untouched and is refused there exactly as
* before.
*
* ⚠️ The one-package path keeps handing the authored argument through RAW,
* on purpose. `strict: false` still runs the load-time ADR-0087 D2 conversion
* pass, so a conversion the load still applies (`driver: 'mongo'`) would be
* applied before the chain runs — unlisted in `applied`, unwritten by
* `--write`. Measured on a refused one-package stack: the raw hand-back lists
* and writes it, a `strict: false` fallback does neither. A composed input
* pays that cost because the composer cannot take it any other way.
* So the shim wraps `composeStacks` to hand each input whose `defineStack` call
* it saw — refused and recorded, or accepted and kept — to the real
* `defineStack` again, before the real `composeStacks` runs, starting from
* what the author wrote. It asks in the producer's `strict: false` mode, with
* the producer's internal parameter that skips the load-time ADR-0087 D2
* conversion pass ({@link SPEC_AUTHORED_INPUT_OPTION}, #22256). Without that
* parameter a conversion the load still applies (`driver: 'mongo'`) was
* applied while the input was produced again, so the chain never listed it
* and `--write` never wrote it — the one-package defect, inside every package
* body. With it, composition runs its own rule over the authored inputs, and
* each `packages[i].manifest` body the chain reads is what input i's author
* wrote. The producer marks its own output (`strict: false` is a choice made
* inside it), so nothing here writes the mark; and an input the shim never
* saw a `defineStack` call build — a plain object the author never wrapped, a
* spread of a built stack — reaches the real `composeStacks` untouched and is
* refused there exactly as before.
*
* The one-package path keeps handing the authored argument through RAW: it
* needs no producer at all, since nothing composes it.
*
* ⚠️ Deliberately NOT the default for `loadConfig()`. Every other command —
* `os build`, `os validate`, `os serve` — must keep hearing the rejection: the
Expand Down
Loading
Loading