|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Canonical audit-timestamp format on SQLite. |
| 5 | + * |
| 6 | + * SQLite has no native timestamp type. The two write paths used to disagree: |
| 7 | + * INSERT fell back to the column default `CURRENT_TIMESTAMP` |
| 8 | + * (`'YYYY-MM-DD HH:MM:SS'`) while UPDATE stamped |
| 9 | + * `toISOString().replace('T',' ').replace('Z','')` |
| 10 | + * (`'YYYY-MM-DD HH:MM:SS.mmm'`). BOTH were timezone-NAIVE: `Date.parse` reads a |
| 11 | + * zone-less, space-separated string as LOCAL time, so a UTC wall-clock value |
| 12 | + * silently shifted by the host offset on a non-UTC runtime — the bug that made |
| 13 | + * the objectos kernel freshness probe never evict (it compared a shifted |
| 14 | + * `updated_at` against an absolute `builtAtMs`). |
| 15 | + * |
| 16 | + * These tests pin the fix: every driver write path stamps a single canonical |
| 17 | + * ISO-8601-with-`Z` instant, INSERT and UPDATE agree on that one format, and |
| 18 | + * legacy/raw zone-naive rows are repaired to the same format on read. |
| 19 | + */ |
| 20 | + |
| 21 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 22 | +import { SqlDriver } from '../src/index.js'; |
| 23 | + |
| 24 | +const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; |
| 25 | + |
| 26 | +describe('SqlDriver canonical audit-timestamp format (SQLite)', () => { |
| 27 | + let driver: SqlDriver; |
| 28 | + let raw: any; |
| 29 | + |
| 30 | + beforeEach(async () => { |
| 31 | + driver = new SqlDriver({ |
| 32 | + client: 'better-sqlite3', |
| 33 | + connection: { filename: ':memory:' }, |
| 34 | + useNullAsDefault: true, |
| 35 | + }); |
| 36 | + raw = (driver as any).knex; |
| 37 | + await driver.initObjects([ |
| 38 | + { name: 'thing', fields: { name: { type: 'string' } } }, |
| 39 | + ]); |
| 40 | + }); |
| 41 | + |
| 42 | + afterEach(async () => { |
| 43 | + await driver.disconnect(); |
| 44 | + }); |
| 45 | + |
| 46 | + it('create() stamps created_at AND updated_at as canonical ISO-8601 Z (raw on disk)', async () => { |
| 47 | + await driver.create('thing', { id: 't1', name: 'A' }, { bypassTenantAudit: true }); |
| 48 | + const row = await raw('thing').where('id', 't1').first(); |
| 49 | + expect(row.created_at).toMatch(ISO_Z); |
| 50 | + expect(row.updated_at).toMatch(ISO_Z); |
| 51 | + // both stamped from one instant on insert |
| 52 | + expect(row.created_at).toBe(row.updated_at); |
| 53 | + }); |
| 54 | + |
| 55 | + it('update() stamps updated_at canonical, INSERT and UPDATE agree on the format, created_at is preserved', async () => { |
| 56 | + await driver.create('thing', { id: 't2', name: 'A' }, { bypassTenantAudit: true }); |
| 57 | + const inserted = await raw('thing').where('id', 't2').first(); |
| 58 | + await new Promise((r) => setTimeout(r, 5)); |
| 59 | + await driver.update('thing', 't2', { name: 'B' }, { bypassTenantAudit: true }); |
| 60 | + const updated = await raw('thing').where('id', 't2').first(); |
| 61 | + |
| 62 | + expect(updated.updated_at).toMatch(ISO_Z); // same canonical shape as insert |
| 63 | + expect(inserted.updated_at).toMatch(ISO_Z); |
| 64 | + expect(updated.created_at).toBe(inserted.created_at); // created_at immutable |
| 65 | + // Lexicographic == chronological for ISO-8601-Z, so a SQL ORDER BY is correct. |
| 66 | + expect(updated.updated_at >= updated.created_at).toBe(true); |
| 67 | + }); |
| 68 | + |
| 69 | + it('no on-disk format mixing: an inserted-only row and an updated row share one format (SQL ORDER BY safe)', async () => { |
| 70 | + await driver.create('thing', { id: 'never', name: 'x' }, { bypassTenantAudit: true }); |
| 71 | + await driver.create('thing', { id: 'edited', name: 'y' }, { bypassTenantAudit: true }); |
| 72 | + await driver.update('thing', 'edited', { name: 'y2' }, { bypassTenantAudit: true }); |
| 73 | + const rows = await raw('thing').select('updated_at'); |
| 74 | + for (const r of rows) expect(r.updated_at).toMatch(ISO_Z); |
| 75 | + }); |
| 76 | + |
| 77 | + it('preserves a caller-provided created_at, still stamps a missing updated_at', async () => { |
| 78 | + const provided = '2025-03-01T12:00:00.000Z'; |
| 79 | + await driver.create('thing', { id: 't3', name: 'A', created_at: provided }, { bypassTenantAudit: true }); |
| 80 | + const row = await raw('thing').where('id', 't3').first(); |
| 81 | + expect(row.created_at).toBe(provided); |
| 82 | + expect(row.updated_at).toMatch(ISO_Z); |
| 83 | + }); |
| 84 | + |
| 85 | + it('bulkCreate stamps canonical timestamps', async () => { |
| 86 | + await driver.bulkCreate('thing', [ |
| 87 | + { id: 'b1', name: '1' }, |
| 88 | + { id: 'b2', name: '2' }, |
| 89 | + ], { bypassTenantAudit: true }); |
| 90 | + const rows = await raw('thing').whereIn('id', ['b1', 'b2']).select('created_at', 'updated_at'); |
| 91 | + for (const r of rows) { |
| 92 | + expect(r.created_at).toMatch(ISO_Z); |
| 93 | + expect(r.updated_at).toMatch(ISO_Z); |
| 94 | + } |
| 95 | + }); |
| 96 | + |
| 97 | + it('upsert: insert stamps canonical; a conflicting merge preserves created_at and advances updated_at', async () => { |
| 98 | + await driver.upsert('thing', { id: 'u1', name: 'first' }, ['id'], { bypassTenantAudit: true }); |
| 99 | + const afterInsert = await raw('thing').where('id', 'u1').first(); |
| 100 | + expect(afterInsert.created_at).toMatch(ISO_Z); |
| 101 | + expect(afterInsert.updated_at).toMatch(ISO_Z); |
| 102 | + |
| 103 | + await new Promise((r) => setTimeout(r, 5)); |
| 104 | + await driver.upsert('thing', { id: 'u1', name: 'second' }, ['id'], { bypassTenantAudit: true }); |
| 105 | + const afterMerge = await raw('thing').where('id', 'u1').first(); |
| 106 | + expect(afterMerge.name).toBe('second'); |
| 107 | + expect(afterMerge.created_at).toBe(afterInsert.created_at); // created_at immutable on merge |
| 108 | + expect(afterMerge.updated_at).toMatch(ISO_Z); |
| 109 | + expect(afterMerge.updated_at >= afterInsert.updated_at).toBe(true); |
| 110 | + }); |
| 111 | + |
| 112 | + // ── Read-side tolerant reader (legacy / raw zone-naive rows) ──────────────── |
| 113 | + |
| 114 | + it('repairs a legacy space-separated row to canonical ISO-Z on read, interpreting it as UTC', async () => { |
| 115 | + // Simulate a row written by the OLD update stamp / CURRENT_TIMESTAMP default, |
| 116 | + // bypassing the driver write path entirely. |
| 117 | + await raw('thing').insert({ id: 'legacy', name: 'L', created_at: '2026-01-15 08:30:00', updated_at: '2026-01-15 08:30:00.246' }); |
| 118 | + const row: any = await driver.findOne('thing', 'legacy', { bypassTenantAudit: true }); |
| 119 | + expect(row.created_at).toBe('2026-01-15T08:30:00.000Z'); |
| 120 | + expect(row.updated_at).toBe('2026-01-15T08:30:00.246Z'); |
| 121 | + }); |
| 122 | + |
| 123 | + it('REGRESSION (freshness probe): the repaired instant equals the UTC wall-clock, host-timezone-independent', async () => { |
| 124 | + // The zone-naive '2026-01-15 08:30:00' must mean 08:30 UTC, NOT 08:30 local. |
| 125 | + await raw('thing').insert({ id: 'fr', name: 'F', created_at: '2026-01-15 08:30:00', updated_at: '2026-01-15 08:30:00' }); |
| 126 | + const row: any = await driver.findOne('thing', 'fr', { bypassTenantAudit: true }); |
| 127 | + expect(new Date(row.updated_at as string).getTime()).toBe(Date.parse('2026-01-15T08:30:00.000Z')); |
| 128 | + }); |
| 129 | + |
| 130 | + it('read-repair is idempotent: an already-canonical value is returned unchanged', async () => { |
| 131 | + const canonical = '2026-02-02T02:02:02.222Z'; |
| 132 | + await raw('thing').insert({ id: 'canon', name: 'C', created_at: canonical, updated_at: canonical }); |
| 133 | + const row: any = await driver.findOne('thing', 'canon', { bypassTenantAudit: true }); |
| 134 | + expect(row.created_at).toBe(canonical); |
| 135 | + expect(row.updated_at).toBe(canonical); |
| 136 | + }); |
| 137 | + |
| 138 | + it('does not mangle a Field.datetime-typed audit column stored as epoch ms', async () => { |
| 139 | + // When created_at is declared `datetime`, better-sqlite3 stores a JS Date as |
| 140 | + // INTEGER ms; the repair must leave that number alone (Field.datetime owns it). |
| 141 | + const dd = new SqlDriver({ |
| 142 | + client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, |
| 143 | + }); |
| 144 | + try { |
| 145 | + await dd.initObjects([{ name: 'evt', fields: { created_at: { type: 'datetime' }, label: { type: 'string' } } }]); |
| 146 | + await dd.create('evt', { id: 'e1', label: 'x', created_at: new Date('2026-04-04T04:04:04.004Z') }, { bypassTenantAudit: true }); |
| 147 | + const row: any = await dd.findOne('evt', 'e1', { bypassTenantAudit: true }); |
| 148 | + expect(typeof row.created_at).toBe('number'); |
| 149 | + expect(row.created_at).toBe(Date.parse('2026-04-04T04:04:04.004Z')); |
| 150 | + } finally { |
| 151 | + await dd.disconnect(); |
| 152 | + } |
| 153 | + }); |
| 154 | +}); |
0 commit comments