Skip to content
12 changes: 12 additions & 0 deletions .changeset/22289-migrate-meta-composed-project.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/cli': patch
---

fix(cli): `os migrate meta` runs on a project built with `composeStacks`, and converts the retired spellings inside its package bodies (#22289)

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.
- **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, the same as for an input the current schema accepts.
14 changes: 9 additions & 5 deletions content/docs/upgrading.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,15 @@ against, never as the file you ship.
it can prove the site.** A change is written when it traces to one object or
array literal in one project file: through `define*` calls and `.create`
factories, `const` bindings, relative imports and re-exports, and
`Object.values()` over a namespace import. Only that site's bytes change; a
renamed key keeps its value and its comments. Every other change — a value
built by a function call or an expression, a key a spread supplies, a binding
something else also uses, a file in a package — is listed with the reason it was
not written, for you to port by hand. The command then re-runs the chain over
`Object.values()` over a namespace import. A change inside a package body of a
`composeStacks([…])` project (a path under `packages[i].manifest`) traces into
input i, but only when input i and every input before it is a stack literal
that writes its own `manifest` and no `packages`: only then is package i that
one input's body. Only that site's bytes change; a renamed key keeps its value
and its comments. Every other change — a value built by any other function call
or an expression, a key a spread supplies, a binding something else also uses,
a file in a package — is listed with the reason it was not written, for you to
port by hand. The command then re-runs the chain over
the written files and restores them all unless it comes back with exactly the
changes it left. Commit first, and review the diff like any other change.
</Callout>
Expand Down
111 changes: 108 additions & 3 deletions packages/cli/src/commands/migrate/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
MigrationFloorError,
MIGRATION_MAJORS,
MIGRATION_SUPPORT_FLOOR,
type MigrationApplication,
type MigrationChainResult,
type MigrationHopResult,
type MigrationTodo,
} from '@objectstack/spec/migrations';
import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
Expand Down Expand Up @@ -103,6 +105,109 @@ const VALUE_SHAPE_GATE_MAJOR = 17;
*/
const CHAIN_TERMINUS_MAJOR = Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS);

const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);

