Skip to content

Commit 1644f27

Browse files
committed
fix(cli): os migrate meta can open the retired-key sources it exists to rewrite (#9418)
The codemod refused its own input class. A retired authorable key is a `retiredKey()` tombstone -- `z.never()` carrying the upgrade prescription -- so the current schema does not strip it, it REJECTS it. And a real `objectstack.config.ts` runs that schema itself: `os init` scaffolds `export default defineStack({ ... })`, larger projects spread define* helpers across per-artifact modules, and every one of them is a `Schema.parse()`. The rejection therefore fired while the config module was being EVALUATED, inside the load, before the command reached its first conversion -- so it exited 1 having rewritten nothing, printing the very prescription that sends authors there (144 occurrences across 39 files under packages/spec/src). There was no CLI-side validation step to reorder: the gate lives in the loaded module. So `loadConfig()` gains an opt-in `authoredSource` mode, set by `os migrate meta` alone, that replaces each `@objectstack/spec` entrypoint the config imports -- root and subpaths -- with a shim re-exporting the real module and wrapping its define* helpers as try-real-then-authored. A source that loads today loads identically; a source the current schema refuses reaches the chain exactly as authored, with the swallowed verdict announced on stderr. Validation is not skipped but moved after the conversion: the migrated stack is still parsed and reported through `schemaValid`. `--stored` was probed and is not affected -- it never reads the config file. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6ce8feb commit 1644f27

4 files changed

Lines changed: 361 additions & 4 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os migrate meta --from N` can finally open the retired-key sources it exists to rewrite (#9418)
6+
7+
The codemod refused its own input class. A retired authorable key is a
8+
`retiredKey()` tombstone — `z.never()` carrying the upgrade prescription — so the
9+
current schema does not strip it, it **rejects** it. And a real
10+
`objectstack.config.ts` runs that schema itself: `os init` scaffolds
11+
`export default defineStack({ … })`, larger projects spread `defineView` /
12+
`defineAgent` / `defineFlow` across per-artifact modules, and every one of those
13+
`define*` helpers is a `Schema.parse()`. The rejection therefore fired while the
14+
config module was being **evaluated**, inside the load, before `os migrate meta`
15+
reached its first conversion — the command exited 1 having rewritten nothing.
16+
17+
The message it printed was the instruction that sent the author there. The
18+
sentence "Run `os migrate meta --from <N>` to rewrite existing sources
19+
automatically." ships **144 times across 39 files** under `packages/spec/src`, so the v17 upgrade path closed
20+
a loop on itself: hit a retired key, get told to run the codemod, watch the
21+
codemod refuse **because of** the retired key.
22+
23+
**The fix is a tolerant load for that one command.** There was no CLI-side
24+
validation step to reorder — the gate lives in the loaded module — so
25+
`loadConfig()` gains an opt-in `authoredSource` mode that replaces each
26+
`@objectstack/spec` entrypoint the config imports (the root **and** the subpaths
27+
the example apps author through, `@objectstack/spec/ui`, `/ai`, `/data`, …) with
28+
a generated shim. The shim re-exports the real module and wraps its `define*`
29+
helpers as try-real-then-authored: the real helper runs first, and only when the
30+
current schema refuses the artifact is it handed on **exactly as authored**, with
31+
the swallowed verdict announced on stderr.
32+
33+
Three properties keep this a restoration rather than a widening of what the
34+
command accepts:
35+
36+
- **A source that loads today loads identically** — the real helper still runs,
37+
so its defaults and transforms still apply (`defineForm` still moves
38+
`schemaId` into `data`, `defineStack` still merges actions into objects). Only
39+
the sources that are refused today take the new path.
40+
- **Validation is moved after the conversion, not skipped.** The command still
41+
parses the **migrated** stack through `ObjectStackDefinitionSchema` and reports
42+
`schemaValid`, so a source broken for reasons the chain cannot fix is still
43+
reported as broken — after the codemod has done the part it can.
44+
- **Every other command still hears the tombstone.** `os build`, `os validate`
45+
and `os serve` keep the default strict load: the rejection is their upgrade
46+
channel, and only the codemod is entitled to read past it. Pinned both ways.
47+
48+
`os migrate meta --stored` was probed and is **not** affected: it never reads
49+
`objectstack.config.ts` at all — it boots from the compiled artifact and replays
50+
the chain over `sys_metadata` rows, and it already exits 0 in a project whose
51+
config carries a retired key. The defect was the authored-source arm alone.
52+
53+
The regression proof is shaped like a real project rather than like a test — the
54+
retired keys are authored through `defineStack` **and** through helpers imported
55+
from a spec subpath, which is where a tolerance scoped to `defineStack` alone
56+
would still have refused. The suite that shipped alongside the defect could not
57+
have caught it: its fixture is a bare `export default { … }` object literal, and
58+
a bare literal is validated by nobody at load.

‎packages/cli/src/commands/migrate/meta.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,22 @@ export default class MigrateMeta extends Command {
290290

291291
try {
292292
if (!flags.json) printStep('Loading configuration…');
293-
const { config, absolutePath } = await loadConfig(args.config);
293+
// `authoredSource`: read the config as the author WROTE it, not as the
294+
// current schema would have it (#9418). A retired key is a `retiredKey()`
295+
// tombstone — the schema rejects it rather than stripping it — and a real
296+
// config runs that schema itself: `os init` scaffolds
297+
// `export default defineStack({ … })`, and every `define*` helper is a
298+
// `Schema.parse()`. So the refusal used to happen while the config module
299+
// was being EVALUATED, inside the load, before this command reached its
300+
// first conversion — leaving the codemod unable to open the one input
301+
// class it exists for, while the message it printed was the prescription
302+
// telling the author to run it.
303+
//
304+
// This is where "convert before validating" has to land, because the CLI
305+
// has no validation step of its own to move: the load is tolerant, and
306+
// the schema verdict is taken below on the MIGRATED stack instead
307+
// (`schemaValid`), which is the stack the author is being asked to adopt.
308+
const { config, absolutePath } = await loadConfig(args.config, { authoredSource: true });
294309

295310
// Map→array normalization ONLY (convert:false): the chain must replay the
296311
// conversions itself against the raw authored source so each rewrite is

‎packages/cli/src/utils/config.ts‎

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
import path from 'path';
44
import fs from 'fs';
5+
import { createRequire } from 'node:module';
6+
import { pathToFileURL } from 'node:url';
57
import chalk from 'chalk';
68
import { bundleRequire } from 'bundle-require';
9+
import type { Plugin } from 'esbuild';
710
import { printError } from './format.js';
811

912
export interface LoadedConfig {
@@ -72,17 +75,173 @@ export function resolveConfigPath(source?: string): string {
7275
process.exit(1);
7376
}
7477

78+
/**
79+
* Every `@objectstack/spec` entrypoint an authored config can reach the
80+
* `define*` helpers through — the root and every subpath export. Real projects
81+
* use both: the example apps import `defineView`/`defineApp` from
82+
* `@objectstack/spec/ui` and `defineHook`/`defineDatasource` from
83+
* `@objectstack/spec/data`, so a shim that knew only the root package would
84+
* cover the smaller half of the authored surface.
85+
*/
86+
const SPEC_MODULE_RE = /^@objectstack\/spec(?:\/[\w./-]+)?$/;
87+
88+
/** esbuild namespace the authored-source shim modules live in. */
89+
const AUTHORED_SOURCE_NAMESPACE = 'objectstack-authored-source';
90+
91+
/** `defineStack`, `defineView`, … — the authoring helpers, by naming convention. */
92+
const DEFINE_HELPER_RE = /^define[A-Z]/;
93+
94+
/**
95+
* The `define*` helpers a given `@objectstack/spec` entrypoint exports, read
96+
* from the copy **the config itself would import** (resolved from the config's
97+
* own directory, not the CLI's).
98+
*
99+
* Returns `[]` — i.e. "shim nothing" — when the entrypoint cannot be resolved
100+
* or imported. That is the safe direction: an unshimmed load is exactly
101+
* today's behaviour, so a project the enumeration cannot read is no worse off
102+
* than before.
103+
*/
104+
async function defineHelpersOf(specifier: string, requireFromConfig: NodeRequire): Promise<string[]> {
105+
try {
106+
const resolved = requireFromConfig.resolve(specifier);
107+
const ns = (await import(pathToFileURL(resolved).href)) as Record<string, unknown>;
108+
return Object.keys(ns).filter((k) => DEFINE_HELPER_RE.test(k) && typeof ns[k] === 'function');
109+
} catch {
110+
return [];
111+
}
112+
}
113+
114+
/**
115+
* Load an authored config **as authored**, for the one consumer whose input is
116+
* a source the CURRENT schema is expected to refuse: the `os migrate meta`
117+
* codemod (#9418).
118+
*
119+
* ## The defect this exists to close
120+
*
121+
* A retired authorable key is a `retiredKey()` tombstone — `z.never()` carrying
122+
* the upgrade prescription — so the current schema does not strip it, it
123+
* REJECTS it. Every `define*` helper in `@objectstack/spec` is a
124+
* `Schema.parse(config)`, and a real `objectstack.config.ts` calls them: `os
125+
* init` scaffolds `export default defineStack({ … })`, and larger projects
126+
* spread `defineView` / `defineAgent` / `defineFlow` across per-artifact
127+
* modules. So the rejection happens while the config MODULE is being evaluated,
128+
* inside `bundleRequire` — before `os migrate meta` has run a line of its own.
129+
*
130+
* The CLI never had a validation step to reorder: the gate lives in the loaded
131+
* module. That made the codemod refuse the only input class it exists for, and
132+
* the refusal it printed was the prescription telling the author to run it —
133+
* `Run \`os migrate meta --from <N>\`…` ships 144 times across 39 files under
134+
* `packages/spec/src`, so the upgrade path closed a loop on itself.
135+
*
136+
* ## What the shim does
137+
*
138+
* Each `@objectstack/spec` entrypoint the config imports is replaced by a
139+
* generated module that re-exports the real one and wraps its `define*`
140+
* helpers as **try-real-then-authored**:
141+
*
142+
* ```js
143+
* export const defineView = (...authored) => {
144+
* try { return realDefineView(...authored); } catch { return authored[0]; }
145+
* };
146+
* ```
147+
*
148+
* The narrowness is the point, and it is what keeps this a restoration rather
149+
* than a widening of what the command accepts:
150+
*
151+
* - **A source that loads today loads identically.** The real helper runs, so
152+
* its defaults and transforms still apply (`defineForm` moves `schemaId`
153+
* into `data`, `defineStack` merges actions into objects, …). Nothing about
154+
* the existing happy path is re-decided.
155+
* - **A source the current schema refuses reaches the chain as authored** —
156+
* which is precisely the codemod's input. `defineX(config: z.input<typeof
157+
* XSchema>)` means the authored argument is by construction a shape
158+
* `XSchema` accepts, so handing it on unparsed yields a well-formed
159+
* authoring tree rather than an ad-hoc one.
160+
* - **Validation is not skipped, it is moved after the conversion.** The
161+
* command still parses the MIGRATED stack through
162+
* `ObjectStackDefinitionSchema` and reports `schemaValid`, so a source that
163+
* is broken for reasons the chain cannot fix is still reported as broken —
164+
* just after the codemod has done the part it can.
165+
*
166+
* A swallowed verdict is announced on **stderr** rather than dropped: the
167+
* author deserves to know an artifact bypassed the parse, and stderr keeps a
168+
* `--json` run's stdout a single parseable document.
169+
*
170+
* ⚠️ Deliberately NOT the default for `loadConfig()`. Every other command —
171+
* `os build`, `os validate`, `os serve` — must keep hearing the rejection: the
172+
* tombstone IS their upgrade channel. Only the codemod is entitled to read
173+
* past it.
174+
*/
175+
function authoredSourcePlugin(configPath: string): Plugin {
176+
const requireFromConfig = createRequire(configPath);
177+
return {
178+
name: 'objectstack:authored-source',
179+
setup(build) {
180+
build.onResolve({ filter: SPEC_MODULE_RE }, (args) => {
181+
// The shim re-exports the SAME specifier it stands in for. Left to
182+
// resolve normally that import would land back here and shim itself
183+
// forever, so inside the namespace the specifier is handed straight to
184+
// the runtime — which is also what keeps `__real` the project's own
185+
// copy of spec rather than the CLI's.
186+
if (args.namespace === AUTHORED_SOURCE_NAMESPACE) {
187+
return { path: args.path, external: true };
188+
}
189+
return { path: args.path, namespace: AUTHORED_SOURCE_NAMESPACE };
190+
});
191+
192+
build.onLoad({ filter: /.*/, namespace: AUTHORED_SOURCE_NAMESPACE }, async (args) => {
193+
const helpers = await defineHelpersOf(args.path, requireFromConfig);
194+
const spec = JSON.stringify(args.path);
195+
const lines = [
196+
`import * as __real from ${spec};`,
197+
`export * from ${spec};`,
198+
];
199+
for (const name of helpers) {
200+
lines.push(
201+
`export const ${name} = (...authored) => {`,
202+
` try {`,
203+
` return __real.${name}(...authored);`,
204+
` } catch (error) {`,
205+
` console.warn(`,
206+
` '[authored-source] ' + ${JSON.stringify(name)} + '(): the current schema refuses this '`,
207+
` + 'artifact, so it is handed to the migration chain exactly as authored. '`,
208+
` + ((error && error.message) || String(error)),`,
209+
` );`,
210+
` return authored[0];`,
211+
` }`,
212+
`};`,
213+
);
214+
}
215+
return { contents: lines.join('\n'), loader: 'js' };
216+
});
217+
},
218+
};
219+
}
220+
221+
export interface LoadConfigOptions {
222+
/**
223+
* Read the config as AUTHORED rather than as the current schema would have
224+
* it — see {@link authoredSourcePlugin}. Set by `os migrate meta` only.
225+
*
226+
* @default false
227+
*/
228+
authoredSource?: boolean;
229+
}
230+
75231
/**
76232
* Load and bundle a config file using bundle-require.
77233
* Returns the resolved config object and load time.
78234
*/
79-
export async function loadConfig(source?: string): Promise<LoadedConfig> {
235+
export async function loadConfig(source?: string, options?: LoadConfigOptions): Promise<LoadedConfig> {
80236
const absolutePath = resolveConfigPath(source);
81237
const start = Date.now();
82238

83239
const { mod } = await bundleRequire({
84240
filepath: absolutePath,
85241
external: BUNDLE_REQUIRE_EXTERNALS,
242+
...(options?.authoredSource
243+
? { esbuildOptions: { plugins: [authoredSourcePlugin(absolutePath)] } }
244+
: {}),
86245
});
87246

88247
const baseConfig = mod.default || mod;

0 commit comments

Comments
 (0)