From 421728853ff012a8cc55fe4d5b51c6757c94b139 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 31 Aug 2026 13:23:31 +0900 Subject: [PATCH 1/3] test(offline): exercise repository against real SQLite --- .github/workflows/lint.yml | 2 + package.json | 2 + tests/sqlite-offline-repository.node.spec.ts | 167 +++++++++++++++++++ vitest.sqlite.config.ts | 14 ++ 4 files changed, 185 insertions(+) create mode 100644 tests/sqlite-offline-repository.node.spec.ts create mode 100644 vitest.sqlite.config.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 20df5a7..735cd96 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,6 +32,8 @@ jobs: ${{ runner.os }}-angular- - run: npm ci - run: npm run lint + - run: npm run lint:sqlite + - run: npm run test:sqlite - run: npm run build - run: npm run test:package-consumer - run: npm run test:offline-types:nonstrict diff --git a/package.json b/package.json index 4c11d9c..e590170 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,11 @@ "prestart": "npm run prebuild", "release": "np --no-tests --no-publish", "lint": "ng lint", + "lint:sqlite": "eslint vitest.sqlite.config.ts \"tests/**/*.ts\"", "test:watch": "ng test", "test:actions": "node --test .github/actions/classify-mobile-release/classify-mobile-release.spec.mjs && vitest run --config .github/actions/vitest.config.mjs", "test:offline-types:nonstrict": "npm run prebuild:kit && tsc -p projects/kit/offline/tsconfig.nonstrict-null.json", + "test:sqlite": "npm run test:sqlite-concurrency && vitest run --config vitest.sqlite.config.ts", "test:sqlite-concurrency": "node --test projects/kit/offline/src/lib/sqlite-concurrency.node.test.mjs", "test:package-consumer": "node scripts/test-package-consumer.mjs", "e2e": "playwright test", diff --git a/tests/sqlite-offline-repository.node.spec.ts b/tests/sqlite-offline-repository.node.spec.ts new file mode 100644 index 0000000..70acb5b --- /dev/null +++ b/tests/sqlite-offline-repository.node.spec.ts @@ -0,0 +1,167 @@ +import '@angular/compiler'; +import { createEnvironmentInjector, Injector, type EnvironmentInjector } from '@angular/core'; +import { DatabaseSync, type SQLInputValue } from 'node:sqlite'; +import { afterEach, describe, expect, it } from 'vitest'; +import { OFFLINE_KIT_OPTIONS } from '../projects/kit/offline/src/lib/offline-kit-options'; +import { defineOfflineReplicaSchema, defineReplicaEntity, generatedId, text } from '../projects/kit/offline/src/lib/offline-replica-schema'; +import type { OfflineCommand, OfflineReplicaRow } from '../projects/kit/offline/src/lib/offline-repository'; +import { + COMMUNITY_SQLITE, + type CommunitySqliteDriver, + SqliteOfflineRepository, +} from '../projects/kit/offline/src/lib/sqlite-offline-repository'; + +const itemEntity = defineReplicaEntity<{ id: number; title: string }>()({ + table: 'contract_items', + sourceKey: 'contract_items', + scope: 'partition', + fields: { id: generatedId('integer'), title: text() }, +}); + +const replicaSchema = defineOfflineReplicaSchema({ version: 1, entities: [itemEntity], migrations: [] }); + +/** Test-only adapter that runs production repository SQL against Node's SQLite engine. */ +class NodeSqliteDriver implements CommunitySqliteDriver { + readonly #databases = new Map(); + + async open(options: { databaseName: string }): Promise<{ databaseId: string }> { + if (!this.#databases.has(options.databaseName)) { + this.#databases.set(options.databaseName, new DatabaseSync(':memory:')); + } + return { databaseId: options.databaseName }; + } + + async executeBatch(options: { databaseId: string; statements: readonly string[] }): Promise { + this.#database(options.databaseId).exec(options.statements.join(';\n')); + } + + async execute(options: { databaseId: string; statement: string; values?: (string | number | null)[] }): Promise { + this.#database(options.databaseId) + .prepare(options.statement) + .run(...this.#values(options.values)); + } + + async query(options: { databaseId: string; statement: string; values?: (string | number | null)[] }): Promise<{ rows: unknown[] }> { + const rows = this.#database(options.databaseId) + .prepare(options.statement) + .all(...this.#values(options.values)); + return { rows }; + } + + async beginTransaction(options: { databaseId: string }): Promise { + this.#database(options.databaseId).exec('BEGIN IMMEDIATE'); + } + + async commitTransaction(options: { databaseId: string }): Promise { + this.#database(options.databaseId).exec('COMMIT'); + } + + async rollbackTransaction(options: { databaseId: string }): Promise { + this.#database(options.databaseId).exec('ROLLBACK'); + } + + close(): void { + for (const database of this.#databases.values()) database.close(); + this.#databases.clear(); + } + + #database(databaseId: string): DatabaseSync { + const database = this.#databases.get(databaseId); + if (!database) throw new Error(`SQLite database "${databaseId}" is not open.`); + return database; + } + + #values(values: (string | number | null)[] | undefined): SQLInputValue[] { + return values ?? []; + } +} + +describe('SqliteOfflineRepository real SQLite contract', () => { + let driver: NodeSqliteDriver | null = null; + let injector: EnvironmentInjector | null = null; + + afterEach(() => { + injector?.destroy(); + injector = null; + driver?.close(); + driver = null; + }); + + function createRepository(): SqliteOfflineRepository { + driver = new NodeSqliteDriver(); + injector = createEnvironmentInjector( + [ + SqliteOfflineRepository, + { provide: COMMUNITY_SQLITE, useValue: driver }, + { + provide: OFFLINE_KIT_OPTIONS, + useValue: { databaseName: 'real-sqlite-contract', databaseEncryption: false, replicaSchema }, + }, + ], + Injector.NULL as EnvironmentInjector, + ); + return injector.get(SqliteOfflineRepository); + } + + it('creates real tables and atomically round-trips a row, cursor, and Outbox command', async () => { + const repository = createRepository(); + await repository.initialize(); + const scope = { userId: 7 as const, scopeId: 'demo' }; + const row: OfflineReplicaRow = { + ...scope, + sourceKey: 'contract_items', + identity: { kind: 'generated', localId: 'local-1', remoteId: 101 }, + values: { id: 101, title: 'Demo' }, + confirmedValues: { id: 101, title: 'Demo' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }; + const command: OfflineCommand = { + ...scope, + commandId: 'command-1', + aggregateType: 'contract_items', + sourceKey: 'contract_items', + identity: { kind: 'generated', localId: 'local-1' }, + operation: 'contract_items.update', + payload: { title: 'Changed' }, + baseRevision: 1, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }; + + await repository.transactReplica({ + putRows: [row], + putCursors: [{ ...scope, cursor: 'cursor-1' }], + putCommands: [command], + }); + + await expect(repository.getReplicaRow(scope, 'contract_items', command.identity)).resolves.toMatchObject({ + ...row, + values: { title: 'Demo' }, + confirmedValues: { title: 'Demo' }, + }); + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-1' }); + await expect(repository.getCommands(scope)).resolves.toMatchObject([command]); + }); + + it('rolls back earlier writes when a real SQLite constraint rejects the transaction', async () => { + const repository = createRepository(); + await repository.initialize(); + const scope = { userId: 7 as const, scopeId: 'demo' }; + + await expect( + repository.transactReplica({ + putCursors: [ + { ...scope, cursor: 'first' }, + { ...scope, cursor: null as unknown as string }, + ], + }), + ).rejects.toThrow(); + + await expect(repository.getReplicaCursor(scope)).resolves.toBeNull(); + }); +}); diff --git a/vitest.sqlite.config.ts b/vitest.sqlite.config.ts new file mode 100644 index 0000000..33d8d8f --- /dev/null +++ b/vitest.sqlite.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + '@rdlabo/ionic-angular-kit': fileURLToPath(new URL('./projects/kit/src/public-api.ts', import.meta.url)), + }, + }, + test: { + environment: 'jsdom', + include: ['tests/sqlite-offline-repository.node.spec.ts'], + }, +}); From 5f7ffd7d51429f653c45be7a08ccc07214b7a7b5 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 31 Aug 2026 13:35:47 +0900 Subject: [PATCH 2/3] ci: run SQLite checks from postlint --- .github/workflows/lint.yml | 2 -- package.json | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 735cd96..20df5a7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,8 +32,6 @@ jobs: ${{ runner.os }}-angular- - run: npm ci - run: npm run lint - - run: npm run lint:sqlite - - run: npm run test:sqlite - run: npm run build - run: npm run test:package-consumer - run: npm run test:offline-types:nonstrict diff --git a/package.json b/package.json index e590170..bd1632b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "prestart": "npm run prebuild", "release": "np --no-tests --no-publish", "lint": "ng lint", + "postlint": "npm run lint:sqlite && npm run test:sqlite", "lint:sqlite": "eslint vitest.sqlite.config.ts \"tests/**/*.ts\"", "test:watch": "ng test", "test:actions": "node --test .github/actions/classify-mobile-release/classify-mobile-release.spec.mjs && vitest run --config .github/actions/vitest.config.mjs", From 262ceac8dbef5216c23a6232f8481b8660edc77f Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 31 Aug 2026 13:54:15 +0900 Subject: [PATCH 3/3] test(offline): cover Outbox with real SQLite --- tests/sqlite-offline-repository.node.spec.ts | 187 +++++++++++++++++-- 1 file changed, 172 insertions(+), 15 deletions(-) diff --git a/tests/sqlite-offline-repository.node.spec.ts b/tests/sqlite-offline-repository.node.spec.ts index 70acb5b..6c98ff1 100644 --- a/tests/sqlite-offline-repository.node.spec.ts +++ b/tests/sqlite-offline-repository.node.spec.ts @@ -103,6 +103,26 @@ describe('SqliteOfflineRepository real SQLite contract', () => { return injector.get(SqliteOfflineRepository); } + function outboxCommand(overrides: Partial = {}): OfflineCommand { + return { + userId: 7, + scopeId: 'demo', + commandId: 'command-1', + aggregateType: 'contract_items', + sourceKey: 'contract_items', + identity: { kind: 'generated', localId: 'local-1' }, + operation: 'contract_items.update', + payload: { title: 'Changed' }, + baseRevision: 1, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + ...overrides, + }; + } + it('creates real tables and atomically round-trips a row, cursor, and Outbox command', async () => { const repository = createRepository(); await repository.initialize(); @@ -117,21 +137,7 @@ describe('SqliteOfflineRepository real SQLite contract', () => { fetchedAt: 1, syncState: 'confirmed', }; - const command: OfflineCommand = { - ...scope, - commandId: 'command-1', - aggregateType: 'contract_items', - sourceKey: 'contract_items', - identity: { kind: 'generated', localId: 'local-1' }, - operation: 'contract_items.update', - payload: { title: 'Changed' }, - baseRevision: 1, - state: 'pending', - attempts: 0, - retryAt: null, - createdAt: 1, - lastErrorCode: null, - }; + const command = outboxCommand(); await repository.transactReplica({ putRows: [row], @@ -164,4 +170,155 @@ describe('SqliteOfflineRepository real SQLite contract', () => { await expect(repository.getReplicaCursor(scope)).resolves.toBeNull(); }); + + it('round-trips every durable Outbox field and keeps ordering and user/scope isolation', async () => { + const repository = createRepository(); + await repository.initialize(); + const scope = { userId: 7 as const, scopeId: 'demo' }; + const footprint = [{ ...scope, sourceKey: 'contract_items', identity: { kind: 'generated' as const, localId: 'local-1' } }]; + const richCommand = outboxCommand({ + commandId: 'command-z', + operation: 'contract_items.delete', + payload: { title: 'Changed', nested: { enabled: true }, values: [1, null, 'x'] }, + legacyOptimisticValue: { title: 'Legacy' }, + legacyOptimisticCompanions: [{ id: 1 }], + legacyPayloadHash: 'legacy-hash', + localOnlyFootprint: footprint, + replicaMutation: 'delete', + baseRevision: 'revision-1', + state: 'retry_wait', + attempts: 3, + retryAt: 500, + createdAt: 20, + lastErrorCode: 'network', + serverCommitUnknown: true, + reconciliationIdentity: { remoteId: 101 }, + }); + const earlier: OfflineCommand = { + ...richCommand, + commandId: 'command-a', + createdAt: 10, + payload: { title: 'Earlier' }, + }; + const otherScope = { ...richCommand, commandId: 'command-m', scopeId: 'other', createdAt: 20 }; + const otherUser = { ...richCommand, commandId: 'command-user-8', userId: 8, createdAt: 1 }; + + await repository.putCommand(richCommand); + await repository.putCommand(earlier); + await repository.putCommand(otherScope); + await repository.putCommand(otherUser); + + await expect(repository.getCommands(scope)).resolves.toEqual([earlier, richCommand]); + await expect(repository.getCommandsForUser(7)).resolves.toEqual([earlier, otherScope, richCommand]); + await expect(repository.getCommandsForUser(8)).resolves.toEqual([otherUser]); + }); + + it('replaces an Outbox state without retaining optional values, then removes it', async () => { + const repository = createRepository(); + await repository.initialize(); + const scope = { userId: 7 as const, scopeId: 'demo' }; + const command = outboxCommand({ + legacyOptimisticValue: { title: 'Legacy' }, + legacyOptimisticCompanions: [{ id: 1 }], + legacyPayloadHash: 'legacy-hash', + localOnlyFootprint: [{ ...scope, sourceKey: 'contract_items', identity: { kind: 'generated', localId: 'local-1' } }], + replicaMutation: 'delete', + baseRevision: 1, + state: 'sending', + attempts: 1, + retryAt: 100, + createdAt: 1, + lastErrorCode: 'network', + serverCommitUnknown: true, + reconciliationIdentity: { remoteId: 101 }, + }); + await repository.putCommand(command); + + const replacement = outboxCommand({ + commandId: command.commandId, + payload: { title: 'Retry' }, + baseRevision: null, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: command.createdAt, + lastErrorCode: null, + }); + await repository.replaceCommand(replacement); + + await expect(repository.getCommands(scope)).resolves.toEqual([ + { ...replacement, replicaMutation: 'upsert', serverCommitUnknown: false }, + ]); + await repository.removeCommand(command.commandId); + await expect(repository.getCommands(scope)).resolves.toEqual([]); + }); + + it('atomically rolls back an Outbox enqueue when a later real SQLite write fails', async () => { + const repository = createRepository(); + await repository.initialize(); + const scope = { userId: 7 as const, scopeId: 'demo' }; + const command = outboxCommand({ + commandId: 'command-rollback', + }); + + await expect( + repository.transactReplica({ + putCommands: [command], + putCursors: [{ ...scope, cursor: null as unknown as string }], + }), + ).rejects.toThrow(); + + await expect(repository.getCommands(scope)).resolves.toEqual([]); + }); + + it('does not lose or reorder rapid concurrent Outbox enqueues', async () => { + const repository = createRepository(); + await repository.initialize(); + const scope = { userId: 7 as const, scopeId: 'demo' }; + const commands = Array.from({ length: 32 }, (_, index) => + outboxCommand({ + commandId: `command-${String(31 - index).padStart(2, '0')}`, + identity: { kind: 'generated', localId: `local-${index}` }, + operation: index % 2 === 0 ? 'contract_items.consume' : 'contract_items.add', + payload: { quantity: index + 1 }, + baseRevision: index, + }), + ); + + await Promise.all(commands.map((command) => repository.putCommand(command))); + + const stored = await repository.getCommands(scope); + expect(stored).toHaveLength(commands.length); + expect(stored.map(({ commandId }) => commandId)).toEqual( + commands.map(({ commandId }) => commandId).sort((left, right) => left.localeCompare(right)), + ); + expect(new Set(stored.map(({ identity }) => JSON.stringify(identity))).size).toBe(commands.length); + }); + + it('clears only the requested Outbox scope and user', async () => { + const repository = createRepository(); + await repository.initialize(); + const base = outboxCommand({ + commandId: 'demo-command', + }); + const otherScope = { ...base, scopeId: 'other', commandId: 'other-command' }; + const otherUser = { ...base, userId: 8, commandId: 'other-user-command' }; + await repository.putCommand(base); + await repository.putCommand(otherScope); + await repository.putCommand(otherUser); + + await repository.clearScope({ userId: 7, scopeId: 'demo' }); + await expect(repository.getCommandsForUser(7).then((commands) => commands.map(({ commandId }) => commandId))).resolves.toEqual([ + otherScope.commandId, + ]); + await expect(repository.getCommandsForUser(8).then((commands) => commands.map(({ commandId }) => commandId))).resolves.toEqual([ + otherUser.commandId, + ]); + + await repository.clearUser(7); + await expect(repository.getCommandsForUser(7)).resolves.toEqual([]); + await expect(repository.getCommandsForUser(8).then((commands) => commands.map(({ commandId }) => commandId))).resolves.toEqual([ + otherUser.commandId, + ]); + }); });