/**
* The migration chain over a stack AND each package body it carries — what
* `applyMetaMigrations` is for this command, at every call site.
*
* ## Why the bodies need their own run
*
* The conversions walk a stack's own collections (`objects[]`, `views[]`,
* `pages[]`, …) and do not descend into `packages[]`. A multi-package
* artifact built by `composeStacks(…, { manifest: 'preserve' })` carries each
* definition ONCE, under the package that owns it (ADR-0130 D4 addendum): its
* top level holds `manifest` and `packages` and no flattened copy. So a
* retired spelling inside a package was reached by no conversion — `applied`
* came back empty and the schema verdict refused the very spelling the chain
* exists to convert.
*
* A body (`packages[i].manifest`) is a stack-shaped bag — manifest fields and
* collections side by side — so the chain runs over each body as a stack, and
* each of its applications is listed under the body's own path
* (`packages[0].manifest.objects[0]…`), which names the package and is the
* path `--write` traces to the input that authored it. A conversion whose
* reach already includes `packages[].manifest` (the manifest's permission
* list) runs in the stack's own run and finds no `manifest` key in a body,
* so nothing is applied twice.
*
* ## The merged result
*
* - `applied` keeps application order hop by hop: the stack's own edits of a
* hop, then each body's, in package order.
* - `todos` is the stack's own list — every semantic entry of every hop
* crossed is in it whatever the stack holds, so a body's run lists the same
* entries again.
* - `absentTodos` keeps an entry only when every run names it — the stack's
* own and each body's — matched by hop and id. The relevance question
* already reads `packages[]` itself; what a body's run adds is its own
* applications, and an entry that judges a conversion which applied an
* edit is never named absent.
* - A stack without a `packages` list, which is every one-package project,
* gets the plain chain result back unchanged.
*/
export function applyMetaMigrationsToPackages(
stack: Record<string, unknown>,
fromMajor: number,
toMajor: number,
): MigrationChainResult {
const own = applyMetaMigrations(stack, fromMajor, toMajor);
const entries = own.stack.packages;
if (!Array.isArray(entries)) return own;

const bodies: Array<{ index: number; prefix: string; run: MigrationChainResult }> = [];
for (const [index, entry] of entries.entries()) {
if (!isRecord(entry) || !isRecord(entry.manifest)) continue;
bodies.push({
index,
prefix: `packages[${index}].manifest`,
run: applyMetaMigrations(entry.manifest, fromMajor, toMajor),
});
}
if (bodies.length === 0) return own;

const located = (prefix: string) => (application: MigrationApplication): MigrationApplication => ({
...application,
path: application.path ? `${prefix}.${application.path}` : prefix,
});
const todoKey = (todo: MigrationTodo) => `${todo.toMajor}:${todo.id}`;
const absentEverywhere = (absent: readonly MigrationTodo[], bodyAbsent: (run: MigrationChainResult) => readonly MigrationTodo[]) => {
const bodySets = bodies.map(({ run }) => new Set(bodyAbsent(run).map(todoKey)));
return absent.filter((todo) => bodySets.every((set) => set.has(todoKey(todo))));
};
/** `base` with each body replaced by its stack from `pick`, copied only where one changed. */
const withBodies = (base: Record<string, unknown>, pick: (run: MigrationChainResult) => Record<string, unknown>) => {
const list = base.packages;
if (!Array.isArray(list)) return base;
let changed = false;
const next = list.slice();
for (const { index, run } of bodies) {
const entry = next[index];
const body = pick(run);
if (!isRecord(entry) || entry.manifest === body) continue;
next[index] = { ...entry, manifest: body };
changed = true;
}
return changed ? { ...base, packages: next } : base;
};

const hops: MigrationHopResult[] = own.hops.map((hop, k) => ({
...hop,
stack: withBodies(hop.stack, (run) => run.hops[k]!.stack),
applied: [...hop.applied, ...bodies.flatMap(({ prefix, run }) => run.hops[k]!.applied.map(located(prefix)))],
absentTodos: absentEverywhere(hop.absentTodos, (run) => run.hops[k]!.absentTodos),
}));

return {
...own,
stack: withBodies(own.stack, (run) => run.stack),
applied: hops.flatMap((hop) => hop.applied),
absentTodos: absentEverywhere(own.absentTodos, (run) => run.absentTodos),
hops,
};
}

/** Flags that mean something only in `--stored` mode (#4327). */
const STORED_ONLY_FLAGS = ['apply', 'yes', 'force', 'type', 'database-url'] as const;

Expand Down Expand Up @@ -222,7 +327,7 @@ function printEmptyRangeAnswer(
return;
}

const wider = applyMetaMigrations(stack, fromMajor, CHAIN_TERMINUS_MAJOR);
const wider = applyMetaMigrationsToPackages(stack, fromMajor, CHAIN_TERMINUS_MAJOR);
const widerListed = listedTodos(wider.todos, wider.absentTodos);
if (wider.applied.length === 0 && widerListed.length === 0) {
printInfo(
Expand Down Expand Up @@ -1060,7 +1165,7 @@ export default class MigrateMeta extends Command {
const normalized = normalizeStackInput(config as Record<string, unknown>, { convert: false });

if (!flags.json) printStep(`Replaying chain: protocol ${fromMajor} → ${toMajor}…`);
const result = applyMetaMigrations(normalized, fromMajor, toMajor);
const result = applyMetaMigrationsToPackages(normalized, fromMajor, toMajor);

// Prove the migrated stack is schema-valid — the "generated, provably valid
// diff" the consumer agent reviews (ADR-0087 D3/D5).
Expand Down Expand Up @@ -1237,7 +1342,7 @@ export default class MigrateMeta extends Command {
try {
const reloaded = await loadConfig(input.configArg, { authoredSource: true });
const rerunInput = normalizeStackInput(reloaded.config as Record<string, unknown>, { convert: false });
const rerun = applyMetaMigrations(rerunInput, result.fromMajor, result.toMajor);
const rerun = applyMetaMigrationsToPackages(rerunInput, result.fromMajor, result.toMajor);
// The range the written sources still owe, judged as it was before the write.
const rerunRange = planProtocolRange(rerunInput, result.fromMajor, result.toMajor);
verification = verifyAuthoredSourceWrite(
Expand Down
Loading
Loading