From 99fee4f4b8909f496e31d4a79b270159fd198294 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 05:19:31 +0000 Subject: [PATCH 1/5] fix(cli): os migrate meta wraps the strict authoring factories, not only define* The authored-source shim wrapped only `define*` exports, so an `ObjectSchema.create(...)` carrying a retired key threw at the call, inside the config load, before the migration chain could convert it. The shim now also wraps every strict authoring factory from one written list (`STRICT_AUTHORING_FACTORIES`: ObjectSchema/App/Dashboard/Report/Action `.create`), in place on the owner through a Proxy, and renders a swallowed raw ZodError through the project's `formatZodError` instead of its JSON array. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/cli/src/utils/config.ts | 185 ++++++++++++++++++++++++++----- 1 file changed, 159 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index ec9f5b86c38..c62ff4fec46 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -268,29 +268,159 @@ const SPEC_MODULE_RE = /^@objectstack\/spec(?:\/[\w./-]+)?$/; /** esbuild namespace the authored-source shim modules live in. */ const AUTHORED_SOURCE_NAMESPACE = 'objectstack-authored-source'; +/** The package root, which carries `formatZodError` for the shim's refusal text. */ +const SPEC_ROOT_MODULE = '@objectstack/spec'; + /** `defineStack`, `defineView`, … — the authoring helpers, by naming convention. */ const DEFINE_HELPER_RE = /^define[A-Z]/; /** - * The `define*` helpers a given `@objectstack/spec` entrypoint exports, read - * from the copy **the config itself would import** (resolved from the config's - * own directory, not the CLI's). - * - * Returns `[]` — i.e. "shim nothing" — when the entrypoint cannot be resolved - * or imported. That is the safe direction: an unshimmed load is exactly - * today's behaviour, so a project the enumeration cannot read is no worse off - * than before. + * One strict authoring factory that is not a `define*` helper: the function + * `member` of the exported value `owner`, which validates its argument AT THE + * CALL and throws on a shape the current schema refuses. + */ +export interface StrictAuthoringFactory { + /** The export that carries the factory: `ObjectSchema`, `App`, … */ + readonly owner: string; + /** The factory on it: `create`. */ + readonly member: string; + /** + * The `@objectstack/spec` entrypoint the entry was measured on. The shim does + * not read it — it wraps the factory on EVERY entrypoint whose `owner` export + * carries the member — and the pin reads it to prove the entry is still live. + */ + readonly home: string; +} + +/** + * Every strict authoring factory `@objectstack/spec` exports besides the + * `define*` helpers: the ONE list the authored-source shim wraps them from. + * + * `ObjectSchema.create(…)` is the authoring spelling of every example app's + * objects, and it is as strict as a `define*` helper — it parses at the call. + * Unwrapped, a retired key inside it aborted `os migrate meta` at load with a + * raw `ZodError` array, while the tombstone it printed told the author to run + * `os migrate meta`. + * + * ## Why a written list and not a pattern + * + * Measured over every JS entrypoint of `@objectstack/spec`: 19 exported values + * carry a `create` member. Five validate — the five below — and fourteen + * (`ApiEndpoint`, `Task`, `RestServerConfig`, …) are identity factories, + * `(config) => config`, that refuse nothing and so have nothing to tolerate. + * The other function members of exported namespaces (`Field.*`, `SCIM.*`, + * `RLS.*`, `OData.*`) build or read values and validate nothing. So: + * + * - a NAME pattern (`*.create`) would wrap fourteen no-ops and still say + * nothing about which factories are strict; + * - a SHAPE enumeration at load cannot even see the one this list exists for: + * `ObjectSchema` is a lazy-schema Proxy whose `ownKeys` trap throws, so + * `Object.keys(ObjectSchema)` never names `create`. + * + * The pin beside this file's tests holds the list to the spec surface in both + * directions: every entry resolves at its `home` and throws on a refused input, + * and every `create` member spec exports that is NOT listed returns its + * argument untouched. A new strict factory in spec therefore reddens the pin + * with its name instead of reopening this defect. + * + * ⛔ Unlisted factories are never wrapped. `ObjectSchema.create` itself stays + * strict everywhere else: this list is read by {@link authoredSourcePlugin} + * alone, which only `os migrate meta` installs. + */ +export const STRICT_AUTHORING_FACTORIES: readonly StrictAuthoringFactory[] = Object.freeze([ + { owner: 'ObjectSchema', member: 'create', home: '@objectstack/spec/data' }, + { owner: 'App', member: 'create', home: '@objectstack/spec/ui' }, + { owner: 'Dashboard', member: 'create', home: '@objectstack/spec/ui' }, + { owner: 'Report', member: 'create', home: '@objectstack/spec/ui' }, + { owner: 'Action', member: 'create', home: '@objectstack/spec/ui' }, +]); + +/** What one `@objectstack/spec` entrypoint gives the shim to wrap. */ +interface AuthoredSourceHelpers { + /** Its `define*` helpers, by {@link DEFINE_HELPER_RE}. */ + readonly defineHelpers: readonly string[]; + /** Its {@link STRICT_AUTHORING_FACTORIES}, as owner export → factory members. */ + readonly factories: ReadonlyMap; +} + +/** + * The strict authoring surface a given `@objectstack/spec` entrypoint exports — + * its `define*` helpers and its {@link STRICT_AUTHORING_FACTORIES} — read from + * the copy **the config itself would import** (resolved from the config's own + * directory, not the CLI's). + * + * A listed factory is read by PROPERTY (`ns[owner][member]`), never by + * enumerating the owner: `ObjectSchema` is a lazy-schema Proxy, and its + * `ownKeys` trap throws. + * + * Returns nothing to wrap — i.e. "shim nothing" — when the entrypoint cannot + * be resolved or imported. That is the safe direction: an unshimmed load is + * exactly today's behaviour, so a project the enumeration cannot read is no + * worse off than before. */ -async function defineHelpersOf(specifier: string, requireFromConfig: NodeRequire): Promise { +async function authoredSourceHelpersOf( + specifier: string, + requireFromConfig: NodeRequire, +): Promise { try { const resolved = requireFromConfig.resolve(specifier); const ns = (await import(pathToFileURL(resolved).href)) as Record; - return Object.keys(ns).filter((k) => DEFINE_HELPER_RE.test(k) && typeof ns[k] === 'function'); + const defineHelpers = Object.keys(ns).filter((k) => DEFINE_HELPER_RE.test(k) && typeof ns[k] === 'function'); + const factories = new Map(); + for (const { owner, member } of STRICT_AUTHORING_FACTORIES) { + const value = ns[owner]; + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) continue; + if (typeof (value as Record)[member] !== 'function') continue; + factories.set(owner, [...(factories.get(owner) ?? []), member]); + } + return { defineHelpers, factories }; } catch { - return []; + return { defineHelpers: [], factories: new Map() }; } } +/** + * The helpers every generated shim module opens with. + * + * `__tolerant` is the try-real-then-authored wrap; `__tolerantMembers` applies + * it to a factory member and hands every other member of the owner through + * untouched — a Proxy rather than a copy, because the owner may itself be a + * lazy-schema Proxy that cannot be enumerated. + * + * `__refusal` renders a raw `ZodError` — whose `message` is its issues as a + * JSON array — through the project's own `formatZodError`, so the swallowed + * verdict reads like the loader's `defineStack validation failed` block rather + * than as a JSON dump. An error that already carries prose keeps its message. + */ +const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ + `const __refusal = (label, error) =>`, + ` error && error.name === 'ZodError' && Array.isArray(error.issues)`, + ` && typeof __specRoot.formatZodError === 'function'`, + ` ? __specRoot.formatZodError(error, label + ' validation failed')`, + ` : (error && error.message) || String(error);`, + `const __tolerant = (label, call) => (...authored) => {`, + ` try {`, + ` return call(...authored);`, + ` } catch (error) {`, + ` console.warn(`, + ` '[authored-source] ' + label + '(): the current schema refuses this '`, + ` + 'artifact, so it is handed to the migration chain exactly as authored. '`, + ` + __refusal(label, error),`, + ` );`, + ` return authored[0];`, + ` }`, + `};`, + `const __tolerantMembers = (owner, ownerName, members) => {`, + ` const wrapped = new Map(members.map((member) => [`, + ` member,`, + ` __tolerant(ownerName + '.' + member, (...authored) => owner[member](...authored)),`, + ` ]));`, + ` return new Proxy(owner, {`, + ` get: (target, prop) => (wrapped.has(prop) ? wrapped.get(prop) : Reflect.get(target, prop)),`, + ` });`, + `};`, +]; + /** * Load an authored config **as authored**, for the one consumer whose input is * a source the CURRENT schema is expected to refuse: the `os migrate meta` @@ -317,7 +447,8 @@ async function defineHelpersOf(specifier: string, requireFromConfig: NodeRequire * * Each `@objectstack/spec` entrypoint the config imports is replaced by a * generated module that re-exports the real one and wraps its `define*` - * helpers as **try-real-then-authored**: + * helpers — and the {@link STRICT_AUTHORING_FACTORIES} it carries, such as + * `ObjectSchema.create` — as **try-real-then-authored**: * * ```js * export const defineView = (...authored) => { @@ -325,6 +456,12 @@ async function defineHelpersOf(specifier: string, requireFromConfig: NodeRequire * }; * ``` * + * A factory is wrapped in place on its owner: `ObjectSchema` stays the real + * schema for every other member (`parse`, `shape`, …), and only `create` is + * tolerant. Both kinds are strict at the call, so both must be wrapped for the + * chain to convert first — a `defineStack` wrap alone never ran, because the + * `ObjectSchema.create(…)` inside its argument threw before it was called. + * * The narrowness is the point, and it is what keeps this a restoration rather * than a widening of what the command accepts: * @@ -370,26 +507,22 @@ function authoredSourcePlugin(configPath: string): Plugin { }); build.onLoad({ filter: /.*/, namespace: AUTHORED_SOURCE_NAMESPACE }, async (args) => { - const helpers = await defineHelpersOf(args.path, requireFromConfig); + const { defineHelpers, factories } = await authoredSourceHelpersOf(args.path, requireFromConfig); const spec = JSON.stringify(args.path); const lines = [ `import * as __real from ${spec};`, + `import * as __specRoot from ${JSON.stringify(SPEC_ROOT_MODULE)};`, `export * from ${spec};`, + ...AUTHORED_SOURCE_PRELUDE, ]; - for (const name of helpers) { + for (const name of defineHelpers) { + lines.push( + `export const ${name} = __tolerant(${JSON.stringify(name)}, (...authored) => __real.${name}(...authored));`, + ); + } + for (const [owner, members] of factories) { lines.push( - `export const ${name} = (...authored) => {`, - ` try {`, - ` return __real.${name}(...authored);`, - ` } catch (error) {`, - ` console.warn(`, - ` '[authored-source] ' + ${JSON.stringify(name)} + '(): the current schema refuses this '`, - ` + 'artifact, so it is handed to the migration chain exactly as authored. '`, - ` + ((error && error.message) || String(error)),`, - ` );`, - ` return authored[0];`, - ` }`, - `};`, + `export const ${owner} = __tolerantMembers(__real.${owner}, ${JSON.stringify(owner)}, ${JSON.stringify(members)});`, ); } return { contents: lines.join('\n'), loader: 'js' }; From 5156f7b149d90a5b287472ab53e42537cfebb3e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 05:23:14 +0000 Subject: [PATCH 2/5] test(cli): pin os migrate meta over the strict authoring factories The card's repro migrates, the plain-literal control is unchanged, an unrelated strict error surfaces as a refusal and never as a raw ZodError array, every listed factory is tolerated through the shim only, and the list is held to the spec surface in both directions. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../migrate-meta-strict-factories.test.ts | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 packages/cli/test/migrate-meta-strict-factories.test.ts diff --git a/packages/cli/test/migrate-meta-strict-factories.test.ts b/packages/cli/test/migrate-meta-strict-factories.test.ts new file mode 100644 index 00000000000..81fffb83148 --- /dev/null +++ b/packages/cli/test/migrate-meta-strict-factories.test.ts @@ -0,0 +1,345 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate meta` over objects built with a STRICT AUTHORING FACTORY — + * `ObjectSchema.create(…)` and its siblings — and the one list the + * authored-source shim wraps them from (`STRICT_AUTHORING_FACTORIES`). + * + * ## The defect + * + * The shim wrapped only `define*` exports. `ObjectSchema.create(…)` parses at + * the call, so an object carrying a retired key threw while the config module + * was being evaluated — inside `defineStack`'s argument, before the wrapped + * `defineStack` was ever called — and the command exited 1 at load with a raw + * `ZodError` array. The tombstone in that array prescribes `os migrate meta`, + * the command that had just refused. The same object written as a plain + * literal migrated cleanly. + * + * ## What is pinned, and how + * + * 1. The card's repro migrates: the conversion applies, `schemaValid: true`. + * 2. The plain-literal control is unchanged, and the factory spelling now + * reaches the SAME applied edits as the literal. + * 3. An unrelated strict error still surfaces as a refusal — in the report's + * verdict group, rendered — and never as a raw `ZodError` array, on any + * stream. The retired key beside it is still converted. + * 4. Every listed factory is tolerated through the shim, the owner's other + * members pass through untouched, and a load WITHOUT the shim still + * refuses: the tolerance is the codemod's alone. + * 5. The list itself: every entry is live at its `home` and strict, and every + * `create` member spec exports that is not listed returns its argument + * untouched — so a new strict factory reddens this pin by name. + * + * In-process over the real command (`MigrateMeta.run`), against a temp project + * that links the real `@objectstack/spec`: the config load, the shim, the chain + * and the verdict are the ones the CLI runs. No process is spawned and no + * kernel is booted, so this file sits in the `unit` tier. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; +import MigrateMeta from '../src/commands/migrate/meta.js'; +import { loadConfig, STRICT_AUTHORING_FACTORIES } from '../src/utils/config.js'; + +const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); +const RUN_TIMEOUT = 120_000; + +/** + * Resolved through node_modules rather than by walking up from this file: + * `packages/cli` already depends on `@objectstack/spec`, so the dependency is + * one turbo already knows about, and a package specifier is not a + * cross-package source read. + */ +const requireFromCli = createRequire(import.meta.url); +const SPEC_PACKAGE_ROOT = dirname(requireFromCli.resolve('@objectstack/spec/package.json')); + +/** A raw `ZodError` array as `ZodError.message` spells it: `[ { "code": "…" … } ]`. */ +const RAW_ZOD_ARRAY = /"code":\s*"/; + +/** The card's repro, verbatim in shape: the object is built by the factory. */ +const FACTORY_REPRO = ` +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema } from '@objectstack/spec/data'; + +export default defineStack({ + objects: [ObjectSchema.create({ + name: 'cr_ticket', + label: 'Ticket', + fields: { title: { type: 'text', label: 'Title' } }, + tenancy: { enabled: true, organizationField: 'organization_id' }, + })], +}); +`; + +/** The card's control: the same object as a plain literal. */ +const LITERAL_CONTROL = ` +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + objects: [{ + name: 'cr_ticket', + label: 'Ticket', + fields: { title: { type: 'text', label: 'Title' } }, + tenancy: { enabled: true, organizationField: 'organization_id' }, + }], +}); +`; + +/** The repro plus a strict error no conversion repairs: an unknown field type. */ +const FACTORY_UNRELATED_ERROR = ` +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema } from '@objectstack/spec/data'; + +export default defineStack({ + objects: [ObjectSchema.create({ + name: 'cr_ticket', + label: 'Ticket', + fields: { + title: { type: 'text', label: 'Title' }, + stage: { type: 'dropdown', label: 'Stage' }, + }, + tenancy: { enabled: true, organizationField: 'organization_id' }, + })], +}); +`; + +/** + * Every listed factory, called with an argument its schema refuses, generated + * FROM the list so a new entry is covered the day it is added. Each call names + * itself in its probe, so a result can be matched to its factory. The last key + * reads another member of the first owner through the shim's Proxy. + */ +function everyFactoryConfig(): string { + const byHome = new Map(); + for (const { owner, home } of STRICT_AUTHORING_FACTORIES) { + const owners = byHome.get(home) ?? []; + if (!owners.includes(owner)) owners.push(owner); + byHome.set(home, owners); + } + const imports = [...byHome].map(([home, owners]) => `import { ${owners.join(', ')} } from '${home}';`); + const calls = STRICT_AUTHORING_FACTORIES.map( + ({ owner, member }) => ` ${owner}.${member}({ __strict_probe__: '${owner}.${member}' }),`, + ); + return [ + ...imports, + '', + 'export default {', + ' results: [', + ...calls, + ' ],', + " untouched: typeof ObjectSchema.safeParse === 'function'", + " && ObjectSchema.safeParse({ name: 'sf_ok', fields: {} }).success,", + '};', + '', + ].join('\n'); +} + +interface Run { + stdout: string; + stderr: string; + exitCode: number | undefined; +} + +let root: string; +let specLink: string; +let caseSeq = 0; + +/** Write `source` as the config of a fresh case directory under the temp project. */ +function writeCase(source: string): string { + const dir = join(root, `case-${++caseSeq}`); + mkdirSync(dir); + const configPath = join(dir, 'objectstack.config.ts'); + writeFileSync(configPath, source); + return configPath; +} + +/** Run the real command in-process, capturing both streams and any exit. */ +async function runMeta(configPath: string, flags: string[]): Promise { + const out: string[] = []; + const err: string[] = []; + const priorExitCode = process.exitCode; + const write = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => { + out.push(String(chunk)); + const done = rest.find((r) => typeof r === 'function') as (() => void) | undefined; + done?.(); + return true; + }) as never); + const log = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { out.push(a.join(' ')); }); + const warn = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); }); + const error = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); }); + let exitCode: number | undefined; + try { + await MigrateMeta.run([configPath, '--from', '17', ...flags], { root: CLI_ROOT }); + } catch (e: any) { + if (typeof e?.oclif?.exit !== 'number') throw e; + exitCode = e.oclif.exit; + } finally { + write.mockRestore(); + log.mockRestore(); + warn.mockRestore(); + error.mockRestore(); + if (exitCode === undefined && typeof process.exitCode === 'number' && process.exitCode !== 0) { + exitCode = process.exitCode; + } + process.exitCode = priorExitCode; + } + return { + stdout: stripVTControlCharacters(out.join('\n')), + stderr: stripVTControlCharacters(err.join('\n')), + exitCode, + }; +} + +function applied(run: Run): Array<{ conversionId: string; path: string }> { + return JSON.parse(run.stdout).applied.map((a: any) => ({ conversionId: a.conversionId, path: a.path })); +} + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-migrate-meta-strict-factories-')); + mkdirSync(join(root, 'node_modules', '@objectstack'), { recursive: true }); + specLink = join(root, 'node_modules', '@objectstack', 'spec'); + symlinkSync(SPEC_PACKAGE_ROOT, specLink, 'dir'); +}); + +afterAll(() => { + // Unlinked BEFORE the recursive remove, and named explicitly: this symlink + // points at the real `packages/spec`, and a cleanup must never be able to + // follow it. + try { unlinkSync(specLink); } catch { /* already gone */ } + try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('os migrate meta over an object built with ObjectSchema.create', () => { + const RETIRED = { conversionId: 'object-tenancy-organization-field-removed', path: 'objects[0].tenancy.organizationField' }; + + it("migrates the card's repro: the conversion applies and the migrated stack is schema-valid", async () => { + const run = await runMeta(writeCase(FACTORY_REPRO), ['--json']); + + expect(run.exitCode, run.stderr).toBeUndefined(); + const payload = JSON.parse(run.stdout); + expect(applied(run)).toContainEqual(RETIRED); + expect(payload.schemaValid).toBe(true); + // The swallowed verdict is announced, by factory name, and rendered. + expect(run.stderr).toContain('[authored-source] ObjectSchema.create(): the current schema refuses this artifact'); + expect(run.stderr).not.toMatch(RAW_ZOD_ARRAY); + }, RUN_TIMEOUT); + + it('leaves the plain-literal control unchanged — and the factory spelling now matches it', async () => { + const literal = await runMeta(writeCase(LITERAL_CONTROL), ['--json']); + const factory = await runMeta(writeCase(FACTORY_REPRO), ['--json']); + + expect(literal.exitCode, literal.stderr).toBeUndefined(); + expect(applied(literal)).toContainEqual(RETIRED); + expect(JSON.parse(literal.stdout).schemaValid).toBe(true); + // No factory was called, so the shim had nothing of the factory's to say. + expect(literal.stderr).not.toContain('ObjectSchema.create'); + + expect(applied(factory)).toEqual(applied(literal)); + }, RUN_TIMEOUT); + + it('surfaces an unrelated strict error as a refusal, never as a raw ZodError array', async () => { + const configPath = writeCase(FACTORY_UNRELATED_ERROR); + const json = await runMeta(configPath, ['--json']); + const human = await runMeta(configPath, []); + + // The load no longer aborts: the retired key is converted… + expect(json.exitCode, json.stderr).toBeUndefined(); + expect(applied(json)).toContainEqual(RETIRED); + // …and what the chain cannot repair is the verdict, not a crash. + expect(JSON.parse(json.stdout).schemaValid).toBe(false); + + expect(human.exitCode, human.stderr).toBeUndefined(); + expect(human.stdout).toMatch(/does not yet pass schema validation — 1 refusal left after the chain/); + expect(human.stdout).toMatch(/✗ objects\.0\.fields\.stage\.type: /); + // The one refusal is the unrelated one; the retired key is not among them. + expect(human.stdout).not.toMatch(/✗ objects\.0\.tenancy/); + + for (const stream of [json.stdout, json.stderr, human.stdout, human.stderr]) { + expect(stream).not.toMatch(RAW_ZOD_ARRAY); + } + }, RUN_TIMEOUT); +}); + +describe('STRICT_AUTHORING_FACTORIES — the one list the shim wraps', () => { + it('tolerates every listed factory through the shim, and only through the shim', async () => { + const configPath = writeCase(everyFactoryConfig()); + + const warnings: string[] = []; + const warn = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { warnings.push(a.join(' ')); }); + let loaded; + try { + loaded = await loadConfig(configPath, { authoredSource: true }); + } finally { + warn.mockRestore(); + } + + // Each refused call is handed on exactly as authored… + expect(loaded.config.results).toEqual( + STRICT_AUTHORING_FACTORIES.map(({ owner, member }) => ({ __strict_probe__: `${owner}.${member}` })), + ); + // …and announced once, by its own name. + for (const { owner, member } of STRICT_AUTHORING_FACTORIES) { + expect(warnings.filter((w) => w.startsWith(`[authored-source] ${owner}.${member}(): `))).toHaveLength(1); + } + // The owner is still the real schema for every other member. + expect(loaded.config.untouched).toBe(true); + + // Without the shim the same source is refused at load, as every other + // command must keep hearing it. + await expect(loadConfig(configPath)).rejects.toThrow(); + }, RUN_TIMEOUT); + + it('lists only live, strict factories, and every unlisted spec `create` is an identity factory', async () => { + const spec = requireFromCli('@objectstack/spec/package.json') as { exports: Record }; + const entrypoints = Object.keys(spec.exports) + .filter((key) => !key.endsWith('.json')) + .map((key) => (key === '.' ? '@objectstack/spec' : `@objectstack/spec/${key.slice(2)}`)); + + const strictFound = new Set(); + const identity: string[] = []; + const neither: string[] = []; + for (const entrypoint of entrypoints) { + const ns = (await import(pathToFileURL(requireFromCli.resolve(entrypoint)).href)) as Record; + for (const [owner, value] of Object.entries(ns)) { + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) continue; + // By property, never by enumerating the owner — see the list's docblock. + const create = (value as { create?: unknown }).create; + if (typeof create !== 'function') continue; + const listed = STRICT_AUTHORING_FACTORIES.some((f) => f.owner === owner && f.member === 'create'); + const probe = { __strict_probe__: `${entrypoint}#${owner}.create` }; + let threw = false; + let returned: unknown; + try { + returned = create.call(value, probe); + } catch { + threw = true; + } + if (listed && threw) strictFound.add(owner); + else if (!listed && !threw && returned === probe) identity.push(`${entrypoint}#${owner}`); + else neither.push(`${entrypoint}#${owner}.create (listed: ${listed}, threw: ${threw})`); + } + } + + // A strict factory spec exports but the list does not name — or a listed + // one that stopped refusing — lands here by name. + expect(neither, 'add a strict `create` to STRICT_AUTHORING_FACTORIES, or drop an entry that no longer refuses').toEqual([]); + // The enumeration is not vacuous: it found every listed owner, strict… + expect([...strictFound].sort()).toEqual( + [...new Set(STRICT_AUTHORING_FACTORIES.filter((f) => f.member === 'create').map((f) => f.owner))].sort(), + ); + // …and at least one identity factory to tell them apart from. + expect(identity.length).toBeGreaterThan(0); + + // Every entry is live at the entrypoint it names. + for (const { owner, member, home } of STRICT_AUTHORING_FACTORIES) { + const ns = (await import(pathToFileURL(requireFromCli.resolve(home)).href)) as Record; + expect(typeof ns[owner]?.[member], `${home}#${owner}.${member}`).toBe('function'); + expect(() => ns[owner][member]({ __strict_probe__: home }), `${home}#${owner}.${member}`).toThrow(); + } + }, RUN_TIMEOUT); +}); From 794971ded6c744952bbf9a33d1c29cd1d1999a88 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 05:28:44 +0000 Subject: [PATCH 3/5] test(cli): name the missing payload and bind the member probe independently of the list Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../cli/test/migrate-meta-strict-factories.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/migrate-meta-strict-factories.test.ts b/packages/cli/test/migrate-meta-strict-factories.test.ts index 81fffb83148..509f1770233 100644 --- a/packages/cli/test/migrate-meta-strict-factories.test.ts +++ b/packages/cli/test/migrate-meta-strict-factories.test.ts @@ -112,7 +112,7 @@ export default defineStack({ * Every listed factory, called with an argument its schema refuses, generated * FROM the list so a new entry is covered the day it is added. Each call names * itself in its probe, so a result can be matched to its factory. The last key - * reads another member of the first owner through the shim's Proxy. + * reads another member of `ObjectSchema` through the shim's Proxy. */ function everyFactoryConfig(): string { const byHome = new Map(); @@ -127,13 +127,16 @@ function everyFactoryConfig(): string { ); return [ ...imports, + // A second, aliased binding of the same export, so the member read below + // never depends on which owners the list happens to name. + "import { ObjectSchema as __ObjectSchemaMembers } from '@objectstack/spec/data';", '', 'export default {', ' results: [', ...calls, ' ],', - " untouched: typeof ObjectSchema.safeParse === 'function'", - " && ObjectSchema.safeParse({ name: 'sf_ok', fields: {} }).success,", + " untouched: typeof __ObjectSchemaMembers.safeParse === 'function'", + " && __ObjectSchemaMembers.safeParse({ name: 'sf_ok', fields: {} }).success,", '};', '', ].join('\n'); @@ -196,7 +199,9 @@ async function runMeta(configPath: string, flags: string[]): Promise { } function applied(run: Run): Array<{ conversionId: string; path: string }> { - return JSON.parse(run.stdout).applied.map((a: any) => ({ conversionId: a.conversionId, path: a.path })); + const payload = JSON.parse(run.stdout); + expect(Array.isArray(payload.applied), `no \`applied\` in the --json payload: ${run.stdout}`).toBe(true); + return payload.applied.map((a: any) => ({ conversionId: a.conversionId, path: a.path })); } beforeAll(() => { From 888e0c78d026ed097d7f6f8deeade6e55699dc2f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 05:48:18 +0000 Subject: [PATCH 4/5] chore(changeset): os migrate meta converts objects built with the strict factories Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../20696-migrate-meta-strict-factories.md | 54 +++++++++++++++++++ packages/cli/src/utils/config.ts | 3 +- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 .changeset/20696-migrate-meta-strict-factories.md diff --git a/.changeset/20696-migrate-meta-strict-factories.md b/.changeset/20696-migrate-meta-strict-factories.md new file mode 100644 index 00000000000..e90bba3ba15 --- /dev/null +++ b/.changeset/20696-migrate-meta-strict-factories.md @@ -0,0 +1,54 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os migrate meta` converts an object built with `ObjectSchema.create(…)` instead of stopping at load when the object carries a retired key + +Clause-②: no + +`os migrate meta` reads a config the current schema refuses, so it can rewrite the +retired keys in it. It did that for artifacts built with a `define*` helper and for +plain object literals. It did not do it for artifacts built with a factory such as +`ObjectSchema.create(…)`, which validates when it is called. An object like this: + +```ts +ObjectSchema.create({ + name: 'ticket', + fields: { title: { type: 'text' } }, + tenancy: { enabled: true, organizationField: 'organization_id' }, +}) +``` + +stopped `os migrate meta --from 17` at load with exit 1 and a raw JSON array of +validation issues. The message in that array told the author to run +`os migrate meta --from 17`. + +The command now loads it, applies the conversion (here +`object-tenancy-organization-field-removed`), and reports `schemaValid` for the +migrated stack, exactly as it does for the same object written as a plain literal. +This covers the five factories in `@objectstack/spec` that validate when called: +`ObjectSchema.create` (`@objectstack/spec/data`) and `App.create`, +`Dashboard.create`, `Report.create` and `Action.create` (`@objectstack/spec/ui`). +The other `create` factories spec exports return their argument unchanged and +never refused anything, so nothing changes for them. + +A schema problem the migration cannot fix is still reported: it is listed among +the refusals under the verdict, and `schemaValid` is `false`. A check that only +the factory makes when it is called, such as `ObjectSchema.create` refusing a +`managedBy: 'system-data'` object that grants no create, edit or delete, is not +part of the stack schema. It is reported on the stderr line described below and +does not change `schemaValid`, the same as `defineStack`'s own call-time checks. +`os validate` still refuses it. + +While the config loads, `os migrate meta` prints one stderr line for each +artifact the current schema refused. A raw validation error on that line is now +printed as a block, for example `ObjectSchema.create validation failed (1 issue):` +followed by one `✗ path: message` line per issue, instead of a raw JSON array. +This also applies to `define*` helpers that throw a raw validation error, such +as `defineAgent`. + +Nothing else changes. `os validate`, `os build` and every other command still +refuse the retired key at load, with the same message. `ObjectSchema.create` and +the other factories stay strict everywhere outside `os migrate meta`. The keys +of the `--json` payload are unchanged, and a run whose migrated stack does not +parse still exits 0. diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index c62ff4fec46..4cbc236c8fe 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -257,7 +257,8 @@ export function resolveConfigPath(source?: string): string { /** * Every `@objectstack/spec` entrypoint an authored config can reach the - * `define*` helpers through — the root and every subpath export. Real projects + * `define*` helpers and the {@link STRICT_AUTHORING_FACTORIES} through — the + * root and every subpath export. Real projects * use both: the example apps import `defineView`/`defineApp` from * `@objectstack/spec/ui` and `defineHook`/`defineDatasource` from * `@objectstack/spec/data`, so a shim that knew only the root package would From 3d877c6d452377a6e0c69092285a0adf8fcfaf04 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 07:06:51 +0000 Subject: [PATCH 5/5] docs(cli): state the measured create-factory population exactly (18 values, 31 names, 5 strict, 13 identity) Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/cli/src/utils/config.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 4cbc236c8fe..f6aa4e53d9c 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -305,14 +305,16 @@ export interface StrictAuthoringFactory { * * ## Why a written list and not a pattern * - * Measured over every JS entrypoint of `@objectstack/spec`: 19 exported values - * carry a `create` member. Five validate — the five below — and fourteen - * (`ApiEndpoint`, `Task`, `RestServerConfig`, …) are identity factories, - * `(config) => config`, that refuse nothing and so have nothing to tolerate. + * Measured over all 19 JS entrypoints of `@objectstack/spec`: 18 distinct + * exported values carry a `create` member (31 export names — each identity + * factory is exported a second time as its `*Schema`). Five validate — the + * five below — and thirteen (`ApiEndpoint`, `Task`, `RestServerConfig`, …) are + * identity factories, `(config) => config`, that refuse nothing and so have + * nothing to tolerate. * The other function members of exported namespaces (`Field.*`, `SCIM.*`, * `RLS.*`, `OData.*`) build or read values and validate nothing. So: * - * - a NAME pattern (`*.create`) would wrap fourteen no-ops and still say + * - a NAME pattern (`*.create`) would wrap thirteen no-ops and still say * nothing about which factories are strict; * - a SHAPE enumeration at load cannot even see the one this list exists for: * `ObjectSchema` is a lazy-schema Proxy whose `ownKeys` trap throws, so