Skip to content

Commit 071872b

Browse files
committed
test(runtime): pin a seed replay over every blank-as-null arm (#29277)
Claude-Session: https://claude.ai/code/session_01ADHkmRPC4f86r9bWGuYWJu Co-authored-by: Claude <noreply@anthropic.com>
1 parent 375469f commit 071872b

1 file changed

Lines changed: 144 additions & 0 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A seed replay compares a blank in its STORED form — measured on a REAL
5+
* `SqlDriver` under a real `ObjectQL`.
6+
*
7+
* The engine's write door stores some blanks as `null` (`blankStorageForm`,
8+
* `@objectstack/core`): any blank on a non-string-typed column (#20308),
9+
* exactly `''` on an optional unique text column (#29176), and exactly `''` on
10+
* an optional one-string column on the `$empty` table's `null_only` row
11+
* (#29277). A seed that writes `''` there lands as `null`, so a replay that
12+
* compared the seed's `''` with the stored `null` literally read every such
13+
* row as changed and rewrote it on every boot — the churn `isNoOpReplay`
14+
* exists to prevent (`updated_at` bump, re-validation, a phantom history
15+
* edit). The loader now asks the same predicate the door asks.
16+
*
17+
* Pinned, one arm per column, each against its own control:
18+
*
19+
* 1. A seed `''` on each arm's column lands as `null`, and a replay of the
20+
* same seed skips every row (`totalUpdated: 0`).
21+
* 2. Control: on a plain optional text column the door keeps `''`, so a
22+
* stored `null` against a seed `''` is still a change: the row is
23+
* rewritten, and stores `''`.
24+
*/
25+
26+
import { describe, it, expect, afterEach } from 'vitest';
27+
import { mkdtempSync, rmSync } from 'node:fs';
28+
import { tmpdir } from 'node:os';
29+
import { join } from 'node:path';
30+
import { ObjectQL } from '@objectstack/objectql';
31+
import { SeedLoaderService } from '@objectstack/metadata-protocol';
32+
import { SqlDriver } from '@objectstack/driver-sql';
33+
import {
34+
captureExpectedReadRefusals,
35+
type ExpectedReadRefusalCapture,
36+
} from './expected-read-refusal-noise.js';
37+
38+
const ROW = {
39+
name: 'blank_replay_row',
40+
fields: {
41+
name: { type: 'text', required: true },
42+
qty: { type: 'number' },
43+
due: { type: 'date' },
44+
sku: { type: 'text', unique: true },
45+
stage: { type: 'select', options: [{ value: 'a', label: 'A' }] },
46+
note: { type: 'text' },
47+
},
48+
};
49+
const OBJECTS = [ROW];
50+
51+
const ENV = ['prod', 'dev', 'test'];
52+
const SEED_CONFIG = {
53+
dryRun: false, haltOnError: false, multiPass: true,
54+
defaultMode: 'upsert', batchSize: 1000, transaction: false,
55+
} as any;
56+
const logger = { info() {}, warn() {}, error() {}, debug() {} };
57+
58+
/** One row per arm, each blank on its own column only. */
59+
const SEED = {
60+
object: 'blank_replay_row', externalId: 'name', mode: 'upsert', env: ENV,
61+
records: [
62+
{ name: 'typed-number', qty: '' },
63+
{ name: 'typed-date', due: '' },
64+
{ name: 'unique-text', sku: '' },
65+
{ name: 'one-string', stage: '' },
66+
],
67+
};
68+
69+
function metadataFor(objects: any[]) {
70+
const byName = new Map(objects.map((o) => [o.name, o]));
71+
return {
72+
getObject: async (name: string) => byName.get(name),
73+
listObjects: async () => objects,
74+
register: async () => {}, get: async (_t: string, n: string) => byName.get(n),
75+
list: async () => [], unregister: async () => {}, exists: async () => false, listNames: async () => [],
76+
} as any;
77+
}
78+
79+
describe('a seed replay compares a blank in its stored form, on a REAL SqlDriver', () => {
80+
let dir: string | null = null;
81+
let engine: ObjectQL | null = null;
82+
let noise: ExpectedReadRefusalCapture | null = null;
83+
84+
afterEach(async () => {
85+
try { await engine?.destroy(); } catch { /* noop */ }
86+
engine = null;
87+
if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; }
88+
expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]);
89+
noise = null;
90+
});
91+
92+
async function boot() {
93+
dir = mkdtempSync(join(tmpdir(), 'os-seed-blank-replay-'));
94+
const driver = new SqlDriver({
95+
client: 'better-sqlite3',
96+
connection: { filename: join(dir, 'data.sqlite') },
97+
useNullAsDefault: true,
98+
});
99+
noise = captureExpectedReadRefusals(['sys_organization']);
100+
noise.captureDriver(driver);
101+
await driver.initObjects(OBJECTS);
102+
engine = new ObjectQL();
103+
noise.captureEngine(engine);
104+
engine.registerDriver(driver, true);
105+
await engine.init();
106+
for (const o of OBJECTS) engine.registry.registerObject(o as any);
107+
const knex = (driver as any).knex;
108+
const stored = async (name: string) => (await knex('blank_replay_row').where({ name }).first()) as Record<string, unknown>;
109+
return { loader: new SeedLoaderService(engine as any, metadataFor(OBJECTS), logger), stored };
110+
}
111+
112+
it('each blank-as-null arm lands as null, and a replay skips every row', async () => {
113+
const { loader, stored } = await boot();
114+
115+
const first = await loader.load({ seeds: [SEED] as any, config: SEED_CONFIG });
116+
expect(first.errors.map((x: any) => x.message)).toEqual([]);
117+
expect(first.summary.totalInserted).toBe(4);
118+
expect((await stored('typed-number')).qty).toBeNull();
119+
expect((await stored('typed-date')).due).toBeNull();
120+
expect((await stored('unique-text')).sku).toBeNull();
121+
expect((await stored('one-string')).stage).toBeNull();
122+
123+
const replay = await loader.load({ seeds: [SEED] as any, config: SEED_CONFIG });
124+
expect(replay.errors.map((x: any) => x.message)).toEqual([]);
125+
expect(replay.summary.totalInserted).toBe(0);
126+
expect(replay.summary.totalUpdated).toBe(0);
127+
expect(replay.summary.totalSkipped).toBe(4);
128+
});
129+
130+
it("control: a plain text column keeps '', so a stored null against a seed '' is still a change", async () => {
131+
const { loader, stored } = await boot();
132+
const seedNull = { ...SEED, records: [{ name: 'plain', note: null }] };
133+
const seedBlank = { ...SEED, records: [{ name: 'plain', note: '' }] };
134+
135+
const first = await loader.load({ seeds: [seedNull] as any, config: SEED_CONFIG });
136+
expect(first.summary.totalInserted).toBe(1);
137+
expect((await stored('plain')).note).toBeNull();
138+
139+
const changed = await loader.load({ seeds: [seedBlank] as any, config: SEED_CONFIG });
140+
expect(changed.errors.map((x: any) => x.message)).toEqual([]);
141+
expect(changed.summary.totalUpdated).toBe(1);
142+
expect((await stored('plain')).note).toBe('');
143+
});
144+
});

0 commit comments

Comments
 (0)