From 9482a97795dfc39ade1c8305e6dbcb5c3962b6c2 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 16 Sep 2026 22:32:48 +0200 Subject: [PATCH] feat(api): Postgres persistence for registries + scans (owner-scoped, fail-closed) - schema migration 001 (registries, scans; owner_sub = introspected subject) - pg pool + services; scan ownership enforced in SQL (INSERT ... SELECT WHERE owner_sub) - real handlers: 201/400/404/500, owner-scoped GET/POST, listScans registryId filter - graceful shutdown closes pool; 7 new persistence tests (21/21) --- apps/api/migrations/001_registries_scans.sql | 26 ++++ apps/api/package.json | 4 +- apps/api/src/db/pool.ts | 22 ++++ apps/api/src/index.ts | 118 ++++++++++++++++--- apps/api/src/services/registries.ts | 56 +++++++++ apps/api/src/services/scans.ts | 70 +++++++++++ apps/api/tests/persistence.test.ts | 87 ++++++++++++++ pnpm-lock.yaml | 116 ++++++++++++++++++ 8 files changed, 480 insertions(+), 19 deletions(-) create mode 100644 apps/api/migrations/001_registries_scans.sql create mode 100644 apps/api/src/db/pool.ts create mode 100644 apps/api/src/services/registries.ts create mode 100644 apps/api/src/services/scans.ts create mode 100644 apps/api/tests/persistence.test.ts diff --git a/apps/api/migrations/001_registries_scans.sql b/apps/api/migrations/001_registries_scans.sql new file mode 100644 index 0000000..13893f0 --- /dev/null +++ b/apps/api/migrations/001_registries_scans.sql @@ -0,0 +1,26 @@ +-- C0PY 001 — registries + scans (canonical capture target registry) +-- Idempotent; owner_sub is the Zitadel introspection subject (never client-controlled). + +CREATE TABLE IF NOT EXISTS registries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_sub TEXT NOT NULL, + name TEXT NOT NULL, + url TEXT NOT NULL, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS registries_owner_idx ON registries(owner_sub); + +CREATE TABLE IF NOT EXISTS scans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + registry_id UUID NOT NULL REFERENCES registries(id) ON DELETE CASCADE, + owner_sub TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + result JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS scans_owner_idx ON scans(owner_sub); +CREATE INDEX IF NOT EXISTS scans_registry_idx ON scans(registry_id); diff --git a/apps/api/package.json b/apps/api/package.json index 295e818..486ca68 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,14 +11,16 @@ "test": "vitest run" }, "dependencies": { - "@siax/c0py-core": "workspace:*", "@siax/c0py-config": "workspace:*", + "@siax/c0py-core": "workspace:*", "@siax/c0py-types": "workspace:*", "fastify": "^5.12.0", + "pg": "^8.23.0", "zod": "^3.24.0" }, "devDependencies": { "@types/node": "^24.0.0", + "@types/pg": "^8.23.1", "tsx": "^4.21.0", "typescript": "^5.7.2", "vitest": "^3.0.0" diff --git a/apps/api/src/db/pool.ts b/apps/api/src/db/pool.ts new file mode 100644 index 0000000..bcc2e5f --- /dev/null +++ b/apps/api/src/db/pool.ts @@ -0,0 +1,22 @@ +import { Pool } from "pg"; + +let pool: Pool | null = null; + +export function getPool(): Pool { + if (!pool) { + pool = new Pool({ + connectionString: process.env.DATABASE_URL, + max: 10, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + }); + } + return pool; +} + +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b4c877a..c0dc95f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,6 +1,13 @@ -import Fastify from "fastify"; +import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; import { envSchema } from "@siax/c0py-config"; import { createIntrospector } from "./auth/introspection.js"; +import { getPool, closePool } from "./db/pool.js"; +import { + listRegistries, + createRegistry, + getRegistry, +} from "./services/registries.js"; +import { createScan, listScans, getScan } from "./services/scans.js"; async function main() { const env = envSchema.parse(process.env); @@ -51,40 +58,115 @@ async function main() { } }); - server.get("/", async () => ({ - name: "c0py", - version: "0.1.0", - status: "running", - })); + const requireSub = (request: FastifyRequest): string => { + const user = (request as unknown as { user?: { sub?: string } }).user; + if (!user?.sub) { + throw Object.assign(new Error("missing subject"), { statusCode: 401 }); + } + return user.sub; + }; - // Registry endpoints - server.get("/v1/c0py/registries", async () => { - return { registries: [] }; + const dbError = (reply: FastifyReply, err: unknown) => { + reply.log.error(err, "database error"); + return reply.code(500).send({ error: "Internal Server Error" }); + }; + + // Registry endpoints (owner-scoped by introspected subject) + server.get("/v1/c0py/registries", async (request, reply) => { + try { + return { registries: await listRegistries(getPool(), requireSub(request)) }; + } catch (err) { + return dbError(reply, err); + } }); - server.post("/v1/c0py/registries", async (request) => { + server.post("/v1/c0py/registries", async (request, reply) => { const body = (request.body as Record) ?? {}; - return { created: body }; + const name = typeof body.name === "string" ? body.name.trim() : ""; + const url = typeof body.url === "string" ? body.url.trim() : ""; + if (!name || !url) { + await reply.code(400).send({ error: "name and url are required" }); + return; + } + try { + const registry = await createRegistry(getPool(), requireSub(request), { + name, + url, + config: body.config, + }); + await reply.code(201).send({ registry }); + } catch (err) { + return dbError(reply, err); + } }); - server.get("/v1/c0py/registries/:id", async (request) => { + server.get("/v1/c0py/registries/:id", async (request, reply) => { const { id } = request.params as Record; - return { id }; + try { + const registry = await getRegistry(getPool(), requireSub(request), id); + if (!registry) { + await reply.code(404).send({ error: "Not Found" }); + return; + } + return { registry }; + } catch (err) { + return dbError(reply, err); + } }); - // Scan endpoints - server.post("/v1/c0py/scans", async (request) => { + // Scan endpoints (owner-scoped, registry ownership enforced in SQL) + server.post("/v1/c0py/scans", async (request, reply) => { const body = (request.body as Record) ?? {}; - return { scanId: "scan-" + Date.now(), created: body }; + const registryId = typeof body.registryId === "string" ? body.registryId.trim() : ""; + if (!registryId) { + await reply.code(400).send({ error: "registryId is required" }); + return; + } + try { + const scan = await createScan(getPool(), requireSub(request), registryId); + if (!scan) { + await reply.code(404).send({ error: "Not Found" }); + return; + } + await reply.code(201).send({ scan }); + } catch (err) { + return dbError(reply, err); + } }); - server.get("/v1/c0py/scans/:id", async (request) => { + server.get("/v1/c0py/scans", async (request, reply) => { + const query = request.query as Record; + try { + return { scans: await listScans(getPool(), requireSub(request), query.registryId) }; + } catch (err) { + return dbError(reply, err); + } + }); + + server.get("/v1/c0py/scans/:id", async (request, reply) => { const { id } = request.params as Record; - return { id }; + try { + const scan = await getScan(getPool(), requireSub(request), id); + if (!scan) { + await reply.code(404).send({ error: "Not Found" }); + return; + } + return { scan }; + } catch (err) { + return dbError(reply, err); + } }); await server.listen({ port: env.PORT, host: "0.0.0.0" }); console.log(`c0py API listening on port ${env.PORT}`); + + for (const signal of ["SIGTERM", "SIGINT"] as const) { + process.on(signal, async () => { + await server.close(); + await closePool(); + process.exit(0); + }); + } } main().catch((err) => { diff --git a/apps/api/src/services/registries.ts b/apps/api/src/services/registries.ts new file mode 100644 index 0000000..28e7a62 --- /dev/null +++ b/apps/api/src/services/registries.ts @@ -0,0 +1,56 @@ +export interface RegistryRow { + id: string; + owner_sub: string; + name: string; + url: string; + config: unknown; + created_at: Date; +} + +export interface PoolLike { + query(text: string, values?: unknown[]): Promise<{ rows: Record[] }>; +} + +function toRegistry(row: Record): { + id: string; + name: string; + url: string; + config: unknown; + createdAt: string; +} { + return { + id: String(row.id), + name: String(row.name), + url: String(row.url), + config: row.config, + createdAt: new Date(row.created_at as string).toISOString(), + }; +} + +export async function listRegistries(db: PoolLike, ownerSub: string) { + const res = await db.query( + "SELECT id, owner_sub, name, url, config, created_at FROM registries WHERE owner_sub = $1 ORDER BY created_at DESC", + [ownerSub], + ); + return res.rows.map(toRegistry); +} + +export async function createRegistry( + db: PoolLike, + ownerSub: string, + input: { name: string; url: string; config?: unknown }, +) { + const res = await db.query( + "INSERT INTO registries (owner_sub, name, url, config) VALUES ($1, $2, $3, $4::jsonb) RETURNING id, owner_sub, name, url, config, created_at", + [ownerSub, input.name, input.url, JSON.stringify(input.config ?? {})], + ); + return toRegistry(res.rows[0]); +} + +export async function getRegistry(db: PoolLike, ownerSub: string, id: string) { + const res = await db.query( + "SELECT id, owner_sub, name, url, config, created_at FROM registries WHERE owner_sub = $1 AND id = $2", + [ownerSub, id], + ); + return res.rows[0] ? toRegistry(res.rows[0]) : null; +} diff --git a/apps/api/src/services/scans.ts b/apps/api/src/services/scans.ts new file mode 100644 index 0000000..6e0f178 --- /dev/null +++ b/apps/api/src/services/scans.ts @@ -0,0 +1,70 @@ +export interface ScanRow { + id: string; + registry_id: string; + owner_sub: string; + status: string; + result: unknown; + created_at: Date; + updated_at: Date; +} + +export interface PoolLike { + query(text: string, values?: unknown[]): Promise<{ rows: Record[] }>; +} + +function toScan(row: Record): { + id: string; + registryId: string; + status: string; + result: unknown; + createdAt: string; + updatedAt: string; +} { + return { + id: String(row.id), + registryId: String(row.registry_id), + status: String(row.status), + result: row.result, + createdAt: new Date(row.created_at as string).toISOString(), + updatedAt: new Date(row.updated_at as string).toISOString(), + }; +} + +export async function createScan( + db: PoolLike, + ownerSub: string, + registryId: string, +): Promise | null> { + // Ownership check happens in SQL: the scan may only reference a registry + // owned by the same subject. Inserting otherwise returns no rows (fail-closed). + const res = await db.query( + `INSERT INTO scans (registry_id, owner_sub, status) + SELECT id, $1, 'pending' FROM registries WHERE id = $2 AND owner_sub = $1 + RETURNING id, registry_id, owner_sub, status, result, created_at, updated_at`, + [ownerSub, registryId], + ); + return res.rows[0] ? toScan(res.rows[0]) : null; +} + +export async function listScans(db: PoolLike, ownerSub: string, registryId?: string) { + if (registryId !== undefined) { + const res = await db.query( + "SELECT id, registry_id, owner_sub, status, result, created_at, updated_at FROM scans WHERE owner_sub = $1 AND registry_id = $2 ORDER BY created_at DESC", + [ownerSub, registryId], + ); + return res.rows.map(toScan); + } + const res = await db.query( + "SELECT id, registry_id, owner_sub, status, result, created_at, updated_at FROM scans WHERE owner_sub = $1 ORDER BY created_at DESC", + [ownerSub], + ); + return res.rows.map(toScan); +} + +export async function getScan(db: PoolLike, ownerSub: string, id: string) { + const res = await db.query( + "SELECT id, registry_id, owner_sub, status, result, created_at, updated_at FROM scans WHERE owner_sub = $1 AND id = $2", + [ownerSub, id], + ); + return res.rows[0] ? toScan(res.rows[0]) : null; +} diff --git a/apps/api/tests/persistence.test.ts b/apps/api/tests/persistence.test.ts new file mode 100644 index 0000000..8c39756 --- /dev/null +++ b/apps/api/tests/persistence.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { listRegistries, createRegistry, getRegistry } from "../src/services/registries.js"; +import { createScan, listScans, getScan } from "../src/services/scans.js"; +import type { PoolLike } from "../src/services/registries.js"; + +function fakePool(handlers: { + query: (text: string, values?: unknown[]) => { rows: Record[] }; +}): PoolLike & { queries: { text: string; values?: unknown[] }[] } { + const queries: { text: string; values?: unknown[] }[] = []; + return { + queries, + query(text, values) { + queries.push({ text, values }); + return handlers.query(text, values); + }, + }; +} + +const ROW = { + id: "11111111-1111-1111-1111-111111111111", + owner_sub: "svc-user", + name: "example", + url: "https://example.com", + config: {}, + created_at: new Date("2026-09-16T10:00:00Z"), +}; + +describe("registries service", () => { + it("listRegistries filters by owner", async () => { + const db = fakePool({ query: () => ({ rows: [ROW] }) }); + const out = await listRegistries(db, "svc-user"); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ id: ROW.id, name: "example", createdAt: "2026-09-16T10:00:00.000Z" }); + expect(db.queries[0].values).toEqual(["svc-user"]); + expect(db.queries[0].text).toContain("owner_sub = $1"); + }); + + it("createRegistry inserts with owner and config jsonb", async () => { + const db = fakePool({ query: () => ({ rows: [ROW] }) }); + const out = await createRegistry(db, "svc-user", { name: "example", url: "https://example.com" }); + expect(out.id).toBe(ROW.id); + expect(db.queries[0].values?.[0]).toBe("svc-user"); + expect(db.queries[0].text).toContain("RETURNING"); + }); + + it("getRegistry returns null when not owner (fail-closed)", async () => { + const db = fakePool({ query: () => ({ rows: [] }) }); + const out = await getRegistry(db, "other-user", ROW.id); + expect(out).toBeNull(); + }); +}); + +describe("scans service", () => { + const SCAN_ROW = { + ...ROW, + registry_id: ROW.id, + status: "pending", + result: {}, + updated_at: ROW.created_at, + }; + + it("createScan enforces registry ownership in SQL and returns null otherwise", async () => { + const db = fakePool({ query: () => ({ rows: [] }) }); + const out = await createScan(db, "svc-user", ROW.id); + expect(out).toBeNull(); + expect(db.queries[0].text).toContain("owner_sub = $1"); + }); + + it("createScan returns scan for owned registry", async () => { + const db = fakePool({ query: () => ({ rows: [SCAN_ROW] }) }); + const out = await createScan(db, "svc-user", ROW.id); + expect(out).toMatchObject({ id: SCAN_ROW.id, registryId: SCAN_ROW.id, status: "pending" }); + }); + + it("listScans supports optional registryId filter", async () => { + const db = fakePool({ query: () => ({ rows: [SCAN_ROW] }) }); + await listScans(db, "svc-user"); + expect(db.queries[0].text).not.toContain("registry_id = $2"); + await listScans(db, "svc-user", SCAN_ROW.id); + expect(db.queries[1].values).toEqual(["svc-user", SCAN_ROW.id]); + }); + + it("getScan returns null for foreign scan", async () => { + const db = fakePool({ query: () => ({ rows: [] }) }); + expect(await getScan(db, "other-user", SCAN_ROW.id)).toBeNull(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ae6c72..e0880e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: fastify: specifier: ^5.12.0 version: 5.12.5 + pg: + specifier: ^8.23.0 + version: 8.23.0 zod: specifier: ^3.24.0 version: 3.25.76 @@ -54,6 +57,9 @@ importers: '@types/node': specifier: ^24.0.0 version: 24.13.5 + '@types/pg': + specifier: ^8.23.1 + version: 8.23.1 tsx: specifier: ^4.21.0 version: 4.23.13 @@ -716,6 +722,9 @@ packages: '@types/node@24.13.5': resolution: {integrity: sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==} + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: @@ -1239,6 +1248,40 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1264,6 +1307,22 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1562,6 +1621,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1973,6 +2036,12 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/pg@8.23.1': + dependencies: + '@types/node': 24.13.5 + pg-protocol: 1.16.0 + pg-types: 2.2.0 + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -2552,6 +2621,41 @@ snapshots: pathval@2.0.1: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.0): + dependencies: + pg: 8.23.0 + + pg-protocol@1.16.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -2588,6 +2692,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier@3.9.7: {} @@ -2878,6 +2992,8 @@ snapshots: word-wrap@1.2.5: {} + xtend@4.0.2: {} + yocto-queue@0.1.0: {} zod@3.25.76: {}