diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b2aa42d79..3b26c63a0 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -62,6 +62,16 @@ jobs: env: ELECTRIC_URL: http://localhost:3000 + - name: Run Node SQLite persisted collection E2E tests + run: | + cd packages/db-node-sqlite-persisted-collection + pnpm test:e2e + + - name: Run Electron SQLite persisted collection E2E tests (full bridge) + run: | + cd packages/db-electron-sqlite-persisted-collection + TANSTACK_DB_ELECTRON_E2E_ALL=1 pnpm test:e2e + - name: Run React Native/Expo persisted collection E2E tests run: | cd packages/db-react-native-sqlite-persisted-collection diff --git a/packages/db-electron-sqlite-persisted-collection/README.md b/packages/db-electron-sqlite-persisted-collection/README.md new file mode 100644 index 000000000..d439d36ec --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/README.md @@ -0,0 +1,71 @@ +# @tanstack/db-electron-sqlite-persisted-collection + +Thin Electron bridge for TanStack DB SQLite persistence. + +## Public API + +- `exposeElectronSQLitePersistence(...)` (main process) +- `createElectronSQLitePersistence(...)` (renderer process) +- `persistedCollectionOptions(...)` (re-exported from core) + +Use `@tanstack/db-electron-sqlite-persisted-collection/main` and +`@tanstack/db-electron-sqlite-persisted-collection/renderer` if you prefer +explicit process-specific entrypoints. + +## Main process + +```ts +import { ipcMain } from 'electron' +import { createNodeSQLitePersistence } from '@tanstack/db-node-sqlite-persisted-collection' +import { exposeElectronSQLitePersistence } from '@tanstack/db-electron-sqlite-persisted-collection/main' +import Database from 'better-sqlite3' + +const database = new Database(`./tanstack-db.sqlite`) + +const persistence = createNodeSQLitePersistence({ + database, +}) + +const dispose = exposeElectronSQLitePersistence({ + ipcMain, + persistence, +}) + +// Call dispose() and database.close() during shutdown. +``` + +## Renderer process + +```ts +import { createCollection } from '@tanstack/db' +import { ipcRenderer } from 'electron' +import { + createElectronSQLitePersistence, + persistedCollectionOptions, +} from '@tanstack/db-electron-sqlite-persisted-collection' + +type Todo = { + id: string + title: string + completed: boolean +} + +const persistence = createElectronSQLitePersistence({ + ipcRenderer, +}) + +export const todosCollection = createCollection( + persistedCollectionOptions({ + id: `todos`, + getKey: (todo) => todo.id, + persistence, + schemaVersion: 1, // Per-collection schema version + }), +) +``` + +## Notes + +- The renderer API mirrors other runtimes: one shared `create...Persistence`. +- Collection mode (`sync-present` vs `sync-absent`) and `schemaVersion` are + resolved per collection and forwarded across IPC automatically. diff --git a/packages/db-electron-sqlite-persisted-collection/package.json b/packages/db-electron-sqlite-persisted-collection/package.json new file mode 100644 index 000000000..3e39b25a2 --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/package.json @@ -0,0 +1,80 @@ +{ + "name": "@tanstack/db-electron-sqlite-persisted-collection", + "version": "0.1.0", + "description": "Electron SQLite persisted collection bridge for TanStack DB", + "author": "TanStack Team", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/db.git", + "directory": "packages/db-electron-sqlite-persisted-collection" + }, + "homepage": "https://tanstack.com/db", + "keywords": [ + "sqlite", + "electron", + "ipc", + "persistence", + "typescript" + ], + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "eslint . --fix", + "test": "vitest --run", + "test:e2e": "pnpm --filter @tanstack/db-ivm build && pnpm --filter @tanstack/db build && pnpm --filter @tanstack/db-sqlite-persisted-collection-core build && pnpm --filter @tanstack/db-node-sqlite-persisted-collection build && pnpm --filter @tanstack/db-electron-sqlite-persisted-collection build && vitest --config vitest.e2e.config.ts --run", + "test:e2e:all": "pnpm --filter @tanstack/db-ivm build && pnpm --filter @tanstack/db build && pnpm --filter @tanstack/db-sqlite-persisted-collection-core build && pnpm --filter @tanstack/db-node-sqlite-persisted-collection build && pnpm --filter @tanstack/db-electron-sqlite-persisted-collection build && TANSTACK_DB_ELECTRON_E2E_ALL=1 vitest --run && TANSTACK_DB_ELECTRON_E2E_ALL=1 vitest --config vitest.e2e.config.ts --run" + }, + "type": "module", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./main": { + "import": { + "types": "./dist/esm/main.d.ts", + "default": "./dist/esm/main.js" + }, + "require": { + "types": "./dist/cjs/main.d.cts", + "default": "./dist/cjs/main.cjs" + } + }, + "./renderer": { + "import": { + "types": "./dist/esm/renderer.d.ts", + "default": "./dist/esm/renderer.js" + }, + "require": { + "types": "./dist/cjs/renderer.d.cts", + "default": "./dist/cjs/renderer.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@tanstack/db-sqlite-persisted-collection-core": "workspace:*" + }, + "peerDependencies": { + "typescript": ">=4.7" + }, + "devDependencies": { + "@vitest/coverage-istanbul": "^3.2.4", + "electron": "^40.2.1" + } +} diff --git a/packages/db-electron-sqlite-persisted-collection/src/electron-coordinator.ts b/packages/db-electron-sqlite-persisted-collection/src/electron-coordinator.ts new file mode 100644 index 000000000..b8e93f3bb --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/src/electron-coordinator.ts @@ -0,0 +1,842 @@ +import type { + ApplyLocalMutationsResponse, + PersistedCollectionCoordinator, + PersistedIndexSpec, + PersistedMutationEnvelope, + PersistenceAdapter, + ProtocolEnvelope, + PullSinceResponse, +} from '@tanstack/db-sqlite-persisted-collection-core' +import type { LoadSubsetOptions } from '@tanstack/db' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const HEARTBEAT_INTERVAL_MS = 3_000 +const RPC_TIMEOUT_MS = 10_000 +const RPC_RETRY_ATTEMPTS = 2 +const RPC_RETRY_DELAY_MS = 200 +const WRITER_LOCK_BUSY_RETRY_MS = 50 +const WRITER_LOCK_MAX_RETRIES = 20 + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +type RPCRequest = + | { + type: `rpc:ensureRemoteSubset:req` + rpcId: string + options: LoadSubsetOptions + } + | { + type: `rpc:ensurePersistedIndex:req` + rpcId: string + signature: string + spec: PersistedIndexSpec + } + | { + type: `rpc:applyLocalMutations:req` + rpcId: string + envelopeId: string + mutations: Array + } + | { + type: `rpc:pullSince:req` + rpcId: string + fromRowVersion: number + } + +type RPCResponse = + | { + type: `rpc:ensureRemoteSubset:res` + rpcId: string + ok: boolean + error?: string + } + | { + type: `rpc:ensurePersistedIndex:res` + rpcId: string + ok: boolean + error?: string + } + | ApplyLocalMutationsResponse + | PullSinceResponse + +type PendingRPC = { + resolve: (response: RPCResponse) => void + reject: (error: Error) => void + timer: ReturnType +} + +type CollectionState = { + isLeader: boolean + lockAbortController: AbortController | null + heartbeatTimer: ReturnType | null + latestTerm: number + latestSeq: number + latestRowVersion: number + subscribers: Set<(message: ProtocolEnvelope) => void> +} + +// Adapter with pullSince support +type AdapterWithPullSince = PersistenceAdapter< + Record, + string | number +> & { + pullSince?: ( + collectionId: string, + fromRowVersion: number, + ) => Promise< + | { + latestRowVersion: number + requiresFullReload: true + } + | { + latestRowVersion: number + requiresFullReload: false + changedKeys: Array + deletedKeys: Array + } + > + getStreamPosition?: (collectionId: string) => Promise<{ + latestTerm: number + latestSeq: number + latestRowVersion: number + }> +} + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export type ElectronCollectionCoordinatorOptions = { + dbName: string + adapter?: AdapterWithPullSince +} + +// --------------------------------------------------------------------------- +// ElectronCollectionCoordinator +// --------------------------------------------------------------------------- + +export class ElectronCollectionCoordinator implements PersistedCollectionCoordinator { + private readonly nodeId = crypto.randomUUID() + private readonly dbName: string + private adapter: AdapterWithPullSince | null + private readonly channel: BroadcastChannel + private readonly collections = new Map() + private readonly pendingRPCs = new Map() + private readonly appliedEnvelopeIds = new Map() + private disposed = false + + /** Method indirection to prevent TypeScript from narrowing `disposed` across awaits */ + private isDisposed(): boolean { + return this.disposed + } + + private requireAdapter(): AdapterWithPullSince { + if (!this.adapter) { + throw new Error( + `ElectronCollectionCoordinator: adapter not set. Call setAdapter() before using leader-side operations.`, + ) + } + return this.adapter + } + + constructor(options: ElectronCollectionCoordinatorOptions) { + this.dbName = options.dbName + this.adapter = options.adapter ?? null + this.channel = new BroadcastChannel(`tsdb:coord:${this.dbName}`) + this.channel.onmessage = (event: MessageEvent) => { + this.onChannelMessage(event.data) + } + } + + /** + * Set or replace the persistence adapter used for leader-side RPC handling. + * Called by `createElectronSQLitePersistence` to wire the internally-created + * adapter into the coordinator. + */ + setAdapter(adapter: AdapterWithPullSince): void { + this.adapter = adapter + } + + // ----------------------------------------------------------------------- + // PersistedCollectionCoordinator interface + // ----------------------------------------------------------------------- + + getNodeId(): string { + return this.nodeId + } + + subscribe( + collectionId: string, + onMessage: (message: ProtocolEnvelope) => void, + ): () => void { + const state = this.ensureCollectionState(collectionId) + state.subscribers.add(onMessage) + return () => { + state.subscribers.delete(onMessage) + } + } + + publish(_collectionId: string, message: ProtocolEnvelope): void { + this.channel.postMessage(message) + } + + isLeader(collectionId: string): boolean { + return this.collections.get(collectionId)?.isLeader ?? false + } + + async ensureLeadership(collectionId: string): Promise { + const state = this.ensureCollectionState(collectionId) + if (state.isLeader) return + await this.acquireLeadership(collectionId, state) + } + + async requestEnsureRemoteSubset( + collectionId: string, + options: LoadSubsetOptions, + ): Promise { + if (this.isLeader(collectionId)) return + + const response = await this.sendRPC<{ + type: `rpc:ensureRemoteSubset:res` + rpcId: string + ok: boolean + error?: string + }>(collectionId, { + type: `rpc:ensureRemoteSubset:req`, + rpcId: crypto.randomUUID(), + options, + }) + + if (!response.ok) { + throw new Error( + `ensureRemoteSubset failed: ${response.error ?? `unknown error`}`, + ) + } + } + + async requestEnsurePersistedIndex( + collectionId: string, + signature: string, + spec: PersistedIndexSpec, + ): Promise { + if (this.isLeader(collectionId)) { + await this.requireAdapter().ensureIndex(collectionId, signature, spec) + return + } + + const response = await this.sendRPC<{ + type: `rpc:ensurePersistedIndex:res` + rpcId: string + ok: boolean + error?: string + }>(collectionId, { + type: `rpc:ensurePersistedIndex:req`, + rpcId: crypto.randomUUID(), + signature, + spec, + }) + + if (!response.ok) { + throw new Error( + `ensurePersistedIndex failed: ${response.error ?? `unknown error`}`, + ) + } + } + + async requestApplyLocalMutations( + collectionId: string, + mutations: Array, + ): Promise { + if (this.isLeader(collectionId)) { + return this.handleApplyLocalMutations(collectionId, { + type: `rpc:applyLocalMutations:req`, + rpcId: crypto.randomUUID(), + envelopeId: crypto.randomUUID(), + mutations, + }) + } + + return this.sendRPC(collectionId, { + type: `rpc:applyLocalMutations:req`, + rpcId: crypto.randomUUID(), + envelopeId: crypto.randomUUID(), + mutations, + }) + } + + async pullSince( + collectionId: string, + fromRowVersion: number, + ): Promise { + if (this.isLeader(collectionId)) { + return this.handlePullSince(collectionId, { + type: `rpc:pullSince:req`, + rpcId: crypto.randomUUID(), + fromRowVersion, + }) + } + + return this.sendRPC(collectionId, { + type: `rpc:pullSince:req`, + rpcId: crypto.randomUUID(), + fromRowVersion, + }) + } + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + dispose(): void { + this.disposed = true + + for (const [collectionId, state] of this.collections) { + this.releaseLeadership(collectionId, state) + } + + for (const [, pending] of this.pendingRPCs) { + clearTimeout(pending.timer) + pending.reject(new Error(`coordinator disposed`)) + } + this.pendingRPCs.clear() + + this.channel.close() + this.collections.clear() + } + + // ----------------------------------------------------------------------- + // Leadership via Web Locks + // ----------------------------------------------------------------------- + + private ensureCollectionState(collectionId: string): CollectionState { + let state = this.collections.get(collectionId) + if (!state) { + state = { + isLeader: false, + lockAbortController: null, + heartbeatTimer: null, + latestTerm: 0, + latestSeq: 0, + latestRowVersion: 0, + subscribers: new Set(), + } + this.collections.set(collectionId, state) + void this.acquireLeadership(collectionId, state) + } + return state + } + + private async acquireLeadership( + collectionId: string, + state: CollectionState, + ): Promise { + if (this.disposed || state.isLeader) return + + const lockName = `tsdb:leader:${this.dbName}:${collectionId}` + const abortController = new AbortController() + state.lockAbortController = abortController + + try { + await navigator.locks.request( + lockName, + { signal: abortController.signal }, + async () => { + if (this.isDisposed()) return + + try { + // Restore stream position from DB before claiming leadership + const adapter = this.requireAdapter() + if (adapter.getStreamPosition) { + const pos = await adapter.getStreamPosition(collectionId) + state.latestTerm = pos.latestTerm + state.latestSeq = pos.latestSeq + state.latestRowVersion = pos.latestRowVersion + } + + state.latestTerm++ + state.isLeader = true + + this.emitHeartbeat(collectionId, state) + state.heartbeatTimer = setInterval(() => { + this.emitHeartbeat(collectionId, state) + }, HEARTBEAT_INTERVAL_MS) + + // Hold the lock until disposed or aborted + await new Promise((resolve) => { + const onAbort = () => { + abortController.signal.removeEventListener(`abort`, onAbort) + resolve() + } + if (abortController.signal.aborted) { + resolve() + return + } + abortController.signal.addEventListener(`abort`, onAbort) + }) + } finally { + state.isLeader = false + if (state.heartbeatTimer) { + clearInterval(state.heartbeatTimer) + state.heartbeatTimer = null + } + } + }, + ) + } catch (error) { + if (error instanceof DOMException && error.name === `AbortError`) { + return + } + console.warn(`Failed to acquire leadership for ${collectionId}:`, error) + } + + // Re-acquire if not disposed (leadership was released by another means) + if (!this.isDisposed()) { + void this.acquireLeadership(collectionId, state) + } + } + + private releaseLeadership( + _collectionId: string, + state: CollectionState, + ): void { + if (state.lockAbortController) { + state.lockAbortController.abort() + state.lockAbortController = null + } + if (state.heartbeatTimer) { + clearInterval(state.heartbeatTimer) + state.heartbeatTimer = null + } + state.isLeader = false + } + + private emitHeartbeat(collectionId: string, state: CollectionState): void { + const envelope: ProtocolEnvelope = { + v: 1, + dbName: this.dbName, + collectionId, + senderId: this.nodeId, + ts: Date.now(), + payload: { + type: `leader:heartbeat`, + term: state.latestTerm, + leaderId: this.nodeId, + latestSeq: state.latestSeq, + latestRowVersion: state.latestRowVersion, + }, + } + this.channel.postMessage(envelope) + } + + // ----------------------------------------------------------------------- + // BroadcastChannel message handling + // ----------------------------------------------------------------------- + + private onChannelMessage(data: unknown): void { + if (!isProtocolEnvelope(data)) return + + const envelope = data + + // Ignore own messages + if (envelope.senderId === this.nodeId) return + + const payload = envelope.payload + if (!payload || typeof payload !== `object`) return + + const type = (payload as Record).type as string | undefined + + // Handle RPC responses (for pending outbound RPCs) + if (type && type.endsWith(`:res`)) { + const rpcId = (payload as { rpcId?: string }).rpcId + if (rpcId && this.pendingRPCs.has(rpcId)) { + const pending = this.pendingRPCs.get(rpcId)! + this.pendingRPCs.delete(rpcId) + clearTimeout(pending.timer) + pending.resolve(payload as RPCResponse) + return + } + } + + // Handle RPC requests (leader only) + if (type && type.endsWith(`:req`)) { + const collectionId = envelope.collectionId + if (this.isLeader(collectionId)) { + void this.handleRPCRequest(collectionId, payload as RPCRequest) + } + return + } + + // Forward protocol messages to subscribers + const state = this.collections.get(envelope.collectionId) + if (state) { + for (const subscriber of state.subscribers) { + subscriber(envelope) + } + } + } + + // ----------------------------------------------------------------------- + // RPC - Outbound (follower side) + // ----------------------------------------------------------------------- + + private async sendRPC( + collectionId: string, + request: RPCRequest, + ): Promise { + let lastError: Error | undefined + + for (let attempt = 0; attempt <= RPC_RETRY_ATTEMPTS; attempt++) { + if (attempt > 0) { + await sleep(RPC_RETRY_DELAY_MS * attempt) + } + + try { + return await this.sendRPCOnce(collectionId, request) + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + } + } + + throw lastError ?? new Error(`RPC failed after retries`) + } + + private sendRPCOnce( + collectionId: string, + request: RPCRequest, + ): Promise { + return new Promise((resolve, reject) => { + const rpcId = request.rpcId + + const timer = setTimeout(() => { + this.pendingRPCs.delete(rpcId) + reject( + new Error(`RPC ${request.type} timed out after ${RPC_TIMEOUT_MS}ms`), + ) + }, RPC_TIMEOUT_MS) + + this.pendingRPCs.set(rpcId, { + resolve: resolve as (response: RPCResponse) => void, + reject, + timer, + }) + + const envelope: ProtocolEnvelope = { + v: 1, + dbName: this.dbName, + collectionId, + senderId: this.nodeId, + ts: Date.now(), + payload: request, + } + this.channel.postMessage(envelope) + }) + } + + // ----------------------------------------------------------------------- + // RPC - Inbound (leader side) + // ----------------------------------------------------------------------- + + private async handleRPCRequest( + collectionId: string, + request: RPCRequest, + ): Promise { + let response: RPCResponse + + try { + switch (request.type) { + case `rpc:ensureRemoteSubset:req`: + response = await this.handleEnsureRemoteSubset(collectionId, request) + break + case `rpc:ensurePersistedIndex:req`: + response = await this.handleEnsurePersistedIndex( + collectionId, + request, + ) + break + case `rpc:applyLocalMutations:req`: + response = await this.handleApplyLocalMutations(collectionId, request) + break + case `rpc:pullSince:req`: + response = await this.handlePullSince(collectionId, request) + break + default: + return + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + response = { + type: request.type.replace(`:req`, `:res`) as RPCResponse[`type`], + rpcId: request.rpcId, + ok: false, + error: errorMessage, + } as RPCResponse + } + + const envelope: ProtocolEnvelope = { + v: 1, + dbName: this.dbName, + collectionId, + senderId: this.nodeId, + ts: Date.now(), + payload: response, + } + this.channel.postMessage(envelope) + } + + private handleEnsureRemoteSubset( + _collectionId: string, + request: { type: `rpc:ensureRemoteSubset:req`; rpcId: string }, + ): RPCResponse { + // Leader doesn't need to do anything special — the remote subset + // is ensured by the leader's own sync connection + return { + type: `rpc:ensureRemoteSubset:res`, + rpcId: request.rpcId, + ok: true, + } + } + + private async handleEnsurePersistedIndex( + collectionId: string, + request: { + type: `rpc:ensurePersistedIndex:req` + rpcId: string + signature: string + spec: PersistedIndexSpec + }, + ): Promise { + await this.withWriterLock(() => + this.requireAdapter().ensureIndex( + collectionId, + request.signature, + request.spec, + ), + ) + return { + type: `rpc:ensurePersistedIndex:res`, + rpcId: request.rpcId, + ok: true, + } + } + + private async handleApplyLocalMutations( + collectionId: string, + request: { + type: `rpc:applyLocalMutations:req` + rpcId: string + envelopeId: string + mutations: Array + }, + ): Promise { + // Dedupe by envelopeId + if (this.appliedEnvelopeIds.has(request.envelopeId)) { + return { + type: `rpc:applyLocalMutations:res`, + rpcId: request.rpcId, + ok: false, + code: `CONFLICT`, + error: `envelope ${request.envelopeId} already applied`, + } + } + + const state = this.collections.get(collectionId) + if (!state || !state.isLeader) { + return { + type: `rpc:applyLocalMutations:res`, + rpcId: request.rpcId, + ok: false, + code: `NOT_LEADER`, + error: `not the leader for ${collectionId}`, + } + } + + // Assign stream position + state.latestSeq++ + state.latestRowVersion++ + + const term = state.latestTerm + const seq = state.latestSeq + const rowVersion = state.latestRowVersion + + // Build and apply the persisted transaction + const tx = { + txId: crypto.randomUUID(), + term, + seq, + rowVersion, + mutations: request.mutations.map((m) => ({ + type: m.type, + key: m.key, + value: m.value, + })), + } + + await this.withWriterLock(() => + this.requireAdapter().applyCommittedTx(collectionId, tx), + ) + + // Track envelope for dedup + this.appliedEnvelopeIds.set(request.envelopeId, Date.now()) + this.pruneAppliedEnvelopeIds() + + // Broadcast tx:committed to all tabs + const changedRows = request.mutations + .filter((m) => m.type !== `delete`) + .map((m) => ({ key: m.key, value: m.value })) + const deletedKeys = request.mutations + .filter((m) => m.type === `delete`) + .map((m) => m.key) + + const txCommitted: ProtocolEnvelope = { + v: 1, + dbName: this.dbName, + collectionId, + senderId: this.nodeId, + ts: Date.now(), + payload: { + type: `tx:committed`, + term, + seq, + txId: tx.txId, + latestRowVersion: rowVersion, + requiresFullReload: false, + changedRows, + deletedKeys, + }, + } + this.channel.postMessage(txCommitted) + + // Deliver to local subscribers too + for (const subscriber of state.subscribers) { + subscriber(txCommitted) + } + + return { + type: `rpc:applyLocalMutations:res`, + rpcId: request.rpcId, + ok: true, + term, + seq, + latestRowVersion: rowVersion, + acceptedMutationIds: request.mutations.map((m) => m.mutationId), + } + } + + private async handlePullSince( + collectionId: string, + request: { + type: `rpc:pullSince:req` + rpcId: string + fromRowVersion: number + }, + ): Promise { + const state = this.collections.get(collectionId) + + const adapter = this.requireAdapter() + if (!adapter.pullSince) { + return { + type: `rpc:pullSince:res`, + rpcId: request.rpcId, + ok: true, + latestTerm: state?.latestTerm ?? 0, + latestSeq: state?.latestSeq ?? 0, + latestRowVersion: state?.latestRowVersion ?? 0, + requiresFullReload: true, + } + } + + const result = await adapter.pullSince(collectionId, request.fromRowVersion) + + if (result.requiresFullReload) { + return { + type: `rpc:pullSince:res`, + rpcId: request.rpcId, + ok: true, + latestTerm: state?.latestTerm ?? 0, + latestSeq: state?.latestSeq ?? 0, + latestRowVersion: result.latestRowVersion, + requiresFullReload: true, + } + } + + return { + type: `rpc:pullSince:res`, + rpcId: request.rpcId, + ok: true, + latestTerm: state?.latestTerm ?? 0, + latestSeq: state?.latestSeq ?? 0, + latestRowVersion: result.latestRowVersion, + requiresFullReload: false, + changedKeys: result.changedKeys, + deletedKeys: result.deletedKeys, + } + } + + // ----------------------------------------------------------------------- + // DB Writer Lock + // ----------------------------------------------------------------------- + + private async withWriterLock(fn: () => Promise): Promise { + const lockName = `tsdb:writer:${this.dbName}` + + for (let attempt = 0; attempt <= WRITER_LOCK_MAX_RETRIES; attempt++) { + try { + return await navigator.locks.request(lockName, async () => fn()) + } catch (error) { + if (error instanceof DOMException && error.name === `AbortError`) { + throw error + } + + if (attempt < WRITER_LOCK_MAX_RETRIES) { + await sleep(WRITER_LOCK_BUSY_RETRY_MS * Math.min(attempt + 1, 5)) + continue + } + + throw error + } + } + + // Unreachable but satisfies TypeScript + throw new Error(`writer lock acquisition failed`) + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private pruneAppliedEnvelopeIds(): void { + // Keep envelopes for 60 seconds for dedup + const cutoff = Date.now() - 60_000 + for (const [id, ts] of this.appliedEnvelopeIds) { + if (ts < cutoff) { + this.appliedEnvelopeIds.delete(id) + } + } + } +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function isProtocolEnvelope(data: unknown): data is ProtocolEnvelope { + if (!data || typeof data !== `object`) return false + const record = data as Record + return ( + record.v === 1 && + typeof record.dbName === `string` && + typeof record.collectionId === `string` && + typeof record.senderId === `string` && + typeof record.ts === `number` + ) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/packages/db-electron-sqlite-persisted-collection/src/errors.ts b/packages/db-electron-sqlite-persisted-collection/src/errors.ts new file mode 100644 index 000000000..33bd0c4ec --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/src/errors.ts @@ -0,0 +1,110 @@ +import type { + ElectronPersistenceMethod, + ElectronSerializedError, +} from './protocol' + +type ElectronPersistenceErrorOptions = { + code?: string + cause?: unknown +} + +export class ElectronPersistenceError extends Error { + readonly code: string | undefined + + constructor(message: string, options?: ElectronPersistenceErrorOptions) { + super(message, { cause: options?.cause }) + this.name = `ElectronPersistenceError` + this.code = options?.code + } +} + +export class UnknownElectronPersistenceCollectionError extends ElectronPersistenceError { + readonly collectionId: string + + constructor(collectionId: string) { + super( + `Unknown electron persistence collection "${collectionId}". Register the collection adapter in the main process host.`, + { + code: `UNKNOWN_COLLECTION`, + }, + ) + this.name = `UnknownElectronPersistenceCollectionError` + this.collectionId = collectionId + } +} + +export class UnsupportedElectronPersistenceMethodError extends ElectronPersistenceError { + readonly method: ElectronPersistenceMethod + readonly collectionId: string + + constructor(method: ElectronPersistenceMethod, collectionId: string) { + super( + `Method "${method}" is not supported by the electron persistence adapter for collection "${collectionId}".`, + { + code: `UNSUPPORTED_METHOD`, + }, + ) + this.name = `UnsupportedElectronPersistenceMethodError` + this.method = method + this.collectionId = collectionId + } +} + +export class ElectronPersistenceProtocolError extends ElectronPersistenceError { + constructor(message: string, options?: ElectronPersistenceErrorOptions) { + super(message, { + code: options?.code ?? `INVALID_PROTOCOL`, + cause: options?.cause, + }) + this.name = `ElectronPersistenceProtocolError` + } +} + +export class ElectronPersistenceTimeoutError extends ElectronPersistenceError { + constructor(message: string) { + super(message, { + code: `TIMEOUT`, + }) + this.name = `ElectronPersistenceTimeoutError` + } +} + +export class ElectronPersistenceRpcError extends ElectronPersistenceError { + readonly method: ElectronPersistenceMethod + readonly collectionId: string + readonly requestId: string + readonly remoteName: string + + constructor( + method: ElectronPersistenceMethod, + collectionId: string, + requestId: string, + serializedError: ElectronSerializedError, + ) { + super( + `${serializedError.name}: ${serializedError.message} (method=${method}, collection=${collectionId}, request=${requestId})`, + { + code: serializedError.code ?? `REMOTE_ERROR`, + }, + ) + this.name = `ElectronPersistenceRpcError` + this.method = method + this.collectionId = collectionId + this.requestId = requestId + this.remoteName = serializedError.name + } + + static fromSerialized( + method: ElectronPersistenceMethod, + collectionId: string, + requestId: string, + serializedError: ElectronSerializedError, + ): ElectronPersistenceRpcError { + return new ElectronPersistenceRpcError( + method, + collectionId, + requestId, + serializedError, + ) + } +} diff --git a/packages/db-electron-sqlite-persisted-collection/src/index.ts b/packages/db-electron-sqlite-persisted-collection/src/index.ts new file mode 100644 index 000000000..d06335511 --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/src/index.ts @@ -0,0 +1,17 @@ +export { exposeElectronSQLitePersistence } from './main' +export type { + ElectronIpcMainLike, + ElectronSQLiteMainProcessOptions, +} from './main' +export { createElectronSQLitePersistence } from './renderer' +export type { + ElectronIpcRendererLike, + ElectronSQLitePersistenceOptions, +} from './renderer' +export { ElectronCollectionCoordinator } from './electron-coordinator' +export type { ElectronCollectionCoordinatorOptions } from './electron-coordinator' +export { persistedCollectionOptions } from '@tanstack/db-sqlite-persisted-collection-core' +export type { + PersistedCollectionCoordinator, + PersistedCollectionPersistence, +} from '@tanstack/db-sqlite-persisted-collection-core' diff --git a/packages/db-electron-sqlite-persisted-collection/src/main.ts b/packages/db-electron-sqlite-persisted-collection/src/main.ts new file mode 100644 index 000000000..ccdb5dd12 --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/src/main.ts @@ -0,0 +1,267 @@ +import { InvalidPersistedCollectionConfigError } from '@tanstack/db-sqlite-persisted-collection-core' +import { + DEFAULT_ELECTRON_PERSISTENCE_CHANNEL, + ELECTRON_PERSISTENCE_PROTOCOL_VERSION, +} from './protocol' +import type { + PersistedCollectionPersistence, + PersistenceAdapter, + SQLitePullSinceResult, +} from '@tanstack/db-sqlite-persisted-collection-core' +import type { + ElectronPersistedKey, + ElectronPersistedRow, + ElectronPersistenceRequestEnvelope, + ElectronPersistenceResponseEnvelope, + ElectronSerializedError, +} from './protocol' + +type ElectronMainPersistenceAdapter = PersistenceAdapter< + ElectronPersistedRow, + ElectronPersistedKey +> & { + pullSince?: ( + collectionId: string, + fromRowVersion: number, + ) => Promise> + getStreamPosition?: (collectionId: string) => Promise<{ + latestTerm: number + latestSeq: number + latestRowVersion: number + }> +} + +function serializeError(error: unknown): ElectronSerializedError { + const fallbackMessage = `Unknown electron persistence error` + + if (!(error instanceof Error)) { + return { + name: `Error`, + message: fallbackMessage, + code: undefined, + } + } + + const codedError = error as Error & { code?: unknown } + return { + name: error.name || `Error`, + message: error.message || fallbackMessage, + stack: error.stack, + code: typeof codedError.code === `string` ? codedError.code : undefined, + } +} + +function createErrorResponse( + request: ElectronPersistenceRequestEnvelope, + error: unknown, +): ElectronPersistenceResponseEnvelope { + return { + v: ELECTRON_PERSISTENCE_PROTOCOL_VERSION, + requestId: request.requestId, + method: request.method, + ok: false, + error: serializeError(error), + } +} + +function assertValidRequest(request: ElectronPersistenceRequestEnvelope): void { + if (request.v !== ELECTRON_PERSISTENCE_PROTOCOL_VERSION) { + throw new InvalidPersistedCollectionConfigError( + `Unsupported electron persistence protocol version "${request.v}"`, + ) + } + + if ( + typeof request.requestId !== `string` || + request.requestId.trim().length === 0 + ) { + throw new InvalidPersistedCollectionConfigError( + `Electron persistence requestId cannot be empty`, + ) + } + + if ( + typeof request.collectionId !== `string` || + request.collectionId.trim().length === 0 + ) { + throw new InvalidPersistedCollectionConfigError( + `Electron persistence collectionId cannot be empty`, + ) + } +} + +async function executeRequestAgainstAdapter( + request: ElectronPersistenceRequestEnvelope, + adapter: ElectronMainPersistenceAdapter, +): Promise { + switch (request.method) { + case `loadSubset`: { + const result = await adapter.loadSubset( + request.collectionId, + request.payload.options, + request.payload.ctx, + ) + return { + v: ELECTRON_PERSISTENCE_PROTOCOL_VERSION, + requestId: request.requestId, + method: request.method, + ok: true, + result, + } + } + + case `applyCommittedTx`: { + await adapter.applyCommittedTx(request.collectionId, request.payload.tx) + return { + v: ELECTRON_PERSISTENCE_PROTOCOL_VERSION, + requestId: request.requestId, + method: request.method, + ok: true, + result: null, + } + } + + case `ensureIndex`: { + await adapter.ensureIndex( + request.collectionId, + request.payload.signature, + request.payload.spec, + ) + return { + v: ELECTRON_PERSISTENCE_PROTOCOL_VERSION, + requestId: request.requestId, + method: request.method, + ok: true, + result: null, + } + } + + case `markIndexRemoved`: { + if (!adapter.markIndexRemoved) { + throw new InvalidPersistedCollectionConfigError( + `markIndexRemoved is not supported by the configured electron persistence adapter`, + ) + } + await adapter.markIndexRemoved( + request.collectionId, + request.payload.signature, + ) + return { + v: ELECTRON_PERSISTENCE_PROTOCOL_VERSION, + requestId: request.requestId, + method: request.method, + ok: true, + result: null, + } + } + + case `pullSince`: { + if (!adapter.pullSince) { + throw new InvalidPersistedCollectionConfigError( + `pullSince is not supported by the configured electron persistence adapter`, + ) + } + const result = await adapter.pullSince( + request.collectionId, + request.payload.fromRowVersion, + ) + return { + v: ELECTRON_PERSISTENCE_PROTOCOL_VERSION, + requestId: request.requestId, + method: request.method, + ok: true, + result, + } + } + + case `getStreamPosition`: { + if (!adapter.getStreamPosition) { + throw new InvalidPersistedCollectionConfigError( + `getStreamPosition is not supported by the configured electron persistence adapter`, + ) + } + const position = await adapter.getStreamPosition(request.collectionId) + return { + v: ELECTRON_PERSISTENCE_PROTOCOL_VERSION, + requestId: request.requestId, + method: request.method, + ok: true, + result: position, + } + } + } +} + +function resolveModeAwarePersistence( + persistence: PersistedCollectionPersistence< + ElectronPersistedRow, + ElectronPersistedKey + >, + request: ElectronPersistenceRequestEnvelope, +): PersistedCollectionPersistence { + const mode = request.resolution?.mode ?? `sync-absent` + const schemaVersion = request.resolution?.schemaVersion + const collectionAwarePersistence = + persistence.resolvePersistenceForCollection?.({ + collectionId: request.collectionId, + mode, + schemaVersion, + }) + if (collectionAwarePersistence) { + return collectionAwarePersistence + } + + const modeAwarePersistence = persistence.resolvePersistenceForMode?.(mode) + return modeAwarePersistence ?? persistence +} + +export type ElectronIpcMainLike = { + handle: ( + channel: string, + listener: ( + event: unknown, + request: ElectronPersistenceRequestEnvelope, + ) => Promise, + ) => void + removeHandler?: (channel: string) => void +} + +export type ElectronSQLiteMainProcessOptions = { + persistence: PersistedCollectionPersistence< + ElectronPersistedRow, + ElectronPersistedKey + > + ipcMain: ElectronIpcMainLike + channel?: string +} + +export function exposeElectronSQLitePersistence( + options: ElectronSQLiteMainProcessOptions, +): () => void { + const channel = options.channel ?? DEFAULT_ELECTRON_PERSISTENCE_CHANNEL + options.ipcMain.handle( + channel, + async ( + _event, + request: ElectronPersistenceRequestEnvelope, + ): Promise => { + try { + assertValidRequest(request) + const modeAwarePersistence = resolveModeAwarePersistence( + options.persistence, + request, + ) + return await executeRequestAgainstAdapter( + request, + modeAwarePersistence.adapter, + ) + } catch (error) { + return createErrorResponse(request, error) + } + }, + ) + + return () => { + options.ipcMain.removeHandler?.(channel) + } +} diff --git a/packages/db-electron-sqlite-persisted-collection/src/protocol.ts b/packages/db-electron-sqlite-persisted-collection/src/protocol.ts new file mode 100644 index 000000000..b9faa4cfa --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/src/protocol.ts @@ -0,0 +1,123 @@ +import type { LoadSubsetOptions } from '@tanstack/db' +import type { + PersistedCollectionMode, + PersistedIndexSpec, + PersistedTx, + SQLitePullSinceResult, +} from '@tanstack/db-sqlite-persisted-collection-core' + +export const ELECTRON_PERSISTENCE_PROTOCOL_VERSION = 1 as const +export const DEFAULT_ELECTRON_PERSISTENCE_CHANNEL = `tanstack-db:sqlite-persistence` + +export type ElectronPersistedRow = Record +export type ElectronPersistedKey = string | number + +export type ElectronPersistenceResolution = { + mode: PersistedCollectionMode + schemaVersion?: number +} + +export type ElectronPersistenceMethod = + | `loadSubset` + | `applyCommittedTx` + | `ensureIndex` + | `markIndexRemoved` + | `pullSince` + | `getStreamPosition` + +export type ElectronPersistencePayloadMap = { + loadSubset: { + options: LoadSubsetOptions + ctx?: { requiredIndexSignatures?: ReadonlyArray } + } + applyCommittedTx: { + tx: PersistedTx + } + ensureIndex: { + signature: string + spec: PersistedIndexSpec + } + markIndexRemoved: { + signature: string + } + pullSince: { + fromRowVersion: number + } + getStreamPosition: {} +} + +export type ElectronPersistenceResultMap = { + loadSubset: Array<{ key: ElectronPersistedKey; value: ElectronPersistedRow }> + applyCommittedTx: null + ensureIndex: null + markIndexRemoved: null + pullSince: SQLitePullSinceResult + getStreamPosition: { + latestTerm: number + latestSeq: number + latestRowVersion: number + } +} + +export type ElectronSerializedError = { + name: string + message: string + stack?: string + code?: string +} + +export type ElectronPersistenceRequestByMethod = { + [Method in ElectronPersistenceMethod]: { + v: number + requestId: string + collectionId: string + resolution?: ElectronPersistenceResolution + method: Method + payload: ElectronPersistencePayloadMap[Method] + } +} + +export type ElectronPersistenceRequest< + TMethod extends ElectronPersistenceMethod = ElectronPersistenceMethod, +> = ElectronPersistenceRequestByMethod[TMethod] + +export type ElectronPersistenceRequestEnvelope = + ElectronPersistenceRequestByMethod[ElectronPersistenceMethod] + +type ElectronPersistenceSuccessResponseByMethod = { + [Method in ElectronPersistenceMethod]: { + v: number + requestId: string + method: Method + ok: true + result: ElectronPersistenceResultMap[Method] + } +} + +type ElectronPersistenceErrorResponseByMethod = { + [Method in ElectronPersistenceMethod]: { + v: number + requestId: string + method: Method + ok: false + error: ElectronSerializedError + } +} + +export type ElectronPersistenceResponse< + TMethod extends ElectronPersistenceMethod = ElectronPersistenceMethod, +> = + | ElectronPersistenceSuccessResponseByMethod[TMethod] + | ElectronPersistenceErrorResponseByMethod[TMethod] + +export type ElectronPersistenceResponseEnvelope = + ElectronPersistenceResponse + +export type ElectronPersistenceRequestHandler = ( + request: ElectronPersistenceRequestEnvelope, +) => Promise + +export type ElectronPersistenceInvoke = ( + channel: string, + request: ElectronPersistenceRequestEnvelope, +) => Promise diff --git a/packages/db-electron-sqlite-persisted-collection/src/renderer.ts b/packages/db-electron-sqlite-persisted-collection/src/renderer.ts new file mode 100644 index 000000000..99b0a0a3c --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/src/renderer.ts @@ -0,0 +1,362 @@ +import { + InvalidPersistedCollectionConfigError, + SingleProcessCoordinator, +} from '@tanstack/db-sqlite-persisted-collection-core' +import { ElectronCollectionCoordinator } from './electron-coordinator' +import { + DEFAULT_ELECTRON_PERSISTENCE_CHANNEL, + ELECTRON_PERSISTENCE_PROTOCOL_VERSION, +} from './protocol' +import type { + PersistedCollectionCoordinator, + PersistedCollectionMode, + PersistedCollectionPersistence, + PersistedIndexSpec, + PersistedTx, + SQLitePullSinceResult, +} from '@tanstack/db-sqlite-persisted-collection-core' +import type { + ElectronPersistedKey, + ElectronPersistedRow, + ElectronPersistenceInvoke, + ElectronPersistenceMethod, + ElectronPersistencePayloadMap, + ElectronPersistenceRequest, + ElectronPersistenceRequestEnvelope, + ElectronPersistenceResolution, + ElectronPersistenceResponseEnvelope, + ElectronPersistenceResultMap, +} from './protocol' +import type { LoadSubsetOptions } from '@tanstack/db' + +const DEFAULT_REQUEST_TIMEOUT_MS = 5_000 +let nextRequestId = 1 + +function createRequestId(): string { + const requestId = nextRequestId + nextRequestId++ + return `electron-persistence-${requestId}` +} + +function withTimeout( + promise: Promise, + timeoutMs: number, + timeoutMessage: string, +): Promise { + if (timeoutMs <= 0) { + return promise + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new InvalidPersistedCollectionConfigError(timeoutMessage)) + }, timeoutMs) + + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error: unknown) => { + clearTimeout(timer) + reject(error) + }, + ) + }) +} + +function assertValidResponse( + response: ElectronPersistenceResponseEnvelope, + request: ElectronPersistenceRequestEnvelope, +): void { + if (response.v !== ELECTRON_PERSISTENCE_PROTOCOL_VERSION) { + throw new InvalidPersistedCollectionConfigError( + `Unexpected electron persistence protocol version "${response.v}" in response`, + ) + } + + if (response.requestId !== request.requestId) { + throw new InvalidPersistedCollectionConfigError( + `Mismatched electron persistence response request id. Expected "${request.requestId}", received "${response.requestId}"`, + ) + } + + if (response.method !== request.method) { + throw new InvalidPersistedCollectionConfigError( + `Mismatched electron persistence response method. Expected "${request.method}", received "${response.method}"`, + ) + } +} + +function createSerializableLoadSubsetOptions( + subsetOptions: LoadSubsetOptions, +): LoadSubsetOptions { + const { subscription: _subscription, ...serializableOptions } = subsetOptions + return serializableOptions +} + +type RendererRequestExecutor = ( + method: TMethod, + collectionId: string, + payload: ElectronPersistencePayloadMap[TMethod], + resolution?: ElectronPersistenceResolution, +) => Promise + +function createRendererRequestExecutor(options: { + invoke: ElectronPersistenceInvoke + channel?: string + timeoutMs?: number +}): RendererRequestExecutor { + const channel = options.channel ?? DEFAULT_ELECTRON_PERSISTENCE_CHANNEL + const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS + + return async ( + method: TMethod, + collectionId: string, + payload: ElectronPersistencePayloadMap[TMethod], + resolution?: ElectronPersistenceResolution, + ) => { + const request = { + v: ELECTRON_PERSISTENCE_PROTOCOL_VERSION, + requestId: createRequestId(), + collectionId, + method, + resolution, + payload, + } as ElectronPersistenceRequest + + const response = await withTimeout( + options.invoke(channel, request), + timeoutMs, + `Electron persistence request timed out (method=${method}, collection=${collectionId}, timeoutMs=${timeoutMs})`, + ) + assertValidResponse(response, request) + + if (!response.ok) { + const remoteError = new InvalidPersistedCollectionConfigError( + `${response.error.name}: ${response.error.message}`, + ) + if (typeof response.error.stack === `string`) { + remoteError.stack = response.error.stack + } + if (typeof response.error.code === `string`) { + ;(remoteError as Error & { code?: string }).code = response.error.code + } + throw remoteError + } + + return response.result as ElectronPersistenceResultMap[TMethod] + } +} + +type ElectronRendererResolvedAdapter< + T extends object, + TKey extends string | number = string | number, +> = PersistedCollectionPersistence[`adapter`] & { + pullSince: ( + collectionId: string, + fromRowVersion: number, + ) => Promise> + getStreamPosition: (collectionId: string) => Promise<{ + latestTerm: number + latestSeq: number + latestRowVersion: number + }> +} + +function createResolvedRendererAdapter< + T extends object, + TKey extends string | number = string | number, +>( + executeRequest: RendererRequestExecutor, + resolution?: ElectronPersistenceResolution, +): ElectronRendererResolvedAdapter { + return { + loadSubset: async ( + collectionId: string, + subsetOptions: LoadSubsetOptions, + ctx?: { requiredIndexSignatures?: ReadonlyArray }, + ) => { + const result = await executeRequest( + `loadSubset`, + collectionId, + { + options: createSerializableLoadSubsetOptions(subsetOptions), + ctx, + }, + resolution, + ) + + return result as Array<{ key: TKey; value: T }> + }, + applyCommittedTx: async ( + collectionId: string, + tx: PersistedTx, + ): Promise => { + await executeRequest( + `applyCommittedTx`, + collectionId, + { + tx: tx as PersistedTx, + }, + resolution, + ) + }, + ensureIndex: async ( + collectionId: string, + signature: string, + spec: PersistedIndexSpec, + ): Promise => { + await executeRequest( + `ensureIndex`, + collectionId, + { + signature, + spec, + }, + resolution, + ) + }, + markIndexRemoved: async ( + collectionId: string, + signature: string, + ): Promise => { + await executeRequest( + `markIndexRemoved`, + collectionId, + { + signature, + }, + resolution, + ) + }, + pullSince: async ( + collectionId: string, + fromRowVersion: number, + ): Promise> => { + const result = await executeRequest( + `pullSince`, + collectionId, + { + fromRowVersion, + }, + resolution, + ) + return result as SQLitePullSinceResult + }, + getStreamPosition: async ( + collectionId: string, + ): Promise<{ + latestTerm: number + latestSeq: number + latestRowVersion: number + }> => { + return executeRequest(`getStreamPosition`, collectionId, {}, resolution) + }, + } +} + +export type ElectronIpcRendererLike = { + invoke: ( + channel: string, + request: ElectronPersistenceRequestEnvelope, + ) => Promise +} + +export type ElectronSQLitePersistenceOptions = { + invoke?: ElectronPersistenceInvoke + ipcRenderer?: ElectronIpcRendererLike + channel?: string + timeoutMs?: number + coordinator?: PersistedCollectionCoordinator +} + +function resolveInvoke( + options: ElectronSQLitePersistenceOptions, +): ElectronPersistenceInvoke { + if (options.invoke) { + return options.invoke + } + + if (options.ipcRenderer) { + return (channel, request) => options.ipcRenderer!.invoke(channel, request) + } + + throw new InvalidPersistedCollectionConfigError( + `Electron renderer persistence requires either invoke or ipcRenderer`, + ) +} + +export function createElectronSQLitePersistence< + T extends object, + TKey extends string | number = string | number, +>( + options: ElectronSQLitePersistenceOptions, +): PersistedCollectionPersistence { + const invoke = resolveInvoke(options) + const coordinator = options.coordinator ?? new SingleProcessCoordinator() + const executeRequest = createRendererRequestExecutor({ + invoke, + channel: options.channel, + timeoutMs: options.timeoutMs, + }) + const adapterCache = new Map< + string, + ElectronRendererResolvedAdapter, string | number> + >() + + const getAdapterForCollection = ( + mode: PersistedCollectionMode, + schemaVersion: number | undefined, + ) => { + const schemaVersionKey = + schemaVersion === undefined ? `schema:default` : `schema:${schemaVersion}` + const cacheKey = `mode:${mode}|${schemaVersionKey}` + const cachedAdapter = adapterCache.get(cacheKey) + if (cachedAdapter) { + return cachedAdapter + } + + const adapter = createResolvedRendererAdapter< + Record, + string | number + >(executeRequest, { + mode, + schemaVersion, + }) + adapterCache.set(cacheKey, adapter) + + // Wire the adapter into the coordinator so it can handle + // leader-side RPCs (applyCommittedTx, pullSince, getStreamPosition, etc.) + if (coordinator instanceof ElectronCollectionCoordinator) { + coordinator.setAdapter(adapter) + } + + return adapter + } + + const createCollectionPersistence = ( + mode: PersistedCollectionMode, + schemaVersion: number | undefined, + ): PersistedCollectionPersistence => ({ + adapter: getAdapterForCollection( + mode, + schemaVersion, + ) as unknown as PersistedCollectionPersistence[`adapter`], + coordinator, + }) + + const defaultPersistence = createCollectionPersistence( + `sync-absent`, + undefined, + ) + + return { + ...defaultPersistence, + resolvePersistenceForCollection: ({ mode, schemaVersion }) => + createCollectionPersistence(mode, schemaVersion), + // Backward compatible fallback for older callers. + resolvePersistenceForMode: (mode) => + createCollectionPersistence(mode, undefined), + } +} diff --git a/packages/db-electron-sqlite-persisted-collection/tests/e2e/electron-process-client.ts b/packages/db-electron-sqlite-persisted-collection/tests/e2e/electron-process-client.ts new file mode 100644 index 000000000..f960721ed --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tests/e2e/electron-process-client.ts @@ -0,0 +1,425 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { deserialize } from 'node:v8' +import { DEFAULT_ELECTRON_PERSISTENCE_CHANNEL } from '../../src/protocol' +import { + E2E_RESULT_BASE64_PREFIX, + E2E_RESULT_PREFIX, +} from './fixtures/runtime-bridge-types' +import type { ElectronPersistenceInvoke } from '../../src/protocol' +import type { + ElectronRuntimeBridgeAdapterOptions, + ElectronRuntimeBridgeHostKind, + ElectronRuntimeBridgeInput, + ElectronRuntimeBridgeProcessResult, + ElectronRuntimeBridgeScenarioResult, +} from './fixtures/runtime-bridge-types' + +const ELECTRON_SCENARIO_TIMEOUT_MS = 20_000 +const require = createRequire(import.meta.url) +const currentFilePath = fileURLToPath(import.meta.url) +const e2eDirectory = dirname(currentFilePath) +const testsDirectory = dirname(e2eDirectory) +const packageRoot = dirname(testsDirectory) +const electronRunnerPath = join(e2eDirectory, `fixtures`, `electron-main.mjs`) +const E2E_INPUT_ENV_VAR = `TANSTACK_DB_E2E_INPUT` +const E2E_TRANSPORT_TYPE_TAG = `__tanstack_db_e2e_transport_type__` +const E2E_TRANSPORT_VALUE_TAG = `value` + +export const ELECTRON_FULL_E2E_ENV_VAR = `TANSTACK_DB_ELECTRON_E2E_ALL` + +export function isElectronFullE2EEnabled(): boolean { + return process.env[ELECTRON_FULL_E2E_ENV_VAR] === `1` +} + +type CreateElectronRuntimeBridgeInvokeOptions = { + dbPath: string + collectionId: string + allowAnyCollectionId?: boolean + timeoutMs?: number + hostKind?: ElectronRuntimeBridgeHostKind + adapterOptions?: ElectronRuntimeBridgeAdapterOptions +} + +function createElectronScenarioEnv( + input: ElectronRuntimeBridgeInput, +): NodeJS.ProcessEnv { + const childEnv: NodeJS.ProcessEnv = { ...process.env } + + delete childEnv.NODE_V8_COVERAGE + + for (const envKey of Object.keys(childEnv)) { + if ( + envKey.startsWith(`VITEST_`) || + envKey.startsWith(`__VITEST_`) || + envKey.startsWith(`NYC_`) + ) { + delete childEnv[envKey] + } + } + + childEnv[E2E_INPUT_ENV_VAR] = encodeInputForEnv(input) + childEnv.ELECTRON_DISABLE_SECURITY_WARNINGS = `true` + + return childEnv +} + +function resolveElectronBinaryPath(): string { + const electronModuleValue: unknown = require(`electron`) + if ( + typeof electronModuleValue !== `string` || + electronModuleValue.length === 0 + ) { + throw new Error(`Failed to resolve electron binary path`) + } + return electronModuleValue +} + +function parseScenarioResult( + stdoutBuffer: string, + stderrBuffer: string, + exitCode: number | null, +): ElectronRuntimeBridgeProcessResult { + const outputLines = stdoutBuffer.split(/\r?\n/u) + const base64ResultLine = outputLines.find((line) => + line.startsWith(E2E_RESULT_BASE64_PREFIX), + ) + if (base64ResultLine) { + const rawResult = base64ResultLine.slice(E2E_RESULT_BASE64_PREFIX.length) + const serializedResult = Buffer.from(rawResult, `base64`) + return deserialize(serializedResult) as ElectronRuntimeBridgeProcessResult + } + + const jsonResultLine = outputLines.find((line) => + line.startsWith(E2E_RESULT_PREFIX), + ) + + if (!jsonResultLine) { + throw new Error( + [ + `Electron e2e runner did not emit a result line`, + `exitCode=${String(exitCode)}`, + `stderr=${stderrBuffer}`, + `stdout=${stdoutBuffer}`, + ].join(`\n`), + ) + } + + const rawResult = jsonResultLine.slice(E2E_RESULT_PREFIX.length) + return JSON.parse(rawResult) as ElectronRuntimeBridgeProcessResult +} + +function encodeTransportValue( + value: unknown, + ancestors: WeakSet = new WeakSet(), +): unknown { + if (value === null) { + return null + } + + if ( + typeof value === `string` || + typeof value === `boolean` || + (typeof value === `number` && Number.isFinite(value)) + ) { + return value + } + + if (typeof value === `number`) { + if (Number.isNaN(value)) { + return { + [E2E_TRANSPORT_TYPE_TAG]: `nan`, + } + } + if (value === Number.POSITIVE_INFINITY) { + return { + [E2E_TRANSPORT_TYPE_TAG]: `infinity`, + } + } + if (value === Number.NEGATIVE_INFINITY) { + return { + [E2E_TRANSPORT_TYPE_TAG]: `-infinity`, + } + } + } + + if (typeof value === `bigint`) { + return { + [E2E_TRANSPORT_TYPE_TAG]: `bigint`, + [E2E_TRANSPORT_VALUE_TAG]: value.toString(), + } + } + + if (value instanceof Date) { + const timestamp = value.getTime() + if (Number.isNaN(timestamp)) { + return { + [E2E_TRANSPORT_TYPE_TAG]: `date_invalid`, + } + } + return { + [E2E_TRANSPORT_TYPE_TAG]: `date`, + [E2E_TRANSPORT_VALUE_TAG]: value.toISOString(), + } + } + + if (Array.isArray(value)) { + if (ancestors.has(value)) { + return undefined + } + ancestors.add(value) + try { + return value.map((item) => { + const encodedItem = encodeTransportValue(item, ancestors) + return encodedItem === undefined ? null : encodedItem + }) + } finally { + ancestors.delete(value) + } + } + + if ( + typeof value === `undefined` || + typeof value === `function` || + typeof value === `symbol` + ) { + return undefined + } + + if (typeof value === `object`) { + if (ancestors.has(value)) { + return undefined + } + ancestors.add(value) + try { + const encodedObject: Record = {} + for (const [key, objectValue] of Object.entries( + value as Record, + )) { + const encodedObjectValue = encodeTransportValue(objectValue, ancestors) + if (encodedObjectValue !== undefined) { + encodedObject[key] = encodedObjectValue + } + } + return encodedObject + } finally { + ancestors.delete(value) + } + } + + return undefined +} + +function encodeInputForEnv(input: ElectronRuntimeBridgeInput): string { + const encodedInput = encodeTransportValue(input) + if (!encodedInput || typeof encodedInput !== `object`) { + throw new Error(`Failed to encode e2e runtime input`) + } + return JSON.stringify(encodedInput) +} + +export async function runElectronRuntimeBridgeScenario( + input: ElectronRuntimeBridgeInput, +): Promise { + const scenarioTimeoutMs = Math.max( + ELECTRON_SCENARIO_TIMEOUT_MS, + (input.timeoutMs ?? 0) + 8_000, + ) + const electronBinaryPath = resolveElectronBinaryPath() + const xvfbRunPath = `/usr/bin/xvfb-run` + const hasXvfbRun = existsSync(xvfbRunPath) + const electronArgs = [ + `--disable-gpu`, + `--disable-dev-shm-usage`, + `--no-sandbox`, + electronRunnerPath, + ] + const command = hasXvfbRun ? xvfbRunPath : electronBinaryPath + const args = hasXvfbRun + ? [ + `-a`, + `--server-args=-screen 0 1280x720x24`, + electronBinaryPath, + ...electronArgs, + ] + : electronArgs + + const processResult = await new Promise( + (resolve, reject) => { + const child = spawn(command, args, { + cwd: packageRoot, + env: createElectronScenarioEnv(input), + stdio: [`ignore`, `pipe`, `pipe`], + }) + let stdoutBuffer = `` + let stderrBuffer = `` + let isSettled = false + let resultFromStdout: ElectronRuntimeBridgeProcessResult | undefined + let gracefulCloseTimeout: ReturnType | undefined + + const settle = ( + callback: (result: ElectronRuntimeBridgeProcessResult) => void, + result: ElectronRuntimeBridgeProcessResult, + ) => { + if (isSettled) { + return + } + isSettled = true + clearTimeout(timeout) + if (gracefulCloseTimeout) { + clearTimeout(gracefulCloseTimeout) + } + callback(result) + + if (!child.killed) { + child.kill(`SIGKILL`) + } + } + + const rejectOnce = (error: unknown) => { + if (isSettled) { + return + } + isSettled = true + clearTimeout(timeout) + if (gracefulCloseTimeout) { + clearTimeout(gracefulCloseTimeout) + } + reject(error) + if (!child.killed) { + child.kill(`SIGKILL`) + } + } + + const timeout = setTimeout(() => { + rejectOnce( + new Error( + [ + `Electron e2e scenario timed out after ${String(scenarioTimeoutMs)}ms`, + `stderr=${stderrBuffer}`, + `stdout=${stdoutBuffer}`, + ].join(`\n`), + ), + ) + }, scenarioTimeoutMs) + + child.on(`error`, (error) => { + rejectOnce(error) + }) + + child.stdout.on(`data`, (chunk: Buffer) => { + stdoutBuffer += chunk.toString() + + try { + const parsedResult = parseScenarioResult( + stdoutBuffer, + stderrBuffer, + null, + ) + if (!resultFromStdout) { + resultFromStdout = parsedResult + gracefulCloseTimeout = setTimeout(() => { + settle(resolve, parsedResult) + }, 1_000) + } + } catch { + // Result line might not be complete yet. + } + }) + child.stderr.on(`data`, (chunk: Buffer) => { + stderrBuffer += chunk.toString() + }) + + child.on(`close`, (exitCode) => { + if (isSettled) { + return + } + + try { + if (resultFromStdout) { + settle(resolve, resultFromStdout) + return + } + + const parsedResult = parseScenarioResult( + stdoutBuffer, + stderrBuffer, + exitCode, + ) + settle(resolve, parsedResult) + } catch (error) { + rejectOnce(error) + } + }) + }, + ) + + if (!processResult.ok) { + throw new Error( + `Electron e2e runner failed: ${processResult.error.name}: ${processResult.error.message}`, + ) + } + + return processResult.result +} + +export function createElectronRuntimeBridgeInvoke( + options: CreateElectronRuntimeBridgeInvokeOptions, +): ElectronPersistenceInvoke { + let queue: Promise = Promise.resolve() + + return async (channel, request) => { + const queuedInvoke = queue.then( + () => + runElectronRuntimeBridgeScenario({ + dbPath: options.dbPath, + collectionId: options.collectionId, + allowAnyCollectionId: options.allowAnyCollectionId, + hostKind: options.hostKind, + adapterOptions: options.adapterOptions, + channel, + timeoutMs: options.timeoutMs ?? 4_000, + scenario: { + type: `invokeRequest`, + request, + }, + }), + () => + runElectronRuntimeBridgeScenario({ + dbPath: options.dbPath, + collectionId: options.collectionId, + allowAnyCollectionId: options.allowAnyCollectionId, + hostKind: options.hostKind, + adapterOptions: options.adapterOptions, + channel, + timeoutMs: options.timeoutMs ?? 4_000, + scenario: { + type: `invokeRequest`, + request, + }, + }), + ) + queue = queuedInvoke.then( + () => undefined, + () => undefined, + ) + + const result = await queuedInvoke + + if (result.type !== `invokeRequest`) { + throw new Error(`Unexpected invokeRequest result: ${result.type}`) + } + + return result.response + } +} + +export function withDefaultElectronChannel( + invoke: ElectronPersistenceInvoke, +): ElectronPersistenceInvoke { + return (channel, request) => + invoke(channel || DEFAULT_ELECTRON_PERSISTENCE_CHANNEL, request) +} diff --git a/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/electron-main.mjs b/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/electron-main.mjs new file mode 100644 index 000000000..94ff930b9 --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/electron-main.mjs @@ -0,0 +1,428 @@ +import { dirname, join } from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { AsyncLocalStorage } from 'node:async_hooks' +import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { serialize } from 'node:v8' +import { BrowserWindow, app, ipcMain } from 'electron' +import { createSQLiteCorePersistenceAdapter } from '@tanstack/db-sqlite-persisted-collection-core' +import { exposeElectronSQLitePersistence } from '../../../dist/esm/main.js' + +const E2E_RESULT_PREFIX = `__TANSTACK_DB_E2E_RESULT__:` +const E2E_RESULT_BASE64_PREFIX = `__TANSTACK_DB_E2E_RESULT_BASE64__:` +const E2E_INPUT_ENV_VAR = `TANSTACK_DB_E2E_INPUT` +const E2E_TRANSPORT_TYPE_TAG = `__tanstack_db_e2e_transport_type__` +const E2E_TRANSPORT_VALUE_TAG = `value` +const execFileAsync = promisify(execFile) + +function toSqlLiteral(value) { + if (value === null || value === undefined) { + return `NULL` + } + + if (typeof value === `number`) { + return Number.isFinite(value) ? String(value) : `NULL` + } + + if (typeof value === `boolean`) { + return value ? `1` : `0` + } + + if (typeof value === `bigint`) { + return value.toString() + } + + const textValue = typeof value === `string` ? value : String(value) + return `'${textValue.replace(/'/g, `''`)}'` +} + +function interpolateSql(sql, params) { + let parameterIndex = 0 + const renderedSql = sql.replace(/\?/g, () => { + const currentParam = params[parameterIndex] + parameterIndex++ + return toSqlLiteral(currentParam) + }) + + if (parameterIndex !== params.length) { + throw new Error( + `SQL interpolation mismatch: used ${parameterIndex} params, received ${params.length}`, + ) + } + + return renderedSql +} + +class SqliteCliDriver { + transactionDbPath = new AsyncLocalStorage() + queue = Promise.resolve() + + constructor(dbPath) { + this.dbPath = dbPath + } + + async exec(sql) { + const activeDbPath = this.transactionDbPath.getStore() + if (activeDbPath) { + await execFileAsync(`sqlite3`, [activeDbPath, sql]) + return + } + + await this.enqueue(async () => { + await execFileAsync(`sqlite3`, [this.dbPath, sql]) + }) + } + + async query(sql, params = []) { + const activeDbPath = this.transactionDbPath.getStore() + const renderedSql = interpolateSql(sql, params) + const queryDbPath = activeDbPath ?? this.dbPath + + const runQuery = async () => { + const { stdout } = await execFileAsync(`sqlite3`, [ + `-json`, + queryDbPath, + renderedSql, + ]) + const trimmedOutput = stdout.trim() + if (!trimmedOutput) { + return [] + } + + return JSON.parse(trimmedOutput) + } + + if (activeDbPath) { + return runQuery() + } + + return this.enqueue(async () => runQuery()) + } + + async run(sql, params = []) { + const activeDbPath = this.transactionDbPath.getStore() + const renderedSql = interpolateSql(sql, params) + const runDbPath = activeDbPath ?? this.dbPath + + if (activeDbPath) { + await execFileAsync(`sqlite3`, [runDbPath, renderedSql]) + return + } + + await this.enqueue(async () => { + await execFileAsync(`sqlite3`, [runDbPath, renderedSql]) + }) + } + + async transaction(fn) { + const activeDbPath = this.transactionDbPath.getStore() + if (activeDbPath) { + return fn(this) + } + + return this.enqueue(async () => { + const txDirectory = mkdtempSync(join(tmpdir(), `db-electron-e2e-tx-`)) + const txDbPath = join(txDirectory, `state.sqlite`) + + if (existsSync(this.dbPath)) { + copyFileSync(this.dbPath, txDbPath) + } + + try { + const txResult = await this.transactionDbPath.run(txDbPath, async () => + fn(this), + ) + if (existsSync(txDbPath)) { + copyFileSync(txDbPath, this.dbPath) + } + return txResult + } finally { + rmSync(txDirectory, { recursive: true, force: true }) + } + }) + } + + enqueue(operation) { + const queuedOperation = this.queue.then(operation, operation) + this.queue = queuedOperation.then( + () => undefined, + () => undefined, + ) + return queuedOperation + } +} + +function parseInputFromEnv() { + const rawInput = process.env[E2E_INPUT_ENV_VAR] + if (!rawInput) { + throw new Error(`Missing ${E2E_INPUT_ENV_VAR}`) + } + + const parsed = JSON.parse(rawInput) + const decoded = decodeTransportValue(parsed) + if (!decoded || typeof decoded !== `object`) { + throw new Error(`Invalid ${E2E_INPUT_ENV_VAR} payload`) + } + + return decoded +} + +function isEncodedTransportValue(value) { + return ( + value && + typeof value === `object` && + typeof value[E2E_TRANSPORT_TYPE_TAG] === `string` + ) +} + +function decodeTransportValue(value) { + if (value === null) { + return null + } + + if ( + typeof value === `string` || + typeof value === `number` || + typeof value === `boolean` + ) { + return value + } + + if (Array.isArray(value)) { + return value.map((item) => decodeTransportValue(item)) + } + + if (isEncodedTransportValue(value)) { + switch (value[E2E_TRANSPORT_TYPE_TAG]) { + case `bigint`: + return BigInt(value[E2E_TRANSPORT_VALUE_TAG]) + case `date`: + return new Date(value[E2E_TRANSPORT_VALUE_TAG]) + case `date_invalid`: + return new Date(Number.NaN) + case `nan`: + return Number.NaN + case `infinity`: + return Number.POSITIVE_INFINITY + case `-infinity`: + return Number.NEGATIVE_INFINITY + default: + break + } + } + + if (typeof value === `object`) { + const decodedObject = {} + for (const [key, objectValue] of Object.entries(value)) { + decodedObject[key] = decodeTransportValue(objectValue) + } + return decodedObject + } + + return value +} + +function printProcessResult(result) { + try { + const serializedResult = Buffer.from(serialize(result)).toString(`base64`) + process.stdout.write(`${E2E_RESULT_BASE64_PREFIX}${serializedResult}\n`) + } catch { + process.stdout.write(`${E2E_RESULT_PREFIX}${JSON.stringify(result)}\n`) + } +} + +function getPreloadPath() { + const currentFile = fileURLToPath(import.meta.url) + return join(dirname(currentFile), `renderer-preload.cjs`) +} + +function getRendererPagePath() { + const currentFile = fileURLToPath(import.meta.url) + return join(dirname(currentFile), `renderer-page.html`) +} + +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + } + } + + return { + name: `Error`, + message: `Unknown runtime error`, + } +} + +function createUnknownCollectionError(collectionId) { + const error = new Error( + `Unknown electron persistence collection "${collectionId}"`, + ) + error.name = `UnknownElectronPersistenceCollectionError` + error.code = `UNKNOWN_COLLECTION` + return error +} + +function createMainPersistence(input, driver) { + const adapter = createSQLiteCorePersistenceAdapter({ + driver, + ...(input.adapterOptions ?? {}), + }) + + if (input.allowAnyCollectionId) { + return { + persistence: { + adapter, + }, + cleanup: () => {}, + } + } + + return { + persistence: { + adapter: { + loadSubset: (collectionId, options, ctx) => { + if (collectionId !== input.collectionId) { + throw createUnknownCollectionError(collectionId) + } + return adapter.loadSubset(collectionId, options, ctx) + }, + applyCommittedTx: (collectionId, tx) => { + if (collectionId !== input.collectionId) { + throw createUnknownCollectionError(collectionId) + } + return adapter.applyCommittedTx(collectionId, tx) + }, + ensureIndex: (collectionId, signature, spec) => { + if (collectionId !== input.collectionId) { + throw createUnknownCollectionError(collectionId) + } + return adapter.ensureIndex(collectionId, signature, spec) + }, + markIndexRemoved: (collectionId, signature) => { + if (collectionId !== input.collectionId) { + throw createUnknownCollectionError(collectionId) + } + return adapter.markIndexRemoved?.(collectionId, signature) + }, + pullSince: (collectionId, fromRowVersion) => { + if (collectionId !== input.collectionId) { + throw createUnknownCollectionError(collectionId) + } + return adapter.pullSince?.(collectionId, fromRowVersion) + }, + }, + }, + cleanup: () => {}, + } +} + +async function run() { + app.commandLine.appendSwitch(`disable-gpu`) + app.commandLine.appendSwitch(`disable-dev-shm-usage`) + app.commandLine.appendSwitch(`no-sandbox`) + + const input = parseInputFromEnv() + const driver = new SqliteCliDriver(input.dbPath) + const mainRuntime = createMainPersistence(input, driver) + const disposeIpc = exposeElectronSQLitePersistence({ + ipcMain, + persistence: mainRuntime.persistence, + channel: input.channel, + }) + + let window + try { + await app.whenReady() + window = new BrowserWindow({ + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + preload: getPreloadPath(), + }, + }) + + const rendererDiagnostics = [] + window.webContents.on( + `console-message`, + (_event, level, message, line, sourceId) => { + rendererDiagnostics.push( + `[console:${String(level)}] ${sourceId}:${String(line)} ${message}`, + ) + }, + ) + window.webContents.on(`preload-error`, (_event, path, error) => { + rendererDiagnostics.push( + `[preload-error] ${path}: ${error?.message ?? `unknown preload error`}`, + ) + }) + + await window.loadFile(getRendererPagePath()) + + const scenarioInputBase64 = Buffer.from( + serialize({ + collectionId: input.collectionId, + allowAnyCollectionId: input.allowAnyCollectionId, + hostKind: input.hostKind, + adapterOptions: input.adapterOptions, + channel: input.channel, + timeoutMs: input.timeoutMs, + scenario: input.scenario, + }), + ).toString(`base64`) + + const hasBridgeApi = await window.webContents.executeJavaScript( + `typeof window.__tanstackDbRuntimeBridge__ === 'object'`, + true, + ) + if (!hasBridgeApi) { + throw new Error( + `Renderer preload bridge is unavailable.\n${rendererDiagnostics.join(`\n`)}`, + ) + } + + let result + try { + result = await window.webContents.executeJavaScript( + `window.__tanstackDbRuntimeBridge__.runScenarioFromBase64('${scenarioInputBase64}')`, + true, + ) + } catch (error) { + const message = error instanceof Error ? error.message : `Unknown error` + throw new Error( + `Renderer scenario execution failed: ${message}\n${rendererDiagnostics.join(`\n`)}`, + ) + } + + return { + ok: true, + result, + } + } finally { + if (window) { + window.destroy() + } + disposeIpc() + mainRuntime.cleanup() + await app.quit() + } +} + +void run() + .then((result) => { + printProcessResult(result) + process.exitCode = 0 + }) + .catch((error) => { + printProcessResult({ + ok: false, + error: serializeError(error), + }) + process.exitCode = 1 + }) diff --git a/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/renderer-page.html b/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/renderer-page.html new file mode 100644 index 000000000..4c51c20cc --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/renderer-page.html @@ -0,0 +1,10 @@ + + + + + TanStack DB Electron Runtime Bridge E2E + + +
runtime bridge e2e
+ + diff --git a/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/renderer-preload.cjs b/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/renderer-preload.cjs new file mode 100644 index 000000000..62899bfd5 --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/renderer-preload.cjs @@ -0,0 +1,118 @@ +const { contextBridge, ipcRenderer } = require(`electron`) +const { deserialize } = require(`node:v8`) +const rendererModulePath = `${__dirname}/../../../dist/cjs/renderer.cjs` +const protocolModulePath = `${__dirname}/../../../dist/cjs/protocol.cjs` +const { createElectronSQLitePersistence } = require(rendererModulePath) +const { DEFAULT_ELECTRON_PERSISTENCE_CHANNEL } = require(protocolModulePath) + +async function runScenario(input) { + const invokeBridge = (channel, request) => + ipcRenderer.invoke(channel, request) + const persistence = createElectronSQLitePersistence({ + ipcRenderer, + channel: input.channel, + timeoutMs: input.timeoutMs, + }) + const adapter = persistence.adapter + + const scenario = input.scenario + switch (scenario.type) { + case `noop`: + return { type: `noop` } + + case `writeTodo`: { + await adapter.applyCommittedTx(input.collectionId, { + txId: scenario.txId, + term: 1, + seq: scenario.seq, + rowVersion: scenario.rowVersion, + mutations: [ + { + type: `insert`, + key: scenario.todo.id, + value: scenario.todo, + }, + ], + }) + return { type: `writeTodo` } + } + + case `loadTodos`: { + const rows = await adapter.loadSubset( + scenario.collectionId ?? input.collectionId, + {}, + ) + return { + type: `loadTodos`, + rows: rows.map((row) => ({ + key: String(row.key), + value: { + id: String(row.value?.id ?? ``), + title: String(row.value?.title ?? ``), + score: Number(row.value?.score ?? 0), + }, + })), + } + } + + case `loadUnknownCollectionError`: { + try { + await adapter.loadSubset(scenario.collectionId, {}) + return { + type: `loadUnknownCollectionError`, + error: { + name: `Error`, + message: `Expected unknown collection error but operation succeeded`, + }, + } + } catch (error) { + if (error instanceof Error) { + return { + type: `loadUnknownCollectionError`, + error: { + name: error.name, + message: error.message, + code: + `code` in error && typeof error.code === `string` + ? error.code + : undefined, + }, + } + } + + return { + type: `loadUnknownCollectionError`, + error: { + name: `Error`, + message: `Unknown error type`, + }, + } + } + } + + case `invokeRequest`: { + const response = await invokeBridge( + input.channel ?? DEFAULT_ELECTRON_PERSISTENCE_CHANNEL, + scenario.request, + ) + return { + type: `invokeRequest`, + response, + } + } + + default: + throw new Error(`Unsupported electron runtime bridge scenario`) + } +} + +function runScenarioFromBase64(serializedInputBase64) { + const serializedInputBuffer = Buffer.from(serializedInputBase64, `base64`) + const input = deserialize(serializedInputBuffer) + return runScenario(input) +} + +contextBridge.exposeInMainWorld(`__tanstackDbRuntimeBridge__`, { + runScenario, + runScenarioFromBase64, +}) diff --git a/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/runtime-bridge-types.ts b/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/runtime-bridge-types.ts new file mode 100644 index 000000000..3ec1283af --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tests/e2e/fixtures/runtime-bridge-types.ts @@ -0,0 +1,93 @@ +import type { + RuntimeBridgeE2EContractError, + RuntimeBridgeE2EContractTodo, +} from '../../../../db-sqlite-persisted-collection-core/tests/contracts/runtime-bridge-e2e-contract' +import type { SQLiteCoreAdapterOptions } from '@tanstack/db-sqlite-persisted-collection-core' +import type { + ElectronPersistenceRequestEnvelope, + ElectronPersistenceResponseEnvelope, +} from '../../../src/protocol' + +export const E2E_RESULT_PREFIX = `__TANSTACK_DB_E2E_RESULT__:` +export const E2E_RESULT_BASE64_PREFIX = `__TANSTACK_DB_E2E_RESULT_BASE64__:` + +export type ElectronRuntimeBridgeHostKind = `core-host` | `node-registry` + +export type ElectronRuntimeBridgeAdapterOptions = Omit< + SQLiteCoreAdapterOptions, + `driver` +> + +export type ElectronRuntimeBridgeScenario = + | { + type: `noop` + } + | { + type: `writeTodo` + todo: RuntimeBridgeE2EContractTodo + txId: string + seq: number + rowVersion: number + } + | { + type: `loadTodos` + collectionId?: string + } + | { + type: `loadUnknownCollectionError` + collectionId: string + } + | { + type: `invokeRequest` + request: ElectronPersistenceRequestEnvelope + } + +export type ElectronRuntimeBridgeInput = { + dbPath: string + collectionId: string + allowAnyCollectionId?: boolean + hostKind?: ElectronRuntimeBridgeHostKind + adapterOptions?: ElectronRuntimeBridgeAdapterOptions + channel?: string + timeoutMs?: number + scenario: ElectronRuntimeBridgeScenario +} + +export type ElectronRuntimeBridgeScenarioResult = + | { + type: `noop` + } + | { + type: `writeTodo` + } + | { + type: `loadTodos` + rows: Array<{ + key: string + value: RuntimeBridgeE2EContractTodo + }> + } + | { + type: `loadUnknownCollectionError` + error: RuntimeBridgeE2EContractError + } + | { + type: `invokeRequest` + response: ElectronPersistenceResponseEnvelope + } + +export type ElectronRuntimeBridgeProcessError = { + name: string + message: string + stack?: string +} + +export type ElectronRuntimeBridgeProcessResult = + | { + ok: true + result: ElectronRuntimeBridgeScenarioResult + } + | { + ok: false + error: ElectronRuntimeBridgeProcessError + } diff --git a/packages/db-electron-sqlite-persisted-collection/tests/electron-ipc.test-d.ts b/packages/db-electron-sqlite-persisted-collection/tests/electron-ipc.test-d.ts new file mode 100644 index 000000000..21648b29a --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tests/electron-ipc.test-d.ts @@ -0,0 +1,61 @@ +import { expectTypeOf, test } from 'vitest' +import { createElectronSQLitePersistence } from '../src' +import type { ElectronPersistenceInvoke } from '../src/protocol' + +test(`renderer persistence requires invoke transport`, () => { + const invoke: ElectronPersistenceInvoke = (_channel, request) => { + switch (request.method) { + case `loadSubset`: + return Promise.resolve({ + v: 1, + requestId: request.requestId, + method: request.method, + ok: true, + result: [], + }) + case `pullSince`: + return Promise.resolve({ + v: 1, + requestId: request.requestId, + method: request.method, + ok: true, + result: { + latestRowVersion: 0, + requiresFullReload: true, + }, + }) + case `getStreamPosition`: + return Promise.resolve({ + v: 1, + requestId: request.requestId, + method: request.method, + ok: true, + result: { + latestTerm: 0, + latestSeq: 0, + latestRowVersion: 0, + }, + }) + default: + return Promise.resolve({ + v: 1, + requestId: request.requestId, + method: request.method, + ok: true, + result: null, + }) + } + } + + const persistence = createElectronSQLitePersistence({ + invoke, + }) + + expectTypeOf(persistence.adapter).toHaveProperty(`loadSubset`) + + createElectronSQLitePersistence({ + invoke, + // @ts-expect-error renderer-side persistence must use invoke transport, not a direct driver + driver: {}, + }) +}) diff --git a/packages/db-electron-sqlite-persisted-collection/tests/electron-ipc.test.ts b/packages/db-electron-sqlite-persisted-collection/tests/electron-ipc.test.ts new file mode 100644 index 000000000..a97131d1b --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tests/electron-ipc.test.ts @@ -0,0 +1,391 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { InvalidPersistedCollectionConfigError } from '@tanstack/db-sqlite-persisted-collection-core' +import { createNodeSQLitePersistence } from '@tanstack/db-node-sqlite-persisted-collection' +import { BetterSqlite3SQLiteDriver } from '../../db-node-sqlite-persisted-collection/src/node-driver' +import { + createElectronSQLitePersistence, + exposeElectronSQLitePersistence, +} from '../src' +import { + DEFAULT_ELECTRON_PERSISTENCE_CHANNEL, + ELECTRON_PERSISTENCE_PROTOCOL_VERSION, +} from '../src/protocol' +import { + createElectronRuntimeBridgeInvoke, + isElectronFullE2EEnabled, +} from './e2e/electron-process-client' +import type { PersistedCollectionPersistence } from '@tanstack/db-sqlite-persisted-collection-core' +import type { + ElectronPersistenceInvoke, + ElectronPersistenceRequestEnvelope, + ElectronPersistenceResponseEnvelope, +} from '../src/protocol' + +type Todo = { + id: string + title: string + score: number +} + +type InvokeHarness = { + invoke: ElectronPersistenceInvoke + close: () => void +} + +type ElectronMainPersistence = PersistedCollectionPersistence< + Record, + string | number +> + +const electronRuntimeBridgeTimeoutMs = isElectronFullE2EEnabled() + ? 45_000 + : 4_000 + +function createFilteredPersistence( + collectionId: string, + allowAnyCollectionId: boolean, + persistence: ElectronMainPersistence, +): ElectronMainPersistence { + if (allowAnyCollectionId) { + return persistence + } + + const baseAdapter = persistence.adapter + const assertKnownCollection = (requestedCollectionId: string) => { + if (requestedCollectionId !== collectionId) { + const error = new Error( + `Unknown electron persistence collection "${requestedCollectionId}"`, + ) + error.name = `UnknownElectronPersistenceCollectionError` + ;(error as Error & { code?: string }).code = `UNKNOWN_COLLECTION` + throw error + } + } + + const adapter: ElectronMainPersistence[`adapter`] = { + loadSubset: (requestedCollectionId, options, ctx) => { + assertKnownCollection(requestedCollectionId) + return baseAdapter.loadSubset(requestedCollectionId, options, ctx) + }, + applyCommittedTx: (requestedCollectionId, tx) => { + assertKnownCollection(requestedCollectionId) + return baseAdapter.applyCommittedTx(requestedCollectionId, tx) + }, + ensureIndex: (requestedCollectionId, signature, spec) => { + assertKnownCollection(requestedCollectionId) + return baseAdapter.ensureIndex(requestedCollectionId, signature, spec) + }, + markIndexRemoved: (requestedCollectionId, signature) => { + assertKnownCollection(requestedCollectionId) + if (!baseAdapter.markIndexRemoved) { + return Promise.resolve() + } + return baseAdapter.markIndexRemoved(requestedCollectionId, signature) + }, + } + + return { + coordinator: persistence.coordinator, + adapter, + } +} + +function createInvokeHarness( + dbPath: string, + collectionId: string, + allowAnyCollectionId: boolean = true, +): InvokeHarness { + if (isElectronFullE2EEnabled()) { + return { + invoke: createElectronRuntimeBridgeInvoke({ + dbPath, + collectionId, + allowAnyCollectionId, + timeoutMs: electronRuntimeBridgeTimeoutMs, + }), + close: () => {}, + } + } + + const driver = new BetterSqlite3SQLiteDriver({ filename: dbPath }) + const persistence = createNodeSQLitePersistence< + Record, + string | number + >({ + database: driver.getDatabase(), + }) + const filteredPersistence = createFilteredPersistence( + collectionId, + allowAnyCollectionId, + persistence, + ) + + let handler: + | (( + event: unknown, + request: ElectronPersistenceRequestEnvelope, + ) => Promise) + | undefined + + const ipcMainLike = { + handle: ( + _channel: string, + listener: ( + event: unknown, + request: ElectronPersistenceRequestEnvelope, + ) => Promise, + ) => { + handler = listener + }, + removeHandler: () => {}, + } + const dispose = exposeElectronSQLitePersistence({ + ipcMain: ipcMainLike, + persistence: filteredPersistence, + }) + + return { + invoke: async (_channel, request) => { + if (!handler) { + throw new Error(`Electron IPC handler was not registered`) + } + return handler(undefined, request) + }, + close: () => { + dispose() + driver.close() + }, + } +} + +const activeCleanupFns: Array<() => void> = [] + +afterEach(() => { + while (activeCleanupFns.length > 0) { + const cleanupFn = activeCleanupFns.pop() + cleanupFn?.() + } +}) + +function createTempDbPath(): string { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-electron-ipc-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + activeCleanupFns.push(() => { + rmSync(tempDirectory, { recursive: true, force: true }) + }) + return dbPath +} + +describe(`electron sqlite persistence bridge`, () => { + it(`round-trips reads and writes through main process`, async () => { + const dbPath = createTempDbPath() + const invokeHarness = createInvokeHarness(dbPath, `todos`) + activeCleanupFns.push(() => invokeHarness.close()) + + const rendererPersistence = createElectronSQLitePersistence({ + invoke: async (channel, request) => { + expect(channel).toBe(DEFAULT_ELECTRON_PERSISTENCE_CHANNEL) + return invokeHarness.invoke(channel, request) + }, + timeoutMs: electronRuntimeBridgeTimeoutMs, + }) + + await rendererPersistence.adapter.applyCommittedTx(`todos`, { + txId: `tx-1`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `1`, + value: { + id: `1`, + title: `From renderer`, + score: 10, + }, + }, + ], + }) + + const rows = await rendererPersistence.adapter.loadSubset(`todos`, {}) + expect(rows).toEqual([ + { + key: `1`, + value: { + id: `1`, + title: `From renderer`, + score: 10, + }, + }, + ]) + }) + + it(`persists data across main process restarts`, async () => { + const dbPath = createTempDbPath() + + if (isElectronFullE2EEnabled()) { + const invoke = createElectronRuntimeBridgeInvoke({ + dbPath, + collectionId: `todos`, + timeoutMs: electronRuntimeBridgeTimeoutMs, + }) + const rendererPersistence = createElectronSQLitePersistence( + { + invoke, + timeoutMs: electronRuntimeBridgeTimeoutMs, + }, + ) + + await rendererPersistence.adapter.applyCommittedTx(`todos`, { + txId: `tx-restart-1`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `persisted`, + value: { + id: `persisted`, + title: `Survives restart`, + score: 42, + }, + }, + ], + }) + + const rows = await rendererPersistence.adapter.loadSubset(`todos`, {}) + expect(rows[0]?.value.title).toBe(`Survives restart`) + return + } + + const invokeHarnessA = createInvokeHarness(dbPath, `todos`) + const rendererPersistenceA = createElectronSQLitePersistence({ + invoke: invokeHarnessA.invoke, + timeoutMs: electronRuntimeBridgeTimeoutMs, + }) + await rendererPersistenceA.adapter.applyCommittedTx(`todos`, { + txId: `tx-restart-1`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `persisted`, + value: { + id: `persisted`, + title: `Survives restart`, + score: 42, + }, + }, + ], + }) + invokeHarnessA.close() + + const invokeHarnessB = createInvokeHarness(dbPath, `todos`) + activeCleanupFns.push(() => invokeHarnessB.close()) + const rendererPersistenceB = createElectronSQLitePersistence({ + invoke: invokeHarnessB.invoke, + timeoutMs: electronRuntimeBridgeTimeoutMs, + }) + const rows = await rendererPersistenceB.adapter.loadSubset(`todos`, {}) + expect(rows[0]?.value.title).toBe(`Survives restart`) + }) + + it(`returns deterministic timeout errors`, async () => { + const neverInvoke: ElectronPersistenceInvoke = async () => + await new Promise(() => {}) + + const rendererPersistence = createElectronSQLitePersistence({ + invoke: neverInvoke, + timeoutMs: 5, + }) + + await expect( + rendererPersistence.adapter.loadSubset(`todos`, {}), + ).rejects.toBeInstanceOf(InvalidPersistedCollectionConfigError) + }) + + it(`returns remote errors for unknown collections`, async () => { + const dbPath = createTempDbPath() + const invokeHarness = createInvokeHarness(dbPath, `known`, false) + activeCleanupFns.push(() => invokeHarness.close()) + const rendererPersistence = createElectronSQLitePersistence({ + invoke: invokeHarness.invoke, + timeoutMs: electronRuntimeBridgeTimeoutMs, + }) + + await expect( + rendererPersistence.adapter.loadSubset(`missing`, {}), + ).rejects.toThrow(`Unknown electron persistence collection`) + }) + + it(`registers and unregisters ipc handlers through thin api`, async () => { + let registeredChannel: string | undefined + let registeredHandler: + | (( + event: unknown, + request: ElectronPersistenceRequestEnvelope, + ) => Promise) + | undefined + const removedChannels: Array = [] + + const fakeIpcMain = { + handle: ( + channel: string, + handler: ( + event: unknown, + request: ElectronPersistenceRequestEnvelope, + ) => Promise, + ) => { + registeredChannel = channel + registeredHandler = handler + }, + removeHandler: (channel: string) => { + removedChannels.push(channel) + }, + } + + const driver = new BetterSqlite3SQLiteDriver({ + filename: createTempDbPath(), + }) + activeCleanupFns.push(() => driver.close()) + const persistence = createNodeSQLitePersistence< + Record, + string | number + >({ + database: driver.getDatabase(), + }) + + const dispose = exposeElectronSQLitePersistence({ + ipcMain: fakeIpcMain, + persistence, + }) + + expect(registeredChannel).toBe(DEFAULT_ELECTRON_PERSISTENCE_CHANNEL) + expect(registeredHandler).toBeDefined() + + const response = await registeredHandler?.(undefined, { + v: ELECTRON_PERSISTENCE_PROTOCOL_VERSION, + requestId: `req-1`, + collectionId: `todos`, + method: `loadSubset`, + payload: { + options: {}, + }, + }) + expect(response).toMatchObject({ + ok: true, + requestId: `req-1`, + method: `loadSubset`, + }) + + dispose() + expect(removedChannels).toEqual([DEFAULT_ELECTRON_PERSISTENCE_CHANNEL]) + }) +}) diff --git a/packages/db-electron-sqlite-persisted-collection/tests/electron-persisted-collection.e2e.test.ts b/packages/db-electron-sqlite-persisted-collection/tests/electron-persisted-collection.e2e.test.ts new file mode 100644 index 000000000..b1710b779 --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tests/electron-persisted-collection.e2e.test.ts @@ -0,0 +1,350 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, afterEach, beforeAll } from 'vitest' +import { createCollection } from '@tanstack/db' +import { persistedCollectionOptions } from '@tanstack/db-sqlite-persisted-collection-core' +import { createNodeSQLitePersistence } from '@tanstack/db-node-sqlite-persisted-collection' +import { BetterSqlite3SQLiteDriver } from '../../db-node-sqlite-persisted-collection/src/node-driver' +import { + createElectronSQLitePersistence, + exposeElectronSQLitePersistence, +} from '../src' +import { generateSeedData } from '../../db-collection-e2e/src/fixtures/seed-data' +import { runPersistedCollectionConformanceSuite } from '../../db-sqlite-persisted-collection-core/tests/contracts/persisted-collection-conformance-contract' +import { + createElectronRuntimeBridgeInvoke, + isElectronFullE2EEnabled, +} from './e2e/electron-process-client' +import type { PersistedTx } from '@tanstack/db-sqlite-persisted-collection-core' +import type { Collection } from '@tanstack/db' +import type { + ElectronPersistenceInvoke, + ElectronPersistenceRequestEnvelope, + ElectronPersistenceResponseEnvelope, +} from '../src/protocol' +import type { + Comment, + E2ETestConfig, + Post, + User, +} from '../../db-collection-e2e/src/types' + +type PersistableRow = { + id: string +} + +type ElectronPersistedCollectionTestConfig = E2ETestConfig +type PersistedCollectionHarness = { + collection: Collection + seedPersisted: (rows: Array) => Promise +} + +type InvokeHarness = { + invoke: ElectronPersistenceInvoke + close: () => void +} + +let config: ElectronPersistedCollectionTestConfig | undefined + +function createInvokeHarness(dbPath: string): InvokeHarness { + if (isElectronFullE2EEnabled()) { + return { + invoke: createElectronRuntimeBridgeInvoke({ + dbPath, + collectionId: `seed`, + allowAnyCollectionId: true, + timeoutMs: 12_000, + }), + close: () => {}, + } + } + + const driver = new BetterSqlite3SQLiteDriver({ filename: dbPath }) + const persistence = createNodeSQLitePersistence< + Record, + string | number + >({ + database: driver.getDatabase(), + }) + let handler: + | (( + event: unknown, + request: ElectronPersistenceRequestEnvelope, + ) => Promise) + | undefined + const dispose = exposeElectronSQLitePersistence({ + ipcMain: { + handle: (_channel, listener) => { + handler = listener + }, + removeHandler: () => {}, + }, + persistence, + }) + + return { + invoke: async (_channel, request) => { + if (!handler) { + throw new Error(`Electron IPC handler not registered`) + } + return handler(undefined, request) + }, + close: () => { + dispose() + driver.close() + }, + } +} + +function createSeedTx( + collectionId: string, + seedSequence: number, + rows: Array, +): PersistedTx { + return { + txId: `seed-${collectionId}-${seedSequence}`, + term: 1, + seq: seedSequence, + rowVersion: seedSequence, + mutations: rows.map((row) => ({ + type: `insert` as const, + key: row.id, + value: row, + })), + } +} + +function createPersistedCollection( + invoke: ElectronPersistenceInvoke, + id: string, + syncMode: `eager` | `on-demand`, +): PersistedCollectionHarness { + const persistence = createElectronSQLitePersistence({ + invoke, + }) + let seedSequence = 0 + const seedPersisted = async (rows: Array): Promise => { + if (rows.length === 0) { + return + } + seedSequence++ + await persistence.adapter.applyCommittedTx( + id, + createSeedTx(id, seedSequence, rows), + ) + } + + const collection = createCollection( + persistedCollectionOptions({ + id, + syncMode, + getKey: (item) => item.id, + persistence, + }), + ) + + return { + collection, + seedPersisted, + } +} + +type PersistedTransactionHandle = { + isPersisted: { + promise: Promise + } +} + +async function waitForPersisted( + transaction: PersistedTransactionHandle, +): Promise { + await transaction.isPersisted.promise +} + +async function seedCollection( + collection: Collection, + rows: Array, +): Promise { + const tx = collection.insert(rows) + await waitForPersisted(tx) +} + +async function insertRowIntoCollections( + collections: ReadonlyArray>, + row: T, +): Promise { + for (const collection of collections) { + const tx = collection.insert(row) + await waitForPersisted(tx) + } +} + +async function updateRowAcrossCollections( + collections: ReadonlyArray>, + id: string, + updates: Partial, +): Promise { + for (const collection of collections) { + if (!collection.has(id)) { + continue + } + const tx = collection.update(id, (draft) => { + Object.assign(draft, updates) + }) + await waitForPersisted(tx) + } +} + +async function deleteRowAcrossCollections( + collections: ReadonlyArray>, + id: string, +): Promise { + for (const collection of collections) { + if (!collection.has(id)) { + continue + } + const tx = collection.delete(id) + await waitForPersisted(tx) + } +} + +beforeAll(async () => { + const tempDirectory = mkdtempSync( + join(tmpdir(), `db-electron-persisted-e2e-`), + ) + const dbPath = join(tempDirectory, `state.sqlite`) + const suiteId = Date.now().toString(36) + const invokeHarness = createInvokeHarness(dbPath) + const seedData = generateSeedData() + + const eagerUsers = createPersistedCollection( + invokeHarness.invoke, + `electron-persisted-users-eager-${suiteId}`, + `eager`, + ) + const eagerPosts = createPersistedCollection( + invokeHarness.invoke, + `electron-persisted-posts-eager-${suiteId}`, + `eager`, + ) + const eagerComments = createPersistedCollection( + invokeHarness.invoke, + `electron-persisted-comments-eager-${suiteId}`, + `eager`, + ) + const onDemandUsers = createPersistedCollection( + invokeHarness.invoke, + `electron-persisted-users-ondemand-${suiteId}`, + `on-demand`, + ) + const onDemandPosts = createPersistedCollection( + invokeHarness.invoke, + `electron-persisted-posts-ondemand-${suiteId}`, + `on-demand`, + ) + const onDemandComments = createPersistedCollection( + invokeHarness.invoke, + `electron-persisted-comments-ondemand-${suiteId}`, + `on-demand`, + ) + + await eagerUsers.collection.preload() + await eagerPosts.collection.preload() + await eagerComments.collection.preload() + + await seedCollection(eagerUsers.collection, seedData.users) + await seedCollection(eagerPosts.collection, seedData.posts) + await seedCollection(eagerComments.collection, seedData.comments) + await onDemandUsers.seedPersisted(seedData.users) + await onDemandPosts.seedPersisted(seedData.posts) + await onDemandComments.seedPersisted(seedData.comments) + + config = { + collections: { + eager: { + users: eagerUsers.collection, + posts: eagerPosts.collection, + comments: eagerComments.collection, + }, + onDemand: { + users: onDemandUsers.collection, + posts: onDemandPosts.collection, + comments: onDemandComments.collection, + }, + }, + mutations: { + insertUser: async (user) => + insertRowIntoCollections( + [eagerUsers.collection, onDemandUsers.collection], + user, + ), + updateUser: async (id, updates) => + updateRowAcrossCollections( + [eagerUsers.collection, onDemandUsers.collection], + id, + updates, + ), + deleteUser: async (id) => + deleteRowAcrossCollections( + [eagerUsers.collection, onDemandUsers.collection], + id, + ), + insertPost: async (post) => + insertRowIntoCollections( + [eagerPosts.collection, onDemandPosts.collection], + post, + ), + }, + setup: async () => {}, + afterEach: async () => { + await onDemandUsers.collection.cleanup() + await onDemandPosts.collection.cleanup() + await onDemandComments.collection.cleanup() + + onDemandUsers.collection.startSyncImmediate() + onDemandPosts.collection.startSyncImmediate() + onDemandComments.collection.startSyncImmediate() + }, + teardown: async () => { + await eagerUsers.collection.cleanup() + await eagerPosts.collection.cleanup() + await eagerComments.collection.cleanup() + await onDemandUsers.collection.cleanup() + await onDemandPosts.collection.cleanup() + await onDemandComments.collection.cleanup() + invokeHarness.close() + rmSync(tempDirectory, { recursive: true, force: true }) + }, + } +}) + +afterEach(async () => { + if (config?.afterEach) { + await config.afterEach() + } +}) + +afterAll(async () => { + if (config) { + await config.teardown() + } +}) + +function getConfig(): Promise { + if (!config) { + throw new Error( + `Electron persisted collection conformance is not initialized`, + ) + } + return Promise.resolve(config) +} + +const conformanceMode = isElectronFullE2EEnabled() + ? `real electron ipc` + : `in-process invoke` + +runPersistedCollectionConformanceSuite( + `electron persisted collection conformance (${conformanceMode})`, + getConfig, +) diff --git a/packages/db-electron-sqlite-persisted-collection/tests/electron-runtime-bridge.e2e.test.ts b/packages/db-electron-sqlite-persisted-collection/tests/electron-runtime-bridge.e2e.test.ts new file mode 100644 index 000000000..ae163bbd4 --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tests/electron-runtime-bridge.e2e.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runRuntimeBridgeE2EContractSuite } from '../../db-sqlite-persisted-collection-core/tests/contracts/runtime-bridge-e2e-contract' +import { runElectronRuntimeBridgeScenario } from './e2e/electron-process-client' +import type { + RuntimeBridgeE2EContractError, + RuntimeBridgeE2EContractHarness, + RuntimeBridgeE2EContractHarnessFactory, + RuntimeBridgeE2EContractTodo, +} from '../../db-sqlite-persisted-collection-core/tests/contracts/runtime-bridge-e2e-contract' +import type { + ElectronRuntimeBridgeInput, + ElectronRuntimeBridgeScenarioResult, +} from './e2e/fixtures/runtime-bridge-types' + +const createHarness: RuntimeBridgeE2EContractHarnessFactory = () => { + const tempDirectory = mkdtempSync( + join(tmpdir(), `db-electron-runtime-bridge-`), + ) + const dbPath = join(tempDirectory, `state.sqlite`) + const collectionId = `todos` + let nextSequence = 1 + + const runScenario = async ( + scenario: ElectronRuntimeBridgeInput[`scenario`], + ): Promise => + runElectronRuntimeBridgeScenario({ + dbPath, + collectionId, + timeoutMs: 4_000, + scenario, + }) + + const harness: RuntimeBridgeE2EContractHarness = { + writeTodoFromClient: async (todo: RuntimeBridgeE2EContractTodo) => { + const result = await runScenario({ + type: `writeTodo`, + todo, + txId: `tx-${nextSequence}`, + seq: nextSequence, + rowVersion: nextSequence, + }) + nextSequence++ + + if (result.type !== `writeTodo`) { + throw new Error(`Unexpected write scenario result: ${result.type}`) + } + }, + loadTodosFromClient: async (targetCollectionId?: string) => { + const result = await runScenario({ + type: `loadTodos`, + collectionId: targetCollectionId, + }) + if (result.type !== `loadTodos`) { + throw new Error(`Unexpected load scenario result: ${result.type}`) + } + return result.rows + }, + loadUnknownCollectionErrorFromClient: + async (): Promise => { + const result = await runScenario({ + type: `loadUnknownCollectionError`, + collectionId: `missing`, + }) + if (result.type !== `loadUnknownCollectionError`) { + throw new Error(`Unexpected error scenario result: ${result.type}`) + } + return result.error + }, + restartHost: async () => { + const result = await runScenario({ + type: `noop`, + }) + if (result.type !== `noop`) { + throw new Error(`Unexpected restart scenario result: ${result.type}`) + } + }, + cleanup: () => { + rmSync(tempDirectory, { recursive: true, force: true }) + }, + } + + return harness +} + +runRuntimeBridgeE2EContractSuite( + `electron runtime bridge e2e (real main/renderer IPC)`, + createHarness, + { + testTimeoutMs: 45_000, + }, +) diff --git a/packages/db-electron-sqlite-persisted-collection/tests/electron-sqlite-core-adapter-contract.test.ts b/packages/db-electron-sqlite-persisted-collection/tests/electron-sqlite-core-adapter-contract.test.ts new file mode 100644 index 000000000..22651f0e3 --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tests/electron-sqlite-core-adapter-contract.test.ts @@ -0,0 +1,115 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createSQLiteCorePersistenceAdapter } from '@tanstack/db-sqlite-persisted-collection-core' +import { runSQLiteCoreAdapterContractSuite } from '../../db-sqlite-persisted-collection-core/tests/contracts/sqlite-core-adapter-contract' +import { BetterSqlite3SQLiteDriver } from '../../db-node-sqlite-persisted-collection/src/node-driver' +import { + createElectronSQLitePersistence, + exposeElectronSQLitePersistence, +} from '../src' +import { + createElectronRuntimeBridgeInvoke, + isElectronFullE2EEnabled, +} from './e2e/electron-process-client' +import type { + SQLiteCoreAdapterContractTodo, + SQLiteCoreAdapterHarnessFactory, +} from '../../db-sqlite-persisted-collection-core/tests/contracts/sqlite-core-adapter-contract' +import type { + ElectronPersistenceInvoke, + ElectronPersistenceResponseEnvelope, +} from '../src/protocol' + +const createHarness: SQLiteCoreAdapterHarnessFactory = (options) => { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-electron-contract-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const runFullE2E = isElectronFullE2EEnabled() + const requestTimeoutMs = runFullE2E ? 45_000 : 2_000 + const driver = new BetterSqlite3SQLiteDriver({ + filename: dbPath, + pragmas: runFullE2E + ? [`journal_mode = DELETE`, `synchronous = NORMAL`, `foreign_keys = ON`] + : undefined, + }) + + let invoke: ElectronPersistenceInvoke + let cleanupInvoke: () => void = () => {} + if (runFullE2E) { + invoke = createElectronRuntimeBridgeInvoke({ + dbPath, + collectionId: `todos`, + allowAnyCollectionId: true, + timeoutMs: requestTimeoutMs, + adapterOptions: options, + }) + } else { + const mainAdapter = createSQLiteCorePersistenceAdapter< + Record, + string | number + >({ + driver, + ...options, + }) + let handler: + | ((event: unknown, request: unknown) => Promise) + | undefined + const dispose = exposeElectronSQLitePersistence({ + ipcMain: { + handle: (_channel, listener) => { + handler = listener as ( + event: unknown, + request: unknown, + ) => Promise + }, + removeHandler: () => {}, + }, + persistence: { + adapter: mainAdapter, + }, + }) + invoke = async (_channel, request) => { + if (!handler) { + throw new Error(`Electron IPC handler not registered`) + } + return handler( + undefined, + request, + ) as Promise + } + cleanupInvoke = () => dispose() + } + + const rendererAdapter = createElectronSQLitePersistence< + SQLiteCoreAdapterContractTodo, + string + >({ + invoke, + timeoutMs: requestTimeoutMs, + }).adapter as ReturnType[`adapter`] + + return { + adapter: rendererAdapter, + driver, + cleanup: () => { + try { + cleanupInvoke() + } finally { + try { + driver.close() + } finally { + rmSync(tempDirectory, { recursive: true, force: true }) + } + } + }, + } +} + +const electronContractMode = isElectronFullE2EEnabled() + ? `real electron e2e invoke` + : `in-process invoke` + +runSQLiteCoreAdapterContractSuite( + `SQLiteCorePersistenceAdapter contract over electron IPC bridge (${electronContractMode})`, + createHarness, +) diff --git a/packages/db-electron-sqlite-persisted-collection/tsconfig.docs.json b/packages/db-electron-sqlite-persisted-collection/tsconfig.docs.json new file mode 100644 index 000000000..3fd384ad1 --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tsconfig.docs.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@tanstack/db": ["../db/src"], + "@tanstack/db-node-sqlite-persisted-collection": [ + "../db-node-sqlite-persisted-collection/src" + ], + "@tanstack/db-sqlite-persisted-collection-core": [ + "../db-sqlite-persisted-collection-core/src" + ] + } + }, + "include": ["src"] +} diff --git a/packages/db-electron-sqlite-persisted-collection/tsconfig.json b/packages/db-electron-sqlite-persisted-collection/tsconfig.json new file mode 100644 index 000000000..b07723d02 --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "paths": { + "@tanstack/db": ["../db/src"], + "@tanstack/db-ivm": ["../db-ivm/src"], + "@tanstack/db-node-sqlite-persisted-collection": [ + "../db-node-sqlite-persisted-collection/src" + ], + "@tanstack/db-sqlite-persisted-collection-core": [ + "../db-sqlite-persisted-collection-core/src" + ] + } + }, + "include": [ + "src", + "tests/**/*.test.ts", + "tests/**/*.test-d.ts", + "vite.config.ts", + "vitest.e2e.config.ts" + ], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/db-electron-sqlite-persisted-collection/vite.config.ts b/packages/db-electron-sqlite-persisted-collection/vite.config.ts new file mode 100644 index 000000000..872ef9ccf --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const runElectronFullE2E = process.env.TANSTACK_DB_ELECTRON_E2E_ALL === `1` + +const config = defineConfig({ + test: { + name: packageJson.name, + include: [`tests/**/*.test.ts`], + exclude: [`tests/**/*.e2e.test.ts`], + environment: `node`, + fileParallelism: !runElectronFullE2E, + testTimeout: runElectronFullE2E ? 120_000 : undefined, + hookTimeout: runElectronFullE2E ? 180_000 : undefined, + coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, + typecheck: { + enabled: true, + include: [`tests/**/*.test.ts`, `tests/**/*.test-d.ts`], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: [`./src/index.ts`, `./src/main.ts`, `./src/renderer.ts`], + srcDir: `./src`, + }), +) diff --git a/packages/db-electron-sqlite-persisted-collection/vitest.e2e.config.ts b/packages/db-electron-sqlite-persisted-collection/vitest.e2e.config.ts new file mode 100644 index 000000000..70fe559f0 --- /dev/null +++ b/packages/db-electron-sqlite-persisted-collection/vitest.e2e.config.ts @@ -0,0 +1,35 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +const packageDirectory = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + resolve: { + alias: { + '@tanstack/db': resolve(packageDirectory, `../db/src`), + '@tanstack/db-ivm': resolve(packageDirectory, `../db-ivm/src`), + '@tanstack/db-node-sqlite-persisted-collection': resolve( + packageDirectory, + `../db-node-sqlite-persisted-collection/src`, + ), + '@tanstack/db-sqlite-persisted-collection-core': resolve( + packageDirectory, + `../db-sqlite-persisted-collection-core/src`, + ), + }, + }, + test: { + include: [`tests/**/*.e2e.test.ts`], + environment: `node`, + fileParallelism: false, + testTimeout: 60_000, + hookTimeout: 120_000, + typecheck: { + enabled: false, + }, + coverage: { + enabled: false, + }, + }, +}) diff --git a/packages/db-node-sqlite-persisted-collection/README.md b/packages/db-node-sqlite-persisted-collection/README.md new file mode 100644 index 000000000..d13b9224b --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/README.md @@ -0,0 +1,49 @@ +# @tanstack/db-node-sqlite-persisted-collection + +Thin Node SQLite persistence for TanStack DB. + +## Public API + +- `createNodeSQLitePersistence(...)` +- `persistedCollectionOptions(...)` (re-exported from core) + +## Quick start + +```ts +import { createCollection } from '@tanstack/db' +import { + createNodeSQLitePersistence, + persistedCollectionOptions, +} from '@tanstack/db-node-sqlite-persisted-collection' +import Database from 'better-sqlite3' + +type Todo = { + id: string + title: string + completed: boolean +} + +// You own database lifecycle directly. +const database = new Database(`./tanstack-db.sqlite`) + +// One shared persistence instance for the whole database. +const persistence = createNodeSQLitePersistence({ + database, +}) + +export const todosCollection = createCollection( + persistedCollectionOptions({ + id: `todos`, + getKey: (todo) => todo.id, + persistence, + schemaVersion: 1, // Per-collection schema version + }), +) +``` + +## Notes + +- `createNodeSQLitePersistence` is shared across collections; it resolves + mode-specific behavior (`sync-present` vs `sync-absent`) automatically. +- `schemaVersion` is specified per collection via `persistedCollectionOptions`. +- Call `database.close()` when your app shuts down. diff --git a/packages/db-node-sqlite-persisted-collection/e2e/node-persisted-collection.e2e.test.ts b/packages/db-node-sqlite-persisted-collection/e2e/node-persisted-collection.e2e.test.ts new file mode 100644 index 000000000..6331e31cf --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/e2e/node-persisted-collection.e2e.test.ts @@ -0,0 +1,263 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, afterEach, beforeAll } from 'vitest' +import { createCollection } from '@tanstack/db' +import BetterSqlite3 from 'better-sqlite3' +import { createNodeSQLitePersistence, persistedCollectionOptions } from '../src' +import { generateSeedData } from '../../db-collection-e2e/src/fixtures/seed-data' +import { runPersistedCollectionConformanceSuite } from '../../db-sqlite-persisted-collection-core/tests/contracts/persisted-collection-conformance-contract' +import type { Collection } from '@tanstack/db' +import type { + Comment, + E2ETestConfig, + Post, + User, +} from '../../db-collection-e2e/src/types' + +type PersistableRow = { + id: string +} + +type NodePersistedCollectionTestConfig = E2ETestConfig +type PersistedCollectionHarness = { + collection: Collection + seedPersisted: (rows: Array) => Promise +} + +let config: NodePersistedCollectionTestConfig + +function createPersistedCollection( + database: InstanceType, + id: string, + syncMode: `eager` | `on-demand`, +): PersistedCollectionHarness { + const persistence = createNodeSQLitePersistence({ + database, + }) + let seedTxSequence = 0 + const seedPersisted = async (rows: Array): Promise => { + if (rows.length === 0) { + return + } + seedTxSequence++ + await persistence.adapter.applyCommittedTx(id, { + txId: `seed-${id}-${seedTxSequence}`, + term: 1, + seq: seedTxSequence, + rowVersion: seedTxSequence, + mutations: rows.map((row) => ({ + type: `insert` as const, + key: row.id, + value: row, + })), + }) + } + + const collection = createCollection( + persistedCollectionOptions({ + id, + syncMode, + getKey: (item) => item.id, + persistence, + }), + ) + + return { + collection, + seedPersisted, + } +} + +type PersistedTransactionHandle = { + isPersisted: { + promise: Promise + } +} + +async function waitForPersisted( + transaction: PersistedTransactionHandle, +): Promise { + await transaction.isPersisted.promise +} + +async function seedCollection( + collection: Collection, + rows: Array, +): Promise { + const tx = collection.insert(rows) + await waitForPersisted(tx) +} + +async function insertRowIntoCollections( + collections: ReadonlyArray>, + row: T, +): Promise { + for (const collection of collections) { + const tx = collection.insert(row) + await waitForPersisted(tx) + } +} + +async function updateRowAcrossCollections( + collections: ReadonlyArray>, + id: string, + updates: Partial, +): Promise { + for (const collection of collections) { + if (!collection.has(id)) { + continue + } + const tx = collection.update(id, (draft) => { + Object.assign(draft, updates) + }) + await waitForPersisted(tx) + } +} + +async function deleteRowAcrossCollections( + collections: ReadonlyArray>, + id: string, +): Promise { + for (const collection of collections) { + if (!collection.has(id)) { + continue + } + const tx = collection.delete(id) + await waitForPersisted(tx) + } +} + +beforeAll(async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-node-persisted-e2e-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const suiteId = Date.now().toString(36) + const database = new BetterSqlite3(dbPath) + const seedData = generateSeedData() + + const eagerUsers = createPersistedCollection( + database, + `node-persisted-users-eager-${suiteId}`, + `eager`, + ) + const eagerPosts = createPersistedCollection( + database, + `node-persisted-posts-eager-${suiteId}`, + `eager`, + ) + const eagerComments = createPersistedCollection( + database, + `node-persisted-comments-eager-${suiteId}`, + `eager`, + ) + + const onDemandUsers = createPersistedCollection( + database, + `node-persisted-users-ondemand-${suiteId}`, + `on-demand`, + ) + const onDemandPosts = createPersistedCollection( + database, + `node-persisted-posts-ondemand-${suiteId}`, + `on-demand`, + ) + const onDemandComments = createPersistedCollection( + database, + `node-persisted-comments-ondemand-${suiteId}`, + `on-demand`, + ) + + await Promise.all([ + eagerUsers.collection.preload(), + eagerPosts.collection.preload(), + eagerComments.collection.preload(), + ]) + + await seedCollection(eagerUsers.collection, seedData.users) + await seedCollection(eagerPosts.collection, seedData.posts) + await seedCollection(eagerComments.collection, seedData.comments) + await onDemandUsers.seedPersisted(seedData.users) + await onDemandPosts.seedPersisted(seedData.posts) + await onDemandComments.seedPersisted(seedData.comments) + + config = { + collections: { + eager: { + users: eagerUsers.collection, + posts: eagerPosts.collection, + comments: eagerComments.collection, + }, + onDemand: { + users: onDemandUsers.collection, + posts: onDemandPosts.collection, + comments: onDemandComments.collection, + }, + }, + mutations: { + insertUser: async (user) => + insertRowIntoCollections( + [eagerUsers.collection, onDemandUsers.collection], + user, + ), + updateUser: async (id, updates) => + updateRowAcrossCollections( + [eagerUsers.collection, onDemandUsers.collection], + id, + updates, + ), + deleteUser: async (id) => + deleteRowAcrossCollections( + [eagerUsers.collection, onDemandUsers.collection], + id, + ), + insertPost: async (post) => + insertRowIntoCollections( + [eagerPosts.collection, onDemandPosts.collection], + post, + ), + }, + setup: async () => {}, + afterEach: async () => { + await Promise.all([ + onDemandUsers.collection.cleanup(), + onDemandPosts.collection.cleanup(), + onDemandComments.collection.cleanup(), + ]) + + onDemandUsers.collection.startSyncImmediate() + onDemandPosts.collection.startSyncImmediate() + onDemandComments.collection.startSyncImmediate() + }, + teardown: async () => { + await Promise.all([ + eagerUsers.collection.cleanup(), + eagerPosts.collection.cleanup(), + eagerComments.collection.cleanup(), + onDemandUsers.collection.cleanup(), + onDemandPosts.collection.cleanup(), + onDemandComments.collection.cleanup(), + ]) + database.close() + rmSync(tempDirectory, { recursive: true, force: true }) + }, + } +}) + +afterEach(async () => { + if (config.afterEach) { + await config.afterEach() + } +}) + +afterAll(async () => { + await config.teardown() +}) + +function getConfig(): Promise { + return Promise.resolve(config) +} + +runPersistedCollectionConformanceSuite( + `node persisted collection conformance`, + getConfig, +) diff --git a/packages/db-node-sqlite-persisted-collection/package.json b/packages/db-node-sqlite-persisted-collection/package.json new file mode 100644 index 000000000..727b95159 --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/package.json @@ -0,0 +1,60 @@ +{ + "name": "@tanstack/db-node-sqlite-persisted-collection", + "version": "0.1.0", + "description": "Node SQLite persisted collection adapter for TanStack DB", + "author": "TanStack Team", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/db.git", + "directory": "packages/db-node-sqlite-persisted-collection" + }, + "homepage": "https://tanstack.com/db", + "keywords": [ + "sqlite", + "node", + "persistence", + "optimistic", + "typescript" + ], + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "eslint . --fix", + "test": "vitest --run", + "test:e2e": "pnpm --filter @tanstack/db-ivm build && pnpm --filter @tanstack/db build && pnpm --filter @tanstack/db-sqlite-persisted-collection-core build && pnpm --filter @tanstack/db-node-sqlite-persisted-collection build && vitest --config vitest.e2e.config.ts --run" + }, + "type": "module", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@tanstack/db-sqlite-persisted-collection-core": "workspace:*", + "better-sqlite3": "^12.6.2" + }, + "peerDependencies": { + "typescript": ">=4.7" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@vitest/coverage-istanbul": "^3.2.4" + } +} diff --git a/packages/db-node-sqlite-persisted-collection/src/index.ts b/packages/db-node-sqlite-persisted-collection/src/index.ts new file mode 100644 index 000000000..0e649333f --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/src/index.ts @@ -0,0 +1,11 @@ +export { createNodeSQLitePersistence } from './node-persistence' +export type { + BetterSqlite3Database, + NodeSQLitePersistenceOptions, + NodeSQLiteSchemaMismatchPolicy, +} from './node-persistence' +export { persistedCollectionOptions } from '@tanstack/db-sqlite-persisted-collection-core' +export type { + PersistedCollectionCoordinator, + PersistedCollectionPersistence, +} from '@tanstack/db-sqlite-persisted-collection-core' diff --git a/packages/db-node-sqlite-persisted-collection/src/node-driver.ts b/packages/db-node-sqlite-persisted-collection/src/node-driver.ts new file mode 100644 index 000000000..b849a9f3d --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/src/node-driver.ts @@ -0,0 +1,246 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import BetterSqlite3 from 'better-sqlite3' +import { InvalidPersistedCollectionConfigError } from '@tanstack/db-sqlite-persisted-collection-core' +import type { SQLiteDriver } from '@tanstack/db-sqlite-persisted-collection-core' + +const DEFAULT_PRAGMAS = [ + `journal_mode = WAL`, + `synchronous = NORMAL`, + `foreign_keys = ON`, +] as const + +const INVALID_PRAGMA_PATTERN = /(;|--|\/\*)/ + +export type BetterSqlite3Database = InstanceType +export type BetterSqlite3OpenOptions = ConstructorParameters< + typeof BetterSqlite3 +>[1] + +type BetterSqlite3ExistingDatabaseOptions = { + database: BetterSqlite3Database + pragmas?: ReadonlyArray +} + +type BetterSqlite3OpenFileOptions = { + filename: string + options?: BetterSqlite3OpenOptions + pragmas?: ReadonlyArray +} + +export type BetterSqlite3DriverOptions = + | BetterSqlite3ExistingDatabaseOptions + | BetterSqlite3OpenFileOptions + +type TransactionContext = { + depth: number +} + +function assertTransactionCallbackHasDriverArg( + fn: (transactionDriver: SQLiteDriver) => Promise, +): void { + if (fn.length > 0) { + return + } + + throw new InvalidPersistedCollectionConfigError( + `SQLiteDriver.transaction callback must accept the transaction driver argument`, + ) +} + +function hasExistingDatabase( + options: BetterSqlite3DriverOptions, +): options is BetterSqlite3ExistingDatabaseOptions { + return `database` in options +} + +export class BetterSqlite3SQLiteDriver implements SQLiteDriver { + private readonly database: BetterSqlite3Database + private readonly ownsDatabase: boolean + private readonly transactionContext = + new AsyncLocalStorage() + private queue: Promise = Promise.resolve() + private nextSavepointId = 1 + + constructor(options: BetterSqlite3DriverOptions) { + if (hasExistingDatabase(options)) { + this.database = options.database + this.ownsDatabase = false + this.applyPragmas(options.pragmas ?? DEFAULT_PRAGMAS) + return + } + + if (options.filename.trim().length === 0) { + throw new InvalidPersistedCollectionConfigError( + `Node SQLite driver filename cannot be empty`, + ) + } + + this.database = new BetterSqlite3(options.filename, options.options) + this.ownsDatabase = true + this.applyPragmas(options.pragmas ?? DEFAULT_PRAGMAS) + } + + async exec(sql: string): Promise { + if (this.isInsideTransaction()) { + this.database.exec(sql) + return + } + + await this.enqueue(() => { + this.database.exec(sql) + }) + } + + async query( + sql: string, + params: ReadonlyArray = [], + ): Promise> { + if (this.isInsideTransaction()) { + return this.executeQuery(sql, params) + } + + return this.enqueue(() => this.executeQuery(sql, params)) + } + + async run(sql: string, params: ReadonlyArray = []): Promise { + if (this.isInsideTransaction()) { + this.executeRun(sql, params) + return + } + + await this.enqueue(() => { + this.executeRun(sql, params) + }) + } + + async transaction( + fn: (transactionDriver: SQLiteDriver) => Promise, + ): Promise { + assertTransactionCallbackHasDriverArg(fn) + + if (this.isInsideTransaction()) { + return this.runNestedTransaction(fn) + } + + return this.enqueue(async () => { + this.database.exec(`BEGIN IMMEDIATE`) + try { + const result = await this.transactionContext.run( + { depth: 1 }, + async () => fn(this), + ) + this.database.exec(`COMMIT`) + return result + } catch (error) { + try { + this.database.exec(`ROLLBACK`) + } catch { + // Keep the original transaction error as the primary failure. + } + throw error + } + }) + } + + async transactionWithDriver( + fn: (transactionDriver: SQLiteDriver) => Promise, + ): Promise { + return this.transaction(fn) + } + + close(): void { + if (!this.ownsDatabase) { + return + } + + this.database.close() + } + + getDatabase(): BetterSqlite3Database { + return this.database + } + + private applyPragmas(pragmas: ReadonlyArray): void { + for (const pragma of pragmas) { + const trimmedPragma = pragma.trim() + if (trimmedPragma.length === 0) { + continue + } + + if (INVALID_PRAGMA_PATTERN.test(trimmedPragma)) { + throw new InvalidPersistedCollectionConfigError( + `Invalid SQLite PRAGMA: "${pragma}"`, + ) + } + + this.database.exec(`PRAGMA ${trimmedPragma}`) + } + } + + private isInsideTransaction(): boolean { + return this.transactionContext.getStore() !== undefined + } + + private executeQuery( + sql: string, + params: ReadonlyArray, + ): ReadonlyArray { + const statement = this.database.prepare(sql) + if (params.length === 0) { + return statement.all() as ReadonlyArray + } + + return statement.all(...params) as ReadonlyArray + } + + private executeRun(sql: string, params: ReadonlyArray): void { + const statement = this.database.prepare(sql) + if (params.length === 0) { + statement.run() + return + } + + statement.run(...params) + } + + private enqueue(operation: () => Promise | T): Promise { + const queuedOperation = this.queue.then(operation, operation) + this.queue = queuedOperation.then( + () => undefined, + () => undefined, + ) + return queuedOperation + } + + private async runNestedTransaction( + fn: (transactionDriver: SQLiteDriver) => Promise, + ): Promise { + const context = this.transactionContext.getStore() + if (!context) { + return fn(this) + } + + const savepointName = `tsdb_sp_${this.nextSavepointId}` + this.nextSavepointId++ + this.database.exec(`SAVEPOINT ${savepointName}`) + + try { + const result = await this.transactionContext.run( + { depth: context.depth + 1 }, + async () => fn(this), + ) + this.database.exec(`RELEASE SAVEPOINT ${savepointName}`) + return result + } catch (error) { + this.database.exec(`ROLLBACK TO SAVEPOINT ${savepointName}`) + this.database.exec(`RELEASE SAVEPOINT ${savepointName}`) + throw error + } + } +} + +export function createBetterSqlite3Driver( + options: BetterSqlite3DriverOptions, +): BetterSqlite3SQLiteDriver { + return new BetterSqlite3SQLiteDriver(options) +} diff --git a/packages/db-node-sqlite-persisted-collection/src/node-persistence.ts b/packages/db-node-sqlite-persisted-collection/src/node-persistence.ts new file mode 100644 index 000000000..993a1418a --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/src/node-persistence.ts @@ -0,0 +1,169 @@ +import { + SingleProcessCoordinator, + createSQLiteCorePersistenceAdapter, +} from '@tanstack/db-sqlite-persisted-collection-core' +import { BetterSqlite3SQLiteDriver } from './node-driver' +import type { + PersistedCollectionCoordinator, + PersistedCollectionMode, + PersistedCollectionPersistence, + SQLiteCoreAdapterOptions, + SQLiteDriver, +} from '@tanstack/db-sqlite-persisted-collection-core' +import type { BetterSqlite3Database } from './node-driver' + +export type { BetterSqlite3Database } from './node-driver' + +type NodeSQLiteCoreSchemaMismatchPolicy = + | `sync-present-reset` + | `sync-absent-error` + | `reset` + +export type NodeSQLiteSchemaMismatchPolicy = + | NodeSQLiteCoreSchemaMismatchPolicy + | `throw` + +type NodeSQLitePersistenceBaseOptions = Omit< + SQLiteCoreAdapterOptions, + `driver` | `schemaVersion` | `schemaMismatchPolicy` +> & { + database: BetterSqlite3Database + pragmas?: ReadonlyArray + coordinator?: PersistedCollectionCoordinator + schemaMismatchPolicy?: NodeSQLiteSchemaMismatchPolicy +} + +export type NodeSQLitePersistenceOptions = NodeSQLitePersistenceBaseOptions + +function normalizeSchemaMismatchPolicy( + policy: NodeSQLiteSchemaMismatchPolicy, +): NodeSQLiteCoreSchemaMismatchPolicy { + if (policy === `throw`) { + return `sync-absent-error` + } + + return policy +} + +function resolveSchemaMismatchPolicy( + explicitPolicy: NodeSQLiteSchemaMismatchPolicy | undefined, + mode: PersistedCollectionMode, +): NodeSQLiteCoreSchemaMismatchPolicy { + if (explicitPolicy) { + return normalizeSchemaMismatchPolicy(explicitPolicy) + } + + return mode === `sync-present` ? `sync-present-reset` : `sync-absent-error` +} + +function createAdapterCacheKey( + schemaMismatchPolicy: NodeSQLiteCoreSchemaMismatchPolicy, + schemaVersion: number | undefined, +): string { + const schemaVersionKey = + schemaVersion === undefined ? `schema:default` : `schema:${schemaVersion}` + return `${schemaMismatchPolicy}|${schemaVersionKey}` +} + +function createInternalSQLiteDriver( + options: NodeSQLitePersistenceOptions, +): SQLiteDriver { + return new BetterSqlite3SQLiteDriver({ + database: options.database, + ...(options.pragmas ? { pragmas: options.pragmas } : {}), + }) +} + +function resolveAdapterBaseOptions( + options: NodeSQLitePersistenceOptions, +): Omit< + SQLiteCoreAdapterOptions, + `driver` | `schemaVersion` | `schemaMismatchPolicy` +> { + return { + appliedTxPruneMaxRows: options.appliedTxPruneMaxRows, + appliedTxPruneMaxAgeSeconds: options.appliedTxPruneMaxAgeSeconds, + pullSinceReloadThreshold: options.pullSinceReloadThreshold, + } +} + +/** + * Creates a shared SQLite persistence instance that can be reused by many + * collections on the same database. Collection-specific schema versions are + * resolved by `persistedCollectionOptions` via `resolvePersistenceForCollection`. + */ +export function createNodeSQLitePersistence< + T extends object, + TKey extends string | number = string | number, +>( + options: NodeSQLitePersistenceOptions, +): PersistedCollectionPersistence { + const { coordinator, schemaMismatchPolicy } = options + const driver = createInternalSQLiteDriver(options) + const adapterBaseOptions = resolveAdapterBaseOptions(options) + const resolvedCoordinator = coordinator ?? new SingleProcessCoordinator() + const adapterCache = new Map< + string, + ReturnType< + typeof createSQLiteCorePersistenceAdapter< + Record, + string | number + > + > + >() + + const getAdapterForCollection = ( + mode: PersistedCollectionMode, + schemaVersion: number | undefined, + ) => { + const resolvedSchemaMismatchPolicy = resolveSchemaMismatchPolicy( + schemaMismatchPolicy, + mode, + ) + const cacheKey = createAdapterCacheKey( + resolvedSchemaMismatchPolicy, + schemaVersion, + ) + const cachedAdapter = adapterCache.get(cacheKey) + if (cachedAdapter) { + return cachedAdapter + } + + const adapter = createSQLiteCorePersistenceAdapter< + Record, + string | number + >({ + ...adapterBaseOptions, + driver, + schemaMismatchPolicy: resolvedSchemaMismatchPolicy, + ...(schemaVersion === undefined ? {} : { schemaVersion }), + }) + adapterCache.set(cacheKey, adapter) + return adapter + } + + const createCollectionPersistence = ( + mode: PersistedCollectionMode, + schemaVersion: number | undefined, + ): PersistedCollectionPersistence => ({ + adapter: getAdapterForCollection( + mode, + schemaVersion, + ) as unknown as PersistedCollectionPersistence[`adapter`], + coordinator: resolvedCoordinator, + }) + + const defaultPersistence = createCollectionPersistence( + `sync-absent`, + undefined, + ) + + return { + ...defaultPersistence, + resolvePersistenceForCollection: ({ mode, schemaVersion }) => + createCollectionPersistence(mode, schemaVersion), + // Backward compatible fallback for older callers. + resolvePersistenceForMode: (mode) => + createCollectionPersistence(mode, undefined), + } +} diff --git a/packages/db-node-sqlite-persisted-collection/tests/node-driver.test.ts b/packages/db-node-sqlite-persisted-collection/tests/node-driver.test.ts new file mode 100644 index 000000000..2979b9983 --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/tests/node-driver.test.ts @@ -0,0 +1,25 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runSQLiteDriverContractSuite } from '../../db-sqlite-persisted-collection-core/tests/contracts/sqlite-driver-contract' +import { BetterSqlite3SQLiteDriver } from '../src/node-driver' +import type { SQLiteDriverContractHarness } from '../../db-sqlite-persisted-collection-core/tests/contracts/sqlite-driver-contract' + +function createDriverHarness(): SQLiteDriverContractHarness { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-node-sqlite-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const driver = new BetterSqlite3SQLiteDriver({ filename: dbPath }) + + return { + driver, + cleanup: () => { + try { + driver.close() + } finally { + rmSync(tempDirectory, { recursive: true, force: true }) + } + }, + } +} + +runSQLiteDriverContractSuite(`better-sqlite3 node driver`, createDriverHarness) diff --git a/packages/db-node-sqlite-persisted-collection/tests/node-persistence.test.ts b/packages/db-node-sqlite-persisted-collection/tests/node-persistence.test.ts new file mode 100644 index 000000000..c4f3a5f17 --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/tests/node-persistence.test.ts @@ -0,0 +1,225 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import BetterSqlite3 from 'better-sqlite3' +import { describe, expect, it } from 'vitest' +import { createNodeSQLitePersistence, persistedCollectionOptions } from '../src' +import { BetterSqlite3SQLiteDriver } from '../src/node-driver' +import { SingleProcessCoordinator } from '../../db-sqlite-persisted-collection-core/src' +import { runRuntimePersistenceContractSuite } from '../../db-sqlite-persisted-collection-core/tests/contracts/runtime-persistence-contract' +import type { + RuntimePersistenceContractTodo, + RuntimePersistenceDatabaseHarness, +} from '../../db-sqlite-persisted-collection-core/tests/contracts/runtime-persistence-contract' + +function createRuntimeDatabaseHarness(): RuntimePersistenceDatabaseHarness { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-node-persistence-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const drivers = new Set() + + return { + createDriver: () => { + const driver = new BetterSqlite3SQLiteDriver({ filename: dbPath }) + drivers.add(driver) + return driver + }, + cleanup: () => { + for (const driver of drivers) { + try { + driver.close() + } catch { + // ignore cleanup errors from already-closed handles + } + } + drivers.clear() + rmSync(tempDirectory, { recursive: true, force: true }) + }, + } +} + +runRuntimePersistenceContractSuite(`node runtime persistence helpers`, { + createDatabaseHarness: createRuntimeDatabaseHarness, + createAdapter: (driver) => + createNodeSQLitePersistence({ + database: (driver as BetterSqlite3SQLiteDriver).getDatabase(), + }).adapter, + createPersistence: (driver, coordinator) => + createNodeSQLitePersistence({ + database: (driver as BetterSqlite3SQLiteDriver).getDatabase(), + coordinator, + }), + createCoordinator: () => new SingleProcessCoordinator(), +}) + +describe(`node persistence helpers`, () => { + it(`defaults coordinator to SingleProcessCoordinator`, () => { + const runtimeHarness = createRuntimeDatabaseHarness() + const driver = runtimeHarness.createDriver() + try { + const persistence = createNodeSQLitePersistence({ + database: (driver as BetterSqlite3SQLiteDriver).getDatabase(), + }) + expect(persistence.coordinator).toBeInstanceOf(SingleProcessCoordinator) + } finally { + runtimeHarness.cleanup() + } + }) + + it(`allows overriding the default coordinator`, () => { + const runtimeHarness = createRuntimeDatabaseHarness() + const driver = runtimeHarness.createDriver() + try { + const coordinator = new SingleProcessCoordinator() + const persistence = createNodeSQLitePersistence({ + database: (driver as BetterSqlite3SQLiteDriver).getDatabase(), + coordinator, + }) + + expect(persistence.coordinator).toBe(coordinator) + } finally { + runtimeHarness.cleanup() + } + }) + + it(`accepts a bare better-sqlite3 database handle`, async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-node-direct-db-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const collectionId = `todos` + const database = new BetterSqlite3(dbPath) + + try { + const persistence = createNodeSQLitePersistence< + RuntimePersistenceContractTodo, + string + >({ + database, + }) + + await persistence.adapter.applyCommittedTx(collectionId, { + txId: `tx-direct-db-1`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `1`, + value: { + id: `1`, + title: `from raw database`, + score: 1, + }, + }, + ], + }) + + const rows = await persistence.adapter.loadSubset(collectionId, {}) + expect(rows).toEqual([ + { + key: `1`, + value: { + id: `1`, + title: `from raw database`, + score: 1, + }, + }, + ]) + } finally { + database.close() + rmSync(tempDirectory, { recursive: true, force: true }) + } + }) + + it(`infers schema policy from sync mode`, async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-node-schema-infer-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const collectionId = `todos` + const firstDatabase = new BetterSqlite3(dbPath) + + try { + const firstPersistence = createNodeSQLitePersistence< + RuntimePersistenceContractTodo, + string + >({ + database: firstDatabase, + }) + const firstCollectionOptions = persistedCollectionOptions< + RuntimePersistenceContractTodo, + string + >({ + id: collectionId, + schemaVersion: 1, + getKey: (todo) => todo.id, + persistence: firstPersistence, + }) + + await firstCollectionOptions.persistence.adapter.applyCommittedTx( + collectionId, + { + txId: `tx-1`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `1`, + value: { + id: `1`, + title: `before mismatch`, + score: 1, + }, + }, + ], + }, + ) + } finally { + firstDatabase.close() + } + + const secondDatabase = new BetterSqlite3(dbPath) + try { + const secondPersistence = createNodeSQLitePersistence< + RuntimePersistenceContractTodo, + string + >({ + database: secondDatabase, + }) + const syncAbsentOptions = persistedCollectionOptions< + RuntimePersistenceContractTodo, + string + >({ + id: collectionId, + schemaVersion: 2, + getKey: (todo) => todo.id, + persistence: secondPersistence, + }) + await expect( + syncAbsentOptions.persistence.adapter.loadSubset(collectionId, {}), + ).rejects.toThrow(`Schema version mismatch`) + + const syncPresentOptions = persistedCollectionOptions< + RuntimePersistenceContractTodo, + string + >({ + id: collectionId, + schemaVersion: 2, + getKey: (todo) => todo.id, + sync: { + sync: ({ markReady }) => { + markReady() + }, + }, + persistence: secondPersistence, + }) + const rows = await syncPresentOptions.persistence.adapter.loadSubset( + collectionId, + {}, + ) + expect(rows).toEqual([]) + } finally { + secondDatabase.close() + rmSync(tempDirectory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/db-node-sqlite-persisted-collection/tests/node-sqlite-core-adapter-contract.test.ts b/packages/db-node-sqlite-persisted-collection/tests/node-sqlite-core-adapter-contract.test.ts new file mode 100644 index 000000000..eb37c05d9 --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/tests/node-sqlite-core-adapter-contract.test.ts @@ -0,0 +1,40 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runSQLiteCoreAdapterContractSuite } from '../../db-sqlite-persisted-collection-core/tests/contracts/sqlite-core-adapter-contract' +import { BetterSqlite3SQLiteDriver } from '../src/node-driver' +import { SQLiteCorePersistenceAdapter } from '../../db-sqlite-persisted-collection-core/src' +import type { + SQLiteCoreAdapterContractTodo, + SQLiteCoreAdapterHarnessFactory, +} from '../../db-sqlite-persisted-collection-core/tests/contracts/sqlite-core-adapter-contract' + +const createHarness: SQLiteCoreAdapterHarnessFactory = (options) => { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-node-sqlite-core-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const driver = new BetterSqlite3SQLiteDriver({ filename: dbPath }) + const adapter = new SQLiteCorePersistenceAdapter< + SQLiteCoreAdapterContractTodo, + string + >({ + driver, + ...options, + }) + + return { + adapter, + driver, + cleanup: () => { + try { + driver.close() + } finally { + rmSync(tempDirectory, { recursive: true, force: true }) + } + }, + } +} + +runSQLiteCoreAdapterContractSuite( + `SQLiteCorePersistenceAdapter (better-sqlite3 node driver)`, + createHarness, +) diff --git a/packages/db-node-sqlite-persisted-collection/tsconfig.docs.json b/packages/db-node-sqlite-persisted-collection/tsconfig.docs.json new file mode 100644 index 000000000..5fddb4598 --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/tsconfig.docs.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@tanstack/db": ["../db/src"], + "@tanstack/db-sqlite-persisted-collection-core": [ + "../db-sqlite-persisted-collection-core/src" + ] + } + }, + "include": ["src"] +} diff --git a/packages/db-node-sqlite-persisted-collection/tsconfig.json b/packages/db-node-sqlite-persisted-collection/tsconfig.json new file mode 100644 index 000000000..4074d193d --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "paths": { + "@tanstack/db": ["../db/src"], + "@tanstack/db-ivm": ["../db-ivm/src"], + "@tanstack/db-sqlite-persisted-collection-core": [ + "../db-sqlite-persisted-collection-core/src" + ] + } + }, + "include": ["src", "tests", "e2e", "vite.config.ts", "vitest.e2e.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/db-node-sqlite-persisted-collection/vite.config.ts b/packages/db-node-sqlite-persisted-collection/vite.config.ts new file mode 100644 index 000000000..ea27c667a --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + include: [`tests/**/*.test.ts`], + environment: `node`, + coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, + typecheck: { + enabled: true, + include: [`tests/**/*.test.ts`, `tests/**/*.test-d.ts`], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: `./src/index.ts`, + srcDir: `./src`, + }), +) diff --git a/packages/db-node-sqlite-persisted-collection/vitest.e2e.config.ts b/packages/db-node-sqlite-persisted-collection/vitest.e2e.config.ts new file mode 100644 index 000000000..063029e51 --- /dev/null +++ b/packages/db-node-sqlite-persisted-collection/vitest.e2e.config.ts @@ -0,0 +1,24 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +const packageDirectory = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + resolve: { + alias: { + '@tanstack/db': resolve(packageDirectory, `../db/src`), + '@tanstack/db-ivm': resolve(packageDirectory, `../db-ivm/src`), + '@tanstack/db-sqlite-persisted-collection-core': resolve( + packageDirectory, + `../db-sqlite-persisted-collection-core/src`, + ), + }, + }, + test: { + include: [`e2e/**/*.e2e.test.ts`], + fileParallelism: false, + testTimeout: 60_000, + environment: `jsdom`, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index beb589e68..c1ecfd3c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -862,6 +862,22 @@ importers: packages/db-collections: {} + packages/db-electron-sqlite-persisted-collection: + dependencies: + '@tanstack/db-sqlite-persisted-collection-core': + specifier: workspace:* + version: link:../db-sqlite-persisted-collection-core + typescript: + specifier: '>=4.7' + version: 5.9.3 + devDependencies: + '@vitest/coverage-istanbul': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + electron: + specifier: ^40.2.1 + version: 40.8.0 + packages/db-ivm: dependencies: fractional-indexing: @@ -881,6 +897,25 @@ importers: specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) + packages/db-node-sqlite-persisted-collection: + dependencies: + '@tanstack/db-sqlite-persisted-collection-core': + specifier: workspace:* + version: link:../db-sqlite-persisted-collection-core + better-sqlite3: + specifier: ^12.6.2 + version: 12.6.2 + typescript: + specifier: '>=4.7' + version: 5.9.3 + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@vitest/coverage-istanbul': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + packages/db-react-native-sqlite-persisted-collection: dependencies: '@op-engineering/op-sqlite': @@ -2065,6 +2100,10 @@ packages: '@electric-sql/client@1.5.10': resolution: {integrity: sha512-AMXaGhGSUYPZSQHvtUIYoW0IQVW5MSAT1Oc2jDb1KyAPQa5wypoW6NzGqaUR7jKkNh9vJiqKjT56nH+fh1bu0g==} + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -4173,6 +4212,10 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -4317,6 +4360,10 @@ packages: resolution: {integrity: sha512-08eKiDAjj4zLug1taXSIJ0kGL5cawjVCyJkBb6EWSg5fEPX6L+Wtr0CH2If4j5KYylz85iaZiFlUItvgJvll5g==} engines: {node: ^14.13.1 || ^16.0.0 || >=18} + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@tailwindcss/node@4.2.1': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -4694,6 +4741,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -4736,6 +4786,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -4757,6 +4810,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -4766,6 +4822,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@types/node@25.2.2': resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} @@ -4786,6 +4845,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/send@0.17.5': resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} @@ -4825,6 +4887,9 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5533,6 +5598,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -5573,6 +5642,9 @@ packages: resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} engines: {node: '>=16.20.1'} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5594,6 +5666,14 @@ packages: resolution: {integrity: sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==} engines: {node: ^20.17.0 || >=22.9.0} + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -5731,6 +5811,9 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -6022,6 +6105,10 @@ packages: resolution: {integrity: sha512-AWfM0vhFmESRZawEJfLhRJMsAR5IOhwyxGxIDOh9RXGKcdV65cWtkFB31MNjUfFvAlfbk3c2ooX0rr1pWIXshw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -6062,6 +6149,9 @@ packages: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devalue@5.6.3: resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} @@ -6240,6 +6330,11 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + electron@40.8.0: + resolution: {integrity: sha512-WoPq0Nr9Yx3g7T6VnJXdwa/rr2+VRyH3a+K+ezfMKBlf6WjxE/LmhMQabKbb6yjm9RbZhJBRcYyoLph421O2mQ==} + engines: {node: '>= 12.20.55'} + hasBin: true + emoji-regex@10.5.0: resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==} @@ -6361,6 +6456,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -6724,6 +6822,11 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} @@ -6760,6 +6863,9 @@ packages: fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -6940,6 +7046,10 @@ packages: resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} engines: {node: '>=6'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} @@ -6982,6 +7092,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -7014,6 +7128,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -7138,6 +7256,10 @@ packages: resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -7638,6 +7760,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -7974,6 +8099,10 @@ packages: lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -8030,6 +8159,10 @@ packages: marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -8177,6 +8310,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -8412,6 +8549,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + npm-bundled@5.0.0: resolution: {integrity: sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -8561,6 +8702,10 @@ packages: oxc-resolver@11.16.2: resolution: {integrity: sha512-Uy76u47vwhhF7VAmVY61Srn+ouiOobf45MU9vGct9GD2ARy6hKoqEElyHDB0L+4JOM6VLuZ431KiLwyjI/A21g==} + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + p-defer@4.0.1: resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} engines: {node: '>=12'} @@ -8708,6 +8853,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -8949,6 +9097,10 @@ packages: queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -9126,6 +9278,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-from@3.0.0: resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==} engines: {node: '>=4'} @@ -9165,6 +9320,9 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + restore-cursor@2.0.0: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} @@ -9189,6 +9347,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + rollup-plugin-preserve-directives@0.4.0: resolution: {integrity: sha512-gx4nBxYm5BysmEQS+e2tAMrtFxrGvk+Pe5ppafRibQi0zlW7VYAbEGk6IKDw9sJGPdFWgVTE0o4BU4cdG0Fylg==} peerDependencies: @@ -9259,6 +9421,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -9303,6 +9468,10 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + seroval-plugins@1.5.0: resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} engines: {node: '>=10'} @@ -9580,6 +9749,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + srvx@0.11.8: resolution: {integrity: sha512-2n9t0YnAXPJjinytvxccNgs7rOA5gmE7Wowt/8Dy2dx2fDC6sBhfBpbrCvjYKALlVukPS/Uq3QwkolKNa7P/2Q==} engines: {node: '>=20.16.0'} @@ -9734,6 +9906,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} @@ -9983,6 +10159,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -10648,6 +10828,9 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -11832,6 +12015,20 @@ snapshots: optionalDependencies: '@rollup/rollup-darwin-arm64': 4.59.0 + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -13954,6 +14151,8 @@ snapshots: '@sinclair/typebox@0.27.8': {} + '@sindresorhus/is@4.6.0': {} + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 @@ -14137,6 +14336,10 @@ snapshots: transitivePeerDependencies: - encoding + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -14678,6 +14881,13 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.2.2 + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 25.2.2 + '@types/responselike': 1.0.3 + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 @@ -14733,6 +14943,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -14751,12 +14963,20 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/keyv@3.1.4': + dependencies: + '@types/node': 25.2.2 + '@types/mime@1.3.5': {} '@types/ms@2.1.0': {} '@types/node@12.20.55': {} + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + '@types/node@25.2.2': dependencies: undici-types: 7.16.0 @@ -14779,6 +14999,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/responselike@1.0.3': + dependencies: + '@types/node': 25.2.2 + '@types/send@0.17.5': dependencies: '@types/mime': 1.3.5 @@ -14823,6 +15047,11 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 25.2.2 + optional: true + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -15684,6 +15913,9 @@ snapshots: boolbase@1.0.0: {} + boolean@3.2.0: + optional: true + bplist-creator@0.1.0: dependencies: stream-buffers: 2.2.0 @@ -15734,6 +15966,8 @@ snapshots: bson@6.10.4: {} + buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -15764,6 +15998,18 @@ snapshots: ssri: 13.0.1 unique-filename: 5.0.0 + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -15932,6 +16178,10 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + clone@1.0.4: {} clsx@2.1.1: {} @@ -16199,6 +16449,8 @@ snapshots: defekt@9.3.0: {} + defer-to-connect@2.0.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -16227,6 +16479,9 @@ snapshots: detect-libc@2.0.4: {} + detect-node@2.1.0: + optional: true + devalue@5.6.3: {} dexie@4.0.10: {} @@ -16324,6 +16579,14 @@ snapshots: electron-to-chromium@1.5.286: {} + electron@40.8.0: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 24.12.0 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + emoji-regex@10.5.0: {} emoji-regex@8.0.0: {} @@ -16513,6 +16776,9 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es6-error@4.1.1: + optional: true + esbuild-register@3.6.0(esbuild@0.25.11): dependencies: debug: 4.4.3 @@ -17068,6 +17334,16 @@ snapshots: extendable-error@0.1.7: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 @@ -17106,6 +17382,10 @@ snapshots: dependencies: walk-up-path: 4.0.0 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -17321,6 +17601,10 @@ snapshots: dependencies: pump: 3.0.3 + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + get-stream@8.0.1: {} get-symbol-description@1.1.0: @@ -17371,6 +17655,16 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.4 + serialize-error: 7.0.1 + optional: true + globals@14.0.0: {} globals@15.15.0: {} @@ -17399,6 +17693,20 @@ snapshots: gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + graceful-fs@4.2.11: {} graphql-ws@5.16.2(graphql@16.12.0): @@ -17518,6 +17826,11 @@ snapshots: transitivePeerDependencies: - debug + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -18025,6 +18338,9 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: + optional: true + json5@2.2.3: {} jsonc-parser@3.3.1: {} @@ -18383,6 +18699,8 @@ snapshots: dependencies: tslib: 2.8.1 + lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} lru-cache@11.2.5: {} @@ -18455,6 +18773,11 @@ snapshots: marky@1.3.0: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} mdn-data@2.12.2: {} @@ -18687,6 +19010,8 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@1.0.1: {} + mimic-response@3.1.0: {} min-indent@1.0.1: {} @@ -18891,6 +19216,8 @@ snapshots: normalize-path@3.0.0: {} + normalize-url@6.1.0: {} + npm-bundled@5.0.0: dependencies: npm-normalize-package-bin: 5.0.0 @@ -19101,6 +19428,8 @@ snapshots: '@oxc-resolver/binding-win32-ia32-msvc': 11.16.2 '@oxc-resolver/binding-win32-x64-msvc': 11.16.2 + p-cancelable@2.1.1: {} + p-defer@4.0.1: {} p-filter@2.1.0: @@ -19249,6 +19578,8 @@ snapshots: pathval@2.0.1: {} + pend@1.2.0: {} + pg-cloudflare@1.3.0: optional: true @@ -19480,6 +19811,8 @@ snapshots: dependencies: inherits: 2.0.4 + quick-lru@5.1.1: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -19708,6 +20041,8 @@ snapshots: requires-port@1.0.0: {} + resolve-alpn@1.2.1: {} + resolve-from@3.0.0: {} resolve-from@4.0.0: {} @@ -19742,6 +20077,10 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + restore-cursor@2.0.0: dependencies: onetime: 2.0.1 @@ -19762,6 +20101,16 @@ snapshots: dependencies: glob: 7.2.3 + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + rollup-plugin-preserve-directives@0.4.0(rollup@4.59.0): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.59.0) @@ -19912,6 +20261,9 @@ snapshots: scheduler@0.27.0: {} + semver-compare@1.0.0: + optional: true + semver@5.7.2: {} semver@6.3.1: {} @@ -19980,6 +20332,11 @@ snapshots: serialize-error@2.1.0: {} + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + seroval-plugins@1.5.0(seroval@1.5.0): dependencies: seroval: 1.5.0 @@ -20308,6 +20665,9 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: + optional: true + srvx@0.11.8: {} ssri@13.0.1: @@ -20471,6 +20831,12 @@ snapshots: pirates: 4.0.7 ts-interface-checker: 0.1.13 + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + superjson@2.2.6: dependencies: copy-anything: 4.0.5 @@ -20723,6 +21089,9 @@ snapshots: type-detect@4.0.8: {} + type-fest@0.13.1: + optional: true + type-fest@0.21.3: {} type-fest@0.7.1: {} @@ -21373,6 +21742,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {}