Skip to content

Commit 2bba35a

Browse files
fix(cli): os migrate record-currency-codes judges --code before the probe, prompt and boot; document the command and the record-mode switch (#28416)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01LjZwNRFiG2fkp8SNf6rHNx?thread=cmsg_01LjZwNRFiG2fkp8SNf6rHNxTiTuqLhvqhUP6JVyRUyTJk)_ Fixes #28168 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`os migrate record-currency-codes` 现在一进来就判 `--code`,不在 ISO 4217 里的码在探库、`--apply` 确认提示和 boot 之前就以 `INVALID_FIELD` / 400 退出;CLI 文档和 Currency 文档页补上这条命令、切换时的 409 与 dry run / `--apply` 两步。 - 为什么改:之前坏码要等 boot 之后才被引擎拒绝,而 `--apply` 的 boot 会先建表建列;文档里兄弟命令都有条目,唯独这条没有。 - 风险与回滚:只在命令开头加一个纯函数判断,判据与引擎同一个 `isValueDomainMember('iso_4217_currency', …)`,引擎侧检查原样保留;回滚即 revert 本 PR。 - 先看:`packages/cli/src/commands/migrate/record-currency-codes.ts`(新函数与 `run()` 开头那段),其测试文件新增的 describe,`content/docs/data-modeling/currency.mdx` 新增小节。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run --project integration src/commands/migrate/record-currency-codes.test.ts`,预期 12 个用例全绿,含「坏码不探库、不弹提示、不 boot」两条。 - 需要你做的:无,席位落地。 ## What changes - **S2, `packages/cli/src/commands/migrate/record-currency-codes.ts`.** `run()` now judges `--code` first, right after the header and before the occupancy probe, the `--apply` confirmation prompt and `bootSchemaStack`. The predicate is the one `backfillRecordCurrencyCodes` applies, `isValueDomainMember('iso_4217_currency', code)` from `@objectstack/spec/shared` (a light import; `@objectstack/objectql` stays lazily loaded). The error is the engine's envelope: `code: 'INVALID_FIELD'`, `status` and `httpStatus` 400, so `--json` answers `{ error, code: 'INVALID_FIELD', httpStatus: 400 }` and exit 1, as it did after the boot. The human face prints the message and exits 1. - **Wording.** The message keeps the engine's prefix and its sentences: `[record-currency-backfill] 'usd' is not an ISO 4217 currency code, so … would refuse it: give the uppercase alphabetic code of the currency these amounts are in (for example USD).` One part cannot match word for word: the engine names the code field (`'opportunity.currency_code'`), which is read from the object's declaration, and that is not loaded before the boot. The early refusal names the amount instead (`the currency code field of 'opportunity.amount'`) and ends `Refused before the database was opened or any row was read.` - **Engine unchanged.** `packages/objectql/src/record-currency-backfill.ts` is untouched and keeps its own check for every other caller. The CLI's post-boot catch comment now says the code is judged before the boot. - The `--code` flag description now says a non-ISO code is refused before the database is opened. - **S3, `content/docs/deployment/cli.mdx`.** A row in the data-migrations table, after `summary-nulls`, and a `#### os migrate record-currency-codes` section shaped like `summary-nulls`: why the rows owe a code, the three invocations, the code is the operator's and never derived, the early `--code` refusal and the post-boot `OBJECT_NOT_FOUND` / `INVALID_FIELD` refusals, writes through the engine, idempotent, no flag. - **S3, `content/docs/data-modeling/currency.mdx`.** A `### Switching a field that already holds rows` section under the record mode: the 409 `destructive_change` with finding `currency_mode_to_record`, lifted by re-submitting with `force` (`?force=true` on `PUT /api/v1/meta/:type/:name`), then the dry run and `--apply`. It says the code is the one given with `--code` and is never derived, and links to the CLI section. Both pages are hand-written trees under AGENTS.md's Documentation Guardrails, not release-owned. - `.changeset/28168-record-currency-code-before-boot.md`: `@objectstack/cli` patch, both declaration lines. ## Tests `packages/cli/src/commands/migrate/record-currency-codes.test.ts` (integration tier, as the file already was): - New describe `a code outside ISO 4217 is refused before the probe, the prompt and the boot`: - `--json --apply` with `usd` and no `--yes`: exit 1, the envelope is `INVALID_FIELD` / 400 (the same `code` and `httpStatus` as the real engine's refusal of the same code, which the test produces), never `confirmation_required`; the message's leading clause equals the engine's; `bootSchemaStack`, `probeMigrationTarget` and the backfill are never called. - Human face, `--apply` on a terminal (`stdin.isTTY` true) with `EURO`: exit 1, the engine's leading clause is printed, no readline prompt is opened, and neither the boot nor the probe is called. - Control: a member code (`USD`) still reaches the boot and the backfill. - The existing envelope case fed the engine's code refusal through a mocked backfill. That path is now unreachable for a bad code, so the case now feeds the engine's other pre-walk refusal (a member code on a field that is not record-mode), which only the booted engine can make, and still pins `INVALID_FIELD` / 400 / the message reaching `--json`. ## Acceptance notes - The message names `'OBJECT.FIELD'`'s code field instead of the code field by name (reason above). The envelope (`code`, `httpStatus`) and every other sentence are the engine's. - No out-of-scope findings. ## Verification All readings at head `738019ef`. <details><summary>Tests and typecheck</summary> - `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 src/commands/migrate/record-currency-codes.test.ts`: Test Files 1 passed, Tests 12 passed; lock VERDICT command-exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 test/migrate-confirmation-exit.pin.test.ts`: Tests 38 passed. - `pnpm --filter @objectstack/cli typecheck`: exit 0 (tsc, then check:test-typecheck OK). - The rest of the unit and integration layers are declared to CI. </details> <details><summary>Ablation</summary> `node scripts/ablation-replace.mjs` replaced the call `refuseNonMemberCurrencyCode(flags.object, flags.field, flags.code);` with a marker (anchor 1 to 0, blob 6bf55f7 to 9501b58a), then ran the test file: the two new refusal cases failed (Tests 2 failed, 10 passed) and the control passed. Restored: the file's blob equals HEAD's (6bf55f7) and `git diff HEAD` is empty. The command imports from `src`, so no rebuild was involved. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 93 families. All 93 ran, each recorded as `COMMAND :: exit N`. Five (`check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`) first exited 3 (PREREQUISITE NOT MET); after a full `pnpm build` (lock VERDICT command-exit 0, Tasks 116 successful of 116) all five exited 0. - `dispatch-gates --ran`: 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - `pnpm lint`: exit 0. - CI check-runs are cited in the report once they settle. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English, except the 维护者速读 summary, which the PM loop writes in Chinese. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QUs3BvyNBMezKJUd7aydyG --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 93c0a63 commit 2bba35a

5 files changed

Lines changed: 206 additions & 19 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate record-currency-codes` refuses a `--code` outside ISO 4217 before it probes the database, prompts or boots
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A code that is not an uppercase ISO 4217 code (`usd`, `EURO`) used to be refused only by the backfill, after the app had booted, and an `--apply` boot creates missing tables and columns before that refusal. The command now judges `--code` first, with the same predicate the backfill uses, and answers with the same `INVALID_FIELD` / 400 envelope (`code` and `httpStatus` under `--json`) and exit 1. Nothing is probed, prompted or booted for a bad code. The backfill in `@objectstack/objectql` keeps its own check unchanged.

‎content/docs/data-modeling/currency.mdx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,28 @@ as an explicit value. A `defaultValue` you declare on the code field yourself is
122122
ordinary field default: the engine applies it before the check, so it satisfies the
123123
rule, because you wrote it.
124124

125+
### Switching a field that already holds rows
126+
127+
An existing `dynamic` or `fixed` amount can be switched to `currencyMode: 'record'`,
128+
but every row already stored then holds an amount beside a blank code. Such a row is
129+
not refused for resting, but it shows as a plain number, falls into the `null` group of
130+
a grouped aggregate, and sits outside every filter on the pair until it holds a code.
131+
132+
1. **The save answers 409 `destructive_change`.** Saving the switched object reports
133+
the finding `currency_mode_to_record` on the amount field, and the message names the
134+
command to run. Re-submit the save with `force` (`?force=true` on
135+
`PUT /api/v1/meta/:type/:name`) to make the switch.
136+
2. **Dry run.** `os migrate record-currency-codes --object opportunity --field amount --code USD`
137+
counts the rows holding an amount and no code, and writes nothing.
138+
3. **`--apply`.** The same command with `--apply` writes the code into those rows. It
139+
is idempotent: re-run it until the report says zero.
140+
141+
The code is the one you give with `--code`, and it is never derived: not from the
142+
`defaultCurrency` the field had under `fixed`, not from the organization's currency.
143+
One run writes one code, so if the stored amounts are in more than one currency, split
144+
those rows with your own writes first. See
145+
[`os migrate record-currency-codes`](/docs/deployment/cli#os-migrate-record-currency-codes).
146+
125147
### Aggregating a record-mode amount
126148

127149
Amounts in different currencies do not add. A `sum`, `avg`, `min` or `max` over a

‎content/docs/deployment/cli.mdx‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,6 +1302,7 @@ where the data lives.
13021302
| `os migrate files-to-references` | Convert legacy file-field values to `sys_file` references, verify the ownership ledger, and record the deployment's migration flag |
13031303
| `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean |
13041304
| `os migrate summary-nulls` | Backfill roll-up `count` / `sum` columns still stored as `NULL` on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run |
1305+
| `os migrate record-currency-codes` | Write the currency code you give 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 never derived. No flag; idempotent. See [below](#os-migrate-record-currency-codes) |
13051306
| `os migrate comment-reactions` | Carry the emoji reactions a 17.x deployment stored in the retired `sys_comment.reactions` column into `sys_comment_reaction` records, list every stored value that does not read, and record the verified run that lets `os migrate apply --allow-destructive` drop the column |
13061307
| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. It upgrades a database 17.x or an 18.0 pre-release (`18.0.0-next.*`) created; a v18 server refuses to start on one until it has run. See [below](#os-migrate-organization-ownership) |
13071308
| `os migrate activity-changed-fields` | Stamp the two read-rule columns (`changed_fields`, `milestone_key`) on the `sys_activity` rows written before they existed. Until it runs, those rows are withheld from every reader but the system, so run it at once after upgrading. No flag; idempotent |
@@ -1504,6 +1505,43 @@ A deployment whose database was seeded fresh on this version has nothing to do
15041505
here: its parents were created with the value already in place, and the run
15051506
reports zero.
15061507
1508+
#### `os migrate record-currency-codes`
1509+
1510+
Switching an existing currency field to `currencyMode: 'record'` leaves every row
1511+
already stored holding an amount beside a blank code. Nothing refuses such a row for
1512+
resting, but until it holds a code it shows as a plain number, falls into the `null`
1513+
group of a grouped aggregate, and sits outside every filter on the amount and its
1514+
code. The save that makes the switch answers 409 `destructive_change` naming this
1515+
command ([Currency fields](/docs/data-modeling/currency#switching-a-field-that-already-holds-rows)
1516+
covers the switch). This command writes the code into those rows.
1517+
1518+
```bash
1519+
os migrate record-currency-codes --object opportunity --field amount --code USD # Dry run: counts the rows, writes none
1520+
os migrate record-currency-codes --object opportunity --field amount --code USD --apply # Write the code (prompts)
1521+
os migrate record-currency-codes --object opportunity --field amount --code USD --apply --yes --json # CI / scripts
1522+
```
1523+
1524+
**The code is yours, never derived.** `--object`, `--field` and `--code` are all
1525+
required. One run writes the one code you give into every row of the field whose
1526+
amount is set and whose code is blank. It never reads a code from the `defaultCurrency`
1527+
the field had under `fixed`, from the organization's `localization.currency`, or from
1528+
anything else. A row that holds a code is never touched, whatever the code, and a row
1529+
with no amount owes none. If the stored amounts are in more than one currency, split
1530+
them with your own writes first: nothing here can tell those rows apart.
1531+
1532+
**A bad `--code` is refused before anything is opened.** A code that is not an
1533+
uppercase ISO 4217 code (`usd`, `EURO`) is refused with `INVALID_FIELD` / 400 and
1534+
exit 1 before the database is probed, the `--apply` prompt is shown or the app is
1535+
booted. An object your deployment does not declare (`OBJECT_NOT_FOUND`), or a field
1536+
that is not a record-mode currency field (`INVALID_FIELD`), is refused after the boot
1537+
and before any row is read.
1538+
1539+
Each row is written through the engine like any other update, so the record-mode
1540+
write rule and the code field's `valueDomain` judge it, and it is audited. Idempotent:
1541+
every write turns a blank code into a code, so a second run finds nothing to write.
1542+
Re-running until the report says zero *is* the verification, which is why this
1543+
command records no flag.
1544+
15071545
#### `os migrate comment-reactions`
15081546
15091547
v18 retired `sys_comment.reactions`, the JSON column that held every emoji

‎packages/cli/src/commands/migrate/record-currency-codes.test.ts‎

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
* `record-currency-backfill.test.ts`. What is pinned here is what a unit test
99
* of the backfill cannot see: the command is dry-run-by-default, its three
1010
* naming flags are required, they reach `backfillRecordCurrencyCodes` as given,
11-
* a refusal reaches the `--json` envelope with its code, and the human face's
12-
* dry-run line is followed by the journal-mode note on a SQLite target only.
11+
* a refusal reaches the `--json` envelope with its code, a code outside ISO
12+
* 4217 is refused before the occupancy probe, the `--apply` prompt and the boot
13+
* (card #28168), and the human face's dry-run line is followed by the
14+
* journal-mode note on a SQLite target only.
1315
* The seams that would boot a database or walk a real engine are replaced; the
1416
* command's own parse and control flow run for real, and the report's formatter
1517
* and the refusal are the real ones.
@@ -31,6 +33,19 @@ vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
3133
probeMigrationTarget: vi.fn(),
3234
}));
3335
vi.mock('../../utils/data-migration-plugins.js', () => ({ buildDataMigrationPlugins: vi.fn(async () => []) }));
36+
// The `--apply` confirmation: a prompt opened at all is recorded, and answered
37+
// no, so a case that should never reach it can say so instead of hanging.
38+
const prompt = vi.hoisted(() => ({ opened: 0 }));
39+
vi.mock('node:readline', async (importOriginal) => {
40+
const real = await importOriginal<typeof import('node:readline')>();
41+
return {
42+
...real,
43+
createInterface: (() => {
44+
prompt.opened++;
45+
return { question: (_q: string, answer: (a: string) => void) => answer('n'), close: () => {} };
46+
}) as unknown as typeof real.createInterface,
47+
};
48+
});
3449
vi.mock('@objectstack/objectql', async (importOriginal) => {
3550
const real = await importOriginal<typeof import('@objectstack/objectql')>();
3651
return {
@@ -134,29 +149,88 @@ describe('os migrate record-currency-codes', () => {
134149
expect(payload).toMatchObject({ code: 'OBJECT_NOT_FOUND' });
135150
}, RUN_TIMEOUT);
136151

137-
it('a refused code reaches the --json envelope with its code and httpStatus, and exits 1', async () => {
152+
it('a refusal from the backfill reaches the --json envelope with its code and httpStatus, and exits 1', async () => {
138153
const real = await vi.importActual<typeof import('@objectstack/objectql')>('@objectstack/objectql');
139-
const recordEngine = {
140-
...engine,
141-
getObject: () => ({
142-
fields: { amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } } },
143-
}),
144-
};
145-
const refusal = await real.backfillRecordCurrencyCodes(recordEngine, { info() {}, warn() {} }, {
146-
object: 'opportunity', field: 'amount', code: 'usd',
147-
}).then(() => { throw new Error('expected the code to be refused'); }, (e: unknown) => e as Error);
154+
// A member code on a field that is not record-mode: the refusal only the
155+
// booted engine can make, since it reads the object's declaration.
156+
const plainEngine = { ...engine, getObject: () => ({ fields: { amount: { type: 'currency' } } }) };
157+
const refusal = await real.backfillRecordCurrencyCodes(plainEngine, { info() {}, warn() {} }, {
158+
object: 'opportunity', field: 'amount', code: 'USD',
159+
}).then(() => { throw new Error('expected the field to be refused'); }, (e: unknown) => e as Error);
148160
vi.mocked(backfillRecordCurrencyCodes).mockRejectedValue(refusal);
149161

150-
const err = await MigrateRecordCurrencyCodes.run(
151-
['--json', '--object', 'opportunity', '--field', 'amount', '--code', 'usd'],
152-
{ root: CLI_ROOT },
153-
).catch((e: unknown) => e);
162+
const err = await MigrateRecordCurrencyCodes.run(['--json', ...NAMING], { root: CLI_ROOT }).catch((e: unknown) => e);
154163
expect(isExitSignal(err)).toBe(true);
155164
expect((err as { oclif?: { exit?: number } }).oclif?.exit).toBe(1);
156165
const payload = JSON.parse(stdout.mock.calls.map((c: unknown[]) => String(c[0])).join(''));
157166
expect(payload).toMatchObject({ code: 'INVALID_FIELD', httpStatus: 400, error: refusal.message });
158167
}, RUN_TIMEOUT);
159168

169+
describe('a code outside ISO 4217 is refused before the probe, the prompt and the boot', () => {
170+
/** The engine's own refusal of the same code, for the envelope and wording it is held to. */
171+
const engineRefusal = async (code: string): Promise<Error & { code?: string; httpStatus?: number }> => {
172+
const real = await vi.importActual<typeof import('@objectstack/objectql')>('@objectstack/objectql');
173+
const recordEngine = {
174+
...engine,
175+
getObject: () => ({
176+
fields: { amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } } },
177+
}),
178+
};
179+
return real.backfillRecordCurrencyCodes(recordEngine, { info() {}, warn() {} }, {
180+
object: 'opportunity', field: 'amount', code,
181+
}).then(() => { throw new Error('expected the code to be refused'); }, (e: unknown) => e as Error);
182+
};
183+
/** Up to the clause naming the field, which only the booted engine can name. */
184+
const leadingClause = (message: string): string => message.slice(0, message.indexOf(', so '));
185+
186+
beforeEach(() => {
187+
vi.mocked(bootSchemaStack).mockClear();
188+
vi.mocked(probeMigrationTarget).mockClear();
189+
prompt.opened = 0;
190+
});
191+
192+
it('--json --apply: the engine\'s INVALID_FIELD / 400 envelope, never confirmation_required, and nothing opened', async () => {
193+
const err = await MigrateRecordCurrencyCodes.run(
194+
['--json', '--apply', '--object', 'opportunity', '--field', 'amount', '--code', 'usd'],
195+
{ root: CLI_ROOT },
196+
).catch((e: unknown) => e);
197+
expect(isExitSignal(err)).toBe(true);
198+
expect((err as { oclif?: { exit?: number } }).oclif?.exit).toBe(1);
199+
const payload = JSON.parse(stdout.mock.calls.map((c: unknown[]) => String(c[0])).join(''));
200+
const refusal = await engineRefusal('usd');
201+
expect(payload).toMatchObject({ code: refusal.code, httpStatus: refusal.httpStatus });
202+
expect(payload).toMatchObject({ code: 'INVALID_FIELD', httpStatus: 400 });
203+
expect(leadingClause(payload.error)).toBe(leadingClause(refusal.message));
204+
expect(vi.mocked(bootSchemaStack)).not.toHaveBeenCalled();
205+
expect(vi.mocked(probeMigrationTarget)).not.toHaveBeenCalled();
206+
expect(vi.mocked(backfillRecordCurrencyCodes)).not.toHaveBeenCalled();
207+
}, RUN_TIMEOUT);
208+
209+
it('human face, --apply on a terminal: the refusal is printed and no prompt opens', async () => {
210+
const wasTTY = process.stdin.isTTY;
211+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
212+
try {
213+
const err = await MigrateRecordCurrencyCodes.run(
214+
['--apply', '--object', 'opportunity', '--field', 'amount', '--code', 'EURO'],
215+
{ root: CLI_ROOT },
216+
).catch((e: unknown) => e);
217+
expect((err as { oclif?: { exit?: number } }).oclif?.exit).toBe(1);
218+
} finally {
219+
Object.defineProperty(process.stdin, 'isTTY', { value: wasTTY, configurable: true });
220+
}
221+
expect(printedLines().join('\n')).toContain(leadingClause((await engineRefusal('EURO')).message));
222+
expect(prompt.opened).toBe(0);
223+
expect(vi.mocked(bootSchemaStack)).not.toHaveBeenCalled();
224+
expect(vi.mocked(probeMigrationTarget)).not.toHaveBeenCalled();
225+
}, RUN_TIMEOUT);
226+
227+
it('a member code still reaches the boot (the control)', async () => {
228+
await MigrateRecordCurrencyCodes.run(['--json', ...NAMING], { root: CLI_ROOT });
229+
expect(vi.mocked(bootSchemaStack)).toHaveBeenCalledTimes(1);
230+
expect(vi.mocked(backfillRecordCurrencyCodes)).toHaveBeenCalledTimes(1);
231+
}, RUN_TIMEOUT);
232+
});
233+
160234
it('on a SQLite file the dry-run line claims no row or table and names the journal-mode switch', async () => {
161235
boot(SQLITE_CONFIG);
162236
await MigrateRecordCurrencyCodes.run(NAMING, { root: CLI_ROOT });

‎packages/cli/src/commands/migrate/record-currency-codes.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Command, Flags } from '@oclif/core';
44
import chalk from 'chalk';
55
import { createInterface } from 'node:readline';
6+
import { isValueDomainMember } from '@objectstack/spec/shared';
67
import {
78
printHeader,
89
printSuccess,
@@ -24,6 +25,33 @@ import { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js';
2425
// Type-only, so the heavy engine package is still loaded lazily below.
2526
import type { RecordCurrencyBackfillEngine } from '@objectstack/objectql';
2627

28+
/**
29+
* The `--code` refusal, judged before the occupancy probe, the `--apply`
30+
* prompt and the boot: a code the code field's `valueDomain` would refuse
31+
* fails the same way on every database, so nothing about the database needs
32+
* opening to say so, and an `--apply` boot may create tables and columns.
33+
*
34+
* The predicate is the one `backfillRecordCurrencyCodes` applies, and the
35+
* error is its envelope (`INVALID_FIELD` / 400, `httpStatus` for the `--json`
36+
* face) and its wording, so the answer is the one the engine gave when this
37+
* was judged after the boot. The code field's name is in the object's
38+
* declaration, which is not loaded yet, so the message names the amount field
39+
* the code is owed by. The engine keeps its own check for every other caller.
40+
*/
41+
function refuseNonMemberCurrencyCode(object: string, field: string, code: string): void {
42+
if (isValueDomainMember('iso_4217_currency', code)) return;
43+
const err = new Error(
44+
`[record-currency-backfill] '${code}' is not an ISO 4217 currency code, so the currency code field of ` +
45+
`'${object}.${field}' would refuse it: give the uppercase alphabetic code of the currency these amounts ` +
46+
'are in (for example USD). Refused before the database was opened or any row was read.',
47+
) as Error & { code: string; status: number; httpStatus: number; field: string };
48+
err.code = 'INVALID_FIELD';
49+
err.status = 400;
50+
err.httpStatus = 400;
51+
err.field = field;
52+
throw err;
53+
}
54+
2755
async function confirm(question: string): Promise<boolean> {
2856
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
2957
const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -78,7 +106,7 @@ export default class MigrateRecordCurrencyCodes extends Command {
78106
code: Flags.string({
79107
description:
80108
'The ISO 4217 code to write, uppercase (e.g. USD). Written into every row with an amount and a blank code; ' +
81-
'a row that holds a code is never touched. A code that is not ISO 4217 is refused before any row is read',
109+
'a row that holds a code is never touched. A code that is not ISO 4217 is refused before the database is opened',
82110
required: true,
83111
}),
84112
apply: Flags.boolean({
@@ -103,6 +131,20 @@ export default class MigrateRecordCurrencyCodes extends Command {
103131

104132
if (!flags.json) printHeader('Migrate · record-currency-codes');
105133

134+
// `--code` first: before the occupancy probe, the `--apply` prompt and the boot.
135+
try {
136+
refuseNonMemberCurrencyCode(flags.object, flags.field, flags.code);
137+
} catch (error: any) {
138+
if (flags.json) {
139+
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
140+
this.exit(1);
141+
return;
142+
}
143+
printError(error.message);
144+
this.exit(1);
145+
return;
146+
}
147+
106148
// Occupancy gate, as `summary-nulls`: probed before boot and before the prompt.
107149
const occupancy = await probeMigrationTarget(flags['database-url']);
108150
if (occupancy.status === 'busy' && apply && !flags.force) {
@@ -244,8 +286,9 @@ export default class MigrateRecordCurrencyCodes extends Command {
244286
} catch (error: any) {
245287
if (isExitSignal(error)) throw error;
246288
if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); }
247-
// `INVALID_FIELD` is the backfill refusing the field or the code before
248-
// its walk ("Refused before the walk read or wrote any row."). The boot
289+
// `INVALID_FIELD` is the backfill refusing the field before its walk
290+
// ("Refused before the walk read or wrote any row."; the code was judged
291+
// before the boot, so it is refused there only if that judge drifts). The boot
249292
// above ran first, so on a SQLite file the note naming a journal-mode
250293
// switch follows, as on `summary-nulls`.
251294
const journalNote = error?.code === 'INVALID_FIELD'

0 commit comments

Comments
 (0)