Skip to content

Commit 261520d

Browse files
committed
feat(migrate,objectql): the upgrade path names the data migrations still open here (#3438)
Both value-shape gates fail toward leniency — a deployment that never runs its migration keeps warning instead of rejecting, and keeps every released file forever. That default is right and completely silent, so a gate could sit open for the life of a deployment without anyone learning one command ends it. Two announcements, each where an upgrade actually looks: - `os migrate meta --from 16` ends by naming the data migrations a chain crossing into 17 leaves behind, scoped to the field classes the author's own metadata declares; --json carries the same list as `dataMigrations`. The command reads no database, so it reports what remains to do and never claims what a deployment has done — which is why the ADR's "with their gate status" lands on the other half. - The server logs one line per open gate at boot, naming the command that closes it. Only the lax posture announces itself; a verified gate already logs that it enforces, and an app declaring neither class costs nothing and says nothing. On `kernel:bootstrapped`, not `kernel:ready`: the answer depends on the storage service's own ready handler, which registers sys_migration and may attest a store it just created — racing it would tell a brand-new deployment its gates are open moments after they closed. Enforcement is unchanged: same gates, same flags, same default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KM46HedAWLbkuZyMBXn5wJ
1 parent 19365b7 commit 261520d

7 files changed

Lines changed: 366 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(migrate,objectql): the upgrade path names the data migrations that are still open here (#3438, ADR-0104 2026-07-30)
7+
8+
Both value-shape gates fail toward leniency: a deployment that never runs its
9+
migration keeps warning instead of rejecting, and keeps every released file
10+
forever. That default is right — and completely silent, so the gate could sit
11+
open for the life of a deployment without anyone learning that one command ends
12+
it. A gate nobody is told about is served by nobody.
13+
14+
Two announcements, each where an upgrade actually looks:
15+
16+
- **`os migrate meta --from 16`** now ends by naming the data migrations a
17+
chain crossing into 17 leaves behind — `files-to-references`, `value-shapes`
18+
— with what each unlocks, scoped to the field classes the author's own
19+
metadata declares (an app with no media field is never told about the file
20+
migration). `--json` carries the same list as `dataMigrations`. The command
21+
reads no database, so it reports what remains *to do*, never what a given
22+
deployment has *done*.
23+
- **The server logs one line per open gate at boot**, naming the command that
24+
closes it. Only the lax posture announces itself — a verified gate already
25+
logs that it is enforcing, and an app declaring neither class of field costs
26+
nothing and says nothing. This is the half that can speak to a deployment's
27+
actual data, because it is the half with the database.
28+
29+
Nothing about enforcement changes: same gates, same flags, same fail-toward-
30+
leniency default. The advisory runs on `kernel:bootstrapped` rather than
31+
`kernel:ready`, deliberately — the answer depends on the storage service's own
32+
ready handler, which registers `sys_migration` and may attest a store it just
33+
created, and racing it would tell a brand-new deployment its gates are open
34+
moments after they closed.

content/docs/deployment/cli.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,22 @@ them temporarily, `OS_ALLOW_LAX_MEDIA_VALUES=1` (media) and
745745
per class, and re-running the corresponding migration re-establishes its flag
746746
from the data itself.
747747

748+
#### How you find out a gate is open
749+
750+
You are told, in the two places an upgrade actually looks.
751+
752+
**`os migrate meta --from 16`** — the metadata half of the upgrade — ends by
753+
naming the data migrations that remain, scoped to the field classes *your*
754+
metadata declares. It reads no database, so it reports what is left to do,
755+
never what this deployment has already done. The machine-readable output
756+
carries the same list under `dataMigrations`.
757+
758+
**The server, once per boot**, logs one line per gate that is still open here
759+
and the command that closes it. Only the lax posture announces itself: a
760+
closed gate logs that it is enforcing, and an app that declares neither class
761+
of field says nothing at all. So a running deployment always tells you the
762+
state of its own data — which is the question `os migrate meta` cannot answer.
763+
748764
### Scaffolding
749765

750766
| Command | Alias | Description |

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
MigrationFloorError,
1313
} from '@objectstack/spec';
1414
import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
15+
import { FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, STRUCTURED_JSON_TYPES } from '@objectstack/spec/data';
16+
import { FILE_REFERENCES_MIGRATION_ID, VALUE_SHAPES_MIGRATION_ID } from '@objectstack/spec/system';
1517
import { loadConfig } from '../../utils/config.js';
1618
import {
1719
printHeader,
@@ -24,6 +26,86 @@ import {
2426
emitJson,
2527
} from '../../utils/format.js';
2628

29+
/** The protocol major that introduced the per-deployment value-shape gates. */
30+
const VALUE_SHAPE_GATE_MAJOR = 17;
31+
32+
interface PendingDataMigration {
33+
/** `sys_migration` row id the run records. */
34+
id: string;
35+
command: string;
36+
/** What staying un-run costs, in this deployment's terms. */
37+
unlocks: string;
38+
}
39+
40+
/**
41+
* The DATA migrations a metadata chain crossing into {@link VALUE_SHAPE_GATE_MAJOR}
42+
* leaves for the operator (ADR-0104's 2026-07-30 addendum, #3438).
43+
*
44+
* Metadata migration and data migration are different jobs with different
45+
* subjects: this command rewrites an author's source, while these two rewrite
46+
* (or vouch for) a deployment's rows, one deployment at a time. Nothing here
47+
* can run them, and — with no database in reach — nothing here can say whether
48+
* they have run; the booting server reports that. What this can do is make
49+
* sure the upgrade never *ends* without naming them, because a gate nobody is
50+
* told about is served by nobody.
51+
*
52+
* Listed only when the author's own metadata declares the field classes each
53+
* gate is about, so the advice is never noise.
54+
*/
55+
function pendingDataMigrations(stack: any, fromMajor: number, toMajor: number): PendingDataMigration[] {
56+
if (!(fromMajor < VALUE_SHAPE_GATE_MAJOR && toMajor >= VALUE_SHAPE_GATE_MAJOR)) return [];
57+
58+
let media = false;
59+
let covered = false;
60+
for (const obj of (Array.isArray(stack?.objects) ? stack.objects : []) as any[]) {
61+
for (const def of Object.values(obj?.fields ?? {}) as any[]) {
62+
if (!def?.type) continue;
63+
if (FILE_REFERENCE_TYPES.has(def.type)) media = true;
64+
else if (REFERENCE_VALUE_TYPES.has(def.type) || STRUCTURED_JSON_TYPES.has(def.type)) covered = true;
65+
}
66+
if (media && covered) break;
67+
}
68+
69+
const pending: PendingDataMigration[] = [];
70+
if (media) {
71+
pending.push({
72+
id: FILE_REFERENCES_MIGRATION_ID,
73+
command: 'os migrate files-to-references',
74+
unlocks:
75+
'converts legacy file values to sys_file references, then enforces media value shapes ' +
76+
'and lets released files be collected. Until it passes here, media values only warn and ' +
77+
'released files are kept forever.',
78+
});
79+
}
80+
if (covered) {
81+
pending.push({
82+
id: VALUE_SHAPES_MIGRATION_ID,
83+
command: 'os migrate value-shapes',
84+
unlocks:
85+
'scans stored reference and structured-JSON values against their field contracts. ' +
86+
'Until it passes here, a malformed value only warns.',
87+
});
88+
}
89+
return pending;
90+
}
91+
92+
/** Print the data-migration advice — the last thing a crossing upgrade sees. */
93+
function printPendingDataMigrations(pending: PendingDataMigration[]): void {
94+
if (pending.length === 0) return;
95+
console.log(chalk.bold(' Then, against each deployment\'s database:'));
96+
for (const m of pending) {
97+
console.log(` ${chalk.cyan('→')} ${chalk.white(m.command)}`);
98+
console.log(chalk.dim(` ${m.unlocks}`));
99+
}
100+
console.log(
101+
chalk.dim(
102+
' Both are dry-run by default and report what they would do; `--apply` is the only ' +
103+
'writing mode. Not running them is safe — enforcement simply stays off here.',
104+
),
105+
);
106+
console.log('');
107+
}
108+
27109
/**
28110
* `os migrate meta --from N` — replay the ADR-0087 D3 migration chain.
29111
*
@@ -94,6 +176,7 @@ export default class MigrateMeta extends Command {
94176
// diff" the consumer agent reviews (ADR-0087 D3/D5).
95177
const parsed = ObjectStackDefinitionSchema.safeParse(result.stack);
96178
const specChanges = composeSpecChanges(flags.from, toMajor);
179+
const dataMigrations = pendingDataMigrations(result.stack, result.fromMajor, result.toMajor);
97180

98181
if (flags.json) {
99182
await emitJson({
@@ -112,6 +195,9 @@ export default class MigrateMeta extends Command {
112195
: undefined,
113196
specChanges,
114197
schemaValid: parsed.success,
198+
// Per-deployment data migrations this chain leaves to the
199+
// operator — the metadata is only half of a crossing upgrade.
200+
dataMigrations,
115201
duration: timer.elapsed(),
116202
});
117203
if (flags.out) writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2));
@@ -124,6 +210,10 @@ export default class MigrateMeta extends Command {
124210

125211
if (result.applied.length === 0 && result.todos.length === 0) {
126212
printSuccess('Nothing to migrate — the metadata is already canonical for this range.');
213+
// Still advertise: metadata needing no rewrite says nothing about
214+
// whether this deployment's DATA has been migrated.
215+
console.log('');
216+
printPendingDataMigrations(dataMigrations);
127217
return;
128218
}
129219

@@ -162,6 +252,8 @@ export default class MigrateMeta extends Command {
162252
printInfo(`Wrote migrated stack snapshot → ${chalk.white(resolve(flags.out))}`);
163253
}
164254

255+
printPendingDataMigrations(dataMigrations);
256+
165257
if (parsed.success) {
166258
printSuccess(`Migrated stack is schema-valid ${chalk.dim(`(${timer.display()})`)}`);
167259
} else {

packages/cli/test/migrate-meta.e2e.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,76 @@ describe('os migrate meta --from 16 (e2e over the real CLI)', () => {
230230
}
231231
}, 120_000);
232232

233+
/**
234+
* ADR-0104 / #3438. The metadata half of a 16→17 upgrade is only half of it:
235+
* two DATA migrations gate enforcement per deployment, and a gate nobody is
236+
* told about is served by nobody. The advice is scoped to the field classes
237+
* the author actually declares, so it is never noise.
238+
*/
239+
describe('per-deployment data migrations (#3438)', () => {
240+
const withFields = (fields: string) => `
241+
export default {
242+
name: 'dm_probe', label: 'DM Probe',
243+
objects: [{ name: 'dm_thing', label: 'Thing', fields: { ${fields} } }],
244+
};
245+
`;
246+
247+
async function runJson(fields: string, args: string[] = ['--from', '16', '--json']) {
248+
const d = mkdtempSync(join(tmpdir(), 'os-migrate-meta-dm-'));
249+
try {
250+
writeFileSync(join(d, 'objectstack.config.ts'), withFields(fields));
251+
return JSON.parse(await runMeta(args, d));
252+
} finally {
253+
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
254+
}
255+
}
256+
257+
it('names the file migration when the metadata declares a media field', async () => {
258+
const res = await runJson(`cover: { type: 'image', label: 'Cover' }`);
259+
expect(res.dataMigrations.map((m: any) => m.id)).toEqual(['adr-0104-file-references']);
260+
}, 120_000);
261+
262+
it('names the value-shape scan when the metadata declares a covered field', async () => {
263+
const res = await runJson(`spot: { type: 'location', label: 'Spot' }`);
264+
expect(res.dataMigrations.map((m: any) => m.id)).toEqual(['adr-0104-value-shapes']);
265+
}, 120_000);
266+
267+
it('names both when both classes are declared', async () => {
268+
const res = await runJson(
269+
`cover: { type: 'image', label: 'Cover' }, spot: { type: 'location', label: 'Spot' }`,
270+
);
271+
expect(res.dataMigrations.map((m: any) => m.id).sort()).toEqual([
272+
'adr-0104-file-references',
273+
'adr-0104-value-shapes',
274+
]);
275+
}, 120_000);
276+
277+
it('stays silent for metadata that declares neither class', async () => {
278+
const res = await runJson(`title: { type: 'text', label: 'Title' }`);
279+
expect(res.dataMigrations).toEqual([]);
280+
}, 120_000);
281+
282+
it('says nothing on a chain that does not cross into 17', async () => {
283+
const res = await runJson(`cover: { type: 'image', label: 'Cover' }`, ['--from', '17', '--json']);
284+
expect(res.dataMigrations).toEqual([]);
285+
}, 120_000);
286+
287+
it('ends the human-readable upgrade with the advice, not just the JSON', async () => {
288+
// Where an operator actually reads it. The advice names the command and
289+
// says the run is dry by default, so nothing here reads as "do this and
290+
// something irreversible happens".
291+
const d = mkdtempSync(join(tmpdir(), 'os-migrate-meta-dm-h-'));
292+
try {
293+
writeFileSync(join(d, 'objectstack.config.ts'), withFields(`cover: { type: 'image', label: 'Cover' }`));
294+
const stdout = await runMeta(['--from', '16'], d);
295+
expect(stdout).toMatch(/os migrate files-to-references/);
296+
expect(stdout).toMatch(/dry-run by default/);
297+
} finally {
298+
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
299+
}
300+
}, 120_000);
301+
});
302+
233303
it('refuses a --from below the support floor with the structured error', async () => {
234304
let failed = false;
235305
try {

packages/objectql/src/engine.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ vi.mock('./registry', () => {
1818
const instance: any = {
1919
getObject: vi.fn((name) => mockObjects.get(name)),
2020
resolveObject: vi.fn((name) => mockObjects.get(name)),
21+
getAllObjects: vi.fn(() => [...mockObjects.values()]),
2122
registerObject: vi.fn((obj, packageId, namespace, ownership, priority) => {
2223
const fqn = namespace ? `${namespace}__${obj.name}` : obj.name;
2324
mockObjects.set(fqn, { ...obj, name: fqn });
@@ -2318,4 +2319,96 @@ describe('ObjectQL — file-as-reference migration flag (#3617)', () => {
23182319
expect(engine.wasDatastoreCreatedFromEmpty()).toBe(false);
23192320
});
23202321
});
2322+
2323+
/**
2324+
* ADR-0104 / #3438. Both gates fail toward leniency, which is right and
2325+
* silent — so the LAX posture is the one that has to announce itself, or the
2326+
* deployment never learns a command would end it. Scoped to what this
2327+
* deployment's own metadata declares, so it is never noise.
2328+
*/
2329+
describe('announceOpenMigrationGates', () => {
2330+
const registerObjects = (objects: Record<string, any>) => {
2331+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name: string) => objects[name]);
2332+
vi.mocked((SchemaRegistry as any).getAllObjects).mockImplementation(() => Object.values(objects));
2333+
};
2334+
const SYS_MIGRATION = { name: 'sys_migration', fields: { id: { type: 'text' } } };
2335+
const lines = (info: any) => info.mock.calls.map((c: any[]) => String(c[0])).join('\n');
2336+
2337+
it('names the command that closes an open value-shape gate', async () => {
2338+
registerObjects({
2339+
place: { name: 'place', fields: { spot: { type: 'location' } } },
2340+
sys_migration: SYS_MIGRATION,
2341+
});
2342+
vi.mocked(driver.find).mockResolvedValue([]); // no flag row → gate open
2343+
const info = vi.spyOn((engine as any).logger, 'info');
2344+
2345+
await engine.announceOpenMigrationGates();
2346+
2347+
expect(lines(info)).toMatch(/os migrate value-shapes/);
2348+
});
2349+
2350+
it('names the file migration for an app that stores media', async () => {
2351+
registerObjects({
2352+
note: { name: 'note', fields: { doc: { type: 'file' } } },
2353+
sys_migration: SYS_MIGRATION,
2354+
});
2355+
vi.mocked(driver.find).mockResolvedValue([]);
2356+
const info = vi.spyOn((engine as any).logger, 'info');
2357+
2358+
await engine.announceOpenMigrationGates();
2359+
2360+
const said = lines(info);
2361+
expect(said).toMatch(/os migrate files-to-references/);
2362+
expect(said).not.toMatch(/os migrate value-shapes/);
2363+
});
2364+
2365+
it('says nothing about a gate this deployment has closed', async () => {
2366+
registerObjects({
2367+
note: { name: 'note', fields: { doc: { type: 'file' } } },
2368+
sys_migration: SYS_MIGRATION,
2369+
});
2370+
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2371+
const info = vi.spyOn((engine as any).logger, 'info');
2372+
2373+
await engine.announceOpenMigrationGates();
2374+
2375+
expect(lines(info)).not.toMatch(/os migrate files-to-references/);
2376+
});
2377+
2378+
it('stays silent — and costs no query — for an app declaring neither class', async () => {
2379+
registerObjects({
2380+
tag: { name: 'tag', fields: { label: { type: 'text' } } },
2381+
sys_migration: SYS_MIGRATION,
2382+
});
2383+
const info = vi.spyOn((engine as any).logger, 'info');
2384+
2385+
await engine.announceOpenMigrationGates();
2386+
2387+
expect(lines(info)).not.toMatch(/os migrate/);
2388+
expect(driver.find).not.toHaveBeenCalled();
2389+
});
2390+
2391+
it('announces once per process, not once per caller', async () => {
2392+
registerObjects({
2393+
place: { name: 'place', fields: { spot: { type: 'location' } } },
2394+
sys_migration: SYS_MIGRATION,
2395+
});
2396+
vi.mocked(driver.find).mockResolvedValue([]);
2397+
const info = vi.spyOn((engine as any).logger, 'info');
2398+
2399+
await engine.announceOpenMigrationGates();
2400+
const first = info.mock.calls.length;
2401+
await engine.announceOpenMigrationGates();
2402+
2403+
expect(info.mock.calls.length).toBe(first);
2404+
});
2405+
2406+
it('never throws into a boot when the registry cannot be read', async () => {
2407+
vi.mocked((SchemaRegistry as any).getAllObjects).mockImplementation(() => {
2408+
throw new Error('registry exploded');
2409+
});
2410+
2411+
await expect(engine.announceOpenMigrationGates()).resolves.toBeUndefined();
2412+
});
2413+
});
23212414
});

0 commit comments

Comments
 (0)