|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Real-SQLite end-to-end for the `preserveAudit` seam (#3493 / #3549 / #3556). |
| 5 | + * |
| 6 | + * A "historical" import (`treatAsHistorical`) writes with `context.preserveAudit` |
| 7 | + * so it KEEPS the original `updated_at` and author-declared business `readonly` |
| 8 | + * fields (`closed_at`) instead of stamping-now / stripping them. The undo of such |
| 9 | + * an import mirrors that flag so restoring the captured pre-import snapshot rolls |
| 10 | + * the timeline BACK rather than re-stamping it (#3549 → fixed in #3556). |
| 11 | + * |
| 12 | + * Each half is already unit-tested in isolation — the engine's audit hook + |
| 13 | + * readonly-strip whitelist against a mock driver (objectql `plugin.integration`), |
| 14 | + * and the SQL driver's own `updated_at` force-stamp bypass against a directly- |
| 15 | + * supplied option (`driver-sql` `sql-driver-timestamp-format`). What NEITHER can |
| 16 | + * prove is the SEAM BETWEEN them: that `context.preserveAudit` set on an engine |
| 17 | + * write actually threads through `buildDriverOptions` into the driver's options |
| 18 | + * and defeats the REAL SQL `updated_at` force-stamp end-to-end. A mock/in-memory |
| 19 | + * driver echoes `data.updated_at` and never force-stamps, so it structurally |
| 20 | + * cannot catch a break in that thread. This wires the REAL {@link ObjectQL} |
| 21 | + * engine to the REAL {@link SqlDriver} (better-sqlite3, on-disk) and reads the |
| 22 | + * persisted row back to pin the whole path. |
| 23 | + */ |
| 24 | + |
| 25 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 26 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 27 | +import { tmpdir } from 'node:os'; |
| 28 | +import { join } from 'node:path'; |
| 29 | +import { ObjectQL } from '@objectstack/objectql'; |
| 30 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 31 | + |
| 32 | +const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; |
| 33 | +const HISTORICAL = '2021-03-01T09:00:00.000Z'; |
| 34 | + |
| 35 | +const TICKET = { |
| 36 | + name: 'ticket', |
| 37 | + fields: { |
| 38 | + name: { type: 'text' }, |
| 39 | + // Author-declared business readonly field — a case's close time. Normally |
| 40 | + // stripped on a client write; a historical import (preserveAudit) reinstates it. |
| 41 | + closed_at: { type: 'datetime', readonly: true }, |
| 42 | + }, |
| 43 | +}; |
| 44 | + |
| 45 | +describe('preserveAudit end-to-end on a REAL SqlDriver (#3493 / #3549)', () => { |
| 46 | + let engine: ObjectQL | null = null; |
| 47 | + let dir: string | null = null; |
| 48 | + |
| 49 | + afterEach(async () => { |
| 50 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 51 | + engine = null; |
| 52 | + if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } |
| 53 | + }); |
| 54 | + |
| 55 | + async function boot() { |
| 56 | + dir = mkdtempSync(join(tmpdir(), 'os-preserveaudit-')); |
| 57 | + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true }); |
| 58 | + await driver.initObjects([TICKET]); // create the real table + audit columns |
| 59 | + engine = new ObjectQL(); |
| 60 | + engine.registerDriver(driver, true); |
| 61 | + await engine.init(); |
| 62 | + engine.registry.registerObject(TICKET as any); |
| 63 | + return engine; |
| 64 | + } |
| 65 | + |
| 66 | + const readOne = async (id: string): Promise<any> => |
| 67 | + (await (engine as ObjectQL).find('ticket', { where: { id } }))[0]; |
| 68 | + |
| 69 | + it('historical write: preserveAudit keeps the supplied updated_at AND the business readonly closed_at, through the SQL force-stamp', async () => { |
| 70 | + const e = await boot(); |
| 71 | + const rec: any = await e.insert('ticket', { name: 'A' }); |
| 72 | + const id = rec.id; |
| 73 | + |
| 74 | + await e.update( |
| 75 | + 'ticket', |
| 76 | + { id, name: 'B', updated_at: HISTORICAL, closed_at: HISTORICAL }, |
| 77 | + { context: { preserveAudit: true } } as any, |
| 78 | + ); |
| 79 | + |
| 80 | + const row = await readOne(id); |
| 81 | + expect(row.name).toBe('B'); |
| 82 | + // The engine threaded preserveAudit into the driver options, so the driver's |
| 83 | + // updated_at force-stamp was bypassed and the supplied instant survived to disk. |
| 84 | + expect(row.updated_at).toBe(HISTORICAL); |
| 85 | + // The engine's readonly-strip whitelist admitted the business readonly field, |
| 86 | + // and it was actually written by the real driver (not just echoed by a mock). |
| 87 | + expect(row.closed_at).toBe(HISTORICAL); |
| 88 | + }); |
| 89 | + |
| 90 | + it('normal write (control): without preserveAudit the SQL driver force-stamps updated_at and the engine strips readonly closed_at', async () => { |
| 91 | + const e = await boot(); |
| 92 | + const rec: any = await e.insert('ticket', { name: 'A' }); |
| 93 | + const id = rec.id; |
| 94 | + |
| 95 | + await e.update( |
| 96 | + 'ticket', |
| 97 | + { id, name: 'B', updated_at: HISTORICAL, closed_at: HISTORICAL }, |
| 98 | + { context: {} } as any, |
| 99 | + ); |
| 100 | + |
| 101 | + const row = await readOne(id); |
| 102 | + expect(row.name).toBe('B'); |
| 103 | + expect(row.updated_at).toMatch(ISO_Z); |
| 104 | + expect(row.updated_at).not.toBe(HISTORICAL); // force-stamped "now", not the supplied instant |
| 105 | + expect(row.closed_at ?? null).toBeNull(); // readonly business field stripped — never reached the driver |
| 106 | + }); |
| 107 | + |
| 108 | + it('undo capstone (#3549): restoring a captured pre-import snapshot under preserveAudit rolls updated_at BACK; without the flag it re-stamps now (the corruption #3556 prevents)', async () => { |
| 109 | + const e = await boot(); |
| 110 | + const rec: any = await e.insert('ticket', { name: 'A' }); |
| 111 | + const id = rec.id; |
| 112 | + const original = await readOne(id); |
| 113 | + expect(original.updated_at).toMatch(ISO_Z); |
| 114 | + |
| 115 | + // Ensure "now" is measurably later than the original insert stamp. |
| 116 | + await new Promise((r) => setTimeout(r, 5)); |
| 117 | + |
| 118 | + // A historical import moves the row's recorded timeline to the historical instant. |
| 119 | + await e.update('ticket', { id, name: 'B', updated_at: HISTORICAL }, { context: { preserveAudit: true } } as any); |
| 120 | + expect((await readOne(id)).updated_at).toBe(HISTORICAL); |
| 121 | + |
| 122 | + // The pre-import snapshot the undo log captured (payload keys only). |
| 123 | + const before = { id, name: original.name, updated_at: original.updated_at }; |
| 124 | + |
| 125 | + // Undo WITH preserveAudit (the #3556 fix): the original timeline is restored, |
| 126 | + // not re-stamped — the driver's force-stamp is bypassed on the restore write too. |
| 127 | + await e.update('ticket', before, { context: { preserveAudit: true, skipAutomations: true } } as any); |
| 128 | + expect((await readOne(id)).updated_at).toBe(original.updated_at); |
| 129 | + |
| 130 | + // Control — the SAME restore WITHOUT preserveAudit (the pre-#3556 behavior): |
| 131 | + // move the timeline again, then undo with a plain context. The SQL driver |
| 132 | + // force-stamps now, so the captured original is silently lost. This is exactly |
| 133 | + // the #3549 corruption the fix removes. |
| 134 | + await e.update('ticket', { id, name: 'B2', updated_at: HISTORICAL }, { context: { preserveAudit: true } } as any); |
| 135 | + await e.update('ticket', before, { context: { skipAutomations: true } } as any); |
| 136 | + const corrupted = await readOne(id); |
| 137 | + expect(corrupted.updated_at).toMatch(ISO_Z); |
| 138 | + expect(corrupted.updated_at).not.toBe(original.updated_at); // timeline NOT restored — the bug shape |
| 139 | + }); |
| 140 | +}); |
0 commit comments