Skip to content

Commit e62d038

Browse files
committed
wip(dogfood): home projection rounding pins on better-sqlite3, sqlite-wasm and Postgres (#27192)
Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 89c3c18 commit e62d038

1 file changed

Lines changed: 211 additions & 0 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [ADR-0144 D5, D7 parameters 3 and 4, Open items 2, 3 and 6] The write-time
4+
// home projection's rounding pins on the real SQL drivers, with the platform's
5+
// own `sys_exchange_rate` object: the rate row is chosen by each dialect's
6+
// `effective_date <= ? ORDER BY effective_date DESC LIMIT 1`, read back
7+
// through each dialect's exact-decimal column, and the home amount is stored
8+
// and read back through each dialect's currency column.
9+
//
10+
// Cells: better-sqlite3 and sqlite-wasm always run; PostgreSQL runs where
11+
// `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. The Postgres
12+
// cell owns one schema, named from this file, created before and dropped
13+
// after. The in-memory face of the same pins (and the strips, defaults,
14+
// preview and predicate-update cells) is packages/objectql's
15+
// `engine-home-amount-projection.test.ts`.
16+
17+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
18+
import { basename } from 'node:path';
19+
import { fileURLToPath } from 'node:url';
20+
import { ObjectQL } from '@objectstack/objectql';
21+
import { SqlDriver } from '@objectstack/driver-sql';
22+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
23+
import { SysExchangeRate } from '@objectstack/platform-objects/system';
24+
import { installDefaultOrganization, DEFAULT_ORGANIZATION_ID } from './default-organization.testkit.js';
25+
26+
const LIVE_NAMESPACE = `os_lv_${basename(fileURLToPath(import.meta.url)).replace(/\.test\.ts$/, '').replace(/[^a-z0-9]+/gi, '_')}`;
27+
const PG_URL = process.env.OS_TEST_POSTGRES_URL;
28+
29+
async function adminRaw(config: Record<string, unknown>, statements: string[]): Promise<void> {
30+
const admin = new SqlDriver(config as never);
31+
try {
32+
for (const statement of statements) await admin.execute(statement);
33+
} finally {
34+
await admin.disconnect();
35+
}
36+
}
37+
38+
interface Cell {
39+
readonly id: string;
40+
readonly env: string | null;
41+
readonly url: string | undefined;
42+
readonly make: () => Promise<SqlDriver>;
43+
readonly teardown: () => Promise<void>;
44+
}
45+
46+
const CELLS: readonly Cell[] = [
47+
{
48+
id: 'driver-sql (better-sqlite3 :memory:)',
49+
env: null,
50+
url: 'sqlite',
51+
make: async () => new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
52+
teardown: async () => {},
53+
},
54+
{
55+
id: 'driver-sqlite-wasm (:memory:)',
56+
env: null,
57+
url: 'sqlite-wasm',
58+
make: async () => new SqliteWasmDriver({ filename: ':memory:' }),
59+
teardown: async () => {},
60+
},
61+
{
62+
id: 'driver-sql (pg)',
63+
env: 'OS_TEST_POSTGRES_URL',
64+
url: PG_URL,
65+
make: async () => {
66+
await adminRaw({ client: 'pg', connection: PG_URL }, [
67+
`drop schema if exists ${LIVE_NAMESPACE} cascade`,
68+
`create schema ${LIVE_NAMESPACE}`,
69+
]);
70+
return new SqlDriver({ client: 'pg', connection: PG_URL, searchPath: [LIVE_NAMESPACE] } as never);
71+
},
72+
teardown: async () => {
73+
await adminRaw({ client: 'pg', connection: PG_URL }, [`drop schema if exists ${LIVE_NAMESPACE} cascade`]);
74+
},
75+
},
76+
];
77+
78+
const SYS = { context: { isSystem: true } };
79+
80+
const FX_INVOICE = {
81+
name: 'fx_invoice',
82+
label: 'FX invoice',
83+
fields: {
84+
name: { name: 'name', type: 'text' },
85+
currency_code: { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' },
86+
invoice_date: { name: 'invoice_date', type: 'date' },
87+
amount: {
88+
name: 'amount',
89+
type: 'currency',
90+
currencyConfig: {
91+
currencyMode: 'record',
92+
currencyField: 'currency_code',
93+
homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' },
94+
},
95+
},
96+
amount_home: { name: 'amount_home', type: 'currency', readonly: true },
97+
amount_rate: { name: 'amount_rate', type: 'number', readonly: true },
98+
},
99+
};
100+
101+
const appWith = (objects: unknown[]) => ({
102+
id: 'com.objectstack.dogfood.home-amount-projection',
103+
name: 'Home amount projection',
104+
version: '1.0.0',
105+
type: 'plugin',
106+
scope: 'system',
107+
objects,
108+
});
109+
110+
/** Each rate as `[currency, effective_date, rate]`; every currency's rate against the home currency of the test. */
111+
const RATES: ReadonlyArray<[string, string, number]> = [
112+
['USD', '2026-01-01', 0.1389],
113+
['USD', '2026-04-01', 0.15],
114+
['USD', '2026-05-01', 7.123456789012], // twelve decimals, the column's scale
115+
['EUR', '2026-01-01', 0.0065], // read as JPY-home in the minor-unit cell
116+
['GBP', '2026-01-01', 1.0857],
117+
['CHF', '2026-01-01', 3.2468],
118+
];
119+
120+
for (const cell of CELLS) {
121+
describe.skipIf(!cell.url)(
122+
`the write-time home projection — ${cell.id}${cell.url ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
123+
() => {
124+
let driver: SqlDriver;
125+
let engine: ObjectQL;
126+
let home = 'CNY';
127+
128+
beforeAll(async () => {
129+
driver = await cell.make();
130+
engine = new ObjectQL();
131+
engine.registerDriver(driver, true);
132+
await engine.init();
133+
engine.registerApp(appWith([SysExchangeRate, FX_INVOICE]));
134+
await engine.syncSchemas();
135+
await installDefaultOrganization(engine);
136+
engine.setHomeCurrencyResolver(async () => home);
137+
for (const [currency, effective_date, rate] of RATES) {
138+
await engine.insert('sys_exchange_rate', { currency, effective_date, rate, source: 'manual' }, SYS);
139+
}
140+
});
141+
142+
afterAll(async () => {
143+
await engine?.destroy();
144+
await cell.teardown();
145+
});
146+
147+
/** Insert one invoice and read it back from the store. */
148+
async function written(row: Record<string, unknown>) {
149+
const created = await engine.insert('fx_invoice', { name: 'inv', ...row }, SYS);
150+
return read(created.id);
151+
}
152+
async function read(id: unknown) {
153+
const back = await engine.findOne('fx_invoice', { where: { id }, context: SYS.context } as never) as Record<string, unknown>;
154+
return { home: Number(back.amount_home), rate: Number(back.amount_rate), org: back.organization_id };
155+
}
156+
157+
it('the card\'s check: USD 10,000 at 0.1389 into CNY stores 71994.24 with the rate 0.1389', async () => {
158+
home = 'CNY';
159+
expect(await written({ currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }))
160+
.toEqual({ home: 71994.24, rate: 0.1389, org: DEFAULT_ORGANIZATION_ID });
161+
});
162+
163+
it('three minor-unit classes round half away from zero: JPY 0, USD 2, KWD 3', async () => {
164+
home = 'JPY';
165+
expect(await written({ currency_code: 'EUR', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 15385, rate: 0.0065 }); // 15384.615…
166+
home = 'USD';
167+
expect(await written({ currency_code: 'GBP', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 92.11, rate: 1.0857 }); // 92.1064…
168+
home = 'KWD';
169+
expect(await written({ currency_code: 'CHF', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 30.8, rate: 3.2468 }); // 30.79955…
170+
home = 'CNY';
171+
});
172+
173+
it('a twelve-decimal rate survives the column and divides exactly', async () => {
174+
// 1000 ÷ 7.123456789012 = 140.381282…
175+
expect(await written({ currency_code: 'USD', invoice_date: '2026-05-02', amount: 1000 })).toMatchObject({ home: 140.38, rate: 7.123456789012 });
176+
});
177+
178+
it('the same currency as home is rate 1, rounded to the home minor units', async () => {
179+
home = 'CNY';
180+
expect(await written({ currency_code: 'CNY', amount: 12.345 })).toMatchObject({ home: 12.35, rate: 1 });
181+
});
182+
183+
it('the rate row is the latest on or before the document date; none ⇒ refused, nothing stored', async () => {
184+
home = 'CNY';
185+
expect(await written({ currency_code: 'USD', invoice_date: '2026-03-31', amount: 150 })).toMatchObject({ home: 1079.91, rate: 0.1389 });
186+
expect(await written({ currency_code: 'USD', invoice_date: '2026-04-01', amount: 150 })).toMatchObject({ home: 1000, rate: 0.15 });
187+
const before = (await engine.find('fx_invoice', { context: SYS.context } as never)).length;
188+
const err = await engine.insert('fx_invoice', { name: 'early', currency_code: 'USD', invoice_date: '2025-12-31', amount: 1 }, SYS)
189+
.then(() => null, (e: { code?: string; status?: number; currency?: string; date?: string }) => e);
190+
expect({ code: err?.code, status: err?.status, currency: err?.currency, date: err?.date })
191+
.toEqual({ code: 'ERR_EXCHANGE_RATE_MISSING', status: 400, currency: 'USD', date: '2025-12-31' });
192+
expect((await engine.find('fx_invoice', { context: SYS.context } as never)).length).toBe(before);
193+
});
194+
195+
it('an update of the amount, the code or the date converts again; another edit does not', async () => {
196+
home = 'CNY';
197+
const { id } = await engine.insert('fx_invoice', { name: 'u', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, SYS);
198+
await engine.update('fx_invoice', { id, amount: 1389 }, SYS);
199+
expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 });
200+
await engine.update('fx_invoice', { id, invoice_date: '2026-04-15' }, SYS);
201+
expect(await read(id)).toMatchObject({ home: 9260, rate: 0.15 });
202+
await engine.update('fx_invoice', { id, currency_code: 'CNY' }, SYS);
203+
expect(await read(id)).toMatchObject({ home: 1389, rate: 1 });
204+
await engine.update('fx_invoice', { id, currency_code: 'USD', invoice_date: '2026-03-15' }, SYS);
205+
expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 });
206+
await engine.update('fx_invoice', { id, name: 'renamed' }, SYS);
207+
expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 });
208+
});
209+
},
210+
);
211+
}

0 commit comments

Comments
 (0)