Skip to content

Commit d2b188f

Browse files
fix(cli): os migrate meta converts objects built with ObjectSchema.create and the other strict authoring factories (#20801)
Fixes #20696 Clause-②: no ## What was wrong `os migrate meta` loads the config through the authored-source shim in `packages/cli/src/utils/config.ts`. The shim swaps each `@objectstack/spec` entrypoint for a module that wraps its helpers as try-real-then-authored, so a retired key reaches the conversion chain instead of stopping the load. It wrapped only the exports matching `DEFINE_HELPER_RE` (the `define*` helpers). `ObjectSchema.create(...)` parses when it is called, inside `defineStack`'s argument, so it threw before the wrapped `defineStack` ever ran. Reproduced on `main` at `91e8fa194` before any edit: - **Repro.** The card's `defineStack({ objects: [ObjectSchema.create({ ..., tenancy: { enabled: true, organizationField: 'organization_id' } })] })`, with `ObjectSchema` imported from `@objectstack/spec/data`. `os migrate meta --from 17 --json` exits 1 with `{"error":"[\n {\n \"code\": \"unrecognized_keys\", ...` (the raw `ZodError` array). The tombstone inside that array says `Run os migrate meta --from 17`. - **Control.** The same object as a plain literal exits 0: `applied` holds `object-tenancy-organization-field-removed` at `objects[0].tenancy.organizationField`, and `schemaValid` is `true`. ## What changed Everything below is in `packages/cli/src/utils/config.ts`, as the triage direction asks. `packages/spec` is untouched: `ObjectSchema.create` stays strict everywhere, and the shim is installed only by `os migrate meta`. - **One written list.** `STRICT_AUTHORING_FACTORIES` names the five factories below as owner export → member, plus the entrypoint each was measured on. | factory | home | |:--|:--| | `ObjectSchema.create` | `@objectstack/spec/data` | | `App.create` | `@objectstack/spec/ui` | | `Dashboard.create` | `@objectstack/spec/ui` | | `Report.create` | `@objectstack/spec/ui` | | `Action.create` | `@objectstack/spec/ui` | The shim wraps an entry on every entrypoint whose owner export carries that member. Unlisted factories are not wrapped. `DEFINE_HELPER_RE` is unchanged, and no second pattern is added. - **How a factory is wrapped.** The wrap happens in place, on a `Proxy` over the real owner. Only the listed member gets the try-real-then-authored wrap; every other member (`parse`, `safeParse`, `shape`, ...) passes through untouched. It has to be a `Proxy` rather than a copy because `ObjectSchema` is a lazy-schema `Proxy` whose `ownKeys` trap throws. `Reflect.ownKeys(ObjectSchema)` answers `TypeError: 'ownKeys' on proxy: trap result did not include 'prototype'`, so `Object.keys(ObjectSchema)` cannot see `create`. For the same reason, the shim reads the owner by property and never enumerates it. - **The refused-artifact line on stderr is rendered.** While the config loads, the shim prints one stderr line per artifact the current schema refused. A raw `ZodError` on that line (its `message` is the issues as a JSON array) is now printed through the project's own `formatZodError`, as a `ObjectSchema.create validation failed (1 issue):` block with one `✗ path: message` line per issue. Errors that already carry prose, such as `defineStack`'s `StackSchemaInvalidError` or `ObjectSchema.create`'s own unknown-key and `system-data` refusals, keep their message. The template is shared, so `define*` helpers that throw a raw `ZodError`, such as `defineAgent`, get the same rendering. This is the same defect class inside the same generated template: the card's third pin forbids a raw `ZodError` array, and without this change the fixed repro would still print one, only on stderr instead of in the exit payload. ## Where the list lives, and how it was measured **The list lives on the cli side.** It does not have to be exported by `packages/spec`: the shim is its only reader, and the pin holds it to the spec surface from `packages/cli`. So there is no `needs_decision`. Measured at `5f3c0f648` over all 19 JS entrypoints in `@objectstack/spec`'s `exports` (the two `.json` entries excluded), reading each export's `create` by property: - 18 distinct exported values carry a `create` member, under 31 export names (each identity factory is exported a second time as its `*Schema`). - **5 are strict** (throw on a refused input): the five in the list. Positive control: `ObjectSchema.create` is found at `@objectstack/spec/data`. - **13 are identity** (`(config) => config`): `ApiDocumentationConfig`, `ApiEndpoint`, `ApiTestCollection`, `BatchTask`, `MiddlewareConfig`, `OpenApiSpec`, `QueueConfig`, `RestApiConfig`, `RestApiPluginConfig`, `RestApiRouteRegistration`, `RestServerConfig`, `Task`, `WorkerConfig`. They refuse nothing, so there is nothing to tolerate. - **Siblings.** An eager-mode (`OS_EAGER_SCHEMAS=1`) sweep of every function member of every exported value checked the other members: `Field.*` (32 builders), `SCIM.*`, `RLS.*`, `OData.*`, `StorageNameMapping.resolveTableName`. None of them validates. - **Top-level exports.** The only non-`define*` top-level functions whose own source calls `.parse(` are `connectorFetchOptions`, `describeHighPrivilegeBits` and `utcInstantMs`. They are runtime helpers, not authoring factories. - **Producers.** `git grep` on this tree counts 35 `ObjectSchema.create(` sites and 3 `App.create(` sites under `examples/`. `Dashboard.create`, `Report.create` and `Action.create` have 0 sites in `examples/`, but they are wrapped anyway because the triage asked for every strict factory from one list. ## Pins `packages/cli/test/migrate-meta-strict-factories.test.ts` is `unit` tier: in-process over `MigrateMeta.run` and `loadConfig`, against a temp project that links the real `@objectstack/spec`. It spawns no process and boots no kernel. It has five cases: 1. **The card's repro migrates.** It exits cleanly, `applied` holds the tenancy conversion at `objects[0].tenancy.organizationField`, and `schemaValid` is `true`. The stderr line names `ObjectSchema.create()` and carries no raw `ZodError` array. 2. **The plain-literal control is unchanged**, and the factory spelling now produces the same `applied` list as the literal. 3. **An unrelated strict error surfaces as a refusal.** The fixture adds an unknown field type next to the retired key. The retired key is still converted and `schemaValid` is `false`. The human report reads `does not yet pass schema validation — 1 refusal left after the chain` and lists `✗ objects.0.fields.stage.type: ...`. No stream (stdout or stderr, `--json` or human) contains a raw `ZodError` array. 4. **Every listed factory is tolerated through the shim, and only through it.** The fixture is generated from the list, and each refused call comes back exactly as authored, announced once by name. `ObjectSchema.safeParse` still works through the `Proxy`. Loading the same source without `authoredSource` still rejects. 5. **The list stays honest in both directions.** Every entry is live at its `home` and throws on a refused input. Every `create` that spec exports and the list does not name returns its argument by identity. A new strict factory in spec therefore turns this case red and names itself. ## Reverse verification I committed the fix first, then ran each mutation with `scripts/ablation-replace.mjs`. It checks that the anchor hits exactly once, that the file's hash on disk changes, and that it is restored afterwards (hash equal to `HEAD`, empty `git diff HEAD`). The subject is `src` through relative imports, so no `dist` rebuild was involved. Predicted direction for both: red. - **A: `ObjectSchema` entry deleted from the list** (at `5f3c0f648`). Cases 1, 2, 3 and 5 are red; case 4 is green. - Cases 1 and 3 fail on `expected 1 to be undefined`, meaning exit 1 at load. - Case 2 fails on ``no `applied` in the --json payload: {"error":"[\n {\n \"code\": \"unrecognized_keys\", ...``, which is the original defect. - Case 5 fails on ``add a strict `create` to STRICT_AUTHORING_FACTORIES ...``, naming the unlisted strict factory. - Case 4 staying green is correct: its population is the list itself, so removing an entry removes it from that case, and case 5 is the one that catches the removal. - **B: the ZodError rendering disabled** (`error.name === 'ZodError'` changed to `'AblatedZodError'`, at `888e0c78d`). Cases 1 and 3 are red on `expected '[authored-source] ObjectSchema.create…' not to match /"code":\s*"/`. Cases 2, 4 and 5 are green, as predicted. ## Verification - **cli unit tier** (`pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`) at `5f3c0f648`: `Test Files 237 passed (237)`, `Tests 3370 passed (3370)`. The integration tier is declared to CI: this diff touches no integration-tier file and no spawn or boot entry point. - **cli typecheck** (`pnpm --filter @objectstack/cli typecheck`, which is `tsc --noEmit` plus `check:test-typecheck`) at `5f3c0f648`: exit 0. The test layer passes (`OK ... 3 file(s) / 28 error(s) / 6 pinned signature(s) held`, all pre-existing debt), and `tsconfig.test.json --listFilesOnly` includes the new pin file. - **`pnpm lint`** (full repo, `eslint . --no-inline-config`) at `5f3c0f648`: exit 0 in 183s. The last commit, `3d877c6d4`, is a docblock-only change (it corrects the measured counts). At that head, a targeted `eslint --no-inline-config --format json` over the two touched TS files reports both linted with 0 errors and 0 warnings. The changeset `.md` is outside eslint's population. - **Gates.** `dispatch-gates --commands` derives 63 families from this diff: the 56 in the dispatch list, plus 7 that the changeset adds (`check-adr-0087-registration` ×2, `check-empty-changeset` ×2, `release-rehearsal-clone --self-test`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`). All 63 exited 0 at `5f3c0f648`. `--ran` reconciliation reports: `63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero, since every entry recorded an exit code). They were re-run at the final head `3d877c6d4`; see the report comment on the card. - **Branch.** It carries one merge of `origin/main` (`085ca6bc1`). After the merge I rebuilt `@objectstack/spec` and the six closure packages that the merge changed, plus `@objectstack/cli`. ## Acceptance notes - **Checks that only a factory makes are not part of `schemaValid`.** Some checks run only when a factory is called and are not part of the stack schema: `ObjectSchema.create`'s refusal of a `managedBy: 'system-data'` object that grants no create, edit or delete, its `referenceVia` sibling check, and its refusal of an explicit `required: false` on a `controlled_by_parent` master-detail reference. A source that trips one of them now migrates with that refusal on the stderr line and `schemaValid: true`. `os validate` still refuses it (exit 1). - Measured: `system-data` with every write closed, plus the retired tenancy key, gives `applied: [object-tenancy-organization-field-removed]`, `schemaValid: true`, the refusal prose on stderr, and `os validate` exit 1. - Precedent: `defineStack` has behaved this way under the existing shim. Its call-time namespace-prefix check throws `StackNamespacePrefixInvalidError` while `ObjectStackDefinitionSchema.safeParse` of the same stack succeeds. - Reading: `schemaValid` is documented as whether the migrated stack parses under the installed schema, and that is what it reports. The changeset states this boundary. - **Lazy-schema proxies cannot be enumerated.** `Reflect.ownKeys` on a lazy-schema proxy (`ObjectSchema`, for one) throws, because the trap omits the target function's non-configurable `prototype`. I found no public door that reaches it, so this is an observation, not a filed finding. Carrier: none. - **Sibling cards.** #20620 (the verdict-first report) has a changeset already on `main`, and this diff does not touch that report code. For #20583 (`os lint --json` reads the conversion record), nothing here changes the record that tool reads. The only premise this moves for either card is that `os migrate meta` now opens stacks whose objects are built with a strict factory. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5bed1f6 commit d2b188f

3 files changed

Lines changed: 567 additions & 27 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate meta` converts an object built with `ObjectSchema.create(…)` instead of stopping at load when the object carries a retired key
6+
7+
Clause-②: no
8+
9+
`os migrate meta` reads a config the current schema refuses, so it can rewrite the
10+
retired keys in it. It did that for artifacts built with a `define*` helper and for
11+
plain object literals. It did not do it for artifacts built with a factory such as
12+
`ObjectSchema.create(…)`, which validates when it is called. An object like this:
13+
14+
```ts
15+
ObjectSchema.create({
16+
name: 'ticket',
17+
fields: { title: { type: 'text' } },
18+
tenancy: { enabled: true, organizationField: 'organization_id' },
19+
})
20+
```
21+
22+
stopped `os migrate meta --from 17` at load with exit 1 and a raw JSON array of
23+
validation issues. The message in that array told the author to run
24+
`os migrate meta --from 17`.
25+
26+
The command now loads it, applies the conversion (here
27+
`object-tenancy-organization-field-removed`), and reports `schemaValid` for the
28+
migrated stack, exactly as it does for the same object written as a plain literal.
29+
This covers the five factories in `@objectstack/spec` that validate when called:
30+
`ObjectSchema.create` (`@objectstack/spec/data`) and `App.create`,
31+
`Dashboard.create`, `Report.create` and `Action.create` (`@objectstack/spec/ui`).
32+
The other `create` factories spec exports return their argument unchanged and
33+
never refused anything, so nothing changes for them.
34+
35+
A schema problem the migration cannot fix is still reported: it is listed among
36+
the refusals under the verdict, and `schemaValid` is `false`. A check that only
37+
the factory makes when it is called, such as `ObjectSchema.create` refusing a
38+
`managedBy: 'system-data'` object that grants no create, edit or delete, is not
39+
part of the stack schema. It is reported on the stderr line described below and
40+
does not change `schemaValid`, the same as `defineStack`'s own call-time checks.
41+
`os validate` still refuses it.
42+
43+
While the config loads, `os migrate meta` prints one stderr line for each
44+
artifact the current schema refused. A raw validation error on that line is now
45+
printed as a block, for example `ObjectSchema.create validation failed (1 issue):`
46+
followed by one `✗ path: message` line per issue, instead of a raw JSON array.
47+
This also applies to `define*` helpers that throw a raw validation error, such
48+
as `defineAgent`.
49+
50+
Nothing else changes. `os validate`, `os build` and every other command still
51+
refuse the retired key at load, with the same message. `ObjectSchema.create` and
52+
the other factories stay strict everywhere outside `os migrate meta`. The keys
53+
of the `--json` payload are unchanged, and a run whose migrated stack does not
54+
parse still exits 0.

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

Lines changed: 163 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,8 @@ export function resolveConfigPath(source?: string): string {
257257

258258
/**
259259
* Every `@objectstack/spec` entrypoint an authored config can reach the
260-
* `define*` helpers through — the root and every subpath export. Real projects
260+
* `define*` helpers and the {@link STRICT_AUTHORING_FACTORIES} through — the
261+
* root and every subpath export. Real projects
261262
* use both: the example apps import `defineView`/`defineApp` from
262263
* `@objectstack/spec/ui` and `defineHook`/`defineDatasource` from
263264
* `@objectstack/spec/data`, so a shim that knew only the root package would
@@ -268,29 +269,161 @@ const SPEC_MODULE_RE = /^@objectstack\/spec(?:\/[\w./-]+)?$/;
268269
/** esbuild namespace the authored-source shim modules live in. */
269270
const AUTHORED_SOURCE_NAMESPACE = 'objectstack-authored-source';
270271

272+
/** The package root, which carries `formatZodError` for the shim's refusal text. */
273+
const SPEC_ROOT_MODULE = '@objectstack/spec';
274+
271275
/** `defineStack`, `defineView`, … — the authoring helpers, by naming convention. */
272276
const DEFINE_HELPER_RE = /^define[A-Z]/;
273277

274278
/**
275-
* The `define*` helpers a given `@objectstack/spec` entrypoint exports, read
276-
* from the copy **the config itself would import** (resolved from the config's
277-
* own directory, not the CLI's).
278-
*
279-
* Returns `[]` — i.e. "shim nothing" — when the entrypoint cannot be resolved
280-
* or imported. That is the safe direction: an unshimmed load is exactly
281-
* today's behaviour, so a project the enumeration cannot read is no worse off
282-
* than before.
279+
* One strict authoring factory that is not a `define*` helper: the function
280+
* `member` of the exported value `owner`, which validates its argument AT THE
281+
* CALL and throws on a shape the current schema refuses.
282+
*/
283+
export interface StrictAuthoringFactory {
284+
/** The export that carries the factory: `ObjectSchema`, `App`, … */
285+
readonly owner: string;
286+
/** The factory on it: `create`. */
287+
readonly member: string;
288+
/**
289+
* The `@objectstack/spec` entrypoint the entry was measured on. The shim does
290+
* not read it — it wraps the factory on EVERY entrypoint whose `owner` export
291+
* carries the member — and the pin reads it to prove the entry is still live.
292+
*/
293+
readonly home: string;
294+
}
295+
296+
/**
297+
* Every strict authoring factory `@objectstack/spec` exports besides the
298+
* `define*` helpers: the ONE list the authored-source shim wraps them from.
299+
*
300+
* `ObjectSchema.create(…)` is the authoring spelling of every example app's
301+
* objects, and it is as strict as a `define*` helper — it parses at the call.
302+
* Unwrapped, a retired key inside it aborted `os migrate meta` at load with a
303+
* raw `ZodError` array, while the tombstone it printed told the author to run
304+
* `os migrate meta`.
305+
*
306+
* ## Why a written list and not a pattern
307+
*
308+
* Measured over all 19 JS entrypoints of `@objectstack/spec`: 18 distinct
309+
* exported values carry a `create` member (31 export names — each identity
310+
* factory is exported a second time as its `*Schema`). Five validate — the
311+
* five below — and thirteen (`ApiEndpoint`, `Task`, `RestServerConfig`, …) are
312+
* identity factories, `(config) => config`, that refuse nothing and so have
313+
* nothing to tolerate.
314+
* The other function members of exported namespaces (`Field.*`, `SCIM.*`,
315+
* `RLS.*`, `OData.*`) build or read values and validate nothing. So:
316+
*
317+
* - a NAME pattern (`*.create`) would wrap thirteen no-ops and still say
318+
* nothing about which factories are strict;
319+
* - a SHAPE enumeration at load cannot even see the one this list exists for:
320+
* `ObjectSchema` is a lazy-schema Proxy whose `ownKeys` trap throws, so
321+
* `Object.keys(ObjectSchema)` never names `create`.
322+
*
323+
* The pin beside this file's tests holds the list to the spec surface in both
324+
* directions: every entry resolves at its `home` and throws on a refused input,
325+
* and every `create` member spec exports that is NOT listed returns its
326+
* argument untouched. A new strict factory in spec therefore reddens the pin
327+
* with its name instead of reopening this defect.
328+
*
329+
* ⛔ Unlisted factories are never wrapped. `ObjectSchema.create` itself stays
330+
* strict everywhere else: this list is read by {@link authoredSourcePlugin}
331+
* alone, which only `os migrate meta` installs.
332+
*/
333+
export const STRICT_AUTHORING_FACTORIES: readonly StrictAuthoringFactory[] = Object.freeze([
334+
{ owner: 'ObjectSchema', member: 'create', home: '@objectstack/spec/data' },
335+
{ owner: 'App', member: 'create', home: '@objectstack/spec/ui' },
336+
{ owner: 'Dashboard', member: 'create', home: '@objectstack/spec/ui' },
337+
{ owner: 'Report', member: 'create', home: '@objectstack/spec/ui' },
338+
{ owner: 'Action', member: 'create', home: '@objectstack/spec/ui' },
339+
]);
340+
341+
/** What one `@objectstack/spec` entrypoint gives the shim to wrap. */
342+
interface AuthoredSourceHelpers {
343+
/** Its `define*` helpers, by {@link DEFINE_HELPER_RE}. */
344+
readonly defineHelpers: readonly string[];
345+
/** Its {@link STRICT_AUTHORING_FACTORIES}, as owner export → factory members. */
346+
readonly factories: ReadonlyMap<string, readonly string[]>;
347+
}
348+
349+
/**
350+
* The strict authoring surface a given `@objectstack/spec` entrypoint exports —
351+
* its `define*` helpers and its {@link STRICT_AUTHORING_FACTORIES} — read from
352+
* the copy **the config itself would import** (resolved from the config's own
353+
* directory, not the CLI's).
354+
*
355+
* A listed factory is read by PROPERTY (`ns[owner][member]`), never by
356+
* enumerating the owner: `ObjectSchema` is a lazy-schema Proxy, and its
357+
* `ownKeys` trap throws.
358+
*
359+
* Returns nothing to wrap — i.e. "shim nothing" — when the entrypoint cannot
360+
* be resolved or imported. That is the safe direction: an unshimmed load is
361+
* exactly today's behaviour, so a project the enumeration cannot read is no
362+
* worse off than before.
283363
*/
284-
async function defineHelpersOf(specifier: string, requireFromConfig: NodeRequire): Promise<string[]> {
364+
async function authoredSourceHelpersOf(
365+
specifier: string,
366+
requireFromConfig: NodeRequire,
367+
): Promise<AuthoredSourceHelpers> {
285368
try {
286369
const resolved = requireFromConfig.resolve(specifier);
287370
const ns = (await import(pathToFileURL(resolved).href)) as Record<string, unknown>;
288-
return Object.keys(ns).filter((k) => DEFINE_HELPER_RE.test(k) && typeof ns[k] === 'function');
371+
const defineHelpers = Object.keys(ns).filter((k) => DEFINE_HELPER_RE.test(k) && typeof ns[k] === 'function');
372+
const factories = new Map<string, string[]>();
373+
for (const { owner, member } of STRICT_AUTHORING_FACTORIES) {
374+
const value = ns[owner];
375+
if (value === null || (typeof value !== 'object' && typeof value !== 'function')) continue;
376+
if (typeof (value as Record<string, unknown>)[member] !== 'function') continue;
377+
factories.set(owner, [...(factories.get(owner) ?? []), member]);
378+
}
379+
return { defineHelpers, factories };
289380
} catch {
290-
return [];
381+
return { defineHelpers: [], factories: new Map() };
291382
}
292383
}
293384

385+
/**
386+
* The helpers every generated shim module opens with.
387+
*
388+
* `__tolerant` is the try-real-then-authored wrap; `__tolerantMembers` applies
389+
* it to a factory member and hands every other member of the owner through
390+
* untouched — a Proxy rather than a copy, because the owner may itself be a
391+
* lazy-schema Proxy that cannot be enumerated.
392+
*
393+
* `__refusal` renders a raw `ZodError` — whose `message` is its issues as a
394+
* JSON array — through the project's own `formatZodError`, so the swallowed
395+
* verdict reads like the loader's `defineStack validation failed` block rather
396+
* than as a JSON dump. An error that already carries prose keeps its message.
397+
*/
398+
const AUTHORED_SOURCE_PRELUDE: readonly string[] = [
399+
`const __refusal = (label, error) =>`,
400+
` error && error.name === 'ZodError' && Array.isArray(error.issues)`,
401+
` && typeof __specRoot.formatZodError === 'function'`,
402+
` ? __specRoot.formatZodError(error, label + ' validation failed')`,
403+
` : (error && error.message) || String(error);`,
404+
`const __tolerant = (label, call) => (...authored) => {`,
405+
` try {`,
406+
` return call(...authored);`,
407+
` } catch (error) {`,
408+
` console.warn(`,
409+
` '[authored-source] ' + label + '(): the current schema refuses this '`,
410+
` + 'artifact, so it is handed to the migration chain exactly as authored. '`,
411+
` + __refusal(label, error),`,
412+
` );`,
413+
` return authored[0];`,
414+
` }`,
415+
`};`,
416+
`const __tolerantMembers = (owner, ownerName, members) => {`,
417+
` const wrapped = new Map(members.map((member) => [`,
418+
` member,`,
419+
` __tolerant(ownerName + '.' + member, (...authored) => owner[member](...authored)),`,
420+
` ]));`,
421+
` return new Proxy(owner, {`,
422+
` get: (target, prop) => (wrapped.has(prop) ? wrapped.get(prop) : Reflect.get(target, prop)),`,
423+
` });`,
424+
`};`,
425+
];
426+
294427
/**
295428
* Load an authored config **as authored**, for the one consumer whose input is
296429
* a source the CURRENT schema is expected to refuse: the `os migrate meta`
@@ -317,14 +450,21 @@ async function defineHelpersOf(specifier: string, requireFromConfig: NodeRequire
317450
*
318451
* Each `@objectstack/spec` entrypoint the config imports is replaced by a
319452
* generated module that re-exports the real one and wraps its `define*`
320-
* helpers as **try-real-then-authored**:
453+
* helpers — and the {@link STRICT_AUTHORING_FACTORIES} it carries, such as
454+
* `ObjectSchema.create` — as **try-real-then-authored**:
321455
*
322456
* ```js
323457
* export const defineView = (...authored) => {
324458
* try { return realDefineView(...authored); } catch { return authored[0]; }
325459
* };
326460
* ```
327461
*
462+
* A factory is wrapped in place on its owner: `ObjectSchema` stays the real
463+
* schema for every other member (`parse`, `shape`, …), and only `create` is
464+
* tolerant. Both kinds are strict at the call, so both must be wrapped for the
465+
* chain to convert first — a `defineStack` wrap alone never ran, because the
466+
* `ObjectSchema.create(…)` inside its argument threw before it was called.
467+
*
328468
* The narrowness is the point, and it is what keeps this a restoration rather
329469
* than a widening of what the command accepts:
330470
*
@@ -370,26 +510,22 @@ function authoredSourcePlugin(configPath: string): Plugin {
370510
});
371511

372512
build.onLoad({ filter: /.*/, namespace: AUTHORED_SOURCE_NAMESPACE }, async (args) => {
373-
const helpers = await defineHelpersOf(args.path, requireFromConfig);
513+
const { defineHelpers, factories } = await authoredSourceHelpersOf(args.path, requireFromConfig);
374514
const spec = JSON.stringify(args.path);
375515
const lines = [
376516
`import * as __real from ${spec};`,
517+
`import * as __specRoot from ${JSON.stringify(SPEC_ROOT_MODULE)};`,
377518
`export * from ${spec};`,
519+
...AUTHORED_SOURCE_PRELUDE,
378520
];
379-
for (const name of helpers) {
521+
for (const name of defineHelpers) {
522+
lines.push(
523+
`export const ${name} = __tolerant(${JSON.stringify(name)}, (...authored) => __real.${name}(...authored));`,
524+
);
525+
}
526+
for (const [owner, members] of factories) {
380527
lines.push(
381-
`export const ${name} = (...authored) => {`,
382-
` try {`,
383-
` return __real.${name}(...authored);`,
384-
` } catch (error) {`,
385-
` console.warn(`,
386-
` '[authored-source] ' + ${JSON.stringify(name)} + '(): the current schema refuses this '`,
387-
` + 'artifact, so it is handed to the migration chain exactly as authored. '`,
388-
` + ((error && error.message) || String(error)),`,
389-
` );`,
390-
` return authored[0];`,
391-
` }`,
392-
`};`,
528+
`export const ${owner} = __tolerantMembers(__real.${owner}, ${JSON.stringify(owner)}, ${JSON.stringify(members)});`,
393529
);
394530
}
395531
return { contents: lines.join('\n'), loader: 'js' };

0 commit comments

Comments
 (0)