diff --git a/.github/workflows/lambda-deploy.yml b/.github/workflows/lambda-deploy.yml index e0c199b4..38bf5406 100644 --- a/.github/workflows/lambda-deploy.yml +++ b/.github/workflows/lambda-deploy.yml @@ -7,6 +7,7 @@ on: - 'apps/backend/lambdas/**' - 'shared/types/**' - 'shared/lambda-http/**' + - 'shared/store/**' - 'apps/backend/db/migrations/**' workflow_dispatch: inputs: @@ -42,7 +43,7 @@ jobs: migrate=false code=false grep -q '^apps/backend/db/migrations/' <<<"$changed_files" && migrate=true - grep -qE '^(apps/backend/lambdas/|shared/types/|shared/lambda-http/)' <<<"$changed_files" && code=true + grep -qE '^(apps/backend/lambdas/|shared/types/|shared/lambda-http/|shared/store/)' <<<"$changed_files" && code=true # workflow_dispatch has no meaningful diff: apply everything, or # migrations only when explicitly asked (recovery / manual re-run). diff --git a/apps/backend/db/migrations/20260906215733_move_rollups_to_application.sql b/apps/backend/db/migrations/20260906215733_move_rollups_to_application.sql new file mode 100644 index 00000000..d49275ef --- /dev/null +++ b/apps/backend/db/migrations/20260906215733_move_rollups_to_application.sql @@ -0,0 +1,46 @@ +-- 20260906215733_move_rollups_to_application +-- +-- Aurora DSQL supports neither triggers nor PL/pgSQL, so rollup maintenance moves +-- into @branch/store. The three LANGUAGE sql helpers stay -- DSQL supports those, +-- and they hold the arithmetic; only the INSERT/UPDATE/DELETE dispatch moves. +-- +-- The two FKs that flip to RESTRICT are the ones whose cascade used to fire a row +-- trigger from a lambda that never names the rollup tables (DELETE /donors/{id}, +-- DELETE /users/{id}). RESTRICT turns a forgotten cascade into a loud FK error +-- instead of a silently wrong total. @branch/store deletes the children first. + +DROP TRIGGER IF EXISTS expenditures_rollup_sync ON expenditures; +DROP TRIGGER IF EXISTS donations_rollup_sync ON project_donations; +DROP TRIGGER IF EXISTS memberships_rollup_sync ON project_memberships; +DROP TRIGGER IF EXISTS reports_rollup_sync ON reports; +DROP TRIGGER IF EXISTS projects_rollup_seed ON projects; + +DROP TRIGGER IF EXISTS expenditures_rollup_truncate ON expenditures; +DROP TRIGGER IF EXISTS donations_rollup_truncate ON project_donations; +DROP TRIGGER IF EXISTS memberships_rollup_truncate ON project_memberships; +DROP TRIGGER IF EXISTS reports_rollup_truncate ON reports; + +DROP FUNCTION branch.expenditures_rollup_sync(); +DROP FUNCTION branch.donations_rollup_sync(); +DROP FUNCTION branch.memberships_rollup_sync(); +DROP FUNCTION branch.reports_rollup_sync(); +DROP FUNCTION branch.projects_rollup_seed(); +DROP FUNCTION branch.expenditures_rollup_truncate(); +DROP FUNCTION branch.donations_rollup_truncate(); +DROP FUNCTION branch.memberships_rollup_truncate(); +DROP FUNCTION branch.reports_rollup_truncate(); + +-- NOT VALID because DSQL requires it on ALTER TABLE ADD CONSTRAINT, and it is +-- accurate either way: the constraint being replaced referenced the same rows, +-- so existing data cannot violate the new one. Only the delete action changes. +ALTER TABLE project_donations + DROP CONSTRAINT project_donations_donor_id_fkey; +ALTER TABLE project_donations + ADD CONSTRAINT project_donations_donor_id_fkey + FOREIGN KEY (donor_id) REFERENCES donors(donor_id) ON DELETE RESTRICT NOT VALID; + +ALTER TABLE project_memberships + DROP CONSTRAINT project_memberships_user_id_fkey; +ALTER TABLE project_memberships + ADD CONSTRAINT project_memberships_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE RESTRICT NOT VALID; diff --git a/apps/backend/db/migrations/20260907213524_project_rollup_bump_reports_hit.sql b/apps/backend/db/migrations/20260907213524_project_rollup_bump_reports_hit.sql new file mode 100644 index 00000000..7ab47e1c --- /dev/null +++ b/apps/backend/db/migrations/20260907213524_project_rollup_bump_reports_hit.sql @@ -0,0 +1,23 @@ +-- 20260907213524_project_rollup_bump_reports_hit +-- +-- Returns 1/NULL, not void: only the store seeds project_rollup now, so a bump +-- matching no row must fail rather than drift. + +DROP FUNCTION branch.project_rollup_bump(INT, INT, NUMERIC, INT, INT); + +CREATE FUNCTION branch.project_rollup_bump( + p_project_id INT, + p_members INT, + p_donated NUMERIC, + p_donations INT, + p_reports INT +) RETURNS integer LANGUAGE sql AS $$ + UPDATE branch.project_rollup + SET member_count = member_count + p_members, + total_donated = total_donated + p_donated, + donation_count = donation_count + p_donations, + report_count = report_count + p_reports, + updated_at = CURRENT_TIMESTAMP + WHERE project_id = p_project_id + RETURNING 1; +$$; diff --git a/apps/backend/db/testkit.ts b/apps/backend/db/testkit.ts index 0ec21657..9546f960 100644 --- a/apps/backend/db/testkit.ts +++ b/apps/backend/db/testkit.ts @@ -196,6 +196,98 @@ async function truncateAll(client: Queryable): Promise { export async function resetData(client: Queryable): Promise { await client.query(await truncateAll(client)); await client.query(seedSql()); + await reconcileRollups(client); +} + +const EXPECTED_EXPENDITURE_ROLLUP = ` + SELECT project_id, + date_trunc('month', spent_on)::date AS month, + category, + status, + SUM(amount) AS total_amount, + COUNT(*)::int AS expenditure_count + FROM ${SCHEMA}.expenditures + GROUP BY project_id, date_trunc('month', spent_on)::date, category, status`; + +const EXPECTED_PROJECT_ROLLUP = ` + SELECT p.project_id, + COALESCE(m.c, 0) AS member_count, + COALESCE(d.total, 0) AS total_donated, + COALESCE(d.c, 0) AS donation_count, + COALESCE(r.c, 0) AS report_count + FROM ${SCHEMA}.projects p + LEFT JOIN (SELECT project_id, COUNT(*) AS c FROM ${SCHEMA}.project_memberships GROUP BY project_id) m + ON m.project_id = p.project_id + LEFT JOIN (SELECT project_id, COUNT(*) AS c, SUM(amount) AS total FROM ${SCHEMA}.project_donations GROUP BY project_id) d + ON d.project_id = p.project_id + LEFT JOIN (SELECT project_id, COUNT(*) AS c FROM ${SCHEMA}.reports GROUP BY project_id) r + ON r.project_id = p.project_id`; + +// A zero-count expenditure_rollup row equals a missing one: _remove decrements without deleting. +export async function findRollupDrift(client: Queryable): Promise { + const expenditures = await client.query(` + WITH expected AS (${EXPECTED_EXPENDITURE_ROLLUP}) + SELECT 'expenditure_rollup project=' || COALESCE(e.project_id, a.project_id) + || ' month=' || COALESCE(e.month, a.month) + || ' status=' || COALESCE(e.status, a.status) + || ' category=' || COALESCE(e.category, a.category, '') + || ' expected=' || COALESCE(e.total_amount, 0) || '/' || COALESCE(e.expenditure_count, 0) + || ' actual=' || COALESCE(a.total_amount, 0) || '/' || COALESCE(a.expenditure_count, 0) AS drift + FROM expected e + FULL OUTER JOIN ${SCHEMA}.expenditure_rollup a + ON a.project_id = e.project_id + AND a.month = e.month + AND a.status = e.status + AND (a.category IS NULL) = (e.category IS NULL) + AND COALESCE(a.category, '') = COALESCE(e.category, '') + WHERE COALESCE(a.expenditure_count, 0) <> COALESCE(e.expenditure_count, 0) + OR COALESCE(a.total_amount, 0) <> COALESCE(e.total_amount, 0)`); + + const projects = await client.query(` + WITH expected AS (${EXPECTED_PROJECT_ROLLUP}) + SELECT 'project_rollup project=' || e.project_id + || ' expected=' || e.member_count || '/' || e.total_donated || '/' || e.donation_count || '/' || e.report_count + || ' actual=' || COALESCE(a.member_count::text, 'ROW MISSING') + || '/' || COALESCE(a.total_donated::text, '-') + || '/' || COALESCE(a.donation_count::text, '-') + || '/' || COALESCE(a.report_count::text, '-') AS drift + FROM expected e + LEFT JOIN ${SCHEMA}.project_rollup a ON a.project_id = e.project_id + WHERE a.project_id IS NULL + OR a.member_count <> e.member_count + OR a.total_donated <> e.total_donated + OR a.donation_count <> e.donation_count + OR a.report_count <> e.report_count`); + + return [...(expenditures.rows ?? []), ...(projects.rows ?? [])].map( + (row) => row.drift as string, + ); +} + +export async function assertRollupsConsistent(client: Queryable): Promise { + const drift = await findRollupDrift(client); + if (drift.length > 0) { + throw new Error( + `rollups disagree with the base tables:\n ${drift.join('\n ')}`, + ); + } +} + +export async function reconcileRollups(client: Queryable): Promise { + await client.query(`DELETE FROM ${SCHEMA}.expenditure_rollup`); + await client.query( + `INSERT INTO ${SCHEMA}.expenditure_rollup + (project_id, month, category, status, total_amount, expenditure_count) + SELECT project_id, month, category, status, total_amount, expenditure_count + FROM (${EXPECTED_EXPENDITURE_ROLLUP}) AS expected`, + ); + await client.query(`DELETE FROM ${SCHEMA}.project_rollup`); + await client.query( + `INSERT INTO ${SCHEMA}.project_rollup + (project_id, member_count, total_donated, donation_count, report_count) + SELECT project_id, member_count, total_donated, donation_count, report_count + FROM (${EXPECTED_PROJECT_ROLLUP}) AS expected`, + ); } /** diff --git a/apps/backend/lambdas/auth/auth.ts b/apps/backend/lambdas/auth/auth.ts index 87b5c5e1..d5f3b4c8 100644 --- a/apps/backend/lambdas/auth/auth.ts +++ b/apps/backend/lambdas/auth/auth.ts @@ -1,6 +1,6 @@ import { authenticateRequest as _authenticateRequest, loadRbacSubject } from '@branch/lambda-auth'; import { createAuthResolver } from '@branch/lambda-http'; -import db from './db'; +import { db } from '@branch/store'; export * from '@branch/lambda-auth'; diff --git a/apps/backend/lambdas/auth/controllers/mfa.ts b/apps/backend/lambdas/auth/controllers/mfa.ts index 5e746eff..6763312d 100644 --- a/apps/backend/lambdas/auth/controllers/mfa.ts +++ b/apps/backend/lambdas/auth/controllers/mfa.ts @@ -7,7 +7,7 @@ import { } from '@aws-sdk/client-cognito-identity-provider'; import { json, parseBody, reportError, serverError } from '@branch/lambda-http'; import type { RouteHandler } from '@branch/lambda-http'; -import db from '../db'; +import { db } from '@branch/store'; import { cognitoClient } from '../services/cognito'; /** diff --git a/apps/backend/lambdas/auth/controllers/register.ts b/apps/backend/lambdas/auth/controllers/register.ts index 5b01d7fc..1b6d76a5 100644 --- a/apps/backend/lambdas/auth/controllers/register.ts +++ b/apps/backend/lambdas/auth/controllers/register.ts @@ -9,7 +9,7 @@ import { ResendConfirmationCodeCommand, } from '@aws-sdk/client-cognito-identity-provider'; import { json, reportError, serverError } from '@branch/lambda-http'; -import db from '../db'; +import { db, claimUser } from '@branch/store'; import { cognitoClient, USER_POOL_CLIENT_ID, USER_POOL_ID, validatePassword } from '../services/cognito'; export async function handleRegister(event: any): Promise { @@ -125,15 +125,10 @@ export async function handleRegister(event: any): Promise ); const sub = cognitoUser.UserAttributes?.find((a) => a.Name === 'sub')?.Value; if (sub && cognitoUser.UserStatus === 'CONFIRMED') { - const linkResult = await db - .updateTable('branch.users') - .set({ cognito_sub: sub }) - .where('user_id', '=', claimingUserId) - .where('cognito_sub', 'is', null) - .executeTakeFirst(); + const linked = await claimUser(claimingUserId, { cognito_sub: sub }); // A concurrent claim already took this row; do not delete the // pre-existing Cognito user, it may back a working account. - if (linkResult.numUpdatedRows > 0n) { + if (linked > 0n) { return json(200, { message: 'Existing account linked', claimed: true, @@ -187,16 +182,14 @@ export async function handleRegister(event: any): Promise // claim one an admin already approved. The cognito_sub IS NULL predicate // makes a concurrent claim a no-op rather than an overwrite; // UNIQUE(cognito_sub) is the backstop. - const claimResult = await db - .updateTable('branch.users') - .set({ cognito_sub: cognitoUserSub, name: name.trim() }) - .where('user_id', '=', claimingUserId) - .where('cognito_sub', 'is', null) - .executeTakeFirst(); + const claimed = await claimUser(claimingUserId, { + cognito_sub: cognitoUserSub, + name: name.trim(), + }); // No-op claim: the Cognito sub we just created would reference no row, so // every later login would fail. Undo the Cognito user instead. - if (claimResult.numUpdatedRows === 0n) { + if (claimed === 0n) { console.error('Invitation already claimed for user_id:', claimingUserId); await rollbackCognitoUser(); return json(409, { diff --git a/apps/backend/lambdas/auth/db.ts b/apps/backend/lambdas/auth/db.ts deleted file mode 100644 index 6a890925..00000000 --- a/apps/backend/lambdas/auth/db.ts +++ /dev/null @@ -1,39 +0,0 @@ - -import { Kysely, PostgresDialect } from 'kysely' -import { Pool } from 'pg' -import type { DB } from '@branch/types' - -const db = new Kysely({ - dialect: new PostgresDialect({ - pool: new Pool({ - host: process.env.DB_HOST ?? 'localhost', - port: Number(process.env.DB_PORT ?? 5432), - user: process.env.DB_USER ?? 'branch_dev', - password: process.env.DB_PASSWORD ?? 'password', - database: process.env.DB_NAME ?? 'branch_db', - - // rds.force_ssl = 1 on default.postgres17 rejects unencrypted connections, - // so ssl: false never worked against prod. Local postgres has no TLS. - // TODO: pin the RDS CA bundle instead of skipping verification. - ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, - - // Without this a blackholed SYN hangs until the 30s lambda timeout instead - // of erroring, which is how the unreachable-database bug presented. - connectionTimeoutMillis: 5000, - - // A lambda container serves one request at a time, so pg's default of 10 - // just multiplies idle sockets against db.t3.micro's ~112 max_connections. - max: 1, - - // Lambda freezes the container between invocations, so the idle timer fires - // late and the pool can hand back a socket the server already dropped. - idleTimeoutMillis: 0, - keepAlive: true, - - // Bound a runaway query well under the 30s lambda timeout. - statement_timeout: 10000, - }), - }), -}) - -export default db diff --git a/apps/backend/lambdas/auth/package-lock.json b/apps/backend/lambdas/auth/package-lock.json index db245921..01d20ecb 100644 --- a/apps/backend/lambdas/auth/package-lock.json +++ b/apps/backend/lambdas/auth/package-lock.json @@ -14,6 +14,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "dotenv": "^17.2.3", "kysely": "^0.28.10", @@ -81,6 +82,24 @@ "typescript": "^5.4.5" } }, + "../../../../shared/store": { + "name": "@branch/store", + "version": "1.0.0", + "dependencies": { + "kysely": "^0.28.8", + "pg": "^8.16.3" + }, + "devDependencies": { + "@branch/types": "file:../types", + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "@types/pg": "^8.15.5", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -1259,6 +1278,10 @@ "resolved": "../../../../shared/rbac", "link": true }, + "node_modules/@branch/store": { + "resolved": "../../../../shared/store", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/auth/package.json b/apps/backend/lambdas/auth/package.json index 9e2c63e2..4828da02 100644 --- a/apps/backend/lambdas/auth/package.json +++ b/apps/backend/lambdas/auth/package.json @@ -31,6 +31,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "dotenv": "^17.2.3", "kysely": "^0.28.10", diff --git a/apps/backend/lambdas/auth/test/auth.login.unit.test.ts b/apps/backend/lambdas/auth/test/auth.login.unit.test.ts index e5e114a7..c1eb98c4 100644 --- a/apps/backend/lambdas/auth/test/auth.login.unit.test.ts +++ b/apps/backend/lambdas/auth/test/auth.login.unit.test.ts @@ -35,35 +35,27 @@ const mockUpdateResult = jest.fn(); const mockSet = jest.fn(); const mockValues = jest.fn(); -jest.mock('../db', () => { +jest.mock('@branch/store', () => { const selectChain: any = { where: () => selectChain, selectAll: () => selectChain, select: () => selectChain, executeTakeFirst: (...a: unknown[]) => mockExecuteTakeFirst(...a), }; - const updateChain: any = { - set: (...a: unknown[]) => { - mockSet(...a); - return updateChain; - }, - where: () => updateChain, - execute: (...a: unknown[]) => mockExecute(...a), - executeTakeFirst: (...a: unknown[]) => mockUpdateResult(...a), - }; - const insertChain: any = { - values: (...a: unknown[]) => { - mockValues(...a); - return insertChain; - }, - execute: (...a: unknown[]) => mockExecute(...a), - }; return { __esModule: true, - default: { - selectFrom: () => selectChain, - updateTable: () => updateChain, - insertInto: () => insertChain, + db: { selectFrom: () => selectChain }, + claimUser: (_id: unknown, values: unknown) => { + mockSet(values); + return mockUpdateResult(); + }, + updateUser: (_id: unknown, values: unknown) => { + mockSet(values); + return mockUpdateResult(); + }, + createUser: (...a: unknown[]) => { + mockValues(...a); + return mockExecute(); }, }; }); @@ -94,7 +86,7 @@ const TOKENS = { beforeEach(() => { jest.clearAllMocks(); - mockUpdateResult.mockResolvedValue({ numUpdatedRows: 1n }); + mockUpdateResult.mockResolvedValue(1n); jest.spyOn(console, 'error').mockImplementation(() => undefined); jest.spyOn(console, 'warn').mockImplementation(() => undefined); jest.spyOn(console, 'log').mockImplementation(() => undefined); diff --git a/apps/backend/lambdas/auth/test/auth.mfa.unit.test.ts b/apps/backend/lambdas/auth/test/auth.mfa.unit.test.ts index 81681c2b..045065ba 100644 --- a/apps/backend/lambdas/auth/test/auth.mfa.unit.test.ts +++ b/apps/backend/lambdas/auth/test/auth.mfa.unit.test.ts @@ -29,7 +29,7 @@ jest.mock('../auth', () => ({ const mockExecuteTakeFirst = jest.fn(); -jest.mock('../db', () => { +jest.mock('@branch/store', () => { const selectChain: any = { where: () => selectChain, selectAll: () => selectChain, @@ -38,9 +38,7 @@ jest.mock('../db', () => { }; return { __esModule: true, - default: { - selectFrom: () => selectChain, - }, + db: { selectFrom: () => selectChain }, }; }); diff --git a/apps/backend/lambdas/donors/auth.ts b/apps/backend/lambdas/donors/auth.ts index 87b5c5e1..d5f3b4c8 100644 --- a/apps/backend/lambdas/donors/auth.ts +++ b/apps/backend/lambdas/donors/auth.ts @@ -1,6 +1,6 @@ import { authenticateRequest as _authenticateRequest, loadRbacSubject } from '@branch/lambda-auth'; import { createAuthResolver } from '@branch/lambda-http'; -import db from './db'; +import { db } from '@branch/store'; export * from '@branch/lambda-auth'; diff --git a/apps/backend/lambdas/donors/controllers/donations.ts b/apps/backend/lambdas/donors/controllers/donations.ts index 2d082cee..3e816a7f 100644 --- a/apps/backend/lambdas/donors/controllers/donations.ts +++ b/apps/backend/lambdas/donors/controllers/donations.ts @@ -2,7 +2,7 @@ import type { RouteCtx } from '@branch/lambda-http'; import { json, requirePermission } from '@branch/lambda-http'; import { projectScopeIds } from '@branch/rbac'; import { sql, type SqlBool } from 'kysely'; -import db from '../db'; +import { db, recordDonation, removeDonation } from '@branch/store'; // Authentication and the route's permission are enforced by dispatch before any // of these run — see routes.ts. @@ -132,16 +132,12 @@ export async function createDonation({ event }: RouteCtx) { } try { - const donation = await db - .insertInto('branch.project_donations') - .values({ - donor_id: donorId, - project_id: projectId, - amount: donationAmount, - ...(donatedAt ? { donated_at: donatedAt } : {}), - }) - .returningAll() - .executeTakeFirstOrThrow(); + const donation = await recordDonation({ + donor_id: donorId, + project_id: projectId, + amount: donationAmount, + ...(donatedAt ? { donated_at: donatedAt } : {}), + }); return json(201, { data: donation }); } catch (err: any) { @@ -179,8 +175,8 @@ export async function deleteDonation({ params, auth }: RouteCtx) { }); if (invisible) return json(404, { message: 'Donation not found' }); - const deleted = await db.deleteFrom('branch.project_donations').where('donation_id', '=', Number(id)).execute(); - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + const deleted = await removeDonation(Number(id)); + if (deleted === 0n) { return json(404, { message: 'Donation not found' }); } diff --git a/apps/backend/lambdas/donors/controllers/donors.ts b/apps/backend/lambdas/donors/controllers/donors.ts index 2de3e4ec..9753fa96 100644 --- a/apps/backend/lambdas/donors/controllers/donors.ts +++ b/apps/backend/lambdas/donors/controllers/donors.ts @@ -1,6 +1,6 @@ import type { RouteCtx } from '@branch/lambda-http'; import { json, serverError } from '@branch/lambda-http'; -import db from '../db'; +import { db, createDonor as storeCreateDonor, removeDonor } from '@branch/store'; import { DonorValidationUtils } from '../validation-utils'; // Authentication and the route's permission are enforced by dispatch before any @@ -67,14 +67,11 @@ export async function createDonor({ event }: RouteCtx) { const { organization, contactName, contactEmail } = validationResult; try { - await db - .insertInto('branch.donors') - .values({ - organization, - contact_name: contactName ?? null, - contact_email: contactEmail ?? null, - }) - .executeTakeFirst(); + await storeCreateDonor({ + organization, + contact_name: contactName ?? null, + contact_email: contactEmail ?? null, + }); } catch (err) { return serverError(err, 'Failed to create donor'); } @@ -97,8 +94,8 @@ export async function deleteDonor({ params }: RouteCtx) { return json(400, { message: 'id must be a positive integer' }); } - const deleted = await db.deleteFrom('branch.donors').where('donor_id', '=', Number(id)).execute(); - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + const deleted = await removeDonor(Number(id)); + if (deleted === 0n) { return json(404, { message: 'Donor not found' }); } diff --git a/apps/backend/lambdas/donors/db.ts b/apps/backend/lambdas/donors/db.ts deleted file mode 100644 index 655b36f0..00000000 --- a/apps/backend/lambdas/donors/db.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Kysely, PostgresDialect } from 'kysely' -import { Pool } from 'pg' -import type { DB } from '@branch/types' - - -const db = new Kysely({ - dialect: new PostgresDialect({ - pool: new Pool({ - host: process.env.DB_HOST ?? 'localhost', - port: Number(process.env.DB_PORT ?? 5432), - user: process.env.DB_USER ?? 'branch_dev', - password: process.env.DB_PASSWORD ?? 'password', - database: process.env.DB_NAME ?? 'branch_db', - - // rds.force_ssl = 1 on default.postgres17 rejects unencrypted connections, - // so ssl: false never worked against prod. Local postgres has no TLS. - // TODO: pin the RDS CA bundle instead of skipping verification. - ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, - - // Without this a blackholed SYN hangs until the 30s lambda timeout instead - // of erroring, which is how the unreachable-database bug presented. - connectionTimeoutMillis: 5000, - - // A lambda container serves one request at a time, so pg's default of 10 - // just multiplies idle sockets against db.t3.micro's ~112 max_connections. - max: 1, - - // Lambda freezes the container between invocations, so the idle timer fires - // late and the pool can hand back a socket the server already dropped. - idleTimeoutMillis: 0, - keepAlive: true, - - // Bound a runaway query well under the 30s lambda timeout. - statement_timeout: 10000, - }), - }), -}) - -export default db \ No newline at end of file diff --git a/apps/backend/lambdas/donors/package-lock.json b/apps/backend/lambdas/donors/package-lock.json index 4f58115e..592dc0a2 100644 --- a/apps/backend/lambdas/donors/package-lock.json +++ b/apps/backend/lambdas/donors/package-lock.json @@ -11,6 +11,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.17.2" @@ -75,6 +76,24 @@ "typescript": "^5.4.5" } }, + "../../../../shared/store": { + "name": "@branch/store", + "version": "1.0.0", + "dependencies": { + "kysely": "^0.28.8", + "pg": "^8.16.3" + }, + "devDependencies": { + "@branch/types": "file:../types", + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "@types/pg": "^8.15.5", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -588,6 +607,10 @@ "resolved": "../../../../shared/rbac", "link": true }, + "node_modules/@branch/store": { + "resolved": "../../../../shared/store", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/donors/package.json b/apps/backend/lambdas/donors/package.json index 7879e97d..f9fb6f71 100644 --- a/apps/backend/lambdas/donors/package.json +++ b/apps/backend/lambdas/donors/package.json @@ -26,6 +26,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.17.2" diff --git a/apps/backend/lambdas/donors/test/donors.test.ts b/apps/backend/lambdas/donors/test/donors.test.ts index 6ee7fcff..e591b2d3 100644 --- a/apps/backend/lambdas/donors/test/donors.test.ts +++ b/apps/backend/lambdas/donors/test/donors.test.ts @@ -1,7 +1,7 @@ import { Pool } from 'pg'; import { ensureSchema, resetData } from '../../../db/testkit'; import { handler } from '../handler'; -import db from '../db'; +import { db, closeConnection } from '@branch/store'; jest.mock('../auth', () => { // dispatch() resolves the caller through resolveAuth, so an auto-mock would // hand it `undefined` and every route would 500. Only the authenticate half @@ -13,7 +13,7 @@ jest.mock('../auth', () => { const { loadRbacSubject } = jest.requireActual( '@branch/lambda-auth', ); - const db = jest.requireActual('../db').default; + const db = jest.requireActual('@branch/store').db; const authenticateRequest = jest.fn(); return { ...jest.requireActual('../auth'), @@ -118,11 +118,6 @@ describe("Donor API with data", () => { } }); - test("health test 🌞", async () => { - let res = await fetch("http://localhost:3000/donors/health"); - expect(res.status).toBe(200); - }); - test("Status check for get all donors when donors exist 🌞 - with auth", async () => { mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); const res = await handler(createEvent('GET', '/')); @@ -659,7 +654,7 @@ describe("Donor API when DB is empty", () => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); diff --git a/apps/backend/lambdas/expenditures/auth.ts b/apps/backend/lambdas/expenditures/auth.ts index 87b5c5e1..d5f3b4c8 100644 --- a/apps/backend/lambdas/expenditures/auth.ts +++ b/apps/backend/lambdas/expenditures/auth.ts @@ -1,6 +1,6 @@ import { authenticateRequest as _authenticateRequest, loadRbacSubject } from '@branch/lambda-auth'; import { createAuthResolver } from '@branch/lambda-http'; -import db from './db'; +import { db } from '@branch/store'; export * from '@branch/lambda-auth'; diff --git a/apps/backend/lambdas/expenditures/db.ts b/apps/backend/lambdas/expenditures/db.ts deleted file mode 100644 index 773138d3..00000000 --- a/apps/backend/lambdas/expenditures/db.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Kysely, PostgresDialect } from 'kysely' -import { Pool } from 'pg' -import type { DB } from '@branch/types' - - -const db = new Kysely({ - dialect: new PostgresDialect({ - pool: new Pool({ - host: process.env.DB_HOST ?? 'localhost', - port: Number(process.env.DB_PORT ?? 5432), - user: process.env.DB_USER ?? 'branch_dev', - password: process.env.DB_PASSWORD ?? 'password', - database: process.env.DB_NAME ?? 'branch_db', - - // rds.force_ssl = 1 on default.postgres17 rejects unencrypted connections, - // so ssl: false never worked against prod. Local postgres has no TLS. - // TODO: pin the RDS CA bundle instead of skipping verification. - ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, - - // Without this a blackholed SYN hangs until the 30s lambda timeout instead - // of erroring, which is how the unreachable-database bug presented. - connectionTimeoutMillis: 5000, - - // A lambda container serves one request at a time, so pg's default of 10 - // just multiplies idle sockets against db.t3.micro's ~112 max_connections. - max: 1, - - // Lambda freezes the container between invocations, so the idle timer fires - // late and the pool can hand back a socket the server already dropped. - idleTimeoutMillis: 0, - keepAlive: true, - - // Bound a runaway query well under the 30s lambda timeout. - statement_timeout: 10000, - }), - }), -}) - -export default db diff --git a/apps/backend/lambdas/expenditures/package-lock.json b/apps/backend/lambdas/expenditures/package-lock.json index bce77b8f..faa15a22 100644 --- a/apps/backend/lambdas/expenditures/package-lock.json +++ b/apps/backend/lambdas/expenditures/package-lock.json @@ -14,6 +14,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "kysely": "^0.28.8", @@ -81,6 +82,24 @@ "typescript": "^5.4.5" } }, + "../../../../shared/store": { + "name": "@branch/store", + "version": "1.0.0", + "dependencies": { + "kysely": "^0.28.8", + "pg": "^8.16.3" + }, + "devDependencies": { + "@branch/types": "file:../types", + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "@types/pg": "^8.15.5", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -938,6 +957,10 @@ "resolved": "../../../../shared/rbac", "link": true }, + "node_modules/@branch/store": { + "resolved": "../../../../shared/store", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/expenditures/package.json b/apps/backend/lambdas/expenditures/package.json index 8ebdfdfd..1dbd9931 100644 --- a/apps/backend/lambdas/expenditures/package.json +++ b/apps/backend/lambdas/expenditures/package.json @@ -32,6 +32,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "kysely": "^0.28.8", diff --git a/apps/backend/lambdas/expenditures/services/expenditures.ts b/apps/backend/lambdas/expenditures/services/expenditures.ts index 593427bc..cf4645fc 100644 --- a/apps/backend/lambdas/expenditures/services/expenditures.ts +++ b/apps/backend/lambdas/expenditures/services/expenditures.ts @@ -3,7 +3,7 @@ import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } fro import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { reportError } from '@branch/lambda-http'; import type { DB } from '@branch/types'; -import db from '../db'; +import { db, recordExpenditure, editExpenditure, removeExpenditure } from '@branch/store'; import type { ExpenditureStatus } from '../validation-utils'; import { applyExpenditureScope, type ExpenditureScope } from './scope'; @@ -115,7 +115,7 @@ export async function findExpenditureWithNames(id: number) { } export async function insertExpenditure(values: Insertable): Promise { - await db.insertInto('branch.expenditures').values(values).executeTakeFirst(); + await recordExpenditure(values); } export async function findExpenditureById(id: number) { @@ -123,8 +123,7 @@ export async function findExpenditureById(id: number) { } export async function deleteExpenditureById(id: number): Promise { - const deleted = await db.deleteFrom('branch.expenditures').where('expenditure_id', '=', id).execute(); - return deleted[0]?.numDeletedRows ?? 0n; + return removeExpenditure(id); } /** @@ -145,12 +144,7 @@ export type ExpenditureEdit = Pick< */ export async function updateExpenditure(id: number, values: ExpenditureEdit) { if (Object.keys(values).length === 0) return undefined; - return db - .updateTable('branch.expenditures') - .set(values) - .where('expenditure_id', '=', id) - .returningAll() - .executeTakeFirst(); + return editExpenditure(id, values); } export async function getUserContact(userId: number) { @@ -167,12 +161,10 @@ export async function updateExpenditureStatus( status: ExpenditureStatus, adminNotes: string | undefined, ) { - return db - .updateTable('branch.expenditures') - .set(adminNotes === undefined ? { status } : { status, admin_notes: adminNotes }) - .where('expenditure_id', '=', id) - .returningAll() - .executeTakeFirst(); + return editExpenditure( + id, + adminNotes === undefined ? { status } : { status, admin_notes: adminNotes }, + ); } export async function presignUploadUrl(projectId: number, fileName: string): Promise<{ uploadUrl: string; objectUrl: string }> { diff --git a/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts b/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts index b15094e5..5f4f8fb4 100644 --- a/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts +++ b/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeAll, beforeEach, afterAll, jest } from '@jest/globals'; import { Pool } from 'pg'; import { ensureSchema, resetData } from '../../../db/testkit'; -import db from '../db'; +import { db, closeConnection } from '@branch/store'; // mock auth only for now jest.mock('../auth', () => { @@ -15,7 +15,7 @@ jest.mock('../auth', () => { const { loadRbacSubject } = jest.requireActual( '@branch/lambda-auth', ); - const db = jest.requireActual('../db').default; + const db = jest.requireActual('@branch/store').db; const authenticateRequest = jest.fn(); return { ...jest.requireActual('../auth'), @@ -187,7 +187,7 @@ describe('Expenditures integration tests', () => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); describe('Health check', () => { diff --git a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts index 6405e5bf..39e55f78 100644 --- a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts +++ b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; // Mock the database module BEFORE importing handler -jest.mock('../db'); +jest.mock('@branch/store'); // Memberships the mocked session should appear to have. Named `mock*` so it can // be referenced from the jest.mock factory below. // Mutated per test to give the mocked session memberships. `beforeEach` in each @@ -49,11 +49,14 @@ jest.mock('@aws-sdk/client-s3', () => ({ jest.mock('../mailer'); import { handler } from '../handler'; -import db from '../db'; +import { db, recordExpenditure, editExpenditure, removeExpenditure } from '@branch/store'; import { authenticateRequest } from '../auth'; import { sendExpenseStatusEmail } from '../mailer'; const mockDb = db as any; +const mockRecordExpenditure = recordExpenditure as jest.MockedFunction; +const mockEditExpenditure = editExpenditure as jest.MockedFunction; +const mockRemoveExpenditure = removeExpenditure as jest.MockedFunction; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; const mockSendExpenseStatusEmail = sendExpenseStatusEmail as jest.MockedFunction; @@ -595,12 +598,7 @@ describe('POST /expenditures unit tests', () => { }), }); - // Mock: insert throws error - mockDb.insertInto.mockReturnValue({ - values: jest.fn().mockReturnValue({ - executeTakeFirst: (jest.fn() as any).mockRejectedValue(new Error('Database connection failed')), - }), - }); + mockRecordExpenditure.mockRejectedValue(new Error('Database connection failed')); const res = await handler( postEvent({ @@ -857,12 +855,12 @@ describe('DELETE /expenditures/{id} unit tests', () => { expect(res.statusCode).toBe(404); expect(JSON.parse(res.body).message).toBe('Expenditure not found'); // deleteFrom should never be reached if the expenditure lookup fails - expect(mockDb.deleteFrom).not.toHaveBeenCalled(); + expect(mockRemoveExpenditure).not.toHaveBeenCalled(); }); test('404: row already gone by the time delete executes (race condition)', async () => { mockDb.selectFrom.mockReturnValueOnce(mockSelectExpenditure(fakeExpenditure)); - mockDb.deleteFrom.mockReturnValue(mockDelete(0n)); + mockRemoveExpenditure.mockResolvedValue(0n); const res = await handler(idEvent('DELETE', '5')); expect(res.statusCode).toBe(404); @@ -879,7 +877,7 @@ describe('DELETE /expenditures/{id} unit tests', () => { const res = await handler(idEvent('DELETE', '5')); expect(res.statusCode).toBe(404); - expect(mockDb.deleteFrom).not.toHaveBeenCalled(); + expect(mockRemoveExpenditure).not.toHaveBeenCalled(); }); test('403: a member of the project who did not submit it is rejected', async () => { @@ -890,7 +888,7 @@ describe('DELETE /expenditures/{id} unit tests', () => { const res = await handler(idEvent('DELETE', '5')); expect(res.statusCode).toBe(403); expect(JSON.parse(res.body).message).toBe('You can only delete expenses you submitted'); - expect(mockDb.deleteFrom).not.toHaveBeenCalled(); + expect(mockRemoveExpenditure).not.toHaveBeenCalled(); }); // Directing the project is not enough either -- that rule went away with @@ -902,7 +900,7 @@ describe('DELETE /expenditures/{id} unit tests', () => { const res = await handler(idEvent('DELETE', '5')); expect(res.statusCode).toBe(403); - expect(mockDb.deleteFrom).not.toHaveBeenCalled(); + expect(mockRemoveExpenditure).not.toHaveBeenCalled(); }); test('403: the submitter cannot delete once it has been approved', async () => { @@ -915,7 +913,7 @@ describe('DELETE /expenditures/{id} unit tests', () => { const res = await handler(idEvent('DELETE', '5')); expect(res.statusCode).toBe(403); expect(JSON.parse(res.body).message).toMatch(/Approved expenses/); - expect(mockDb.deleteFrom).not.toHaveBeenCalled(); + expect(mockRemoveExpenditure).not.toHaveBeenCalled(); }); }); @@ -929,7 +927,7 @@ describe('DELETE /expenditures/{id} unit tests', () => { mockMemberships.length = 0; process.env.REPORTS_BUCKET_NAME = 'bucket'; mockDb.selectFrom.mockReturnValueOnce(mockSelectExpenditure(withReceipt)); - mockDb.deleteFrom.mockReturnValue(mockDelete(1n)); + mockRemoveExpenditure.mockResolvedValue(1n); mockS3Send.mockResolvedValue({}); }); @@ -961,7 +959,7 @@ describe('DELETE /expenditures/{id} unit tests', () => { test('an expenditure with no receipt makes no S3 call', async () => { process.env.REPORTS_BUCKET_NAME = 'bucket'; mockDb.selectFrom.mockReturnValueOnce(mockSelectExpenditure(fakeExpenditure)); - mockDb.deleteFrom.mockReturnValue(mockDelete(1n)); + mockRemoveExpenditure.mockResolvedValue(1n); const res = await handler(idEvent('DELETE', '5')); @@ -973,7 +971,7 @@ describe('DELETE /expenditures/{id} unit tests', () => { describe('Success cases', () => { test('200: admin can delete without a membership lookup', async () => { mockDb.selectFrom.mockReturnValueOnce(mockSelectExpenditure(fakeExpenditure)); - mockDb.deleteFrom.mockReturnValue(mockDelete(1n)); + mockRemoveExpenditure.mockResolvedValue(1n); const res = await handler(idEvent('DELETE', '5')); @@ -990,7 +988,7 @@ describe('DELETE /expenditures/{id} unit tests', () => { mockDb.selectFrom.mockReturnValue( mockSelectExpenditure({ ...fakeExpenditure, entered_by: 2 }), ); - mockDb.deleteFrom.mockReturnValue(mockDelete(1n)); + mockRemoveExpenditure.mockResolvedValue(1n); const res = await handler(idEvent('DELETE', '5')); expect(res.statusCode).toBe(200); @@ -1003,7 +1001,7 @@ describe('DELETE /expenditures/{id} unit tests', () => { mockDb.selectFrom.mockReturnValue( mockSelectExpenditure({ ...fakeExpenditure, entered_by: 2 }), ); - mockDb.deleteFrom.mockReturnValue(mockDelete(1n)); + mockRemoveExpenditure.mockResolvedValue(1n); const res = await handler(idEvent('DELETE', '5')); expect(res.statusCode).toBe(200); @@ -1032,17 +1030,9 @@ describe('PATCH /expenditures/{id}/status unit tests', () => { updated?: Record, submitter?: { name: string; email: string } | null, ) { - mockDb.updateTable.mockReturnValue({ - set: jest.fn().mockReturnValue({ - where: jest.fn().mockReturnValue({ - returningAll: jest.fn().mockReturnValue({ - executeTakeFirst: (jest.fn() as any).mockResolvedValue( - existing === null ? undefined : (updated ?? existing), - ), - }), - }), - }), - }); + mockEditExpenditure.mockResolvedValue( + (existing === null ? undefined : (updated ?? existing)) as never, + ); mockDb.selectFrom.mockImplementation((table: string) => { if (table === 'branch.users') { @@ -1139,25 +1129,18 @@ describe('PATCH /expenditures/{id}/status unit tests', () => { }); test('200: admin notes are persisted alongside the status', async () => { - const setSpy: any = jest.fn().mockReturnValue({ - where: jest.fn().mockReturnValue({ - returningAll: jest.fn().mockReturnValue({ - executeTakeFirst: (jest.fn() as any).mockResolvedValue({ - expenditure_id: 5, - status: 'needs_more_info', - admin_notes: 'Need the itemised receipt', - }), - }), - }), - }); - mockDb.updateTable.mockReturnValue({ set: setSpy }); + mockEditExpenditure.mockResolvedValue({ + expenditure_id: 5, + status: 'needs_more_info', + admin_notes: 'Need the itemised receipt', + } as never); const res = await handler( patchStatusEvent(5, { status: 'needs_more_info', adminNotes: 'Need the itemised receipt' }), ); expect(res.statusCode).toBe(200); - expect(setSpy).toHaveBeenCalledWith({ + expect(mockEditExpenditure).toHaveBeenCalledWith(5, { status: 'needs_more_info', admin_notes: 'Need the itemised receipt', }); diff --git a/apps/backend/lambdas/projects/auth.ts b/apps/backend/lambdas/projects/auth.ts index 87b5c5e1..d5f3b4c8 100644 --- a/apps/backend/lambdas/projects/auth.ts +++ b/apps/backend/lambdas/projects/auth.ts @@ -1,6 +1,6 @@ import { authenticateRequest as _authenticateRequest, loadRbacSubject } from '@branch/lambda-auth'; import { createAuthResolver } from '@branch/lambda-http'; -import db from './db'; +import { db } from '@branch/store'; export * from '@branch/lambda-auth'; diff --git a/apps/backend/lambdas/projects/controllers/dashboard.ts b/apps/backend/lambdas/projects/controllers/dashboard.ts index 75e73305..334e878d 100644 --- a/apps/backend/lambdas/projects/controllers/dashboard.ts +++ b/apps/backend/lambdas/projects/controllers/dashboard.ts @@ -1,6 +1,6 @@ import { sql } from 'kysely'; import { json, RouteHandler, serverError } from '@branch/lambda-http'; -import db from '../db'; +import { db } from '@branch/store'; import { APPROVED_EXPENDITURE_STATUS } from '../validation-utils'; import { isProjectActive, listRoster, loadAdminHeadcount } from '../services/projects'; import { requireVisibleProject } from './project-guard'; diff --git a/apps/backend/lambdas/projects/controllers/donors.ts b/apps/backend/lambdas/projects/controllers/donors.ts index 3b12d09e..d8fe6270 100644 --- a/apps/backend/lambdas/projects/controllers/donors.ts +++ b/apps/backend/lambdas/projects/controllers/donors.ts @@ -1,6 +1,6 @@ import { json, RouteHandler } from '@branch/lambda-http'; import { can } from '@branch/rbac'; -import db from '../db'; +import { db } from '@branch/store'; import { requireVisibleProject } from './project-guard'; // GET /projects/{id}/donors diff --git a/apps/backend/lambdas/projects/controllers/expenditures.ts b/apps/backend/lambdas/projects/controllers/expenditures.ts index 38c675c3..0ddeee94 100644 --- a/apps/backend/lambdas/projects/controllers/expenditures.ts +++ b/apps/backend/lambdas/projects/controllers/expenditures.ts @@ -1,6 +1,6 @@ import { json, RouteHandler, serverError } from '@branch/lambda-http'; import { can } from '@branch/rbac'; -import db from '../db'; +import { db } from '@branch/store'; import { requireVisibleProject } from './project-guard'; // GET /projects/{id}/expenditures diff --git a/apps/backend/lambdas/projects/controllers/members.ts b/apps/backend/lambdas/projects/controllers/members.ts index 1cd20abe..06b10c1d 100644 --- a/apps/backend/lambdas/projects/controllers/members.ts +++ b/apps/backend/lambdas/projects/controllers/members.ts @@ -1,5 +1,5 @@ import { json, RouteHandler } from '@branch/lambda-http'; -import db from '../db'; +import { db } from '@branch/store'; import { listRoster } from '../services/projects'; import { requireVisibleProject } from './project-guard'; diff --git a/apps/backend/lambdas/projects/controllers/projects.ts b/apps/backend/lambdas/projects/controllers/projects.ts index ceee9836..15776c9c 100644 --- a/apps/backend/lambdas/projects/controllers/projects.ts +++ b/apps/backend/lambdas/projects/controllers/projects.ts @@ -1,8 +1,8 @@ import { json, parseBody, requirePermission, RouteHandler, serverError } from '@branch/lambda-http'; import { projectScopeIds } from '@branch/rbac'; import { sql, type SqlBool } from 'kysely'; -import db from '../db'; -import { ProjectValidationUtils } from '../validation-utils'; +import { db, createProject as storeCreateProject, updateProject as storeUpdateProject, removeProject } from '@branch/store'; +import { DEFAULT_PROJECT_ROLE, ProjectValidationUtils } from '../validation-utils'; import { requireVisibleProject } from './project-guard'; import { ADMIN_ASSIGNMENT_MESSAGE, @@ -12,7 +12,6 @@ import { isProjectActive, loadAdminHeadcount, loadProjectAggregates, - syncMemberships, toIsoDate, } from '../services/projects'; @@ -131,26 +130,12 @@ export const updateProject: RouteHandler = async ({ event, params, auth }) => { } try { - // Field update and roster replacement share a transaction: a failed - // membership insert must not leave the project with nobody assigned. - const updatedProject = await db.transaction().execute(async (trx) => { - const row = Object.keys(updateValues).length > 0 - ? await trx - .updateTable('branch.projects') - .set(updateValues) - .where('project_id', '=', Number(id)) - .returningAll() - .executeTakeFirst() - : await trx - .selectFrom('branch.projects') - .where('project_id', '=', Number(id)) - .selectAll() - .executeTakeFirst(); - - if (!row) return undefined; - if (members !== undefined) await syncMemberships(trx, Number(id), members); - return row; - }); + const updatedProject = await storeUpdateProject( + Number(id), + updateValues, + members, + DEFAULT_PROJECT_ROLE, + ); if (!updatedProject) return json(404, { message: `Project not found for id: ${id}` }); return json(200, updatedProject); @@ -165,8 +150,8 @@ export const deleteProject: RouteHandler = async ({ params }) => { if (!id) return json(400, { message: 'id is required' }); if (!/^\d+$/.test(id)) return json(400, { message: 'id must be a valid number' }); - const deleted = await db.deleteFrom('branch.projects').where('project_id', '=', Number(id)).execute(); - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + const deleted = await removeProject(Number(id)); + if (deleted === 0n) { return json(404, { message: 'Project not found' }); } @@ -240,16 +225,7 @@ export const createProject: RouteHandler = async ({ event, auth }) => { // Creating the project and its roster together so a partial save cannot // leave a project without the staff the caller picked. The roster may be // empty — admins reach every project through `users.is_admin`. - const inserted = await db.transaction().execute(async (trx) => { - const row = await trx - .insertInto('branch.projects') - .values(values) - .returningAll() - .executeTakeFirstOrThrow(); - - if (members.length > 0) await syncMemberships(trx, row.project_id, members); - return row; - }); + const inserted = await storeCreateProject(values, members, DEFAULT_PROJECT_ROLE); return json(201, inserted); } catch (e) { diff --git a/apps/backend/lambdas/projects/package-lock.json b/apps/backend/lambdas/projects/package-lock.json index e2d5a018..015cdb40 100644 --- a/apps/backend/lambdas/projects/package-lock.json +++ b/apps/backend/lambdas/projects/package-lock.json @@ -12,6 +12,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.16.3" @@ -77,6 +78,24 @@ "typescript": "^5.4.5" } }, + "../../../../shared/store": { + "name": "@branch/store", + "version": "1.0.0", + "dependencies": { + "kysely": "^0.28.8", + "pg": "^8.16.3" + }, + "devDependencies": { + "@branch/types": "file:../types", + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "@types/pg": "^8.15.5", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -898,6 +917,10 @@ "resolved": "../../../../shared/rbac", "link": true }, + "node_modules/@branch/store": { + "resolved": "../../../../shared/store", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/projects/package.json b/apps/backend/lambdas/projects/package.json index 276ba98b..0714e33a 100644 --- a/apps/backend/lambdas/projects/package.json +++ b/apps/backend/lambdas/projects/package.json @@ -28,6 +28,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.16.3" diff --git a/apps/backend/lambdas/projects/services/projects.ts b/apps/backend/lambdas/projects/services/projects.ts index 1acd4794..ca2c30db 100644 --- a/apps/backend/lambdas/projects/services/projects.ts +++ b/apps/backend/lambdas/projects/services/projects.ts @@ -1,4 +1,3 @@ -import { Transaction } from 'kysely'; import { S3Client, ListObjectsV2Command, @@ -6,9 +5,9 @@ import { } from '@aws-sdk/client-s3'; import { reportError } from '@branch/lambda-http'; import type { DB } from '@branch/types'; -import db from '../db'; +import { db } from '@branch/store'; import { ADMIN_MEMBER_ROLE, type MemberDisplayRole } from '@branch/rbac'; -import { APPROVED_EXPENDITURE_STATUS, DEFAULT_PROJECT_ROLE, MemberAssignment } from '../validation-utils'; +import { APPROVED_EXPENDITURE_STATUS, MemberAssignment } from '../validation-utils'; const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-2' }); @@ -205,49 +204,6 @@ export async function loadAdminHeadcount(projectIds: number[]): Promise<{ }; } -/** - * Replaces a project's roster with `members` inside the caller's transaction. - * - * Delete-then-insert rather than a diff: the set is small and bounded by the - * staff list, and doing it in one transaction means a failed insert cannot - * leave the project with nobody assigned. - * - * An entry with no `role` keeps the role that member already held. The staff - * picker submits bare ids, and "Director" is derived from these rows, so - * defaulting them all to Student would make every ordinary project edit strip - * the project's directors of their role. - */ -export async function syncMemberships( - trx: Transaction, - projectId: number, - members: MemberAssignment[], -): Promise { - const existing = await trx - .selectFrom('branch.project_memberships') - .where('project_id', '=', projectId) - .select(['user_id', 'role']) - .execute(); - const heldRole = new Map(existing.map((row) => [row.user_id, row.role])); - - await trx - .deleteFrom('branch.project_memberships') - .where('project_id', '=', projectId) - .execute(); - - if (members.length === 0) return; - - await trx - .insertInto('branch.project_memberships') - .values( - members.map((m) => ({ - project_id: projectId, - user_id: m.user_id, - role: m.role ?? heldRole.get(m.user_id) ?? DEFAULT_PROJECT_ROLE, - })), - ) - .execute(); -} - export const ADMIN_ASSIGNMENT_MESSAGE = 'Admins are on every project and cannot be assigned to one.'; diff --git a/apps/backend/lambdas/projects/test/approved-expenditures.e2e.test.ts b/apps/backend/lambdas/projects/test/approved-expenditures.e2e.test.ts index 2a00ee46..ca5967a0 100644 --- a/apps/backend/lambdas/projects/test/approved-expenditures.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/approved-expenditures.e2e.test.ts @@ -9,7 +9,7 @@ */ import { describe, test, expect, beforeAll, beforeEach, afterAll, jest } from '@jest/globals'; import { Pool } from 'pg'; -import { ensureSchema, resetData } from '../../../db/testkit'; +import { ensureSchema, resetData, reconcileRollups } from '../../../db/testkit'; jest.mock('../auth', () => { // dispatch() resolves the caller through resolveAuth, so an auto-mock would @@ -22,7 +22,7 @@ jest.mock('../auth', () => { const { loadRbacSubject } = jest.requireActual( '@branch/lambda-auth', ); - const db = jest.requireActual('../db').default; + const db = jest.requireActual('@branch/store').db; const authenticateRequest = jest.fn(); return { ...jest.requireActual('../auth'), @@ -35,7 +35,7 @@ jest.mock('../auth', () => { }); import { handler } from '../handler'; -import db from '../db'; +import { db, closeConnection } from '@branch/store'; import { authenticateRequest } from '../auth'; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; @@ -83,6 +83,7 @@ beforeEach(async () => { (1, 1, 1000, 'Travel', 'needs info', 'needs_more_info', CURRENT_DATE) `); await client.query(`UPDATE branch.projects SET end_date = '2099-12-31' WHERE end_date IS NOT NULL`); + await reconcileRollups(client); } finally { client.release(); } @@ -90,7 +91,7 @@ beforeEach(async () => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); function getEvent(rawPath: string) { diff --git a/apps/backend/lambdas/projects/test/crud.test.ts b/apps/backend/lambdas/projects/test/crud.test.ts index d9797918..d2b9ab6b 100644 --- a/apps/backend/lambdas/projects/test/crud.test.ts +++ b/apps/backend/lambdas/projects/test/crud.test.ts @@ -13,7 +13,7 @@ jest.mock('../auth', () => { const { loadRbacSubject } = jest.requireActual( '@branch/lambda-auth', ); - const db = jest.requireActual('../db').default; + const db = jest.requireActual('@branch/store').db; const authenticateRequest = jest.fn(); return { ...jest.requireActual('../auth'), @@ -26,7 +26,7 @@ jest.mock('../auth', () => { }); import { handler } from '../handler'; -import db from '../db'; +import { db, closeConnection } from '@branch/store'; import { authenticateRequest } from '../auth'; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; @@ -97,7 +97,7 @@ beforeEach(async () => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); test("health test 🌞", async () => { diff --git a/apps/backend/lambdas/projects/test/dashboard.unit.test.ts b/apps/backend/lambdas/projects/test/dashboard.unit.test.ts index 08d78c45..0b8daaa3 100644 --- a/apps/backend/lambdas/projects/test/dashboard.unit.test.ts +++ b/apps/backend/lambdas/projects/test/dashboard.unit.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; -jest.mock('../db'); +jest.mock('@branch/store'); // Memberships the mocked session should appear to have. Named `mock*` so it can // be referenced from the jest.mock factory below. const mockMemberships: Array<{ project_id: number; role: string }> = []; @@ -25,7 +25,7 @@ jest.mock('../auth', () => { }); import { handler } from '../handler'; -import db from '../db'; +import { db } from '@branch/store'; import { authenticateRequest } from '../auth'; const mockDb = db as any; diff --git a/apps/backend/lambdas/projects/test/delete-authz.unit.test.ts b/apps/backend/lambdas/projects/test/delete-authz.unit.test.ts index ed20b4d7..a6a3ccfc 100644 --- a/apps/backend/lambdas/projects/test/delete-authz.unit.test.ts +++ b/apps/backend/lambdas/projects/test/delete-authz.unit.test.ts @@ -29,13 +29,10 @@ jest.mock('../auth', () => ({ })); const mockDeleteExecute = jest.fn(); -jest.mock('../db', () => ({ +jest.mock('@branch/store', () => ({ __esModule: true, - default: { - deleteFrom: () => ({ - where: () => ({ execute: (...a: unknown[]) => mockDeleteExecute(...a) }), - }), - }, + db: {}, + removeProject: (...a: unknown[]) => mockDeleteExecute(...a), })); import { handler } from '../handler'; @@ -61,7 +58,7 @@ const staffContext = { beforeEach(() => { jest.clearAllMocks(); mockSubject.isAdmin = true; - mockDeleteExecute.mockResolvedValue([{ numDeletedRows: 1n }]); + mockDeleteExecute.mockResolvedValue(1n); }); describe('DELETE /projects/{id}', () => { @@ -96,7 +93,7 @@ describe('DELETE /projects/{id}', () => { test('404 when an admin targets a project that does not exist', async () => { mockAuthenticateRequest.mockResolvedValue(adminContext); - mockDeleteExecute.mockResolvedValue([{ numDeletedRows: 0n }]); + mockDeleteExecute.mockResolvedValue(0n); const res = await handler(deleteEvent(999)); diff --git a/apps/backend/lambdas/projects/test/delete-objects.unit.test.ts b/apps/backend/lambdas/projects/test/delete-objects.unit.test.ts index cf599c12..bbd10019 100644 --- a/apps/backend/lambdas/projects/test/delete-objects.unit.test.ts +++ b/apps/backend/lambdas/projects/test/delete-objects.unit.test.ts @@ -29,13 +29,10 @@ jest.mock('../auth', () => ({ })); const mockDeleteExecute = jest.fn(); -jest.mock('../db', () => ({ +jest.mock('@branch/store', () => ({ __esModule: true, - default: { - deleteFrom: () => ({ - where: () => ({ execute: (...a: unknown[]) => mockDeleteExecute(...a) }), - }), - }, + db: {}, + removeProject: (...a: unknown[]) => mockDeleteExecute(...a), })); const mockS3Send = jest.fn<(command: any) => Promise>(); @@ -81,7 +78,7 @@ describe('DELETE /projects/{id} object cleanup', () => { process.env.REPORTS_BUCKET_NAME = 'bucket'; mockSubject.isAdmin = true; mockAuthenticateRequest.mockResolvedValue(adminContext as never); - mockDeleteExecute.mockResolvedValue([{ numDeletedRows: 1n }] as never); + mockDeleteExecute.mockResolvedValue(1n as never); }); test('clears both the receipts and reports prefixes for that project', async () => { @@ -163,7 +160,7 @@ describe('DELETE /projects/{id} object cleanup', () => { }); test('touches nothing when the row was already gone', async () => { - mockDeleteExecute.mockResolvedValue([{ numDeletedRows: 0n }] as never); + mockDeleteExecute.mockResolvedValue(0n as never); listReturns({ 'receipts/7/': ['receipts/7/a.pdf'] }); const res = await handler(deleteEvent(7)); diff --git a/apps/backend/lambdas/projects/test/example.test.ts b/apps/backend/lambdas/projects/test/example.test.ts index 8904df90..6f59df2b 100644 --- a/apps/backend/lambdas/projects/test/example.test.ts +++ b/apps/backend/lambdas/projects/test/example.test.ts @@ -13,7 +13,7 @@ jest.mock('../auth', () => { const { loadRbacSubject } = jest.requireActual( '@branch/lambda-auth', ); - const db = jest.requireActual('../db').default; + const db = jest.requireActual('@branch/store').db; const authenticateRequest = jest.fn(); return { ...jest.requireActual('../auth'), @@ -26,7 +26,7 @@ jest.mock('../auth', () => { }); import { handler } from '../handler'; -import db from '../db'; +import { db, closeConnection } from '@branch/store'; import { authenticateRequest } from '../auth'; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; @@ -80,7 +80,7 @@ beforeEach(async () => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); test("health test 🌞", async () => { diff --git a/apps/backend/lambdas/projects/test/project-page.unit.test.ts b/apps/backend/lambdas/projects/test/project-page.unit.test.ts index 8b4794a0..9f380c31 100644 --- a/apps/backend/lambdas/projects/test/project-page.unit.test.ts +++ b/apps/backend/lambdas/projects/test/project-page.unit.test.ts @@ -17,7 +17,7 @@ jest.mock('../auth', () => { const { loadRbacSubject } = jest.requireActual( '@branch/lambda-auth', ); - const db = jest.requireActual('../db').default; + const db = jest.requireActual('@branch/store').db; const authenticateRequest = jest.fn(); return { ...jest.requireActual('../auth'), @@ -30,7 +30,7 @@ jest.mock('../auth', () => { }); import { handler } from '../handler'; -import db from '../db'; +import { db, closeConnection } from '@branch/store'; import { authenticateRequest } from '../auth'; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction< @@ -91,7 +91,7 @@ beforeEach(async () => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); const stored = (members: Array<{ user_id: number; role: string }>) => diff --git a/apps/backend/lambdas/projects/test/projects.e2e.test.ts b/apps/backend/lambdas/projects/test/projects.e2e.test.ts index 2207f429..94ecffe3 100644 --- a/apps/backend/lambdas/projects/test/projects.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/projects.e2e.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeAll, beforeEach, afterAll, jest } from '@jest/globals'; import { Pool } from 'pg'; -import { ensureSchema, resetData } from '../../../db/testkit'; +import { ensureSchema, resetData, reconcileRollups } from '../../../db/testkit'; jest.mock('../auth', () => { // dispatch() resolves the caller through resolveAuth, so an auto-mock would @@ -13,7 +13,7 @@ jest.mock('../auth', () => { const { loadRbacSubject } = jest.requireActual( '@branch/lambda-auth', ); - const db = jest.requireActual('../db').default; + const db = jest.requireActual('@branch/store').db; const authenticateRequest = jest.fn(); return { ...jest.requireActual('../auth'), @@ -26,7 +26,7 @@ jest.mock('../auth', () => { }); import { handler } from '../handler'; -import db from '../db'; +import { db, closeConnection } from '@branch/store'; import { authenticateRequest } from '../auth'; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; @@ -89,7 +89,7 @@ beforeEach(async () => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); function postEvent(body: unknown) { @@ -147,6 +147,7 @@ describe('Authorization', () => { `INSERT INTO branch.project_memberships (project_id, user_id, role, start_date, hours) SELECT 1, user_id, 'Director', '2025-01-01', 10 FROM branch.users WHERE email = 'directormember@branch.org'`, ); + await reconcileRollups(client); } finally { client.release(); } @@ -427,6 +428,7 @@ describe('GET /dashboard (e2e)', () => { EXTRACT(DAY FROM spent_on)::int ) `); + await reconcileRollups(client); } finally { client.release(); } diff --git a/apps/backend/lambdas/projects/test/projects.unit.test.ts b/apps/backend/lambdas/projects/test/projects.unit.test.ts index 5b1b3035..e11b7463 100644 --- a/apps/backend/lambdas/projects/test/projects.unit.test.ts +++ b/apps/backend/lambdas/projects/test/projects.unit.test.ts @@ -13,7 +13,7 @@ jest.mock('../auth', () => { const { loadRbacSubject } = jest.requireActual( '@branch/lambda-auth', ); - const db = jest.requireActual('../db').default; + const db = jest.requireActual('@branch/store').db; const authenticateRequest = jest.fn(); return { ...jest.requireActual('../auth'), @@ -26,7 +26,7 @@ jest.mock('../auth', () => { }); import { handler } from '../handler'; -import db from '../db'; +import { db, closeConnection } from '@branch/store'; import { authenticateRequest } from '../auth'; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; @@ -80,7 +80,7 @@ beforeEach(async () => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); test('201: creates project with number budget', async () => { diff --git a/apps/backend/lambdas/projects/test/rollup-triggers.e2e.test.ts b/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts similarity index 50% rename from apps/backend/lambdas/projects/test/rollup-triggers.e2e.test.ts rename to apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts index bc41e7fe..5fdc8a8b 100644 --- a/apps/backend/lambdas/projects/test/rollup-triggers.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts @@ -1,11 +1,21 @@ -/** - * The rollup triggers, tested against the database alone — no handler, no auth. - * `auditRollups` re-derives every rollup figure from the base tables; each test - * mutates in one shape and audits, so a regression names the path that broke. - */ import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll } from '@jest/globals'; import { Pool, PoolClient } from 'pg'; -import { ensureSchema, resetData } from '../../../db/testkit'; +import { ensureSchema, resetData, reconcileRollups } from '../../../db/testkit'; +import { + closeConnection, + createProject, + editExpenditure, + recordDonation, + recordExpenditure, + recordReport, + removeDonation, + removeDonor, + removeExpenditure, + removeProject, + removeReport, + removeUser, + updateProject, +} from '@branch/store'; const pool = new Pool({ host: 'localhost', @@ -83,6 +93,8 @@ beforeEach(async () => { await client.query('DELETE FROM branch.expenditures'); await client.query('DELETE FROM branch.project_donations'); await client.query('DELETE FROM branch.reports'); + await reconcileRollups(client); + await auditRollups(client); }); afterEach(() => { @@ -91,6 +103,7 @@ afterEach(() => { afterAll(async () => { await pool.end(); + await closeConnection(); }); /** Live buckets for one project. Emptied buckets stay at zero, so exclude them. */ @@ -111,43 +124,41 @@ async function approvedTotal(): Promise { return Number(rows[0].t); } -const oneExpenditure = ` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 250, 'Travel', 'approved', CURRENT_DATE) -`; - -describe('backfill', () => { - test('truncate + reseed rebuilds both rollups through the triggers', async () => { +const travel = { + project_id: 1, + entered_by: 1, + amount: 250, + category: 'Travel', + status: 'approved', + spent_on: '2026-03-11', +}; + +describe('reconcile', () => { + test('resetData leaves both rollups in step with the base tables', async () => { await resetData(client); await auditRollups(client); }); }); describe('expenditure_rollup', () => { - test('INSERT lands in a bucket', async () => { - await client.query(oneExpenditure); + test('recordExpenditure lands in a bucket', async () => { + await recordExpenditure(travel); await auditRollups(client); expect(await bucketsFor(1)).toBe(1); expect(await approvedTotal()).toBe(250); }); test('status is part of the grain, so an unapproved row is stored separately', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 250, 'Travel', 'approved', CURRENT_DATE), - (1, 1, 900, 'Travel', 'pending', CURRENT_DATE) - `); + await recordExpenditure(travel); + await recordExpenditure({ ...travel, amount: 900, status: 'pending' }); await auditRollups(client); expect(await bucketsFor(1)).toBe(2); expect(await approvedTotal()).toBe(250); }); test('two NULL-category rows share one bucket', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 50, NULL, 'approved', DATE '2026-03-11'), - (1, 1, 25, NULL, 'approved', DATE '2026-03-12') - `); + await recordExpenditure({ ...travel, amount: 50, category: null }); + await recordExpenditure({ ...travel, amount: 25, category: null, spent_on: '2026-03-12' }); await auditRollups(client); const { rows } = await client.query(` @@ -160,85 +171,70 @@ describe('expenditure_rollup', () => { }); test("a category of '' does not share NULL's bucket", async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 50, NULL, 'approved', DATE '2026-03-11'), - (1, 1, 25, '', 'approved', DATE '2026-03-12') - `); + await recordExpenditure({ ...travel, amount: 50, category: null }); + await recordExpenditure({ ...travel, amount: 25, category: '' }); await auditRollups(client); expect(await bucketsFor(1)).toBe(2); }); - test('UPDATE of the amount alone stays in the same bucket', async () => { - await client.query(oneExpenditure); - await client.query(`UPDATE branch.expenditures SET amount = 400 WHERE project_id = 1`); + test('editing the amount alone stays in the same bucket', async () => { + const row = await recordExpenditure(travel); + await editExpenditure(row.expenditure_id, { amount: 400 }); await auditRollups(client); expect(await bucketsFor(1)).toBe(1); expect(await approvedTotal()).toBe(400); }); test.each([ - ['category', `SET category = 'Equipment'`], - ['month', `SET spent_on = CURRENT_DATE - INTERVAL '2 months'`], - ['status', `SET status = 'denied'`], - ['project', `SET project_id = 2`], - ['category to NULL', `SET category = NULL`], - ])('UPDATE crossing %s debits the old bucket and credits the new', async (_col, setClause) => { - await client.query(oneExpenditure); - await client.query(`UPDATE branch.expenditures ${setClause} WHERE project_id = 1`); + ['category', { category: 'Equipment' }], + ['month', { spent_on: '2026-01-11' }], + ['status', { status: 'denied' }], + ['category to NULL', { category: null }], + ])('an edit crossing %s debits the old bucket and credits the new', async (_label, patch) => { + const row = await recordExpenditure(travel); + await editExpenditure(row.expenditure_id, patch as Record); await auditRollups(client); }); test('a status change takes the row out of approved spend', async () => { - await client.query(oneExpenditure); - await client.query(`UPDATE branch.expenditures SET status = 'denied' WHERE project_id = 1`); + const row = await recordExpenditure(travel); + await editExpenditure(row.expenditure_id, { status: 'denied' }); await auditRollups(client); expect(await approvedTotal()).toBe(0); }); - test('one statement updating many rows moves every one of them', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 10, 'Travel', 'pending', CURRENT_DATE), - (1, 1, 20, 'Equipment', 'pending', CURRENT_DATE), - (2, 1, 30, 'Travel', 'pending', CURRENT_DATE) - `); - await auditRollups(client); - await client.query(`UPDATE branch.expenditures SET status = 'approved'`); + test('removeExpenditure decrements the bucket', async () => { + await recordExpenditure(travel); + const second = await recordExpenditure({ ...travel, amount: 100 }); + await removeExpenditure(second.expenditure_id); await auditRollups(client); - expect(await approvedTotal()).toBe(60); + expect(await approvedTotal()).toBe(250); }); - test('DELETE decrements the bucket', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 250, 'Travel', 'approved', CURRENT_DATE), - (1, 1, 100, 'Travel', 'approved', CURRENT_DATE) - `); - await client.query(`DELETE FROM branch.expenditures WHERE amount = 100`); + test('removeExpenditure on an unknown id is a no-op', async () => { + await recordExpenditure(travel); + expect(await removeExpenditure(999_999)).toBe(0n); await auditRollups(client); expect(await approvedTotal()).toBe(250); }); - test('TRUNCATE clears the rollup even though it fires no row triggers', async () => { - await client.query(oneExpenditure); - await client.query('TRUNCATE branch.expenditures'); + // Sequential on purpose: the store pool is max: 1, so a concurrent version of + // this passes even against the unfixed code. + test('removing the same row twice decrements once', async () => { + const row = await recordExpenditure(travel); + await recordExpenditure({ ...travel, amount: 100 }); + + expect(await removeExpenditure(row.expenditure_id)).toBe(1n); + expect(await removeExpenditure(row.expenditure_id)).toBe(0n); + await auditRollups(client); - expect(await bucketsFor(1)).toBe(0); + expect(await approvedTotal()).toBe(100); }); test('deleting a project cascades without orphaning or resurrecting a bucket', async () => { - // Cascade order is undefined, so the decrement must tolerate a bucket - // that is already gone rather than re-inserting it. - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (4, 1, 250, 'Travel', 'approved', CURRENT_DATE) - `); - await client.query(` - INSERT INTO branch.reports (project_id, title, object_url, report_type) - VALUES (4, 'r', 's3://r', 'technical') - `); - await client.query(`DELETE FROM branch.projects WHERE project_id = 4`); + await recordExpenditure({ ...travel, project_id: 4 }); + await recordReport({ project_id: 4, title: 'r', object_url: 's3://r', report_type: 'technical' }); + await removeProject(4); await auditRollups(client); const orphans = await client.query(` @@ -252,74 +248,103 @@ describe('expenditure_rollup', () => { }); describe('project_rollup', () => { - test('a new project gets a zeroed rollup row', async () => { - const { rows } = await client.query(` - INSERT INTO branch.projects (name, description, total_budget, start_date, currency) - VALUES ('fresh', 'x', 500, CURRENT_DATE, 'USD') RETURNING project_id - `); + test('createProject seeds a zeroed rollup row', async () => { + const created = await createProject( + { name: 'fresh', description: 'x', total_budget: 500, currency: 'USD' }, + [], + 'Student', + ); await auditRollups(client); const rollup = await client.query( 'SELECT * FROM branch.project_rollup WHERE project_id = $1', - [rows[0].project_id], + [created.project_id], ); expect(rollup.rows).toHaveLength(1); expect(rollup.rows[0].member_count).toBe(0); expect(Number(rollup.rows[0].total_donated)).toBe(0); }); - test('donations add, update and remove', async () => { - await client.query(` - INSERT INTO branch.project_donations (donor_id, project_id, amount) VALUES (1, 1, 500) - `); + test('donations add and remove', async () => { + const donation = await recordDonation({ donor_id: 1, project_id: 1, amount: 500 }); await auditRollups(client); - await client.query(`UPDATE branch.project_donations SET amount = 650 WHERE project_id = 1`); - await auditRollups(client); - await client.query(`DELETE FROM branch.project_donations WHERE project_id = 1`); + await removeDonation(donation.donation_id); await auditRollups(client); }); - test('moving a donation between projects debits one and credits the other', async () => { - await client.query(` - INSERT INTO branch.project_donations (donor_id, project_id, amount) VALUES (1, 1, 500) - `); - await client.query(`UPDATE branch.project_donations SET project_id = 2 WHERE project_id = 1`); + test('deleting a donor takes its donations off the rollup', async () => { + await recordDonation({ donor_id: 1, project_id: 1, amount: 500 }); + await recordDonation({ donor_id: 1, project_id: 2, amount: 250 }); + await auditRollups(client); + + await removeDonor(1); await auditRollups(client); const { rows } = await client.query( - 'SELECT project_id, total_donated FROM branch.project_rollup WHERE project_id IN (1, 2) ORDER BY project_id', + 'SELECT total_donated, donation_count FROM branch.project_rollup WHERE project_id IN (1, 2) ORDER BY project_id', ); - expect(Number(rows[0].total_donated)).toBe(0); - expect(Number(rows[1].total_donated)).toBe(500); + expect(rows.every((r) => Number(r.total_donated) === 0 && r.donation_count === 0)).toBe(true); }); - test('membership churn survives delete-then-insert', async () => { - // syncMemberships deletes then re-inserts, so the counter drops and climbs. - await client.query(`DELETE FROM branch.project_memberships WHERE project_id = 1`); + test('roster replacement keeps member_count in step', async () => { + await updateProject(1, {}, [{ user_id: 1 }, { user_id: 2 }], 'Student'); await auditRollups(client); - await client.query(` - INSERT INTO branch.project_memberships (project_id, user_id, role) - VALUES (1, 1, 'Student'), (1, 2, 'Student') - `); + await updateProject(1, {}, [{ user_id: 3 }], 'Student'); + await auditRollups(client); + await updateProject(1, {}, [], 'Student'); await auditRollups(client); }); - test('moving a membership between projects debits one and credits the other', async () => { - await client.query(`DELETE FROM branch.project_memberships`); - await client.query(` - INSERT INTO branch.project_memberships (project_id, user_id, role) VALUES (1, 1, 'Student') - `); - await client.query(` - UPDATE branch.project_memberships SET project_id = 2 WHERE project_id = 1 AND user_id = 1 - `); + test('deleting a user takes its memberships off the rollup', async () => { + await updateProject(1, {}, [{ user_id: 4 }], 'Student'); + await updateProject(2, {}, [{ user_id: 4 }], 'Student'); + await auditRollups(client); + + const before = await client.query( + 'SELECT project_id, member_count FROM branch.project_rollup WHERE project_id IN (1, 2) ORDER BY project_id', + ); + expect(before.rows.map((r) => r.member_count)).toEqual([1, 1]); + + expect(await removeUser(4)).toBe(1n); + await auditRollups(client); + + const gone = await client.query('SELECT 1 FROM branch.users WHERE user_id = 4'); + expect(gone.rows).toHaveLength(0); + const orphaned = await client.query( + 'SELECT 1 FROM branch.project_memberships WHERE user_id = 4', + ); + expect(orphaned.rows).toHaveLength(0); + + const after = await client.query( + 'SELECT project_id, member_count FROM branch.project_rollup WHERE project_id IN (1, 2) ORDER BY project_id', + ); + expect(after.rows.map((r) => r.member_count)).toEqual([0, 0]); + }); + + test('removing a user that does not exist is a no-op', async () => { + expect(await removeUser(999_999)).toBe(0n); await auditRollups(client); }); + test('a write against a project with no rollup row fails loudly', async () => { + await client.query('DELETE FROM branch.project_rollup WHERE project_id = 1'); + + await expect( + recordReport({ project_id: 1, title: 'a', object_url: 's3://a', report_type: 'technical' }), + ).rejects.toThrow(/no row for project 1/); + + const reports = await client.query('SELECT 1 FROM branch.reports WHERE project_id = 1'); + expect(reports.rows).toHaveLength(0); + }); + test('reports add and remove', async () => { - await client.query(` - INSERT INTO branch.reports (project_id, title, object_url, report_type) - VALUES (1, 'a', 's3://a', 'technical'), (1, 'b', 's3://b', 'narrative') - `); + await recordReport({ project_id: 1, title: 'a', object_url: 's3://a', report_type: 'technical' }); + const b = await recordReport({ + project_id: 1, + title: 'b', + object_url: 's3://b', + report_type: 'narrative', + }); await auditRollups(client); const { rows } = await client.query( @@ -327,25 +352,7 @@ describe('project_rollup', () => { ); expect(rows[0].report_count).toBe(2); - await client.query(`DELETE FROM branch.reports WHERE project_id = 1 AND title = 'a'`); + await removeReport(b.report_id); await auditRollups(client); }); - - test('TRUNCATE of donations, memberships and reports zeroes their counters', async () => { - await client.query(` - INSERT INTO branch.project_donations (donor_id, project_id, amount) VALUES (1, 1, 500) - `); - await client.query(` - INSERT INTO branch.reports (project_id, title, object_url, report_type) - VALUES (1, 'a', 's3://a', 'technical') - `); - await client.query('TRUNCATE branch.project_donations, branch.project_memberships, branch.reports'); - await auditRollups(client); - - const { rows } = await client.query('SELECT * FROM branch.project_rollup WHERE project_id = 1'); - expect(rows[0].member_count).toBe(0); - expect(rows[0].donation_count).toBe(0); - expect(rows[0].report_count).toBe(0); - expect(Number(rows[0].total_donated)).toBe(0); - }); }); diff --git a/apps/backend/lambdas/projects/test/rollups.e2e.test.ts b/apps/backend/lambdas/projects/test/rollups.e2e.test.ts index 5df06fc1..32fa0fa6 100644 --- a/apps/backend/lambdas/projects/test/rollups.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/rollups.e2e.test.ts @@ -5,7 +5,7 @@ */ import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll, jest } from '@jest/globals'; import { Pool, PoolClient } from 'pg'; -import { ensureSchema, resetData } from '../../../db/testkit'; +import { ensureSchema, resetData, reconcileRollups } from '../../../db/testkit'; jest.mock('../auth', () => { const { createAuthResolver } = jest.requireActual( @@ -14,7 +14,7 @@ jest.mock('../auth', () => { const { loadRbacSubject } = jest.requireActual( '@branch/lambda-auth', ); - const db = jest.requireActual('../db').default; + const db = jest.requireActual('@branch/store').db; const authenticateRequest = jest.fn(); return { ...jest.requireActual('../auth'), @@ -27,7 +27,21 @@ jest.mock('../auth', () => { }); import { handler } from '../handler'; -import db from '../db'; +import { + closeConnection, + createProject, + db, + editExpenditure, + recordDonation, + recordExpenditure, + recordReport, + removeDonation, + removeDonor, + removeExpenditure, + removeProject, + removeReport, + updateProject, +} from '@branch/store'; import { authenticateRequest } from '../auth'; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; @@ -122,6 +136,7 @@ beforeEach(async () => { await client.query('DELETE FROM branch.expenditures'); await client.query('DELETE FROM branch.project_donations'); await client.query('DELETE FROM branch.reports'); + await reconcileRollups(client); }); afterEach(() => { @@ -130,7 +145,7 @@ afterEach(() => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); async function get(rawPath: string) { @@ -154,20 +169,33 @@ async function bucketsFor(projectId: number): Promise { return rows[0].n; } -describe('backfill and reset', () => { - test('truncate + reseed rebuilds both rollups through the triggers', async () => { - // resetData truncates the rollups too; the triggers put the figures back. +const today = new Date().toISOString().slice(0, 10); + +function monthsAgo(n: number): string { + const d = new Date(); + d.setMonth(d.getMonth() - n); + return d.toISOString().slice(0, 10); +} + +const travel = { + project_id: 1, + entered_by: 1, + amount: 250, + category: 'Travel', + status: 'approved', + spent_on: today, +}; + +describe('reset', () => { + test('resetData leaves both rollups in step with the base tables', async () => { await resetData(client); await auditRollups(client); }); }); -describe('expenditure_rollup trigger', () => { - test('INSERT lands in a bucket and reaches the dashboard', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 250, 'Travel', 'approved', CURRENT_DATE) - `); +describe('expenditure spend reaches the dashboard', () => { + test('recordExpenditure lands in a bucket and reaches the dashboard', async () => { + await recordExpenditure(travel); await auditRollups(client); const body = await get('/dashboard'); @@ -175,25 +203,18 @@ describe('expenditure_rollup trigger', () => { }); test('unapproved rows are rolled up but stay out of every spend figure', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 250, 'Travel', 'approved', CURRENT_DATE), - (1, 1, 900, 'Travel', 'pending', CURRENT_DATE) - `); + await recordExpenditure(travel); + await recordExpenditure({ ...travel, amount: 900, status: 'pending' }); await auditRollups(client); - // Status is part of the grain, so both rows are stored — separately. expect(await bucketsFor(1)).toBe(2); const body = await get('/dashboard'); expect(body.summary.totalSpent).toBe(250); }); test('two NULL-category rows share one bucket', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 50, NULL, 'approved', DATE '2026-03-11'), - (1, 1, 25, NULL, 'approved', DATE '2026-03-12') - `); + await recordExpenditure({ ...travel, amount: 50, category: null, spent_on: '2026-03-11' }); + await recordExpenditure({ ...travel, amount: 25, category: null, spent_on: '2026-03-12' }); await auditRollups(client); const { rows } = await client.query(` @@ -205,12 +226,9 @@ describe('expenditure_rollup trigger', () => { expect(rows[0].expenditure_count).toBe(2); }); - test('UPDATE of the amount alone stays in the same bucket', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 250, 'Travel', 'approved', CURRENT_DATE) - `); - await client.query(`UPDATE branch.expenditures SET amount = 400 WHERE project_id = 1`); + test('editing the amount alone stays in the same bucket', async () => { + const row = await recordExpenditure(travel); + await editExpenditure(row.expenditure_id, { amount: 400 }); await auditRollups(client); expect(await bucketsFor(1)).toBe(1); @@ -219,83 +237,38 @@ describe('expenditure_rollup trigger', () => { }); test.each([ - ['category', `SET category = 'Equipment'`], - ['month', `SET spent_on = CURRENT_DATE - INTERVAL '2 months'`], - ['status', `SET status = 'denied'`], - ['project', `SET project_id = 2`], - ])('UPDATE crossing %s decrements the old bucket and increments the new', async (_col, setClause) => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 250, 'Travel', 'approved', CURRENT_DATE) - `); - await client.query(`UPDATE branch.expenditures ${setClause} WHERE project_id = 1`); + ['category', { category: 'Equipment' }], + ['month', { spent_on: monthsAgo(2) }], + ['status', { status: 'denied' }], + ])('an edit crossing %s decrements the old bucket and increments the new', async (_label, patch) => { + const row = await recordExpenditure(travel); + await editExpenditure(row.expenditure_id, patch as Record); await auditRollups(client); }); test('a status change moves spend off the dashboard', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 250, 'Travel', 'approved', CURRENT_DATE) - `); - await client.query(`UPDATE branch.expenditures SET status = 'denied' WHERE project_id = 1`); + const row = await recordExpenditure(travel); + await editExpenditure(row.expenditure_id, { status: 'denied' }); await auditRollups(client); const body = await get('/dashboard'); expect(body.summary.totalSpent).toBe(0); }); - test('DELETE decrements the bucket', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 250, 'Travel', 'approved', CURRENT_DATE), - (1, 1, 100, 'Travel', 'approved', CURRENT_DATE) - `); - await client.query(`DELETE FROM branch.expenditures WHERE amount = 100`); + test('removeExpenditure decrements the bucket', async () => { + await recordExpenditure(travel); + const second = await recordExpenditure({ ...travel, amount: 100 }); + await removeExpenditure(second.expenditure_id); await auditRollups(client); const body = await get('/dashboard'); expect(body.summary.totalSpent).toBe(250); }); - test('one statement updating many rows moves every one of them', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 10, 'Travel', 'pending', CURRENT_DATE), - (1, 1, 20, 'Equipment', 'pending', CURRENT_DATE), - (2, 1, 30, 'Travel', 'pending', CURRENT_DATE) - `); - await auditRollups(client); - - await client.query(`UPDATE branch.expenditures SET status = 'approved'`); - await auditRollups(client); - - expect((await get('/dashboard')).summary.totalSpent).toBe(60); - }); - - test('TRUNCATE clears the rollup even though it fires no row triggers', async () => { - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (1, 1, 250, 'Travel', 'approved', CURRENT_DATE) - `); - await client.query('TRUNCATE branch.expenditures'); - await auditRollups(client); - - expect(await bucketsFor(1)).toBe(0); - expect((await get('/dashboard')).summary.totalSpent).toBe(0); - }); - test('deleting a project cascades without orphaning or resurrecting a bucket', async () => { - // Cascade order is undefined, so the decrement must tolerate a bucket - // that is already gone rather than re-inserting it. - await client.query(` - INSERT INTO branch.expenditures (project_id, entered_by, amount, category, status, spent_on) - VALUES (4, 1, 250, 'Travel', 'approved', CURRENT_DATE) - `); - await client.query(` - INSERT INTO branch.reports (project_id, title, object_url, report_type) - VALUES (4, 'r', 's3://r', 'technical') - `); - await client.query(`DELETE FROM branch.projects WHERE project_id = 4`); + await recordExpenditure({ ...travel, project_id: 4 }); + await recordReport({ project_id: 4, title: 'r', object_url: 's3://r', report_type: 'technical' }); + await removeProject(4); await auditRollups(client); const orphans = await client.query(` @@ -308,17 +281,18 @@ describe('expenditure_rollup trigger', () => { }); }); -describe('project_rollup trigger', () => { - test('a new project gets a zeroed rollup row', async () => { - const { rows } = await client.query(` - INSERT INTO branch.projects (name, description, total_budget, start_date, currency) - VALUES ('fresh', 'x', 500, CURRENT_DATE, 'USD') RETURNING project_id - `); +describe('project overview figures', () => { + test('createProject seeds a zeroed rollup row', async () => { + const created = await createProject( + { name: 'fresh', description: 'x', total_budget: 500, currency: 'USD' }, + [], + 'Student', + ); await auditRollups(client); const rollup = await client.query( 'SELECT * FROM branch.project_rollup WHERE project_id = $1', - [rows[0].project_id], + [created.project_id], ); expect(rollup.rows).toHaveLength(1); expect(rollup.rows[0].member_count).toBe(0); @@ -326,89 +300,41 @@ describe('project_rollup trigger', () => { }); test('donations move totalDonated on the project overview', async () => { - await client.query(` - INSERT INTO branch.project_donations (donor_id, project_id, amount) VALUES (1, 1, 500) - `); + const donation = await recordDonation({ donor_id: 1, project_id: 1, amount: 500 }); await auditRollups(client); expect((await get('/1/overview')).stats.totalDonated).toBe(500); - await client.query(`UPDATE branch.project_donations SET amount = 650 WHERE project_id = 1`); - await auditRollups(client); - expect((await get('/1/overview')).stats.totalDonated).toBe(650); - - await client.query(`DELETE FROM branch.project_donations WHERE project_id = 1`); + await removeDonation(donation.donation_id); await auditRollups(client); expect((await get('/1/overview')).stats.totalDonated).toBe(0); }); - test('membership churn keeps member_count exact through delete-then-insert', async () => { - // syncMemberships deletes then re-inserts, so the counter drops and climbs. - await client.query(`DELETE FROM branch.project_memberships WHERE project_id = 1`); + test('deleting a donor takes its donations off the overview', async () => { + await recordDonation({ donor_id: 1, project_id: 1, amount: 500 }); await auditRollups(client); - await client.query(` - INSERT INTO branch.project_memberships (project_id, user_id, role) - VALUES (1, 1, 'Student'), (1, 2, 'Student') - `); + await removeDonor(1); await auditRollups(client); - - const { rows } = await client.query( - 'SELECT member_count FROM branch.project_rollup WHERE project_id = 1', - ); - expect(rows[0].member_count).toBe(2); - }); - - test('moving a donation between projects debits one and credits the other', async () => { - await client.query(` - INSERT INTO branch.project_donations (donor_id, project_id, amount) VALUES (1, 1, 500) - `); - await client.query(`UPDATE branch.project_donations SET project_id = 2 WHERE project_id = 1`); - await auditRollups(client); - expect((await get('/1/overview')).stats.totalDonated).toBe(0); - expect((await get('/2/overview')).stats.totalDonated).toBe(500); }); - test('moving a membership between projects debits one and credits the other', async () => { - await client.query(`DELETE FROM branch.project_memberships`); - await client.query(` - INSERT INTO branch.project_memberships (project_id, user_id, role) VALUES (1, 1, 'Student') - `); - await client.query(` - UPDATE branch.project_memberships SET project_id = 2 WHERE project_id = 1 AND user_id = 1 - `); + test('roster replacement keeps member_count exact through delete-then-insert', async () => { + await updateProject(1, {}, [{ user_id: 1 }, { user_id: 2 }], 'Student'); await auditRollups(client); const { rows } = await client.query( - 'SELECT project_id, member_count FROM branch.project_rollup WHERE project_id IN (1, 2)', + 'SELECT member_count FROM branch.project_rollup WHERE project_id = 1', ); - const byProject = new Map(rows.map((r: any) => [r.project_id, r.member_count])); - expect(byProject.get(1)).toBe(0); - expect(byProject.get(2)).toBe(1); - }); - - test('TRUNCATE of donations, memberships and reports zeroes their counters', async () => { - await client.query(` - INSERT INTO branch.project_donations (donor_id, project_id, amount) VALUES (1, 1, 500) - `); - await client.query(` - INSERT INTO branch.reports (project_id, title, object_url, report_type) - VALUES (1, 'a', 's3://a', 'technical') - `); - await client.query('TRUNCATE branch.project_donations, branch.project_memberships, branch.reports'); - await auditRollups(client); - - const { rows } = await client.query('SELECT * FROM branch.project_rollup WHERE project_id = 1'); - expect(rows[0].member_count).toBe(0); - expect(rows[0].donation_count).toBe(0); - expect(rows[0].report_count).toBe(0); - expect(Number(rows[0].total_donated)).toBe(0); + expect(rows[0].member_count).toBe(2); }); test('reports move report_count', async () => { - await client.query(` - INSERT INTO branch.reports (project_id, title, object_url, report_type) - VALUES (1, 'a', 's3://a', 'technical'), (1, 'b', 's3://b', 'narrative') - `); + await recordReport({ project_id: 1, title: 'a', object_url: 's3://a', report_type: 'technical' }); + const b = await recordReport({ + project_id: 1, + title: 'b', + object_url: 's3://b', + report_type: 'narrative', + }); await auditRollups(client); const { rows } = await client.query( @@ -416,7 +342,7 @@ describe('project_rollup trigger', () => { ); expect(rows[0].report_count).toBe(2); - await client.query(`DELETE FROM branch.reports WHERE project_id = 1 AND title = 'a'`); + await removeReport(b.report_id); await auditRollups(client); }); }); @@ -433,6 +359,7 @@ describe('rollup-backed read paths agree with the base tables', () => { (2, 1, 500, 'Travel', 'approved', date_trunc('year', CURRENT_DATE)), (2, 1, 50, NULL, 'approved', date_trunc('year', CURRENT_DATE)) `); + await reconcileRollups(client); }); test('dashboard totalSpent equals the approved sum for the year', async () => { diff --git a/apps/backend/lambdas/reports/auth.ts b/apps/backend/lambdas/reports/auth.ts index 87b5c5e1..d5f3b4c8 100644 --- a/apps/backend/lambdas/reports/auth.ts +++ b/apps/backend/lambdas/reports/auth.ts @@ -1,6 +1,6 @@ import { authenticateRequest as _authenticateRequest, loadRbacSubject } from '@branch/lambda-auth'; import { createAuthResolver } from '@branch/lambda-http'; -import db from './db'; +import { db } from '@branch/store'; export * from '@branch/lambda-auth'; diff --git a/apps/backend/lambdas/reports/controllers/reports.ts b/apps/backend/lambdas/reports/controllers/reports.ts index 72d49d56..712e9f29 100644 --- a/apps/backend/lambdas/reports/controllers/reports.ts +++ b/apps/backend/lambdas/reports/controllers/reports.ts @@ -2,7 +2,7 @@ import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } fro import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { json, parseBody, reportError, serverError } from '@branch/lambda-http'; import type { RouteHandler } from '@branch/lambda-http'; -import db from '../db'; +import { db, recordReport, removeReport } from '@branch/store'; import { fetchReportData, generatePdf, @@ -258,11 +258,12 @@ export const createReport: RouteHandler = async ({ event }) => { return json(400, { message: "objectUrl must point at this project's prefix in the reports bucket" }); } - const report = await db - .insertInto('branch.reports') - .values({ project_id: projectId, title: (title as string).trim(), object_url: objectUrl as string, report_type: resolvedReportType }) - .returningAll() - .executeTakeFirst(); + const report = await recordReport({ + project_id: projectId, + title: (title as string).trim(), + object_url: objectUrl as string, + report_type: resolvedReportType, + }); return json(201, report); }; @@ -307,8 +308,8 @@ export const deleteReport: RouteHandler = async ({ params, path, method }) => { const report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); if (!report) return json(404, { message: 'Report not found' }); - const deleted = await db.deleteFrom('branch.reports').where('report_id', '=', Number(id)).execute(); - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + const deleted = await removeReport(Number(id)); + if (deleted === 0n) { return json(404, { message: 'Report not found' }); } diff --git a/apps/backend/lambdas/reports/db.ts b/apps/backend/lambdas/reports/db.ts deleted file mode 100644 index 25eeac6e..00000000 --- a/apps/backend/lambdas/reports/db.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Kysely, PostgresDialect } from 'kysely' -import { Pool } from 'pg' -import type { DB } from '@branch/types' - -const db = new Kysely({ - dialect: new PostgresDialect({ - pool: new Pool({ - host: process.env.DB_HOST ?? 'localhost', - port: Number(process.env.DB_PORT ?? 5432), - user: process.env.DB_USER ?? 'branch_dev', - password: process.env.DB_PASSWORD ?? 'password', - database: process.env.DB_NAME ?? 'branch_db', - - // rds.force_ssl = 1 on default.postgres17 rejects unencrypted connections, - // so ssl: false never worked against prod. Local postgres has no TLS. - // TODO: pin the RDS CA bundle instead of skipping verification. - ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, - - // Without this a blackholed SYN hangs until the 30s lambda timeout instead - // of erroring, which is how the unreachable-database bug presented. - connectionTimeoutMillis: 5000, - - // A lambda container serves one request at a time, so pg's default of 10 - // just multiplies idle sockets against db.t3.micro's ~112 max_connections. - max: 1, - - // Lambda freezes the container between invocations, so the idle timer fires - // late and the pool can hand back a socket the server already dropped. - idleTimeoutMillis: 0, - keepAlive: true, - - // Bound a runaway query well under the 30s lambda timeout. - statement_timeout: 10000, - }), - }), -}) - -export default db diff --git a/apps/backend/lambdas/reports/package-lock.json b/apps/backend/lambdas/reports/package-lock.json index dbc70881..53d37049 100644 --- a/apps/backend/lambdas/reports/package-lock.json +++ b/apps/backend/lambdas/reports/package-lock.json @@ -13,6 +13,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "docx": "^9.5.0", @@ -84,6 +85,24 @@ "typescript": "^5.4.5" } }, + "../../../../shared/store": { + "name": "@branch/store", + "version": "1.0.0", + "dependencies": { + "kysely": "^0.28.8", + "pg": "^8.16.3" + }, + "devDependencies": { + "@branch/types": "file:../types", + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "@types/pg": "^8.15.5", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -1498,6 +1517,10 @@ "resolved": "../../../../shared/rbac", "link": true }, + "node_modules/@branch/store": { + "resolved": "../../../../shared/store", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/reports/package.json b/apps/backend/lambdas/reports/package.json index 8e016ad4..92371575 100644 --- a/apps/backend/lambdas/reports/package.json +++ b/apps/backend/lambdas/reports/package.json @@ -32,6 +32,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "docx": "^9.5.0", diff --git a/apps/backend/lambdas/reports/report-service.ts b/apps/backend/lambdas/reports/report-service.ts index 3738c9d4..2522e5c9 100644 --- a/apps/backend/lambdas/reports/report-service.ts +++ b/apps/backend/lambdas/reports/report-service.ts @@ -1,4 +1,4 @@ -import db from './db'; +import { db, recordReport } from '@branch/store'; import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import type { TDocumentDefinitions, Content, TableCell } from 'pdfmake/interfaces'; import { @@ -535,16 +535,12 @@ export async function saveReportRecord( title: string, reportType: 'technical' | 'narrative' = 'technical', ): Promise<{ report_id: number; object_url: string; report_type: string }> { - const row = await db - .insertInto('branch.reports') - .values({ - project_id: projectId, - object_url: objectUrl, - title, - report_type: reportType, - }) - .returning(['report_id', 'object_url', 'report_type']) - .executeTakeFirstOrThrow(); + const row = await recordReport({ + project_id: projectId, + object_url: objectUrl, + title, + report_type: reportType, + }); return { report_id: row.report_id, object_url: row.object_url, report_type: row.report_type }; } diff --git a/apps/backend/lambdas/reports/test/report-service.e2e.test.ts b/apps/backend/lambdas/reports/test/report-service.e2e.test.ts index d287f4d1..c7d786fe 100644 --- a/apps/backend/lambdas/reports/test/report-service.e2e.test.ts +++ b/apps/backend/lambdas/reports/test/report-service.e2e.test.ts @@ -7,7 +7,7 @@ import { describe, test, expect, beforeAll, beforeEach, afterAll } from '@jest/g import { Pool } from 'pg'; import { ensureSchema, resetData } from '../../../db/testkit'; -import db from '../db'; +import { db, closeConnection } from '@branch/store'; import { fetchReportData, keyFromObjectUrl, objectUrlFor, reportKeyPrefix } from '../report-service'; const pool = new Pool({ @@ -49,7 +49,7 @@ beforeEach(async () => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); describe('fetchReportData', () => { diff --git a/apps/backend/lambdas/reports/test/reports.e2e.test.ts b/apps/backend/lambdas/reports/test/reports.e2e.test.ts index 1cb58e08..02629035 100644 --- a/apps/backend/lambdas/reports/test/reports.e2e.test.ts +++ b/apps/backend/lambdas/reports/test/reports.e2e.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeAll, beforeEach, afterAll, jest } from '@jest/globals'; import { Pool } from 'pg'; import { ensureSchema, resetData } from '../../../db/testkit'; -import db from '../db'; +import { db, closeConnection } from '@branch/store'; jest.mock('../auth', () => { // dispatch() resolves the caller through resolveAuth, so an auto-mock would @@ -14,7 +14,7 @@ jest.mock('../auth', () => { const { loadRbacSubject } = jest.requireActual( '@branch/lambda-auth', ); - const db = jest.requireActual('../db').default; + const db = jest.requireActual('@branch/store').db; const authenticateRequest = jest.fn(); return { ...jest.requireActual('../auth'), @@ -99,7 +99,7 @@ describe('Reports e2e tests', () => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); describe('Health check', () => { diff --git a/apps/backend/lambdas/reports/test/reports.unit.test.ts b/apps/backend/lambdas/reports/test/reports.unit.test.ts index 6c95025f..5d3545d8 100644 --- a/apps/backend/lambdas/reports/test/reports.unit.test.ts +++ b/apps/backend/lambdas/reports/test/reports.unit.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; -jest.mock('../db'); +jest.mock('@branch/store'); // Memberships the mocked session should appear to have. Named `mock*` so it can // be referenced from the jest.mock factory below. const mockMemberships: Array<{ project_id: number; role: string }> = []; @@ -54,11 +54,13 @@ jest.mock('../report-service', () => ({ })); import { handler } from '../handler'; -import db from '../db'; +import { db, recordReport, removeReport } from '@branch/store'; import { authenticateRequest } from '../auth'; import * as reportService from '../report-service'; const mockDb = db as any; +const mockRecordReport = recordReport as jest.MockedFunction; +const mockRemoveReport = removeReport as jest.MockedFunction; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; const mockReportService = reportService as jest.Mocked; function getEvent(queryStringParameters?: Record) { @@ -652,13 +654,7 @@ describe('POST /reports unit tests', () => { } function setupInsertMock(report: Record) { - mockDb.insertInto = jest.fn().mockReturnValue({ - values: jest.fn().mockReturnValue({ - returningAll: jest.fn().mockReturnValue({ - executeTakeFirst: jest.fn().mockReturnValue(report as any), - }), - }), - }); + mockRecordReport.mockResolvedValue(report as never); } function setupProjectMock(project: Record | undefined) { @@ -774,15 +770,16 @@ describe('POST /reports unit tests', () => { test('201: title is trimmed before inserting', async () => { let capturedValues: Record = {}; - mockDb.insertInto = jest.fn().mockReturnValue({ - values: jest.fn().mockImplementation((vals: any) => { - capturedValues = vals; - return { - returningAll: jest.fn().mockReturnValue({ - executeTakeFirst: jest.fn().mockReturnValue({ report_id: 1, project_id: 1, title: vals.title, object_url: fakeObjectUrl, report_type: 'technical', date_created: new Date() } as any), - }), - }; - }), + mockRecordReport.mockImplementation(async (vals: any) => { + capturedValues = vals; + return { + report_id: 1, + project_id: 1, + title: vals.title, + object_url: fakeObjectUrl, + report_type: 'technical', + date_created: new Date(), + } as never; }); await handler(postEvent({ title: ' My Report ', projectId: 1, objectUrl: fakeObjectUrl })); @@ -1006,11 +1003,7 @@ describe('DELETE /reports/{id} unit tests', () => { } function setupDeleteMock(numDeletedRows: bigint) { - mockDb.deleteFrom = jest.fn().mockReturnValue({ - where: jest.fn().mockReturnValue({ - execute: jest.fn().mockReturnValue([{ numDeletedRows }]), - }), - }); + mockRemoveReport.mockResolvedValue(numDeletedRows); } beforeEach(() => { @@ -1047,7 +1040,7 @@ describe('DELETE /reports/{id} unit tests', () => { expect(res.statusCode).toBe(404); expect(JSON.parse(res.body).message).toBe('Report not found'); - expect(mockDb.deleteFrom).not.toHaveBeenCalled(); + expect(mockRemoveReport).not.toHaveBeenCalled(); }); test('403: a non-admin cannot delete a report', async () => { @@ -1056,7 +1049,7 @@ describe('DELETE /reports/{id} unit tests', () => { expect(res.statusCode).toBe(403); expect(JSON.parse(res.body).message).toBe('Only administrators can do this'); - expect(mockDb.deleteFrom).not.toHaveBeenCalled(); + expect(mockRemoveReport).not.toHaveBeenCalled(); }); test('404: row already gone by the time delete executes (race condition)', async () => { @@ -1130,13 +1123,9 @@ describe('DELETE /reports/{id} unit tests', () => { test('the row is deleted before the object', async () => { const order: string[] = []; - mockDb.deleteFrom = jest.fn().mockReturnValue({ - where: jest.fn().mockReturnValue({ - execute: jest.fn(() => { - order.push('row'); - return [{ numDeletedRows: 1n }]; - }), - }), + mockRemoveReport.mockImplementation(async () => { + order.push('row'); + return 1n; }); mockS3Send.mockImplementation(async () => { order.push('object'); diff --git a/apps/backend/lambdas/users/auth.ts b/apps/backend/lambdas/users/auth.ts index 87b5c5e1..d5f3b4c8 100644 --- a/apps/backend/lambdas/users/auth.ts +++ b/apps/backend/lambdas/users/auth.ts @@ -1,6 +1,6 @@ import { authenticateRequest as _authenticateRequest, loadRbacSubject } from '@branch/lambda-auth'; import { createAuthResolver } from '@branch/lambda-http'; -import db from './db'; +import { db } from '@branch/store'; export * from '@branch/lambda-auth'; diff --git a/apps/backend/lambdas/users/controllers/users.ts b/apps/backend/lambdas/users/controllers/users.ts index 703d771e..be06bbb0 100644 --- a/apps/backend/lambdas/users/controllers/users.ts +++ b/apps/backend/lambdas/users/controllers/users.ts @@ -4,7 +4,7 @@ import { AdminDeleteUserCommand, } from '@aws-sdk/client-cognito-identity-provider'; import { json, reportError, requirePermission, type RouteHandler } from '@branch/lambda-http'; -import db from '../db'; +import { db, createUser as storeCreateUser, updateUser, removeUser } from '@branch/store'; import { UserValidationUtils } from '../validation-utils'; import { AVATAR_EXTENSIONS, @@ -203,12 +203,7 @@ export const patchUser: RouteHandler = async ({ event, params, auth }) => { // SELECT ahead of it and none behind it. The cost is that a malformed body is // now answered before a bad id — a 400 rather than a 404 — which is the same // precedence every other validated route here already has. - const updatedUser = await db - .updateTable('branch.users') - .set(updates) - .where('user_id', '=', Number(userId)) - .returningAll() - .executeTakeFirst(); + const updatedUser = await updateUser(Number(userId), updates); if (!updatedUser) return json(404, { message: 'User not found' }); @@ -222,9 +217,9 @@ export const deleteUser: RouteHandler = async ({ params }) => { const user = await db.selectFrom('branch.users').where('user_id', '=', Number(userId)).select('email').executeTakeFirst(); if (!user) return json(404, { message: 'User not found' }); - const deleted = await db.deleteFrom('branch.users').where('user_id', '=', Number(userId)).execute(); + const deleted = await removeUser(Number(userId)); - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + if (deleted === 0n) { return json(404, { message: 'User not found' }); } @@ -316,10 +311,7 @@ export const createUser: RouteHandler = async ({ event }) => { // Insert into database with cognito_sub try { - await db - .insertInto('branch.users') - .values({ cognito_sub: cognitoSub, email, name, is_admin: isAdmin, profile_image }) - .execute(); + await storeCreateUser({ cognito_sub: cognitoSub, email, name, is_admin: isAdmin, profile_image }); } catch (err: any) { console.error('Database insert error:', err); // Rollback: delete Cognito user to keep systems in sync diff --git a/apps/backend/lambdas/users/db.ts b/apps/backend/lambdas/users/db.ts deleted file mode 100644 index 6a890925..00000000 --- a/apps/backend/lambdas/users/db.ts +++ /dev/null @@ -1,39 +0,0 @@ - -import { Kysely, PostgresDialect } from 'kysely' -import { Pool } from 'pg' -import type { DB } from '@branch/types' - -const db = new Kysely({ - dialect: new PostgresDialect({ - pool: new Pool({ - host: process.env.DB_HOST ?? 'localhost', - port: Number(process.env.DB_PORT ?? 5432), - user: process.env.DB_USER ?? 'branch_dev', - password: process.env.DB_PASSWORD ?? 'password', - database: process.env.DB_NAME ?? 'branch_db', - - // rds.force_ssl = 1 on default.postgres17 rejects unencrypted connections, - // so ssl: false never worked against prod. Local postgres has no TLS. - // TODO: pin the RDS CA bundle instead of skipping verification. - ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, - - // Without this a blackholed SYN hangs until the 30s lambda timeout instead - // of erroring, which is how the unreachable-database bug presented. - connectionTimeoutMillis: 5000, - - // A lambda container serves one request at a time, so pg's default of 10 - // just multiplies idle sockets against db.t3.micro's ~112 max_connections. - max: 1, - - // Lambda freezes the container between invocations, so the idle timer fires - // late and the pool can hand back a socket the server already dropped. - idleTimeoutMillis: 0, - keepAlive: true, - - // Bound a runaway query well under the 30s lambda timeout. - statement_timeout: 10000, - }), - }), -}) - -export default db diff --git a/apps/backend/lambdas/users/package-lock.json b/apps/backend/lambdas/users/package-lock.json index 91eb2d10..71cde042 100644 --- a/apps/backend/lambdas/users/package-lock.json +++ b/apps/backend/lambdas/users/package-lock.json @@ -14,6 +14,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.16.3" @@ -79,6 +80,24 @@ "typescript": "^5.4.5" } }, + "../../../../shared/store": { + "name": "@branch/store", + "version": "1.0.0", + "dependencies": { + "kysely": "^0.28.8", + "pg": "^8.16.3" + }, + "devDependencies": { + "@branch/types": "file:../types", + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "@types/pg": "^8.15.5", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -936,6 +955,10 @@ "resolved": "../../../../shared/rbac", "link": true }, + "node_modules/@branch/store": { + "resolved": "../../../../shared/store", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/users/package.json b/apps/backend/lambdas/users/package.json index 297fab61..2aaf82f3 100644 --- a/apps/backend/lambdas/users/package.json +++ b/apps/backend/lambdas/users/package.json @@ -30,6 +30,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", "@branch/rbac": "file:../../../../shared/rbac", + "@branch/store": "file:../../../../shared/store", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", "pg": "^8.16.3" diff --git a/apps/backend/lambdas/users/test/user.photos.unit.test.ts b/apps/backend/lambdas/users/test/user.photos.unit.test.ts index e5565f7f..8a8b2885 100644 --- a/apps/backend/lambdas/users/test/user.photos.unit.test.ts +++ b/apps/backend/lambdas/users/test/user.photos.unit.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; -jest.mock('../db'); +jest.mock('@branch/store'); // Memberships the mocked session should appear to have. None matter here -- // profile:* is self-scoped -- but resolveAuth still has to build a subject. @@ -33,10 +33,11 @@ jest.mock('@aws-sdk/s3-request-presigner', () => ({ })); import { handler } from '../handler'; -import db from '../db'; +import { db, updateUser } from '@branch/store'; import { authenticateRequest } from '../auth'; const mockDb = db as any; +const mockUpdateUser = updateUser as jest.MockedFunction; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; // Only the identity is mocked: dispatch and the controllers run the real @@ -154,22 +155,14 @@ describe('PATCH /users/{userId} profileImage', () => { // patchUser settles the update, the 404 and the response body in one // statement, so the chain ends in returningAll().executeTakeFirst() rather // than execute() followed by a re-read. - mockDb.updateTable.mockReturnValue({ - set: jest.fn().mockReturnValue({ - where: jest.fn().mockReturnValue({ - returningAll: jest.fn().mockReturnValue({ - executeTakeFirst: (jest.fn() as any).mockResolvedValue({ - user_id: userId, - name: 'Ada Lovelace', - email: 'ada@example.com', - is_admin: false, - profile_image: null, - created_at: null, - }), - }), - }), - }), - }); + mockUpdateUser.mockResolvedValue({ + user_id: userId, + name: 'Ada Lovelace', + email: 'ada@example.com', + is_admin: false, + profile_image: null, + created_at: null, + } as never); } test('accepts a key this service minted for the same user', async () => { diff --git a/apps/backend/lambdas/users/test/user.privilege-escalation.test.ts b/apps/backend/lambdas/users/test/user.privilege-escalation.test.ts index 830b7657..7f8ca044 100644 --- a/apps/backend/lambdas/users/test/user.privilege-escalation.test.ts +++ b/apps/backend/lambdas/users/test/user.privilege-escalation.test.ts @@ -7,7 +7,7 @@ */ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; -jest.mock('../db'); +jest.mock('@branch/store'); // Memberships the mocked session should appear to have. Named `mock*` so it can // be referenced from the jest.mock factory below. const mockMemberships: Array<{ project_id: number; role: string }> = []; @@ -32,10 +32,11 @@ jest.mock('../auth', () => { }); import { handler } from '../handler'; -import db from '../db'; +import { db, updateUser } from '@branch/store'; import { authenticateRequest } from '../auth'; const mockDb = db as any; +const mockUpdateUser = updateUser as jest.MockedFunction; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction< typeof authenticateRequest >; @@ -66,7 +67,16 @@ function mockDbForPatch() { }), }), }); - mockDb.updateTable.mockReturnValue({ set: mockSet }); + mockUpdateUser.mockImplementation(async (_id, values) => { + mockSet(values); + return { + user_id: 2, + name: 'Regular User', + email: 'user@example.com', + is_admin: false, + profile_image: null, + } as never; + }); } /** Non-admin, userId 2 — so /2 is "self". */ diff --git a/apps/backend/lambdas/users/test/user.unit.test.ts b/apps/backend/lambdas/users/test/user.unit.test.ts index 38366828..ae76a2de 100644 --- a/apps/backend/lambdas/users/test/user.unit.test.ts +++ b/apps/backend/lambdas/users/test/user.unit.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; import { dispatch, json, type Route } from '@branch/lambda-http'; // Mock the database module BEFORE importing handler -jest.mock('../db'); +jest.mock('@branch/store'); // Memberships the mocked session should appear to have. Named `mock*` so it can // be referenced from the jest.mock factory below. const mockMemberships: Array<{ project_id: number; role: string }> = []; @@ -38,11 +38,14 @@ jest.mock('@aws-sdk/client-cognito-identity-provider', () => { }); import { handler } from '../handler'; -import db from '../db'; +import { db, updateUser, createUser, removeUser } from '@branch/store'; import { authenticateRequest } from '../auth'; import { before } from 'node:test'; const mockDb = db as any; +const mockUpdateUser = updateUser as jest.MockedFunction; +const mockCreateUser = createUser as jest.MockedFunction; +const mockRemoveUser = removeUser as jest.MockedFunction; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; @@ -84,15 +87,7 @@ function mockExistingUserForPatch(updated?: Record) { // patchUser writes and reads back in one statement, so the row the handler // answers with is the one the UPDATE's RETURNING produces. - mockDb.updateTable.mockReturnValue({ - set: jest.fn().mockReturnValue({ - where: jest.fn().mockReturnValue({ - returningAll: jest.fn().mockReturnValue({ - executeTakeFirst: (jest.fn() as any).mockResolvedValue(updated ?? existing), - }), - }), - }), - }); + mockUpdateUser.mockResolvedValue((updated ?? existing) as never); } function mockAdminAuth() { @@ -343,12 +338,7 @@ describe('POST /users unit tests', () => { where: jest.fn().mockReturnValue(whereChain), }); - // Mock the insert - mockDb.insertInto.mockReturnValue({ - values: jest.fn().mockReturnValue({ - execute: (jest.fn() as any).mockResolvedValue(undefined), - }), - }); + mockCreateUser.mockResolvedValue({ user_id: 1 } as never); const res = await handler( postEvent({ @@ -486,16 +476,7 @@ describe('PATCH /users/{userId} unit tests', () => { describe('Success Cases', () => { test('404: returns 404 when user does not exist', async () => { - // Nothing matched the id, so the UPDATE returns no row. - mockDb.updateTable.mockReturnValue({ - set: jest.fn().mockReturnValue({ - where: jest.fn().mockReturnValue({ - returningAll: jest.fn().mockReturnValue({ - executeTakeFirst: (jest.fn() as any).mockResolvedValue(undefined), - }), - }), - }), - }); + mockUpdateUser.mockResolvedValue(undefined); const res = await handler(patchEvent(999, { name: 'Whoever' })); @@ -539,7 +520,7 @@ describe('PATCH /users/{userId} unit tests', () => { // Only the provided field should be passed to .set(). toStrictEqual (unlike // toEqual) does NOT ignore undefined keys, so this fails if the handler ever // regresses to setting every column and leaving omitted ones undefined. - const setCall = (mockDb.updateTable.mock.results[0].value.set as jest.Mock).mock.calls[0][0]; + const setCall = mockUpdateUser.mock.calls[0][1]; expect(setCall).toStrictEqual({ name: 'New Name' }); }); }); diff --git a/apps/backend/lambdas/users/test/users.test.ts b/apps/backend/lambdas/users/test/users.test.ts index 71810bf7..b3fc5691 100644 --- a/apps/backend/lambdas/users/test/users.test.ts +++ b/apps/backend/lambdas/users/test/users.test.ts @@ -14,7 +14,7 @@ jest.mock('@aws-sdk/client-cognito-identity-provider', () => { import { Pool } from 'pg'; import { ensureSchema, resetData } from '../../../db/testkit'; -import db from '../db'; +import { db, closeConnection } from '@branch/store'; import { handler } from '../handler'; import { authenticateRequest } from '../auth'; @@ -30,7 +30,7 @@ jest.mock('../auth', () => { const { loadRbacSubject } = jest.requireActual( '@branch/lambda-auth', ); - const db = jest.requireActual('../db').default; + const db = jest.requireActual('@branch/store').db; const authenticateRequest = jest.fn(); return { ...jest.requireActual('../auth'), @@ -93,7 +93,7 @@ beforeEach(async () => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); diff --git a/infrastructure/aws/README.md b/infrastructure/aws/README.md index 844bc58f..6f23dbab 100644 --- a/infrastructure/aws/README.md +++ b/infrastructure/aws/README.md @@ -65,9 +65,11 @@ | [aws_iam_role_policy.ci_plan_state_lock](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.ci_preview](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.lambda_cognito_admin](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.lambda_dsql_connect](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.lambda_s3_objects](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.lambda_ses_send](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy_attachment.ci_apply_admin](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.ci_plan_dsql_readonly](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.ci_plan_readonly](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.lambda_basic](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_lambda_function.functions](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/lambda_function) | resource | diff --git a/infrastructure/aws/lambda.tf b/infrastructure/aws/lambda.tf index 0f350199..959534d5 100644 --- a/infrastructure/aws/lambda.tf +++ b/infrastructure/aws/lambda.tf @@ -109,6 +109,22 @@ resource "aws_iam_role_policy" "lambda_ses_send" { }) } +# Resource "*" until the DSQL cluster exists; scope to its ARN then. +resource "aws_iam_role_policy" "lambda_dsql_connect" { + name = "branch-lambda-dsql-connect" + role = aws_iam_role.lambda_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "LambdaDsqlConnect" + Effect = "Allow" + Action = ["dsql:DbConnectAdmin"] + Resource = "*" + }] + }) +} + # Get AWS account ID for unique bucket naming data "aws_caller_identity" "current" {} diff --git a/infrastructure/aws/oidc.tf b/infrastructure/aws/oidc.tf index a164cf57..3820a3f4 100644 --- a/infrastructure/aws/oidc.tf +++ b/infrastructure/aws/oidc.tf @@ -45,6 +45,12 @@ resource "aws_iam_role_policy_attachment" "ci_plan_readonly" { policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" } +# Plan needs dsql read access; ReadOnlyAccess is not guaranteed to include it. +resource "aws_iam_role_policy_attachment" "ci_plan_dsql_readonly" { + role = aws_iam_role.ci_plan.name + policy_arn = "arn:aws:iam::aws:policy/AmazonAuroraDSQLReadOnlyAccess" +} + resource "aws_iam_role_policy" "ci_plan_state_lock" { name = "tfstate-lock" role = aws_iam_role.ci_plan.id diff --git a/shared/store/jest.config.js b/shared/store/jest.config.js new file mode 100644 index 00000000..37b24d51 --- /dev/null +++ b/shared/store/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/test/**/*.test.ts'], +}; diff --git a/shared/store/package-lock.json b/shared/store/package-lock.json new file mode 100644 index 00000000..b10795fd --- /dev/null +++ b/shared/store/package-lock.json @@ -0,0 +1,4908 @@ +{ + "name": "@branch/store", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@branch/store", + "version": "1.0.0", + "dependencies": { + "kysely": "^0.28.8", + "pg": "^8.16.3" + }, + "devDependencies": { + "@branch/types": "file:../types", + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "@types/pg": "^8.15.5", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, + "../types": { + "name": "@branch/types", + "version": "1.0.0", + "dev": true + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@branch/types": { + "resolved": "../types", + "link": true + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.5.1.tgz", + "integrity": "sha512-u5Ncuc+gXVUwjNMFQOnphHo2Qx2DxyC8Dvpmf4HCx4y0kvSkRRNiQYRixrvOP4V7y52JcSuNxqochCt0PpdHVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.5.1.tgz", + "integrity": "sha512-BL9g6CJUUhIbdoAflz/Va658erSSXUIvU8XUYiWNTbZljJjZ5yaC9EJ9/dQ19rpbYV3ZOK4sBACqhPaTyhoUIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.5.1", + "@jest/pattern": "30.5.0", + "@jest/reporters": "30.5.1", + "@jest/test-result": "30.5.1", + "@jest/transform": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.5.1", + "jest-config": "30.5.1", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-resolve-dependencies": "30.5.1", + "jest-runner": "30.5.1", + "jest-runtime": "30.5.1", + "jest-snapshot": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "jest-watcher": "30.5.1", + "pretty-format": "30.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", + "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.1.tgz", + "integrity": "sha512-eYJAkOsrpwDXPcoLNEG6lN9Zo3Cy35pxnVQD74vyIfsi/Q8wB/lZFsEjU4wrecOgT4ZviQDZaX/cFIJyMdMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-mock": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.1.tgz", + "integrity": "sha512-uOGd40P/COyUp9xHf5jeGiGJC2/ANg+2+Tk9/xN5/LxmlY/r/gxsPrx3DTEtaRZsLAX4wgNSLEDELZ5bmVs2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.5.1", + "jest-snapshot": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.1.tgz", + "integrity": "sha512-WcRWhHQdTMRDpyWKZ/6MINBmovI7zeD+bL8wFjCncRV3NQOwKy1X45IfyblfHR4k/XciIlNEdFL9QjFO+HNKOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.1.tgz", + "integrity": "sha512-rEkV6YzpBXo/L9cnj2ibyuYZuyGiNWaPUsyCu3HYJbqCV4jnEiKmf7hId20z8eKjZ0JQ9jtIYKxRSmYB/OYSsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.1", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.1.tgz", + "integrity": "sha512-VhqvQ251XIC7pk46YymI3HCeBLOdvriDw9zibekodAHEwoVvbVVgpU5H13GvmyOAzxtZCfsm1uvzqkaqJf2I+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.1", + "@jest/expect": "30.5.1", + "@jest/types": "30.5.1", + "jest-mock": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/reporters": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.5.1.tgz", + "integrity": "sha512-RbUXIfv85KxitJn4l3MpAoMilkvXe2QCO5lXxHWftywo/VdZ5vkEoHRDgphcxP6qmoIkUaN8/UZj6NhdkIFdJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.5.1", + "@jest/test-result": "30.5.1", + "@jest/transform": "30.5.1", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.5.1.tgz", + "integrity": "sha512-V3wnxNtiVmw5PPVg433Cn3VdXnsOeu/ofLw3KC04Bn2y1wIlU5kozXQn48rhrgj/02LrCVtntTEF1yBha0XSUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.5.0.tgz", + "integrity": "sha512-xWpTJP9D0bDFGbPGT8XuWSwwha/iHADyyKzUnMx4UbdgnHugxrDaQFO4RZ8x4ZsFzRP6pNii8uvlgKCDxCIuDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "callsites": "^3.1.0", + "convert-source-map": "^2.0.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.5.1.tgz", + "integrity": "sha512-A/1S6ZBdpic50E0pxLgvaB9XNPL4k7AksmG69OO2oiotxciWZwHkbOec+qbIi+uUtIIByOVlXJUl97oZUsz+Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.5.1", + "@jest/types": "30.5.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.5.1.tgz", + "integrity": "sha512-SHcPnrjdVRYJv6y6l4JUTy4Jccu7zmO6BmiOfOA34UiVBto22s19WiDC0A/2qfM7MWB/qdcoqfSIRMitpjTT4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.5.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.5.1.tgz", + "integrity": "sha512-EDnDhn0jleU9ZhpVoA4gvqw+Ev0iw/r5upNT2b79RiwiaTiYAMMhvNJP3lmocjIe5J2ZkoNr0B3K/J6O5GIm4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", + "babel-plugin-istanbul": "^8.0.0", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", + "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.1.tgz", + "integrity": "sha512-ge1xUVZS91ml09YRMgRGgeKJ4YJpcOiuwteAxFBYLugQyp7cRw+hHej6Ho0vPjvLrjq60bb7JPHH9LAMA1/krA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.5.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^8.0.0", + "babel-preset-jest": "30.5.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", + "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^7.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.5.0.tgz", + "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.5.0.tgz", + "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.5.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.5.1.tgz", + "integrity": "sha512-m8YrYgvKe9+9gEnWEuKz+qCGfHqkrff7PPfyDnOFkjsfYRKqiYyOxDFMFieCGAuhtk/VNm63tvNgKZVzVy+Hvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.1", + "@jest/get-type": "30.5.0", + "jest-matcher-utils": "30.5.1", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.1", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.5.1.tgz", + "integrity": "sha512-3qrR8+ZXFnn7y0H2yjWQNkGGBLBY4zTRoTMAq9zJcgwLLtlyonfsCLviIXK9xuE2KgIyM+M36AFcoK2DgFR36w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.5.1", + "@jest/types": "30.5.1", + "import-local": "^3.2.0", + "jest-cli": "30.5.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.5.1.tgz", + "integrity": "sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.5.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.5.1.tgz", + "integrity": "sha512-NgliezXQ6yznqR4W5Gqw++0cZSbBZlF0NknNIAE5VmSJKWOtmWJmWnBq3TSkUOVBTPrT7FGGhVdA8DLWnxo2Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.1", + "@jest/expect": "30.5.1", + "@jest/test-result": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.5.1", + "jest-matcher-utils": "30.5.1", + "jest-message-util": "30.5.1", + "jest-runtime": "30.5.1", + "jest-snapshot": "30.5.1", + "jest-util": "30.5.1", + "p-limit": "^3.1.0", + "pretty-format": "30.5.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.5.1.tgz", + "integrity": "sha512-uwNYepWgaNBCplm42fCIUZTf/tIEHIy5AYvx7eL1BrwIgyjPt+2poouR23UO2yopIP+kV8oZFHfv+l4pg/8prQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.5.1", + "@jest/test-result": "30.5.1", + "@jest/types": "30.5.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.5.1.tgz", + "integrity": "sha512-L8PKM2X/ngG8PxfLMqglKGZjylPgw84bVPUlMY9W/o76TnLqPWrmQgsaT0HrheGdRtpGE5FbmREA0Kvb+Qx5gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/test-sequencer": "30.5.1", + "@jest/types": "30.5.1", + "babel-jest": "30.5.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "jest-circus": "30.5.1", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-runner": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "parse-json": "^5.2.0", + "pretty-format": "30.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.1.tgz", + "integrity": "sha512-e3cNNMpv8Kh20MjjphTXs+3Vz7DQyLM1nft7KJhnh46atFhjVJRa+0Hq0beywuwsACtMQUBihQkFl8zxb7gt1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.5.0.tgz", + "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.5.1.tgz", + "integrity": "sha512-S1af0TU4v1EZ/AUlkFs/sxf/5KGsbAT9kRgdyoMX/x72y7C8ZEgETE5o1TPnbKRnrbprc5FR/moYLfdRmqEjsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", + "chalk": "^4.1.2", + "jest-util": "30.5.1", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.5.1.tgz", + "integrity": "sha512-LrPj3sPMjsQoOB3jrb8p/sa+XkSFNKo60TTsyc+EB2kxQJHgbwElpXnx1yX25fdFn5958FIObRtcLSHyV8VIAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.1", + "@jest/fake-timers": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-mock": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.1.tgz", + "integrity": "sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@parcel/watcher": "^2.6.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "fdir": "^6.5.0", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.5.1.tgz", + "integrity": "sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.1.tgz", + "integrity": "sha512-aroZVqwOz/wC2y6pC+obgFWKV9viaQWQTTSB6W5H55+egtUczIfXR/rxExTv92xD/ADYwpqaYfVWx1aqwKg7FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "jest-diff": "30.5.1", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz", + "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.1", + "picomatch": "^4.0.3", + "pretty-format": "30.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.1.tgz", + "integrity": "sha512-9fVjc3leUpGID2/by/LU4Dvdcp7PFh9LlxS3QRWK3ABm+KtvEVsG/AEGeLY3gKOZsjkBxyfwGltoAVlW7dygHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.5.1.tgz", + "integrity": "sha512-wprhLejRtwN6h8ZgaqC0eYjGJ1uMdGPT/+b3eFncbG4NWuuP9QL+vWwRinu9waw7hkOLcpMJGVJAMgBwsPssMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.12.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.5.1.tgz", + "integrity": "sha512-JKpXGONDcTaunrNVn8KCl6qAnwl06jIkvv70lhq0Ze47lsPx9sH5HoeEUiXX6iFGnliHN/qpIbQ0l38wrVmXaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.5.0", + "jest-snapshot": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.5.1.tgz", + "integrity": "sha512-FPlQE4+mwnFxXmpPrSi836KV2ZzvK1g6/nPCT8o5BcoDUUNJQeHo1/Qdkoe/QeoL4M7OeJpbnGUhYKhC1VMdaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.5.1", + "@jest/environment": "30.5.1", + "@jest/source-map": "30.5.0", + "@jest/test-result": "30.5.1", + "@jest/transform": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.1", + "jest-haste-map": "30.5.1", + "jest-leak-detector": "30.5.1", + "jest-message-util": "30.5.1", + "jest-resolve": "30.5.1", + "jest-runtime": "30.5.1", + "jest-util": "30.5.1", + "jest-watcher": "30.5.1", + "jest-worker": "30.5.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.5.1.tgz", + "integrity": "sha512-UB88+NRkK2Tw/OqV7dofYcyiUGrVZtD41k/N0xQOs9fG//57XHK7JLWG52HD1UYpUAhjK4xm6DBvFX3yWudsMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.1", + "@jest/fake-timers": "30.5.1", + "@jest/globals": "30.5.1", + "@jest/source-map": "30.5.0", + "@jest/test-result": "30.5.1", + "@jest/transform": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.2.0", + "collect-v8-coverage": "^1.0.2", + "es-module-lexer": "^2.1.0", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-snapshot": "30.5.1", + "jest-util": "30.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.5.1.tgz", + "integrity": "sha512-cNWFdSb5xuDGl8hKkAZJ3YtI/PzHpAPFV+HUXWIOG8rMhpDTLVbvAc2d2wRicoYw2wGsJGLaurJT6BLC97bLXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.5.1", + "@jest/get-type": "30.5.0", + "@jest/snapshot-utils": "30.5.1", + "@jest/transform": "30.5.1", + "@jest/types": "30.5.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.5.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.5.1", + "jest-matcher-utils": "30.5.1", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "pretty-format": "30.5.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.1.tgz", + "integrity": "sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.5.1.tgz", + "integrity": "sha512-i/buJ56wTpxihE93hQYNMfdOThS87+HGvLZzZJmU4xggPcdTYQq051iwALLCHp3q+SkCGH+EFejQZz5EbBSkkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.5.1.tgz", + "integrity": "sha512-+FHJ7C+S7b3ySfhA1aFmoa6TztsnwZVv84ycPRn0tVN93np2EU4b8C/KadqA3l6igdjgLBTnUotab9ER0HLG8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.5.1", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.5.1.tgz", + "integrity": "sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.5.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kysely": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", + "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-jest": { + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/shared/store/package.json b/shared/store/package.json new file mode 100644 index 00000000..03314a25 --- /dev/null +++ b/shared/store/package.json @@ -0,0 +1,30 @@ +{ + "name": "@branch/store", + "version": "1.0.0", + "private": true, + "description": "The only writer of rollup-affecting tables. Shared verbatim by the lambdas.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "prepare": "tsc", + "test": "jest" + }, + "dependencies": { + "kysely": "^0.28.8", + "pg": "^8.16.3" + }, + "devDependencies": { + "@branch/types": "file:../types", + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "@types/pg": "^8.15.5", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } +} diff --git a/apps/backend/lambdas/projects/db.ts b/shared/store/src/connection.ts similarity index 58% rename from apps/backend/lambdas/projects/db.ts rename to shared/store/src/connection.ts index 284bf483..ff9382d9 100644 --- a/apps/backend/lambdas/projects/db.ts +++ b/shared/store/src/connection.ts @@ -2,7 +2,7 @@ import { Kysely, PostgresDialect } from 'kysely' import { Pool } from 'pg' import type { DB } from '@branch/types' -const db = new Kysely({ +export const writeDb = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ host: process.env.DB_HOST ?? 'localhost', @@ -11,21 +11,19 @@ const db = new Kysely({ password: process.env.DB_PASSWORD ?? 'password', database: process.env.DB_NAME ?? 'branch_db', - // rds.force_ssl = 1 on default.postgres17 rejects unencrypted connections, - // so ssl: false never worked against prod. Local postgres has no TLS. + // rds.force_ssl = 1 rejects unencrypted connections. Local postgres has no TLS. // TODO: pin the RDS CA bundle instead of skipping verification. ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, - // Without this a blackholed SYN hangs until the 30s lambda timeout instead - // of erroring, which is how the unreachable-database bug presented. + // A blackholed SYN otherwise hangs until the 30s lambda timeout instead of erroring. connectionTimeoutMillis: 5000, - // A lambda container serves one request at a time, so pg's default of 10 - // just multiplies idle sockets against db.t3.micro's ~112 max_connections. + // A lambda container serves one request at a time; pg's default of 10 just + // multiplies idle sockets against db.t3.micro's ~112 max_connections. max: 1, - // Lambda freezes the container between invocations, so the idle timer fires - // late and the pool can hand back a socket the server already dropped. + // Lambda freezes the container between invocations, so the idle timer fires late + // and the pool can hand back a socket the server already dropped. idleTimeoutMillis: 0, keepAlive: true, @@ -34,4 +32,3 @@ const db = new Kysely({ }), }), }) -export default db \ No newline at end of file diff --git a/shared/store/src/donations.ts b/shared/store/src/donations.ts new file mode 100644 index 00000000..a2be473f --- /dev/null +++ b/shared/store/src/donations.ts @@ -0,0 +1,79 @@ +import type { Selectable } from 'kysely' +import type { DB, NewDonation, NewDonor } from '@branch/types' +import { tx } from './tx' +import { projectRollupBump } from './rollups' + +type Donation = Selectable + +// amount is NUMERIC (a string); negate textually so the exact decimal survives. +const negate = (amount: string) => (amount.startsWith('-') ? amount.slice(1) : `-${amount}`) + +export async function recordDonation(values: NewDonation): Promise { + return tx(async (trx) => { + const row = await trx + .insertInto('branch.project_donations') + .values(values) + .returningAll() + .executeTakeFirstOrThrow() + await projectRollupBump(trx, row.project_id, { donated: row.amount, donations: 1 }) + return row + }) +} + +export async function removeDonation(id: number): Promise { + return tx(async (trx) => { + const before = await trx + .selectFrom('branch.project_donations') + .where('donation_id', '=', id) + .selectAll() + .forUpdate() + .executeTakeFirst() + if (!before) return 0n + + const deleted = await trx + .deleteFrom('branch.project_donations') + .where('donation_id', '=', id) + .executeTakeFirst() + const removed = deleted?.numDeletedRows ?? 0n + if (removed === 0n) return 0n + + await projectRollupBump(trx, before.project_id, { + donated: negate(before.amount), + donations: -1, + }) + return removed + }) +} + +export async function createDonor( + values: NewDonor, +): Promise> { + return tx(async (trx) => + trx.insertInto('branch.donors').values(values).returningAll().executeTakeFirstOrThrow(), + ) +} + +// donor_id is ON DELETE RESTRICT: delete the donations first and back their rollup out. +export async function removeDonor(donorId: number): Promise { + return tx(async (trx) => { + // RETURNING, not a prior SELECT: a donation added in between would be missed. + const removed = await trx + .deleteFrom('branch.project_donations') + .where('donor_id', '=', donorId) + .returning(['project_id', 'amount']) + .execute() + + const deleted = await trx + .deleteFrom('branch.donors') + .where('donor_id', '=', donorId) + .executeTakeFirst() + + for (const donation of removed) { + await projectRollupBump(trx, donation.project_id, { + donated: negate(donation.amount), + donations: -1, + }) + } + return deleted?.numDeletedRows ?? 0n + }) +} diff --git a/shared/store/src/expenditures.ts b/shared/store/src/expenditures.ts new file mode 100644 index 00000000..9853bb84 --- /dev/null +++ b/shared/store/src/expenditures.ts @@ -0,0 +1,66 @@ +import type { Selectable } from 'kysely' +import type { DB, ExpenditureEdit, NewExpenditure } from '@branch/types' +import { tx } from './tx' +import { expenditureRollupAdd, expenditureRollupRemove } from './rollups' + +type Expenditure = Selectable + +export async function recordExpenditure(values: NewExpenditure): Promise { + return tx(async (trx) => { + const row = await trx + .insertInto('branch.expenditures') + .values(values) + .returningAll() + .executeTakeFirstOrThrow() + await expenditureRollupAdd(trx, row) + return row + }) +} + +export async function editExpenditure( + id: number, + values: ExpenditureEdit, +): Promise { + return tx(async (trx) => { + const before = await trx + .selectFrom('branch.expenditures') + .where('expenditure_id', '=', id) + .selectAll() + .forUpdate() + .executeTakeFirst() + if (!before) return undefined + + const after = await trx + .updateTable('branch.expenditures') + .set(values) + .where('expenditure_id', '=', id) + .returningAll() + .executeTakeFirstOrThrow() + + await expenditureRollupRemove(trx, before) + await expenditureRollupAdd(trx, after) + return after + }) +} + +export async function removeExpenditure(id: number): Promise { + return tx(async (trx) => { + const before = await trx + .selectFrom('branch.expenditures') + .where('expenditure_id', '=', id) + .selectAll() + .forUpdate() + .executeTakeFirst() + if (!before) return 0n + + const deleted = await trx + .deleteFrom('branch.expenditures') + .where('expenditure_id', '=', id) + .executeTakeFirst() + const removed = deleted?.numDeletedRows ?? 0n + if (removed === 0n) return 0n + + await expenditureRollupRemove(trx, before) + return removed + }) +} diff --git a/shared/store/src/index.ts b/shared/store/src/index.ts new file mode 100644 index 00000000..cdf8d430 --- /dev/null +++ b/shared/store/src/index.ts @@ -0,0 +1,42 @@ +import type { Kysely } from 'kysely' +import type { DB } from '@branch/types' +import { writeDb } from './connection' + +export type ReadOnlyDb = Omit< + Kysely, + | 'insertInto' + | 'updateTable' + | 'deleteFrom' + | 'replaceInto' + | 'transaction' + | 'withSchema' + | 'with' + | 'withRecursive' + | 'schema' + | 'destroy' +> + +export const db: ReadOnlyDb = writeDb + +export function closeConnection(): Promise { + return writeDb.destroy() +} + +export { recordExpenditure, editExpenditure, removeExpenditure } from './expenditures' +export { recordDonation, removeDonation, createDonor, removeDonor } from './donations' +export { createProject, updateProject, removeProject } from './projects' +export { recordReport, removeReport } from './reports' +export { createUser, updateUser, claimUser, removeUser } from './users' + +export type { + NewExpenditure, + ExpenditureEdit, + NewDonation, + NewDonor, + NewReport, + NewProject, + ProjectEdit, + NewUser, + UserEdit, + ProjectMemberInput, +} from '@branch/types' diff --git a/shared/store/src/projects.ts b/shared/store/src/projects.ts new file mode 100644 index 00000000..4e24d794 --- /dev/null +++ b/shared/store/src/projects.ts @@ -0,0 +1,98 @@ +import type { Selectable, Transaction } from 'kysely' +import type { DB, NewProject, ProjectEdit, ProjectMemberInput } from '@branch/types' +import { tx } from './tx' +import { projectRollupBump, seedProjectRollup } from './rollups' + +type Project = Selectable + +// An absent `role` keeps the member's current one: the staff picker submits bare ids, and defaulting strips directors. +async function syncMemberships( + trx: Transaction, + projectId: number, + members: ProjectMemberInput[], + defaultRole: string, +): Promise { + const existing = await trx + .selectFrom('branch.project_memberships') + .where('project_id', '=', projectId) + .select(['user_id', 'role']) + .execute() + const heldRole = new Map(existing.map((row) => [row.user_id, row.role])) + + const cleared = await trx + .deleteFrom('branch.project_memberships') + .where('project_id', '=', projectId) + .executeTakeFirst() + + if (members.length > 0) { + await trx + .insertInto('branch.project_memberships') + .values( + members.map((m) => ({ + project_id: projectId, + user_id: m.user_id, + role: m.role ?? heldRole.get(m.user_id) ?? defaultRole, + })), + ) + .execute() + } + + // Counted from the DELETE: a concurrent roster edit makes `existing` stale. + const delta = members.length - Number(cleared?.numDeletedRows ?? 0n) + if (delta !== 0) await projectRollupBump(trx, projectId, { members: delta }) +} + +export async function createProject( + values: NewProject, + members: ProjectMemberInput[], + defaultRole: string, +): Promise { + return tx(async (trx) => { + const row = await trx + .insertInto('branch.projects') + .values(values) + .returningAll() + .executeTakeFirstOrThrow() + await seedProjectRollup(trx, row.project_id) + if (members.length > 0) await syncMemberships(trx, row.project_id, members, defaultRole) + return row + }) +} + +export async function updateProject( + id: number, + values: ProjectEdit, + members: ProjectMemberInput[] | undefined, + defaultRole: string, +): Promise { + return tx(async (trx) => { + const row = + Object.keys(values).length > 0 + ? await trx + .updateTable('branch.projects') + .set(values) + .where('project_id', '=', id) + .returningAll() + .executeTakeFirst() + : await trx + .selectFrom('branch.projects') + .where('project_id', '=', id) + .selectAll() + .executeTakeFirst() + + if (!row) return undefined + if (members !== undefined) await syncMemberships(trx, id, members, defaultRole) + return row + }) +} + +/** Both rollup tables reference projects ON DELETE CASCADE, so they clean themselves up. */ +export async function removeProject(id: number): Promise { + return tx(async (trx) => { + const deleted = await trx + .deleteFrom('branch.projects') + .where('project_id', '=', id) + .executeTakeFirst() + return deleted?.numDeletedRows ?? 0n + }) +} diff --git a/shared/store/src/reports.ts b/shared/store/src/reports.ts new file mode 100644 index 00000000..c82ee6e8 --- /dev/null +++ b/shared/store/src/reports.ts @@ -0,0 +1,40 @@ +import type { Selectable } from 'kysely' +import type { DB, NewReport } from '@branch/types' +import { tx } from './tx' +import { projectRollupBump } from './rollups' + +type Report = Selectable + +export async function recordReport(values: NewReport): Promise { + return tx(async (trx) => { + const row = await trx + .insertInto('branch.reports') + .values(values) + .returningAll() + .executeTakeFirstOrThrow() + await projectRollupBump(trx, row.project_id, { reports: 1 }) + return row + }) +} + +export async function removeReport(id: number): Promise { + return tx(async (trx) => { + const before = await trx + .selectFrom('branch.reports') + .where('report_id', '=', id) + .select('project_id') + .forUpdate() + .executeTakeFirst() + if (!before) return 0n + + const deleted = await trx + .deleteFrom('branch.reports') + .where('report_id', '=', id) + .executeTakeFirst() + const removed = deleted?.numDeletedRows ?? 0n + if (removed === 0n) return 0n + + await projectRollupBump(trx, before.project_id, { reports: -1 }) + return removed + }) +} diff --git a/shared/store/src/rollups.ts b/shared/store/src/rollups.ts new file mode 100644 index 00000000..60f26585 --- /dev/null +++ b/shared/store/src/rollups.ts @@ -0,0 +1,61 @@ +import { sql, type Selectable, type Transaction } from 'kysely' +import type { DB } from '@branch/types' + +export type ExpenditureGrain = Pick< + Selectable, + 'project_id' | 'spent_on' | 'category' | 'status' | 'amount' +> + +export async function expenditureRollupAdd( + trx: Transaction, + row: ExpenditureGrain, +): Promise { + await sql`select branch.expenditure_rollup_add( + ${row.project_id}, ${row.spent_on}, ${row.category}, ${row.status}, ${row.amount} + )`.execute(trx) +} + +export async function expenditureRollupRemove( + trx: Transaction, + row: ExpenditureGrain, +): Promise { + await sql`select branch.expenditure_rollup_remove( + ${row.project_id}, ${row.spent_on}, ${row.category}, ${row.status}, ${row.amount} + )`.execute(trx) +} + +export type RollupDelta = { + members?: number + donated?: number | string + donations?: number + reports?: number +} + +export async function projectRollupBump( + trx: Transaction, + projectId: number, + delta: RollupDelta, +): Promise { + const result = await sql<{ hit: number | null }>`select branch.project_rollup_bump( + ${projectId}, + ${delta.members ?? 0}, + ${delta.donated ?? 0}, + ${delta.donations ?? 0}, + ${delta.reports ?? 0} + ) AS hit`.execute(trx) + + if (result.rows[0]?.hit !== 1) { + throw new Error(`project_rollup has no row for project ${projectId}`) + } +} + +export async function seedProjectRollup( + trx: Transaction, + projectId: number, +): Promise { + await trx + .insertInto('branch.project_rollup') + .values({ project_id: projectId }) + .onConflict((oc) => oc.column('project_id').doNothing()) + .execute() +} diff --git a/shared/store/src/tx.ts b/shared/store/src/tx.ts new file mode 100644 index 00000000..8da47126 --- /dev/null +++ b/shared/store/src/tx.ts @@ -0,0 +1,27 @@ +import type { Transaction } from 'kysely' +import type { DB } from '@branch/types' +import { writeDb } from './connection' + +// Postgres serialization_failure/deadlock, plus Aurora DSQL's commit-time OCC codes. +const RETRYABLE = new Set(['40001', '40P01', 'OC000', 'OC001']) + +function isRetryable(err: unknown): boolean { + const code = (err as { code?: unknown } | null)?.code + return typeof code === 'string' && RETRYABLE.has(code) +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +export async function tx( + fn: (trx: Transaction) => Promise, + attempts = 3, +): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await writeDb.transaction().execute(fn) + } catch (err) { + if (attempt >= attempts - 1 || !isRetryable(err)) throw err + await sleep(2 ** attempt * 25 + Math.random() * 25) + } + } +} diff --git a/shared/store/src/users.ts b/shared/store/src/users.ts new file mode 100644 index 00000000..0abb6478 --- /dev/null +++ b/shared/store/src/users.ts @@ -0,0 +1,61 @@ +import type { Selectable } from 'kysely' +import type { DB, NewUser, UserEdit } from '@branch/types' +import { tx } from './tx' +import { projectRollupBump } from './rollups' + +type User = Selectable + +export async function createUser(values: NewUser): Promise { + return tx(async (trx) => + trx.insertInto('branch.users').values(values).returningAll().executeTakeFirstOrThrow(), + ) +} + +export async function updateUser( + id: number, + values: UserEdit, +): Promise { + return tx(async (trx) => + trx + .updateTable('branch.users') + .set(values) + .where('user_id', '=', id) + .returningAll() + .executeTakeFirst(), + ) +} + +// The `cognito_sub IS NULL` guard makes a concurrent claim a no-op instead of overwriting a live account. +export async function claimUser(userId: number, values: UserEdit): Promise { + return tx(async (trx) => { + const result = await trx + .updateTable('branch.users') + .set(values) + .where('user_id', '=', userId) + .where('cognito_sub', 'is', null) + .executeTakeFirst() + return result.numUpdatedRows + }) +} + +// user_id on project_memberships is ON DELETE RESTRICT: delete memberships first and decrement member_count. +export async function removeUser(userId: number): Promise { + return tx(async (trx) => { + // RETURNING, not a prior SELECT: a membership added in between would be missed. + const removed = await trx + .deleteFrom('branch.project_memberships') + .where('user_id', '=', userId) + .returning('project_id') + .execute() + + const deleted = await trx + .deleteFrom('branch.users') + .where('user_id', '=', userId) + .executeTakeFirst() + + for (const membership of removed) { + await projectRollupBump(trx, membership.project_id, { members: -1 }) + } + return deleted?.numDeletedRows ?? 0n + }) +} diff --git a/shared/store/test/read-only-surface.test.ts b/shared/store/test/read-only-surface.test.ts new file mode 100644 index 00000000..e054630f --- /dev/null +++ b/shared/store/test/read-only-surface.test.ts @@ -0,0 +1,21 @@ +import type { ReadOnlyDb } from '../src' + +type Assert = T +type Has = K extends keyof T ? true : false +type Lacks = Has extends false ? true : false + +type _Select = Assert> +type _Fn = Assert> + +type _NoInsert = Assert> +type _NoUpdate = Assert> +type _NoDelete = Assert> +type _NoTx = Assert> +type _NoSchema = Assert> +type _NoWithSchema = Assert> +type _NoWith = Assert> + +it('keeps the write surface off the exported handle', () => { + // The assertions above are compile-time; ts-jest fails the suite if they break. + expect(true).toBe(true) +}) diff --git a/shared/store/tsconfig.json b/shared/store/tsconfig.json new file mode 100644 index 00000000..e57dbd9f --- /dev/null +++ b/shared/store/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/shared/types/index.d.ts b/shared/types/index.d.ts index f988d9c8..78cdbe25 100644 --- a/shared/types/index.d.ts +++ b/shared/types/index.d.ts @@ -1,2 +1,3 @@ export * from './db-types'; export * from './auth-types'; +export * from './store-types'; diff --git a/shared/types/store-types.d.ts b/shared/types/store-types.d.ts new file mode 100644 index 00000000..08462bc5 --- /dev/null +++ b/shared/types/store-types.d.ts @@ -0,0 +1,80 @@ +export interface NewExpenditure { + project_id: number; + amount: number | string; + entered_by?: number | null; + category?: string | null; + description?: string | null; + status?: string; + receipt_url?: string | null; + spent_on?: Date | string; + admin_notes?: string | null; +} + +export interface ExpenditureEdit { + amount?: number | string; + category?: string | null; + description?: string | null; + receipt_url?: string | null; + spent_on?: Date | string; + status?: string; + admin_notes?: string | null; +} + +export interface NewDonation { + donor_id: number; + project_id: number; + amount: number | string; + donated_at?: Date | string | null; +} + +export interface NewDonor { + organization: string; + contact_name?: string | null; + contact_email?: string | null; +} + +export interface NewReport { + project_id: number; + title: string; + object_url: string; + report_type?: string; +} + +export interface NewProject { + name: string; + description: string; + total_budget?: number | string | null; + start_date?: Date | string | null; + end_date?: Date | string | null; + currency?: string | null; +} + +export interface ProjectEdit { + name?: string; + description?: string; + total_budget?: number | string | null; + start_date?: Date | string | null; + end_date?: Date | string | null; + currency?: string | null; +} + +export interface NewUser { + email: string; + name: string; + cognito_sub?: string | null; + is_admin?: boolean | null; + profile_image?: string | null; +} + +export interface UserEdit { + email?: string; + name?: string; + cognito_sub?: string | null; + is_admin?: boolean | null; + profile_image?: string | null; +} + +export interface ProjectMemberInput { + user_id: number; + role?: string | null; +}