Skip to content

Commit f76afd3

Browse files
committed
wip(cli): os migrate stored-expressions command
Claude-Session: https://claude.ai/code/session_01Utp6Cb2FL7H75wB1wA6PpZ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0e473fe commit f76afd3

2 files changed

Lines changed: 269 additions & 3 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Command, Flags } from '@oclif/core';
4+
import chalk from 'chalk';
5+
import { createInterface } from 'node:readline';
6+
import {
7+
printHeader,
8+
printSuccess,
9+
printWarning,
10+
printError,
11+
printInfo,
12+
printStep,
13+
createTimer,
14+
emitJson,
15+
isExitSignal,
16+
errorCodeFields,
17+
} from '../../utils/format.js';
18+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
19+
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
20+
import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
21+
import { describeDriverSqliteFile } from '../../utils/connection-display.js';
22+
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
23+
import { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js';
24+
// Type-only: the engine package is imported dynamically inside run(), never at
25+
// module load, so `os --help` and every other command pay nothing for it.
26+
import type { StoredExpressionBackfillEngine } from '@objectstack/objectql';
27+
28+
async function confirm(question: string): Promise<boolean> {
29+
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
30+
const rl = createInterface({ input: process.stdin, output: process.stdout });
31+
try {
32+
const answer: string = await new Promise((resolve) => rl.question(question, resolve));
33+
return /^y(es)?$/i.test(answer.trim());
34+
} finally {
35+
rl.close();
36+
}
37+
}
38+
39+
/**
40+
* `os migrate stored-expressions` — fill the stored computed fields (a
41+
* non-`formula` field declaring `expression`) of rows no write has computed
42+
* since the field was declared.
43+
*
44+
* The engine computes such a field on every insert and update, and on nothing
45+
* else, so a field declared on an object that already holds rows is NULL on
46+
* every one of them until each is next written. This command computes each
47+
* through the write's own computation and writes it; the boot names it when
48+
* a sample of rows holds such a NULL.
49+
*
50+
* The command is the shell; the walk, its refusals and its report are
51+
* `@objectstack/objectql`'s `backfillStoredExpressions` — the split
52+
* `os migrate summary-nulls` has with `summary-backfill.ts`.
53+
*
54+
* Dry run by default (writes no row), `--apply` to write. `--changed` also
55+
* rewrites values that differ from the expression — the run to make after an
56+
* expression changed; every run counts them. Rows on which an expression does
57+
* not evaluate are listed; one that holds a value there is skipped, because
58+
* the write would store NULL over it. Idempotent: a re-run with nothing
59+
* listed is the verification. No `sys_migration` flag is recorded: nothing
60+
* gates on the run.
61+
*/
62+
export default class MigrateStoredExpressions extends Command {
63+
static override description =
64+
'Fill stored computed fields (fields declaring an expression, other than formula fields) on rows no write has ' +
65+
'computed since the field was declared — they hold NULL until each row is next written. Dry run by default; ' +
66+
'--apply writes, --changed also recomputes values that differ from a changed expression.';
67+
68+
static override examples = [
69+
'$ os migrate stored-expressions',
70+
'$ os migrate stored-expressions --object contract',
71+
'$ os migrate stored-expressions --object contract --field review_sla_days_offset --apply',
72+
'$ os migrate stored-expressions --changed --apply --yes --json',
73+
];
74+
75+
static override flags = {
76+
'database-url': Flags.string({
77+
description: 'Database URL to migrate (defaults to $OS_DATABASE_URL / the project DB)',
78+
env: 'OS_DATABASE_URL',
79+
}),
80+
object: Flags.string({
81+
description:
82+
'Restrict to this object (repeatable; default: every object declaring a stored computed field). A name the ' +
83+
'deployment does not declare is refused',
84+
multiple: true,
85+
}),
86+
field: Flags.string({
87+
description: 'One stored computed field of the one --object (default: every one it declares)',
88+
dependsOn: ['object'],
89+
}),
90+
changed: Flags.boolean({
91+
description:
92+
'Also recompute rows whose stored value differs from the expression — after an expression changed. An ' +
93+
'expression reading the clock (now(), today()) recomputes to the current time',
94+
default: false,
95+
}),
96+
apply: Flags.boolean({ description: 'Write the computed values (default is a read-only dry run)', default: false }),
97+
yes: Flags.boolean({ char: 'y', description: 'Skip the --apply confirmation prompt', default: false }),
98+
force: Flags.boolean({
99+
description: 'Apply even when another process is using the database (SQLite occupancy check)',
100+
default: false,
101+
}),
102+
'max-records': Flags.integer({ description: 'Safety bound on rows read per object — exceeding it truncates the walk' }),
103+
json: Flags.boolean({ description: 'Output as JSON (implies non-interactive; requires --yes to apply)' }),
104+
};
105+
106+
async run(): Promise<void> {
107+
const { flags } = await this.parse(MigrateStoredExpressions);
108+
const timer = createTimer();
109+
const apply = flags.apply;
110+
111+
if (!flags.json) printHeader('Migrate · stored-expressions');
112+
113+
// Occupancy gate, as `summary-nulls`: an apply rewrites ROWS, so a second writer on the same
114+
// SQLite file is a real hazard. Probed before boot and before the prompt.
115+
const occupancy = await probeMigrationTarget(flags['database-url']);
116+
if (occupancy.status === 'busy' && apply && !flags.force) {
117+
if (flags.json) {
118+
await emitJson({
119+
error: 'database_busy',
120+
database: occupancy.filename,
121+
signal: occupancy.signal,
122+
detail: occupancy.detail,
123+
hint: OCCUPANCY_HINT,
124+
}, 0, { compact: true });
125+
this.exit(1);
126+
return;
127+
}
128+
printError(describeOccupancy(occupancy));
129+
printWarning(OCCUPANCY_HINT);
130+
this.exit(1);
131+
return;
132+
}
133+
if (occupancy.status === 'busy' && !flags.json) {
134+
printWarning(apply
135+
? `--force: ${describeOccupancy(occupancy)} Filling anyway — the live process may write rows mid-walk.`
136+
: `${describeOccupancy(occupancy)} The dry run below writes no row or table, but its counts may shift while that process is running. ${JOURNAL_MODE_SWITCH_NOTE}`);
137+
}
138+
if (occupancy.status === 'unknown' && !flags.json) {
139+
printWarning(`Could not check whether the database is in use — ${occupancy.detail}`);
140+
}
141+
142+
if (apply && !flags.yes) {
143+
if (flags.json || !process.stdin.isTTY) {
144+
if (flags.json) {
145+
await emitJson({ error: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true });
146+
this.exit(1);
147+
return;
148+
}
149+
printWarning('Apply mode rewrites record data. Re-run with --yes to confirm, or run without --apply to preview.');
150+
this.exit(1);
151+
return;
152+
}
153+
const ok = await confirm(
154+
chalk.bold(
155+
`\nCompute and write the stored computed fields ${flags.changed ? 'that are NULL or differ from their expression' : 'still NULL'}` +
156+
`${flags.object ? ` of ${flags.object.join(', ')}` : ''} on this database? [y/N] `,
157+
),
158+
);
159+
if (!ok) {
160+
printInfo('Aborted — no changes made.');
161+
return;
162+
}
163+
}
164+
165+
if (!flags.json) printStep(apply ? 'Booting data stack (APPLY mode)…' : 'Booting data stack (dry run)…');
166+
167+
let stack;
168+
try {
169+
// The dry run boots read-only, as `summary-nulls` does; `--apply` keeps
170+
// the plain boot, because the tables and columns must exist before it writes.
171+
stack = await bootSchemaStack({
172+
jsonOutput: flags.json,
173+
databaseUrl: flags['database-url'],
174+
extraPlugins: await buildDataMigrationPlugins(),
175+
...(apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }),
176+
});
177+
} catch (error: any) {
178+
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }
179+
printError(error.message || String(error));
180+
this.exit(1);
181+
return;
182+
}
183+
184+
try {
185+
const engine: StoredExpressionBackfillEngine = stack.kernel.getService('objectql');
186+
if (typeof engine?.computeStoredExpressionsOver !== 'function') {
187+
throw new Error('No ObjectQL engine on this stack — cannot compute stored expressions.');
188+
}
189+
// An empty walk is indistinguishable from a clean one: refuse when no app metadata is loaded.
190+
const loadedObjects: string[] =
191+
typeof engine.getConfigs === 'function' ? Object.keys(engine.getConfigs()) : [];
192+
if (!loadedObjects.some((name) => !name.startsWith('sys_'))) {
193+
throw new Error(
194+
'No app objects are loaded, so the walk would examine nothing. ' +
195+
'Run "os build" in your project root first (the migration reads dist/objectstack.json), then re-run.',
196+
);
197+
}
198+
refuseUndeclaredObjects(flags.object, loadedObjects);
199+
200+
const { backfillStoredExpressions, formatStoredExpressionBackfillReport } = await import('@objectstack/objectql');
201+
202+
// In JSON mode keep stdout parseable — route warnings to stderr.
203+
const logger = flags.json
204+
? { info: (m: string) => console.error(m), warn: (m: string) => console.error(m) }
205+
: { info: (m: string) => printInfo(m), warn: (m: string) => printWarning(m) };
206+
207+
const report = await backfillStoredExpressions(engine, logger, {
208+
apply,
209+
objects: flags.object,
210+
field: flags.field,
211+
changed: flags.changed,
212+
maxRecordsPerObject: flags['max-records'],
213+
});
214+
// Not done while a row could not be written, a row was skipped, or the walk was cut short.
215+
const gap = report.failures.length > 0 || report.skippedRows > 0 || report.truncated;
216+
const pending = report.nullRows + (report.changed ? report.staleRows : 0);
217+
218+
if (flags.json) {
219+
await emitJson({ database: stack.dbLabel, apply, report, duration: timer.elapsed() });
220+
if (gap) this.exit(1);
221+
return;
222+
}
223+
224+
printInfo(`Database: ${chalk.white(stack.dbLabel)}`);
225+
console.log('');
226+
// The report's dry-run line claims no row or table; on a SQLite file the read-only boot's
227+
// connect may still have switched the journal mode (its header, no row), so the note follows.
228+
const journalNote = !report.applied && pending > 0
229+
&& describeDriverSqliteFile(stack.driver?.config) !== undefined
230+
? ` ${JOURNAL_MODE_SWITCH_NOTE}`
231+
: '';
232+
console.log(`${formatStoredExpressionBackfillReport(report).join('\n')}${journalNote}`);
233+
console.log('');
234+
235+
if (report.failures.length > 0) {
236+
printError(`${report.failures.length} row(s) could not be written — re-run to finish them.`);
237+
} else if (report.skippedRows > 0) {
238+
printError(`${report.skippedRows} row(s) were listed and not written — fix each one listed above, then re-run.`);
239+
} else if (report.truncated) {
240+
printError('The walk stopped at --max-records: rows past it were not looked at. Re-run with a higher --max-records.');
241+
} else if (apply && report.writtenRows > 0) {
242+
printSuccess(`Wrote ${report.writtenRows} row(s). Re-run any time — it only rewrites rows that still differ.`);
243+
} else if (!apply && pending > 0) {
244+
printInfo(`Dry run only — ${pending} value(s) would be written. Re-run with --apply.`);
245+
} else {
246+
printSuccess('Nothing to fill — every stored computed field already holds its expression\'s value.');
247+
}
248+
console.log(chalk.dim(` ${timer.display()}`));
249+
console.log('');
250+
if (gap) this.exit(1);
251+
} catch (error: any) {
252+
if (isExitSignal(error)) throw error;
253+
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }
254+
// `INVALID_FIELD` is the run refusing an object or field before its walk ("Refused before
255+
// the walk read or wrote any row."). The boot ran first, so on a SQLite file the note follows.
256+
const journalNote = error?.code === 'INVALID_FIELD'
257+
&& describeDriverSqliteFile(stack.driver?.config) !== undefined
258+
? ` ${JOURNAL_MODE_SWITCH_NOTE}`
259+
: '';
260+
printError(`${error.message || String(error)}${journalNote}`);
261+
this.exit(1);
262+
} finally {
263+
await stack.shutdown();
264+
}
265+
}
266+
}

‎packages/objectql/src/stored-expression-backfill.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -460,9 +460,9 @@ export function formatStoredExpressionBackfillReport(report: StoredExpressionBac
460460
? '✓ Every stored computed value already equals its expression — nothing to recompute.'
461461
: '✓ No NULL a stored computed field\'s expression would fill — nothing to backfill.');
462462
} else {
463-
const verb = report.applied ? 'Wrote' : 'Would write';
464-
lines.push(`${verb} ${report.applied ? report.writtenRows : report.nullRows + (report.changed ? report.staleRows : 0)} `
465-
+ `value(s) in ${pending.length} field(s):`);
463+
lines.push(report.applied
464+
? `Wrote ${report.fields.reduce((n, f) => n + f.written, 0)} value(s) on ${report.writtenRows} row(s) in ${pending.length} field(s):`
465+
: `Would write ${report.nullRows + (report.changed ? report.staleRows : 0)} value(s) in ${pending.length} field(s):`);
466466
for (const f of pending) {
467467
const parts = [`${f.nullRows} NULL row(s)`];
468468
if (report.changed) parts.push(`${f.staleRows} stale row(s)`);

0 commit comments

Comments
 (0)