From 71b0a6081ca12030a8f34730f2f90d0c1bb437d8 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sun, 6 Sep 2026 17:49:31 -0400 Subject: [PATCH 01/11] feat(infra): grant DSQL IAM access Groundwork for the Aurora DSQL migration, split out so the PR that adds the cluster does not also have to change IAM. branch-ci-plan carries AWS-managed ReadOnlyAccess, which does not reliably cover dsql:*. Without AmazonAuroraDSQLReadOnlyAccess, the PR introducing an aws_dsql_cluster fails its own terraform plan. branch-lambda-role gets dsql:DbConnectAdmin because DSQL authenticates with an IAM token rather than a password. The role is shared with every preview lambda, so previews are covered too. Resource is "*" until the cluster exists. Co-Authored-By: Claude Opus 5 (1M context) --- infrastructure/aws/lambda.tf | 18 ++++++++++++++++++ infrastructure/aws/oidc.tf | 7 +++++++ 2 files changed, 25 insertions(+) diff --git a/infrastructure/aws/lambda.tf b/infrastructure/aws/lambda.tf index 0f350199..5888b821 100644 --- a/infrastructure/aws/lambda.tf +++ b/infrastructure/aws/lambda.tf @@ -109,6 +109,24 @@ resource "aws_iam_role_policy" "lambda_ses_send" { }) } +# Aurora DSQL authenticates with an IAM token instead of a password. Granted +# ahead of the cluster so the migration PR does not also have to change IAM. +# Resource is "*" until the cluster exists; scope it to the cluster 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..10ad16c6 100644 --- a/infrastructure/aws/oidc.tf +++ b/infrastructure/aws/oidc.tf @@ -45,6 +45,13 @@ resource "aws_iam_role_policy_attachment" "ci_plan_readonly" { policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" } +# ReadOnlyAccess does not reliably cover dsql:*, and a plan that cannot read a +# resource it manages fails. +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 From 029bb2eb7ae6ebfd076b7f305b7146ce87ecce61 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 6 Sep 2026 21:50:26 +0000 Subject: [PATCH 02/11] chore: auto-format terraform and update documentation - Auto-formatted .tf files with terraform fmt - Updated README.md with terraform-docs Co-authored-by: nourshoreibah --- infrastructure/aws/README.md | 2 ++ 1 file changed, 2 insertions(+) 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 | From 5feae9716c30aaa11ab5179b8c76f55b702104a7 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sun, 6 Sep 2026 17:50:46 -0400 Subject: [PATCH 03/11] chore: trim comments Co-Authored-By: Claude Opus 5 (1M context) --- infrastructure/aws/lambda.tf | 4 +--- infrastructure/aws/oidc.tf | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/infrastructure/aws/lambda.tf b/infrastructure/aws/lambda.tf index 5888b821..959534d5 100644 --- a/infrastructure/aws/lambda.tf +++ b/infrastructure/aws/lambda.tf @@ -109,9 +109,7 @@ resource "aws_iam_role_policy" "lambda_ses_send" { }) } -# Aurora DSQL authenticates with an IAM token instead of a password. Granted -# ahead of the cluster so the migration PR does not also have to change IAM. -# Resource is "*" until the cluster exists; scope it to the cluster ARN then. +# 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 diff --git a/infrastructure/aws/oidc.tf b/infrastructure/aws/oidc.tf index 10ad16c6..3820a3f4 100644 --- a/infrastructure/aws/oidc.tf +++ b/infrastructure/aws/oidc.tf @@ -45,8 +45,7 @@ resource "aws_iam_role_policy_attachment" "ci_plan_readonly" { policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" } -# ReadOnlyAccess does not reliably cover dsql:*, and a plan that cannot read a -# resource it manages fails. +# 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" From 0e8c204cd94ead2e488bfc918e33a560255591be Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sun, 6 Sep 2026 18:04:44 -0400 Subject: [PATCH 04/11] feat(store): add @branch/store and drop the rollup triggers Aurora DSQL supports neither triggers nor PL/pgSQL, so the analytics rollups have to be maintained by the application. This adds the package that owns that, and the migration that removes the database-side machinery. The three LANGUAGE sql helpers stay: DSQL supports those and they hold the arithmetic. Only the nine plpgsql dispatchers move into TypeScript. @branch/store exports `db` as ReadOnlyDb -- a Kysely handle with every mutating entry point stripped -- plus one named operation per write. A controller cannot write without going through an operation that also maintains the rollup, because the write builders will not typecheck. project_donations.donor_id and project_memberships.user_id become ON DELETE RESTRICT. Those two cascades used to fire a row trigger from a lambda that never names the rollup tables (DELETE /donors/{id}, DELETE /users/{id}), so in application code they would have gone silently unmaintained. RESTRICT turns a forgotten cascade into an FK error; the store deletes children first. testkit gains findRollupDrift/assertRollupsConsistent/reconcileRollups, recomputing both rollups from the base tables with the same aggregation as the original backfill. resetData now reconciles after seeding, since nothing refills the rollups once the triggers are gone. Verified against postgres: 0 triggers remain, only the 3 sql functions survive, both FKs are RESTRICT, and drift is detected and repaired. Verified with @aws/dsql-lint: the migration is DSQL-clean. Co-Authored-By: Claude Opus 5 (1M context) --- ...0906215733_move_rollups_to_application.sql | 46 + apps/backend/db/testkit.ts | 112 + shared/store/jest.config.js | 5 + shared/store/package-lock.json | 4908 +++++++++++++++++ shared/store/package.json | 30 + shared/store/src/connection.ts | 38 + shared/store/src/donations.ts | 88 + shared/store/src/expenditures.ts | 69 + shared/store/src/index.ts | 30 + shared/store/src/projects.ts | 99 + shared/store/src/reports.ts | 39 + shared/store/src/rollups.ts | 64 + shared/store/src/tx.ts | 32 + shared/store/src/users.ts | 58 + shared/store/test/read-only-surface.test.ts | 26 + shared/store/tsconfig.json | 17 + 16 files changed, 5661 insertions(+) create mode 100644 apps/backend/db/migrations/20260906215733_move_rollups_to_application.sql create mode 100644 shared/store/jest.config.js create mode 100644 shared/store/package-lock.json create mode 100644 shared/store/package.json create mode 100644 shared/store/src/connection.ts create mode 100644 shared/store/src/donations.ts create mode 100644 shared/store/src/expenditures.ts create mode 100644 shared/store/src/index.ts create mode 100644 shared/store/src/projects.ts create mode 100644 shared/store/src/reports.ts create mode 100644 shared/store/src/rollups.ts create mode 100644 shared/store/src/tx.ts create mode 100644 shared/store/src/users.ts create mode 100644 shared/store/test/read-only-surface.test.ts create mode 100644 shared/store/tsconfig.json 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/testkit.ts b/apps/backend/db/testkit.ts index 0ec21657..eb22eb1f 100644 --- a/apps/backend/db/testkit.ts +++ b/apps/backend/db/testkit.ts @@ -196,6 +196,118 @@ async function truncateAll(client: Queryable): Promise { export async function resetData(client: Queryable): Promise { await client.query(await truncateAll(client)); await client.query(seedSql()); + // TRUNCATE emptied the rollups and seed.sql only writes base rows. The row + // triggers used to refill them; since 20260906215733 nothing does. + await reconcileRollups(client); +} + +/** + * What the rollups would hold if recomputed from the base tables. Identical + * aggregation to the backfill in 20260823055243. One definition, so the + * test-time assertion and the `reconcile` command cannot drift apart. + */ +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`; + +/** + * Rows describing every way the stored rollups disagree with the base tables. + * Empty means consistent. + * + * A zero-count expenditure_rollup row and a missing one are treated as equal: + * expenditure_rollup_remove decrements without deleting, so emptying a grain + * leaves (0, 0) behind by design. + */ +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, + ); +} + +/** + * Call in `afterEach`. The row triggers used to make this true by construction; + * now @branch/store does, so any write path that forgets a rollup -- including a + * cascade nobody thought to guard -- fails the test that touched it. + */ +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 ')}`, + ); + } +} + +/** Rebuilds both rollup tables from the base tables. */ +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/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/shared/store/src/connection.ts b/shared/store/src/connection.ts new file mode 100644 index 00000000..303df663 --- /dev/null +++ b/shared/store/src/connection.ts @@ -0,0 +1,38 @@ +import { Kysely, PostgresDialect } from 'kysely' +import { Pool } from 'pg' +import type { DB } from '@branch/types' + +/** + * The one Kysely instance in the backend. Not exported from the package root: + * callers get the read-only `db` handle, or a named write operation. + */ +export const writeDb = 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 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, + + // A blackholed SYN otherwise hangs until the 30s lambda timeout instead of erroring. + connectionTimeoutMillis: 5000, + + // 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. + idleTimeoutMillis: 0, + keepAlive: true, + + // Bound a runaway query well under the 30s lambda timeout. + statement_timeout: 10000, + }), + }), +}) diff --git a/shared/store/src/donations.ts b/shared/store/src/donations.ts new file mode 100644 index 00000000..2b07d58c --- /dev/null +++ b/shared/store/src/donations.ts @@ -0,0 +1,88 @@ +import type { Insertable, Selectable } from 'kysely' +import type { DB } from '@branch/types' +import { tx } from './tx' +import { projectRollupBump } from './rollups' + +type Donation = Selectable + +// amount is NUMERIC, surfaced as a string. Negate textually so the exact decimal +// survives; Number() would round at the edges of the type. +const negate = (amount: string) => (amount.startsWith('-') ? amount.slice(1) : `-${amount}`) + +export async function recordDonation( + values: Insertable, +): 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() + .executeTakeFirst() + if (!before) return 0n + + const deleted = await trx + .deleteFrom('branch.project_donations') + .where('donation_id', '=', id) + .executeTakeFirst() + + await projectRollupBump(trx, before.project_id, { + donated: negate(before.amount), + donations: -1, + }) + return deleted?.numDeletedRows ?? 0n + }) +} + +export async function createDonor( + values: Insertable, +): Promise> { + return tx(async (trx) => + trx.insertInto('branch.donors').values(values).returningAll().executeTakeFirstOrThrow(), + ) +} + +/** + * donor_id is ON DELETE RESTRICT, so the donations have to go first and their + * rollup contribution has to come off explicitly. Under the old CASCADE the row + * trigger did this; nothing in the donors lambda knew the rollups existed. + */ +export async function removeDonor(donorId: number): Promise { + return tx(async (trx) => { + const donations = await trx + .selectFrom('branch.project_donations') + .where('donor_id', '=', donorId) + .select(['project_id', 'amount']) + .execute() + + if (donations.length > 0) { + await trx.deleteFrom('branch.project_donations').where('donor_id', '=', donorId).execute() + } + + const deleted = await trx + .deleteFrom('branch.donors') + .where('donor_id', '=', donorId) + .executeTakeFirst() + + if ((deleted?.numDeletedRows ?? 0n) === 0n) return 0n + + for (const donation of donations) { + 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..585df04e --- /dev/null +++ b/shared/store/src/expenditures.ts @@ -0,0 +1,69 @@ +import type { Insertable, Selectable, Updateable } from 'kysely' +import type { DB } from '@branch/types' +import { tx } from './tx' +import { expenditureRollupAdd, expenditureRollupRemove } from './rollups' + +type Expenditure = Selectable + +export async function recordExpenditure( + values: Insertable, +): Promise { + return tx(async (trx) => { + const row = await trx + .insertInto('branch.expenditures') + .values(values) + .returningAll() + .executeTakeFirstOrThrow() + await expenditureRollupAdd(trx, row) + return row + }) +} + +/** + * Reads the row before updating it: the rollup grain is keyed on project, month, + * status and category, so the old values are needed to back the old bucket out. + * The trigger this replaced got OLD for free. + */ +export async function editExpenditure( + id: number, + values: Updateable, +): Promise { + return tx(async (trx) => { + const before = await trx + .selectFrom('branch.expenditures') + .where('expenditure_id', '=', id) + .selectAll() + .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() + .executeTakeFirst() + if (!before) return 0n + + const deleted = await trx + .deleteFrom('branch.expenditures') + .where('expenditure_id', '=', id) + .executeTakeFirst() + + await expenditureRollupRemove(trx, before) + return deleted?.numDeletedRows ?? 0n + }) +} diff --git a/shared/store/src/index.ts b/shared/store/src/index.ts new file mode 100644 index 00000000..ade1943e --- /dev/null +++ b/shared/store/src/index.ts @@ -0,0 +1,30 @@ +import type { Kysely } from 'kysely' +import type { DB } from '@branch/types' +import { writeDb } from './connection' + +/** + * Every mutating entry point is stripped, so a controller physically cannot + * write without going through an operation below. That is what keeps the rollup + * tables correct now that the row triggers are gone. + */ +export type ReadOnlyDb = Omit< + Kysely, + | 'insertInto' + | 'updateTable' + | 'deleteFrom' + | 'replaceInto' + | 'transaction' + | 'withSchema' + | 'with' + | 'withRecursive' + | 'schema' + | 'destroy' +> + +export const db: ReadOnlyDb = writeDb + +export { recordExpenditure, editExpenditure, removeExpenditure } from './expenditures' +export { recordDonation, removeDonation, createDonor, removeDonor } from './donations' +export { createProject, updateProject, removeProject, type MemberInput } from './projects' +export { recordReport, removeReport } from './reports' +export { createUser, updateUser, removeUser } from './users' diff --git a/shared/store/src/projects.ts b/shared/store/src/projects.ts new file mode 100644 index 00000000..8d71dfab --- /dev/null +++ b/shared/store/src/projects.ts @@ -0,0 +1,99 @@ +import type { Insertable, Selectable, Transaction, Updateable } from 'kysely' +import type { DB } from '@branch/types' +import { tx } from './tx' +import { projectRollupBump, seedProjectRollup } from './rollups' + +type Project = Selectable + +export type MemberInput = { user_id: number; role?: string | null } + +/** + * Replaces the roster wholesale. An omitted role keeps whatever the member + * already held, so a caller that only reorders members does not reset roles. + */ +async function syncMemberships( + trx: Transaction, + projectId: number, + members: MemberInput[], + 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])) + + await trx.deleteFrom('branch.project_memberships').where('project_id', '=', projectId).execute() + + 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() + } + + const delta = members.length - existing.length + if (delta !== 0) await projectRollupBump(trx, projectId, { members: delta }) +} + +export async function createProject( + values: Insertable, + members: MemberInput[], + 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: Updateable, + members: MemberInput[] | 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..eb60276d --- /dev/null +++ b/shared/store/src/reports.ts @@ -0,0 +1,39 @@ +import type { Insertable, Selectable } from 'kysely' +import type { DB } from '@branch/types' +import { tx } from './tx' +import { projectRollupBump } from './rollups' + +type Report = Selectable + +export async function recordReport( + values: Insertable, +): 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') + .executeTakeFirst() + if (!before) return 0n + + const deleted = await trx + .deleteFrom('branch.reports') + .where('report_id', '=', id) + .executeTakeFirst() + + await projectRollupBump(trx, before.project_id, { reports: -1 }) + return deleted?.numDeletedRows ?? 0n + }) +} diff --git a/shared/store/src/rollups.ts b/shared/store/src/rollups.ts new file mode 100644 index 00000000..5bb55297 --- /dev/null +++ b/shared/store/src/rollups.ts @@ -0,0 +1,64 @@ +import { sql, type Selectable, type Transaction } from 'kysely' +import type { DB } from '@branch/types' + +/** + * Replaces the row triggers dropped in 20260906_move_rollups_to_application. + * The arithmetic still lives in the three LANGUAGE sql functions those triggers + * called; only the INSERT/UPDATE/DELETE dispatch moved up here. + */ + +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 { + await sql`select branch.project_rollup_bump( + ${projectId}, + ${delta.members ?? 0}, + ${delta.donated ?? 0}, + ${delta.donations ?? 0}, + ${delta.reports ?? 0} + )`.execute(trx) +} + +/** Was the projects_rollup_seed AFTER INSERT trigger. */ +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..1a1766d4 --- /dev/null +++ b/shared/store/src/tx.ts @@ -0,0 +1,32 @@ +import type { Transaction } from 'kysely' +import type { DB } from '@branch/types' +import { writeDb } from './connection' + +// Postgres serialization_failure / deadlock_detected, plus the codes Aurora DSQL +// raises at commit time under optimistic concurrency control. +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)) + +/** + * Runs `fn` in one transaction, retrying the WHOLE transaction on a commit-time + * conflict. Retrying only the rollup half would double-count the base write. + */ +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..63d3114a --- /dev/null +++ b/shared/store/src/users.ts @@ -0,0 +1,58 @@ +import type { Insertable, Selectable, Updateable } from 'kysely' +import type { DB } from '@branch/types' +import { tx } from './tx' +import { projectRollupBump } from './rollups' + +type User = Selectable + +export async function createUser(values: Insertable): Promise { + return tx(async (trx) => + trx.insertInto('branch.users').values(values).returningAll().executeTakeFirstOrThrow(), + ) +} + +export async function updateUser( + id: number, + values: Updateable, +): Promise { + return tx(async (trx) => + trx + .updateTable('branch.users') + .set(values) + .where('user_id', '=', id) + .returningAll() + .executeTakeFirst(), + ) +} + +/** + * user_id on project_memberships is ON DELETE RESTRICT, so the memberships have + * to go first and member_count has to come off each project explicitly. Under + * the old CASCADE the row trigger did this; the users lambda never knew the + * rollups existed. + */ +export async function removeUser(userId: number): Promise { + return tx(async (trx) => { + const memberships = await trx + .selectFrom('branch.project_memberships') + .where('user_id', '=', userId) + .select('project_id') + .execute() + + if (memberships.length > 0) { + await trx.deleteFrom('branch.project_memberships').where('user_id', '=', userId).execute() + } + + const deleted = await trx + .deleteFrom('branch.users') + .where('user_id', '=', userId) + .executeTakeFirst() + + if ((deleted?.numDeletedRows ?? 0n) === 0n) return 0n + + for (const membership of memberships) { + 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..16a05fda --- /dev/null +++ b/shared/store/test/read-only-surface.test.ts @@ -0,0 +1,26 @@ +import type { ReadOnlyDb } from '../src' + +// Type-only import: nothing here constructs the pg Pool. + +type Assert = T +type Has = K extends keyof T ? true : false +type Lacks = Has extends false ? true : false + +// Reads stay reachable. +type _Select = Assert> +type _Fn = Assert> + +// Writes do not. If any of these start failing, a controller can bypass the +// store and the rollup tables go stale with nothing to catch it. +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"] +} From a8757d79041fd0a8de1ec0c6bc102b6ae2d73e64 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sun, 6 Sep 2026 18:14:20 -0400 Subject: [PATCH 05/11] refactor(backend): route every write through @branch/store Deletes the six near-identical db.ts files and points all 20 modules at the shared store. The 21 write sites become named store operations, so rollup maintenance travels with the write instead of being a thing each caller has to remember. Write DTOs (NewExpenditure, ExpenditureEdit, NewDonation, ...) are declared in @branch/types beside the row types and re-exported by the store, so a caller does not need Kysely's generics to write a row and cannot set a generated column by accident. claimUser exists because registration's two updates carry a compound `WHERE user_id = $1 AND cognito_sub IS NULL`. That predicate is what makes a concurrent claim a no-op rather than an overwrite of a working account, so it needed its own operation rather than a generic updateUser. syncMemberships moves from the projects lambda into the store, taking the default role as a parameter so the store needs no lambda-local types. The project create/update transactions move with it, which is what lets the member_count rollup update inside the same transaction as the roster. grep for insertInto/updateTable/deleteFrom/db.transaction across lambda source now returns nothing. All six lambdas typecheck. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/lambdas/auth/auth.ts | 2 +- apps/backend/lambdas/auth/controllers/mfa.ts | 2 +- .../lambdas/auth/controllers/register.ts | 23 ++--- apps/backend/lambdas/auth/db.ts | 39 -------- apps/backend/lambdas/auth/package-lock.json | 23 +++++ apps/backend/lambdas/auth/package.json | 1 + apps/backend/lambdas/donors/auth.ts | 2 +- .../lambdas/donors/controllers/donations.ts | 22 ++--- .../lambdas/donors/controllers/donors.ts | 19 ++-- apps/backend/lambdas/donors/db.ts | 39 -------- apps/backend/lambdas/donors/package-lock.json | 23 +++++ apps/backend/lambdas/donors/package.json | 1 + apps/backend/lambdas/expenditures/auth.ts | 2 +- apps/backend/lambdas/expenditures/db.ts | 39 -------- .../lambdas/expenditures/package-lock.json | 23 +++++ .../backend/lambdas/expenditures/package.json | 1 + .../expenditures/services/expenditures.ts | 24 ++--- apps/backend/lambdas/projects/auth.ts | 2 +- .../lambdas/projects/controllers/dashboard.ts | 2 +- .../lambdas/projects/controllers/donors.ts | 2 +- .../projects/controllers/expenditures.ts | 2 +- .../lambdas/projects/controllers/members.ts | 2 +- .../lambdas/projects/controllers/projects.ts | 49 +++------- apps/backend/lambdas/projects/db.ts | 37 -------- .../lambdas/projects/package-lock.json | 23 +++++ apps/backend/lambdas/projects/package.json | 1 + .../lambdas/projects/services/projects.ts | 48 +--------- apps/backend/lambdas/reports/auth.ts | 2 +- .../lambdas/reports/controllers/reports.ts | 17 ++-- apps/backend/lambdas/reports/db.ts | 38 -------- .../backend/lambdas/reports/package-lock.json | 23 +++++ apps/backend/lambdas/reports/package.json | 1 + .../backend/lambdas/reports/report-service.ts | 18 ++-- apps/backend/lambdas/users/auth.ts | 2 +- .../lambdas/users/controllers/users.ts | 18 +--- apps/backend/lambdas/users/db.ts | 39 -------- apps/backend/lambdas/users/package-lock.json | 23 +++++ apps/backend/lambdas/users/package.json | 1 + shared/store/src/donations.ts | 10 +- shared/store/src/expenditures.ts | 10 +- shared/store/src/index.ts | 19 +++- shared/store/src/projects.ts | 25 ++--- shared/store/src/reports.ts | 8 +- shared/store/src/users.ts | 27 +++++- shared/types/index.d.ts | 1 + shared/types/store-types.d.ts | 94 +++++++++++++++++++ 46 files changed, 385 insertions(+), 444 deletions(-) delete mode 100644 apps/backend/lambdas/auth/db.ts delete mode 100644 apps/backend/lambdas/donors/db.ts delete mode 100644 apps/backend/lambdas/expenditures/db.ts delete mode 100644 apps/backend/lambdas/projects/db.ts delete mode 100644 apps/backend/lambdas/reports/db.ts delete mode 100644 apps/backend/lambdas/users/db.ts create mode 100644 shared/types/store-types.d.ts 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/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/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/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..244084fb 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,15 @@ 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; - }); + // Field update, roster replacement and the member_count rollup share one + // transaction: a failed membership insert must not leave the project with + // nobody assigned, or the rollup counting people who are not there. + 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 +153,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 +228,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/db.ts b/apps/backend/lambdas/projects/db.ts deleted file mode 100644 index 284bf483..00000000 --- a/apps/backend/lambdas/projects/db.ts +++ /dev/null @@ -1,37 +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/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/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/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/shared/store/src/donations.ts b/shared/store/src/donations.ts index 2b07d58c..b132a219 100644 --- a/shared/store/src/donations.ts +++ b/shared/store/src/donations.ts @@ -1,5 +1,5 @@ -import type { Insertable, Selectable } from 'kysely' -import type { DB } from '@branch/types' +import type { Selectable } from 'kysely' +import type { DB, NewDonation, NewDonor } from '@branch/types' import { tx } from './tx' import { projectRollupBump } from './rollups' @@ -9,9 +9,7 @@ type Donation = Selectable // survives; Number() would round at the edges of the type. const negate = (amount: string) => (amount.startsWith('-') ? amount.slice(1) : `-${amount}`) -export async function recordDonation( - values: Insertable, -): Promise { +export async function recordDonation(values: NewDonation): Promise { return tx(async (trx) => { const row = await trx .insertInto('branch.project_donations') @@ -46,7 +44,7 @@ export async function removeDonation(id: number): Promise { } export async function createDonor( - values: Insertable, + values: NewDonor, ): Promise> { return tx(async (trx) => trx.insertInto('branch.donors').values(values).returningAll().executeTakeFirstOrThrow(), diff --git a/shared/store/src/expenditures.ts b/shared/store/src/expenditures.ts index 585df04e..b56f691e 100644 --- a/shared/store/src/expenditures.ts +++ b/shared/store/src/expenditures.ts @@ -1,13 +1,11 @@ -import type { Insertable, Selectable, Updateable } from 'kysely' -import type { DB } from '@branch/types' +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: Insertable, -): Promise { +export async function recordExpenditure(values: NewExpenditure): Promise { return tx(async (trx) => { const row = await trx .insertInto('branch.expenditures') @@ -26,7 +24,7 @@ export async function recordExpenditure( */ export async function editExpenditure( id: number, - values: Updateable, + values: ExpenditureEdit, ): Promise { return tx(async (trx) => { const before = await trx diff --git a/shared/store/src/index.ts b/shared/store/src/index.ts index ade1943e..88ffb202 100644 --- a/shared/store/src/index.ts +++ b/shared/store/src/index.ts @@ -25,6 +25,21 @@ export const db: ReadOnlyDb = writeDb export { recordExpenditure, editExpenditure, removeExpenditure } from './expenditures' export { recordDonation, removeDonation, createDonor, removeDonor } from './donations' -export { createProject, updateProject, removeProject, type MemberInput } from './projects' +export { createProject, updateProject, removeProject } from './projects' export { recordReport, removeReport } from './reports' -export { createUser, updateUser, removeUser } from './users' +export { createUser, updateUser, claimUser, removeUser } from './users' + +// The write DTOs are declared in @branch/types alongside the row types; re-exported +// so a caller needs only one import to write a row. +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 index 8d71dfab..7880abd2 100644 --- a/shared/store/src/projects.ts +++ b/shared/store/src/projects.ts @@ -1,20 +1,23 @@ -import type { Insertable, Selectable, Transaction, Updateable } from 'kysely' -import type { DB } from '@branch/types' +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 -export type MemberInput = { user_id: number; role?: string | null } - /** - * Replaces the roster wholesale. An omitted role keeps whatever the member - * already held, so a caller that only reorders members does not reset roles. + * Replaces a project's roster wholesale. Delete-then-insert rather than a diff: + * the set is small and bounded by the staff list. + * + * 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 the fallback would make every ordinary project edit + * strip the project's directors of their role. */ async function syncMemberships( trx: Transaction, projectId: number, - members: MemberInput[], + members: ProjectMemberInput[], defaultRole: string, ): Promise { const existing = await trx @@ -44,8 +47,8 @@ async function syncMemberships( } export async function createProject( - values: Insertable, - members: MemberInput[], + values: NewProject, + members: ProjectMemberInput[], defaultRole: string, ): Promise { return tx(async (trx) => { @@ -62,8 +65,8 @@ export async function createProject( export async function updateProject( id: number, - values: Updateable, - members: MemberInput[] | undefined, + values: ProjectEdit, + members: ProjectMemberInput[] | undefined, defaultRole: string, ): Promise { return tx(async (trx) => { diff --git a/shared/store/src/reports.ts b/shared/store/src/reports.ts index eb60276d..4ee31609 100644 --- a/shared/store/src/reports.ts +++ b/shared/store/src/reports.ts @@ -1,13 +1,11 @@ -import type { Insertable, Selectable } from 'kysely' -import type { DB } from '@branch/types' +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: Insertable, -): Promise { +export async function recordReport(values: NewReport): Promise { return tx(async (trx) => { const row = await trx .insertInto('branch.reports') diff --git a/shared/store/src/users.ts b/shared/store/src/users.ts index 63d3114a..a497220b 100644 --- a/shared/store/src/users.ts +++ b/shared/store/src/users.ts @@ -1,11 +1,11 @@ -import type { Insertable, Selectable, Updateable } from 'kysely' -import type { DB } from '@branch/types' +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: Insertable): Promise { +export async function createUser(values: NewUser): Promise { return tx(async (trx) => trx.insertInto('branch.users').values(values).returningAll().executeTakeFirstOrThrow(), ) @@ -13,7 +13,7 @@ export async function createUser(values: Insertable): Promis export async function updateUser( id: number, - values: Updateable, + values: UserEdit, ): Promise { return tx(async (trx) => trx @@ -25,6 +25,25 @@ export async function updateUser( ) } +/** + * Links a Cognito identity to an invited row, only while that row has none. + * + * The `cognito_sub IS NULL` predicate is the whole point: it makes a concurrent + * claim a no-op rather than an overwrite of a working account. Returns the + * number of rows updated so the caller can tell the two apart. + */ +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, so the memberships have * to go first and member_count has to come off each project explicitly. Under 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..679bbe21 --- /dev/null +++ b/shared/types/store-types.d.ts @@ -0,0 +1,94 @@ +/** + * The single declaration of the @branch/store write DTOs. The store re-exports + * these rather than declaring its own copy. + * + * Deliberately not `Insertable`: a caller should not have to + * know Kysely's generics to write a row, and generated columns (ids, created_at) + * are absent here so they cannot be set by accident. + * + * `number | string` on money columns mirrors NUMERIC's insert type -- the value + * comes back out as a string, so both are accepted going in. + */ + +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; +} + +/** Every column an expenditure update may reach. Routes narrow this further. */ +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; +} + +/** One roster entry. An absent role keeps whatever the member already held. */ +export interface ProjectMemberInput { + user_id: number; + role?: string | null; +} From dff9d5ad3e1b7b570ba5622f64c538ef05b3eed3 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Mon, 7 Sep 2026 16:47:48 -0400 Subject: [PATCH 06/11] test: point the suites at @branch/store Mechanical for the most part: the six db.ts modules are gone, so imports move to @branch/store and db.destroy() becomes closeConnection() (the read-only handle deliberately has no destroy). Mocks that drove the query builder now drive the store operation the code actually calls -- mockDb.insertInto becomes mockRecordExpenditure, and so on. That is a smaller mock in every case. rollup-triggers.e2e.test.ts becomes rollup-store.e2e.test.ts. It drove the row triggers with raw SQL, including shapes no route can produce: a bulk UPDATE with no WHERE, TRUNCATE, moving an expenditure between projects by column. Those tested trigger generality. The replacement exercises every operation the store exposes, and adds the two cascade cases the old file could not reach because the triggers hid them -- deleting a donor and deleting a user. Fixtures that stay on raw SQL now call reconcileRollups afterwards. Raw SQL no longer maintains the rollups, and the dashboard fixture in particular rewrites spent_on, which moves rows between buckets. lambda-deploy.yml learns shared/store in both the push filter and the in-job detector. Without it a store-only change would never deploy. 650 tests pass across all six lambdas. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/lambda-deploy.yml | 3 +- .../lambdas/auth/test/auth.login.unit.test.ts | 38 ++- .../lambdas/auth/test/auth.mfa.unit.test.ts | 6 +- .../lambdas/donors/test/donors.test.ts | 11 +- .../test/expenditures.e2e.test.ts | 6 +- .../test/expenditures.unit.test.ts | 70 ++--- .../test/approved-expenditures.e2e.test.ts | 10 +- .../lambdas/projects/test/crud.test.ts | 6 +- .../projects/test/dashboard.unit.test.ts | 4 +- .../projects/test/delete-authz.unit.test.ts | 13 +- .../projects/test/delete-objects.unit.test.ts | 13 +- .../lambdas/projects/test/example.test.ts | 6 +- .../projects/test/project-page.unit.test.ts | 6 +- .../projects/test/projects.e2e.test.ts | 13 +- .../projects/test/projects.unit.test.ts | 6 +- ...s.e2e.test.ts => rollup-store.e2e.test.ts} | 243 ++++++++--------- .../lambdas/projects/test/rollups.e2e.test.ts | 253 +++++++----------- .../reports/test/report-service.e2e.test.ts | 4 +- .../lambdas/reports/test/reports.e2e.test.ts | 6 +- .../lambdas/reports/test/reports.unit.test.ts | 53 ++-- .../users/test/user.photos.unit.test.ts | 29 +- .../test/user.privilege-escalation.test.ts | 16 +- .../lambdas/users/test/user.unit.test.ts | 39 +-- apps/backend/lambdas/users/test/users.test.ts | 6 +- shared/store/src/index.ts | 5 + 25 files changed, 360 insertions(+), 505 deletions(-) rename apps/backend/lambdas/projects/test/{rollup-triggers.e2e.test.ts => rollup-store.e2e.test.ts} (50%) 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/lambdas/auth/test/auth.login.unit.test.ts b/apps/backend/lambdas/auth/test/auth.login.unit.test.ts index e5e114a7..961e99ec 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,29 @@ 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 carries the `cognito_sub IS NULL` guard; the tests assert on the + // values it was handed and on how many rows it claimed. + 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 +88,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/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/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..206ca989 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,8 @@ 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')), - }), - }); + // Mock: the store write throws + mockRecordExpenditure.mockRejectedValue(new Error('Database connection failed')); const res = await handler( postEvent({ @@ -857,12 +856,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 +878,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 +889,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 +901,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 +914,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 +928,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 +960,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 +972,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 +989,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 +1002,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 +1031,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 +1130,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/test/approved-expenditures.e2e.test.ts b/apps/backend/lambdas/projects/test/approved-expenditures.e2e.test.ts index 2a00ee46..33930fc3 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,8 @@ 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`); + // Raw fixture SQL does not maintain the rollups; put them back in step. + await reconcileRollups(client); } finally { client.release(); } @@ -90,7 +92,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..1e41dd9c 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,8 @@ 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'`, ); + // Raw fixture SQL does not maintain the rollups; put them back in step. + await reconcileRollups(client); } finally { client.release(); } @@ -427,6 +429,9 @@ describe('GET /dashboard (e2e)', () => { EXTRACT(DAY FROM spent_on)::int ) `); + // Shifting spent_on moves rows between rollup buckets, and raw SQL does + // not maintain them; recompute so the dashboard reads the shifted dates. + 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..ce4f1ec3 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,33 @@ /** - * 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. + * @branch/store's rollup maintenance, 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 + * operation that broke. + * + * Replaces rollup-triggers.e2e.test.ts. That file drove the row triggers with + * raw SQL, including shapes no route can produce -- a bulk UPDATE with no WHERE, + * TRUNCATE, moving an expenditure between projects by column. Those tested + * trigger generality; what matters now is that every operation the store + * exposes keeps the rollups exact. */ 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', @@ -80,9 +102,13 @@ beforeAll(async () => { beforeEach(async () => { client = await pool.connect(); await resetData(client); + // Fixture setup, not the behaviour under test: clear the seeded child rows + // with raw SQL, then put the rollups back in step by hand. 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 +117,7 @@ afterEach(() => { afterAll(async () => { await pool.end(); + await closeConnection(); }); /** Live buckets for one project. Emptied buckets stay at zero, so exclude them. */ @@ -111,43 +138,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 +185,57 @@ 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'`); - await auditRollups(client); - expect(await approvedTotal()).toBe(60); - }); - - 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); 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'); + 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 bucketsFor(1)).toBe(0); + expect(await approvedTotal()).toBe(250); }); 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 +249,68 @@ 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 auditRollups(client); + await removeUser(4); await auditRollups(client); }); 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 +318,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 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 removeReport(b.report_id); 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..d4c35ca8 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,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'); + // Raw fixture SQL does not maintain the rollups; put them back in step by hand. + await reconcileRollups(client); }); afterEach(() => { @@ -130,7 +146,7 @@ afterEach(() => { afterAll(async () => { await pool.end(); - await db.destroy(); + await closeConnection(); }); async function get(rawPath: string) { @@ -154,20 +170,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 +204,19 @@ 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. + // 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 +228,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 +239,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 +283,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 +302,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 +344,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 +361,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/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/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..7e504cb8 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,8 @@ 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), - }), - }); + // Mock the store write + mockCreateUser.mockResolvedValue({ user_id: 1 } as never); const res = await handler( postEvent({ @@ -486,16 +477,8 @@ 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), - }), - }), - }), - }); + // Nothing matched the id, so the update returns no row. + mockUpdateUser.mockResolvedValue(undefined); const res = await handler(patchEvent(999, { name: 'Whoever' })); @@ -539,7 +522,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/shared/store/src/index.ts b/shared/store/src/index.ts index 88ffb202..4ee6e6e3 100644 --- a/shared/store/src/index.ts +++ b/shared/store/src/index.ts @@ -23,6 +23,11 @@ export type ReadOnlyDb = Omit< export const db: ReadOnlyDb = writeDb +/** Closes the pool. Test teardown; a lambda never calls this. */ +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' From bccf948e0549e4f1b94a62b775a37994fefb5c18 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Mon, 7 Sep 2026 16:53:52 -0400 Subject: [PATCH 07/11] chore: trim comments Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/db/testkit.ts | 22 +------------------ .../lambdas/auth/test/auth.login.unit.test.ts | 2 -- .../test/expenditures.unit.test.ts | 1 - .../lambdas/projects/controllers/projects.ts | 3 --- .../test/approved-expenditures.e2e.test.ts | 1 - .../projects/test/projects.e2e.test.ts | 3 --- .../projects/test/rollup-store.e2e.test.ts | 14 ------------ .../lambdas/projects/test/rollups.e2e.test.ts | 2 -- .../lambdas/users/test/user.unit.test.ts | 2 -- shared/store/src/connection.ts | 4 ---- shared/store/src/donations.ts | 9 ++------ shared/store/src/expenditures.ts | 5 ----- shared/store/src/index.ts | 8 ------- shared/store/src/projects.ts | 10 +-------- shared/store/src/rollups.ts | 7 ------ shared/store/src/tx.ts | 7 +----- shared/store/src/users.ts | 15 ++----------- shared/store/test/read-only-surface.test.ts | 5 ----- shared/types/store-types.d.ts | 14 ------------ 19 files changed, 7 insertions(+), 127 deletions(-) diff --git a/apps/backend/db/testkit.ts b/apps/backend/db/testkit.ts index eb22eb1f..9546f960 100644 --- a/apps/backend/db/testkit.ts +++ b/apps/backend/db/testkit.ts @@ -196,16 +196,9 @@ async function truncateAll(client: Queryable): Promise { export async function resetData(client: Queryable): Promise { await client.query(await truncateAll(client)); await client.query(seedSql()); - // TRUNCATE emptied the rollups and seed.sql only writes base rows. The row - // triggers used to refill them; since 20260906215733 nothing does. await reconcileRollups(client); } -/** - * What the rollups would hold if recomputed from the base tables. Identical - * aggregation to the backfill in 20260823055243. One definition, so the - * test-time assertion and the `reconcile` command cannot drift apart. - */ const EXPECTED_EXPENDITURE_ROLLUP = ` SELECT project_id, date_trunc('month', spent_on)::date AS month, @@ -230,14 +223,7 @@ const EXPECTED_PROJECT_ROLLUP = ` LEFT JOIN (SELECT project_id, COUNT(*) AS c FROM ${SCHEMA}.reports GROUP BY project_id) r ON r.project_id = p.project_id`; -/** - * Rows describing every way the stored rollups disagree with the base tables. - * Empty means consistent. - * - * A zero-count expenditure_rollup row and a missing one are treated as equal: - * expenditure_rollup_remove decrements without deleting, so emptying a grain - * leaves (0, 0) behind by design. - */ +// 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}) @@ -278,11 +264,6 @@ export async function findRollupDrift(client: Queryable): Promise { ); } -/** - * Call in `afterEach`. The row triggers used to make this true by construction; - * now @branch/store does, so any write path that forgets a rollup -- including a - * cascade nobody thought to guard -- fails the test that touched it. - */ export async function assertRollupsConsistent(client: Queryable): Promise { const drift = await findRollupDrift(client); if (drift.length > 0) { @@ -292,7 +273,6 @@ export async function assertRollupsConsistent(client: Queryable): Promise } } -/** Rebuilds both rollup tables from the base tables. */ export async function reconcileRollups(client: Queryable): Promise { await client.query(`DELETE FROM ${SCHEMA}.expenditure_rollup`); await client.query( 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 961e99ec..c1eb98c4 100644 --- a/apps/backend/lambdas/auth/test/auth.login.unit.test.ts +++ b/apps/backend/lambdas/auth/test/auth.login.unit.test.ts @@ -45,8 +45,6 @@ jest.mock('@branch/store', () => { return { __esModule: true, db: { selectFrom: () => selectChain }, - // claimUser carries the `cognito_sub IS NULL` guard; the tests assert on the - // values it was handed and on how many rows it claimed. claimUser: (_id: unknown, values: unknown) => { mockSet(values); return mockUpdateResult(); diff --git a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts index 206ca989..39e55f78 100644 --- a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts +++ b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts @@ -598,7 +598,6 @@ describe('POST /expenditures unit tests', () => { }), }); - // Mock: the store write throws mockRecordExpenditure.mockRejectedValue(new Error('Database connection failed')); const res = await handler( diff --git a/apps/backend/lambdas/projects/controllers/projects.ts b/apps/backend/lambdas/projects/controllers/projects.ts index 244084fb..15776c9c 100644 --- a/apps/backend/lambdas/projects/controllers/projects.ts +++ b/apps/backend/lambdas/projects/controllers/projects.ts @@ -130,9 +130,6 @@ export const updateProject: RouteHandler = async ({ event, params, auth }) => { } try { - // Field update, roster replacement and the member_count rollup share one - // transaction: a failed membership insert must not leave the project with - // nobody assigned, or the rollup counting people who are not there. const updatedProject = await storeUpdateProject( Number(id), updateValues, 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 33930fc3..ca5967a0 100644 --- a/apps/backend/lambdas/projects/test/approved-expenditures.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/approved-expenditures.e2e.test.ts @@ -83,7 +83,6 @@ 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`); - // Raw fixture SQL does not maintain the rollups; put them back in step. await reconcileRollups(client); } finally { client.release(); diff --git a/apps/backend/lambdas/projects/test/projects.e2e.test.ts b/apps/backend/lambdas/projects/test/projects.e2e.test.ts index 1e41dd9c..94ecffe3 100644 --- a/apps/backend/lambdas/projects/test/projects.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/projects.e2e.test.ts @@ -147,7 +147,6 @@ 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'`, ); - // Raw fixture SQL does not maintain the rollups; put them back in step. await reconcileRollups(client); } finally { client.release(); @@ -429,8 +428,6 @@ describe('GET /dashboard (e2e)', () => { EXTRACT(DAY FROM spent_on)::int ) `); - // Shifting spent_on moves rows between rollup buckets, and raw SQL does - // not maintain them; recompute so the dashboard reads the shifted dates. await reconcileRollups(client); } finally { client.release(); diff --git a/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts b/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts index ce4f1ec3..c7cbb9a4 100644 --- a/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts @@ -1,15 +1,3 @@ -/** - * @branch/store's rollup maintenance, 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 - * operation that broke. - * - * Replaces rollup-triggers.e2e.test.ts. That file drove the row triggers with - * raw SQL, including shapes no route can produce -- a bulk UPDATE with no WHERE, - * TRUNCATE, moving an expenditure between projects by column. Those tested - * trigger generality; what matters now is that every operation the store - * exposes keeps the rollups exact. - */ import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll } from '@jest/globals'; import { Pool, PoolClient } from 'pg'; import { ensureSchema, resetData, reconcileRollups } from '../../../db/testkit'; @@ -102,8 +90,6 @@ beforeAll(async () => { beforeEach(async () => { client = await pool.connect(); await resetData(client); - // Fixture setup, not the behaviour under test: clear the seeded child rows - // with raw SQL, then put the rollups back in step by hand. await client.query('DELETE FROM branch.expenditures'); await client.query('DELETE FROM branch.project_donations'); await client.query('DELETE FROM branch.reports'); diff --git a/apps/backend/lambdas/projects/test/rollups.e2e.test.ts b/apps/backend/lambdas/projects/test/rollups.e2e.test.ts index d4c35ca8..32fa0fa6 100644 --- a/apps/backend/lambdas/projects/test/rollups.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/rollups.e2e.test.ts @@ -136,7 +136,6 @@ beforeEach(async () => { await client.query('DELETE FROM branch.expenditures'); await client.query('DELETE FROM branch.project_donations'); await client.query('DELETE FROM branch.reports'); - // Raw fixture SQL does not maintain the rollups; put them back in step by hand. await reconcileRollups(client); }); @@ -208,7 +207,6 @@ describe('expenditure spend reaches the dashboard', () => { 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); diff --git a/apps/backend/lambdas/users/test/user.unit.test.ts b/apps/backend/lambdas/users/test/user.unit.test.ts index 7e504cb8..ae76a2de 100644 --- a/apps/backend/lambdas/users/test/user.unit.test.ts +++ b/apps/backend/lambdas/users/test/user.unit.test.ts @@ -338,7 +338,6 @@ describe('POST /users unit tests', () => { where: jest.fn().mockReturnValue(whereChain), }); - // Mock the store write mockCreateUser.mockResolvedValue({ user_id: 1 } as never); const res = await handler( @@ -477,7 +476,6 @@ 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. mockUpdateUser.mockResolvedValue(undefined); const res = await handler(patchEvent(999, { name: 'Whoever' })); diff --git a/shared/store/src/connection.ts b/shared/store/src/connection.ts index 303df663..ff9382d9 100644 --- a/shared/store/src/connection.ts +++ b/shared/store/src/connection.ts @@ -2,10 +2,6 @@ import { Kysely, PostgresDialect } from 'kysely' import { Pool } from 'pg' import type { DB } from '@branch/types' -/** - * The one Kysely instance in the backend. Not exported from the package root: - * callers get the read-only `db` handle, or a named write operation. - */ export const writeDb = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ diff --git a/shared/store/src/donations.ts b/shared/store/src/donations.ts index b132a219..6ab6c9cf 100644 --- a/shared/store/src/donations.ts +++ b/shared/store/src/donations.ts @@ -5,8 +5,7 @@ import { projectRollupBump } from './rollups' type Donation = Selectable -// amount is NUMERIC, surfaced as a string. Negate textually so the exact decimal -// survives; Number() would round at the edges of the type. +// 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 { @@ -51,11 +50,7 @@ export async function createDonor( ) } -/** - * donor_id is ON DELETE RESTRICT, so the donations have to go first and their - * rollup contribution has to come off explicitly. Under the old CASCADE the row - * trigger did this; nothing in the donors lambda knew the rollups existed. - */ +// 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) => { const donations = await trx diff --git a/shared/store/src/expenditures.ts b/shared/store/src/expenditures.ts index b56f691e..dbe9ac23 100644 --- a/shared/store/src/expenditures.ts +++ b/shared/store/src/expenditures.ts @@ -17,11 +17,6 @@ export async function recordExpenditure(values: NewExpenditure): Promise, | 'insertInto' @@ -23,7 +18,6 @@ export type ReadOnlyDb = Omit< export const db: ReadOnlyDb = writeDb -/** Closes the pool. Test teardown; a lambda never calls this. */ export function closeConnection(): Promise { return writeDb.destroy() } @@ -34,8 +28,6 @@ export { createProject, updateProject, removeProject } from './projects' export { recordReport, removeReport } from './reports' export { createUser, updateUser, claimUser, removeUser } from './users' -// The write DTOs are declared in @branch/types alongside the row types; re-exported -// so a caller needs only one import to write a row. export type { NewExpenditure, ExpenditureEdit, diff --git a/shared/store/src/projects.ts b/shared/store/src/projects.ts index 7880abd2..e1938da6 100644 --- a/shared/store/src/projects.ts +++ b/shared/store/src/projects.ts @@ -5,15 +5,7 @@ import { projectRollupBump, seedProjectRollup } from './rollups' type Project = Selectable -/** - * Replaces a project's roster wholesale. Delete-then-insert rather than a diff: - * the set is small and bounded by the staff list. - * - * 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 the fallback would make every ordinary project edit - * strip the project's directors of their role. - */ +// 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, diff --git a/shared/store/src/rollups.ts b/shared/store/src/rollups.ts index 5bb55297..e9eaba92 100644 --- a/shared/store/src/rollups.ts +++ b/shared/store/src/rollups.ts @@ -1,12 +1,6 @@ import { sql, type Selectable, type Transaction } from 'kysely' import type { DB } from '@branch/types' -/** - * Replaces the row triggers dropped in 20260906_move_rollups_to_application. - * The arithmetic still lives in the three LANGUAGE sql functions those triggers - * called; only the INSERT/UPDATE/DELETE dispatch moved up here. - */ - export type ExpenditureGrain = Pick< Selectable, 'project_id' | 'spent_on' | 'category' | 'status' | 'amount' @@ -51,7 +45,6 @@ export async function projectRollupBump( )`.execute(trx) } -/** Was the projects_rollup_seed AFTER INSERT trigger. */ export async function seedProjectRollup( trx: Transaction, projectId: number, diff --git a/shared/store/src/tx.ts b/shared/store/src/tx.ts index 1a1766d4..8da47126 100644 --- a/shared/store/src/tx.ts +++ b/shared/store/src/tx.ts @@ -2,8 +2,7 @@ import type { Transaction } from 'kysely' import type { DB } from '@branch/types' import { writeDb } from './connection' -// Postgres serialization_failure / deadlock_detected, plus the codes Aurora DSQL -// raises at commit time under optimistic concurrency control. +// 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 { @@ -13,10 +12,6 @@ function isRetryable(err: unknown): boolean { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) -/** - * Runs `fn` in one transaction, retrying the WHOLE transaction on a commit-time - * conflict. Retrying only the rollup half would double-count the base write. - */ export async function tx( fn: (trx: Transaction) => Promise, attempts = 3, diff --git a/shared/store/src/users.ts b/shared/store/src/users.ts index a497220b..6bdbd1cb 100644 --- a/shared/store/src/users.ts +++ b/shared/store/src/users.ts @@ -25,13 +25,7 @@ export async function updateUser( ) } -/** - * Links a Cognito identity to an invited row, only while that row has none. - * - * The `cognito_sub IS NULL` predicate is the whole point: it makes a concurrent - * claim a no-op rather than an overwrite of a working account. Returns the - * number of rows updated so the caller can tell the two apart. - */ +// 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 @@ -44,12 +38,7 @@ export async function claimUser(userId: number, values: UserEdit): Promise { return tx(async (trx) => { const memberships = await trx diff --git a/shared/store/test/read-only-surface.test.ts b/shared/store/test/read-only-surface.test.ts index 16a05fda..e054630f 100644 --- a/shared/store/test/read-only-surface.test.ts +++ b/shared/store/test/read-only-surface.test.ts @@ -1,17 +1,12 @@ import type { ReadOnlyDb } from '../src' -// Type-only import: nothing here constructs the pg Pool. - type Assert = T type Has = K extends keyof T ? true : false type Lacks = Has extends false ? true : false -// Reads stay reachable. type _Select = Assert> type _Fn = Assert> -// Writes do not. If any of these start failing, a controller can bypass the -// store and the rollup tables go stale with nothing to catch it. type _NoInsert = Assert> type _NoUpdate = Assert> type _NoDelete = Assert> diff --git a/shared/types/store-types.d.ts b/shared/types/store-types.d.ts index 679bbe21..08462bc5 100644 --- a/shared/types/store-types.d.ts +++ b/shared/types/store-types.d.ts @@ -1,15 +1,3 @@ -/** - * The single declaration of the @branch/store write DTOs. The store re-exports - * these rather than declaring its own copy. - * - * Deliberately not `Insertable`: a caller should not have to - * know Kysely's generics to write a row, and generated columns (ids, created_at) - * are absent here so they cannot be set by accident. - * - * `number | string` on money columns mirrors NUMERIC's insert type -- the value - * comes back out as a string, so both are accepted going in. - */ - export interface NewExpenditure { project_id: number; amount: number | string; @@ -22,7 +10,6 @@ export interface NewExpenditure { admin_notes?: string | null; } -/** Every column an expenditure update may reach. Routes narrow this further. */ export interface ExpenditureEdit { amount?: number | string; category?: string | null; @@ -87,7 +74,6 @@ export interface UserEdit { profile_image?: string | null; } -/** One roster entry. An absent role keeps whatever the member already held. */ export interface ProjectMemberInput { user_id: number; role?: string | null; From c446ffb14e496be48736883f03e8675c01672f6a Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Mon, 7 Sep 2026 17:40:44 -0400 Subject: [PATCH 08/11] fix(store): stop the rollups drifting on concurrent and unseeded writes Four review findings, all real. Rollup delta applied even when the DELETE matched nothing. removeExpenditure, removeReport and removeDonation all read the row, deleted, then backed the amount out unconditionally. Two containers deleting the same id would each decrement, leaving the count permanently low. removeDonor and removeUser already guarded this; these three now do too, and the pre-read takes FOR UPDATE so the loser re-reads and finds the row gone rather than racing. The pre-read was also unlocked in editExpenditure, so `before` could be stale by the time the rollup was adjusted -- it would subtract an amount no longer in the bucket. tx()'s retry set could never catch it because Postgres does not raise 40001 under READ COMMITTED. FOR UPDATE closes it on both engines: it blocks on Postgres, and on DSQL the conflict surfaces as an OCC error the retry already handles. project_rollup_bump was a bare UPDATE returning void, so a project with no project_rollup row swallowed every change silently. projects_rollup_seed used to guarantee that row for any insert path; since 20260906215733 only the store seeds it. The function now returns 1 or NULL and the caller throws, so an unseeded project fails the transaction instead of drifting for ever. removeUser did have an e2e test, but a weak one -- it only audited consistency, which passes even if the delete does nothing. It now asserts the RESTRICT path really cleared the memberships and that member_count reached zero on both affected projects. Note on the concurrency test: the double-decrement race is not reachable in-process. The store pool is max: 1, so two tx() calls serialise on the one connection and never interleave -- a test using Promise.all passes against the unfixed code, which is why there is a sequential test and a comment rather than a green test that proves nothing. Reproducing it needs two pools. 653 tests pass. Both new migrations are DSQL-clean per @aws/dsql-lint. Co-Authored-By: Claude Opus 5 (1M context) --- ...213524_project_rollup_bump_reports_hit.sql | 30 ++++++++++ .../projects/test/rollup-store.e2e.test.ts | 60 ++++++++++++++++++- shared/store/src/donations.ts | 6 +- shared/store/src/expenditures.ts | 7 ++- shared/store/src/reports.ts | 6 +- shared/store/src/rollups.ts | 10 +++- 6 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 apps/backend/db/migrations/20260907213524_project_rollup_bump_reports_hit.sql 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..f452459f --- /dev/null +++ b/apps/backend/db/migrations/20260907213524_project_rollup_bump_reports_hit.sql @@ -0,0 +1,30 @@ +-- 20260907213524_project_rollup_bump_reports_hit +-- +-- project_rollup_bump was a bare UPDATE returning void, so a project with no +-- project_rollup row swallowed every donation, membership and report change +-- with no error. The projects_rollup_seed trigger used to guarantee that row +-- for any insert path; since 20260906215733 only @branch/store seeds it, so a +-- project arriving another way would drift silently and permanently. +-- +-- Returns 1 when a row was updated and NULL when none matched, which is what a +-- LANGUAGE sql function yields from an UPDATE ... RETURNING that hits nothing. +-- The caller treats NULL as an error. + +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/lambdas/projects/test/rollup-store.e2e.test.ts b/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts index c7cbb9a4..e7f8719e 100644 --- a/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts @@ -218,6 +218,23 @@ describe('expenditure_rollup', () => { expect(await approvedTotal()).toBe(250); }); + // The double-decrement race this guards (two containers deleting one row; + // the loser's DELETE matches nothing but still backs the amount out) is not + // reachable from here: the store pool is max: 1, so two tx() calls in one + // process serialise on the single connection and never interleave. Reproducing + // it needs two pools. The fix is the FOR UPDATE on the pre-read plus the + // zero-row guard in removeExpenditure/removeReport/removeDonation. + 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 approvedTotal()).toBe(100); + }); + test('deleting a project cascades without orphaning or resurrecting a bucket', async () => { await recordExpenditure({ ...travel, project_id: 4 }); await recordReport({ project_id: 4, title: 'r', object_url: 's3://r', report_type: 'technical' }); @@ -284,11 +301,52 @@ describe('project_rollup', () => { 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]); + + // project_memberships.user_id is ON DELETE RESTRICT, so this only succeeds + // if the store clears the memberships first. + expect(await removeUser(4)).toBe(1n); await auditRollups(client); - await removeUser(4); + + 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 () => { + // projects_rollup_seed used to guarantee this row for every insert path. + // Now only the store seeds it, so a bump that matches nothing has to raise + // rather than drop the delta and drift for ever. + 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/); + + // The transaction rolled back, so the report did not land either. + 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 recordReport({ project_id: 1, title: 'a', object_url: 's3://a', report_type: 'technical' }); const b = await recordReport({ diff --git a/shared/store/src/donations.ts b/shared/store/src/donations.ts index 6ab6c9cf..8addc7a8 100644 --- a/shared/store/src/donations.ts +++ b/shared/store/src/donations.ts @@ -26,6 +26,7 @@ export async function removeDonation(id: number): Promise { .selectFrom('branch.project_donations') .where('donation_id', '=', id) .selectAll() + .forUpdate() .executeTakeFirst() if (!before) return 0n @@ -33,12 +34,15 @@ export async function removeDonation(id: number): Promise { .deleteFrom('branch.project_donations') .where('donation_id', '=', id) .executeTakeFirst() + const removed = deleted?.numDeletedRows ?? 0n + // A concurrent delete already took the row; decrementing again drifts. + if (removed === 0n) return 0n await projectRollupBump(trx, before.project_id, { donated: negate(before.amount), donations: -1, }) - return deleted?.numDeletedRows ?? 0n + return removed }) } diff --git a/shared/store/src/expenditures.ts b/shared/store/src/expenditures.ts index dbe9ac23..f6a9625b 100644 --- a/shared/store/src/expenditures.ts +++ b/shared/store/src/expenditures.ts @@ -26,6 +26,7 @@ export async function editExpenditure( .selectFrom('branch.expenditures') .where('expenditure_id', '=', id) .selectAll() + .forUpdate() .executeTakeFirst() if (!before) return undefined @@ -48,6 +49,7 @@ export async function removeExpenditure(id: number): Promise { .selectFrom('branch.expenditures') .where('expenditure_id', '=', id) .selectAll() + .forUpdate() .executeTakeFirst() if (!before) return 0n @@ -55,8 +57,11 @@ export async function removeExpenditure(id: number): Promise { .deleteFrom('branch.expenditures') .where('expenditure_id', '=', id) .executeTakeFirst() + const removed = deleted?.numDeletedRows ?? 0n + // A concurrent delete already took the row; decrementing again drifts. + if (removed === 0n) return 0n await expenditureRollupRemove(trx, before) - return deleted?.numDeletedRows ?? 0n + return removed }) } diff --git a/shared/store/src/reports.ts b/shared/store/src/reports.ts index 4ee31609..de1010b5 100644 --- a/shared/store/src/reports.ts +++ b/shared/store/src/reports.ts @@ -23,6 +23,7 @@ export async function removeReport(id: number): Promise { .selectFrom('branch.reports') .where('report_id', '=', id) .select('project_id') + .forUpdate() .executeTakeFirst() if (!before) return 0n @@ -30,8 +31,11 @@ export async function removeReport(id: number): Promise { .deleteFrom('branch.reports') .where('report_id', '=', id) .executeTakeFirst() + const removed = deleted?.numDeletedRows ?? 0n + // A concurrent delete already took the row; decrementing again drifts. + if (removed === 0n) return 0n await projectRollupBump(trx, before.project_id, { reports: -1 }) - return deleted?.numDeletedRows ?? 0n + return removed }) } diff --git a/shared/store/src/rollups.ts b/shared/store/src/rollups.ts index e9eaba92..f2523b65 100644 --- a/shared/store/src/rollups.ts +++ b/shared/store/src/rollups.ts @@ -36,13 +36,19 @@ export async function projectRollupBump( projectId: number, delta: RollupDelta, ): Promise { - await sql`select branch.project_rollup_bump( + 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} - )`.execute(trx) + ) AS hit`.execute(trx) + + // NULL means no project_rollup row matched. Dropping the delta silently is how + // the rollup drifts permanently, so fail the transaction instead. + if (result.rows[0]?.hit !== 1) { + throw new Error(`project_rollup has no row for project ${projectId}`) + } } export async function seedProjectRollup( From 3087db1ac9a21be3806e6f938bce0910586dc779 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Mon, 7 Sep 2026 17:43:21 -0400 Subject: [PATCH 09/11] chore: trim comments Co-Authored-By: Claude Opus 5 (1M context) --- ...60907213524_project_rollup_bump_reports_hit.sql | 11 ++--------- .../lambdas/projects/test/rollup-store.e2e.test.ts | 14 ++------------ shared/store/src/donations.ts | 1 - shared/store/src/expenditures.ts | 1 - shared/store/src/reports.ts | 1 - shared/store/src/rollups.ts | 2 -- 6 files changed, 4 insertions(+), 26 deletions(-) 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 index f452459f..7ab47e1c 100644 --- a/apps/backend/db/migrations/20260907213524_project_rollup_bump_reports_hit.sql +++ b/apps/backend/db/migrations/20260907213524_project_rollup_bump_reports_hit.sql @@ -1,14 +1,7 @@ -- 20260907213524_project_rollup_bump_reports_hit -- --- project_rollup_bump was a bare UPDATE returning void, so a project with no --- project_rollup row swallowed every donation, membership and report change --- with no error. The projects_rollup_seed trigger used to guarantee that row --- for any insert path; since 20260906215733 only @branch/store seeds it, so a --- project arriving another way would drift silently and permanently. --- --- Returns 1 when a row was updated and NULL when none matched, which is what a --- LANGUAGE sql function yields from an UPDATE ... RETURNING that hits nothing. --- The caller treats NULL as an error. +-- 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); diff --git a/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts b/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts index e7f8719e..5fdc8a8b 100644 --- a/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/rollup-store.e2e.test.ts @@ -218,12 +218,8 @@ describe('expenditure_rollup', () => { expect(await approvedTotal()).toBe(250); }); - // The double-decrement race this guards (two containers deleting one row; - // the loser's DELETE matches nothing but still backs the amount out) is not - // reachable from here: the store pool is max: 1, so two tx() calls in one - // process serialise on the single connection and never interleave. Reproducing - // it needs two pools. The fix is the FOR UPDATE on the pre-read plus the - // zero-row guard in removeExpenditure/removeReport/removeDonation. + // 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 }); @@ -309,8 +305,6 @@ describe('project_rollup', () => { ); expect(before.rows.map((r) => r.member_count)).toEqual([1, 1]); - // project_memberships.user_id is ON DELETE RESTRICT, so this only succeeds - // if the store clears the memberships first. expect(await removeUser(4)).toBe(1n); await auditRollups(client); @@ -333,16 +327,12 @@ describe('project_rollup', () => { }); test('a write against a project with no rollup row fails loudly', async () => { - // projects_rollup_seed used to guarantee this row for every insert path. - // Now only the store seeds it, so a bump that matches nothing has to raise - // rather than drop the delta and drift for ever. 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/); - // The transaction rolled back, so the report did not land either. const reports = await client.query('SELECT 1 FROM branch.reports WHERE project_id = 1'); expect(reports.rows).toHaveLength(0); }); diff --git a/shared/store/src/donations.ts b/shared/store/src/donations.ts index 8addc7a8..f5567edc 100644 --- a/shared/store/src/donations.ts +++ b/shared/store/src/donations.ts @@ -35,7 +35,6 @@ export async function removeDonation(id: number): Promise { .where('donation_id', '=', id) .executeTakeFirst() const removed = deleted?.numDeletedRows ?? 0n - // A concurrent delete already took the row; decrementing again drifts. if (removed === 0n) return 0n await projectRollupBump(trx, before.project_id, { diff --git a/shared/store/src/expenditures.ts b/shared/store/src/expenditures.ts index f6a9625b..9853bb84 100644 --- a/shared/store/src/expenditures.ts +++ b/shared/store/src/expenditures.ts @@ -58,7 +58,6 @@ export async function removeExpenditure(id: number): Promise { .where('expenditure_id', '=', id) .executeTakeFirst() const removed = deleted?.numDeletedRows ?? 0n - // A concurrent delete already took the row; decrementing again drifts. if (removed === 0n) return 0n await expenditureRollupRemove(trx, before) diff --git a/shared/store/src/reports.ts b/shared/store/src/reports.ts index de1010b5..c82ee6e8 100644 --- a/shared/store/src/reports.ts +++ b/shared/store/src/reports.ts @@ -32,7 +32,6 @@ export async function removeReport(id: number): Promise { .where('report_id', '=', id) .executeTakeFirst() const removed = deleted?.numDeletedRows ?? 0n - // A concurrent delete already took the row; decrementing again drifts. if (removed === 0n) return 0n await projectRollupBump(trx, before.project_id, { reports: -1 }) diff --git a/shared/store/src/rollups.ts b/shared/store/src/rollups.ts index f2523b65..60f26585 100644 --- a/shared/store/src/rollups.ts +++ b/shared/store/src/rollups.ts @@ -44,8 +44,6 @@ export async function projectRollupBump( ${delta.reports ?? 0} ) AS hit`.execute(trx) - // NULL means no project_rollup row matched. Dropping the delta silently is how - // the rollup drifts permanently, so fail the transaction instead. if (result.rows[0]?.hit !== 1) { throw new Error(`project_rollup has no row for project ${projectId}`) } From cfd3165badfc4701f403b9c2ddea388c43757aba Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Mon, 7 Sep 2026 22:35:24 -0400 Subject: [PATCH 10/11] fix(store): derive rollup deltas from the mutation, not a prior read The earlier round fixed the single-row deletes but left the same bug in every multi-row path: the delta came from an unlocked SELECT taken before the mutation, so a concurrent write made it stale. syncMemberships computed `members.length - existing.length`. Two roster edits on one project, both reading existing = 2: the first deletes 2, inserts 3 and bumps +1 (count 3, correct); the second deletes 3, inserts 1 and bumps -1, leaving count 2 against one actual row. Under READ COMMITTED the second DELETE re-reads and removes the first's rows, but its delta was already fixed from the stale count. Now counted from the DELETE itself. removeDonor and removeUser bumped from a list read before deleting, so a donation or membership inserted in between was deleted without ever coming off the rollup. Both now use DELETE ... RETURNING and bump from what was actually removed, which also drops the pre-read entirely. RETURNING beats locking here: SELECT ... FOR UPDATE locks the rows it finds, but does not stop a new child row appearing before the DELETE, so it would not have closed either window. Both early `return 0n` guards go: the bumps now correspond exactly to the rows deleted, and FK RESTRICT means a child cannot outlive its parent, so the observable result is unchanged. DELETE ... RETURNING confirmed DSQL-compatible with @aws/dsql-lint. As with the last round the race is not reproducible in-process (store pool is max: 1), so these ship without a regression test rather than with one that passes against the unfixed code. 568 tests pass across the five affected lambdas. Co-Authored-By: Claude Opus 5 (1M context) --- shared/store/src/donations.ts | 16 ++++++---------- shared/store/src/projects.ts | 9 +++++++-- shared/store/src/users.ts | 16 ++++++---------- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/shared/store/src/donations.ts b/shared/store/src/donations.ts index f5567edc..f98cfbd5 100644 --- a/shared/store/src/donations.ts +++ b/shared/store/src/donations.ts @@ -56,24 +56,20 @@ export async function createDonor( // 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) => { - const donations = await trx - .selectFrom('branch.project_donations') + // RETURNING, not a prior SELECT: a donation inserted between the two would + // be deleted here and never come off the rollup. + const removed = await trx + .deleteFrom('branch.project_donations') .where('donor_id', '=', donorId) - .select(['project_id', 'amount']) + .returning(['project_id', 'amount']) .execute() - if (donations.length > 0) { - await trx.deleteFrom('branch.project_donations').where('donor_id', '=', donorId).execute() - } - const deleted = await trx .deleteFrom('branch.donors') .where('donor_id', '=', donorId) .executeTakeFirst() - if ((deleted?.numDeletedRows ?? 0n) === 0n) return 0n - - for (const donation of donations) { + for (const donation of removed) { await projectRollupBump(trx, donation.project_id, { donated: negate(donation.amount), donations: -1, diff --git a/shared/store/src/projects.ts b/shared/store/src/projects.ts index e1938da6..41de8035 100644 --- a/shared/store/src/projects.ts +++ b/shared/store/src/projects.ts @@ -19,7 +19,10 @@ async function syncMemberships( .execute() const heldRole = new Map(existing.map((row) => [row.user_id, row.role])) - await trx.deleteFrom('branch.project_memberships').where('project_id', '=', projectId).execute() + const cleared = await trx + .deleteFrom('branch.project_memberships') + .where('project_id', '=', projectId) + .executeTakeFirst() if (members.length > 0) { await trx @@ -34,7 +37,9 @@ async function syncMemberships( .execute() } - const delta = members.length - existing.length + // Counted from the DELETE, not from `existing`: a concurrent roster edit makes + // that read stale and member_count drifts permanently. + const delta = members.length - Number(cleared?.numDeletedRows ?? 0n) if (delta !== 0) await projectRollupBump(trx, projectId, { members: delta }) } diff --git a/shared/store/src/users.ts b/shared/store/src/users.ts index 6bdbd1cb..29ac37c5 100644 --- a/shared/store/src/users.ts +++ b/shared/store/src/users.ts @@ -41,24 +41,20 @@ export async function claimUser(userId: number, values: UserEdit): Promise { return tx(async (trx) => { - const memberships = await trx - .selectFrom('branch.project_memberships') + // RETURNING, not a prior SELECT: a membership inserted between the two would + // be deleted here and never come off member_count. + const removed = await trx + .deleteFrom('branch.project_memberships') .where('user_id', '=', userId) - .select('project_id') + .returning('project_id') .execute() - if (memberships.length > 0) { - await trx.deleteFrom('branch.project_memberships').where('user_id', '=', userId).execute() - } - const deleted = await trx .deleteFrom('branch.users') .where('user_id', '=', userId) .executeTakeFirst() - if ((deleted?.numDeletedRows ?? 0n) === 0n) return 0n - - for (const membership of memberships) { + for (const membership of removed) { await projectRollupBump(trx, membership.project_id, { members: -1 }) } return deleted?.numDeletedRows ?? 0n From 50eebe3aca7b1c72dc66e2da6596c76c9ceb6e40 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Mon, 7 Sep 2026 22:36:05 -0400 Subject: [PATCH 11/11] chore: trim comments Co-Authored-By: Claude Opus 5 (1M context) --- shared/store/src/donations.ts | 3 +-- shared/store/src/projects.ts | 3 +-- shared/store/src/users.ts | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/shared/store/src/donations.ts b/shared/store/src/donations.ts index f98cfbd5..a2be473f 100644 --- a/shared/store/src/donations.ts +++ b/shared/store/src/donations.ts @@ -56,8 +56,7 @@ export async function createDonor( // 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 inserted between the two would - // be deleted here and never come off the rollup. + // 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) diff --git a/shared/store/src/projects.ts b/shared/store/src/projects.ts index 41de8035..4e24d794 100644 --- a/shared/store/src/projects.ts +++ b/shared/store/src/projects.ts @@ -37,8 +37,7 @@ async function syncMemberships( .execute() } - // Counted from the DELETE, not from `existing`: a concurrent roster edit makes - // that read stale and member_count drifts permanently. + // 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 }) } diff --git a/shared/store/src/users.ts b/shared/store/src/users.ts index 29ac37c5..0abb6478 100644 --- a/shared/store/src/users.ts +++ b/shared/store/src/users.ts @@ -41,8 +41,7 @@ export async function claimUser(userId: number, values: UserEdit): Promise { return tx(async (trx) => { - // RETURNING, not a prior SELECT: a membership inserted between the two would - // be deleted here and never come off member_count. + // 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)