|
| 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