History

Add owned-database.ts entry point

Griffin Evans committed 4 months ago

Commit 6a53d151 parent 80a67af2 files · +148 −0Browse files

Unified · Split

2 files
  1. apps/local/src/db/owned-database.test.ts +56 −0
  2. apps/local/src/db/owned-database.ts +92 −0
apps/local/src/db/owned-database.test.ts+56−0
@@ -0,0 +1,56 @@
1/* oxlint-disable executor/no-try-catch-or-throw -- boundary: DB ownership test must close the held DB/lock handle even when assertions fail */
2import { mkdtempSync, realpathSync, rmSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5
6import { describe, expect, it } from "@effect/vitest";
7import { collectTables } from "@executor-js/api/server";
8
9import { acquireDataDirOwnership, DataDirOwnershipHeld } from "./data-dir-ownership";
10import { openOwnedLocalDatabase, type OwnedLocalDatabase } from "./owned-database";
11
12const LOCK_DATABASE_FILENAME = "data.db.owner-lock";
13
14const makeOwnedTestDatabase = (dataDir: string) =>
15 openOwnedLocalDatabase({
16 dataDir,
17 tables: collectTables(),
18 namespace: "executor_local_owned_database_test",
19 tenantId: "owned-database-test-tenant",
20 });
21
22describe("openOwnedLocalDatabase", () => {
23 it("holds data-dir ownership until the serving database is closed", async () => {
24 const workDir = mkdtempSync(join(tmpdir(), "executor-owned-database-"));
25 const dataDir = join(workDir, "data");
26 let owned: OwnedLocalDatabase | null = null;
27
28 try {
29 owned = await makeOwnedTestDatabase(dataDir);
30 const realDataDir = realpathSync(dataDir);
31 const expectedLockPath = join(realDataDir, LOCK_DATABASE_FILENAME);
32
33 expect(owned).toMatchObject({
34 dataDir: realDataDir,
35 sqlitePath: join(realDataDir, "data.db"),
36 lockPath: expectedLockPath,
37 migration: { migrated: false, warnings: [] },
38 });
39
40 await expect(acquireDataDirOwnership(dataDir)).rejects.toBeInstanceOf(DataDirOwnershipHeld);
41
42 await owned.close();
43 owned = null;
44
45 const ownership = await acquireDataDirOwnership(dataDir);
46 try {
47 expect(ownership.lockPath).toBe(expectedLockPath);
48 } finally {
49 await ownership.release();
50 }
51 } finally {
52 if (owned) await owned.close();
53 rmSync(workDir, { recursive: true, force: true });
54 }
55 });
56});
apps/local/src/db/owned-database.ts+92−0
@@ -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 */
2import { dirname, join } from "node:path";
3
4import type { FumaTables } from "@executor-js/sdk";
5
6import { acquireDataDirOwnership } from "./data-dir-ownership";
7import { createSqliteFumaDb, type SqliteFumaDb } from "./sqlite-fumadb";
8import {
9 migrateLocalV1ToV2IfNeeded,
10 type LocalV1V2MigrationOptions,
11 type LocalV1V2MigrationResult,
12} from "./v1-v2-migration";
13
14export 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
24export 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
34export 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};