diff --git a/.changeset/27577-record-currency-code-backfill.md b/.changeset/27577-record-currency-code-backfill.md new file mode 100644 index 00000000000..b2cfc336bb1 --- /dev/null +++ b/.changeset/27577-record-currency-code-backfill.md @@ -0,0 +1,16 @@ +--- +'@objectstack/objectql': patch +'@objectstack/cli': patch +'@objectstack/metadata-protocol': patch +--- + +feat(objectql,cli,metadata-protocol): backfill the currency code of rows stored before an amount field was switched to `currencyMode: 'record'`, and acknowledge the switch at the metadata save + +Clause-②: yes (widening) — a new `@objectstack/objectql` export (`backfillRecordCurrencyCodes` and its report helpers), a new command `os migrate record-currency-codes`, and a new finding `currency_mode_to_record` registered in the existing 409 `destructive_change` face, lifted by its existing `force` +Metadata-protocol: none + +An existing `dynamic` or `fixed` amount switched to `record` names a code field, and the rows already stored hold an amount beside a blank code. Such a row is not refused for resting, but until it holds a code it displays as a plain number, falls into the `null` group of a grouped aggregate, and sits outside every currency-paired filter. + +- **The backfill.** `os migrate record-currency-codes --object --field --code ` writes the code you give into every row whose amount is not blank and whose code is blank. ⛔ The code is never derived: not from a former `defaultCurrency`, not from the organization's `localization.currency`. A row that holds a code is never touched; a row with a blank amount is left alone. Dry run by default; `--apply` writes (with `--yes` when not interactive). The report counts the rows written, the rows skipped because they hold a code, and the rows skipped because their amount is blank. Running it again finds nothing, so a clean second report is the verification. A code that is not an ISO 4217 code, or a field that is not a `record`-mode currency field, is refused before any row is read (`INVALID_FIELD`, 400). Each row is written through the engine's update, so the write is judged and audited like any other. No `sys_migration` flag is recorded. +- **The engine function.** `backfillRecordCurrencyCodes(engine, logger, { object, field, code, apply })` from `@objectstack/objectql`, with `formatRecordCurrencyBackfillReport` and `recordCurrencyBackfillComplete`; `isRecordCurrencyBlank` is the one blank predicate the write guard and the backfill share. +- **The save.** Saving an object (REST `PUT /meta`, Studio, a package publish) whose stored amount field moves to `currencyMode: 'record'` from any other mode now answers the existing 409 `DESTRUCTIVE_CHANGE` with the finding `currency_mode_to_record`, whose message names the three consequences and the command. Re-submit with `force` to save. It reads no row, and fires only where a previous version exists: a code-declared package registered at boot is not refused. diff --git a/packages/cli/src/commands/migrate/record-currency-codes.test.ts b/packages/cli/src/commands/migrate/record-currency-codes.test.ts new file mode 100644 index 00000000000..77700dd35a4 --- /dev/null +++ b/packages/cli/src/commands/migrate/record-currency-codes.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate record-currency-codes` command shape (ADR-0144, ruling + * 6492000994). + * + * The backfill itself is proven in `@objectstack/objectql`'s + * `record-currency-backfill.test.ts`. What is pinned here is what a unit test + * of the backfill cannot see: the command is dry-run-by-default, its three + * naming flags are required, they reach `backfillRecordCurrencyCodes` as given, + * a refusal reaches the `--json` envelope with its code, and the human face's + * dry-run line is followed by the journal-mode note on a SQLite target only. + * The seams that would boot a database or walk a real engine are replaced; the + * command's own parse and control flow run for real, and the report's formatter + * and the refusal are the real ones. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import MigrateRecordCurrencyCodes from './record-currency-codes.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js'; +import { isExitSignal } from '../../utils/format.js'; +import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; +import { backfillRecordCurrencyCodes } from '@objectstack/objectql'; + +vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: vi.fn() })); +vi.mock('../../utils/migrate-occupancy-gate.js', () => ({ + OCCUPANCY_HINT: 'occupancy hint', + probeMigrationTarget: vi.fn(), +})); +vi.mock('../../utils/data-migration-plugins.js', () => ({ buildDataMigrationPlugins: vi.fn(async () => []) })); +vi.mock('@objectstack/objectql', async (importOriginal) => { + const real = await importOriginal(); + return { + backfillRecordCurrencyCodes: vi.fn(), + formatRecordCurrencyBackfillReport: real.formatRecordCurrencyBackfillReport, + }; +}); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +/** oclif builds its whole command table on the first `run()` in a process. */ +const RUN_TIMEOUT = 60_000; + +const engine = { + getObject: () => ({ fields: {} }), + find: async () => [], + update: async () => ({}), + getConfigs: () => ({ opportunity: {}, sys_user: {} }), +}; + +const REPORT = { + object: 'opportunity', field: 'amount', codeField: 'currency_code', code: 'USD', + scannedRecords: 3, codeless: 2, written: 0, skippedCodePresent: 1, skippedAmountBlank: 0, + sampleRecordIds: ['o1', 'o2'], applied: false, truncated: false, failures: [], +}; +const NAMING = ['--object', 'opportunity', '--field', 'amount', '--code', 'USD']; +const SQLITE_CONFIG = { client: 'better-sqlite3', connection: { filename: '/srv/app/.objectstack/data/objectstack.db' } }; +const POSTGRES_CONFIG = { client: 'pg', connection: 'postgres://db.example.com:5432/app' }; +const DRY_RUN_LINE = 'Dry run — no row or table was written. Re-run with --apply to backfill.'; + +let stdout: ReturnType; +let log: ReturnType; +const boot = (config?: Record) => vi.mocked(bootSchemaStack).mockResolvedValue({ + ...(config ? { driver: { config } } : {}), + kernel: { getService: () => engine }, + dbLabel: 'test database', + shutdown: vi.fn(async () => {}), +} as any); +beforeEach(() => { + vi.mocked(probeMigrationTarget).mockResolvedValue({ status: 'free' } as any); + boot(); + vi.mocked(backfillRecordCurrencyCodes).mockReset(); + vi.mocked(backfillRecordCurrencyCodes).mockResolvedValue(REPORT as any); + // `emitJson` awaits the write's drain callback, so the double invokes it. + stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, enc?: unknown, cb?: unknown) => { + const done = typeof enc === 'function' ? enc : cb; + if (typeof done === 'function') done(); + return true; + }) as typeof process.stdout.write); + log = vi.spyOn(console, 'log').mockImplementation(() => {}); +}); +afterEach(() => { + stdout.mockRestore(); + log.mockRestore(); +}); + +const printedLines = (): string[] => log.mock.calls.map((c: unknown[]) => c.map(String).join(' ')).join('\n').split('\n'); + +describe('os migrate record-currency-codes', () => { + it('is a dry run by default, and --yes is opt-in', () => { + expect(MigrateRecordCurrencyCodes.flags.apply.default).toBe(false); + expect(MigrateRecordCurrencyCodes.flags.yes.default).toBe(false); + }); + + it('requires --object, --field and --code: the code is the operator\'s, never derived', () => { + expect(MigrateRecordCurrencyCodes.flags.object.required).toBe(true); + expect(MigrateRecordCurrencyCodes.flags.field.required).toBe(true); + expect(MigrateRecordCurrencyCodes.flags.code.required).toBe(true); + }); + + it('refuses to run without --code', async () => { + const err = await MigrateRecordCurrencyCodes.run(['--object', 'opportunity', '--field', 'amount'], { root: CLI_ROOT }) + .catch((e: unknown) => e); + expect(String((err as Error)?.message)).toMatch(/code/); + expect(vi.mocked(backfillRecordCurrencyCodes)).not.toHaveBeenCalled(); + }, RUN_TIMEOUT); + + it('hands the three names to backfillRecordCurrencyCodes as given, dry run by default', async () => { + await MigrateRecordCurrencyCodes.run(['--json', ...NAMING], { root: CLI_ROOT }); + expect(vi.mocked(backfillRecordCurrencyCodes)).toHaveBeenCalledTimes(1); + expect(vi.mocked(backfillRecordCurrencyCodes).mock.calls[0][2]).toEqual({ + object: 'opportunity', field: 'amount', code: 'USD', apply: false, maxRecords: undefined, + }); + const payload = JSON.parse(stdout.mock.calls.map((c: unknown[]) => String(c[0])).join('')); + expect(payload).toMatchObject({ apply: false, report: { codeless: 2, written: 0 } }); + }, RUN_TIMEOUT); + + it('--apply --yes writes', async () => { + await MigrateRecordCurrencyCodes.run(['--json', '--apply', '--yes', ...NAMING], { root: CLI_ROOT }); + expect(vi.mocked(backfillRecordCurrencyCodes).mock.calls[0][2]).toMatchObject({ apply: true }); + }, RUN_TIMEOUT); + + it('an object the deployment does not declare is refused before the backfill is called', async () => { + const err = await MigrateRecordCurrencyCodes.run( + ['--json', '--object', 'nope', '--field', 'amount', '--code', 'USD'], + { root: CLI_ROOT }, + ).catch((e: unknown) => e); + expect((err as { oclif?: { exit?: number } }).oclif?.exit).toBe(1); + expect(vi.mocked(backfillRecordCurrencyCodes)).not.toHaveBeenCalled(); + const payload = JSON.parse(stdout.mock.calls.map((c: unknown[]) => String(c[0])).join('')); + expect(payload).toMatchObject({ code: 'OBJECT_NOT_FOUND' }); + }, RUN_TIMEOUT); + + it('a refused code reaches the --json envelope with its code and httpStatus, and exits 1', async () => { + const real = await vi.importActual('@objectstack/objectql'); + const recordEngine = { + ...engine, + getObject: () => ({ + fields: { amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } } }, + }), + }; + const refusal = await real.backfillRecordCurrencyCodes(recordEngine, { info() {}, warn() {} }, { + object: 'opportunity', field: 'amount', code: 'usd', + }).then(() => { throw new Error('expected the code to be refused'); }, (e: unknown) => e as Error); + vi.mocked(backfillRecordCurrencyCodes).mockRejectedValue(refusal); + + const err = await MigrateRecordCurrencyCodes.run( + ['--json', '--object', 'opportunity', '--field', 'amount', '--code', 'usd'], + { root: CLI_ROOT }, + ).catch((e: unknown) => e); + expect(isExitSignal(err)).toBe(true); + expect((err as { oclif?: { exit?: number } }).oclif?.exit).toBe(1); + const payload = JSON.parse(stdout.mock.calls.map((c: unknown[]) => String(c[0])).join('')); + expect(payload).toMatchObject({ code: 'INVALID_FIELD', httpStatus: 400, error: refusal.message }); + }, RUN_TIMEOUT); + + it('on a SQLite file the dry-run line claims no row or table and names the journal-mode switch', async () => { + boot(SQLITE_CONFIG); + await MigrateRecordCurrencyCodes.run(NAMING, { root: CLI_ROOT }); + const lines = printedLines(); + expect(lines).toContain('Would write USD into currency_code on 2 codeless row(s).'); + expect(lines.find((line) => line.startsWith('Dry run —'))).toBe(`${DRY_RUN_LINE} ${JOURNAL_MODE_SWITCH_NOTE}`); + }, RUN_TIMEOUT); + + it('on PostgreSQL the dry-run line names no switch', async () => { + boot(POSTGRES_CONFIG); + await MigrateRecordCurrencyCodes.run(NAMING, { root: CLI_ROOT }); + const lines = printedLines(); + expect(lines.find((line) => line.startsWith('Dry run —'))).toBe(DRY_RUN_LINE); + expect(lines.join('\n')).not.toContain(JOURNAL_MODE_SWITCH_NOTE); + }, RUN_TIMEOUT); +}); diff --git a/packages/cli/src/commands/migrate/record-currency-codes.ts b/packages/cli/src/commands/migrate/record-currency-codes.ts new file mode 100644 index 00000000000..d730ad9feee --- /dev/null +++ b/packages/cli/src/commands/migrate/record-currency-codes.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { Command, Flags } from '@oclif/core'; +import chalk from 'chalk'; +import { createInterface } from 'node:readline'; +import { + printHeader, + printSuccess, + printWarning, + printError, + printInfo, + printStep, + createTimer, + emitJson, + isExitSignal, + errorCodeFields, +} from '../../utils/format.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js'; +import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; +import { describeDriverSqliteFile } from '../../utils/connection-display.js'; +import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js'; +import { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js'; +// Type-only, so the heavy engine package is still loaded lazily below. +import type { RecordCurrencyBackfillEngine } from '@objectstack/objectql'; + +async function confirm(question: string): Promise { + if (!process.stdin.isTTY) return false; // non-interactive → require --yes + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer: string = await new Promise((resolve) => rl.question(question, resolve)); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} + +/** + * `os migrate record-currency-codes` — the explicit backfill of the currency + * code owed by rows stored before an existing amount field was switched to + * `currencyMode: 'record'` (ADR-0144, ruling 6492000994, letter B). + * + * Such a row holds an amount beside a blank code: it displays as a plain + * number, falls into the `null` group of a grouped aggregate and sits outside + * every currency-paired filter until it holds a code. The metadata save that + * makes the switch answers 409 `destructive_change` naming this command. + * + * The command is the shell; the walk, its refusals and its report are + * `@objectstack/objectql`'s `backfillRecordCurrencyCodes` — the split + * `os migrate summary-nulls` has with `summary-backfill.ts`. The code is the + * operator's `--code`, ⛔ never derived (ADR-0144 D8 rule 4). Dry run by + * default (writes no row), `--apply` to write; idempotent, so re-running and + * reading a clean report is the verification. No `sys_migration` flag is + * recorded: nothing gates on the run. + */ +export default class MigrateRecordCurrencyCodes extends Command { + static override description = + 'Write a currency code into the rows of a record-mode currency field that hold an amount and no code — the ' + + 'rows stored before the field was switched to currencyMode record. The code is the one you give; nothing is ' + + 'derived. Dry run by default; --apply writes.'; + + static override examples = [ + '$ os migrate record-currency-codes --object opportunity --field amount --code USD', + '$ os migrate record-currency-codes --object opportunity --field amount --code USD --apply', + '$ os migrate record-currency-codes --object opportunity --field amount --code USD --apply --yes --json', + ]; + + static override flags = { + 'database-url': Flags.string({ + description: 'Database URL to migrate (defaults to $OS_DATABASE_URL / the project DB)', + env: 'OS_DATABASE_URL', + }), + object: Flags.string({ description: 'The object whose rows are backfilled', required: true }), + field: Flags.string({ + description: 'The record-mode currency field (currencyConfig.currencyMode record) whose rows owe a code', + required: true, + }), + code: Flags.string({ + description: + 'The ISO 4217 code to write, uppercase (e.g. USD). Written into every row with an amount and a blank code; ' + + 'a row that holds a code is never touched. A code that is not ISO 4217 is refused before any row is read', + required: true, + }), + apply: Flags.boolean({ + description: 'Write the code (default is a read-only dry run)', + default: false, + }), + yes: Flags.boolean({ char: 'y', description: 'Skip the --apply confirmation prompt', default: false }), + force: Flags.boolean({ + description: 'Apply even when another process is using the database (SQLite occupancy check)', + default: false, + }), + 'max-records': Flags.integer({ + description: 'Safety bound on rows read — exceeding it truncates the walk', + }), + json: Flags.boolean({ description: 'Output as JSON (implies non-interactive; requires --yes to apply)' }), + }; + + async run(): Promise { + const { flags } = await this.parse(MigrateRecordCurrencyCodes); + const timer = createTimer(); + const apply = flags.apply; + + if (!flags.json) printHeader('Migrate · record-currency-codes'); + + // Occupancy gate, as `summary-nulls`: probed before boot and before the prompt. + const occupancy = await probeMigrationTarget(flags['database-url']); + if (occupancy.status === 'busy' && apply && !flags.force) { + if (flags.json) { + await emitJson({ + error: 'database_busy', + database: occupancy.filename, + signal: occupancy.signal, + detail: occupancy.detail, + hint: OCCUPANCY_HINT, + }, 0, { compact: true }); + this.exit(1); + return; + } + printError(describeOccupancy(occupancy)); + printWarning(OCCUPANCY_HINT); + this.exit(1); + return; + } + if (occupancy.status === 'busy' && !flags.json) { + printWarning(apply + ? `--force: ${describeOccupancy(occupancy)} Backfilling anyway — the live process may write rows mid-walk.` + : `${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}`); + } + if (occupancy.status === 'unknown' && !flags.json) { + printWarning(`Could not check whether the database is in use — ${occupancy.detail}`); + } + + if (apply && !flags.yes) { + if (flags.json || !process.stdin.isTTY) { + if (flags.json) { + await emitJson({ error: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true }); + this.exit(1); + return; + } + printWarning('Apply mode rewrites record data. Re-run with --yes to confirm, or run without --apply to preview.'); + this.exit(1); + return; + } + const ok = await confirm( + chalk.bold( + `\nWrite ${flags.code} as the currency code of every ${flags.object}.${flags.field} amount stored ` + + 'without one, on this database? [y/N] ', + ), + ); + if (!ok) { + printInfo('Aborted — no changes made.'); + return; + } + } + + if (!flags.json) { + printStep(apply ? 'Booting data stack (APPLY mode)…' : 'Booting data stack (dry run)…'); + } + + let stack; + try { + // The dry run boots read-only, as `summary-nulls` does; `--apply` keeps + // the plain boot, because the tables must exist before it writes. + stack = await bootSchemaStack({ + jsonOutput: flags.json, + databaseUrl: flags['database-url'], + extraPlugins: await buildDataMigrationPlugins(), + ...(apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }), + }); + } catch (error: any) { + if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); } + printError(error.message || String(error)); + this.exit(1); + return; + } + + try { + const engine: RecordCurrencyBackfillEngine & { getConfigs?(): Record } = + stack.kernel.getService('objectql'); + if (typeof engine?.getObject !== 'function' || typeof engine?.update !== 'function') { + throw new Error('No ObjectQL engine on this stack — cannot read the object declaration.'); + } + // A stack with no app metadata loaded would answer "object not found" + // for a real object: say what is missing instead. + const loadedObjects: string[] = + typeof engine.getConfigs === 'function' ? Object.keys(engine.getConfigs()) : []; + if (!loadedObjects.some((name) => !name.startsWith('sys_'))) { + throw new Error( + 'No app objects are loaded, so the walk would examine nothing. ' + + 'Run "os build" in your project root first (the migration reads dist/objectstack.json), then re-run.', + ); + } + refuseUndeclaredObjects([flags.object], loadedObjects); + + const { backfillRecordCurrencyCodes, formatRecordCurrencyBackfillReport } = await import('@objectstack/objectql'); + + // In JSON mode keep stdout parseable — route warnings to stderr. + const logger = flags.json + ? { info: (m: string) => console.error(m), warn: (m: string) => console.error(m) } + : { info: (m: string) => printInfo(m), warn: (m: string) => printWarning(m) }; + + const report = await backfillRecordCurrencyCodes(engine, logger, { + object: flags.object, + field: flags.field, + code: flags.code, + apply, + maxRecords: flags['max-records'], + }); + + if (flags.json) { + await emitJson({ database: stack.dbLabel, apply, report, duration: timer.elapsed() }); + if (report.failures.length > 0) this.exit(1); + return; + } + + printInfo(`Database: ${chalk.white(stack.dbLabel)}`); + console.log(''); + // The report's dry-run line claims no row or table; on a SQLite file the + // read-only boot's connect may still have switched the journal mode (its + // header, no row), so the line is followed by the note naming the switch. + const journalNote = !report.applied && report.codeless > 0 + && describeDriverSqliteFile(stack.driver?.config) !== undefined + ? ` ${JOURNAL_MODE_SWITCH_NOTE}` + : ''; + console.log(`${formatRecordCurrencyBackfillReport(report).join('\n')}${journalNote}`); + console.log(''); + + if (report.failures.length > 0) { + printError(`${report.failures.length} row(s) could not be written — re-run to finish them.`); + } else if (apply && report.written > 0) { + printSuccess( + `Wrote ${report.code} on ${report.written} row(s). ` + + 'Re-run any time — it only ever revisits rows whose code is still blank.', + ); + } else if (!apply && report.codeless > 0) { + printInfo(`Dry run only — ${report.codeless} row(s) would get ${report.code}. Re-run with --apply.`); + } else { + printSuccess(`Nothing to backfill — every ${report.object}.${report.field} amount has its currency code.`); + } + console.log(chalk.dim(` ${timer.display()}`)); + console.log(''); + if (report.failures.length > 0) this.exit(1); + } catch (error: any) { + if (isExitSignal(error)) throw error; + if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); } + // `INVALID_FIELD` is the backfill refusing the field or the code before + // its walk ("Refused before the walk read or wrote any row."). The boot + // above ran first, so on a SQLite file the note naming a journal-mode + // switch follows, as on `summary-nulls`. + const journalNote = error?.code === 'INVALID_FIELD' + && describeDriverSqliteFile(stack.driver?.config) !== undefined + ? ` ${JOURNAL_MODE_SWITCH_NOTE}` + : ''; + printError(`${error.message || String(error)}${journalNote}`); + this.exit(1); + } finally { + await stack.shutdown(); + } + } +} diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index 9e8f819ca5d..e1a78472707 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -71,6 +71,7 @@ import MigrateMeta from '../commands/migrate/meta.js'; import MigrateMultiValueColumns from '../commands/migrate/multi-value-columns.js'; import MigrateOrganizationOwnership from '../commands/migrate/organization-ownership.js'; import MigratePlan from '../commands/migrate/plan.js'; +import MigrateRecordCurrencyCodes from '../commands/migrate/record-currency-codes.js'; import MigrateRecordedBy from '../commands/migrate/recorded-by.js'; import MigrateResume from '../commands/migrate/resume.js'; import MigrateSecurityCatalogOverlays from '../commands/migrate/security-catalog-overlays.js'; @@ -270,6 +271,20 @@ const CALLERS: Record = { noWrite: [{ label: 'migrate recorded-by', argv: ['--database-url', '@DB@', '--json'] }], write: [{ label: 'migrate recorded-by --apply', argv: ['--apply', '--yes', '--database-url', '@DB@', '--json'] }], }, + 'commands/migrate/record-currency-codes.ts': { + run: invoke(MigrateRecordCurrencyCodes), + // The fixture's app declares no record-mode amount, so the backfill + // refuses `status` (INVALID_FIELD) after the boot and writes nothing. + // The boot is what these cases are about. + noWrite: [{ + label: 'migrate record-currency-codes', + argv: ['--object', 'osf_lead', '--field', 'status', '--code', 'USD', '--database-url', '@DB@', '--json'], + }], + write: [{ + label: 'migrate record-currency-codes --apply', + argv: ['--apply', '--yes', '--object', 'osf_lead', '--field', 'status', '--code', 'USD', '--database-url', '@DB@', '--json'], + }], + }, 'commands/migrate/resume.ts': { run: invoke(MigrateResume), noWrite: [{ label: 'migrate resume', argv: ['--database-url', '@DB@', '--json'] }], diff --git a/packages/cli/test/migrate-confirmation-exit.pin.test.ts b/packages/cli/test/migrate-confirmation-exit.pin.test.ts index 72936e092eb..0c77f597aa9 100644 --- a/packages/cli/test/migrate-confirmation-exit.pin.test.ts +++ b/packages/cli/test/migrate-confirmation-exit.pin.test.ts @@ -275,6 +275,11 @@ const ROSTER: Record = { }, writes: () => [seams.resumeMigrationJournal], }, + 'record-currency-codes': { + argv: ['--apply', '--object', 'opportunity', '--field', 'amount', '--code', 'USD'], + confirm: '--yes', + writes: BOOT, + }, 'security-catalog-overlays': { argv: ['--apply'], confirm: '--yes', writes: BOOT }, 'summary-nulls': { argv: ['--apply'], confirm: '--yes', writes: BOOT }, 'value-shapes': { argv: ['--apply'], confirm: '--yes', writes: BOOT }, diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index dad4f76542f..8232fe3ec55 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -5473,7 +5473,8 @@ function collectCompositeKeyHeadHits( /** * Phase 3a-destructive: detect changes between an existing object schema * and an incoming overlay that would break runtime data — removed fields, - * field type narrowing, required toggled on without a default. Returned + * field type narrowing, required toggled on without a default, a stored + * amount switched to `currencyMode: 'record'` (rows left codeless). Returned * issues are surfaced as HTTP 409 `destructive_change` unless the caller * sets `force: true`, letting the admin UI render a warning dialog before * proceeding. @@ -5595,6 +5596,36 @@ function detectDestructiveObjectChanges(prev: any, next: any): Array<{ message: `Field '${fname}' is now required but has no default value — existing rows with null values may fail validation.`, }); } + // [ADR-0144, ruling 6492000994] An existing amount switched to + // `currencyMode: 'record'` — the sibling of `field_required_no_default`: + // a one-word config edit that leaves every stored row owing a value. + // The rows already stored hold an amount and no code; the switch is + // allowed (the write guard refuses such a row only when a write + // touches the pair), so this finding acknowledges it and names the + // explicit backfill. Like its siblings it reads no row: whether the + // table holds any is not this gate's to judge, and a finding over an + // empty table costs one `force`. Judged by what the STORED side holds, + // so a previously unstored field (a formula) raises nothing. + if ( + nextField?.type === 'currency' + && nextField.currencyConfig?.currencyMode === 'record' + && prevField.currencyConfig?.currencyMode !== 'record' + && isStored(prevField) + ) { + const objectName = typeof next.name === 'string' && next.name ? next.name : prev.name; + const codeField = typeof nextField.currencyConfig.currencyField === 'string' + ? `'${nextField.currencyConfig.currencyField}'` + : 'its currency code field'; + issues.push({ + code: 'currency_mode_to_record', + field: fname, + message: `Field '${fname}' switched to currencyMode 'record' — rows already stored with an amount and a blank ${codeField} ` + + 'display as a plain number, fall into the null group of a grouped aggregate, and sit outside every ' + + 'currency-paired filter until they hold a code; once saved, write it with ' + + `\`os migrate record-currency-codes --object ${objectName} --field ${fname} --code \`` + + ' (a dry run; add --apply to write).', + }); + } } return issues; } diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 6afbc883353..ed7dd170b94 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -514,6 +514,21 @@ export type { SummaryBackfillLogger, SummaryBackfillOptions, } from './summary-backfill.js'; +// [ADR-0144, ruling 6492000994] The explicit code backfill behind +// `os migrate record-currency-codes`: rows stored before an amount switched to +// `currencyMode: 'record'`. Same split as the roll-up backfill above. +export { + backfillRecordCurrencyCodes, + formatRecordCurrencyBackfillReport, + recordCurrencyBackfillComplete, +} from './record-currency-backfill.js'; +export type { + RecordCurrencyBackfillReport, + RecordCurrencyBackfillFailure, + RecordCurrencyBackfillEngine, + RecordCurrencyBackfillLogger, + RecordCurrencyBackfillOptions, +} from './record-currency-backfill.js'; export { summaryEmptySetValue, summaryNullIsBackfillable, aggregateSummaryValue } from './summary-aggregate.js'; export type { SummaryDescriptor, SummaryAggregateEngine } from './summary-aggregate.js'; export { evaluateValidationRules, needsPriorRecord, legalNextStates } from './validation/rule-validator.js'; diff --git a/packages/objectql/src/protocol-destructive-currency-mode.test.ts b/packages/objectql/src/protocol-destructive-currency-mode.test.ts new file mode 100644 index 00000000000..39f79e8f5f6 --- /dev/null +++ b/packages/objectql/src/protocol-destructive-currency-mode.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from './registry.js'; + +/** + * Phase 3a-destructive: `currency_mode_to_record` (ADR-0144, ruling + * 6492000994, letter B). + * + * An existing stored amount switched to `currencyMode: 'record'` leaves every + * stored row with an amount and no code. The switch is allowed; the save is + * answered with the existing 409 `DESTRUCTIVE_CHANGE`, whose finding names the + * three consequences and the backfill command, and `force: true` lifts it. Like + * its siblings the finding reads no row: the engine is never asked for the + * object's data, only the metadata store for the row the save overwrites. + */ +describe('ObjectStackProtocolImplementation - currency_mode_to_record', () => { + let protocol: ObjectStackProtocolImplementation; + let mockEngine: any; + let registry: SchemaRegistry; + + const CODE_FIELD = { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' }; + const RECORD_AMOUNT = { + name: 'amount', type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' }, + }; + + /** A tenant-authored object (ADR-0131 D6): the population this detector guards. */ + const register = (name: string, fields: Record) => { + registry.registerObject({ name, label: name, _provenance: 'org', fields } as any, 'pkg'); + }; + + /** The save under test: `name`'s amount now in record mode, beside its code field. */ + const saveRecordMode = (name: string, extra: Record = {}) => protocol.saveMetaItem({ + type: 'object', + name, + ...extra, + item: { + name, + label: name, + fields: { + title: { name: 'title', type: 'text' }, + amount: RECORD_AMOUNT, + currency_code: CODE_FIELD, + }, + }, + } as any); + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + register('deal_dynamic', { + title: { name: 'title', type: 'text' }, + amount: { name: 'amount', type: 'currency', currencyConfig: { currencyMode: 'dynamic' } }, + }); + register('deal_fixed', { + title: { name: 'title', type: 'text' }, + amount: { name: 'amount', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } }, + }); + register('deal_unset', { + title: { name: 'title', type: 'text' }, + amount: { name: 'amount', type: 'currency' }, + }); + register('deal_new_amount', { + title: { name: 'title', type: 'text' }, + }); + register('deal_record', { + title: { name: 'title', type: 'text' }, + amount: RECORD_AMOUNT, + currency_code: CODE_FIELD, + }); + + mockEngine = { + registry, + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn().mockResolvedValue(null), + insert: vi.fn().mockResolvedValue({ id: 'x' }), + update: vi.fn().mockResolvedValue({ id: 'x' }), + delete: vi.fn().mockResolvedValue({ deleted: 1 }), + count: vi.fn().mockResolvedValue(0), + aggregate: vi.fn().mockResolvedValue([]), + }; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + protocol = new ObjectStackProtocolImplementation(mockEngine); + }); + + afterEach(() => { + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + }); + + for (const [from, object] of [['dynamic', 'deal_dynamic'], ['fixed', 'deal_fixed'], ['the default (dynamic)', 'deal_unset']] as const) { + it(`refuses ${from} → record over a stored field with 409 DESTRUCTIVE_CHANGE, reading no row`, async () => { + const err: any = await saveRecordMode(object).then(() => null, (e: unknown) => e); + expect(err).toMatchObject({ + code: 'DESTRUCTIVE_CHANGE', + status: 409, + issues: [expect.objectContaining({ code: 'currency_mode_to_record', field: 'amount' })], + }); + const { message } = err.issues[0]; + // The three consequences, and the command that removes them. + expect(message).toContain("blank 'currency_code'"); + expect(message).toContain('display as a plain number'); + expect(message).toContain('fall into the null group of a grouped aggregate'); + expect(message).toContain('sit outside every currency-paired filter'); + expect(message).toContain(`os migrate record-currency-codes --object ${object} --field amount --code `); + // It reads no row of the object: the only reads are the + // metadata store's own (the row this save overwrites). + for (const verb of ['find', 'findOne', 'count', 'aggregate'] as const) { + const objectsRead = mockEngine[verb].mock.calls.map((c: unknown[]) => c[0]); + expect(objectsRead, verb).not.toContain(object); + } + }); + } + + it('force: true lifts it', async () => { + try { + await saveRecordMode('deal_dynamic', { force: true }); + } catch (err: any) { + expect(err?.code).not.toBe('DESTRUCTIVE_CHANGE'); + } + }); + + it('does not fire on a new amount field declared in record mode — its column holds no rows', async () => { + try { + await saveRecordMode('deal_new_amount'); + } catch (err: any) { + expect(err?.code).not.toBe('DESTRUCTIVE_CHANGE'); + } + }); + + it('does not fire on a field already in record mode', async () => { + try { + await saveRecordMode('deal_record'); + } catch (err: any) { + expect(err?.code).not.toBe('DESTRUCTIVE_CHANGE'); + } + }); + + it('does not fire when the field leaves record mode or stays dynamic', async () => { + for (const currencyConfig of [{ currencyMode: 'dynamic' }, { currencyMode: 'fixed', defaultCurrency: 'USD' }]) { + try { + await protocol.saveMetaItem({ + type: 'object', + name: 'deal_record', + item: { + name: 'deal_record', + label: 'deal_record', + fields: { + title: { name: 'title', type: 'text' }, + amount: { name: 'amount', type: 'currency', currencyConfig }, + currency_code: CODE_FIELD, + }, + }, + } as any); + } catch (err: any) { + // The finding codes this save raised (a destructive-change issue's + // own field-addressed vocabulary, not an `error.code`). + const findings = ((err?.issues ?? []) as Array<{ code: string }>).map((i) => i.code); + expect(findings).not.toContain('currency_mode_to_record'); + } + } + }); +}); diff --git a/packages/objectql/src/record-currency-backfill.test.ts b/packages/objectql/src/record-currency-backfill.test.ts new file mode 100644 index 00000000000..89414784964 --- /dev/null +++ b/packages/objectql/src/record-currency-backfill.test.ts @@ -0,0 +1,260 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `os migrate record-currency-codes` — the explicit backfill of the currency +// code a `record`-mode amount owes on rows stored before the switch (ADR-0144, +// ruling 6492000994, letter B). +// +// Every fixture writes its rows STRAIGHT INTO THE DRIVER STORE, bypassing the +// engine's insert path, which is exactly what a database holds after an +// existing `dynamic` / `fixed` amount was switched to `record`: amounts stored +// when no code was owed. Inserting them through the engine now would be +// refused (`ERR_CURRENCY_CODE_REQUIRED`), and there would be nothing to test. +// The backfill's writes go through the real engine, so its own record-mode +// guard and the code field's `valueDomain` judge each one. + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ObjectQL } from './engine.js'; +import { + backfillRecordCurrencyCodes, + formatRecordCurrencyBackfillReport, + recordCurrencyBackfillComplete, +} from './record-currency-backfill.js'; + +function makeDriver() { + const stores = new Map>(); + const writes: Array<{ object: string; id: string; data: Record }> = []; + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + // Implicit equality plus the comparison operators the keyset walk emits. + const checkOp = (value: any, cond: any): boolean => { + if (cond === null || typeof cond !== 'object' || Array.isArray(cond) || cond instanceof Date) { + return value === cond; + } + return Object.entries(cond).every(([op, target]: [string, any]) => { + switch (op) { + case '$eq': return value === target; + case '$ne': return value !== target; + case '$gt': return value > target; + case '$gte': return value >= target; + case '$lt': return value < target; + case '$lte': return value <= target; + case '$in': return Array.isArray(target) && target.includes(value); + case '$nin': return Array.isArray(target) && !target.includes(value); + default: return true; + } + }); + }; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k === '$or') return (v as any[]).some((w) => matches(row, w)); + if (k === '$not') return !matches(row, v); + return checkOp(row?.[k], v); + }); + }; + let failUpdateFor: string | null = null; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + // Rows are seeded in id order and never re-keyed, so the store's order is + // the `id` order the keyset walk seeks by. + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + const row = { ...data }; + storeFor(object).set(String(row.id), row); + return row; + }, + async update(object: string, id: string, data: Record) { + if (failUpdateFor && id === failUpdateFor) throw new Error('driver refused this row'); + writes.push({ object, id, data }); + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return row; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count() { return 0; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r, undefined))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { + driver, + storeFor, + writes, + failUpdateFor: (id: string | null) => { failUpdateFor = id; }, + }; +} + +const quietLogger = { info: () => {}, warn: () => {} }; + +describe('backfillRecordCurrencyCodes — rows stored before the switch to record mode', () => { + let engine: ObjectQL; + let d: ReturnType; + + /** A row as the database holds it after the switch: written before any code was owed. */ + const stored = (id: string, row: Record) => { + d.storeFor('deal').set(id, { id, name: id, ...row }); + }; + const deal = (id: string) => d.storeFor('deal').get(id); + /** Every row read the driver was asked for, counted beside the double rather than inside it. */ + const reads = () => vi.mocked(d.driver.find).mock.calls.length + vi.mocked(d.driver.findOne).mock.calls.length; + + beforeEach(async () => { + engine = new ObjectQL(); + d = makeDriver(); + vi.spyOn(d.driver, 'find'); + vi.spyOn(d.driver, 'findOne'); + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'deal', + fields: { + name: { type: 'text' }, + amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }, + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + // Still `dynamic`: it owes no code, and a run naming it is refused. + budget: { type: 'currency', currencyConfig: { currencyMode: 'dynamic' } }, + }, + } as any); + }); + + /** The state every case starts from: one row of each kind the walk tells apart. */ + const seedMixed = () => { + stored('d1', { amount: 120 }); // codeless — filled + stored('d2', { amount: 0, currency_code: null }); // 0 is an amount — filled + stored('d3', { amount: 75, currency_code: ' ' }); // white space is no code — filled + stored('d4', { amount: 300, currency_code: 'EUR' }); // holds a code — never touched + stored('d5', { amount: null }); // no amount — no code owed + stored('d6', { currency_code: 'JPY' }); // code and no amount — never touched + }; + + it('a dry run counts every kind of row and writes nothing', async () => { + seedMixed(); + const report = await backfillRecordCurrencyCodes(engine, quietLogger, { + object: 'deal', field: 'amount', code: 'USD', + }); + + expect(report).toMatchObject({ + object: 'deal', field: 'amount', codeField: 'currency_code', code: 'USD', + scannedRecords: 6, codeless: 3, written: 0, skippedCodePresent: 2, skippedAmountBlank: 1, + applied: false, truncated: false, failures: [], + }); + expect(report.sampleRecordIds).toEqual(['d1', 'd2', 'd3']); + expect(d.writes).toEqual([]); + expect(deal('d1').currency_code).toBeUndefined(); + }); + + it('--apply writes the supplied code into the codeless rows only, through the engine', async () => { + seedMixed(); + const report = await backfillRecordCurrencyCodes(engine, quietLogger, { + object: 'deal', field: 'amount', code: 'USD', apply: true, + }); + + expect(report).toMatchObject({ codeless: 3, written: 3, skippedCodePresent: 2, skippedAmountBlank: 1, applied: true }); + expect(deal('d1').currency_code).toBe('USD'); + expect(deal('d2').currency_code).toBe('USD'); + expect(deal('d3').currency_code).toBe('USD'); + // A row holding a code keeps it; a blank amount gets none. + expect(deal('d4').currency_code).toBe('EUR'); + expect(deal('d5').currency_code).toBeUndefined(); + expect(deal('d6').currency_code).toBe('JPY'); + expect(d.writes.map((w) => w.id).sort()).toEqual(['d1', 'd2', 'd3']); + // The amounts are the rows' own and are never rewritten. + expect(deal('d1').amount).toBe(120); + expect(recordCurrencyBackfillComplete(report)).toBe(true); + }); + + it('is idempotent — a second run finds nothing left and writes nothing', async () => { + seedMixed(); + await backfillRecordCurrencyCodes(engine, quietLogger, { object: 'deal', field: 'amount', code: 'USD', apply: true }); + const writesAfterFirst = d.writes.length; + + const again = await backfillRecordCurrencyCodes(engine, quietLogger, { + object: 'deal', field: 'amount', code: 'USD', apply: true, + }); + expect(again).toMatchObject({ codeless: 0, written: 0, skippedCodePresent: 5, skippedAmountBlank: 1 }); + expect(d.writes.length).toBe(writesAfterFirst); + expect(formatRecordCurrencyBackfillReport(again)).toContain( + '✓ Every deal.amount amount already has its currency code — nothing to backfill.', + ); + }); + + it('a code that is not an ISO 4217 member is refused before any row is read', async () => { + seedMixed(); + for (const code of ['usd', 'XYZ', 'US', ' USD', '']) { + const before = reads(); + const err = await backfillRecordCurrencyCodes(engine, quietLogger, { + object: 'deal', field: 'amount', code, apply: true, + }).then(() => null, (e: unknown) => e as Record); + expect(err, code).toMatchObject({ code: 'INVALID_FIELD', status: 400, httpStatus: 400, field: 'currency_code' }); + expect(String(err?.message)).toContain('Refused before the walk read or wrote any row.'); + expect(reads(), code).toBe(before); + } + expect(d.writes).toEqual([]); + }); + + it('a field that is not a record-mode currency field is refused before any row is read', async () => { + seedMixed(); + for (const field of ['budget', 'name', 'nope']) { + const err = await backfillRecordCurrencyCodes(engine, quietLogger, { + object: 'deal', field, code: 'USD', apply: true, + }).then(() => null, (e: unknown) => e as Record); + expect(err, field).toMatchObject({ code: 'INVALID_FIELD', status: 400, field }); + expect(String(err?.message)).toContain(`'deal.${field}' is not a record-mode currency field`); + } + expect(reads()).toBe(0); + expect(d.writes).toEqual([]); + }); + + it('an object the registry does not declare is refused with OBJECT_NOT_FOUND', async () => { + await expect(backfillRecordCurrencyCodes(engine, quietLogger, { + object: 'nope', field: 'amount', code: 'USD', + })).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404, object: 'nope' }); + }); + + it('one row whose write fails is reported and does not stop the rest; a re-run finishes it', async () => { + seedMixed(); + d.failUpdateFor('d2'); + const report = await backfillRecordCurrencyCodes(engine, quietLogger, { + object: 'deal', field: 'amount', code: 'USD', apply: true, + }); + expect(report.written).toBe(2); + expect(report.failures).toEqual([{ recordId: 'd2', error: expect.stringContaining('driver refused this row') }]); + expect(recordCurrencyBackfillComplete(report)).toBe(false); + expect(deal('d2').currency_code).toBeNull(); + + d.failUpdateFor(null); + const again = await backfillRecordCurrencyCodes(engine, quietLogger, { + object: 'deal', field: 'amount', code: 'USD', apply: true, + }); + expect(again).toMatchObject({ codeless: 1, written: 1, failures: [] }); + expect(deal('d2').currency_code).toBe('USD'); + }); + + it('the dry-run report names what it would write, what it skipped, and claims no row or table', async () => { + seedMixed(); + const report = await backfillRecordCurrencyCodes(engine, quietLogger, { object: 'deal', field: 'amount', code: 'USD' }); + expect(formatRecordCurrencyBackfillReport(report)).toEqual([ + 'Scanned 6 row(s) of deal for an amount in amount beside a blank currency code in currency_code.', + 'Would write USD into currency_code on 3 codeless row(s).', + ' e.g. d1, d2, d3', + '· Skipped: 2 row(s) already holding a code (never overwritten), 1 row(s) with a blank amount (no code owed).', + 'Dry run — no row or table was written. Re-run with --apply to backfill.', + ]); + }); +}); diff --git a/packages/objectql/src/record-currency-backfill.ts b/packages/objectql/src/record-currency-backfill.ts new file mode 100644 index 00000000000..4389e59b2f5 --- /dev/null +++ b/packages/objectql/src/record-currency-backfill.ts @@ -0,0 +1,320 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The explicit backfill of the currency code a `record`-mode amount owes on + * rows stored before the switch — `os migrate record-currency-codes` + * (ADR-0144, maintainer ruling 6492000994 on the multi-currency epic, letter B). + * + * ## The hole this fills + * + * An existing `dynamic` or `fixed` amount field switched to + * `currencyMode: 'record'` names a sibling code field (`currencyField`), and + * the rows already stored hold an amount beside a blank code. The switch is + * allowed, and nothing refuses such a row for resting: the write guard + * (`validation/record-currency-code.ts`) judges only a write that touches the + * pair (the ADR-0113 D1 posture). Until the code is written, though, the row + * displays as a plain number, falls into the `null` group of a grouped + * aggregate, and sits outside every currency-paired filter — and a home + * currency projection has no code to convert it from. This run writes the code + * into those rows, once. + * + * ## The code is the operator's, never derived + * + * Exactly one code is written, the one the caller supplies. ⛔ It is never + * read from a retired `defaultCurrency`, from the tenant's + * `localization.currency`, or from anything else (ADR-0144 D8 rule 4: the + * stored code is always one somebody submitted). One run writes one code into + * every codeless row of the field; a field whose stored amounts were in more + * than one currency is split by the operator's own writes first, because + * nothing here can tell those rows apart. The code is judged by the shared + * `iso_4217_currency` membership predicate BEFORE any row is read: a code the + * code field's own `valueDomain` would refuse is refused here as a whole, + * instead of row by row. + * + * ## Which rows + * + * A row is filled when its amount is not blank and its code is blank, both + * judged by {@link isRecordCurrencyBlank} — the write guard's own predicate, so + * the rows filled are exactly the rows the guard would refuse on their next + * write of the pair. A row that holds a code is never touched, whatever the + * code; a row with a blank amount needs no code and is left alone. Each is + * counted, so the report says why every row it read was or was not written. + * Like `summary-backfill.ts`, the walk reads the columns and tests them in JS + * rather than pushing a null predicate to the driver: null comparison is where + * drivers diverge, and a backfill whose coverage depends on the driver skips + * rows silently. + * + * ## Written through `engine.update` + * + * The precedent followed is `summary-backfill.ts` and every other `os migrate` + * data run (`comment-parent-pair`, `activity-changed-fields`): one + * `engine.update` per row, under the unscoped system context. The engine path + * is the one that already judges this exact write — the record-mode guard sees + * the payload touch the code and the stored amount beside it, and the code + * field's `valueDomain` judges the code — so a write this run makes is one the + * platform would accept from any caller, audited and hooked like any other. + * A driver write would step around both and record nothing. + * + * ## Idempotent, and no deployment flag + * + * Every write turns a blank code into a member code, so a second run finds no + * row to fill and writes nothing; "re-run until it reports zero" is the + * operator's verification. Nothing gates on the run, so it records no + * `sys_migration` flag (the `summary-nulls` reasoning: a flag nothing reads). + */ + +import { keysetWalk, type KeysetPageQuery } from '@objectstack/types'; +import { objectNotFoundError, withTransientRetry, type RetryOptions } from '@objectstack/core'; +import { isValueDomainMember } from '@objectstack/spec/shared'; + +import { isRecordCurrencyBlank, recordCurrencyFieldOf } from './validation/record-currency-code.js'; + +/** One row whose code could not be written. */ +export interface RecordCurrencyBackfillFailure { + recordId: string; + error: string; +} + +export interface RecordCurrencyBackfillReport { + object: string; + /** The `record`-mode amount field. */ + field: string; + /** Its `currencyConfig.currencyField` — the column this run writes. */ + codeField: string; + /** The operator-supplied ISO 4217 code. */ + code: string; + scannedRecords: number; + /** Rows holding an amount beside a blank code — the rows this run fills. */ + codeless: number; + /** Codes written (equal to `codeless` on an apply run with no failures; + * always 0 on a dry run — a dry run writes nothing). */ + written: number; + /** Rows left alone because they already hold a code. */ + skippedCodePresent: number; + /** Rows left alone because their amount is blank — no amount, no code owed. */ + skippedAmountBlank: number; + /** A few codeless record ids, so an operator can go and look. */ + sampleRecordIds: string[]; + /** False on a dry run — no writes were made. */ + applied: boolean; + /** The `maxRecords` cap cut the walk short. */ + truncated: boolean; + /** Rows whose write failed after retries; the run continues past them. */ + failures: RecordCurrencyBackfillFailure[]; +} + +/** Engine surface the backfill needs: the object's declaration, a read, and + * the update verb every write of the pair goes through. */ +export interface RecordCurrencyBackfillEngine { + getObject(name: string): { fields?: Record } | undefined; + find(object: string, options: Record): Promise>>; + update(object: string, data: Record, options?: any): Promise; +} + +export interface RecordCurrencyBackfillLogger { + info(msg: string, meta?: unknown): void; + warn(msg: string, meta?: unknown): void; +} + +export interface RecordCurrencyBackfillOptions { + /** The object whose rows are walked. */ + object: string; + /** The `record`-mode `currency` field whose rows owe a code. */ + field: string; + /** The ISO 4217 code to write, exactly as written (`USD`). Never derived. */ + code: string; + /** Write the code. Omit for a read-only dry run. */ + apply?: boolean; + /** Safety bound on rows read. */ + maxRecords?: number; + /** Retry policy for one row's update; defaults to the transient defaults. */ + retry?: RetryOptions; +} + +const SCAN_PAGE_SIZE = 500; +const MAX_SAMPLE_IDS = 5; +/** Unscoped by design, the context every `os migrate` data run uses: + * `isSystem` with no `tenantId` is what `buildDriverOptions` reads as "do not + * tenant-scope this", so every organization's rows are walked. */ +const SYSTEM_CTX = { isSystem: true } as const; + +type RefusalError = Error & { code: string; status: number; httpStatus: number; field: string }; + +/** `INVALID_FIELD` / 400 — an option value this run cannot apply as written + * (the `summary-backfill.ts` envelope for the same condition). */ +function invalidField(field: string, message: string): RefusalError { + const err = new Error(message) as RefusalError; + err.code = 'INVALID_FIELD'; + err.status = 400; + // …and `httpStatus`, the same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors. + err.httpStatus = 400; + err.field = field; + return err; +} + +/** + * Resolve the pair and judge the code BEFORE any row is read: an apply run + * must not discover half way that it was asked to write a code the code + * field refuses, or to fill a field that owes none. + */ +function resolvePair( + engine: RecordCurrencyBackfillEngine, + options: RecordCurrencyBackfillOptions, +): string { + const { object, field, code } = options; + const schema = engine.getObject(object); + if (!schema) throw objectNotFoundError(object); + const def = schema.fields?.[field]; + const codeField = recordCurrencyFieldOf(def); + if (codeField === undefined) { + throw invalidField( + field, + `[record-currency-backfill] '${object}.${field}' is not a record-mode currency field: ` + + (def === undefined + ? `'${object}' declares no field '${field}'. ` + : `it does not declare currencyConfig: { currencyMode: 'record', currencyField }. `) + + 'Only a currency field whose records each name their own currency owes a code. ' + + 'Refused before the walk read or wrote any row.', + ); + } + if (!isValueDomainMember('iso_4217_currency', code)) { + throw invalidField( + codeField, + `[record-currency-backfill] '${code}' is not an ISO 4217 currency code, so '${object}.${codeField}' ` + + `would refuse it: give the uppercase alphabetic code of the currency these amounts are in ` + + `(for example USD). Refused before the walk read or wrote any row.`, + ); + } + return codeField; +} + +/** + * Write `options.code` into the code field of every row of `options.object` + * whose `record`-mode amount `options.field` holds a value beside a blank code. + * + * Dry run unless `options.apply` — and a dry run writes nothing at all, so the + * report can be reviewed before any row changes. An unknown object, a field + * that is not a `record`-mode currency field, or a code that is not an ISO 4217 + * member throws before the walk starts, on a dry run as on an apply run. + */ +export async function backfillRecordCurrencyCodes( + engine: RecordCurrencyBackfillEngine, + logger: RecordCurrencyBackfillLogger, + options: RecordCurrencyBackfillOptions, +): Promise { + const apply = options.apply === true; + const { object, field, code } = options; + const codeField = resolvePair(engine, options); + + const report: RecordCurrencyBackfillReport = { + object, + field, + codeField, + code, + scannedRecords: 0, + codeless: 0, + written: 0, + skippedCodePresent: 0, + skippedAmountBlank: 0, + sampleRecordIds: [], + applied: apply, + truncated: false, + failures: [], + }; + + // Seek by `id` rather than offset: this run's claim is about EVERY stored + // row, and an offset walk cannot promise it visited them all. + const walk = keysetWalk>( + (q: KeysetPageQuery) => engine.find(object, { + ...q, + fields: ['id', field, codeField], + context: { ...SYSTEM_CTX }, + }), + { pageSize: SCAN_PAGE_SIZE, max: options.maxRecords ?? 100_000 }, + ); + + for await (const page of walk.pages()) { + for (const row of page) { + report.scannedRecords++; + const id = row.id; + if (id == null || id === '') continue; + // A code, any code, is the row's own: never overwritten. + if (!isRecordCurrencyBlank(row[codeField])) { + report.skippedCodePresent++; + continue; + } + // No amount, no currency owed — the write guard asks none either. + if (isRecordCurrencyBlank(row[field])) { + report.skippedAmountBlank++; + continue; + } + report.codeless++; + if (report.sampleRecordIds.length < MAX_SAMPLE_IDS) report.sampleRecordIds.push(String(id)); + if (!apply) continue; + try { + await withTransientRetry( + () => engine.update(object, { id, [codeField]: code }, { context: { ...SYSTEM_CTX } }), + options.retry ?? {}, + ); + // Bumped AFTER the retry settles, never inside the retried closure. + report.written++; + } catch (err) { + // One row's failure must not abort the rest. Recorded so the caller + // can surface it; the run is idempotent, so a re-run revisits only + // what is still codeless. + const message = (err as any)?.message ?? String(err); + logger.warn(`[record-currency-backfill] ${object} record ${String(id)}: ${message}`); + report.failures.push({ recordId: String(id), error: message }); + } + } + } + if (walk.truncated) report.truncated = true; + return report; +} + +/** Human-readable report body, shared by the CLI's dry-run and apply output. */ +export function formatRecordCurrencyBackfillReport(report: RecordCurrencyBackfillReport): string[] { + const lines: string[] = []; + const pair = `${report.object}.${report.field}`; + lines.push( + `Scanned ${report.scannedRecords} row(s) of ${report.object} for an amount in ${report.field} ` + + `beside a blank currency code in ${report.codeField}.`, + ); + if (report.codeless === 0) { + lines.push(`✓ Every ${pair} amount already has its currency code — nothing to backfill.`); + } else { + lines.push( + report.applied + ? `Wrote ${report.code} into ${report.codeField} on ${report.written} of ${report.codeless} codeless row(s).` + : `Would write ${report.code} into ${report.codeField} on ${report.codeless} codeless row(s).`, + ` e.g. ${report.sampleRecordIds.join(', ')}`, + ); + } + lines.push( + `· Skipped: ${report.skippedCodePresent} row(s) already holding a code (never overwritten), ` + + `${report.skippedAmountBlank} row(s) with a blank amount (no code owed).`, + ); + if (report.failures.length > 0) { + lines.push(`✗ ${report.failures.length} row(s) could not be written:`); + for (const f of report.failures.slice(0, MAX_SAMPLE_IDS)) lines.push(` • ${f.recordId}: ${f.error}`); + if (report.failures.length > MAX_SAMPLE_IDS) { + lines.push(` … and ${report.failures.length - MAX_SAMPLE_IDS} more`); + } + lines.push('Re-running is safe: the backfill only ever revisits rows whose code is still blank.'); + } + if (report.truncated) { + lines.push('⚠ Walk incomplete — some rows were not examined; re-run without the cap to finish.'); + } + // Claims no row or table, never that nothing was written: the command that + // prints it boots through the driver's connect, which can switch a SQLite + // file's journal mode (its header, no row). The command appends that note. + if (!report.applied && report.codeless > 0) { + lines.push('Dry run — no row or table was written. Re-run with --apply to backfill.'); + } + return lines; +} + +/** Did this run leave the field with no known codeless row? */ +export function recordCurrencyBackfillComplete(report: RecordCurrencyBackfillReport): boolean { + return report.failures.length === 0 && !report.truncated && (report.applied || report.codeless === 0); +} diff --git a/packages/objectql/src/validation/record-currency-code.ts b/packages/objectql/src/validation/record-currency-code.ts index e1f62d31511..6dd8a0ffc2f 100644 --- a/packages/objectql/src/validation/record-currency-code.ts +++ b/packages/objectql/src/validation/record-currency-code.ts @@ -76,10 +76,19 @@ export function recordCurrencyPairs(fields: unknown): RecordCurrencyPair[] { return pairs; } -function isBlank(value: unknown): boolean { +/** + * Is one side of a record-mode pair blank — absent, `null`, or a string with no + * characters but white space? The one predicate this refusal and the backfill + * behind `os migrate record-currency-codes` (`record-currency-backfill.ts`) + * both judge by, so the rows the backfill fills are exactly the rows this + * refusal would answer on their next write of the pair. + */ +export function isRecordCurrencyBlank(value: unknown): boolean { return value === undefined || value === null || (typeof value === 'string' && value.trim() === ''); } +const isBlank = isRecordCurrencyBlank; + /** * `ERR_CURRENCY_CODE_REQUIRED` / 400 — see the module header. Identified by * `code`, which survives crossing package boundaries.