diff --git a/.mise.toml b/.mise.toml index b08b5ca..9bca879 100644 --- a/.mise.toml +++ b/.mise.toml @@ -197,7 +197,7 @@ description = "Apply Drizzle migrations to the configured Postgres database" run = "pnpm --filter @rakkr/db db:migrate" [tasks."db:verify"] -description = "Replay Drizzle migrations against a fresh throwaway Postgres database" +description = "Replay Drizzle migrations against an in-process PGlite database (no Docker)" run = "pnpm --filter @rakkr/db db:verify" [tasks.release] diff --git a/AGENTS.md b/AGENTS.md index 4f6e21b..b3ff636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,8 @@ barrel; tables live in per-subsystem modules under `packages/db/src/schema/`. - Edit the matching table module under `packages/db/src/schema/` first (re-exported by `schema.ts`; `drizzle.config.ts` reads `schema.ts`). - `mise run db:generate`, review the SQL/metadata under `packages/db/drizzle`, - then `mise run db:verify` (replays migrations against a throwaway Postgres). + then `mise run db:verify` (replays migrations against an in-process PGlite + database — no Docker/Postgres server needed). - Commit generated migration files with the schema change. ## Gates And Checks @@ -135,6 +136,7 @@ pnpm --filter @rakkr/api test # sets RAKKR_API_NO_LISTEN=1; drops DATABASE_ pnpm --filter @rakkr/web test pnpm --filter @rakkr/shared check pnpm --filter @rakkr/db check +mise run node:test-db # concurrency/race tests; needs real Postgres mise run agent:fake-controller-smoke ``` @@ -143,6 +145,19 @@ setup/helpers in non-`.test.ts` modules so the runner ignores them. Recorder quick checks: `cargo run -p rakkr-recorder-agent -- --print-inventory` (or `--print-meter-frame`). +DB tests split by what they exercise. **Persistence/round-trip** tests run against +an in-process PGlite (WASM Postgres) via `createPgliteDatabase()` from `@rakkr/db` +(`packages/db/src/client.ts`), so they need no server and run in the default +`node:test` suite — call it at the top, set `DATABASE_URL` to the returned +`pglite://…` url (or pass the url straight to `createDatabase`/`LocalAuthService`), +and close the handle in an `after`/`finally`. **Concurrency/race** tests (row-lock +and atomic compare-and-set contention) need genuinely concurrent Postgres +connections, which single-connection PGlite cannot model — those keep the +`RAKKR_API_TEST_DATABASE_URL` skip guard and run via `mise run node:test-db` +against a throwaway Postgres (listed in `run-db-integration-tests.mjs`). Do **not** +move a race test onto PGlite: it would pass vacuously (PGlite serializes +transactions) and mask a removed lock. + Ansible lifecycle smoke: `docker compose up -d --build ansible-runner recorder-test-rig` then `mise run ansible:runner-smoke` (deploys the disposable artifact into `recorder-test-rig`, runs `smoke_check`). Physical X32: set diff --git a/apps/api/test/auth-access-tx.test.ts b/apps/api/test/auth-access-tx.test.ts index 9ad6446..3295f68 100644 --- a/apps/api/test/auth-access-tx.test.ts +++ b/apps/api/test/auth-access-tx.test.ts @@ -1,11 +1,10 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import test from "node:test"; -import { createDatabase, eq, userRoles, users } from "@rakkr/db"; +import test, { after } from "node:test"; +import { createDatabase, createPgliteDatabase, eq, userRoles, users } from "@rakkr/db"; -// Exercises the access-persistence path against a real Postgres. Runs only when a -// test DB is provided via RAKKR_API_TEST_DATABASE_URL (repo convention). Run with -// `--test-force-exit` — the db client pool has no exposed close. +// Exercises the access-persistence path against real Postgres SQL semantics via +// an in-process PGlite (WASM Postgres) database, so it needs no running server. // // Guards R26-ACCESS-TX: persistLocalUserAccess DELETEs a user's roles/grants/groups // and then INSERTs the new set. If an INSERT fails (e.g. a role id violating the @@ -13,62 +12,60 @@ import { createDatabase, eq, userRoles, users } from "@rakkr/db"; // access stripped in the DB while the caller throws. Wrapping the delete+insert // block in a single transaction must roll the DELETEs back so a failed insert // leaves the user's PRE-EXISTING access intact. -const dbUrl = process.env.RAKKR_API_TEST_DATABASE_URL; +const pglite = await createPgliteDatabase("auth-access-tx"); + +after(() => pglite.close()); const { LocalAuthService } = await import("../src/auth-service.js"); -test( - "DB: a failed access INSERT rolls back the DELETEs so prior access survives (R26-ACCESS-TX)", - { skip: dbUrl ? false : "requires RAKKR_API_TEST_DATABASE_URL (Postgres)" }, - async () => { - const db = createDatabase(dbUrl!); - const auth = new LocalAuthService(dbUrl); - const email = `access-tx-${randomUUID()}@example.com`; +test("DB: a failed access INSERT rolls back the DELETEs so prior access survives (R26-ACCESS-TX)", async () => { + const db = createDatabase(pglite.url); + const auth = new LocalAuthService(pglite.url); + const email = `access-tx-${randomUUID()}@example.com`; - const [row] = await db - .insert(users) - .values({ email, name: "Access TX", passwordHash: "x", provider: "local" }) - .returning({ id: users.id }); - const userId = row!.id; + const [row] = await db + .insert(users) + .values({ email, name: "Access TX", passwordHash: "x", provider: "local" }) + .returning({ id: users.id }); + const userId = row!.id; - try { - // Seed a real, valid role so the user starts with concrete access. - await auth.updateLocalUserAccess(userId, { - groupIds: [], - resourceGrants: [], - roles: ["operator"], - }); + try { + // Seed a real, valid role so the user starts with concrete access. + await auth.updateLocalUserAccess(userId, { + groupIds: [], + resourceGrants: [], + roles: ["operator"], + }); - const seeded = await db.select().from(userRoles).where(eq(userRoles.userId, userId)); - assert.deepEqual( - seeded.map((entry) => entry.roleId), - ["operator"], - "user must start with the seeded operator role", - ); + const seeded = await db.select().from(userRoles).where(eq(userRoles.userId, userId)); + assert.deepEqual( + seeded.map((entry) => entry.roleId), + ["operator"], + "user must start with the seeded operator role", + ); - // Drive persistLocalUserAccess with a role id that violates the - // user_roles -> roles FK. The DELETEs run first; the failing INSERT must - // roll the whole thing back rather than leave the user stripped. - await assert.rejects( - ( - auth as unknown as { - persistLocalUserAccess: ( - id: string, - access: { groupIds?: string[]; resourceGrants: never[]; roles: string[] }, - groups: never[], - ) => Promise; - } - ).persistLocalUserAccess(userId, { resourceGrants: [], roles: ["not-a-real-role"] }, []), - ); + // Drive persistLocalUserAccess with a role id that violates the + // user_roles -> roles FK. The DELETEs run first; the failing INSERT must + // roll the whole thing back rather than leave the user stripped. + await assert.rejects( + ( + auth as unknown as { + persistLocalUserAccess: ( + id: string, + access: { groupIds?: string[]; resourceGrants: never[]; roles: string[] }, + groups: never[], + ) => Promise; + } + ).persistLocalUserAccess(userId, { resourceGrants: [], roles: ["not-a-real-role"] }, []), + ); - const after = await db.select().from(userRoles).where(eq(userRoles.userId, userId)); - assert.deepEqual( - after.map((entry) => entry.roleId), - ["operator"], - "the failed insert must not strip the user's pre-existing role", - ); - } finally { - await db.delete(users).where(eq(users.id, userId)); - } - }, -); + const after = await db.select().from(userRoles).where(eq(userRoles.userId, userId)); + assert.deepEqual( + after.map((entry) => entry.roleId), + ["operator"], + "the failed insert must not strip the user's pre-existing role", + ); + } finally { + await db.delete(users).where(eq(users.id, userId)); + } +}); diff --git a/apps/api/test/auth-login-constraint.test.ts b/apps/api/test/auth-login-constraint.test.ts index 7c6793b..34f48e9 100644 --- a/apps/api/test/auth-login-constraint.test.ts +++ b/apps/api/test/auth-login-constraint.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import test, { after } from "node:test"; +import { createPgliteDatabase } from "@rakkr/db"; -// Exercises the login persistence path against a real Postgres. Runs only when a -// test DB is provided via RAKKR_API_TEST_DATABASE_URL. DATABASE_URL must be set -// BEFORE importing the auth service. Run with `--test-force-exit` — the db client -// pool has no exposed close. +// Exercises the login persistence path against real Postgres SQL semantics via an +// in-process PGlite (WASM Postgres) database, so it needs no running server. +// DATABASE_URL must be set BEFORE importing the auth service. // // Guards the R8-DBLATCH scoping fix: a data-integrity error (SQLSTATE class 22/23) // on the fire-and-forget login-session persistence must NOT abort the login. The @@ -14,43 +14,33 @@ import test from "node:test"; // markDatabaseUnavailable re-threw the constraint error, so a valid credential // login surfaced as 401. It must degrade: the session lives in memory, the login // still returns a token. -const dbUrl = process.env.RAKKR_API_TEST_DATABASE_URL; - -if (dbUrl) { - process.env.DATABASE_URL = dbUrl; -} +const pglite = await createPgliteDatabase("auth-login-constraint"); +process.env.DATABASE_URL = pglite.url; process.env.RAKKR_LOCAL_ADMIN_EMAIL = "admin@rakkr.local"; process.env.RAKKR_LOCAL_ADMIN_PASSWORD = "rakkr-login-constraint-password"; +after(() => pglite.close()); + const { LocalAuthService } = await import("../src/auth-service.js"); -test( - "a valid login is not aborted by an over-long session ip_address (constraint error is not re-thrown)", - { - skip: dbUrl ? false : "requires RAKKR_API_TEST_DATABASE_URL (Postgres)", - }, - async () => { - const service = new LocalAuthService(dbUrl); - - // A realistic long forwarded-for chain: well past the varchar(120) budget. - const longForwardedFor = Array.from({ length: 12 }, (_, index) => `203.0.113.${index}`).join( - ", ", - ); - assert.ok( - longForwardedFor.length > 120, - "the forwarded-for chain must exceed the column budget", - ); - - const result = await service.login("admin@rakkr.local", "rakkr-login-constraint-password", { - ipAddress: longForwardedFor, - }); - - assert.ok(result.token, "login returns a session token despite the failed session persist"); - assert.equal(result.user.email, "admin@rakkr.local"); - - // The session must still authenticate (served from the in-memory fallback). - const authed = await service.authenticate(`Bearer ${result.token}`); - assert.equal(authed.user?.email, "admin@rakkr.local"); - }, -); +test("a valid login is not aborted by an over-long session ip_address (constraint error is not re-thrown)", async () => { + const service = new LocalAuthService(pglite.url); + + // A realistic long forwarded-for chain: well past the varchar(120) budget. + const longForwardedFor = Array.from({ length: 12 }, (_, index) => `203.0.113.${index}`).join( + ", ", + ); + assert.ok(longForwardedFor.length > 120, "the forwarded-for chain must exceed the column budget"); + + const result = await service.login("admin@rakkr.local", "rakkr-login-constraint-password", { + ipAddress: longForwardedFor, + }); + + assert.ok(result.token, "login returns a session token despite the failed session persist"); + assert.equal(result.user.email, "admin@rakkr.local"); + + // The session must still authenticate (served from the in-memory fallback). + const authed = await service.authenticate(`Bearer ${result.token}`); + assert.equal(authed.user?.email, "admin@rakkr.local"); +}); diff --git a/apps/api/test/node-channel-room-pg.test.ts b/apps/api/test/node-channel-room-pg.test.ts index 482e013..856825b 100644 --- a/apps/api/test/node-channel-room-pg.test.ts +++ b/apps/api/test/node-channel-room-pg.test.ts @@ -1,85 +1,77 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import test from "node:test"; +import test, { after } from "node:test"; +import { createPgliteDatabase } from "@rakkr/db"; // Postgres-backed round-trip for PostgresNodeStore.assignChannelRooms — the // default suites run against SeedOnlyNodeStore, so the real per-channel room -// persistence (audio_channels.room_id) was never exercised. Runs only when a test -// DB is provided via RAKKR_API_TEST_DATABASE_URL; otherwise it skips and opens no -// pool. DATABASE_URL must be set BEFORE importing the stores. -// -// In DB mode, run with `--test-force-exit` — the db client pool has no exposed -// close, so the process would otherwise idle until the runner's exit timeout. -const dbUrl = process.env.RAKKR_API_TEST_DATABASE_URL; +// persistence (audio_channels.room_id) was never exercised. Runs against an +// in-process PGlite (WASM Postgres) database, so it needs no running server. +// DATABASE_URL must be set BEFORE importing the stores. +const pglite = await createPgliteDatabase("node-channel-room-pg"); -if (dbUrl) { - process.env.DATABASE_URL = dbUrl; -} +process.env.DATABASE_URL = pglite.url; + +after(() => pglite.close()); const { createNodeStore } = await import("../src/node-store.js"); const { createRoomStore } = await import("../src/room-store.js"); -test( - "PostgresNodeStore.assignChannelRooms persists per-channel room assignments", - { - skip: dbUrl ? false : "requires RAKKR_API_TEST_DATABASE_URL (Postgres)", - }, - async () => { - const suffix = randomUUID().slice(0, 8); - const room = await createRoomStore().create({ - id: `room_pg_${suffix}`, - name: `PG Room ${suffix}`, - site: `Site ${suffix}`, - }); +test("PostgresNodeStore.assignChannelRooms persists per-channel room assignments", async () => { + const suffix = randomUUID().slice(0, 8); + const room = await createRoomStore().create({ + id: `room_pg_${suffix}`, + name: `PG Room ${suffix}`, + site: `Site ${suffix}`, + }); - const nodeStore = createNodeStore(); - const enrollment = await nodeStore.enroll({ - agentVersion: "0.0.0-test", - alias: `Channel Room Node ${suffix}`, - hostname: `channel-room-${suffix}.local`, - interfaces: [ - { - alias: "X32", - backend: "alsa", - channelCount: 2, - channels: [ - { alias: "Ch 1", index: 1 }, - { alias: "Ch 2", index: 2 }, - ], - sampleRates: [48000], - systemName: `X-USB ${suffix}`, - systemRef: `hw:CARD=${suffix}`, - }, - ], - ipAddresses: [], - location: { room: "Rack", site: `Site ${suffix}` }, - tags: [], - }); - const nodeId = enrollment.node.id; - const interfaceId = enrollment.node.interfaces[0]?.id; - assert.ok(interfaceId, "enrolled node has an interface id"); + const nodeStore = createNodeStore(); + const enrollment = await nodeStore.enroll({ + agentVersion: "0.0.0-test", + alias: `Channel Room Node ${suffix}`, + hostname: `channel-room-${suffix}.local`, + interfaces: [ + { + alias: "X32", + backend: "alsa", + channelCount: 2, + channels: [ + { alias: "Ch 1", index: 1 }, + { alias: "Ch 2", index: 2 }, + ], + sampleRates: [48000], + systemName: `X-USB ${suffix}`, + systemRef: `hw:CARD=${suffix}`, + }, + ], + ipAddresses: [], + location: { room: "Rack", site: `Site ${suffix}` }, + tags: [], + }); + const nodeId = enrollment.node.id; + const interfaceId = enrollment.node.interfaces[0]?.id; + assert.ok(interfaceId, "enrolled node has an interface id"); - const channelRoom = (node: Awaited>, index: number) => - node?.interfaces - .find((iface) => iface.id === interfaceId) - ?.channels.find((channel) => channel.index === index)?.roomId; + const channelRoom = (node: Awaited>, index: number) => + node?.interfaces + .find((iface) => iface.id === interfaceId) + ?.channels.find((channel) => channel.index === index)?.roomId; - // Assign channel 1 to the room; channel 2 stays unassigned. - const assigned = await nodeStore.assignChannelRooms(nodeId, [ - { channelIndex: 1, interfaceId, roomId: room.id }, - ]); - assert.equal(channelRoom(assigned, 1), room.id, "channel 1 is assigned to the room"); - assert.equal(channelRoom(assigned, 2), undefined, "channel 2 stays unassigned"); + // Assign channel 1 to the room; channel 2 stays unassigned. + const assigned = await nodeStore.assignChannelRooms(nodeId, [ + { channelIndex: 1, interfaceId, roomId: room.id }, + ]); + assert.equal(channelRoom(assigned, 1), room.id, "channel 1 is assigned to the room"); + assert.equal(channelRoom(assigned, 2), undefined, "channel 2 stays unassigned"); - // Round-trip via a FRESH store instance so the assertion reads the DB, not the - // in-memory return value of the writing instance. - const reread = await createNodeStore().find(nodeId); - assert.equal(channelRoom(reread, 1), room.id, "the assignment persists across a fresh read"); + // Round-trip via a FRESH store instance so the assertion reads the DB, not the + // in-memory return value of the writing instance. + const reread = await createNodeStore().find(nodeId); + assert.equal(channelRoom(reread, 1), room.id, "the assignment persists across a fresh read"); - // Clearing the assignment (roomId null) persists as unassigned. - const cleared = await nodeStore.assignChannelRooms(nodeId, [ - { channelIndex: 1, interfaceId, roomId: null }, - ]); - assert.equal(channelRoom(cleared, 1), undefined, "clearing the room persists as unassigned"); - }, -); + // Clearing the assignment (roomId null) persists as unassigned. + const cleared = await nodeStore.assignChannelRooms(nodeId, [ + { channelIndex: 1, interfaceId, roomId: null }, + ]); + assert.equal(channelRoom(cleared, 1), undefined, "clearing the room persists as unassigned"); +}); diff --git a/apps/api/test/oidc-groups-collision.test.ts b/apps/api/test/oidc-groups-collision.test.ts index 419ff8a..9872de1 100644 --- a/apps/api/test/oidc-groups-collision.test.ts +++ b/apps/api/test/oidc-groups-collision.test.ts @@ -1,16 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { createPgliteDatabase } from "@rakkr/db"; import { accessGroupSlug, type AzureAdOidcClaims } from "@rakkr/shared"; const { LocalAuthService } = await import("../src/auth-service.js"); const { normalizeAzureAdOidcUser } = await import("../src/oidc-sync.js"); -// The persistence-level collision check runs only when a Postgres test DB is -// provided (repo convention); otherwise it skips and opens no pool. In DB mode, -// run with `--test-force-exit` — the db client pool has no exposed close, so the -// process would otherwise idle until the runner's exit timeout. -const dbUrl = process.env.RAKKR_API_TEST_DATABASE_URL; - // Lets a test hand the normalizer deliberately malformed claim shapes that the // static AzureAdOidcClaims type would reject. function normalizeClaims(claims: unknown, extra: { groupIds?: string[] } = {}) { @@ -128,44 +123,41 @@ test("syncs a display-name group claim onto the operator slug through the servic ); }); -test( - "resolves an OIDC claim to an operator group without renaming it", - { skip: dbUrl ? false : "requires RAKKR_API_TEST_DATABASE_URL" }, - async () => { - const authService = new LocalAuthService(dbUrl); - - await authService.groups.deleteGroup("room-council").catch(() => undefined); - await authService.groups.deleteGroup("room council").catch(() => undefined); - - try { - const created = await authService.groups.createGroup({ - description: undefined, - memberIds: [], - name: "Room Council", - }); - - assert.equal(created.id, "room-council"); - - // A login whose claim differs only in casing/spacing must join the existing - // group (Fix A: shared slug) and must not clobber its curated name (Fix B) - // nor spawn a divergent "room council" twin. - const user = await authService.syncAzureAdOidcUser({ - claims: { - email: "db-collision@example.com", - groups: ["room council"], - sub: "subject-db-collision", - }, - }); - const groups = await authService.groups.localGroups(); - const matching = groups.filter((group) => group.id === "room-council"); - const detail = await authService.groups.group("room-council"); - - assert.equal(matching.length, 1); - assert.equal(matching[0]?.name, "Room Council"); - assert.ok(!groups.some((group) => group.id === "room council")); - assert.ok(detail?.members.some((member) => member.id === user.id)); - } finally { - await authService.groups.deleteGroup("room-council").catch(() => undefined); - } - }, -); +test("resolves an OIDC claim to an operator group without renaming it", async () => { + // Real Postgres SQL semantics via an in-process PGlite (WASM Postgres) + // database — no running server required. Scoped to this test so the fast + // in-memory normalizer cases above stay server-free. + const pglite = await createPgliteDatabase("oidc-groups-collision"); + const authService = new LocalAuthService(pglite.url); + + try { + const created = await authService.groups.createGroup({ + description: undefined, + memberIds: [], + name: "Room Council", + }); + + assert.equal(created.id, "room-council"); + + // A login whose claim differs only in casing/spacing must join the existing + // group (Fix A: shared slug) and must not clobber its curated name (Fix B) + // nor spawn a divergent "room council" twin. + const user = await authService.syncAzureAdOidcUser({ + claims: { + email: "db-collision@example.com", + groups: ["room council"], + sub: "subject-db-collision", + }, + }); + const groups = await authService.groups.localGroups(); + const matching = groups.filter((group) => group.id === "room-council"); + const detail = await authService.groups.group("room-council"); + + assert.equal(matching.length, 1); + assert.equal(matching[0]?.name, "Room Council"); + assert.ok(!groups.some((group) => group.id === "room council")); + assert.ok(detail?.members.some((member) => member.id === user.id)); + } finally { + await pglite.close(); + } +}); diff --git a/apps/api/test/oidc-user-linking.test.ts b/apps/api/test/oidc-user-linking.test.ts index 7b882f5..f35fbeb 100644 --- a/apps/api/test/oidc-user-linking.test.ts +++ b/apps/api/test/oidc-user-linking.test.ts @@ -1,14 +1,16 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import test from "node:test"; -import { createDatabase, eq, users, type AzureAdOidcClaims } from "@rakkr/db"; +import test, { after } from "node:test"; +import { createDatabase, createPgliteDatabase, eq, users, type AzureAdOidcClaims } from "@rakkr/db"; const { LocalAuthService } = await import("../src/auth-service.js"); const { normalizeAzureAdOidcUser } = await import("../src/oidc-sync.js"); -// Persistence-level linking runs only with a Postgres test DB (repo convention); -// in DB mode run with `--test-force-exit` (the client pool has no exposed close). -const dbUrl = process.env.RAKKR_API_TEST_DATABASE_URL; +// Persistence-level linking runs against real Postgres SQL semantics via an +// in-process PGlite (WASM Postgres) database, so it needs no running server. +const pglite = await createPgliteDatabase("oidc-user-linking"); + +after(() => pglite.close()); function claims(input: Record): AzureAdOidcClaims { return input as AzureAdOidcClaims; @@ -66,89 +68,77 @@ test("memory: a second subject may not claim an email already owned by another a ); }); -test( - "DB: an OIDC login must NOT take over an existing local account by email (R38-OIDC-EMAIL-LINK)", - { skip: dbUrl ? false : "requires RAKKR_API_TEST_DATABASE_URL" }, - async () => { - const db = createDatabase(dbUrl!); - const auth = new LocalAuthService(dbUrl); - const victimEmail = `linking-victim-${randomUUID()}@example.com`; - - await db - .insert(users) - .values({ email: victimEmail, name: "Local Victim", passwordHash: "x", provider: "local" }); - - try { - // A federated login presenting the local user's email but a fresh subject - // must be REFUSED, never merged onto the local (owner-capable) row. - await assert.rejects( - auth.syncAzureAdOidcUser({ - claims: claims({ email: victimEmail, oid: "attacker-subject", sub: "attacker-subject" }), - }), - (error: unknown) => error instanceof Error && /already linked/.test(error.message), - ); - - const [row] = await db.select().from(users).where(eq(users.email, victimEmail)).limit(1); - assert.equal(row?.provider, "local", "victim row must stay local"); - assert.equal(row?.externalId, null, "victim row must not be bound to the federated subject"); - } finally { - await db.delete(users).where(eq(users.email, victimEmail)); - } - }, -); - -test( - "DB: subject-linking creates one account and re-links it across email changes", - { skip: dbUrl ? false : "requires RAKKR_API_TEST_DATABASE_URL" }, - async () => { - const db = createDatabase(dbUrl!); - const auth = new LocalAuthService(dbUrl); - const subject = `link-subject-${randomUUID()}`; - const firstEmail = `link-first-${randomUUID()}@example.com`; - const secondEmail = `link-second-${randomUUID()}@example.com`; - - try { - const first = await auth.syncAzureAdOidcUser({ - claims: claims({ email: firstEmail, oid: subject, sub: subject }), - }); - const renamed = await auth.syncAzureAdOidcUser({ - claims: claims({ email: secondEmail, oid: subject, sub: subject }), - }); - - assert.equal(first.id, renamed.id, "same subject must resolve to one account"); - assert.equal(renamed.email, secondEmail); - } finally { - await db.delete(users).where(eq(users.email, secondEmail)); - await db.delete(users).where(eq(users.email, firstEmail)); - } - }, -); - -test( - "DB: a legacy email-linked OIDC row (no external_id) is adopted on next login, not duplicated", - { skip: dbUrl ? false : "requires RAKKR_API_TEST_DATABASE_URL" }, - async () => { - const db = createDatabase(dbUrl!); - const auth = new LocalAuthService(dbUrl); - const legacyEmail = `link-legacy-${randomUUID()}@example.com`; - const subject = `legacy-subject-${randomUUID()}`; - - const [legacy] = await db - .insert(users) - .values({ email: legacyEmail, name: "Legacy OIDC", provider: "oidc" }) - .returning({ id: users.id }); - - try { - const user = await auth.syncAzureAdOidcUser({ - claims: claims({ email: legacyEmail, oid: subject, sub: subject }), - }); - - assert.equal(user.id, legacy?.id, "legacy row must be adopted, not duplicated"); - - const [row] = await db.select().from(users).where(eq(users.email, legacyEmail)).limit(1); - assert.equal(row?.externalId, subject, "legacy row must be backfilled with the subject"); - } finally { - await db.delete(users).where(eq(users.email, legacyEmail)); - } - }, -); +test("DB: an OIDC login must NOT take over an existing local account by email (R38-OIDC-EMAIL-LINK)", async () => { + const db = createDatabase(pglite.url); + const auth = new LocalAuthService(pglite.url); + const victimEmail = `linking-victim-${randomUUID()}@example.com`; + + await db + .insert(users) + .values({ email: victimEmail, name: "Local Victim", passwordHash: "x", provider: "local" }); + + try { + // A federated login presenting the local user's email but a fresh subject + // must be REFUSED, never merged onto the local (owner-capable) row. + await assert.rejects( + auth.syncAzureAdOidcUser({ + claims: claims({ email: victimEmail, oid: "attacker-subject", sub: "attacker-subject" }), + }), + (error: unknown) => error instanceof Error && /already linked/.test(error.message), + ); + + const [row] = await db.select().from(users).where(eq(users.email, victimEmail)).limit(1); + assert.equal(row?.provider, "local", "victim row must stay local"); + assert.equal(row?.externalId, null, "victim row must not be bound to the federated subject"); + } finally { + await db.delete(users).where(eq(users.email, victimEmail)); + } +}); + +test("DB: subject-linking creates one account and re-links it across email changes", async () => { + const db = createDatabase(pglite.url); + const auth = new LocalAuthService(pglite.url); + const subject = `link-subject-${randomUUID()}`; + const firstEmail = `link-first-${randomUUID()}@example.com`; + const secondEmail = `link-second-${randomUUID()}@example.com`; + + try { + const first = await auth.syncAzureAdOidcUser({ + claims: claims({ email: firstEmail, oid: subject, sub: subject }), + }); + const renamed = await auth.syncAzureAdOidcUser({ + claims: claims({ email: secondEmail, oid: subject, sub: subject }), + }); + + assert.equal(first.id, renamed.id, "same subject must resolve to one account"); + assert.equal(renamed.email, secondEmail); + } finally { + await db.delete(users).where(eq(users.email, secondEmail)); + await db.delete(users).where(eq(users.email, firstEmail)); + } +}); + +test("DB: a legacy email-linked OIDC row (no external_id) is adopted on next login, not duplicated", async () => { + const db = createDatabase(pglite.url); + const auth = new LocalAuthService(pglite.url); + const legacyEmail = `link-legacy-${randomUUID()}@example.com`; + const subject = `legacy-subject-${randomUUID()}`; + + const [legacy] = await db + .insert(users) + .values({ email: legacyEmail, name: "Legacy OIDC", provider: "oidc" }) + .returning({ id: users.id }); + + try { + const user = await auth.syncAzureAdOidcUser({ + claims: claims({ email: legacyEmail, oid: subject, sub: subject }), + }); + + assert.equal(user.id, legacy?.id, "legacy row must be adopted, not duplicated"); + + const [row] = await db.select().from(users).where(eq(users.email, legacyEmail)).limit(1); + assert.equal(row?.externalId, subject, "legacy row must be backfilled with the subject"); + } finally { + await db.delete(users).where(eq(users.email, legacyEmail)); + } +}); diff --git a/apps/api/test/recording-chunk-size-bigint.test.ts b/apps/api/test/recording-chunk-size-bigint.test.ts index 4eaa7d0..8d0f295 100644 --- a/apps/api/test/recording-chunk-size-bigint.test.ts +++ b/apps/api/test/recording-chunk-size-bigint.test.ts @@ -1,39 +1,33 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import test from "node:test"; -import { createDatabase, eq, recordingChunks } from "@rakkr/db"; +import test, { after } from "node:test"; +import { createDatabase, createPgliteDatabase, eq, recordingChunks } from "@rakkr/db"; // Exercises the recording_chunks.size_bytes column width directly (bypassing the // store's DB->JSON failover, which would otherwise mask a Postgres error). Runs -// only when a test DB is provided via RAKKR_API_TEST_DATABASE_URL. -// -// In DB mode, run with `--test-force-exit` — the db client pool has no exposed -// close. -const dbUrl = process.env.RAKKR_API_TEST_DATABASE_URL; +// against an in-process PGlite (WASM Postgres) database, so it needs no running +// server and is part of the default suite. +const pglite = await createPgliteDatabase("recording-chunk-size-bigint"); -test( - "recording chunk size_bytes stores values beyond the 32-bit integer ceiling", - { - skip: dbUrl ? false : "requires RAKKR_API_TEST_DATABASE_URL (Postgres)", - }, - async () => { - const db = createDatabase(dbUrl as string); - const id = `chunk_${randomUUID()}`; - // > 2^31-1 (2,147,483,647): a single 32-channel WAV chunk overflows a 32-bit - // column, which threw "integer out of range" and failed the chunk upsert. - const sizeBytes = 3_000_000_000; +after(() => pglite.close()); - await db.insert(recordingChunks).values({ - id, - index: 1, - jobId: `job_${randomUUID()}`, - recordingId: `rec_${randomUUID()}`, - sizeBytes, - status: "cached", - }); +test("recording chunk size_bytes stores values beyond the 32-bit integer ceiling", async () => { + const db = createDatabase(pglite.url); + const id = `chunk_${randomUUID()}`; + // > 2^31-1 (2,147,483,647): a single 32-channel WAV chunk overflows a 32-bit + // column, which threw "integer out of range" and failed the chunk upsert. + const sizeBytes = 3_000_000_000; - const [row] = await db.select().from(recordingChunks).where(eq(recordingChunks.id, id)); + await db.insert(recordingChunks).values({ + id, + index: 1, + jobId: `job_${randomUUID()}`, + recordingId: `rec_${randomUUID()}`, + sizeBytes, + status: "cached", + }); - assert.equal(row?.sizeBytes, sizeBytes, "a >2GB chunk size must round-trip, not overflow"); - }, -); + const [row] = await db.select().from(recordingChunks).where(eq(recordingChunks.id, id)); + + assert.equal(row?.sizeBytes, sizeBytes, "a >2GB chunk size must round-trip, not overflow"); +}); diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 15913ce..a2aaba3 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -123,12 +123,15 @@ date, highest `0046`). The workflow: ```powershell mise run db:generate # drizzle-kit generate — emit SQL from schema.ts mise run db:migrate # drizzle-kit migrate — apply to DATABASE_URL -mise run db:verify # replay all migrations against a throwaway database, then drop it +mise run db:verify # replay all migrations against an in-process PGlite database ``` Rules: edit `schema.ts` first, generate, review the emitted SQL + snapshot, run `db:verify`, and commit the generated files with the schema change. `db:verify` -is part of the full `mise run check` gate and requires a working Postgres. +is part of the full `mise run check` gate and replays the migrations against an +in-process PGlite (WASM Postgres) database, so it needs no Docker/Postgres server. +Real-server migration application still runs in CI: the `node:test-db` concurrency +harness applies the same migrations against a throwaway Postgres before its tests. ## Shared contracts diff --git a/docs/contributing/development.md b/docs/contributing/development.md index f7b0c0f..a02de27 100644 --- a/docs/contributing/development.md +++ b/docs/contributing/development.md @@ -102,7 +102,7 @@ Edit `packages/db/src/schema.ts` first, then: ```powershell mise run db:generate # emit migration SQL mise run db:migrate # apply locally -mise run db:verify # replay against a throwaway database +mise run db:verify # replay against in-process PGlite (no Docker) ``` Review and commit the generated SQL and metadata with the schema change. See the diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 27f022a..98b3596 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -18,14 +18,37 @@ pnpm --filter @rakkr/api test # controller API pnpm --filter @rakkr/web test # web console helpers/components pnpm --filter @rakkr/shared check # shared contracts type-check pnpm --filter @rakkr/db check # db package type-check +mise run node:test-db # concurrency/race DB tests (needs Postgres) ``` The API test runner sets `RAKKR_API_NO_LISTEN=1` and, by default, **removes -`DATABASE_URL`** so tests run against the in-memory/JSON fallback stores. Set -`RAKKR_API_TEST_DATABASE_URL` to exercise the Postgres path. This is why the -controller is designed to work without a database — see the +`DATABASE_URL`** so tests run against the in-memory/JSON fallback stores. This is +why the controller is designed to work without a database — see the [data model](../architecture/data-model.md). +### Database-backed tests + +DB tests come in two flavours, chosen by what they need to exercise: + +- **Persistence / round-trip** tests (real SQL semantics: constraints, bigint + widths, JSONB, transactions, per-column persistence) run against an **in-process + PGlite** (WASM Postgres) database, so they need **no running server** and are + part of the default `node:test` suite. A test calls `createPgliteDatabase()` + from `@rakkr/db`, which spins up a fresh instance, applies the Drizzle + migrations, and hands back a `pglite://…` url that `createDatabase` (and thus + every store and `LocalAuthService`) resolves to that instance. Set + `DATABASE_URL` to the url (or pass it directly), and close the handle in an + `after`/`finally`. +- **Concurrency / race** tests (row-lock and atomic compare-and-set contention — + double-claim, last-writer-wins, FK races) need **genuinely concurrent Postgres + connections**. PGlite is single-connection and serializes transactions, so it + cannot reproduce them — a race test on PGlite would pass even with the + production lock removed, giving false confidence. These keep the + `RAKKR_API_TEST_DATABASE_URL` skip guard and run via `mise run node:test-db`, + which provisions a throwaway Postgres, migrates it, runs the tagged files + (listed in `packages/db/scripts/run-db-integration-tests.mjs`), and drops it. + Set `RAKKR_API_TEST_DATABASE_URL` to point at a reachable Postgres. + Rust tests, Clippy, and Miri run via: ```powershell diff --git a/docs/internal/baselines/AZURE_AD_OIDC_BASELINE.md b/docs/internal/baselines/AZURE_AD_OIDC_BASELINE.md index 7bbe181..8bc75ae 100644 --- a/docs/internal/baselines/AZURE_AD_OIDC_BASELINE.md +++ b/docs/internal/baselines/AZURE_AD_OIDC_BASELINE.md @@ -101,10 +101,14 @@ synced (Graph resolution is out of scope). at the local fake provider; it can never relax transport security for a remote issuer. - Group-collision and weird-claim coverage lives in - `apps/api/test/oidc-groups-collision.test.ts`. The in-memory cases run in the - default suite; the persistence-level cases (group id collision, no-rename on - login) run in CI via the `node:test-db` task, which provisions a throwaway - Postgres database (like `db:verify`) and is part of `mise run check`. + `apps/api/test/oidc-groups-collision.test.ts`. The in-memory cases and the + persistence-level cases (group id collision, no-rename on login) both run in the + default `node:test` suite: the persistence cases use an in-process PGlite (WASM + Postgres) database via `createPgliteDatabase()`, so they need no server. The + concurrent first-login/email-conflict race in + `apps/api/test/oidc-race-conflict.test.ts` needs real concurrent connections, so + it runs via the `node:test-db` task against a throwaway Postgres — part of + `mise run check`. ## Checked By diff --git a/docs/reference/tasks.md b/docs/reference/tasks.md index 31ed433..ae241ae 100644 --- a/docs/reference/tasks.md +++ b/docs/reference/tasks.md @@ -47,7 +47,7 @@ calendar-versioned tag that triggers that component's release workflow — see | -------------------------------------------- | --------------------------- | | `mise run node:check` | TypeScript type-check. | | `mise run node:test` | Node test suites. | -| `mise run node:test-db` | DB-backed Node tests against a throwaway Postgres. | +| `mise run node:test-db` | Concurrency/race DB tests against a throwaway Postgres (real connections). | | `mise run node:lint` | oxlint. | | `mise run node:format` / `node:format-check` | oxfmt write / check. | | `mise run node:build` | Build TS packages and apps. | @@ -68,7 +68,7 @@ calendar-versioned tag that triggers that component's release workflow — see | ---------------------- | --------------------------------------------------- | | `mise run db:generate` | Generate Drizzle migration SQL from the schema. | | `mise run db:migrate` | Apply migrations to `DATABASE_URL`. | -| `mise run db:verify` | Replay all migrations against a throwaway database. | +| `mise run db:verify` | Replay all migrations against an in-process PGlite database (no Docker). | ## Baseline verifiers diff --git a/packages/db/package.json b/packages/db/package.json index 26c8619..effff49 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -27,6 +27,7 @@ "postgres": "^3.4.9" }, "devDependencies": { + "@electric-sql/pglite": "^0.5.4", "@types/node": "^26.0.0", "drizzle-kit": "^0.31.10", "typescript": "^6.0.3" diff --git a/packages/db/scripts/run-db-integration-tests.mjs b/packages/db/scripts/run-db-integration-tests.mjs index 7a6833c..a3f34a5 100644 --- a/packages/db/scripts/run-db-integration-tests.mjs +++ b/packages/db/scripts/run-db-integration-tests.mjs @@ -3,23 +3,30 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import postgres from "postgres"; -// DB-backed Node tests that need a real Postgres (skipped by the default -// in-memory suite). This provisions an isolated throwaway database on the -// configured server — same contract as db:verify: a Postgres must be reachable at -// DATABASE_URL — migrates it, runs the tagged test files against it with -// --test-force-exit (the api db client pool has no exposed close), then drops it. -// Add DB-gated test files here so they run as part of `mise run check`. +// Concurrency/race Node tests that need GENUINELY concurrent Postgres connections +// (real parallel backends contending on FOR UPDATE row locks / atomic +// compare-and-set). These CANNOT run on the in-process PGlite used by the default +// suite: PGlite is single-connection and serializes transactions, so a race test +// would pass vacuously — even with the production lock removed — giving false +// confidence. They therefore keep a real Postgres. +// +// This provisions an isolated throwaway database on the configured server — same +// contract as db:verify: a Postgres must be reachable at DATABASE_URL — migrates +// it, runs the tagged test files against it with --test-force-exit (the api db +// client pool has no exposed close), then drops it. Add concurrency/race test +// files here so they run as part of `mise run check`. +// +// Persistence/round-trip DB tests do NOT belong here — they run against PGlite in +// the default suite via createPgliteDatabase() (see packages/db/src/client.ts). const dbBackedApiTests = [ - "test/oidc-groups-collision.test.ts", - "test/auth-login-constraint.test.ts", "test/node-ssh-credential-rotation-atomic.test.ts", "test/node-credential-rotation-atomic.test.ts", "test/node-metadata-write-race.test.ts", - "test/recording-chunk-size-bigint.test.ts", + "test/recording-job-claim-atomic.test.ts", + "test/oidc-race-conflict.test.ts", "test/upload-queue-write-race.test.ts", "test/controller-settings-write-race.test.ts", "test/room-delete-fk-race.test.ts", - "test/node-channel-room-pg.test.ts", ]; const DEFAULT_DATABASE_URL = "postgres://rakkr:rakkr@127.0.0.1:5432/rakkr"; diff --git a/packages/db/scripts/verify-migrations.mjs b/packages/db/scripts/verify-migrations.mjs index c850026..c85a578 100644 --- a/packages/db/scripts/verify-migrations.mjs +++ b/packages/db/scripts/verify-migrations.mjs @@ -1,52 +1,25 @@ -import { randomUUID } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import path from "node:path"; -import postgres from "postgres"; - -const DEFAULT_DATABASE_URL = "postgres://rakkr:rakkr@127.0.0.1:5432/rakkr"; - -const baseUrl = new URL(process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL); -const probeDatabase = `rakkr_drizzle_verify_${randomUUID().replaceAll("-", "_")}`; -const adminUrl = new URL(baseUrl); -adminUrl.pathname = "/postgres"; - -const admin = postgres(adminUrl.toString(), { max: 1 }); - -function quoteIdentifier(identifier) { - return `"${identifier.replaceAll('"', '""')}"`; -} - -function runMigration(probeUrl) { - const pnpmEntrypoint = process.env.npm_execpath; - const hasNodeEntrypoint = - pnpmEntrypoint && - path.isAbsolute(pnpmEntrypoint) && - [".cjs", ".js", ".mjs"].includes(path.extname(pnpmEntrypoint)); - const command = hasNodeEntrypoint ? process.execPath : "pnpm"; - const args = hasNodeEntrypoint ? [pnpmEntrypoint, "db:migrate"] : ["db:migrate"]; - const result = spawnSync(command, args, { - env: { ...process.env, DATABASE_URL: probeUrl.toString() }, - stdio: "inherit", - }); - - if (result.error) { - throw result.error; - } - - if (result.status !== 0) { - throw new Error(`Drizzle migration replay failed with exit code ${result.status ?? 1}`); - } -} +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; + +// Replays the full Drizzle migration set against an in-process PGlite (WASM +// Postgres) database. This validates that the committed migrations apply cleanly +// from an empty schema with no Docker/Postgres server — so it runs on any dev +// machine and in CI without provisioning. +// +// Note: PGlite is a real Postgres build, so migration DDL fidelity is high, but it +// is NOT the exact server version production runs. The concurrency harness +// (scripts/run-db-integration-tests.mjs) still applies these same migrations +// against a real Postgres via drizzle-kit before its tests, so real-server +// migration application stays covered in CI. +const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); +const client = new PGlite(); +const db = drizzle(client); try { - await admin.unsafe(`CREATE DATABASE ${quoteIdentifier(probeDatabase)}`); - - const probeUrl = new URL(baseUrl); - probeUrl.pathname = `/${probeDatabase}`; - - runMigration(probeUrl); - console.log(`Verified Drizzle migrations against ${probeDatabase}.`); + await migrate(db, { migrationsFolder }); + console.log("Verified Drizzle migrations against in-process PGlite."); } finally { - await admin.unsafe(`DROP DATABASE IF EXISTS ${quoteIdentifier(probeDatabase)} WITH (FORCE)`); - await admin.end(); + await client.close(); } diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index a07d7a4..58b3190 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; import { and, asc, @@ -17,14 +19,82 @@ import { import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; -export function createDatabase(databaseUrl: string) { +export type Database = ReturnType; + +const PGLITE_URL_PREFIX = "pglite://"; + +// In-process PGlite (WASM Postgres) databases created by createPgliteDatabase(), +// keyed by the sentinel URL handed back to the caller. createDatabase() resolves +// those URLs to the shared instance, so every store and service in the process +// reuses one migrated database — mirroring how the postgres-js pools all reach a +// single server. +const pgliteRegistry = new Map(); + +export function createDatabase(databaseUrl: string): Database { + if (databaseUrl.startsWith(PGLITE_URL_PREFIX)) { + const registered = pgliteRegistry.get(databaseUrl); + + if (!registered) { + throw new Error( + `PGlite database "${databaseUrl}" is not initialized; call createPgliteDatabase() before createDatabase().`, + ); + } + + return registered; + } + const client = postgres(databaseUrl, { max: 3 }); return drizzle(client); } -export async function closeDatabase(database: ReturnType) { +export async function closeDatabase(database: Database) { await database.$client.end(); } +export interface PgliteDatabaseHandle { + /** Sentinel DATABASE_URL that createDatabase() resolves to this instance. */ + url: string; + /** Drop the instance from the registry and release its resources. */ + close(): Promise; +} + +/** + * Provision an in-process PGlite (WASM Postgres) database, apply the Drizzle + * migrations, and register it so createDatabase(url) — and therefore every store + * and LocalAuthService in this process — resolves to it. This lets tests exercise + * the real Postgres SQL path without a running server. + * + * PGlite is loaded via dynamic import so it never enters the production + * postgres-js path. It is single-connection, so it is deliberately NOT a + * substitute for tests that require genuine concurrent connections (row-lock / + * atomic compare-and-set races) — those keep a real Postgres. + */ +export async function createPgliteDatabase(label = "test"): Promise { + const [{ PGlite }, { drizzle: pgliteDrizzle }, { migrate }] = await Promise.all([ + import("@electric-sql/pglite"), + import("drizzle-orm/pglite"), + import("drizzle-orm/pglite/migrator"), + ]); + + const client = new PGlite(); + const db = pgliteDrizzle(client); + const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); + await migrate(db, { migrationsFolder }); + + const url = `${PGLITE_URL_PREFIX}${label}-${randomUUID()}`; + // The PGlite driver exposes the same query builders as postgres-js drizzle, so + // the stores treat it identically; the surface differences (the underlying + // client, migrations) are handled here, not by callers. + pgliteRegistry.set(url, db as unknown as Database); + + return { + url, + async close() { + pgliteRegistry.delete(url); + await client.close(); + }, + }; +} + export { and, asc, count, desc, eq, gt, gte, ilike, inArray, isNull, lte, or, sql, type SQL }; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index c99ec7b..7959233 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node"] }, "include": ["src/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb5a85d..201c5bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,11 +196,14 @@ importers: dependencies: drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@cloudflare/workers-types@4.20260627.1)(postgres@3.4.9) + version: 0.45.2(@cloudflare/workers-types@4.20260627.1)(@electric-sql/pglite@0.5.4)(postgres@3.4.9) postgres: specifier: ^3.4.9 version: 3.4.9 devDependencies: + '@electric-sql/pglite': + specifier: ^0.5.4 + version: 0.5.4 '@types/node': specifier: ^26.0.0 version: 26.0.0 @@ -634,6 +637,9 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@electric-sql/pglite@0.5.4': + resolution: {integrity: sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g==} + '@emmetio/abbreviation@2.3.3': resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} @@ -5138,6 +5144,8 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@electric-sql/pglite@0.5.4': {} + '@emmetio/abbreviation@2.3.3': dependencies: '@emmetio/scanner': 1.0.4 @@ -6920,9 +6928,10 @@ snapshots: esbuild: 0.25.12 tsx: 4.22.4 - drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(postgres@3.4.9): + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@electric-sql/pglite@0.5.4)(postgres@3.4.9): optionalDependencies: '@cloudflare/workers-types': 4.20260627.1 + '@electric-sql/pglite': 0.5.4 postgres: 3.4.9 dset@3.1.4: {}