Skip to content

Commit a3d24c9

Browse files
feat(objectql,cli,metadata-protocol): backfill the currency code of rows stored before an amount switched to record mode, and acknowledge the switch with a 409 at the save (#28128)
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5 <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg9xV1B9ukhcbUkqcsAi8vpW)_ Fixes #27577 Ruling-ref: 6492000994 ⛔ lands before R2 (#27191 / #27192 / #27193). Not release-paired: ships in the next Version Packages. 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 ## 维护者速读 - **改了什么:** 已有数据的 `dynamic` / `fixed` 金额字段切到 `record` 后,旧行有金额无币种码。新增显式回填 `os migrate record-currency-codes --object --field --code`:只给「有金额、码为空」的行写运维给的码,默认只报告,`--apply` 才写,可重复跑;元数据保存这种切换时返回现有 409 `destructive_change`(finding `currency_mode_to_record`),点名三个后果和命令,`force` 放行。 - **为什么改:** 不回填,这些行显示为纯数字、分组落进 null 组、币种筛选看不到,R2 合并会漏算。 - **风险与回滚:** 码从不推断;有码的行从不改;非 ISO 4217 码在读任何行前拒绝;写入走 `engine.update`,受 R1 写入守卫与 `valueDomain` 判;409 不读数据行、不加启动拒绝。回滚即撤 PR。 - **先看:** `packages/objectql/src/record-currency-backfill.ts`,再看 `protocol.ts` 里 `detectDestructiveObjectChanges` 新增的一段。 - **两分钟验证:** 跑 `record-currency-backfill.test.ts`(干跑、写入、幂等、有码不动、空金额跳过、坏码拒绝)与 `protocol-destructive-currency-mode.test.ts`(dynamic/fixed→record 返回 409、新字段不触发、`force` 放行)。 - **需要您做的:** 无,已裁 B;本 PR 不触及受治理路径,skills 一行与 ADR 开放项由 epic PM 另开 Tier H PR。 ## What changes **`@objectstack/objectql` — `backfillRecordCurrencyCodes(engine, logger, { object, field, code, apply, maxRecords })`** (`record-currency-backfill.ts`, modelled on `summary-backfill.ts`), exported with `formatRecordCurrencyBackfillReport` and `recordCurrencyBackfillComplete`. - Before any row is read: the object must resolve (`OBJECT_NOT_FOUND` / 404, `objectNotFoundError`), the field must be a `record`-mode `currency` field (`recordCurrencyFieldOf`, the write guard's own reading; else `INVALID_FIELD` / 400 naming the field), and the code must be an `iso_4217_currency` member (`isValueDomainMember`, the predicate the code field's `valueDomain` uses, exact spelling, no trim or case fold; else `INVALID_FIELD` / 400 naming the code field). Each refusal ends "Refused before the walk read or wrote any row." - The walk: a keyset walk over `id` reading `id`, the amount and the code, under the unscoped system context (`{ isSystem: true }`, as every `os migrate` data run). Per row, in this order: a code that is not blank → `skippedCodePresent`, never written; a blank amount → `skippedAmountBlank`; otherwise `codeless`, and on `--apply` one write. "Blank" is `isRecordCurrencyBlank` (absent, `null`, or white space only), now exported from `validation/record-currency-code.ts` so the backfill and the write guard judge one predicate: the rows filled are exactly the rows the guard would refuse on their next write of the pair. An amount of `0` is an amount. - ⛔ The code is the caller's, never derived: nothing reads `defaultCurrency`, `localization.currency` or any other source (ADR-0144 D8 rule 4). - Dry run unless `apply`. Idempotent: every write turns a blank code into a member code, so a second run counts zero `codeless`. One row's failure is recorded in `failures` and the walk continues; `maxRecords` (default 100 000) truncates and says so. No `sys_migration` flag. **Why `engine.update`, not a driver write** (the card leaves this to the precedent picked): `summary-backfill.ts` itself writes through `engine.update` with the system context, as do `comment-parent-pair`, `activity-changed-fields`, `audit-key-material`, `audit-mail-bodies` and `audit-metadata-bodies`; no `os migrate` data run writes at the driver. The engine path is also the one that already judges this exact write: the R1 guard sees the payload touch the code beside the stored amount, and the code field's `valueDomain` judges the code. A write the backfill makes is therefore one the platform would accept from any caller, audited and hooked like any other; a driver write would step around both and record nothing. **`@objectstack/cli` — `os migrate record-currency-codes --object <o> --field <amount> --code <ISO> [--apply] [--yes] [--json] [--force] [--max-records] [--database-url]`** (`commands/migrate/record-currency-codes.ts`). The shell of `os migrate summary-nulls`, one for one: SQLite occupancy gate, `--yes` for a non-interactive apply, read-only boot for the dry run (`deferSchemaDdl` + `readOnlyProbe`), the "no app objects loaded" refusal, `refuseUndeclaredObjects` for `--object`, the journal-mode note after the dry-run line and after an `INVALID_FIELD` refusal on a SQLite file, `--json` envelope with `errorCodeFields`, exit 1 on any failure. `--object`, `--field` and `--code` are required. Everything else is the engine function's. **`@objectstack/metadata-protocol` — `detectDestructiveObjectChanges` gains `currency_mode_to_record`** (Phase 3a-destructive, `saveMetaItem`): the previous field is stored, its `currencyConfig.currencyMode` is not `record` (an absent config is the default `dynamic`), and the next field is a `currency` field with `currencyMode: 'record'`. The finding answers as the existing 409 `DESTRUCTIVE_CHANGE` with the existing `force` remedy; its message names the code field, the three consequences (plain number, the `null` group, outside every currency-paired filter) and the command with `--object` and `--field` filled in. It reads no row, like `field_type_change` and `field_required_no_default`. A new field declared in `record` mode, a field already in `record` mode, and a move out of `record` raise nothing. ⛔ No boot-time refusal: the gate runs only where a previous version exists (REST `PUT /meta`, Studio, package publish). **Not in this diff** (governed paths, kept with the epic PM's Tier H PR per the card): the `skills/**` line and the ADR-0144 open item with its dated note. The epic PM also adds the blank-code class to #27193. #27193's code is not touched. **Declarations, confirmed against the tree.** `Clause-②: yes (widening)`: new exports and a new command widen the public face; the finding is a new member of the existing `destructive_change` refusal face, which is the reading the card and ruling take from #27339's review 6490309706. One thing for the contract review to weigh: a save of exactly this switch that succeeded without `force` now answers 409 until re-sent with `force`; it is a risk acknowledgement on an existing face, not a refusal of what an author can write, so `Metadata-protocol: none` (every body that saved still saves). One changeset, `patch` for all three packages. ## Why Ruling 6492000994 on #8345 (letter B): the switch is allowed and owes an explicit, audited backfill, and the R2 home projection cannot convert a codeless row, so this lands before R2. ## Verification Head `1636d8c3`: the branch's four commits on `1ed33609` (all four R1 PRs on `main`) plus a merge of `main` at `549203d0` (including #27991), no conflicts. After the merge: `pnpm install --frozen-lockfile && pnpm build` (whole workspace), then everything below on that head. - `packages/objectql`: `record-currency-backfill.test.ts` 8 / 8 (dry run counts every kind of row and writes nothing; `--apply` fills only the codeless rows through the engine, `0` and white-space codes included, codes and amounts otherwise untouched; idempotent; five non-member codes refused with no read; three non-record fields refused with no read; unknown object `OBJECT_NOT_FOUND`; one failed write isolated, re-run finishes it; the dry-run report text). `protocol-destructive-currency-mode.test.ts` 7 / 7 (dynamic, fixed and unset → record: 409 with the finding, the message's three consequences and command, and no data read of the object; `force` lifts it; a new record-mode field, record → record, and record → dynamic / fixed raise nothing). `protocol-destructive.test.ts` 7 / 7 unchanged. - `packages/cli`: `record-currency-codes.test.ts` 9 / 9 (dry run default; the three flags required and `--code` missing refused; names handed through; `--apply --yes`; undeclared object refused before the backfill; a real `INVALID_FIELD` code refusal reaches the `--json` envelope with `httpStatus`; the dry-run line with the journal-mode note on SQLite and without it on PostgreSQL). - `pnpm --filter` typecheck: `@objectstack/objectql`, `@objectstack/metadata-protocol`, `@objectstack/cli` pass (test-typecheck ledgers held). eslint on every touched file: clean. - **Ablations** (`scripts/ablation-replace.mjs`, mutation landed and restored byte-identical each time): 1. the backfill's code-present test `if (!isRecordCurrencyBlank(row[codeField]))` → `if (false && …)`: 5 / 8 red (rows holding EUR and JPY overwritten). 2. the same test → `row[codeField] != null` (a null-only blank): 4 / 8 red (the white-space code row skipped as "present"). 3. the finding's condition → `false && …` (metadata-protocol rebuilt under the mutation): the three 409 cases red, 3 / 7; the four non-firing cases stay green, as they must. - Full suites on `1636d8c3`: `@objectstack/objectql` 496 files / 9615 tests passed; `@objectstack/metadata-protocol` 284 files / 21857 passed (26 skipped, pre-existing); `packages/cli/src/commands/migrate/` 59 files / 429 passed (8 skipped, pre-existing). - Derived gates: `dispatch-gates --commands` on `1636d8c3` derives 65 families (9 paths vs merge base `549203d0`, 1254 changed lines); each ran with its exit captured before any pipe, all 65 exited 0. `dispatch-gates --ran` reads: "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED". On an earlier head, `check:objectql-double-limit` caught the backfill test's driver double counting reads inside `find` (its probe could not drive it); the count moved to spies beside the double (`3c340f77`). **CI.** Head `5d2f5efd`: 35 check runs success, 13 skipped (path-filtered), none failed; `mergeable_state: clean`. Two reds on the way there, both this PR's own and fixed: - `check:error-code-casing` (not in the path-derived list) read the test's `{ code: 'currency_mode_to_record' }` as a lowercase `error.code` emission; the test now reads the raised finding codes as a list (`0f5c7f1f`). A destructive-change finding code is the issue's own field-addressed vocabulary, as `field_type_change` is. - Test Core: two CLI rosters that discover their members from the tree (`test/migrate-confirmation-exit.pin.test.ts`, `src/utils/schema-migrate.one-shot-family.integration.test.ts`) did not yet name the new command; both rows added (`5d2f5efd`). The one-shot fixture declares no record-mode amount, so its two cases reach the boot and then the backfill's `INVALID_FIELD` refusal, the same shape as the `resume` and `plugin trust revoke` rows. Full `@objectstack/cli` suite locally: 535 files / 6680 tests passed (9 skipped, pre-existing). ## 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; eslint on the touched files 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: n/a. - [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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CbJa6hKdimkAau1nbA --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbJa6hKdimkAau1nbA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3cc45cc commit a3d24c9

11 files changed

Lines changed: 1273 additions & 2 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/cli': patch
4+
'@objectstack/metadata-protocol': patch
5+
---
6+
7+
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
8+
9+
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`
10+
Metadata-protocol: none
11+
12+
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.
13+
14+
- **The backfill.** `os migrate record-currency-codes --object <object> --field <amount> --code <ISO 4217>` 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.
15+
- **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.
16+
- **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.
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate record-currency-codes` command shape (ADR-0144, ruling
5+
* 6492000994).
6+
*
7+
* The backfill itself is proven in `@objectstack/objectql`'s
8+
* `record-currency-backfill.test.ts`. What is pinned here is what a unit test
9+
* of the backfill cannot see: the command is dry-run-by-default, its three
10+
* 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.
13+
* The seams that would boot a database or walk a real engine are replaced; the
14+
* command's own parse and control flow run for real, and the report's formatter
15+
* and the refusal are the real ones.
16+
*/
17+
18+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
19+
import { dirname, resolve } from 'node:path';
20+
import { fileURLToPath } from 'node:url';
21+
import MigrateRecordCurrencyCodes from './record-currency-codes.js';
22+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
23+
import { probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
24+
import { isExitSignal } from '../../utils/format.js';
25+
import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
26+
import { backfillRecordCurrencyCodes } from '@objectstack/objectql';
27+
28+
vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: vi.fn() }));
29+
vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
30+
OCCUPANCY_HINT: 'occupancy hint',
31+
probeMigrationTarget: vi.fn(),
32+
}));
33+
vi.mock('../../utils/data-migration-plugins.js', () => ({ buildDataMigrationPlugins: vi.fn(async () => []) }));
34+
vi.mock('@objectstack/objectql', async (importOriginal) => {
35+
const real = await importOriginal<typeof import('@objectstack/objectql')>();
36+
return {
37+
backfillRecordCurrencyCodes: vi.fn(),
38+
formatRecordCurrencyBackfillReport: real.formatRecordCurrencyBackfillReport,
39+
};
40+
});
41+
42+
const HERE = dirname(fileURLToPath(import.meta.url));
43+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
44+
/** oclif builds its whole command table on the first `run()` in a process. */
45+
const RUN_TIMEOUT = 60_000;
46+
47+
const engine = {
48+
getObject: () => ({ fields: {} }),
49+
find: async () => [],
50+
update: async () => ({}),
51+
getConfigs: () => ({ opportunity: {}, sys_user: {} }),
52+
};
53+
54+
const REPORT = {
55+
object: 'opportunity', field: 'amount', codeField: 'currency_code', code: 'USD',
56+
scannedRecords: 3, codeless: 2, written: 0, skippedCodePresent: 1, skippedAmountBlank: 0,
57+
sampleRecordIds: ['o1', 'o2'], applied: false, truncated: false, failures: [],
58+
};
59+
const NAMING = ['--object', 'opportunity', '--field', 'amount', '--code', 'USD'];
60+
const SQLITE_CONFIG = { client: 'better-sqlite3', connection: { filename: '/srv/app/.objectstack/data/objectstack.db' } };
61+
const POSTGRES_CONFIG = { client: 'pg', connection: 'postgres://db.example.com:5432/app' };
62+
const DRY_RUN_LINE = 'Dry run — no row or table was written. Re-run with --apply to backfill.';
63+
64+
let stdout: ReturnType<typeof vi.spyOn>;
65+
let log: ReturnType<typeof vi.spyOn>;
66+
const boot = (config?: Record<string, unknown>) => vi.mocked(bootSchemaStack).mockResolvedValue({
67+
...(config ? { driver: { config } } : {}),
68+
kernel: { getService: () => engine },
69+
dbLabel: 'test database',
70+
shutdown: vi.fn(async () => {}),
71+
} as any);
72+
beforeEach(() => {
73+
vi.mocked(probeMigrationTarget).mockResolvedValue({ status: 'free' } as any);
74+
boot();
75+
vi.mocked(backfillRecordCurrencyCodes).mockReset();
76+
vi.mocked(backfillRecordCurrencyCodes).mockResolvedValue(REPORT as any);
77+
// `emitJson` awaits the write's drain callback, so the double invokes it.
78+
stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, enc?: unknown, cb?: unknown) => {
79+
const done = typeof enc === 'function' ? enc : cb;
80+
if (typeof done === 'function') done();
81+
return true;
82+
}) as typeof process.stdout.write);
83+
log = vi.spyOn(console, 'log').mockImplementation(() => {});
84+
});
85+
afterEach(() => {
86+
stdout.mockRestore();
87+
log.mockRestore();
88+
});
89+
90+
const printedLines = (): string[] => log.mock.calls.map((c: unknown[]) => c.map(String).join(' ')).join('\n').split('\n');
91+
92+
describe('os migrate record-currency-codes', () => {
93+
it('is a dry run by default, and --yes is opt-in', () => {
94+
expect(MigrateRecordCurrencyCodes.flags.apply.default).toBe(false);
95+
expect(MigrateRecordCurrencyCodes.flags.yes.default).toBe(false);
96+
});
97+
98+
it('requires --object, --field and --code: the code is the operator\'s, never derived', () => {
99+
expect(MigrateRecordCurrencyCodes.flags.object.required).toBe(true);
100+
expect(MigrateRecordCurrencyCodes.flags.field.required).toBe(true);
101+
expect(MigrateRecordCurrencyCodes.flags.code.required).toBe(true);
102+
});
103+
104+
it('refuses to run without --code', async () => {
105+
const err = await MigrateRecordCurrencyCodes.run(['--object', 'opportunity', '--field', 'amount'], { root: CLI_ROOT })
106+
.catch((e: unknown) => e);
107+
expect(String((err as Error)?.message)).toMatch(/code/);
108+
expect(vi.mocked(backfillRecordCurrencyCodes)).not.toHaveBeenCalled();
109+
}, RUN_TIMEOUT);
110+
111+
it('hands the three names to backfillRecordCurrencyCodes as given, dry run by default', async () => {
112+
await MigrateRecordCurrencyCodes.run(['--json', ...NAMING], { root: CLI_ROOT });
113+
expect(vi.mocked(backfillRecordCurrencyCodes)).toHaveBeenCalledTimes(1);
114+
expect(vi.mocked(backfillRecordCurrencyCodes).mock.calls[0][2]).toEqual({
115+
object: 'opportunity', field: 'amount', code: 'USD', apply: false, maxRecords: undefined,
116+
});
117+
const payload = JSON.parse(stdout.mock.calls.map((c: unknown[]) => String(c[0])).join(''));
118+
expect(payload).toMatchObject({ apply: false, report: { codeless: 2, written: 0 } });
119+
}, RUN_TIMEOUT);
120+
121+
it('--apply --yes writes', async () => {
122+
await MigrateRecordCurrencyCodes.run(['--json', '--apply', '--yes', ...NAMING], { root: CLI_ROOT });
123+
expect(vi.mocked(backfillRecordCurrencyCodes).mock.calls[0][2]).toMatchObject({ apply: true });
124+
}, RUN_TIMEOUT);
125+
126+
it('an object the deployment does not declare is refused before the backfill is called', async () => {
127+
const err = await MigrateRecordCurrencyCodes.run(
128+
['--json', '--object', 'nope', '--field', 'amount', '--code', 'USD'],
129+
{ root: CLI_ROOT },
130+
).catch((e: unknown) => e);
131+
expect((err as { oclif?: { exit?: number } }).oclif?.exit).toBe(1);
132+
expect(vi.mocked(backfillRecordCurrencyCodes)).not.toHaveBeenCalled();
133+
const payload = JSON.parse(stdout.mock.calls.map((c: unknown[]) => String(c[0])).join(''));
134+
expect(payload).toMatchObject({ code: 'OBJECT_NOT_FOUND' });
135+
}, RUN_TIMEOUT);
136+
137+
it('a refused code reaches the --json envelope with its code and httpStatus, and exits 1', async () => {
138+
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);
148+
vi.mocked(backfillRecordCurrencyCodes).mockRejectedValue(refusal);
149+
150+
const err = await MigrateRecordCurrencyCodes.run(
151+
['--json', '--object', 'opportunity', '--field', 'amount', '--code', 'usd'],
152+
{ root: CLI_ROOT },
153+
).catch((e: unknown) => e);
154+
expect(isExitSignal(err)).toBe(true);
155+
expect((err as { oclif?: { exit?: number } }).oclif?.exit).toBe(1);
156+
const payload = JSON.parse(stdout.mock.calls.map((c: unknown[]) => String(c[0])).join(''));
157+
expect(payload).toMatchObject({ code: 'INVALID_FIELD', httpStatus: 400, error: refusal.message });
158+
}, RUN_TIMEOUT);
159+
160+
it('on a SQLite file the dry-run line claims no row or table and names the journal-mode switch', async () => {
161+
boot(SQLITE_CONFIG);
162+
await MigrateRecordCurrencyCodes.run(NAMING, { root: CLI_ROOT });
163+
const lines = printedLines();
164+
expect(lines).toContain('Would write USD into currency_code on 2 codeless row(s).');
165+
expect(lines.find((line) => line.startsWith('Dry run —'))).toBe(`${DRY_RUN_LINE} ${JOURNAL_MODE_SWITCH_NOTE}`);
166+
}, RUN_TIMEOUT);
167+
168+
it('on PostgreSQL the dry-run line names no switch', async () => {
169+
boot(POSTGRES_CONFIG);
170+
await MigrateRecordCurrencyCodes.run(NAMING, { root: CLI_ROOT });
171+
const lines = printedLines();
172+
expect(lines.find((line) => line.startsWith('Dry run —'))).toBe(DRY_RUN_LINE);
173+
expect(lines.join('\n')).not.toContain(JOURNAL_MODE_SWITCH_NOTE);
174+
}, RUN_TIMEOUT);
175+
});

0 commit comments

Comments
 (0)