Skip to content

Commit bedb96e

Browse files
test(dogfood): a home field added to an object that already holds rows migrates as add_columns and the recompute fills it (multi-currency M2-4) (#30186)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgAYW6ez2s1gqUykrnhwXqEv)_ epic:#8345#1 · os-bill · session_01Utp6CZWjnSeed8H4dMjt9d Fixes #27194 Ruling-ref: 6465431966 Clause-②: no — one new test file under `packages/qa/dogfood/test/`; no export, schema, route or accepted value changes Metadata-protocol: none ## 维护者速读 - **改了什么:** 只加一个证明测试(无产品代码):存量对象先以 `record` 模式写入 4 行,再补一行 `homeAmount` 指针和两个字段,走 `os migrate apply` 同一条延迟 DDL 路径加列,然后跑 `recomputeHomeAmounts` 回填。 - **为什么改:** 证明 R1 期间建的对象升级到 R2 只需加一行指针:加列是普通 `add_columns`,回填就是 M2-3 的重算,不需要新门。 - **风险与回滚:** 无产品风险,测试卡。回滚即撤 PR。 - **先看:** `packages/qa/dogfood/test/home-amount-added-to-existing-object.test.ts`。 - **两分钟验证:** `OS_TEST_POSTGRES_URL=… npx vitest run test/home-amount-added-to-existing-object.test.ts`(在 `packages/qa/dogfood`,6 条,SQLite×2 + PG);跳过重算或跳过加列,各自变红。 - **需要您做的:** 无。 ## What changes One proving test, `packages/qa/dogfood/test/home-amount-added-to-existing-object.test.ts`, on three cells: better-sqlite3 (file), sqlite-wasm (file), PostgreSQL (one schema, named skip without `OS_TEST_POSTGRES_URL`). Each boot is its own engine and its own driver over the same database, as two processes are. 1. **Before.** An object whose amount is `currencyMode: 'record'` with no `homeAmount` and no home field. Four rows written through the engine: USD 10,000 (2026-03-15), USD 1,500 (2026-04-15), CNY 12.345, SEK 500 (no SEK rate row). 2. **The upgrade boot, as `os migrate apply` runs it.** The amount gains `currencyConfig.homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }` and the two author-declared readonly fields it names (D8 rule 2). The driver boots with `setDeferredDdl(true)` (the plan/apply deferral). Pinned: - `previewDeferredSchemaWork()` for the object is exactly `[{ kind: 'add_columns', columns: ['amount_home', 'amount_rate'] }]`, and nothing for any other table (no in-place work); - `detectManagedDrift()` has no entry for the object; - `flushDeferredSchemaDdl()` performs that same entry, and a plan after it is `[]`; - all four rows are still there with their amount and code unchanged, and an empty home field and rate. 3. **The recompute** (`recomputeHomeAmounts`, behind `os migrate recompute-home-amounts`, PR #30183): - dry run: 4 amounts, 3 changed, 1 skipped, nothing written; - apply: the column read back holds the ADR's figures (USD 10,000 at 0.1389 → 71994.24; USD 1,500 at 0.15 → 10000; CNY 12.345 at rate 1 → 12.35), each equal to a fresh write of the same row after the upgrade; - the SEK row is listed (`rate_missing`, SEK, 2026-03-15) and skipped, its home field and rate stay blank, the run is not complete (`homeAmountRecomputeComplete` false), and a fresh write of that row is refused `ERR_EXCHANGE_RATE_MISSING`; - a re-run changes nothing and still lists the one row. **Finding: the add-column path is ordinary.** No refusal, no flag, no data loss: the two columns are a plain `add_columns` entry, as for any field added to an existing object. Nothing in the migrate path changed. The in-memory driver has no add-column path (it is schemaless), so it has no cell here; the recompute's in-memory face is `packages/objectql/src/engine-home-amount-projection.test.ts`. ## Why ADR-0144 D5: a home field added to an object that already holds rows is filled by "an explicit recompute operation" (the recompute's own header names this case). #27194's task asks for the proof that the R1-to-R2 upgrade is one pointer line, the ordinary migration and the recompute, with nothing new in the product. ## Verification - The file: 6/6 passed on all three cells (PostgreSQL 16, local, `OS_TEST_POSTGRES_URL` set). - Ablation, through `scripts/ablation-replace.mjs` (each mutation landed, anchor 1 → 0, file restored after): - recompute skipped (`apply` forced to `false`): 3 failed, one per cell, on the column read back: expected `{ home: 71994.24, rate: 0.1389 }`, received `{ home: null, rate: null }`; - home field declared without the column migration (the flush replaced by a preview and the deferral disarmed): 6 failed. The plan still lists the `add_columns` entry, and the recompute's apply writes nothing: its report carries one `failures` entry per row, `no such column: amount_home` (SQLite). - `npx tsc --noEmit -p packages/qa/dogfood`: 0 errors. `eslint` on the file: exit 0. - `dispatch-gates --commands`: 51 commands, exit codes captured before any pipe: 49 exit 0; `check-plugin-teardown-shape --self-test` exited 3 on the shallow clone and exit 0 after fetching its pinned fixture commit; `pnpm check:dual-build-cjs-loads` exit 3, NOT MEASURED (it reads every package's `dist/`; this worktree built only the test's closure), left to CI. No changeset: a test file in a private package releases nothing (`skip-changeset`). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (spec not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint passes on the changed file (whole-repo `pnpm lint` left to CI). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset: n/a, nothing publishes; both declaration lines are filled in here. - [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. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CaGBD59SYKhNDMRa8h <!-- signed: epic:#8345#1 · os-bill · session_01Utp6CZWjnSeed8H4dMjt9d --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 474c030 commit bedb96e

1 file changed

Lines changed: 283 additions & 0 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [ADR-0144 D5, D7 parameter 1, D8 rule 2] The upgrade path from a `record`
4+
// mode amount with no home field to one that declares `homeAmount`, proven
5+
// with nothing new in the product:
6+
//
7+
// 1. An object already holds rows, written while its amount was `record`
8+
// mode and declared no home field (the shape before the home projection
9+
// shipped).
10+
// 2. The amount gains its `currencyConfig.homeAmount` pointer and the two
11+
// author-declared fields it names. The next boot migrates them through
12+
// the ordinary add-column path: the deferred schema sync `os migrate
13+
// plan` / `os migrate apply` run (`setDeferredDdl`,
14+
// `previewDeferredSchemaWork`, `detectManagedDrift`,
15+
// `flushDeferredSchemaDdl`). The plan lists the two columns as
16+
// `add_columns`, which is additive work, and nothing else; no drift; no
17+
// existing row is touched.
18+
// 3. The explicit recompute (`recomputeHomeAmounts`, behind `os migrate
19+
// recompute-home-amounts`) fills the new home field on every pre-existing
20+
// row with what a fresh write of the same row stores, and lists and skips
21+
// the row whose rate is missing, leaving its home field blank.
22+
//
23+
// Each boot is its own engine and its own driver over the same database, as
24+
// two processes are: a file for the SQLite cells, one schema for PostgreSQL.
25+
// Cells: better-sqlite3 and sqlite-wasm always run; PostgreSQL runs where
26+
// `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. The in-memory
27+
// driver has no columns to add, so it has no add-column path to prove.
28+
//
29+
// The recompute's own cells (a corrected rate, the skip classes, the
30+
// timezone, two organizations) are `home-amount-projection.test.ts`; this
31+
// file reuses its object and rates.
32+
33+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
34+
import { mkdtempSync, rmSync } from 'node:fs';
35+
import { tmpdir } from 'node:os';
36+
import { basename, join } from 'node:path';
37+
import { fileURLToPath } from 'node:url';
38+
import { ObjectQL, recomputeHomeAmounts, homeAmountRecomputeComplete } from '@objectstack/objectql';
39+
import { SqlDriver } from '@objectstack/driver-sql';
40+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
41+
import { SysExchangeRate } from '@objectstack/platform-objects/system';
42+
import { installDefaultOrganization, DEFAULT_ORGANIZATION_ID } from './default-organization.testkit.js';
43+
44+
const LIVE_NAMESPACE = `os_lv_${basename(fileURLToPath(import.meta.url)).replace(/\.test\.ts$/, '').replace(/[^a-z0-9]+/gi, '_')}`;
45+
const PG_URL = process.env.OS_TEST_POSTGRES_URL;
46+
47+
async function adminRaw(statements: string[]): Promise<void> {
48+
const admin = new SqlDriver({ client: 'pg', connection: PG_URL } as never);
49+
try {
50+
for (const statement of statements) await admin.execute(statement);
51+
} finally {
52+
await admin.disconnect();
53+
}
54+
}
55+
56+
interface Cell {
57+
readonly id: string;
58+
readonly env: string | null;
59+
readonly url: string | undefined;
60+
/** Create the empty database, before the first boot. */
61+
readonly setup: () => Promise<void>;
62+
/** A new driver over the same database: one per boot. */
63+
readonly open: () => SqlDriver;
64+
readonly teardown: () => Promise<void>;
65+
}
66+
67+
let dir = '';
68+
const fileIn = (name: string) => join(dir, name);
69+
70+
const CELLS: readonly Cell[] = [
71+
{
72+
id: 'driver-sql (better-sqlite3 file)',
73+
env: null,
74+
url: 'sqlite',
75+
setup: async () => { dir = mkdtempSync(join(tmpdir(), 'os-home-upgrade-')); },
76+
open: () => new SqlDriver({ client: 'better-sqlite3', connection: { filename: fileIn('upgrade.db') }, useNullAsDefault: true }),
77+
teardown: async () => { rmSync(dir, { recursive: true, force: true }); },
78+
},
79+
{
80+
id: 'driver-sqlite-wasm (file)',
81+
env: null,
82+
url: 'sqlite-wasm',
83+
setup: async () => { dir = mkdtempSync(join(tmpdir(), 'os-home-upgrade-')); },
84+
open: () => new SqliteWasmDriver({ filename: fileIn('upgrade-wasm.db') }),
85+
teardown: async () => { rmSync(dir, { recursive: true, force: true }); },
86+
},
87+
{
88+
id: 'driver-sql (pg)',
89+
env: 'OS_TEST_POSTGRES_URL',
90+
url: PG_URL,
91+
setup: async () => {
92+
await adminRaw([`drop schema if exists ${LIVE_NAMESPACE} cascade`, `create schema ${LIVE_NAMESPACE}`]);
93+
},
94+
open: () => new SqlDriver({ client: 'pg', connection: PG_URL, searchPath: [LIVE_NAMESPACE] } as never),
95+
teardown: async () => { await adminRaw([`drop schema if exists ${LIVE_NAMESPACE} cascade`]); },
96+
},
97+
];
98+
99+
const SYS = { context: { isSystem: true } };
100+
const OBJECT = 'fx_upgrade';
101+
102+
/** Before: a `record`-mode amount with no home field. */
103+
const BEFORE = {
104+
name: OBJECT,
105+
label: 'FX upgrade',
106+
fields: {
107+
name: { name: 'name', type: 'text' },
108+
currency_code: { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' },
109+
invoice_date: { name: 'invoice_date', type: 'date' },
110+
amount: {
111+
name: 'amount',
112+
type: 'currency',
113+
currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' },
114+
},
115+
},
116+
};
117+
118+
/** After: the one pointer line, and the two fields it names (D8 rule 2: declared by the author, never injected). */
119+
const AFTER = {
120+
...BEFORE,
121+
fields: {
122+
...BEFORE.fields,
123+
amount: {
124+
...BEFORE.fields.amount,
125+
currencyConfig: {
126+
...BEFORE.fields.amount.currencyConfig,
127+
homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' },
128+
},
129+
},
130+
amount_home: { name: 'amount_home', type: 'currency', readonly: true },
131+
amount_rate: { name: 'amount_rate', type: 'number', readonly: true },
132+
},
133+
};
134+
135+
const appWith = (object: unknown) => ({
136+
id: 'com.objectstack.dogfood.home-amount-added-to-existing-object',
137+
name: 'Home amount added to an existing object',
138+
version: '1.0.0',
139+
type: 'plugin',
140+
scope: 'system',
141+
objects: [SysExchangeRate, object],
142+
});
143+
144+
const RATES: ReadonlyArray<[string, string, number]> = [
145+
['USD', '2026-01-01', 0.1389],
146+
['USD', '2026-04-01', 0.15],
147+
];
148+
149+
/** The rows stored before the upgrade; SEK has no rate row. */
150+
const ROWS: ReadonlyArray<Record<string, unknown>> = [
151+
{ id: 'up_usd_march', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 },
152+
{ id: 'up_usd_april', currency_code: 'USD', invoice_date: '2026-04-15', amount: 1500 },
153+
{ id: 'up_cny', currency_code: 'CNY', invoice_date: '2026-03-15', amount: 12.345 },
154+
{ id: 'up_sek', currency_code: 'SEK', invoice_date: '2026-03-15', amount: 500 },
155+
];
156+
157+
const quiet = { info: () => {}, warn: () => {} };
158+
159+
async function boot(driver: SqlDriver, object: unknown, options: { deferDdl?: boolean } = {}): Promise<ObjectQL> {
160+
// `os migrate plan` / `apply` boot with the deferral armed, on the driver
161+
// before the engine holds it (the CLI's `DeferSchemaDdlPlugin` arms a driver
162+
// ahead of `registerDriver`): the sync registers the metadata and records
163+
// the physical work instead of doing it.
164+
if (options.deferDdl) driver.setDeferredDdl(true);
165+
const engine = new ObjectQL();
166+
engine.registerDriver(driver, true);
167+
await engine.init();
168+
engine.registerApp(appWith(object));
169+
await engine.syncSchemas();
170+
await installDefaultOrganization(engine);
171+
engine.setHomeLocalizationResolver(async () => ({ currency: 'CNY' }));
172+
return engine;
173+
}
174+
175+
for (const cell of CELLS) {
176+
describe.skipIf(!cell.url)(
177+
`a home field added to an object that already holds rows — ${cell.id}${cell.url ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
178+
() => {
179+
let driver: SqlDriver;
180+
let engine: ObjectQL;
181+
182+
beforeAll(async () => {
183+
await cell.setup();
184+
// ── The first boot: the object with no home field, and its rows. ──
185+
const first = await boot(cell.open(), BEFORE);
186+
for (const [currency, effective_date, rate] of RATES) {
187+
await first.insert('sys_exchange_rate', { currency, effective_date, rate, source: 'manual' }, SYS);
188+
}
189+
for (const row of ROWS) await first.insert(OBJECT, { name: row.id, ...row }, SYS);
190+
await first.destroy();
191+
});
192+
193+
afterAll(async () => {
194+
await engine?.destroy();
195+
await cell.teardown();
196+
});
197+
198+
async function stored(id: unknown) {
199+
const back = await engine.findOne(OBJECT, { where: { id }, context: SYS.context } as never) as Record<string, unknown>;
200+
return {
201+
amount: Number(back.amount),
202+
currency: back.currency_code,
203+
home: back.amount_home == null ? null : Number(back.amount_home),
204+
rate: back.amount_rate == null ? null : Number(back.amount_rate),
205+
};
206+
}
207+
208+
// ── The upgrade boot, as `os migrate apply` runs it: the plan is read,
209+
// then the confirmed apply performs it. Read here, once, so each test
210+
// below stands alone. ──
211+
let plan: Awaited<ReturnType<SqlDriver['previewDeferredSchemaWork']>> = [];
212+
let drift: Awaited<ReturnType<SqlDriver['detectManagedDrift']>> = [];
213+
let performed: Awaited<ReturnType<SqlDriver['flushDeferredSchemaDdl']>> = [];
214+
let planAfter: Awaited<ReturnType<SqlDriver['previewDeferredSchemaWork']>> = [];
215+
216+
beforeAll(async () => {
217+
driver = cell.open();
218+
engine = await boot(driver, AFTER, { deferDdl: true });
219+
plan = await driver.previewDeferredSchemaWork();
220+
drift = await driver.detectManagedDrift();
221+
performed = await driver.flushDeferredSchemaDdl();
222+
planAfter = await driver.previewDeferredSchemaWork();
223+
});
224+
225+
it('the declared home field migrates through the ordinary add-column path, and no stored row moves', async () => {
226+
// The plan: the two declared columns, as additive work; nothing rewrites a row.
227+
const table = plan.filter((w) => w.table === OBJECT);
228+
expect(table).toEqual([{ table: OBJECT, kind: 'add_columns', columns: ['amount_home', 'amount_rate'] }]);
229+
expect(plan.filter((w) => w.table !== OBJECT)).toEqual([]);
230+
expect(drift.filter((d) => d.table === OBJECT)).toEqual([]);
231+
232+
// The confirmed apply performed exactly that, and left nothing pending.
233+
expect(performed.filter((w) => w.table === OBJECT)).toEqual(table);
234+
expect(planAfter).toEqual([]);
235+
236+
// Every stored row is still there, as it was, with an empty home field.
237+
expect((await engine.find(OBJECT, { context: SYS.context } as never)).length).toBe(ROWS.length);
238+
for (const row of ROWS) {
239+
expect(await stored(row.id)).toEqual({ amount: row.amount, currency: row.currency_code, home: null, rate: null });
240+
}
241+
});
242+
243+
it('the recompute fills every pre-existing row with what a fresh write stores; the row with no rate is listed and skipped', async () => {
244+
const run = (apply = false) => recomputeHomeAmounts(engine as never, quiet, { object: OBJECT, apply });
245+
246+
const dry = await run();
247+
expect(dry).toMatchObject({ amounts: 4, unchanged: 0, changed: 3, written: 0, skippedTotal: 1, failures: [] });
248+
for (const row of ROWS) expect((await stored(row.id)).home).toBeNull();
249+
250+
const applied = await run(true);
251+
// What the column holds, read back: the ADR's own figures (USD 10,000 at
252+
// 0.1389 is ¥71,994.24; the same currency is rate 1, rounded once).
253+
expect(await stored('up_usd_march')).toMatchObject({ home: 71994.24, rate: 0.1389 });
254+
expect(await stored('up_usd_april')).toMatchObject({ home: 10000, rate: 0.15 });
255+
expect(await stored('up_cny')).toMatchObject({ home: 12.35, rate: 1 });
256+
// The skipped row keeps a blank home field, never a fallback.
257+
expect(await stored('up_sek')).toMatchObject({ home: null, rate: null });
258+
259+
// What the run reported.
260+
expect(applied).toMatchObject({ amounts: 4, changed: 3, written: 3, skippedTotal: 1, failures: [] });
261+
expect(applied.skipped).toEqual([
262+
{ recordId: 'up_sek', organizationId: DEFAULT_ORGANIZATION_ID, field: 'amount', reason: 'rate_missing', currency: 'SEK', date: '2026-03-15' },
263+
]);
264+
expect(homeAmountRecomputeComplete(applied)).toBe(false);
265+
266+
// Equal to a fresh write of the same row, after the upgrade.
267+
for (const row of ROWS.filter((r) => r.id !== 'up_sek')) {
268+
const { id: _old, ...values } = row;
269+
const fresh = await engine.insert(OBJECT, { name: `fresh_${String(row.id)}`, ...values }, SYS);
270+
expect(await stored(row.id)).toEqual(await stored(fresh.id));
271+
}
272+
// A fresh write of the skipped row is refused for the same missing rate.
273+
const refused = await engine.insert(OBJECT, { name: 'fresh_sek', currency_code: 'SEK', invoice_date: '2026-03-15', amount: 500 }, SYS)
274+
.then(() => null, (e: { code?: string }) => e);
275+
expect(refused?.code).toBe('ERR_EXCHANGE_RATE_MISSING');
276+
277+
// A re-run changes nothing and still lists the one row.
278+
const again = await run(true);
279+
expect(again).toMatchObject({ amounts: 7, unchanged: 6, changed: 0, written: 0, skippedTotal: 1 });
280+
});
281+
},
282+
);
283+
}

0 commit comments

Comments
 (0)