From d9abadb5980b78ebb4a4efe1125a23f8b5bb67b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 00:25:59 +0000 Subject: [PATCH 1/4] spike(record-adapter-do-sqlite): probe DO SQLite transaction/pragma behavior for #161 Throwaway probes against the real Workers runtime (@cloudflare/vitest-pool-workers), not the shipped adapter. Findings, to inform the real implementation: - Raw BEGIN/COMMIT/ROLLBACK via sql.exec() is rejected outright. - DO does NOT auto-rollback a write when a later exception is thrown -- each sql.exec() auto-commits immediately, so no-op'ing BEGIN/COMMIT/ROLLBACK would silently break atomicity. - ctx.storage.transactionSync(fn) does roll back on throw, commits on success, and passes return values through synchronously. - PRAGMA foreign_keys = ON works and enforces; PRAGMA journal_mode = WAL is rejected (not authorized) -- expected, DO owns its own durability. - FK/unique violation error strings match what isForeignKeyViolation/ isUniqueConstraintViolation already check for. - Cursor exposes toArray()/one()/raw()/rowsRead/rowsWritten; parameter binding is spread-args, same convention as node:sqlite. Net conclusion: SharedSqlRecordLogic's exec('BEGIN')/'COMMIT'/'ROLLBACK' calls can't translate to DO as-is -- transactionSync's callback boundary doesn't match three independent string-based exec() calls. Needs a SqlExecutor.transaction(fn) primitive instead, touching record-logic.ts and record-adapter-sqlite's executor, not just this new package. pnpm-workspace.yaml: allow workerd's postinstall build script (needed by wrangler/miniflare for the Workers test pool). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VJoUkCo4dLjXsDsNDkYjbx --- .../record-adapter-do-sqlite/package.json | 22 + .../src/spike-worker.ts | 275 ++++ .../tests/spike.test.ts | 99 ++ .../record-adapter-do-sqlite/vitest.config.ts | 6 + .../record-adapter-do-sqlite/wrangler.jsonc | 20 + pnpm-lock.yaml | 1299 ++++++++++++++++- pnpm-workspace.yaml | 1 + 7 files changed, 1700 insertions(+), 22 deletions(-) create mode 100644 packages/record-adapter-do-sqlite/package.json create mode 100644 packages/record-adapter-do-sqlite/src/spike-worker.ts create mode 100644 packages/record-adapter-do-sqlite/tests/spike.test.ts create mode 100644 packages/record-adapter-do-sqlite/vitest.config.ts create mode 100644 packages/record-adapter-do-sqlite/wrangler.jsonc diff --git a/packages/record-adapter-do-sqlite/package.json b/packages/record-adapter-do-sqlite/package.json new file mode 100644 index 0000000..452b38c --- /dev/null +++ b/packages/record-adapter-do-sqlite/package.json @@ -0,0 +1,22 @@ +{ + "name": "@haverstack/record-adapter-do-sqlite", + "version": "0.0.0", + "private": true, + "description": "Cloudflare Durable Objects (SQLite storage) record adapter for Haverstack — spike", + "type": "module", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@haverstack/core": "workspace:^" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.22.0", + "@cloudflare/workers-types": "^5.0.0", + "@haverstack/sqlite-shared": "workspace:*", + "typescript": "^5.5.0", + "vite": "^7.0.0", + "vitest": "^4.1.0", + "wrangler": "^4.127.1" + } +} diff --git a/packages/record-adapter-do-sqlite/src/spike-worker.ts b/packages/record-adapter-do-sqlite/src/spike-worker.ts new file mode 100644 index 0000000..403b760 --- /dev/null +++ b/packages/record-adapter-do-sqlite/src/spike-worker.ts @@ -0,0 +1,275 @@ +import { DurableObject } from 'cloudflare:workers'; + +type ProbeResult = Record; + +export class SpikeDurableObject extends DurableObject { + private get sql(): SqlStorage { + return this.ctx.storage.sql; + } + + private schema(): void { + this.sql.exec(` + CREATE TABLE IF NOT EXISTS parent (id TEXT PRIMARY KEY, name TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS child (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL REFERENCES parent(id)); + `); + } + + async probePragmas(): Promise { + const out: ProbeResult = {}; + try { + this.sql.exec('PRAGMA foreign_keys = ON;'); + out.foreignKeysPragma = 'ok'; + } catch (err) { + out.foreignKeysPragma = `error: ${(err as Error).message}`; + } + try { + const rows = this.sql.exec('PRAGMA foreign_keys;').toArray(); + out.foreignKeysValue = rows; + } catch (err) { + out.foreignKeysValue = `error: ${(err as Error).message}`; + } + try { + this.sql.exec('PRAGMA journal_mode = WAL;'); + out.journalModeWalPragma = 'ok'; + } catch (err) { + out.journalModeWalPragma = `error: ${(err as Error).message}`; + } + try { + const rows = this.sql.exec('PRAGMA journal_mode;').toArray(); + out.journalModeValue = rows; + } catch (err) { + out.journalModeValue = `error: ${(err as Error).message}`; + } + return out; + } + + async probeForeignKeyEnforcement(): Promise { + this.schema(); + this.sql.exec('PRAGMA foreign_keys = ON;'); + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('p1', 'Parent One');`); + const out: ProbeResult = {}; + try { + this.sql.exec(`INSERT INTO child (id, parent_id) VALUES ('c1', 'does-not-exist');`); + out.danglingInsert = 'succeeded (NO enforcement!)'; + } catch (err) { + out.danglingInsert = 'rejected'; + out.errorMessage = (err as Error).message; + out.errorConstructorName = (err as Error).constructor?.name; + out.errorKeys = Object.keys(err as object); + out.errorJson = JSON.stringify(err, Object.getOwnPropertyNames(err as object)); + } + return out; + } + + async probeUniqueViolation(): Promise { + this.schema(); + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('dup', 'First');`); + const out: ProbeResult = {}; + try { + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('dup', 'Second');`); + out.duplicateInsert = 'succeeded (NO uniqueness enforcement!)'; + } catch (err) { + out.duplicateInsert = 'rejected'; + out.errorMessage = (err as Error).message; + out.errorConstructorName = (err as Error).constructor?.name; + } + return out; + } + + async probeRawTransaction(): Promise { + this.schema(); + const out: ProbeResult = {}; + + // Commit path + try { + this.sql.exec('BEGIN'); + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('tx-commit', 'Committed');`); + this.sql.exec('COMMIT'); + const row = this.sql.exec(`SELECT * FROM parent WHERE id = 'tx-commit';`).toArray(); + out.commitPath = 'ok'; + out.commitRow = row; + } catch (err) { + out.commitPath = `error: ${(err as Error).message}`; + } + + // Rollback path + try { + this.sql.exec('BEGIN'); + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('tx-rollback', 'ShouldNotPersist');`); + this.sql.exec('ROLLBACK'); + const row = this.sql + .exec(`SELECT * FROM parent WHERE id = 'tx-rollback';`) + .toArray(); + out.rollbackPath = 'ok'; + out.rollbackRowCountAfterRollback = row.length; + } catch (err) { + out.rollbackPath = `error: ${(err as Error).message}`; + } + + // Nested/nested nesting sanity — does a second BEGIN before COMMIT error? + try { + this.sql.exec('BEGIN'); + this.sql.exec('BEGIN'); + out.nestedBegin = 'second BEGIN did not throw'; + this.sql.exec('ROLLBACK'); + } catch (err) { + out.nestedBegin = `threw: ${(err as Error).message}`; + try { + this.sql.exec('ROLLBACK'); + } catch { + /* best-effort cleanup */ + } + } + + return out; + } + + async probeCursorShape(): Promise { + this.schema(); + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('cur1', 'One'), ('cur2', 'Two');`); + const cursor = this.sql.exec('SELECT * FROM parent ORDER BY id;'); + const out: ProbeResult = { + hasToArray: typeof (cursor as any).toArray === 'function', + hasOne: typeof (cursor as any).one === 'function', + hasRaw: typeof (cursor as any).raw === 'function', + hasNext: typeof (cursor as any).next === 'function', + hasSymbolIterator: typeof (cursor as any)[Symbol.iterator] === 'function', + columnNames: (cursor as any).columnNames, + rowsRead: (cursor as any).rowsRead, + rowsWritten: (cursor as any).rowsWritten, + }; + out.toArrayResult = cursor.toArray(); + + const insertCursor = this.sql.exec( + `INSERT INTO parent (id, name) VALUES ('cur3', 'Three');`, + ); + out.insertRowsWritten = (insertCursor as any).rowsWritten; + out.insertRowsRead = (insertCursor as any).rowsRead; + + const updateCursor = this.sql.exec(`UPDATE parent SET name = 'Updated' WHERE id = 'cur1';`); + out.updateRowsWritten = (updateCursor as any).rowsWritten; + + const deleteCursor = this.sql.exec(`DELETE FROM parent WHERE id = 'cur2';`); + out.deleteRowsWritten = (deleteCursor as any).rowsWritten; + + return out; + } + + async probeFts5(): Promise { + const out: ProbeResult = {}; + try { + this.sql.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(content); + `); + this.sql.exec(`INSERT INTO docs_fts (rowid, content) VALUES (1, 'the quick brown fox');`); + this.sql.exec(`INSERT INTO docs_fts (rowid, content) VALUES (2, 'lazy dog sleeps');`); + const rows = this.sql + .exec(`SELECT rowid, content FROM docs_fts WHERE docs_fts MATCH 'fox';`) + .toArray(); + out.fts5 = 'ok'; + out.matchRows = rows; + } catch (err) { + out.fts5 = `error: ${(err as Error).message}`; + } + return out; + } + + /** + * The critical question for #161: SharedSqlRecordLogic issues raw + * BEGIN/COMMIT/ROLLBACK, which DO SQLite rejects outright (see + * probeRawTransaction). Its error points at automatic "atomic write + * coalescing" instead. If that coalescing actually undoes writes when + * an exception unwinds a synchronous stretch of storage calls — even + * without ROLLBACK ever being called — then BEGIN/COMMIT/ROLLBACK can + * become no-ops in the executor with zero changes to the shared logic. + * This probe writes a row, then throws before returning, with no + * ROLLBACK anywhere, and reports whether the write survived. + */ + // NOTE: these deliberately do NOT catch — the exception must actually + // unwind out of the RPC call boundary for this to test what it claims + // to test. The caller (test file) awaits-and-catches, then makes a + // *separate* RPC call to check what actually persisted. + + async writeThenThrow_sync(): Promise { + this.schema(); + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('auto-rb-sync', 'x');`); + throw new Error('simulated business-logic failure, no ROLLBACK issued'); + } + + async writeThenThrow_microtask(): Promise { + this.schema(); + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('auto-rb-micro', 'x');`); + await Promise.resolve(); + throw new Error('simulated business-logic failure after a microtask hop'); + } + + async writeThenThrow_multiStatement(): Promise { + this.schema(); + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('multi-1', 'x');`); + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('multi-2', 'x');`); + this.sql.exec(`UPDATE parent SET name = 'updated' WHERE id = 'multi-1';`); + throw new Error('fail after three statements, before returning'); + } + + /** + * If BEGIN/COMMIT/ROLLBACK become no-ops in the executor, atomicity has + * to come from somewhere else: wrapping the whole adapter method call + * in ctx.storage.transactionSync(), whose contract explicitly promises + * auto-rollback on a thrown exception. Confirms that promise holds for + * a synchronous multi-statement sequence with mixed reads/writes. + */ + async transactionSyncThenThrow(): Promise { + this.schema(); + this.ctx.storage.transactionSync(() => { + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('txsync-1', 'x');`); + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('txsync-2', 'x');`); + const check = this.sql.exec(`SELECT * FROM parent WHERE id = 'txsync-1';`).toArray(); + if (check.length !== 1) throw new Error('unexpected read result'); + throw new Error('simulated business-logic failure inside transactionSync'); + }); + } + + async transactionSyncReturnsValue(): Promise { + this.schema(); + const result = this.ctx.storage.transactionSync(() => { + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('txsync-ret', 'x');`); + const row = this.sql.exec(`SELECT * FROM parent WHERE id = 'txsync-ret';`).one(); + return { fromInsideTxn: row }; + }); + return { returnedValue: result }; + } + + async transactionSyncCommits(): Promise { + this.schema(); + this.ctx.storage.transactionSync(() => { + this.sql.exec(`INSERT INTO parent (id, name) VALUES ('txsync-ok', 'x');`); + }); + const rows = this.sql.exec(`SELECT * FROM parent WHERE id = 'txsync-ok';`).toArray(); + return { rows }; + } + + async checkSurvivors(likePattern: string): Promise { + const rows = this.sql.exec(`SELECT * FROM parent WHERE id LIKE ? ORDER BY id;`, likePattern).toArray(); + return { rows }; + } + + async probeBindingStyle(): Promise { + this.schema(); + const out: ProbeResult = {}; + try { + this.sql.exec(`INSERT INTO parent (id, name) VALUES (?, ?);`, 'bind1', 'Bound Name'); + const row = this.sql.exec(`SELECT * FROM parent WHERE id = ?;`, 'bind1').toArray(); + out.spreadArgsBinding = 'ok'; + out.row = row; + } catch (err) { + out.spreadArgsBinding = `error: ${(err as Error).message}`; + } + return out; + } +} + +export default { + async fetch(): Promise { + return new Response('spike worker: no HTTP surface, use RPC stub methods in tests'); + }, +}; diff --git a/packages/record-adapter-do-sqlite/tests/spike.test.ts b/packages/record-adapter-do-sqlite/tests/spike.test.ts new file mode 100644 index 0000000..2b186c0 --- /dev/null +++ b/packages/record-adapter-do-sqlite/tests/spike.test.ts @@ -0,0 +1,99 @@ +import { env } from 'cloudflare:test'; +import { describe, test, expect } from 'vitest'; + +// Spike for #161 — answers questions that determine whether +// SharedSqlRecordLogic's synchronous, raw-BEGIN/COMMIT-based SqlExecutor +// contract can be reused as-is against ctx.storage.sql on a DO, or needs +// a different transaction/pragma strategy. Findings are printed via +// console.log (visible in the workers pool's captured output) rather than +// asserted strictly, since the goal here is discovery, not regression +// coverage — the real adapter's tests replace this file. + +const getStub = () => { + const id = env.SPIKE_DO.idFromName(`spike-${Math.random()}`); + return env.SPIKE_DO.get(id); +}; + +describe('DO SQLite spike', () => { + test('pragmas', async () => { + const stub = getStub(); + const result = await stub.probePragmas(); + console.log('PRAGMAS:', JSON.stringify(result, null, 2)); + }); + + test('foreign key enforcement', async () => { + const stub = getStub(); + const result = await stub.probeForeignKeyEnforcement(); + console.log('FK ENFORCEMENT:', JSON.stringify(result, null, 2)); + }); + + test('unique violation', async () => { + const stub = getStub(); + const result = await stub.probeUniqueViolation(); + console.log('UNIQUE VIOLATION:', JSON.stringify(result, null, 2)); + }); + + test('raw BEGIN/COMMIT/ROLLBACK', async () => { + const stub = getStub(); + const result = await stub.probeRawTransaction(); + console.log('RAW TRANSACTION:', JSON.stringify(result, null, 2)); + }); + + test('cursor shape', async () => { + const stub = getStub(); + const result = await stub.probeCursorShape(); + console.log('CURSOR SHAPE:', JSON.stringify(result, null, 2)); + }); + + test('fts5', async () => { + const stub = getStub(); + const result = await stub.probeFts5(); + console.log('FTS5:', JSON.stringify(result, null, 2)); + }); + + test('spread-args parameter binding', async () => { + const stub = getStub(); + const result = await stub.probeBindingStyle(); + console.log('BINDING STYLE:', JSON.stringify(result, null, 2)); + }); + + test('auto-rollback on throw: synchronous, no ROLLBACK issued', async () => { + const stub = getStub(); + await expect(stub.writeThenThrow_sync()).rejects.toThrow(); + const result = await stub.checkSurvivors('auto-rb-sync'); + console.log('AUTO-ROLLBACK (sync throw):', JSON.stringify(result, null, 2)); + }); + + test('auto-rollback on throw: after a microtask hop', async () => { + const stub = getStub(); + await expect(stub.writeThenThrow_microtask()).rejects.toThrow(); + const result = await stub.checkSurvivors('auto-rb-micro'); + console.log('AUTO-ROLLBACK (microtask throw):', JSON.stringify(result, null, 2)); + }); + + test('auto-rollback on throw: multi-statement sequence', async () => { + const stub = getStub(); + await expect(stub.writeThenThrow_multiStatement()).rejects.toThrow(); + const result = await stub.checkSurvivors('multi-%'); + console.log('AUTO-ROLLBACK (multi-statement):', JSON.stringify(result, null, 2)); + }); + + test('transactionSync rolls back on throw', async () => { + const stub = getStub(); + await expect(stub.transactionSyncThenThrow()).rejects.toThrow(); + const result = await stub.checkSurvivors('txsync-%'); + console.log('TRANSACTIONSYNC ROLLBACK:', JSON.stringify(result, null, 2)); + }); + + test('transactionSync commits on success', async () => { + const stub = getStub(); + const result = await stub.transactionSyncCommits(); + console.log('TRANSACTIONSYNC COMMIT:', JSON.stringify(result, null, 2)); + }); + + test('transactionSync passes return value through', async () => { + const stub = getStub(); + const result = await stub.transactionSyncReturnsValue(); + console.log('TRANSACTIONSYNC RETURN VALUE:', JSON.stringify(result, null, 2)); + }); +}); diff --git a/packages/record-adapter-do-sqlite/vitest.config.ts b/packages/record-adapter-do-sqlite/vitest.config.ts new file mode 100644 index 0000000..504783a --- /dev/null +++ b/packages/record-adapter-do-sqlite/vitest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vitest/config'; +import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; + +export default defineConfig({ + plugins: [cloudflareTest({ wrangler: { configPath: './wrangler.jsonc' } })], +}); diff --git a/packages/record-adapter-do-sqlite/wrangler.jsonc b/packages/record-adapter-do-sqlite/wrangler.jsonc new file mode 100644 index 0000000..a215376 --- /dev/null +++ b/packages/record-adapter-do-sqlite/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "record-adapter-do-sqlite-spike", + "main": "src/spike-worker.ts", + "compatibility_date": "2026-08-01", + "durable_objects": { + "bindings": [ + { + "name": "SPIKE_DO", + "class_name": "SpikeDurableObject" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["SpikeDurableObject"] + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 672cabf..048568b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,34 @@ importers: specifier: ^2.0.0 version: 2.1.9(@types/node@22.19.17) + packages/record-adapter-do-sqlite: + dependencies: + '@haverstack/core': + specifier: workspace:^ + version: link:../core + devDependencies: + '@cloudflare/vitest-pool-workers': + specifier: ^0.22.0 + version: 0.22.0(@cloudflare/workers-types@5.20260830.1)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0))) + '@cloudflare/workers-types': + specifier: ^5.0.0 + version: 5.20260830.1 + '@haverstack/sqlite-shared': + specifier: workspace:* + version: link:../sqlite-shared + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vite: + specifier: ^7.0.0 + version: 7.3.6(@types/node@22.19.17)(yaml@2.9.0) + vitest: + specifier: ^4.1.0 + version: 4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0)) + wrangler: + specifier: ^4.127.1 + version: 4.127.1(@cloudflare/workers-types@5.20260830.1) + packages/record-adapter-sqlite: dependencies: '@haverstack/core': @@ -279,6 +307,96 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vitest-pool-workers@0.22.0': + resolution: {integrity: sha512-OJv/qikkOgxnKxJ5xrLS7zuOLZhc/6iziU+llqZm4tiQf2CJUYwlMuXN68VaWIQebORd1AUx4w6A0oy8XRbuaQ==} + peerDependencies: + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 + + '@cloudflare/workerd-darwin-64@1.20260815.1': + resolution: {integrity: sha512-7PsLdcz6pT9EMd1EJGZEgMyYRfs0CHxGs62PS2L1w3s6+xGmQcRXKm/zoMftmqZF45JBa4MzFeownRKbRt/x5g==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-64@1.20260828.1': + resolution: {integrity: sha512-CVd+xPhqUESg8Xhq09TZx0wl4FSirfJGOzvbPz2yHhBIvmNHFFQkSN3rkd7wEwnhQQk37Xi0/aD6ykPLJbmGiQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260815.1': + resolution: {integrity: sha512-60wtg8ng7FVWeOg/UMbZ9Ye0sslpRRAKoftPbdtuH2volq676quxVr6Zm2EjVULH/JFZeCn72dbLlrnbh0Mpcw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260828.1': + resolution: {integrity: sha512-5HDPXRM152vU5JveByGFk34X57TVyIsfp4cabepAf45DC0MKvm52ucJqAjW1h8bvW4X+zRw9GU35OHF9FEC9Ww==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260815.1': + resolution: {integrity: sha512-MuqKIHPo0Qyo8MZMmy0lP2B5PeAL7f4T9Fu4Usk3QdbV4JIrKG/OoybN3Ign7m/Dff+L1Oo/ZHydB+hEg1ueFw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-64@1.20260828.1': + resolution: {integrity: sha512-MQ1Ll9P7F72HHUKizbb7BlDfbY8fRoNMpbIpZoU6uKsSkneFICWSKv6UlgU9EQZ+w0i7TMa12iUgJ8l29eRI9A==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260815.1': + resolution: {integrity: sha512-XNFtJ5rIqJxnY6ISjkfbhT/ODiWJ6LcBvNbntuPD6I/F2k7aZeKgPaXrvWvKde66LXyzFKzc8Hn+Ydx4shevQg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260828.1': + resolution: {integrity: sha512-FBTaUQ1xcU9jcp4OyBPcH8x0QiFvc1iuZL2GkD8zp2q1WyTVHYOptRDQUU+cuHjt0rQ2EIKVPBjahPxfa0joBw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260815.1': + resolution: {integrity: sha512-PiIUWrhbMg3quolwjgMvPOd75vKESjT4aDm7nL6mSjL5IOgmpO/zKstXnYfnEH3pq7sC0UCvKlF8ZPcfsh8NMw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workerd-windows-64@1.20260828.1': + resolution: {integrity: sha512-yvr77hC7dUbvK5K+SCg062kkPq3sx+drV1PcgHslzHDYcJBtT0V3X80qLE49LW1vq2svaeNmsVQS+vHsqWu8cQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@5.20260830.1': + resolution: {integrity: sha512-LBn0wg8kmCbdriUYmz6BSm4ukjFinU4iYGYuBTPaSG6malMhbIFk7Az1gcsFuvBYva2yHxUZFirCwjQY7yBN9A==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -291,6 +409,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} @@ -303,6 +427,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} @@ -315,6 +445,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} @@ -327,6 +463,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} @@ -339,6 +481,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} @@ -351,6 +499,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} @@ -363,6 +517,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} @@ -375,6 +535,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} @@ -387,6 +553,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} @@ -399,6 +571,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} @@ -411,6 +589,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} @@ -423,6 +607,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} @@ -435,6 +625,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} @@ -447,6 +643,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} @@ -459,6 +661,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} @@ -471,6 +679,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} @@ -483,12 +697,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} @@ -501,12 +727,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} @@ -519,12 +757,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} @@ -537,6 +787,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} @@ -549,6 +805,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} @@ -561,6 +823,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} @@ -573,6 +841,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -632,6 +906,168 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -645,6 +1081,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@manypkg/find-root@3.1.0': resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} engines: {node: '>=20.0.0'} @@ -661,6 +1100,15 @@ packages: resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==} engines: {node: '>=22.13'} + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@rollup/rollup-android-arm-eabi@4.60.2': resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} cpu: [arm] @@ -799,6 +1247,22 @@ packages: cpu: [x64] os: [win32] + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -873,6 +1337,9 @@ packages: '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/mocker@2.1.9': resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} peerDependencies: @@ -884,21 +1351,47 @@ packages: vite: optional: true + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@vitest/snapshot@2.1.9': resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/spy@2.1.9': resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -923,6 +1416,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} @@ -945,6 +1441,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -953,6 +1453,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -964,6 +1467,13 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -987,9 +1497,19 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -1000,6 +1520,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1165,6 +1690,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + launch-editor@2.14.1: resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} @@ -1193,6 +1722,14 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + miniflare@5.20260815.0-alpha: + resolution: {integrity: sha512-YAaGj4Sh5f4fqHKiMQ8zRHDOOM5IGUVtMhnLIeyjuQfU+9P6hcOTrHUVtbfj/ZPay9Kzik4pWELB39pGgefjiQ==} + engines: {node: '>=22.0.0'} + + miniflare@5.20260828.0-alpha: + resolution: {integrity: sha512-6nbxhZEcz/UET3Y1OnYPsrAUjUmuFoib3ynUqteRdn1YnDxsLg8cwgZJZCk9QmtOmGzXwzXzgE/d/C0dJAPtVw==} + engines: {node: '>=22.0.0'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1218,6 +1755,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1241,6 +1782,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1323,6 +1867,10 @@ packages: engines: {node: '>=10'} hasBin: true + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1355,11 +1903,18 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1389,6 +1944,10 @@ packages: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tinyspy@3.0.2: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} @@ -1406,6 +1965,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -1447,6 +2009,13 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1486,6 +2055,46 @@ packages: terser: optional: true + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@2.1.9: resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1511,6 +2120,47 @@ packages: jsdom: optional: true + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1525,6 +2175,48 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + workerd@1.20260815.1: + resolution: {integrity: sha512-8bArFkHmlp7qFEKVPyNzDzHzS35gc2fg0PYBcDtaNLF7UCDryCX2BQnpkUkTHYIy824IRrHOTwOEoTj0sUO2Fg==} + engines: {node: '>=16'} + hasBin: true + + workerd@1.20260828.1: + resolution: {integrity: sha512-pB9yvt0kkwZDAGZHmpY59r0o3hM0DzdW6BJERqwZOhunZ3ssOyDSgQxOQer2cSZW4YCFeOTIQYN1qwhK5wv/Cw==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.124.0: + resolution: {integrity: sha512-75euoZKjVTJYFy+Xhctt/5JlZL4M6A4xmovZsUlep+6GHcCm14n9VtdGzNIybOW2t8wuNxRj5iMUwjT5E7Ctog==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260815.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + wrangler@4.127.1: + resolution: {integrity: sha512-OzsiNgaI8i681L/+KnAKc+uEZ5D57xK5JuNvCOpRKICF4/5Q3Cu1oTGuUiT/f3GDUqQb3gzXNT0tfOHGMEtknw==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260828.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -1534,6 +2226,15 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@changesets/apply-release-plan@8.0.0': @@ -1631,35 +2332,105 @@ snapshots: '@changesets/types': 7.0.0 '@manypkg/get-packages': 3.1.0 - '@changesets/read@1.0.0': - dependencies: - '@changesets/git': 4.0.0 - '@changesets/parse': 1.0.0 - '@changesets/types': 7.0.0 + '@changesets/read@1.0.0': + dependencies: + '@changesets/git': 4.0.0 + '@changesets/parse': 1.0.0 + '@changesets/types': 7.0.0 + + '@changesets/should-skip-package@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + + '@changesets/types@7.0.0': {} + + '@changesets/write@1.0.1': + dependencies: + '@changesets/format': 0.1.2 + '@changesets/types': 7.0.0 + human-id: 4.2.1 + + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260815.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260815.1 + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260828.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260828.1 + + '@cloudflare/vitest-pool-workers@0.22.0(@cloudflare/workers-types@5.20260830.1)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0)))': + dependencies: + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260815.0-alpha + vitest: 4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0)) + wrangler: 4.124.0(@cloudflare/workers-types@5.20260830.1) + zod: 4.4.3 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate + + '@cloudflare/workerd-darwin-64@1.20260815.1': + optional: true + + '@cloudflare/workerd-darwin-64@1.20260828.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260815.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260828.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260815.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260828.1': + optional: true - '@changesets/should-skip-package@1.0.0': - dependencies: - '@changesets/types': 7.0.0 + '@cloudflare/workerd-linux-arm64@1.20260815.1': + optional: true - '@changesets/types@7.0.0': {} + '@cloudflare/workerd-linux-arm64@1.20260828.1': + optional: true - '@changesets/write@1.0.1': - dependencies: - '@changesets/format': 0.1.2 - '@changesets/types': 7.0.0 - human-id: 4.2.1 + '@cloudflare/workerd-windows-64@1.20260815.1': + optional: true - '@clack/core@1.4.3': + '@cloudflare/workerd-windows-64@1.20260828.1': + optional: true + + '@cloudflare/workers-types@5.20260830.1': {} + + '@cspotcode/source-map-support@0.8.1': dependencies: - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 + '@jridgewell/trace-mapping': 0.3.9 - '@clack/prompts@1.7.0': + '@emnapi/runtime@1.11.3': dependencies: - '@clack/core': 1.4.3 - fast-string-width: 3.0.2 - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 + tslib: 2.8.1 + optional: true '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1667,147 +2438,225 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.21.5': optional: true '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.21.5': optional: true '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true '@esbuild/win32-x64@0.27.7': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1)': dependencies: eslint: 10.2.1 @@ -1858,6 +2707,112 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1872,6 +2827,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@manypkg/find-root@3.1.0': dependencies: '@manypkg/tools': 2.1.2 @@ -1889,6 +2849,18 @@ snapshots: '@pnpm/deps.graph-sequencer@1100.0.1': {} + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@rollup/rollup-android-arm-eabi@4.60.2': optional: true @@ -1964,6 +2936,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.8': {} @@ -2072,6 +3057,15 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.17))': dependencies: '@vitest/spy': 2.1.9 @@ -2080,31 +3074,63 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@22.19.17) + '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.19.17)(yaml@2.9.0) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 pathe: 1.1.2 + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + '@vitest/snapshot@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 magic-string: 0.30.21 pathe: 1.1.2 + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@2.1.9': dependencies: tinyspy: 3.0.2 + '@vitest/spy@4.1.11': {} + '@vitest/utils@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 loupe: 3.2.1 tinyrainbow: 1.2.0 + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -2124,6 +3150,8 @@ snapshots: balanced-match@4.0.4: {} + blake3-wasm@2.1.5: {} + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -2145,18 +3173,26 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + check-error@2.1.3: {} chokidar@4.0.3: dependencies: readdirp: 4.1.2 + cjs-module-lexer@1.2.3: {} + commander@4.1.1: {} confbox@0.1.8: {} consola@3.4.2: {} + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2173,8 +3209,14 @@ snapshots: deep-is@0.1.4: {} + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.3.2: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -2230,6 +3272,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escape-string-regexp@4.0.0: {} eslint-config-prettier@10.1.8(eslint@10.2.1): @@ -2389,6 +3460,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@4.1.5: {} + launch-editor@2.14.1: dependencies: picocolors: 1.1.1 @@ -2415,6 +3488,30 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + miniflare@5.20260815.0-alpha: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260815.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + miniflare@5.20260828.0-alpha: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260828.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -2440,6 +3537,8 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.4: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2463,6 +3562,8 @@ snapshots: path-key@3.1.1: {} + path-to-regexp@6.3.0: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -2539,6 +3640,38 @@ snapshots: semver@7.8.5: {} + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2559,6 +3692,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.2.0: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -2569,6 +3704,8 @@ snapshots: tinyglobby: 0.2.16 ts-interface-checker: 0.1.13 + supports-color@10.2.2: {} + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -2592,6 +3729,8 @@ snapshots: tinyrainbow@1.2.0: {} + tinyrainbow@3.1.1: {} + tinyspy@3.0.2: {} tree-kill@1.2.2: {} @@ -2602,6 +3741,9 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: + optional: true + tsup@8.5.1(postcss@8.5.13)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) @@ -2651,6 +3793,12 @@ snapshots: undici-types@6.21.0: {} + undici@7.29.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2682,6 +3830,19 @@ snapshots: '@types/node': 22.19.17 fsevents: 2.3.3 + vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.13 + rollup: 4.60.2 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + yaml: 2.9.0 + vitest@2.1.9(@types/node@22.19.17): dependencies: '@vitest/expect': 2.1.9 @@ -2717,6 +3878,33 @@ snapshots: - supports-color - terser + vitest@4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.1 + vite: 7.3.6(@types/node@22.19.17)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + transitivePeerDependencies: + - msw + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2728,6 +3916,73 @@ snapshots: word-wrap@1.2.5: {} + workerd@1.20260815.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260815.1 + '@cloudflare/workerd-darwin-arm64': 1.20260815.1 + '@cloudflare/workerd-linux-64': 1.20260815.1 + '@cloudflare/workerd-linux-arm64': 1.20260815.1 + '@cloudflare/workerd-windows-64': 1.20260815.1 + + workerd@1.20260828.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260828.1 + '@cloudflare/workerd-darwin-arm64': 1.20260828.1 + '@cloudflare/workerd-linux-64': 1.20260828.1 + '@cloudflare/workerd-linux-arm64': 1.20260828.1 + '@cloudflare/workerd-windows-64': 1.20260828.1 + + wrangler@4.124.0(@cloudflare/workers-types@5.20260830.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260815.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260815.0-alpha + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260815.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20260830.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + wrangler@4.127.1(@cloudflare/workers-types@5.20260830.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260828.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260828.0-alpha + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260828.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20260830.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + yaml@2.9.0: {} yocto-queue@0.1.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 578b40f..c9b5a90 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - 'packages/*' allowBuilds: esbuild: true + workerd: true From c01f8de8c3ddaa931e7de8b428d18fbe1eb4f38c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 01:05:15 +0000 Subject: [PATCH 2/4] feat(record-adapter-do-sqlite): add Durable Object SQLite record adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements StackRecordAdapter over Cloudflare Durable Objects' SQLite storage (ctx.storage.sql) for Workers deployments with no Node runtime. Reuses SharedSqlRecordLogic, the FTS5 schema/strategy, query builder, cursor codec, and row mappers from @haverstack/sqlite-shared, via its new ./record subpath (the token-store and file-lock pieces stay Node-only and unreachable from this adapter's bundle — a bare import of either survives esbuild's tree-shaking as a dead-but-still-imported `node:crypto`/`node:fs` module, which throws at load time in a Worker without nodejs_compat). No lock file: a Durable Object id maps to exactly one running instance, so the platform itself is the single-writer guarantee. No persist/flush step: every write through ctx.storage.sql is durable by the time the call returns. sqlite-shared's SqlExecutor gains a transaction(fn: () => T): T primitive, replacing the raw BEGIN/COMMIT/ROLLBACK statements record-logic.ts issued directly. A pre-implementation spike against the real Workers runtime (@cloudflare/vitest-pool-workers) found DO SQLite rejects those statements outright, and does not roll back a write on a later exception the way an open SQL transaction would — its real primitive is ctx.storage.transactionSync(fn), a callback boundary three independent string-based exec() calls can't reach. record-adapter-sqlite's executor implements transaction() as literal BEGIN/COMMIT/ROLLBACK around fn(), behavior-identical to the code it replaces — its full 116-test suite passes unchanged. Tests run against the real Workers runtime, not `environment: 'node'` — a targeted subset (CRUD, FK/unique constraint mapping, FTS5, cursor pagination, associations, commitMigration), plus the load-bearing case for the transaction refactor: a rejected patchContent must leave the FTS index consistent with stored content, which only holds if the rollback actually rolls back. Also: - docs/spec/adapters.md: new adapter in the backends table, capabilities notes, and Concurrency & storage ownership section ("the DO is the lock"); sqlite-shared's two entry points and transaction() rationale. - README.md: package table and directory tree entries. - scripts/verify-pack.mjs: notes why the new package is deliberately absent from the Node-import EXPECTATIONS map (its entry point uses ambient Durable Object globals that don't exist under plain Node). - .npmrc: keep @types/chai (pulled in only by this package's vitest 4) out of pnpm's shared @types hoist, so it can't collide with the chai types vendored in @vitest/expect@2 that the rest of the repo uses. - .gitignore: carve out an exception for hand-authored ambient .d.ts files under tests/support/ (the blanket *.d.ts rule is for build output; this package's cloudflare:test type reference isn't generated). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VJoUkCo4dLjXsDsNDkYjbx --- .changeset/record-adapter-do-sqlite.md | 25 ++ .gitignore | 6 + .npmrc | 9 + README.md | 36 ++- docs/spec/adapters.md | 29 +- .../record-adapter-do-sqlite/package.json | 43 ++- .../record-adapter-do-sqlite/src/executor.ts | 49 +++ .../record-adapter-do-sqlite/src/index.ts | 255 +++++++++++++++ .../src/spike-worker.ts | 275 ---------------- .../tests/record.test.ts | 299 ++++++++++++++++++ .../tests/spike.test.ts | 99 ------ .../tests/support/env.d.ts | 1 + .../tests/support/test-worker.ts | 154 +++++++++ .../record-adapter-do-sqlite/tsconfig.json | 11 + .../record-adapter-do-sqlite/tsup.config.ts | 26 ++ .../record-adapter-do-sqlite/vitest.config.ts | 9 + .../record-adapter-do-sqlite/wrangler.jsonc | 18 +- .../record-adapter-sqlite/src/executor.ts | 12 + packages/sqlite-shared/package.json | 4 + packages/sqlite-shared/src/executor.ts | 11 + packages/sqlite-shared/src/record-logic.ts | 111 ++----- packages/sqlite-shared/src/record.ts | 37 +++ pnpm-lock.yaml | 9 +- scripts/verify-pack.mjs | 13 +- 24 files changed, 1037 insertions(+), 504 deletions(-) create mode 100644 .changeset/record-adapter-do-sqlite.md create mode 100644 .npmrc create mode 100644 packages/record-adapter-do-sqlite/src/executor.ts create mode 100644 packages/record-adapter-do-sqlite/src/index.ts delete mode 100644 packages/record-adapter-do-sqlite/src/spike-worker.ts create mode 100644 packages/record-adapter-do-sqlite/tests/record.test.ts delete mode 100644 packages/record-adapter-do-sqlite/tests/spike.test.ts create mode 100644 packages/record-adapter-do-sqlite/tests/support/env.d.ts create mode 100644 packages/record-adapter-do-sqlite/tests/support/test-worker.ts create mode 100644 packages/record-adapter-do-sqlite/tsconfig.json create mode 100644 packages/record-adapter-do-sqlite/tsup.config.ts create mode 100644 packages/sqlite-shared/src/record.ts diff --git a/.changeset/record-adapter-do-sqlite.md b/.changeset/record-adapter-do-sqlite.md new file mode 100644 index 0000000..becfe43 --- /dev/null +++ b/.changeset/record-adapter-do-sqlite.md @@ -0,0 +1,25 @@ +--- +'@haverstack/record-adapter-do-sqlite': patch +'@haverstack/record-adapter-sqlite': patch +--- + +Add `@haverstack/record-adapter-do-sqlite` — a `StackRecordAdapter` over Cloudflare +Durable Objects' SQLite storage, for Workers deployments with no Node runtime +available. Reuses `SharedSqlRecordLogic`, the FTS5 schema and strategy, the query +builder, cursor codec, and row mappers from `@haverstack/sqlite-shared` — the same +shared layer `record-adapter-sqlite` is built on, now via its `./record` subpath +(the token-store and file-lock pieces stay Node-only and unreachable from this +adapter's bundle). No lock file: a Durable Object id maps to exactly one running +instance, so the platform itself is the single-writer guarantee. No persist/flush +step: every write through `ctx.storage.sql` is durable by the time the call returns. + +`@haverstack/sqlite-shared`'s `SqlExecutor` gained a `transaction(fn: () => T): T` +primitive, replacing the raw `BEGIN`/`COMMIT`/`ROLLBACK` statements `record-logic.ts` +used to issue directly. Durable Object SQLite storage rejects those statements +outright and does not roll back a write on a later exception the way an open SQL +transaction would (verified against the real Workers runtime) — its real primitive +is `ctx.storage.transactionSync(fn)`, a callback boundary that three independent +string-based `exec()` calls can't reach. `record-adapter-sqlite`'s executor +implements `transaction()` as literal `BEGIN`/`COMMIT`/`ROLLBACK` around `fn()`, +behavior-identical to what the inline code did before — its full test suite passes +unchanged. diff --git a/.gitignore b/.gitignore index 26eef9e..257bf6a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,12 @@ dist/ *.js.map *.d.ts *.d.ts.map +# ...except hand-authored ambient declaration files, e.g. a Workers +# package's triple-slash reference to @cloudflare/vitest-pool-workers/types +# for `cloudflare:test` typings (wrangler's own generated +# worker-configuration.d.ts stays ignored — it's regenerated by a +# pretest/pretypecheck script, not committed). +!**/tests/support/*.d.ts # Test databases *.db diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..bf97873 --- /dev/null +++ b/.npmrc @@ -0,0 +1,9 @@ +# @types/chai@5.2.3 (pulled in only by record-adapter-do-sqlite's vitest 4 / +# @cloudflare/vitest-pool-workers) collides with the chai types vendored +# inside @vitest/expect@2.1.9 (used by the rest of the repo on vitest 2) once +# both land in pnpm's shared @types hoist folder — "Duplicate identifier" +# across every package's typecheck, not just the new one. Keeping it +# unhoisted confines it to record-adapter-do-sqlite's own resolution chain, +# where it's actually needed. +hoist-pattern[]=* +hoist-pattern[]=!@types/chai diff --git a/README.md b/README.md index ae5fd46..9bb1f95 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,15 @@ The delegation itself — "this app acts for Bob" — is asserted by you when th This is a monorepo. Packages are published to npm under the `@haverstack` scope. -| Package | Description | -| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| [`@haverstack/core`](./packages/core) | Stack class, types, schema, validation, ID generation | -| [`@haverstack/adapter-local`](./packages/adapter-local) | Local adapter (native SQLite + disk) — single-app/embedded or server use | -| [`@haverstack/record-adapter-sqlite`](./packages/record-adapter-sqlite) | Node native SQLite (`node:sqlite`) `StackRecordAdapter` — used by `adapter-local` | -| [`@haverstack/blob-adapter-disk`](./packages/blob-adapter-disk) | Disk filesystem `StackBlobAdapter` | -| [`@haverstack/adapter-api`](./packages/adapter-api) | HTTP adapter for remote stack servers | -| [`@haverstack/commons`](./packages/commons) | Canonical Schema Commons type definitions (`note`, `task`, `contact`, ...) | +| Package | Description | +| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| [`@haverstack/core`](./packages/core) | Stack class, types, schema, validation, ID generation | +| [`@haverstack/adapter-local`](./packages/adapter-local) | Local adapter (native SQLite + disk) — single-app/embedded or server use | +| [`@haverstack/record-adapter-sqlite`](./packages/record-adapter-sqlite) | Node native SQLite (`node:sqlite`) `StackRecordAdapter` — used by `adapter-local` | +| [`@haverstack/record-adapter-do-sqlite`](./packages/record-adapter-do-sqlite) | Cloudflare Durable Objects (SQLite storage) `StackRecordAdapter` — Workers | +| [`@haverstack/blob-adapter-disk`](./packages/blob-adapter-disk) | Disk filesystem `StackBlobAdapter` | +| [`@haverstack/adapter-api`](./packages/adapter-api) | HTTP adapter for remote stack servers | +| [`@haverstack/commons`](./packages/commons) | Canonical Schema Commons type definitions (`note`, `task`, `contact`, ...) | Planned: @@ -243,13 +244,14 @@ The adapter interface is split into `StackRecordAdapter` (structured records) an - **`record-adapter-*`** — `StackRecordAdapter` only - **`blob-adapter-*`** — `StackBlobAdapter` only -| Package | Type | Use case | -| ----------------------- | ------ | ------------------------------------------------------------------------------- | -| `adapter-local` | full | Single-app/embedded or server use — native SQLite records + disk blobs | -| `record-adapter-sqlite` | record | Node native SQLite (`node:sqlite`) records, FTS5, WAL — used by `adapter-local` | -| `blob-adapter-disk` | blob | Content-addressed blobs on the local filesystem | -| `adapter-api` | full | Hosted/shared stacks via HTTP | -| `adapter-json` | full | Portable JSON files _(planned)_ | +| Package | Type | Use case | +| -------------------------- | ------ | ------------------------------------------------------------------------------- | +| `adapter-local` | full | Single-app/embedded or server use — native SQLite records + disk blobs | +| `record-adapter-sqlite` | record | Node native SQLite (`node:sqlite`) records, FTS5, WAL — used by `adapter-local` | +| `record-adapter-do-sqlite` | record | Cloudflare Durable Objects (SQLite storage) records, FTS5 | +| `blob-adapter-disk` | blob | Content-addressed blobs on the local filesystem | +| `adapter-api` | full | Hosted/shared stacks via HTTP | +| `adapter-json` | full | Portable JSON files _(planned)_ | Use `combineAdapters({ record, blob })` from `@haverstack/core/adapter` to compose a record adapter with a different blob backend — for example, `NativeSQLiteRecordAdapter` with a future `S3BlobAdapter`. `adapter-local` wraps this pattern for the common case. @@ -312,6 +314,10 @@ packages/ index.ts # NativeSQLiteRecordAdapter (StackRecordAdapter), node:sqlite token-store.ts # NativeTokenStore (StackTokenStore), separate file from records tests/ + record-adapter-do-sqlite/ # @haverstack/record-adapter-do-sqlite + src/ + index.ts # DoSQLiteRecordAdapter (StackRecordAdapter), Cloudflare Durable Objects + tests/ blob-adapter-disk/ # @haverstack/blob-adapter-disk src/ index.ts # DiskBlobAdapter (StackBlobAdapter) diff --git a/docs/spec/adapters.md b/docs/spec/adapters.md index 87fc46e..4812c00 100644 --- a/docs/spec/adapters.md +++ b/docs/spec/adapters.md @@ -36,13 +36,14 @@ Packages follow a naming convention that makes the adapter type discoverable: ## Adapter backends -| Package | Type | Use case | -| ----------------------- | ------ | ----------------------------------------------------- | -| `adapter-local` | full | Local app storage — native SQLite + disk blobs | -| `record-adapter-sqlite` | record | Node native SQLite (`node:sqlite`) records, FTS5, WAL | -| `blob-adapter-disk` | blob | Content-addressed blobs on disk | -| `adapter-api` | full | Hosted/shared stacks via HTTP | -| `adapter-json` | full | Portable JSON files _(planned)_ | +| Package | Type | Use case | +| -------------------------- | ------ | --------------------------------------------------------- | +| `adapter-local` | full | Local app storage — native SQLite + disk blobs | +| `record-adapter-sqlite` | record | Node native SQLite (`node:sqlite`) records, FTS5, WAL | +| `record-adapter-do-sqlite` | record | Cloudflare Durable Objects (SQLite storage) records, FTS5 | +| `blob-adapter-disk` | blob | Content-addressed blobs on disk | +| `adapter-api` | full | Hosted/shared stacks via HTTP | +| `adapter-json` | full | Portable JSON files _(planned)_ | `adapter-local` is the batteries-included package for the common local case. It wraps `NativeSQLiteRecordAdapter` and `DiskBlobAdapter` and stores attachments in an `attachments/` subdirectory next to the database file. Bearer tokens, when used, live in a separate sibling file (`.tokens`, via `NativeTokenStore`) — never inside the portable stack database. @@ -61,11 +62,13 @@ const stack = await Stack.create(adapter); All adapters support the full Record API. Performance guarantees differ; correctness does not. -**`@haverstack/sqlite-shared`** is an internal, non-public package holding everything a SQLite-backed record adapter needs that isn't specific to one binding — schema DDL, `WHERE`/`ORDER` building, the cursor codec, row mappers, the FTS5 sanitizer and indexing strategy, the storage-ownership lock, and (via a small `SqlExecutor` interface normalizing a binding's call convention) the actual CRUD/query/version/type/association/token logic itself. An adapter implements only what's genuinely engine-specific: database construction, pragma/WAL setup, and lifecycle. `record-adapter-sqlite` is its only consumer today; the split exists so a second SQLite engine inherits the behavior rather than reimplementing it, and so a cursor minted by one is decodable by another. +**`@haverstack/sqlite-shared`** is an internal, non-public package holding everything a SQLite-backed record adapter needs that isn't specific to one binding — schema DDL, `WHERE`/`ORDER` building, the cursor codec, row mappers, the FTS5 sanitizer and indexing strategy, the storage-ownership lock, and (via a small `SqlExecutor` interface normalizing a binding's call convention) the actual CRUD/query/version/type/association/token logic itself. An adapter implements only what's genuinely engine-specific: database construction, pragma setup, transaction semantics, and lifecycle. `record-adapter-sqlite` and `record-adapter-do-sqlite` both consume it; the split exists so a second (and third) SQLite engine inherits the behavior rather than reimplementing it, and so a cursor minted by one is decodable by another. -**It is bundled into its consumers rather than published.** "Non-public" is enforced, not merely intended: the package is `private`, and `record-adapter-sqlite` inlines it at build time, so a consumer installing that adapter from the registry never resolves `@haverstack/sqlite-shared` and cannot depend on it. `SqlExecutor` and the `Shared*Logic` classes are therefore internal collaborators of the adapters in this repository, not an extension point — a second SQLite engine inherits them by living here, not by installing them. Reversing that (publishing it so third-party adapters can build on `SqlExecutor`) is a deliberate decision to make it public API with the stability obligations that implies, not a packaging tweak. +**It exposes two entry points.** The full barrel (`.`) includes the token-store logic (`SharedTokenLogic`, `TOKENS_SCHEMA_SQL`) and the file-lock helpers, both Node-specific (`node:crypto`, `node:fs`) — fine for `record-adapter-sqlite`, but a bare `import` of either survives tree-shaking as a dead-but-still-imported module in a bundle, which throws at load time in a Workers runtime without `nodejs_compat`. A record-only consumer with no token store and no lock file — `record-adapter-do-sqlite`, where the platform's single-writer-per-id model already is the lock — imports the `./record` subpath instead, which never reaches either. -`SqlExecutor` is synchronous. Every SQLite binding in scope executes queries in-process without yielding, and the shared logic's explicit `BEGIN`/`COMMIT` sequences depend on that — an engine reached over a network (D1, libsql over HTTP) does not fit this interface without making it async throughout. +**It is bundled into its consumers rather than published.** "Non-public" is enforced, not merely intended: the package is `private`, and its consumers inline it at build time, so installing one of them from the registry never resolves `@haverstack/sqlite-shared` and cannot depend on it. `SqlExecutor` and the `Shared*Logic` classes are therefore internal collaborators of the adapters in this repository, not an extension point — a second SQLite engine inherits them by living here, not by installing them. Reversing that (publishing it so third-party adapters can build on `SqlExecutor`) is a deliberate decision to make it public API with the stability obligations that implies, not a packaging tweak. + +`SqlExecutor` is synchronous. Every SQLite binding in scope executes queries in-process without yielding, and the shared logic's explicit transaction boundaries (`SqlExecutor.transaction(fn)`) depend on that — an engine reached over a network (D1, libsql over HTTP) does not fit this interface without making it async throughout. `transaction(fn)` — not raw `BEGIN`/`COMMIT`/`ROLLBACK` strings — is the interface's transaction primitive specifically because it isn't universal SQL text: `record-adapter-sqlite` implements it as literal `BEGIN`/`COMMIT`/`ROLLBACK` around `fn()`, while `record-adapter-do-sqlite` implements it as `ctx.storage.transactionSync(fn)`, because Durable Object SQLite storage rejects raw multi-statement transaction SQL outright and does not auto-commit-then-roll-back on a later exception — verified against the real Workers runtime, not assumed. A binding that only had the three raw statements to work with couldn't reach that primitive at all. SQLite-backed adapters enable foreign-key enforcement (`PRAGMA foreign_keys = ON`) so that operations like `associate()` against a nonexistent record fail loudly (`StackNotFoundError`) instead of silently creating an orphan row. @@ -87,7 +90,7 @@ type AdapterCapabilities = { `AdapterCapabilities` is the adapter-implementer-facing name. On the `StackClient` interface it is exposed as `features: StackFeatures` (a type alias for `AdapterCapabilities`). App and plugin code should read `stack.features` rather than going through the adapter directly. -**`contentFieldQuery` is required-`true` for local adapters, optional and discovery-driven for wire adapters.** "Local" means an adapter that reads/writes its storage in-process, with no network hop to a server that could have its own opinion — `record-adapter-sqlite`, any future JSON-file adapter, and first-party test doubles standing in for one. For storage a local adapter already owns and reads directly, filtering by `content` is just a linear scan over resident data — there's no architectural reason a local adapter can't support it, so declaring `false` is never legitimate there. A remote server reached through `adapter-api` is the one legitimate `false` case: native fields (`typeId`, `parentId`, `entityId`, dates) are a fixed, indexable schema every server needs anyway, but `content` is an arbitrary, app-defined JSON blob, and a server serving many stacks may reasonably decline to index or full-scan it. `fullTextSearch` has no such local-required rule — a local adapter may legitimately decline it (see the JSON adapter note below). +**`contentFieldQuery` is required-`true` for local adapters, optional and discovery-driven for wire adapters.** "Local" means an adapter that reads/writes its storage in-process, with no network hop to a server that could have its own opinion — `record-adapter-sqlite`, `record-adapter-do-sqlite` (a Durable Object's storage is in-process from that DO's own point of view, even though the DO itself is reached over the network), any future JSON-file adapter, and first-party test doubles standing in for one. For storage a local adapter already owns and reads directly, filtering by `content` is just a linear scan over resident data — there's no architectural reason a local adapter can't support it, so declaring `false` is never legitimate there. A remote server reached through `adapter-api` is the one legitimate `false` case: native fields (`typeId`, `parentId`, `entityId`, dates) are a fixed, indexable schema every server needs anyway, but `content` is an arbitrary, app-defined JSON blob, and a server serving many stacks may reasonably decline to index or full-scan it. `fullTextSearch` has no such local-required rule — a local adapter may legitimately decline it (see the JSON adapter note below). `Stack.query()` enforces this before dispatching — see [Capability-gated filters](./data-model.md#capability-gated-filters). That check is a backstop for the rule above, not a substitute for it: a local adapter that (incorrectly) declared `false` would otherwise return an unfiltered superset for every `content` query. @@ -95,9 +98,10 @@ type AdapterCapabilities = { - **JSON adapter** — supports all filter fields via O(n) scan; may maintain `_index.json` to speed up native field lookups; `fullTextSearch: false` in v1 (local adapters may decline `fullTextSearch`; only `contentFieldQuery` is required-`true`) - **Native SQLite adapter** (`record-adapter-sqlite`) — indexes all native fields and association labels; supports content field queries and full-text search via FTS5 +- **Durable Object SQLite adapter** (`record-adapter-do-sqlite`) — same capabilities as the native SQLite adapter (shares `SharedSqlRecordLogic`); DO's SQLite storage ships FTS5 - **API adapter** — capabilities determined by the server; declared in a discovery endpoint; the one adapter kind allowed to declare `contentFieldQuery: false` -Local, embedded adapters (JSON, native SQLite) declare `maxAttachmentBytes: null` — nothing at the storage layer imposes a ceiling. Only a server behind the API adapter enforces one, since it's the only adapter transporting attachment bytes over a connection with its own limits. +Local, embedded adapters (JSON, native SQLite, Durable Object SQLite) declare `maxAttachmentBytes: null` — nothing at the storage layer imposes a ceiling. Only a server behind the API adapter enforces one, since it's the only adapter transporting attachment bytes over a connection with its own limits. **`maxContentBytes` is the same field for the JSON side of a write** — the serialized size of a Record's `content` on create, or of a merge patch on update. Local adapters declare `null` for the same reason: a caller with in-process access to the database can spend its own memory however it likes, and nothing at the storage layer objects. A server declares its request-size limit here, and `Stack.create()`/`Stack.update()` pre-check against it and throw `StackPayloadTooLargeError` before sending — the same client-side courtesy `putAttachment()` extends for attachments, with the server's own limit still authoritative (see [Wire format § Request size limits](./wire-format.md#request-size-limits)). @@ -108,6 +112,7 @@ A stack's backing storage (a SQLite file, a JSON directory) has exactly one owni How each adapter honors the single-writer rule differs by what it actually is: - **`record-adapter-sqlite`** (Node, real files) writes through `node:sqlite` under WAL journaling — page-level writes and crash safety are properties of the storage engine itself. It still acquires a PID-stamped lock file beside the database on `open()`/`initialize()`, released on `close()`, so a second opener gets a clear, immediate error rather than discovering the trust-boundary problem the hard way. A stale lock (owning process no longer alive) is reclaimed automatically, and an explicit override is available for the rare case of PID reuse. +- **`record-adapter-do-sqlite`** (Cloudflare Durable Objects, SQLite storage) needs no lock file at all — a Durable Object id maps to exactly one running instance, enforced by the platform itself, so the single-writer rule is a property of the runtime rather than something this adapter has to implement. There is likewise no `persist`/flush step: every write through `ctx.storage.sql` is durable by the time the call returns. The one real engine-specific wrinkle is transactions — DO SQLite rejects raw `BEGIN`/`COMMIT`/`ROLLBACK` outright, and (confirmed against the real runtime, not assumed) does not roll back a write on a later exception the way an open SQL transaction would; the adapter reaches `ctx.storage.transactionSync()` instead, through `SqlExecutor.transaction()` (see above). - **The planned whole-file `adapter-json`** reads its entire store into memory on open and rewrites it whole on every persist, so it must supply both guarantees itself: a PID lock file (to fail loudly on double-open) and an atomic temp-file-and-`rename()` persist (so a crash mid-write can't leave a torn, unreadable file). `record-adapter-sqlite` gets both from WAL and real file locking instead. ## Lifecycle diff --git a/packages/record-adapter-do-sqlite/package.json b/packages/record-adapter-do-sqlite/package.json index 452b38c..bb940c6 100644 --- a/packages/record-adapter-do-sqlite/package.json +++ b/packages/record-adapter-do-sqlite/package.json @@ -1,19 +1,52 @@ { "name": "@haverstack/record-adapter-do-sqlite", - "version": "0.0.0", - "private": true, - "description": "Cloudflare Durable Objects (SQLite storage) record adapter for Haverstack — spike", + "version": "0.1.0", + "description": "Cloudflare Durable Objects (SQLite storage) StackRecordAdapter for Haverstack — Workers, FTS5, single-writer by construction", "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/haverstack/core.git", + "directory": "packages/record-adapter-do-sqlite" + }, + "license": "CC0-1.0", + "keywords": [ + "haverstack", + "sqlite", + "cloudflare", + "durable-objects", + "workers", + "record", + "adapter", + "personal data", + "storage" + ], "scripts": { - "test": "vitest run" + "prepublishOnly": "pnpm run build", + "build": "tsup", + "pretest": "wrangler types", + "test": "vitest run", + "pretypecheck": "wrangler types", + "typecheck": "tsc --noEmit", + "lint": "eslint src tests" }, "dependencies": { "@haverstack/core": "workspace:^" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.22.0", - "@cloudflare/workers-types": "^5.0.0", "@haverstack/sqlite-shared": "workspace:*", + "tsup": "^8.5.1", "typescript": "^5.5.0", "vite": "^7.0.0", "vitest": "^4.1.0", diff --git a/packages/record-adapter-do-sqlite/src/executor.ts b/packages/record-adapter-do-sqlite/src/executor.ts new file mode 100644 index 0000000..13e9ef4 --- /dev/null +++ b/packages/record-adapter-do-sqlite/src/executor.ts @@ -0,0 +1,49 @@ +import type { SqlExecutor } from '@haverstack/sqlite-shared/record'; + +/** + * Normalizes a Durable Object's ctx.storage.sql to SqlExecutor. + * + * Two things don't map onto node:sqlite's shape the way the rest of this + * interface does: + * + * - There is no get/all/run split — sql.exec() always returns a cursor. + * get()/all() read it via .toArray(); run() reads .rowsWritten for the + * affected-row count run()'s contract promises. + * - Raw BEGIN/COMMIT/ROLLBACK are rejected outright by DO SQLite (verified + * against the real Workers runtime, not assumed) — its storage has no + * notion of a transaction left open across separate exec() calls, and + * each statement auto-commits the instant it runs, so no-op'ing those + * three strings would silently break atomicity, not preserve it. The + * platform's real primitive is ctx.storage.transactionSync(fn), which + * SqlExecutor.transaction() reaches directly — see docs/spec/adapters.md + * § Concurrency & storage ownership. + */ +export class DurableObjectSqliteExecutor implements SqlExecutor { + constructor(private readonly storage: DurableObjectStorage) {} + + private get sql(): SqlStorage { + return this.storage.sql; + } + + exec(sql: string): void { + this.sql.exec(sql); + } + + run(sql: string, params: readonly unknown[] = []): number { + const cursor = this.sql.exec(sql, ...(params as SqlStorageValue[])); + return cursor.rowsWritten; + } + + get>(sql: string, params: readonly unknown[] = []): T | undefined { + const rows = this.sql.exec(sql, ...(params as SqlStorageValue[])).toArray(); + return rows[0] as T | undefined; + } + + all>(sql: string, params: readonly unknown[] = []): T[] { + return this.sql.exec(sql, ...(params as SqlStorageValue[])).toArray() as T[]; + } + + transaction(fn: () => T): T { + return this.storage.transactionSync(fn); + } +} diff --git a/packages/record-adapter-do-sqlite/src/index.ts b/packages/record-adapter-do-sqlite/src/index.ts new file mode 100644 index 0000000..e58eb5b --- /dev/null +++ b/packages/record-adapter-do-sqlite/src/index.ts @@ -0,0 +1,255 @@ +/** + * Haverstack — Durable Object SQLite Record Adapter + * ------------------------------------------------------- + * Implements StackRecordAdapter over a Cloudflare Durable Object's SQLite + * storage (ctx.storage.sql). Full-text search uses FTS5, same as + * record-adapter-sqlite — DO's SQLite build ships it. + * + * Ownership and durability come from the platform, not from anything this + * class does: a Durable Object id maps to exactly one running instance, + * so there is no separate lock file the way record-adapter-sqlite needs + * one for real files (see docs/spec/adapters.md § Concurrency & storage + * ownership) — the DO *is* the lock. There is likewise no persist/flush + * step: every write through ctx.storage.sql is durable by the time the + * call returns, so flush()/close() are no-ops kept only to satisfy the + * optional StackRecordAdapter methods. + * + * This class itself is a thin binding: schema setup and the one piece of + * genuinely engine-specific wiring — SqlExecutor.transaction() reaching + * ctx.storage.transactionSync() instead of raw SQL BEGIN/COMMIT/ROLLBACK, + * which DO SQLite rejects outright — live here (see executor.ts). The + * actual StackRecordAdapter logic lives in @haverstack/sqlite-shared's + * SharedSqlRecordLogic, exactly as it does for record-adapter-sqlite. + */ + +import type { StackType, TypeId, FileId, RecordVersion, ActorOptions } from '@haverstack/core'; +import type { + StackRecord, + StackQuery, + QueryResult, + Association, + Permission, +} from '@haverstack/core'; +import type { StackRecordAdapter, AdapterCapabilities } from '@haverstack/core/adapter'; +import { + RECORD_SCHEMA_SQL, + FTS5_SCHEMA_SQL, + PRAGMA_FOREIGN_KEYS_ON, + insertConfigRecord, + readStackConfig, + SharedSqlRecordLogic, +} from '@haverstack/sqlite-shared/record'; +import { DurableObjectSqliteExecutor } from './executor.js'; + +// ------------------------------------------------------- +// Types +// ------------------------------------------------------- + +export type DoRecordCreateOptions = { + /** Entity ID of the stack owner. Ignored if the DO's storage already has a config record. */ + entityId: string; + /** IANA timezone string e.g. "America/New_York". Optional passthrough app metadata — no default. */ + timezone?: string; +}; + +// ------------------------------------------------------- +// DoSQLiteRecordAdapter +// ------------------------------------------------------- + +export class DoSQLiteRecordAdapter implements StackRecordAdapter { + readonly capabilities: AdapterCapabilities = { + fullTextSearch: true, + contentFieldQuery: true, + sortableFields: ['createdAt', 'updatedAt', 'version'], + maxAttachmentBytes: null, + maxContentBytes: null, + }; + + ownerEntityId!: string; + timezone: string | undefined; + + private readonly record: SharedSqlRecordLogic; + + private constructor(private readonly exec: DurableObjectSqliteExecutor) { + this.record = new SharedSqlRecordLogic({ exec }); + } + + /** + * Create (or reattach to) the adapter for a DO instance. There is no + * initialize()/open() split the way file-based adapters need one: a DO + * id either already has a config record (a previous call created it — + * reattach, opts.entityId/timezone ignored in favor of what's stored) + * or it doesn't (first call — opts.entityId/timezone become the config). + * Schema DDL is `CREATE TABLE IF NOT EXISTS`, so running it every call + * is idempotent and cheap. + */ + static async create( + storage: DurableObjectStorage, + opts: DoRecordCreateOptions, + ): Promise { + const exec = new DurableObjectSqliteExecutor(storage); + exec.exec(RECORD_SCHEMA_SQL); + exec.exec(FTS5_SCHEMA_SQL); + exec.exec(PRAGMA_FOREIGN_KEYS_ON); + // DO SQLite manages its own durability and rejects PRAGMA journal_mode + // outright ("not authorized") — verified against the real runtime, not + // assumed — so unlike record-adapter-sqlite, no WAL pragma runs here. + + const adapter = new DoSQLiteRecordAdapter(exec); + const existing = exec.get<{ content: string }>( + `SELECT content FROM records WHERE id = '_config'`, + ); + if (existing) { + const config = readStackConfig(exec); + adapter.ownerEntityId = config.entityId; + adapter.timezone = config.timezone; + } else { + insertConfigRecord(exec, opts.entityId, opts.timezone); + adapter.ownerEntityId = opts.entityId; + adapter.timezone = opts.timezone; + } + return adapter; + } + + // ------------------------------------------------------- + // Records + // ------------------------------------------------------- + + createRecord(record: StackRecord): Promise { + return this.record.createRecord(record); + } + + getRecord(id: string): Promise { + return this.record.getRecord(id); + } + + patchContent( + id: string, + patch: Record, + opts?: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions, + ): Promise { + return this.record.patchContent(id, patch, opts); + } + + deleteRecord( + id: string, + opts?: { hard?: boolean; expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions, + ): Promise { + return this.record.deleteRecord(id, opts); + } + + undeleteRecord( + id: string, + opts?: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions, + ): Promise { + return this.record.undeleteRecord(id, opts); + } + + setPermissions( + id: string, + permissions: Permission[], + opts?: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions, + ): Promise { + return this.record.setPermissions(id, permissions, opts); + } + + setUnlisted( + id: string, + unlisted: boolean, + opts?: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions, + ): Promise { + return this.record.setUnlisted(id, unlisted, opts); + } + + restoreVersion( + id: string, + version: number, + opts?: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions, + ): Promise { + return this.record.restoreVersion(id, version, opts); + } + + commitMigration( + id: string, + toTypeId: TypeId, + content: Record, + opts?: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions, + ): Promise { + return this.record.commitMigration(id, toTypeId, content, opts); + } + + queryRecords(query: StackQuery): Promise { + return this.record.queryRecords(query); + } + + deleteUnreferencedAttachmentRecords( + fileId: FileId, + metadataTypeId: TypeId, + ): Promise { + return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId); + } + + // ------------------------------------------------------- + // Versions + // ------------------------------------------------------- + + getVersions(id: string): Promise { + return this.record.getVersions(id); + } + + getVersion(id: string, version: number): Promise { + return this.record.getVersion(id, version); + } + + saveVersion(id: string, version: RecordVersion): Promise { + return this.record.saveVersion(id, version); + } + + // ------------------------------------------------------- + // Types + // ------------------------------------------------------- + + saveType(type: StackType): Promise { + return this.record.saveType(type); + } + + getType(id: TypeId): Promise { + return this.record.getType(id); + } + + listTypes(): Promise { + return this.record.listTypes(); + } + + // ------------------------------------------------------- + // Associations + // ------------------------------------------------------- + + associate( + recordId: string, + association: Association, + opts?: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions, + ): Promise { + return this.record.associate(recordId, association, opts); + } + + dissociate( + recordId: string, + association: Association, + opts?: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions, + ): Promise { + return this.record.dissociate(recordId, association, opts); + } + + // ------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------- + + /** No-op: every write through ctx.storage.sql is already durable. */ + async flush(): Promise {} + + /** No-op: no lock file, no connection to release — the DO's own lifecycle governs storage. */ + async close(): Promise {} +} + +export { DurableObjectSqliteExecutor } from './executor.js'; diff --git a/packages/record-adapter-do-sqlite/src/spike-worker.ts b/packages/record-adapter-do-sqlite/src/spike-worker.ts deleted file mode 100644 index 403b760..0000000 --- a/packages/record-adapter-do-sqlite/src/spike-worker.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { DurableObject } from 'cloudflare:workers'; - -type ProbeResult = Record; - -export class SpikeDurableObject extends DurableObject { - private get sql(): SqlStorage { - return this.ctx.storage.sql; - } - - private schema(): void { - this.sql.exec(` - CREATE TABLE IF NOT EXISTS parent (id TEXT PRIMARY KEY, name TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS child (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL REFERENCES parent(id)); - `); - } - - async probePragmas(): Promise { - const out: ProbeResult = {}; - try { - this.sql.exec('PRAGMA foreign_keys = ON;'); - out.foreignKeysPragma = 'ok'; - } catch (err) { - out.foreignKeysPragma = `error: ${(err as Error).message}`; - } - try { - const rows = this.sql.exec('PRAGMA foreign_keys;').toArray(); - out.foreignKeysValue = rows; - } catch (err) { - out.foreignKeysValue = `error: ${(err as Error).message}`; - } - try { - this.sql.exec('PRAGMA journal_mode = WAL;'); - out.journalModeWalPragma = 'ok'; - } catch (err) { - out.journalModeWalPragma = `error: ${(err as Error).message}`; - } - try { - const rows = this.sql.exec('PRAGMA journal_mode;').toArray(); - out.journalModeValue = rows; - } catch (err) { - out.journalModeValue = `error: ${(err as Error).message}`; - } - return out; - } - - async probeForeignKeyEnforcement(): Promise { - this.schema(); - this.sql.exec('PRAGMA foreign_keys = ON;'); - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('p1', 'Parent One');`); - const out: ProbeResult = {}; - try { - this.sql.exec(`INSERT INTO child (id, parent_id) VALUES ('c1', 'does-not-exist');`); - out.danglingInsert = 'succeeded (NO enforcement!)'; - } catch (err) { - out.danglingInsert = 'rejected'; - out.errorMessage = (err as Error).message; - out.errorConstructorName = (err as Error).constructor?.name; - out.errorKeys = Object.keys(err as object); - out.errorJson = JSON.stringify(err, Object.getOwnPropertyNames(err as object)); - } - return out; - } - - async probeUniqueViolation(): Promise { - this.schema(); - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('dup', 'First');`); - const out: ProbeResult = {}; - try { - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('dup', 'Second');`); - out.duplicateInsert = 'succeeded (NO uniqueness enforcement!)'; - } catch (err) { - out.duplicateInsert = 'rejected'; - out.errorMessage = (err as Error).message; - out.errorConstructorName = (err as Error).constructor?.name; - } - return out; - } - - async probeRawTransaction(): Promise { - this.schema(); - const out: ProbeResult = {}; - - // Commit path - try { - this.sql.exec('BEGIN'); - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('tx-commit', 'Committed');`); - this.sql.exec('COMMIT'); - const row = this.sql.exec(`SELECT * FROM parent WHERE id = 'tx-commit';`).toArray(); - out.commitPath = 'ok'; - out.commitRow = row; - } catch (err) { - out.commitPath = `error: ${(err as Error).message}`; - } - - // Rollback path - try { - this.sql.exec('BEGIN'); - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('tx-rollback', 'ShouldNotPersist');`); - this.sql.exec('ROLLBACK'); - const row = this.sql - .exec(`SELECT * FROM parent WHERE id = 'tx-rollback';`) - .toArray(); - out.rollbackPath = 'ok'; - out.rollbackRowCountAfterRollback = row.length; - } catch (err) { - out.rollbackPath = `error: ${(err as Error).message}`; - } - - // Nested/nested nesting sanity — does a second BEGIN before COMMIT error? - try { - this.sql.exec('BEGIN'); - this.sql.exec('BEGIN'); - out.nestedBegin = 'second BEGIN did not throw'; - this.sql.exec('ROLLBACK'); - } catch (err) { - out.nestedBegin = `threw: ${(err as Error).message}`; - try { - this.sql.exec('ROLLBACK'); - } catch { - /* best-effort cleanup */ - } - } - - return out; - } - - async probeCursorShape(): Promise { - this.schema(); - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('cur1', 'One'), ('cur2', 'Two');`); - const cursor = this.sql.exec('SELECT * FROM parent ORDER BY id;'); - const out: ProbeResult = { - hasToArray: typeof (cursor as any).toArray === 'function', - hasOne: typeof (cursor as any).one === 'function', - hasRaw: typeof (cursor as any).raw === 'function', - hasNext: typeof (cursor as any).next === 'function', - hasSymbolIterator: typeof (cursor as any)[Symbol.iterator] === 'function', - columnNames: (cursor as any).columnNames, - rowsRead: (cursor as any).rowsRead, - rowsWritten: (cursor as any).rowsWritten, - }; - out.toArrayResult = cursor.toArray(); - - const insertCursor = this.sql.exec( - `INSERT INTO parent (id, name) VALUES ('cur3', 'Three');`, - ); - out.insertRowsWritten = (insertCursor as any).rowsWritten; - out.insertRowsRead = (insertCursor as any).rowsRead; - - const updateCursor = this.sql.exec(`UPDATE parent SET name = 'Updated' WHERE id = 'cur1';`); - out.updateRowsWritten = (updateCursor as any).rowsWritten; - - const deleteCursor = this.sql.exec(`DELETE FROM parent WHERE id = 'cur2';`); - out.deleteRowsWritten = (deleteCursor as any).rowsWritten; - - return out; - } - - async probeFts5(): Promise { - const out: ProbeResult = {}; - try { - this.sql.exec(` - CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(content); - `); - this.sql.exec(`INSERT INTO docs_fts (rowid, content) VALUES (1, 'the quick brown fox');`); - this.sql.exec(`INSERT INTO docs_fts (rowid, content) VALUES (2, 'lazy dog sleeps');`); - const rows = this.sql - .exec(`SELECT rowid, content FROM docs_fts WHERE docs_fts MATCH 'fox';`) - .toArray(); - out.fts5 = 'ok'; - out.matchRows = rows; - } catch (err) { - out.fts5 = `error: ${(err as Error).message}`; - } - return out; - } - - /** - * The critical question for #161: SharedSqlRecordLogic issues raw - * BEGIN/COMMIT/ROLLBACK, which DO SQLite rejects outright (see - * probeRawTransaction). Its error points at automatic "atomic write - * coalescing" instead. If that coalescing actually undoes writes when - * an exception unwinds a synchronous stretch of storage calls — even - * without ROLLBACK ever being called — then BEGIN/COMMIT/ROLLBACK can - * become no-ops in the executor with zero changes to the shared logic. - * This probe writes a row, then throws before returning, with no - * ROLLBACK anywhere, and reports whether the write survived. - */ - // NOTE: these deliberately do NOT catch — the exception must actually - // unwind out of the RPC call boundary for this to test what it claims - // to test. The caller (test file) awaits-and-catches, then makes a - // *separate* RPC call to check what actually persisted. - - async writeThenThrow_sync(): Promise { - this.schema(); - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('auto-rb-sync', 'x');`); - throw new Error('simulated business-logic failure, no ROLLBACK issued'); - } - - async writeThenThrow_microtask(): Promise { - this.schema(); - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('auto-rb-micro', 'x');`); - await Promise.resolve(); - throw new Error('simulated business-logic failure after a microtask hop'); - } - - async writeThenThrow_multiStatement(): Promise { - this.schema(); - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('multi-1', 'x');`); - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('multi-2', 'x');`); - this.sql.exec(`UPDATE parent SET name = 'updated' WHERE id = 'multi-1';`); - throw new Error('fail after three statements, before returning'); - } - - /** - * If BEGIN/COMMIT/ROLLBACK become no-ops in the executor, atomicity has - * to come from somewhere else: wrapping the whole adapter method call - * in ctx.storage.transactionSync(), whose contract explicitly promises - * auto-rollback on a thrown exception. Confirms that promise holds for - * a synchronous multi-statement sequence with mixed reads/writes. - */ - async transactionSyncThenThrow(): Promise { - this.schema(); - this.ctx.storage.transactionSync(() => { - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('txsync-1', 'x');`); - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('txsync-2', 'x');`); - const check = this.sql.exec(`SELECT * FROM parent WHERE id = 'txsync-1';`).toArray(); - if (check.length !== 1) throw new Error('unexpected read result'); - throw new Error('simulated business-logic failure inside transactionSync'); - }); - } - - async transactionSyncReturnsValue(): Promise { - this.schema(); - const result = this.ctx.storage.transactionSync(() => { - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('txsync-ret', 'x');`); - const row = this.sql.exec(`SELECT * FROM parent WHERE id = 'txsync-ret';`).one(); - return { fromInsideTxn: row }; - }); - return { returnedValue: result }; - } - - async transactionSyncCommits(): Promise { - this.schema(); - this.ctx.storage.transactionSync(() => { - this.sql.exec(`INSERT INTO parent (id, name) VALUES ('txsync-ok', 'x');`); - }); - const rows = this.sql.exec(`SELECT * FROM parent WHERE id = 'txsync-ok';`).toArray(); - return { rows }; - } - - async checkSurvivors(likePattern: string): Promise { - const rows = this.sql.exec(`SELECT * FROM parent WHERE id LIKE ? ORDER BY id;`, likePattern).toArray(); - return { rows }; - } - - async probeBindingStyle(): Promise { - this.schema(); - const out: ProbeResult = {}; - try { - this.sql.exec(`INSERT INTO parent (id, name) VALUES (?, ?);`, 'bind1', 'Bound Name'); - const row = this.sql.exec(`SELECT * FROM parent WHERE id = ?;`, 'bind1').toArray(); - out.spreadArgsBinding = 'ok'; - out.row = row; - } catch (err) { - out.spreadArgsBinding = `error: ${(err as Error).message}`; - } - return out; - } -} - -export default { - async fetch(): Promise { - return new Response('spike worker: no HTTP surface, use RPC stub methods in tests'); - }, -}; diff --git a/packages/record-adapter-do-sqlite/tests/record.test.ts b/packages/record-adapter-do-sqlite/tests/record.test.ts new file mode 100644 index 0000000..5b7f2ce --- /dev/null +++ b/packages/record-adapter-do-sqlite/tests/record.test.ts @@ -0,0 +1,299 @@ +/** + * Targeted subset, not a 1:1 port of record-adapter-sqlite's suite — that + * suite already proves SharedSqlRecordLogic's correctness once. What's + * unique to this adapter and worth proving again, against the real + * Workers runtime (@cloudflare/vitest-pool-workers), not `environment: + * 'node'`: the executor's translation of get/all/run onto SqlStorage's + * cursor API, FK/unique constraint mapping, FTS5, cursor-codec pagination, + * and — the one thing the #161 spike found couldn't be assumed — that + * exec.transaction() reaching ctx.storage.transactionSync() actually + * rolls back a rejected mutation's partial writes (see the FTS-consistency + * test below), since DO SQLite has no raw BEGIN/COMMIT/ROLLBACK to fall + * back on if that wiring were wrong. + */ +import { env } from 'cloudflare:test'; +import { describe, test, expect } from 'vitest'; +import type { StackRecord, StackQuery, QueryResult, Association } from '@haverstack/core'; +import type { AdapterCapabilities } from '@haverstack/core/adapter'; + +/** + * A Durable Object is a separate JS realm from the test file's own — even + * colocated, an RPC call across that boundary reconstructs a thrown Error + * as a generic object carrying the same enumerable properties (message, + * name, code, and any custom fields like recordId), but NOT the original + * class's prototype chain. `instanceof StackConflictError` fails on the + * far side of that boundary even though the error is genuinely a + * StackConflictError inside the DO; `.code` (StackError's discriminant, + * see packages/core/src/stack.ts) is what survives and what these tests + * assert on instead. record-adapter-sqlite's tests never hit this because + * everything there runs in one process. + */ + +/** + * Cloudflare's automatic RPC type inference for a DurableObjectStub + * collapses to `never` for several of TestRecordAdapterDO's methods — + * StackRecord/QueryResult/Association are ordinary data types and the + * runtime call works correctly (see the assertions below), but the + * recursive type transformation the RPC types apply to a class with this + * many methods and this much optional/union structure in their signatures + * doesn't resolve. Declaring the stub's shape explicitly sidesteps that + * inference rather than fighting it. + */ +type TestStub = { + getCapabilities(): Promise; + getOwnerEntityId(): Promise; + createRecord(record: StackRecord): Promise; + getRecord(id: string): Promise; + patchContent( + id: string, + patch: Record, + opts?: Record, + ): Promise; + deleteRecord( + id: string, + opts?: { hard?: boolean } & Record, + ): Promise; + queryRecords(query: StackQuery): Promise; + associate( + recordId: string, + association: Association, + opts?: Record, + ): Promise; + dissociate( + recordId: string, + association: Association, + opts?: Record, + ): Promise; + commitMigration( + id: string, + toTypeId: string, + content: Record, + opts?: Record, + ): Promise; +}; + +const getStub = (): TestStub => { + const id = env.TEST_DO.idFromName(`do-${Math.random().toString(36).slice(2)}`); + return env.TEST_DO.get(id) as unknown as TestStub; +}; + +const NOTE_TYPE_V1 = 'com.example.test/note@1'; + +const makeRecord = (overrides: Partial = {}): StackRecord => ({ + id: `rec-${Math.random().toString(36).slice(2)}`, + typeId: NOTE_TYPE_V1, + createdAt: new Date(), + updatedAt: new Date(), + content: { text: 'Hello world' }, + version: 1, + ...overrides, +}); + +describe('construction', () => { + test('declares capabilities matching record-adapter-sqlite', async () => { + const stub = getStub(); + const capabilities = await stub.getCapabilities(); + expect(capabilities).toEqual({ + fullTextSearch: true, + contentFieldQuery: true, + sortableFields: ['createdAt', 'updatedAt', 'version'], + maxAttachmentBytes: null, + maxContentBytes: null, + }); + }); + + test('sets ownerEntityId from create() options', async () => { + const stub = getStub(); + expect(await stub.getOwnerEntityId()).toBe('entity-test'); + }); +}); + +describe('records — CRUD', () => { + test('createRecord and getRecord roundtrip, with Date fields intact across the RPC boundary', async () => { + const stub = getStub(); + const record = makeRecord({ content: { text: 'Hello' } }); + await stub.createRecord(record); + const retrieved = await stub.getRecord(record.id); + expect(retrieved?.id).toBe(record.id); + expect(retrieved?.content).toEqual({ text: 'Hello' }); + expect(retrieved?.createdAt).toBeInstanceOf(Date); + expect(retrieved?.updatedAt).toBeInstanceOf(Date); + }); + + test('getRecord returns null for unknown id', async () => { + const stub = getStub(); + expect(await stub.getRecord('nonexistent')).toBeNull(); + }); + + test('createRecord throws StackConflictError on a duplicate id (unique constraint mapping)', async () => { + const stub = getStub(); + const record = makeRecord(); + await stub.createRecord(record); + const err = await stub + .createRecord({ ...record, content: { text: 'second' } }) + .catch((e: unknown) => e); + expect((err as { code?: string }).code).toBe('conflict'); + }); + + test('patchContent changes content and bumps version', async () => { + const stub = getStub(); + const record = makeRecord(); + await stub.createRecord(record); + const updated = await stub.patchContent(record.id, { text: 'Updated' }); + expect(updated.content).toEqual({ text: 'Updated' }); + expect(updated.version).toBe(2); + }); + + test('hard deleteRecord removes the record entirely', async () => { + const stub = getStub(); + const record = makeRecord(); + await stub.createRecord(record); + await stub.deleteRecord(record.id, { hard: true }); + expect(await stub.getRecord(record.id)).toBeNull(); + }); +}); + +describe('expectedVersion / transactional rollback', () => { + test('patchContent throws StackVersionConflictError and changes nothing when stale', async () => { + const stub = getStub(); + const record = await stub.createRecord(makeRecord()); + await stub.patchContent(record.id, { text: 'first' }); // -> v2 + + const err = await stub + .patchContent(record.id, { text: 'second' }, { expectedVersion: 1 }) + .catch((e: unknown) => e); + expect( + ( + err as { + code?: string; + recordId?: string; + expectedVersion?: number; + actualVersion?: number; + } + ).code, + ).toBe('version_conflict'); + expect((err as { recordId?: string }).recordId).toBe(record.id); + expect((err as { expectedVersion?: number }).expectedVersion).toBe(1); + expect((err as { actualVersion?: number }).actualVersion).toBe(2); + + const current = await stub.getRecord(record.id); + expect(current?.version).toBe(2); + expect(current?.content).toEqual({ text: 'first' }); + }); + + /** + * This is the load-bearing test for #161's central finding: patchContent + * removes the old FTS entry, then re-inserts it, inside one + * exec.transaction() block. If ctx.storage.transactionSync() did not + * actually roll back on throw — or if the executor had instead tried + * no-op'ing BEGIN/COMMIT/ROLLBACK, which the #161 spike found does NOT + * roll back on DO SQLite — a rejected patch here would leave the FTS + * index missing the original entry: searchable content would vanish + * even though the record's own content never changed. + */ + test('a rejected patchContent leaves the FTS index consistent with stored content', async () => { + const stub = getStub(); + const record = await stub.createRecord( + makeRecord({ content: { text: 'searchable original' } }), + ); + await stub + .patchContent(record.id, { text: 'rejected update' }, { expectedVersion: 999 }) + .catch(() => {}); + + const stillFindsOriginal = await stub.queryRecords({ filter: { search: 'original' } }); + expect(stillFindsOriginal.records.map((r) => r.id)).toEqual([record.id]); + const doesNotFindRejected = await stub.queryRecords({ filter: { search: 'rejected' } }); + expect(doesNotFindRejected.records).toEqual([]); + }); +}); + +describe('records — queries', () => { + test('filters by content field', async () => { + const stub = getStub(); + await stub.createRecord(makeRecord({ id: 'r1', content: { text: 'alpha', priority: 1 } })); + await stub.createRecord(makeRecord({ id: 'r2', content: { text: 'beta', priority: 2 } })); + const result = await stub.queryRecords({ filter: { content: { priority: 1 } } }); + expect(result.records.map((r) => r.id)).toEqual(['r1']); + }); + + test('full-text search (FTS5)', async () => { + const stub = getStub(); + await stub.createRecord(makeRecord({ id: 'r1', content: { text: 'SQLite is great' } })); + await stub.createRecord(makeRecord({ id: 'r2', content: { text: 'Postgres is also great' } })); + const result = await stub.queryRecords({ filter: { search: 'SQLite' } }); + expect(result.records.map((r) => r.id)).toEqual(['r1']); + }); + + test('cursor pagination returns correct pages', async () => { + const stub = getStub(); + for (let i = 0; i < 5; i++) { + await stub.createRecord( + makeRecord({ id: `r${i}`, createdAt: new Date(Date.now() + i * 1000) }), + ); + } + const page1 = await stub.queryRecords({ + sort: { field: 'createdAt', direction: 'asc' }, + limit: 3, + }); + expect(page1.records.length).toBe(3); + expect(page1.cursor).not.toBeNull(); + expect(page1.total).toBe(5); + + const page2 = await stub.queryRecords({ + sort: { field: 'createdAt', direction: 'asc' }, + limit: 3, + cursor: page1.cursor!, + }); + expect(page2.records.length).toBe(2); + expect(page2.cursor).toBeNull(); + }); + + test('malformed cursor throws StackQueryError', async () => { + const stub = getStub(); + await stub.createRecord(makeRecord({ id: 'r1' })); + const err = await stub.queryRecords({ cursor: '!!!not-a-cursor!!!' }).catch((e: unknown) => e); + expect((err as { code?: string }).code).toBe('bad_request'); + }); +}); + +describe('associations', () => { + test('associate adds a tag, dissociate removes it, both bump version', async () => { + const stub = getStub(); + const record = makeRecord(); + await stub.createRecord(record); + await stub.associate(record.id, { kind: 'tag', label: 'starred' }); + const withTag = await stub.getRecord(record.id); + expect(withTag?.associations?.some((a) => a.kind === 'tag' && a.label === 'starred')).toBe( + true, + ); + expect(withTag?.version).toBe(2); + + await stub.dissociate(record.id, { kind: 'tag', label: 'starred' }); + const withoutTag = await stub.getRecord(record.id); + expect(withoutTag?.associations).toBeUndefined(); + }); + + test('associate on a nonexistent record throws StackNotFoundError (FK constraint mapping) instead of creating an orphan row', async () => { + const stub = getStub(); + const err = await stub + .associate('nonexistent', { kind: 'tag', label: 'starred' }) + .catch((e: unknown) => e); + expect((err as { code?: string }).code).toBe('not_found'); + }); +}); + +describe('commitMigration', () => { + test('changes typeId and content together, and bumps version', async () => { + const stub = getStub(); + const record = makeRecord({ typeId: NOTE_TYPE_V1 }); + await stub.createRecord(record); + + const migrated = await stub.commitMigration(record.id, 'com.example.test/note@2', { + text: 'Hello world', + pinned: false, + }); + expect(migrated.typeId).toBe('com.example.test/note@2'); + expect(migrated.content).toEqual({ text: 'Hello world', pinned: false }); + expect(migrated.version).toBe(2); + }); +}); diff --git a/packages/record-adapter-do-sqlite/tests/spike.test.ts b/packages/record-adapter-do-sqlite/tests/spike.test.ts deleted file mode 100644 index 2b186c0..0000000 --- a/packages/record-adapter-do-sqlite/tests/spike.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { env } from 'cloudflare:test'; -import { describe, test, expect } from 'vitest'; - -// Spike for #161 — answers questions that determine whether -// SharedSqlRecordLogic's synchronous, raw-BEGIN/COMMIT-based SqlExecutor -// contract can be reused as-is against ctx.storage.sql on a DO, or needs -// a different transaction/pragma strategy. Findings are printed via -// console.log (visible in the workers pool's captured output) rather than -// asserted strictly, since the goal here is discovery, not regression -// coverage — the real adapter's tests replace this file. - -const getStub = () => { - const id = env.SPIKE_DO.idFromName(`spike-${Math.random()}`); - return env.SPIKE_DO.get(id); -}; - -describe('DO SQLite spike', () => { - test('pragmas', async () => { - const stub = getStub(); - const result = await stub.probePragmas(); - console.log('PRAGMAS:', JSON.stringify(result, null, 2)); - }); - - test('foreign key enforcement', async () => { - const stub = getStub(); - const result = await stub.probeForeignKeyEnforcement(); - console.log('FK ENFORCEMENT:', JSON.stringify(result, null, 2)); - }); - - test('unique violation', async () => { - const stub = getStub(); - const result = await stub.probeUniqueViolation(); - console.log('UNIQUE VIOLATION:', JSON.stringify(result, null, 2)); - }); - - test('raw BEGIN/COMMIT/ROLLBACK', async () => { - const stub = getStub(); - const result = await stub.probeRawTransaction(); - console.log('RAW TRANSACTION:', JSON.stringify(result, null, 2)); - }); - - test('cursor shape', async () => { - const stub = getStub(); - const result = await stub.probeCursorShape(); - console.log('CURSOR SHAPE:', JSON.stringify(result, null, 2)); - }); - - test('fts5', async () => { - const stub = getStub(); - const result = await stub.probeFts5(); - console.log('FTS5:', JSON.stringify(result, null, 2)); - }); - - test('spread-args parameter binding', async () => { - const stub = getStub(); - const result = await stub.probeBindingStyle(); - console.log('BINDING STYLE:', JSON.stringify(result, null, 2)); - }); - - test('auto-rollback on throw: synchronous, no ROLLBACK issued', async () => { - const stub = getStub(); - await expect(stub.writeThenThrow_sync()).rejects.toThrow(); - const result = await stub.checkSurvivors('auto-rb-sync'); - console.log('AUTO-ROLLBACK (sync throw):', JSON.stringify(result, null, 2)); - }); - - test('auto-rollback on throw: after a microtask hop', async () => { - const stub = getStub(); - await expect(stub.writeThenThrow_microtask()).rejects.toThrow(); - const result = await stub.checkSurvivors('auto-rb-micro'); - console.log('AUTO-ROLLBACK (microtask throw):', JSON.stringify(result, null, 2)); - }); - - test('auto-rollback on throw: multi-statement sequence', async () => { - const stub = getStub(); - await expect(stub.writeThenThrow_multiStatement()).rejects.toThrow(); - const result = await stub.checkSurvivors('multi-%'); - console.log('AUTO-ROLLBACK (multi-statement):', JSON.stringify(result, null, 2)); - }); - - test('transactionSync rolls back on throw', async () => { - const stub = getStub(); - await expect(stub.transactionSyncThenThrow()).rejects.toThrow(); - const result = await stub.checkSurvivors('txsync-%'); - console.log('TRANSACTIONSYNC ROLLBACK:', JSON.stringify(result, null, 2)); - }); - - test('transactionSync commits on success', async () => { - const stub = getStub(); - const result = await stub.transactionSyncCommits(); - console.log('TRANSACTIONSYNC COMMIT:', JSON.stringify(result, null, 2)); - }); - - test('transactionSync passes return value through', async () => { - const stub = getStub(); - const result = await stub.transactionSyncReturnsValue(); - console.log('TRANSACTIONSYNC RETURN VALUE:', JSON.stringify(result, null, 2)); - }); -}); diff --git a/packages/record-adapter-do-sqlite/tests/support/env.d.ts b/packages/record-adapter-do-sqlite/tests/support/env.d.ts new file mode 100644 index 0000000..f2d39c1 --- /dev/null +++ b/packages/record-adapter-do-sqlite/tests/support/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/record-adapter-do-sqlite/tests/support/test-worker.ts b/packages/record-adapter-do-sqlite/tests/support/test-worker.ts new file mode 100644 index 0000000..50cda24 --- /dev/null +++ b/packages/record-adapter-do-sqlite/tests/support/test-worker.ts @@ -0,0 +1,154 @@ +/** + * Wraps DoSQLiteRecordAdapter in a real DurableObject subclass, the way a + * consuming Worker would — this IS the reference shape for that wiring, + * not test-only scaffolding around it. Every method is a thin RPC + * pass-through: Workers RPC serializes plain data (including Date, via + * structured clone) across the stub boundary, but not class instances, so + * each StackRecordAdapter method needs its own exposed method here. + */ +import { DurableObject } from 'cloudflare:workers'; +import { DoSQLiteRecordAdapter } from '../../src/index.js'; +import type { + StackRecord, + StackQuery, + Association, + Permission, + RecordVersion, + StackType, + TypeId, + FileId, + ActorOptions, +} from '@haverstack/core'; + +type MutationOpts = { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions; + +export class TestRecordAdapterDO extends DurableObject { + private adapter!: DoSQLiteRecordAdapter; + private readonly ready: Promise; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.ready = ctx.blockConcurrencyWhile(async () => { + this.adapter = await DoSQLiteRecordAdapter.create(ctx.storage, { + entityId: 'entity-test', + timezone: 'America/New_York', + }); + }); + } + + async getCapabilities() { + await this.ready; + return this.adapter.capabilities; + } + + async getOwnerEntityId() { + await this.ready; + return this.adapter.ownerEntityId; + } + + async createRecord(record: StackRecord) { + await this.ready; + return this.adapter.createRecord(record); + } + + async getRecord(id: string) { + await this.ready; + return this.adapter.getRecord(id); + } + + async patchContent(id: string, patch: Record, opts?: MutationOpts) { + await this.ready; + return this.adapter.patchContent(id, patch, opts); + } + + async deleteRecord(id: string, opts?: { hard?: boolean } & MutationOpts) { + await this.ready; + return this.adapter.deleteRecord(id, opts); + } + + async undeleteRecord(id: string, opts?: MutationOpts) { + await this.ready; + return this.adapter.undeleteRecord(id, opts); + } + + async setPermissions(id: string, permissions: Permission[], opts?: MutationOpts) { + await this.ready; + return this.adapter.setPermissions(id, permissions, opts); + } + + async setUnlisted(id: string, unlisted: boolean, opts?: MutationOpts) { + await this.ready; + return this.adapter.setUnlisted(id, unlisted, opts); + } + + async restoreVersion(id: string, version: number, opts?: MutationOpts) { + await this.ready; + return this.adapter.restoreVersion(id, version, opts); + } + + async commitMigration( + id: string, + toTypeId: TypeId, + content: Record, + opts?: MutationOpts, + ) { + await this.ready; + return this.adapter.commitMigration(id, toTypeId, content, opts); + } + + async queryRecords(query: StackQuery) { + await this.ready; + return this.adapter.queryRecords(query); + } + + async deleteUnreferencedAttachmentRecords(fileId: FileId, metadataTypeId: TypeId) { + await this.ready; + return this.adapter.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId); + } + + async getVersions(id: string) { + await this.ready; + return this.adapter.getVersions(id); + } + + async getVersion(id: string, version: number) { + await this.ready; + return this.adapter.getVersion(id, version); + } + + async saveVersion(id: string, version: RecordVersion) { + await this.ready; + return this.adapter.saveVersion(id, version); + } + + async saveType(type: StackType) { + await this.ready; + return this.adapter.saveType(type); + } + + async getType(id: TypeId) { + await this.ready; + return this.adapter.getType(id); + } + + async listTypes() { + await this.ready; + return this.adapter.listTypes(); + } + + async associate(recordId: string, association: Association, opts?: MutationOpts) { + await this.ready; + return this.adapter.associate(recordId, association, opts); + } + + async dissociate(recordId: string, association: Association, opts?: MutationOpts) { + await this.ready; + return this.adapter.dissociate(recordId, association, opts); + } +} + +export default { + async fetch(): Promise { + return new Response('test worker: use RPC stub methods'); + }, +}; diff --git a/packages/record-adapter-do-sqlite/tsconfig.json b/packages/record-adapter-do-sqlite/tsconfig.json new file mode 100644 index 0000000..0ca8b2b --- /dev/null +++ b/packages/record-adapter-do-sqlite/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "noEmit": true, + "lib": ["ES2022"], + "types": [], + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "worker-configuration.d.ts"] +} diff --git a/packages/record-adapter-do-sqlite/tsup.config.ts b/packages/record-adapter-do-sqlite/tsup.config.ts new file mode 100644 index 0000000..b170fce --- /dev/null +++ b/packages/record-adapter-do-sqlite/tsup.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'tsup'; + +/** + * Mirrors record-adapter-sqlite's tsup config: @haverstack/sqlite-shared is + * internal (private, no stability promise, not published) and gets bundled + * into this package's output rather than resolved from the registry. + * @haverstack/core stays external — a real published peer, and inlining it + * would give this package its own private copy of the error classes, + * breaking `instanceof` against the caller's. + * + * target: 'es2022' rather than a Node target — this ships into a Workers + * bundle (via wrangler/esbuild in the consuming app), not run standalone + * under node. + */ +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + target: 'es2022', + dts: true, + sourcemap: true, + clean: true, + splitting: false, + treeshake: true, + external: ['@haverstack/core', '@haverstack/core/adapter'], + noExternal: ['@haverstack/sqlite-shared'], +}); diff --git a/packages/record-adapter-do-sqlite/vitest.config.ts b/packages/record-adapter-do-sqlite/vitest.config.ts index 504783a..3d154aa 100644 --- a/packages/record-adapter-do-sqlite/vitest.config.ts +++ b/packages/record-adapter-do-sqlite/vitest.config.ts @@ -1,6 +1,15 @@ import { defineConfig } from 'vitest/config'; import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; +import { resolve } from 'path'; export default defineConfig({ + resolve: { + alias: { + '@haverstack/core/wire': resolve(__dirname, '../core/src/wire-entry.ts'), + '@haverstack/core/adapter': resolve(__dirname, '../core/src/adapter-entry.ts'), + '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), + '@haverstack/sqlite-shared/record': resolve(__dirname, '../sqlite-shared/src/record.ts'), + }, + }, plugins: [cloudflareTest({ wrangler: { configPath: './wrangler.jsonc' } })], }); diff --git a/packages/record-adapter-do-sqlite/wrangler.jsonc b/packages/record-adapter-do-sqlite/wrangler.jsonc index a215376..775d614 100644 --- a/packages/record-adapter-do-sqlite/wrangler.jsonc +++ b/packages/record-adapter-do-sqlite/wrangler.jsonc @@ -1,20 +1,20 @@ { "$schema": "node_modules/wrangler/config-schema.json", - "name": "record-adapter-do-sqlite-spike", - "main": "src/spike-worker.ts", + "name": "record-adapter-do-sqlite-tests", + "main": "tests/support/test-worker.ts", "compatibility_date": "2026-08-01", "durable_objects": { "bindings": [ { - "name": "SPIKE_DO", - "class_name": "SpikeDurableObject" - } - ] + "name": "TEST_DO", + "class_name": "TestRecordAdapterDO", + }, + ], }, "migrations": [ { "tag": "v1", - "new_sqlite_classes": ["SpikeDurableObject"] - } - ] + "new_sqlite_classes": ["TestRecordAdapterDO"], + }, + ], } diff --git a/packages/record-adapter-sqlite/src/executor.ts b/packages/record-adapter-sqlite/src/executor.ts index 709b2af..035f057 100644 --- a/packages/record-adapter-sqlite/src/executor.ts +++ b/packages/record-adapter-sqlite/src/executor.ts @@ -21,4 +21,16 @@ export class NativeSqliteExecutor implements SqlExecutor { all>(sql: string, params: readonly unknown[] = []): T[] { return this.db.prepare(sql).all(...(params as (string | number | null)[])) as T[]; } + + transaction(fn: () => T): T { + this.db.exec('BEGIN'); + try { + const result = fn(); + this.db.exec('COMMIT'); + return result; + } catch (err) { + this.db.exec('ROLLBACK'); + throw err; + } + } } diff --git a/packages/sqlite-shared/package.json b/packages/sqlite-shared/package.json index 37659ff..b33756e 100644 --- a/packages/sqlite-shared/package.json +++ b/packages/sqlite-shared/package.json @@ -7,6 +7,10 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./record": { + "import": "./dist/record.js", + "types": "./dist/record.d.ts" } }, "main": "./dist/index.js", diff --git a/packages/sqlite-shared/src/executor.ts b/packages/sqlite-shared/src/executor.ts index 4d22d71..3e20f39 100644 --- a/packages/sqlite-shared/src/executor.ts +++ b/packages/sqlite-shared/src/executor.ts @@ -19,6 +19,17 @@ export interface SqlExecutor { get>(sql: string, params?: readonly unknown[]): T | undefined; /** Run a parameterized statement and return all result rows. */ all>(sql: string, params?: readonly unknown[]): T[]; + /** + * Run `fn` atomically: every statement it issues commits together, or + * none do if it throws. `fn` must be synchronous and call back into + * this executor only — not every engine's transaction primitive can + * straddle a suspended call. A binding with a real multi-statement + * transaction (BEGIN/COMMIT/ROLLBACK) implements this with one; a + * binding whose transaction primitive is itself a callback wrapper + * (e.g. a Durable Object's storage.transactionSync) can pass `fn` + * straight through to it. + */ + transaction(fn: () => T): T; } /** SQLite reports FK violations with this exact message; verify it holds when adding an engine. */ diff --git a/packages/sqlite-shared/src/record-logic.ts b/packages/sqlite-shared/src/record-logic.ts index c761975..4d97286 100644 --- a/packages/sqlite-shared/src/record-logic.ts +++ b/packages/sqlite-shared/src/record-logic.ts @@ -165,12 +165,13 @@ export class SharedSqlRecordLogic { } /** - * The synchronous read behind getRecord(). Callers inside a transaction - * use this one: an `await` between BEGIN and COMMIT yields the microtask - * queue mid-transaction, and the next operation to run would try to open - * one of its own. + * The synchronous read behind getRecord(). Callers inside exec.transaction() + * use this one: an `await` inside that callback yields the microtask queue + * mid-transaction, and the next operation to run would try to open one of + * its own — transaction() requires a synchronous callback for exactly this + * reason (see SqlExecutor.transaction). * - * The same synchrony is what lets the post-COMMIT reads below report the + * The same synchrony is what lets the post-commit reads below report the * version their own mutation produced: `getRecord()` runs its body before * the `await` yields, so nothing interleaves between the commit and the * read. A backend that made these reads genuinely asynchronous would open @@ -194,8 +195,7 @@ export class SharedSqlRecordLogic { this.checkExpectedVersion(existing, opts.expectedVersion); const merged = applyMergePatch(existing.content, patch); - this.exec.exec('BEGIN'); - try { + this.exec.transaction(() => { if (opts.snapshot) this.snapshotBeforeMutation(id, opts.snapshot); fts5Strategy.remove(this.exec, id); this.exec.run( @@ -210,11 +210,7 @@ export class SharedSqlRecordLogic { ); fts5Strategy.insert(this.exec, id, JSON.stringify(merged)); this.syncFileRefs(id, existing.typeId, merged); - this.exec.exec('COMMIT'); - } catch (err) { - this.exec.exec('ROLLBACK'); - throw err; - } + }); const updated = await this.getRecord(id); if (!updated) throw new Error(`Record not found after patchContent: "${id}"`); @@ -230,18 +226,9 @@ export class SharedSqlRecordLogic { } & ActorOptions = {}, ): Promise { if (opts.hard) { - this.exec.exec('BEGIN'); - try { - const purged = this.hardDeleteRecord(id, opts.expectedVersion); - this.exec.exec('COMMIT'); - return purged; - } catch (err) { - this.exec.exec('ROLLBACK'); - throw err; - } + return this.exec.transaction(() => this.hardDeleteRecord(id, opts.expectedVersion)); } else { - this.exec.exec('BEGIN'); - try { + this.exec.transaction(() => { if (opts.snapshot) this.snapshotBeforeMutation(id, opts.snapshot); const { clause, params: verParams } = this.versionGuard(opts.expectedVersion); const changed = this.exec.run( @@ -256,11 +243,7 @@ export class SharedSqlRecordLogic { ], ); if (changed === 0) this.throwVersionConflict(id, opts.expectedVersion); - this.exec.exec('COMMIT'); - } catch (err) { - this.exec.exec('ROLLBACK'); - throw err; - } + }); } const updated = await this.getRecord(id); @@ -303,8 +286,7 @@ export class SharedSqlRecordLogic { id: string, opts: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions = {}, ): Promise { - this.exec.exec('BEGIN'); - try { + this.exec.transaction(() => { if (opts.snapshot) this.snapshotBeforeMutation(id, opts.snapshot); const { clause, params: verParams } = this.versionGuard(opts.expectedVersion); const changed = this.exec.run( @@ -312,11 +294,7 @@ export class SharedSqlRecordLogic { [toMs(new Date()), opts.updatedBy ?? null, opts.updatedVia ?? null, id, ...verParams], ); if (changed === 0) this.throwVersionConflict(id, opts.expectedVersion); - this.exec.exec('COMMIT'); - } catch (err) { - this.exec.exec('ROLLBACK'); - throw err; - } + }); const updated = await this.getRecord(id); if (!updated) throw new Error(`Record not found after undelete: "${id}"`); @@ -328,8 +306,7 @@ export class SharedSqlRecordLogic { permissions: Permission[], opts: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions = {}, ): Promise { - this.exec.exec('BEGIN'); - try { + this.exec.transaction(() => { if (opts.snapshot) this.snapshotBeforeMutation(id, opts.snapshot); const { clause, params: verParams } = this.versionGuard(opts.expectedVersion); const changed = this.exec.run( @@ -344,11 +321,7 @@ export class SharedSqlRecordLogic { ], ); if (changed === 0) this.throwVersionConflict(id, opts.expectedVersion); - this.exec.exec('COMMIT'); - } catch (err) { - this.exec.exec('ROLLBACK'); - throw err; - } + }); const updated = await this.getRecord(id); if (!updated) throw new Error(`Record not found after setPermissions: "${id}"`); @@ -360,8 +333,7 @@ export class SharedSqlRecordLogic { unlisted: boolean, opts: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions = {}, ): Promise { - this.exec.exec('BEGIN'); - try { + this.exec.transaction(() => { if (opts.snapshot) this.snapshotBeforeMutation(id, opts.snapshot); const { clause, params: verParams } = this.versionGuard(opts.expectedVersion); const now = toMs(new Date()); @@ -377,11 +349,7 @@ export class SharedSqlRecordLogic { ], ); if (changed === 0) this.throwVersionConflict(id, opts.expectedVersion); - this.exec.exec('COMMIT'); - } catch (err) { - this.exec.exec('ROLLBACK'); - throw err; - } + }); const updated = await this.getRecord(id); if (!updated) throw new Error(`Record not found after setUnlisted: "${id}"`); @@ -400,8 +368,7 @@ export class SharedSqlRecordLogic { const target = await this.getVersion(id, version); if (!target) throw new Error(`Version not found: ${id}@${version}`); - this.exec.exec('BEGIN'); - try { + this.exec.transaction(() => { if (opts.snapshot) this.snapshotBeforeMutation(id, opts.snapshot); fts5Strategy.remove(this.exec, id); this.exec.run( @@ -421,11 +388,7 @@ export class SharedSqlRecordLogic { } fts5Strategy.insert(this.exec, id, JSON.stringify(target.content)); this.syncFileRefs(id, target.typeId, target.content); - this.exec.exec('COMMIT'); - } catch (err) { - this.exec.exec('ROLLBACK'); - throw err; - } + }); const updated = await this.getRecord(id); if (!updated) throw new Error(`Record not found after restoreVersion: "${id}"`); @@ -446,8 +409,7 @@ export class SharedSqlRecordLogic { if (!existing) throw new Error(`Record not found: "${id}"`); this.checkExpectedVersion(existing, opts.expectedVersion); - this.exec.exec('BEGIN'); - try { + this.exec.transaction(() => { if (opts.snapshot) this.snapshotBeforeMutation(id, opts.snapshot); fts5Strategy.remove(this.exec, id); this.exec.run( @@ -463,11 +425,7 @@ export class SharedSqlRecordLogic { ); fts5Strategy.insert(this.exec, id, JSON.stringify(content)); this.syncFileRefs(id, toTypeId, content); - this.exec.exec('COMMIT'); - } catch (err) { - this.exec.exec('ROLLBACK'); - throw err; - } + }); const updated = await this.getRecord(id); if (!updated) throw new Error(`Record not found after commitMigration: "${id}"`); @@ -515,8 +473,7 @@ export class SharedSqlRecordLogic { fileId: FileId, metadataTypeId: TypeId, ): Promise { - this.exec.exec('BEGIN'); - try { + return this.exec.transaction(() => { const referenced = this.exec.all<{ found: number }>( `SELECT 1 as found FROM associations WHERE kind = 'attachment' AND file_id = ? UNION ALL @@ -538,12 +495,8 @@ export class SharedSqlRecordLogic { if (purged) deleted.push(purged); } - this.exec.exec('COMMIT'); return deleted; - } catch (err) { - this.exec.exec('ROLLBACK'); - throw err; - } + }); } // ------------------------------------------------------- @@ -699,18 +652,13 @@ export class SharedSqlRecordLogic { association: Association, opts: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions = {}, ): Promise { - this.exec.exec('BEGIN'); - try { + this.exec.transaction(() => { if (opts.snapshot) this.snapshotBeforeMutation(recordId, opts.snapshot); // Bump (and CAS-check) first, before the associations-table write, so // a lost race never partially applies. this.bumpVersion(recordId, opts); this.insertAssociations(recordId, [association]); - this.exec.exec('COMMIT'); - } catch (err) { - this.exec.exec('ROLLBACK'); - throw err; - } + }); const updated = await this.getRecord(recordId); if (!updated) throw new Error(`Record not found after associate: "${recordId}"`); @@ -722,8 +670,7 @@ export class SharedSqlRecordLogic { association: Association, opts: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions = {}, ): Promise { - this.exec.exec('BEGIN'); - try { + this.exec.transaction(() => { if (opts.snapshot) this.snapshotBeforeMutation(recordId, opts.snapshot); this.bumpVersion(recordId, opts); this.exec.run( @@ -738,11 +685,7 @@ export class SharedSqlRecordLogic { AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)], ); - this.exec.exec('COMMIT'); - } catch (err) { - this.exec.exec('ROLLBACK'); - throw err; - } + }); const updated = await this.getRecord(recordId); if (!updated) throw new Error(`Record not found after dissociate: "${recordId}"`); diff --git a/packages/sqlite-shared/src/record.ts b/packages/sqlite-shared/src/record.ts new file mode 100644 index 0000000..cb17f86 --- /dev/null +++ b/packages/sqlite-shared/src/record.ts @@ -0,0 +1,37 @@ +/** + * Same surface as index.ts, minus the token-store pieces + * (TOKENS_SCHEMA_SQL, SharedTokenLogic) and the file-lock helpers + * (acquireLock/releaseLock). A record-only SQLite engine — one with no + * separate token file and no lock file, e.g. a Durable Object, where the + * platform's single-writer-per-id model already is the lock — imports + * this instead of the full barrel so its bundle never reaches + * token-logic.ts's `node:crypto` import. That module is a real Node + * built-in outside a `nodejs_compat` Worker, and esbuild can't always + * fully eliminate an unused-but-reachable class export's module-level + * imports the way it does for lock.ts's plain functions — so avoiding the + * import (not just the unused export) has to happen at this file's level. + */ +export { + RECORD_SCHEMA_SQL, + FTS5_SCHEMA_SQL, + PRAGMA_FOREIGN_KEYS_ON, + PRAGMA_JOURNAL_MODE_WAL, +} from './schema.js'; +export { buildWhereClause, buildOrderClause, getSortField, getSortColumn } from './query.js'; +export { + encodeCursor, + decodeCursor, + makeCursor, + SORT_FIELDS, + type SortField, + type DecodedCursor, +} from './cursor.js'; +export { rowToRecord, rowToAssociation, rowToType, rowToVersion, toMs, fromMs } from './mappers.js'; +export { sanitizeFts5Query, fts5Strategy } from './fts5.js'; +export { + type SqlExecutor, + isForeignKeyViolation, + isUniqueConstraintViolation, +} from './executor.js'; +export { insertConfigRecord, readStackConfig, type StackConfig } from './config.js'; +export { SharedSqlRecordLogic, type SharedSqlRecordLogicDeps } from './record-logic.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 048568b..7d94524 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,12 +155,12 @@ importers: '@cloudflare/vitest-pool-workers': specifier: ^0.22.0 version: 0.22.0(@cloudflare/workers-types@5.20260830.1)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0))) - '@cloudflare/workers-types': - specifier: ^5.0.0 - version: 5.20260830.1 '@haverstack/sqlite-shared': specifier: workspace:* version: link:../sqlite-shared + tsup: + specifier: ^8.5.1 + version: 8.5.1(postcss@8.5.13)(typescript@5.9.3)(yaml@2.9.0) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -2421,7 +2421,8 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260828.1': optional: true - '@cloudflare/workers-types@5.20260830.1': {} + '@cloudflare/workers-types@5.20260830.1': + optional: true '@cspotcode/source-map-support@0.8.1': dependencies: diff --git a/scripts/verify-pack.mjs b/scripts/verify-pack.mjs index 9056630..dd72e70 100644 --- a/scripts/verify-pack.mjs +++ b/scripts/verify-pack.mjs @@ -25,7 +25,18 @@ import { fileURLToPath } from 'node:url'; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const packagesDir = join(repoRoot, 'packages'); -/** Entry points to import, and a symbol each must export. */ +/** + * Entry points to import, and a symbol each must export. + * + * @haverstack/record-adapter-do-sqlite is deliberately absent: it's a real + * publishable package (still packed and installed into the throwaway + * consumer below, so a missing `files` entry or an accidentally-unbundled + * @haverstack/sqlite-shared would still be caught), but its entry point + * imports ambient Durable Object globals that only exist inside the + * Workers runtime — `node -e "import(...)"` here would fail on that, not + * on anything wrong with the package. Its own vitest suite runs against + * the real Workers runtime (@cloudflare/vitest-pool-workers) instead. + */ const EXPECTATIONS = { '@haverstack/core': [ ['.', ['Stack', 'ScopedStack', 'StackError', 'SYSTEM_TYPES']], From d66d82175aa86148a808ad0353855606706778ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:29:53 +0000 Subject: [PATCH 3/4] fix(record-adapter-do-sqlite): unbreak CI's supply-chain lockfile policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed on every job (lint/build/test/format) with: [ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 1 lockfile entries failed verification: @cloudflare/workers-types@5.20260830.1 was published at 2026-08-30T01:19:42.000Z, within the minimumReleaseAge cutoff. That version is a transitive dependency of wrangler / @cloudflare/vitest- pool-workers, not something this PR asked for directly, but @cloudflare/ workers-types publishes a new calendar-versioned release roughly every 24h — floating on "latest" for it is structurally incompatible with a minimum-release-age policy, since the newest version is almost never older than the policy's window. Pinned it to 5.20260817.1 (~2 weeks aged) via a root pnpm.overrides entry, applying repo-wide regardless of which dependent requests it. That override reintroduces a real conflict this branch had already worked around once: adding @cloudflare/workers-types back as a direct devDependency (needed for tsup's isolated dts build of src/executor.ts, which uses the ambient DurableObjectStorage/SqlStorage/SqlStorageValue globals with no import and doesn't see wrangler's generated worker-configuration.d.ts) reintroduced the ambient Env/Cloudflare.Env duplicate-declaration conflict between the two type sources for the *test* tsconfig, which only needs wrangler's generated types and never needed workers-types at all. Fixed by scoping workers-types to tsup's dts step alone (dts.compilerOptions.types), rather than the shared tsconfig.json every tsc --noEmit run reads — the build gets the globals it needs, the test typecheck never sees the conflicting second copy. Verified with the same commands CI runs (format:check, build, test, lint, typecheck, and a frozen-lockfile install) across the whole workspace, plus confirmed no other lockfile entry is within the release-age window. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VJoUkCo4dLjXsDsNDkYjbx --- package.json | 5 +++ .../record-adapter-do-sqlite/package.json | 1 + .../record-adapter-do-sqlite/tsup.config.ts | 14 +++++++- pnpm-lock.yaml | 33 +++++++++++-------- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index c1e0e0b..f596c14 100644 --- a/package.json +++ b/package.json @@ -27,5 +27,10 @@ "typescript": "^5.5.0", "typescript-eslint": "^8.59.1", "vitest": "^2.0.0" + }, + "pnpm": { + "overrides": { + "@cloudflare/workers-types": "5.20260817.1" + } } } diff --git a/packages/record-adapter-do-sqlite/package.json b/packages/record-adapter-do-sqlite/package.json index bb940c6..cd5c127 100644 --- a/packages/record-adapter-do-sqlite/package.json +++ b/packages/record-adapter-do-sqlite/package.json @@ -45,6 +45,7 @@ }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.22.0", + "@cloudflare/workers-types": "^5.0.0", "@haverstack/sqlite-shared": "workspace:*", "tsup": "^8.5.1", "typescript": "^5.5.0", diff --git a/packages/record-adapter-do-sqlite/tsup.config.ts b/packages/record-adapter-do-sqlite/tsup.config.ts index b170fce..dbf46dc 100644 --- a/packages/record-adapter-do-sqlite/tsup.config.ts +++ b/packages/record-adapter-do-sqlite/tsup.config.ts @@ -11,12 +11,24 @@ import { defineConfig } from 'tsup'; * target: 'es2022' rather than a Node target — this ships into a Workers * bundle (via wrangler/esbuild in the consuming app), not run standalone * under node. + * + * dts.compilerOptions.types is scoped to *this* isolated dts compilation + * only, not the package's shared tsconfig.json (which drives `tsc + * --noEmit` over src/** and tests/** together). src/executor.ts uses the + * ambient DurableObjectStorage/SqlStorage/SqlStorageValue globals with no + * import, so this build step — which follows the entry's module graph, + * not tsconfig's "include" — needs @cloudflare/workers-types to resolve + * them; the test tsconfig gets the same globals for free from wrangler's + * generated worker-configuration.d.ts, and adding workers-types there too + * conflicts with it over the ambient Env/Cloudflare.Env declaration (the + * exact clash wrangler's own "uninstall @cloudflare/workers-types" + * migration note warns about) without ever being needed. */ export default defineConfig({ entry: ['src/index.ts'], format: ['esm'], target: 'es2022', - dts: true, + dts: { compilerOptions: { types: ['@cloudflare/workers-types'] } }, sourcemap: true, clean: true, splitting: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d94524..7f620cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@cloudflare/workers-types': 5.20260817.1 + importers: .: @@ -154,7 +157,10 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: ^0.22.0 - version: 0.22.0(@cloudflare/workers-types@5.20260830.1)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0))) + version: 0.22.0(@cloudflare/workers-types@5.20260817.1)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0))) + '@cloudflare/workers-types': + specifier: 5.20260817.1 + version: 5.20260817.1 '@haverstack/sqlite-shared': specifier: workspace:* version: link:../sqlite-shared @@ -172,7 +178,7 @@ importers: version: 4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0)) wrangler: specifier: ^4.127.1 - version: 4.127.1(@cloudflare/workers-types@5.20260830.1) + version: 4.127.1(@cloudflare/workers-types@5.20260817.1) packages/record-adapter-sqlite: dependencies: @@ -387,8 +393,8 @@ packages: cpu: [x64] os: [win32] - '@cloudflare/workers-types@5.20260830.1': - resolution: {integrity: sha512-LBn0wg8kmCbdriUYmz6BSm4ukjFinU4iYGYuBTPaSG6malMhbIFk7Az1gcsFuvBYva2yHxUZFirCwjQY7yBN9A==} + '@cloudflare/workers-types@5.20260817.1': + resolution: {integrity: sha512-5Dv+cyjusBTPLMRedUCiLJu3zqeeupgyn1QcHmpHJV9k/TjQAxx79AO8RVtpjVaO2CB+Oh3ywAp1UuNpbyDjGQ==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -2190,7 +2196,7 @@ packages: engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^5.20260815.1 + '@cloudflare/workers-types': 5.20260817.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -2200,7 +2206,7 @@ packages: engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^5.20260828.1 + '@cloudflare/workers-types': 5.20260817.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -2376,7 +2382,7 @@ snapshots: optionalDependencies: workerd: 1.20260828.1 - '@cloudflare/vitest-pool-workers@0.22.0(@cloudflare/workers-types@5.20260830.1)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0)))': + '@cloudflare/vitest-pool-workers@0.22.0(@cloudflare/workers-types@5.20260817.1)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0)))': dependencies: '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -2384,7 +2390,7 @@ snapshots: esbuild: 0.28.1 miniflare: 5.20260815.0-alpha vitest: 4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0)) - wrangler: 4.124.0(@cloudflare/workers-types@5.20260830.1) + wrangler: 4.124.0(@cloudflare/workers-types@5.20260817.1) zod: 4.4.3 transitivePeerDependencies: - '@cloudflare/workers-types' @@ -2421,8 +2427,7 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260828.1': optional: true - '@cloudflare/workers-types@5.20260830.1': - optional: true + '@cloudflare/workers-types@5.20260817.1': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -3933,7 +3938,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260828.1 '@cloudflare/workerd-windows-64': 1.20260828.1 - wrangler@4.124.0(@cloudflare/workers-types@5.20260830.1): + wrangler@4.124.0(@cloudflare/workers-types@5.20260817.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260815.1) @@ -3944,13 +3949,13 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260815.1 optionalDependencies: - '@cloudflare/workers-types': 5.20260830.1 + '@cloudflare/workers-types': 5.20260817.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil - utf-8-validate - wrangler@4.127.1(@cloudflare/workers-types@5.20260830.1): + wrangler@4.127.1(@cloudflare/workers-types@5.20260817.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260828.1) @@ -3961,7 +3966,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260828.1 optionalDependencies: - '@cloudflare/workers-types': 5.20260830.1 + '@cloudflare/workers-types': 5.20260817.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil From 508e177c3be14db94cf34cc37b886f05c58ce778 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:38:33 +0000 Subject: [PATCH 4/4] fix: move pnpm config from package.json/.npmrc to pnpm-workspace.yaml for pnpm 11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second round of the same CI failure — this repo's workflow pins pnpm/action-setup to major version 11, but my local pnpm was 10.33, which silently accepted config locations that v11 no longer reads: - package.json's "pnpm.overrides" field: v11 logs a warning and ignores it entirely, so the @cloudflare/workers-types pin from the previous fix never took effect in CI — [ERR_PNPM_LOCKFILE_CONFIG_MISMATCH], since the lockfile itself did carry the pinned resolution but the running config didn't request it. - .npmrc's hoist-pattern: v11 doesn't read it either (no warning, just silently not applied) — confirmed via `pnpm config get hoist-pattern` returning undefined despite the file being present. @types/chai was back in the shared hoist folder, reproducing the duplicate-identifier typecheck failure across every package that the previous branch state had (apparently only coincidentally) stopped showing. Both now live in pnpm-workspace.yaml, which v11 does read for both keys — verified with `pnpm config get hoist-pattern`/`get overrides` actually resolving, and a full clean install + frozen-lockfile install + build + lint + test + typecheck pass, all run with pnpm@11.24.0 specifically (matching what pnpm/action-setup resolves version: 11 to right now) rather than relying on local pnpm 10, which had masked both of these. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VJoUkCo4dLjXsDsNDkYjbx --- .npmrc | 9 --------- package.json | 5 ----- pnpm-workspace.yaml | 19 +++++++++++++++++++ 3 files changed, 19 insertions(+), 14 deletions(-) delete mode 100644 .npmrc diff --git a/.npmrc b/.npmrc deleted file mode 100644 index bf97873..0000000 --- a/.npmrc +++ /dev/null @@ -1,9 +0,0 @@ -# @types/chai@5.2.3 (pulled in only by record-adapter-do-sqlite's vitest 4 / -# @cloudflare/vitest-pool-workers) collides with the chai types vendored -# inside @vitest/expect@2.1.9 (used by the rest of the repo on vitest 2) once -# both land in pnpm's shared @types hoist folder — "Duplicate identifier" -# across every package's typecheck, not just the new one. Keeping it -# unhoisted confines it to record-adapter-do-sqlite's own resolution chain, -# where it's actually needed. -hoist-pattern[]=* -hoist-pattern[]=!@types/chai diff --git a/package.json b/package.json index f596c14..c1e0e0b 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,5 @@ "typescript": "^5.5.0", "typescript-eslint": "^8.59.1", "vitest": "^2.0.0" - }, - "pnpm": { - "overrides": { - "@cloudflare/workers-types": "5.20260817.1" - } } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c9b5a90..eea9d6f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,22 @@ packages: allowBuilds: esbuild: true workerd: true +# @cloudflare/workers-types publishes a new calendar-versioned release +# roughly every 24h, so resolving to "latest" almost always lands inside a +# minimumReleaseAge supply-chain policy window. Pinned to a safely-aged +# version — applies repo-wide since it's only a transitive dependency of +# wrangler / @cloudflare/vitest-pool-workers, nothing here depends on it +# directly for anything version-sensitive. +overrides: + '@cloudflare/workers-types': 5.20260817.1 +# @types/chai@5.2.3 (pulled in only by record-adapter-do-sqlite's vitest 4 / +# @cloudflare/vitest-pool-workers) collides with the chai types vendored +# inside @vitest/expect@2.1.9 (used by the rest of the repo on vitest 2) once +# both land in pnpm's shared @types hoist folder — "Duplicate identifier" +# across every package's typecheck, not just the new one. Keeping it +# unhoisted confines it to record-adapter-do-sqlite's own resolution chain, +# where it's actually needed. (Must live here, not .npmrc — pnpm 11 silently +# stops reading hoist-pattern from .npmrc.) +hoistPattern: + - '*' + - '!@types/chai'