| @@ -0,0 +1,92 @@ |
| | 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: owned local database open composes the lifetime SQLite lock, crash-resumable migration, and serving DB handle */ |
| | 2 | import { dirname, join } from "node:path"; |
| | 3 | |
| | 4 | import type { FumaTables } from "@executor-js/sdk"; |
| | 5 | |
| | 6 | import { acquireDataDirOwnership } from "./data-dir-ownership"; |
| | 7 | import { createSqliteFumaDb, type SqliteFumaDb } from "./sqlite-fumadb"; |
| | 8 | import { |
| | 9 | migrateLocalV1ToV2IfNeeded, |
| | 10 | type LocalV1V2MigrationOptions, |
| | 11 | type LocalV1V2MigrationResult, |
| | 12 | } from "./v1-v2-migration"; |
| | 13 | |
| | 14 | export interface OpenOwnedLocalDatabaseOptions<TTables extends FumaTables = FumaTables> { |
| | 15 | readonly dataDir: string; |
| | 16 | readonly tables: TTables; |
| | 17 | readonly namespace: string; |
| | 18 | readonly tenantId: string; |
| | 19 | readonly version?: string; |
| | 20 | readonly oauthMetadataFetch?: LocalV1V2MigrationOptions["oauthMetadataFetch"]; |
| | 21 | readonly oauthMetadataTimeoutMs?: LocalV1V2MigrationOptions["oauthMetadataTimeoutMs"]; |
| | 22 | } |
| | 23 | |
| | 24 | export interface OwnedLocalDatabase<TTables extends FumaTables = FumaTables> { |
| | 25 | /** Real data directory that owns this handle. Symlinked input paths collapse here. */ |
| | 26 | readonly dataDir: string; |
| | 27 | readonly sqlitePath: string; |
| | 28 | readonly lockPath: string; |
| | 29 | readonly db: SqliteFumaDb<TTables>; |
| | 30 | readonly migration: LocalV1V2MigrationResult; |
| | 31 | readonly close: () => Promise<void>; |
| | 32 | } |
| | 33 | |
| | 34 | export const openOwnedLocalDatabase = async <const TTables extends FumaTables>( |
| | 35 | input: OpenOwnedLocalDatabaseOptions<TTables>, |
| | 36 | ): Promise<OwnedLocalDatabase<TTables>> => { |
| | 37 | const ownership = await acquireDataDirOwnership(input.dataDir); |
| | 38 | let db: SqliteFumaDb<TTables> | null = null; |
| | 39 | |
| | 40 | try { |
| | 41 | // Use the real directory captured by the ownership primitive so a symlinked |
| | 42 | // input path cannot be repointed between lock acquisition and data.db open. |
| | 43 | const dataDir = dirname(ownership.lockPath); |
| | 44 | const sqlitePath = join(dataDir, "data.db"); |
| | 45 | |
| | 46 | const migration = await migrateLocalV1ToV2IfNeeded({ |
| | 47 | sqlitePath, |
| | 48 | tables: input.tables, |
| | 49 | namespace: input.namespace, |
| | 50 | tenantId: input.tenantId, |
| | 51 | ...(input.oauthMetadataFetch !== undefined |
| | 52 | ? { oauthMetadataFetch: input.oauthMetadataFetch } |
| | 53 | : {}), |
| | 54 | ...(input.oauthMetadataTimeoutMs !== undefined |
| | 55 | ? { oauthMetadataTimeoutMs: input.oauthMetadataTimeoutMs } |
| | 56 | : {}), |
| | 57 | }); |
| | 58 | |
| | 59 | const openedDb = await createSqliteFumaDb({ |
| | 60 | tables: input.tables, |
| | 61 | namespace: input.namespace, |
| | 62 | path: sqlitePath, |
| | 63 | ...(input.version !== undefined ? { version: input.version } : {}), |
| | 64 | }); |
| | 65 | db = openedDb; |
| | 66 | |
| | 67 | let closed = false; |
| | 68 | return { |
| | 69 | dataDir, |
| | 70 | sqlitePath, |
| | 71 | lockPath: ownership.lockPath, |
| | 72 | db: openedDb, |
| | 73 | migration, |
| | 74 | close: async () => { |
| | 75 | if (closed) return; |
| | 76 | closed = true; |
| | 77 | try { |
| | 78 | await openedDb.close(); |
| | 79 | } finally { |
| | 80 | await ownership.release(); |
| | 81 | } |
| | 82 | }, |
| | 83 | }; |
| | 84 | } catch (cause) { |
| | 85 | try { |
| | 86 | if (db) await db.close(); |
| | 87 | } finally { |
| | 88 | await ownership.release(); |
| | 89 | } |
| | 90 | throw cause; |
| | 91 | } |
| | 92 | }; |