You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 1cb0edb
Browse filesBrowse the repository at this point in the historyBrowse files
fix(cli): os migrate meta lists and writes a conversion the load already applies, on a stack the schema accepts (#22351)
Part of #22256
Clause-②: no
Release: the composed half stays open on #22256. That half is a
conversion the load still applies inside a `composeStacks` package body.
Its carrier is the `domain:cli` seat, which takes it to the
`domain:spec` seat with the H4 measurement below. It needs a spec seam
or a second copy of composition's rule.
`os migrate meta` now lists and writes a conversion the load still
applies, such as `datasources[].driver: 'mongo'`, on a one-package stack
the current schema ACCEPTS. Every change is in `packages/cli`. There is
no `packages/spec` change, and nothing here reads or writes the
producer's provenance key.
## What changed
1. **The shim keeps the argument of an accepted `defineStack` call**
(`src/utils/config.ts`). `__tolerant` gains a `keep` hook. It runs after
a successful call, outside the `try`. The `defineStack` wrap passes
`__keepAuthored`, which defines one non-enumerable property on the
returned stack, holding the argument. Its key is a CLI-owned
`Symbol.for` key, `@objectstack/cli:authored-source/accepted-argument`.
A refused call is unchanged: it hands its argument through raw, and
`__recordStack` records it for `composeStacks`. A call takes exactly one
of the two arms.
2. **`loadConfig({ authoredSource: true })` starts the default export
from that argument** (`authoredArgumentOf`). It reads the argument off
`mod.default` BEFORE the named-export merge, which is where the
provenance mark and the conversion record are already read: the merge's
spread drops non-enumerable properties. It follows the record to the
end, so `defineStack(defineStack({ … }))` answers the inner literal.
`stackProvenance` and `stackConversions` are still read off the built
stack. A load without `authoredSource`, which is every other command, is
untouched.
3. **`meta.ts`: no code change, one comment.** Both chain call sites
(`:1159`–`:1165`, and the `--write` re-run at `:1343`–`:1344`) already
run `normalizeStackInput(config, { convert: false })` over
`loadConfig(…, { authoredSource: true }).config`, and that value is now
the stack as written. The comment says where it comes from, and that the
composed path is not covered.
4. **Changeset**: `@objectstack/cli` `patch`, `Clause-②: no`. It keeps
PR #22326's "Known limit" true, and restates it for both kinds of
composed input.
### The carrier (H3): a property on the stack, not a WeakMap in the
shim's record module
The record has to cross from the bundled shim to `loadConfig`. The only
values that cross are the config module's exports, and the default
export IS the stack the call returned. A property on it crosses with it,
with no global state and no lifetime beyond the value's. The
hand-through WeakMap (PR #22326) lives in a module bundled inside the
load, and nothing outside the load can reach it; reaching it would take
a process-global. The property is non-enumerable, like the producer's
own mark, so a spread, `JSON.stringify` and a schema parse all leave it
behind.
### Why `loadConfig` substitutes, and `meta.ts` does not re-merge
In the order's route, `loadConfig` exposes the record and `meta.ts`
starts from it at its two call sites. `meta.ts` would then have to redo
`loadConfig`'s named-export merge over the authored argument, which is a
second copy of that rule. Substituting before the merge keeps one merge.
The `--write` planner also gets the authored value, so its literal match
(`subsetOf(literal, loaded)`) compares the literal with what the author
wrote.
## PM readings, measured
All on `origin/main` `3599fef1` with the built CLI (`node
packages/cli/bin/run.js`), against temp projects that link this
worktree's built `@objectstack/spec`.
- **H1, reproduced.** The stack is a one-package stack the schema
accepts, with `datasources: [{ name: 'docs', label: 'Docs', driver:
'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }]`.
`migrate meta objectstack.config.ts --from 16 --write --json` gave exit
0, `"applied": []` and `"write": {"status": "written", "files": [],
"written": [], "manual": [], "unexplained": []}`. The source kept
`driver: 'mongo'`. A later load (`os validate`) printed: `defineStack:
datasources[0].driver: 'mongo' → 'mongodb' (converted at load;
conversion 'datasource-driver-mongo-to-mongodb', retires in protocol
18). Update the source to the canonical shape — the conversion stops
running then.`
- A first probe with `config: {}` was REFUSED, because a mongo
datasource needs a connection target. It took the raw hand-back, and the
conversion was listed and written. Only an accepted stack shows the
defect.
- **H2, confirmed by instrument.** `loadConfig(…, { authoredSource: true
}).config.datasources[0].driver` was `'mongodb'`, and so was the chain
input after `normalizeStackInput(…, { convert: false })`.
`stackConversions` named `datasource-driver-mongo-to-mongodb`. On
success the shim's `__tolerant` returned the real result. Where `driver`
is converted: `buildDefinedStack` (`packages/spec/src/stack.zod.ts`)
calls `normalizeStackInput` with conversions on, in both modes. That
calls `applyConversions`, which runs `datasourceDriverMongoToMongodb`
(`packages/spec/src/conversions/registry.ts`). No other `define*` helper
runs the pass.
- **H3, fixed as above.** After the change, the same H1 run gave
`applied` = `[datasource-driver-mongo-to-mongodb @
datasources[0].driver, mongo → mongodb]`, `write.files` = `[{
objectstack.config.ts, sites: 1 }]`, `verification.ok: true` and
`schemaValid: true`. The source line now reads `driver: 'mongodb'`. A
later `os validate` printed no `converted at load` line, and a re-run
applied nothing.
- **Nothing to convert is unchanged, measured on the corpus.** All four
example apps are ACCEPTED by `defineStack` and carry no load-path
conversion, so the fix changes each one's chain input. I ran `migrate
meta --from 16 --json` and `--from 17 --json` on `app-crm`,
`app-multi-package`, `app-showcase` and `app-todo`. The eight `--json`
documents, minus `duration`, are byte-identical by md5 before and after.
I also ran `--write --from 16` on a throwaway copy of each app. The
`--json` outcome and the written bytes are identical by md5:
`app-showcase` writes 3 sites in `src/automation/flows/index.ts` and
`app-todo` 1 site in `src/flows/task.flow.ts`, both before and after.
- **The `--out` snapshot changes for an accepted stack.** It is now the
authored stack plus the chain's edits, as it already was for a refused
stack. Measured on the three one-package apps, every difference is one
of two kinds. Either a key is present only BEFORE: parse defaults such
as `manifest.scope`, `manifest.defaultDatasource`, `flows[].version` and
`hooks[].runAs`, and `objects[].actions` from `defineStack`'s action
merge. Or the parse transformed a value: `hooks[].condition` and
`flows[].edges[].condition`. Nothing appears that the author did not
write. The composed app's snapshot is unchanged. The changeset says so.
- **H4, measured. NOT fixed here.** The project is
`composeStacks([ServiceStack, AppStack], { manifest: 'preserve' })`,
with the service body carrying the accepted mongo datasource. Before AND
after this change, `--from 16 --write --json` gives exit 0, `applied:
[]`, `write.files: []` and `schemaValid: true`. The sources keep their
md5, and a later load prints the notice once. With a refused spelling
added to the same body (`time` default `'10:00Z'`), `applied` lists only
`time-default-utc-suffix-dropped @
packages[0].manifest.objects[0].fields.starts_at.defaultValue`, because
the `strict: false` re-production converts the driver.
- **Why it stops.** The chain's per-body run reads
`packages[i].manifest`. `composeStacks` assembles that body from the
stack the input's `defineStack` call RETURNED (`assemblePackageBody`: `{
...stack.manifest }`, then each package-owned key). To start that run
from the authored input, `packages/cli` needs two things it does not
have as values:
1. The mapping from entry to input (`preservePackageEntries`: an input
with `packages` contributes its own entries, and an input without
`manifest` contributes none).
2. The body assembly itself. The codemod's `packageBodyKeys` gives the
key SET, and its `composedBody` step states the assembly over SYNTAX
nodes. Neither produces a value.
Writing either as a value is a second copy of composition's rule. The
real `composeStacks` refuses the authored inputs at its step 0
(`STACK_PROVENANCE_MISSING`). Marking them would take the spec's
provenance key, or `defineStack`, which converts. So this half needs a
`packages/spec` seam, for example a `defineStack` mode that skips the D2
pass, or an exported body assembler. It goes back to the seat.
- **H5, holds.** An accepted call is kept (`__keepAuthored`), and a
refused call is handed through (`__recordStack`). They are the two arms
of one `try`. The composed re-production calls the REAL `defineStack`
(`__specRoot.defineStack`), not the wrap, so it keeps nothing. A
composed artifact carries no record, so `loadConfig` uses it as before.
The pin is a stack carrying one refused spelling and one load-path
spelling: each conversion is applied once, and both are written.
## Pins — `test/migrate-meta-load-conversions.test.ts`, `unit` tier
The tests run in-process over `MigrateMeta.run`, against temp projects
that link the real spec. No process is spawned and no kernel is booted.
`vitest list --project unit` lists the file, and `--project integration`
does not. 5 tests:
- **The fix.** The accepted mongo stack. The dry run lists
`datasource-driver-mongo-to-mongodb @ datasources[0].driver`. `--write`
writes it: `write.files` = `[{ objectstack.config.ts, sites: 1 }]`,
`manual: []`, `unexplained: []`, and the bytes are the source with
`'mongo'` → `'mongodb'` and nothing else. The re-run applies nothing.
The strict load's `stackConversions` goes from naming the conversion to
`[]`. The reload is read through `stackConversions`, not the stderr
notice, because `defineStack` prints that notice once per process.
- **Where the argument is read.** With `export const onEnable` beside
the default, the authored-source load gives `driver: 'mongo'`,
`namedExports: ['onEnable']`, `onEnable` merged, `stackProvenance: true`
and `stackConversions` naming the conversion. The strict load still
gives `'mongodb'`.
- **Defined twice.** `defineStack(defineStack({ … }))` lists the
conversion exactly once.
- **Control.** An already-canonical source gives `applied: []` and
`write` = `{ status: 'written', files: [], written: [], manual: [],
unexplained: [] }`, and every byte is unchanged. Its `applied`, `todos`,
`absentTodos` and `schemaValid` equal those of the chain over the stack
`defineStack` built, which is where the chain started before this
change.
- **Mixed (H5).** One refused spelling (`time` default `'10:00Z'`) and
one load-path spelling (the valid mongo datasource). `applied` =
`[mongo, time]`, each once. `write.files` = `[{ objectstack.config.ts,
sites: 2 }]`, and both edits are written. The re-run applies nothing,
and the strict load accepts and converts nothing.
PR #22326's pins (`test/migrate-meta-composed.test.ts`, 8 tests,
including both one-package controls) stay green.
## Ablations
Each ablation went through `scripts/ablation-replace.mjs`. The anchor
hit once, the mutation was proven on disk, and the restore was proven:
blob `0488d7e55d38` = HEAD, and `git diff HEAD` empty. The suite imports
`src` directly, so no `dist/` is involved. The direction was stated
before each run.
1. **The converted hand-through restored in the loader** (`authoredBase`
= `baseConfig`). Predicted 3 red, 2 green. Got 3 red (the fix, where the
argument is read, defined twice) and 2 green (control, mixed). The fix
pin's first red: `expected [] to deeply equal [ { …(4) } ]`.
2. **The shim no longer keeps the argument** (the `defineStack` wrap
passes only `__recordStack`). The same 3 red, 2 green.
3. **The record followed one level only.** Predicted 1 red. Got 1 red
(defined twice) and 4 green.
## Local verification at `038ef735d`
- **CLI unit tier**: `pnpm --filter @objectstack/cli exec vitest run
--project unit --maxWorkers=2` gave `Test Files 269 passed (269)` and
`Tests 3956 passed (3956)`.
- **Typecheck**: `pnpm --filter @objectstack/cli typecheck` (`tsc
--noEmit`, then `check:test-typecheck`) exited 0, ending
`check:test-typecheck: OK … 3 file(s) / 28 error(s) / 6 pinned
signature(s) held`, the pre-existing ledger. It ran at `93f524998`. The
only later commit is the changeset, so the TypeScript tree is the same.
- **Integration tier**: declared to CI, because this diff touches no
integration-tier file and no spawn entry. As a targeted check, I ran the
two integration files that drive `migrate meta` over an authored source,
`test/migrate-meta-default-range.test.ts` and
`test/migrate-meta-engine-guidance.test.ts`, with `--project
integration`: `Test Files 2 passed (2)`, `Tests 10 passed | 1 skipped
(11)`.
- **Gates**: `node scripts/pm/dispatch-gates.mjs --commands`, re-derived
over this diff, gives the same 65 commands the order listed. All 65
exited 0. `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT
MET`: 8 packages outside the closures I had built had no `dist/`). After
building those 8 it passed (`106/66/712/1`
entries/packages/cjsFiles/probes). `dispatch-gates --ran` reports `65
derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`.
- **Lint**: `pnpm lint` (`eslint . --no-inline-config`, the whole repo)
exited 0 with no findings.
- **Base**: the branch is on `3599fef1`. `main` has since moved 6
commits, and none touches `packages/cli`, the spec's stack or provenance
modules, or PR #22326's changeset. I did not merge it; CI's merge ref
covers the join.
## Acceptance notes
- **PR #22326's "Known limit" sentence** ends "…, the same as for an
input the current schema accepts." Inside a composed project, which is
its context, it stays true. Read as the one-package path, this PR makes
the comparison stale. This PR's changeset restates the limit for both
kinds of composed input. I did not edit the other changeset: it is
outside the claimed file surface.
- **`content/docs/upgrading.mdx`**: I read the `--write` paragraph.
Nothing in it goes stale, because it makes no claim about load-path
conversions. No docs edit.
- **Named exports, in this one load.** `migrate meta`'s merge now judges
whether a named export is shadowed against the authored argument, not
the built stack. A top-level key that the parse defaults and that the
author also exports would now merge instead of being reported as
shadowed. Measured on the corpus, no TOP-LEVEL key differs between the
two; every difference is nested.
- **A stack spread from a built stack** (`defineStack({ ...base, … })`)
still reaches the chain with `base`'s converted values. The spread drops
the record, as it drops the provenance mark, and `--write` refuses that
site as `spread` anyway.
- **A one-input `composeStacks([x])`** returns `x` itself. Measured: it
now lists `datasource-driver-mongo-to-mongodb @ datasources[0].driver`,
and `--write` lists that site as `helper` under the codemod's existing
composed-key rule.
## Round 2 (REWORK `6068295985`), written by the `domain:cli` seat
- **Merged `main`** with a merge commit, `daa27b7c7` (parents
`038ef735d` and `35afb1587`). There was no rebase and no force-push, and
no path conflicted.
- **PR #22326's pending changeset, corrected** (`2f8e479a3`). In
`.changeset/22289-migrate-meta-composed-project.md`, the "Known limit"
bullet's closing clause ", the same as for an input the current schema
accepts" is removed. Nothing else in that file moves.
- This PR makes that clause false, and both changesets ship in one
`@objectstack/cli` release.
- It is a deliberate correction of another PR's pending note, so
`check-empty-changeset`'s foreign-changeset rule is red by design. The
seat confirms that in a comment on this PR.
- The first Acceptance note above (PR #22326's "Known limit" sentence)
no longer applies.
- **The open question (the `--out` snapshot) is ruled A.** It is kept as
shipped and declared in this PR's changeset.
- **Re-verified by the dev at `2f8e479a3`,** after a full `turbo run
build`:
- CLI unit tier: 269 files, 3956 tests passed. Typecheck exit 0. `pnpm
lint` exit 0.
- The same 65 gates re-derived over the final diff: 64 exit 0, and
`check-empty-changeset` exits 1 on the foreign-changeset rule only.
`dispatch-gates --ran`: `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`.
- **The docs-drift advisory (`6068211309`), re-derived at `2f8e479a3`**
with `node scripts/docs-audit/affected-docs.mjs --json 35afb15`. It
lists 16 hand-written pages and 9 release-owned ones (read-only).
- The two pages that describe `os migrate meta`
(`content/docs/upgrading.mdx` and `content/docs/deployment/cli.mdx`)
make no claim this PR changes. The `--out` row says "the migrated stack
as a JSON snapshot", which still holds. `cli.mdx:1354` is about
`--stored`, which this PR does not touch.
- The other 14 name `os migrate meta` as a remedy, which stays true.
- So no docs edit is owed.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(cli): `os migrate meta` lists and writes a conversion the load already applies, on a stack the current schema accepts (#22256)
6
+
7
+
Clause-②: no
8
+
9
+
-**What was wrong.** When the current schema accepts a stack, `defineStack` runs its load-time ADR-0087 conversions while the config loads. `os migrate meta` then replayed its chain over a stack that was already converted. So a spelling the load still converts, 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`, the notice that sends the author to this command, and the run exited 0.
10
+
-**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.
11
+
-**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.
12
+
-**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.
13
+
-**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.
Copy file name to clipboardExpand all lines: .changeset/22289-migrate-meta-composed-project.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -9,4 +9,4 @@ Clause-②: no
9
9
-**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.
10
10
-**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.
11
11
-**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`.
12
-
-**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, the same as for an input the current schema accepts.
12
+
-**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.
0 commit comments