|
| 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 | +} |
0 commit comments