diff --git a/.github/CI.md b/.github/CI.md index 11d90b2a8..5ff0cb288 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -13,18 +13,20 @@ The CI workflow runs on pull request events: ## Test Run Matrix -| Job | Type | Runs on PR event (`opened/reopened/synchronize/edited`) | Extra condition | What it runs | -| ------------------------- | ----------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | -| `test-static` | Static checks | Always | `needs: build` | commitlint title, package checks, typecheck, OpenRPC title check, prettier, eslint | -| `test-unit` | Unit/integration (Nx target `test`) | Always | `needs: build` | `yarn nx affected -t test --base=origin/${{ github.base_ref }} --head=HEAD --parallel` | -| `build-docs` | Documentation build | Always | `needs: build` | Sphinx docs build for wallet integration guide | -| `ping-e2e` | E2E worker (ping app) | Always | `needs: build` | starts Canton+services and runs Playwright for `@canton-network/example-ping` | -| `test-ping-e2e` | Aggregator/reporting | Always | `needs: ping-e2e`, `if: always()` | fails if `ping-e2e` did not succeed | -| `portfolio-e2e` | E2E worker (portfolio app) | Always | `needs: build` | starts Canton+services and runs Playwright for `@canton-network/example-portfolio` | -| `test-portfolio-e2e` | Aggregator/reporting | Always | `needs: portfolio-e2e`, `if: always()` | fails if `portfolio-e2e` did not succeed | -| `wallet-sdk-snippets-e2e` | Wallet SDK snippets E2E | Always | `needs: build` | snippet tests on matrix `devnet` + `mainnet` | -| `wallet-sdk-scripts-e2e` | Wallet SDK scripts E2E | Always | `needs: build` | example scripts tests on matrix `devnet` + `mainnet` | -| `wallet-sdk-pkg` | SDK package validation | Always | `needs: build` | `yarn script:validate:package` | -| `test-wallet-sdk-e2e` | Aggregator/reporting | Always | `needs: [wallet-sdk-snippets-e2e, wallet-sdk-scripts-e2e, wallet-sdk-pkg]`, `if: always()` | fails if any required wallet-sdk e2e/package job did not succeed | +| Job | Type | Runs on PR event (`opened/reopened/synchronize/edited`) | Extra condition | What it runs | +| ------------------------- | ----------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| `test-static` | Static checks | Always | `needs: build` | commitlint title, package checks, typecheck, OpenRPC title check, prettier, eslint | +| `test-unit` | Unit/integration (Nx target `test`) | Always | `needs: build` | `yarn nx affected -t test --base=origin/${{ github.base_ref }} --head=HEAD --parallel` | +| `build-docs` | Documentation build | Always | `needs: build` | Sphinx docs build for wallet integration guide | +| `ping-e2e` | E2E worker (ping app) | Always | `needs: build` | starts Canton+services and runs Playwright for `@canton-network/example-ping` | +| `test-ping-e2e` | Aggregator/reporting | Always | `needs: ping-e2e`, `if: always()` | fails if `ping-e2e` did not succeed | +| `portfolio-e2e` | E2E worker (portfolio app) | Always | `needs: build` | starts Canton+services and runs Playwright for `@canton-network/example-portfolio` | +| `test-portfolio-e2e` | Aggregator/reporting | Always | `needs: portfolio-e2e`, `if: always()` | fails if `portfolio-e2e` did not succeed | +| `wallet-sdk-snippets-e2e` | Wallet SDK snippets E2E | Always | `needs: build` | snippet tests on matrix `devnet` + `mainnet` | +| `wallet-sdk-scripts-e2e` | Wallet SDK scripts E2E | Always | `needs: build` | example scripts tests on matrix `devnet` + `mainnet` | +| `wallet-sdk-pkg` | SDK package validation | Always | `needs: build` | `yarn script:validate:package` | +| `test-wallet-sdk-e2e` | Aggregator/reporting | Always | `needs: [wallet-sdk-snippets-e2e, wallet-sdk-scripts-e2e, wallet-sdk-pkg]`, `if: always()` | fails if any required wallet-sdk e2e/package job did not succeed | +| `test-migrations` | SQL migration tests | Conditional | `needs: build` | `yarn nx affected -t test:migrations --base=origin/${{ github.base_ref }} --head=HEAD --parallel=1` | +| `check-migration-lock` | Test SQL migration immutability | Conditional | None // TODO verify if doesnt need build | `yarn nx affected -t migrations:check-lock --base=origin/${{ github.base_ref }} --head=HEAD --parallel` | The workflow uses aggregator wrappers (`test-ping-e2e`, `test-portfolio-e2e`, and `test-wallet-sdk-e2e`) that always run and validate the success of the corresponding worker jobs. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e33806c40..4e2f4163a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -226,14 +226,56 @@ jobs: --head=HEAD \ --parallel + check-migration-lock: + # Verifies migrations.lock.json is in sync with src/migrations for any + # affected SQL store package that defines a `migrations:check-lock` + # script. Catches accidental migration edits + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: ./.github/actions/setup_yarn + + - name: Check migration lock files + run: | + yarn nx affected -t migrations:check-lock \ + --base=origin/${{ github.base_ref }} \ + --head=HEAD \ + --parallel + + test-migrations: + # Runs SQL migration tests for any affected package that defines a + # `test:migrations` script + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: ./.github/actions/setup_yarn + + - name: Run affected migration tests + run: | + yarn nx affected -t test:migrations \ + --base=origin/${{ github.base_ref }} \ + --head=HEAD \ + --parallel=1 + ping-e2e: - name: ping-e2e (${{ matrix.network }}) + name: ping-e2e (${{ matrix.network }}, ${{ matrix.db }}) runs-on: ubuntu-latest needs: [version-config, hydrate-canton-caches] strategy: fail-fast: false matrix: network: [devnet, mainnet] + db: [sqlite, postgres] steps: - name: Checkout uses: actions/checkout@v6 @@ -247,6 +289,45 @@ jobs: network: ${{ matrix.network }} canton_version: ${{ matrix.network == 'devnet' && needs.version-config.outputs.devnet_canton_version || needs.version-config.outputs.mainnet_canton_version }} + - name: Start Postgres container + if: matrix.db == 'postgres' + run: | + docker run -d --name wk-postgres \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=postgres \ + -p 5432:5432 \ + --health-cmd="pg_isready -U postgres" \ + --health-interval=2s \ + postgres:16-alpine + + until [ "$(docker inspect -f '{{.State.Health.Status}}' wk-postgres)" = "healthy" ]; do + sleep 5 + done + docker inspect -f '{{.State.Health.Status}}' wk-postgres | grep -q healthy + + - name: Point gateway config at Postgres + if: matrix.db == 'postgres' + run: | + CONFIG=wallet-gateway/test/config.json + jq '.store.connection = { + type: "postgres", + host: "localhost", + port: 5432, + user: "postgres", + password: "postgres", + database: "wallet" + } + | .signingStore.connection = { + type: "postgres", + host: "localhost", + port: 5432, + user: "postgres", + password: "postgres", + database: "signing" + }' "$CONFIG" > "$CONFIG.tmp" + mv "$CONFIG.tmp" "$CONFIG" + - name: Start remote WK run: | BLOCKDAEMON_API_URL="${{ vars.BLOCKDAEMON_API_URL }}" \ @@ -268,17 +349,21 @@ jobs: - uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: - name: example-ping-playwright-report-${{ matrix.network }} + name: example-ping-playwright-report-${{ matrix.network }}-${{ matrix.db }} path: examples/ping/playwright-report/ retention-days: 5 - uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: - name: ping-wallet-gateway-remote-log-${{ matrix.network }} + name: ping-wallet-gateway-remote-log-${{ matrix.network }}-${{ matrix.db }} path: wallet-gateway-remote.log retention-days: 5 + - name: Stop Postgres container + if: ${{ always() && matrix.db == 'postgres' }} + run: docker rm -f wk-postgres || true + test-ping-e2e: runs-on: ubuntu-latest needs: ping-e2e diff --git a/core/signing-store-sql/migrations.lock.json b/core/signing-store-sql/migrations.lock.json new file mode 100644 index 000000000..1162699bc --- /dev/null +++ b/core/signing-store-sql/migrations.lock.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "migrations": { + "001-init": "ce148408f89c3d2589835991db5c61cf0fc927e362278bcf1243c6d7db53b397", + "002-add-signed-at": "3eba642ef9b54406db9868e40da85708b793459e21479614829158d02b3608ab", + "003-alter-date-fields": "c5195429926500de1fb010fd33ab4b5f63ecd35b27d4f4cb62cf3b426eef9727" + } +} diff --git a/core/signing-store-sql/package.json b/core/signing-store-sql/package.json index b3caf7195..4037d7ffc 100644 --- a/core/signing-store-sql/package.json +++ b/core/signing-store-sql/package.json @@ -23,7 +23,10 @@ "flatpack": "yarn pack --out \"$FLATPACK_OUTDIR\"", "clean": "tsc -b --clean; rm -rf dist", "test": "vitest run --project node --passWithNoTests", - "test:coverage": "vitest run --project node --coverage --passWithNoTests" + "test:coverage": "vitest run --project node --coverage --passWithNoTests", + "test:migrations": "vitest run --project migrations", + "migrations:check-lock": "tsx ../../scripts/src/check-migration-lock.ts", + "migrations:update-lock": "tsx ../../scripts/src/check-migration-lock.ts --update" }, "dependencies": { "@canton-network/core-ledger-client": "workspace:^", @@ -41,6 +44,7 @@ }, "devDependencies": { "@swc/core": "^1.15.18", + "@testcontainers/postgresql": "^11.14.0", "@types/better-sqlite3": "^7.6.13", "@types/pg": "^8.18.0", "@vitest/coverage-v8": "^4.1.2", diff --git a/core/signing-store-sql/src/migrations-test/README.md b/core/signing-store-sql/src/migrations-test/README.md new file mode 100644 index 000000000..68fc988da --- /dev/null +++ b/core/signing-store-sql/src/migrations-test/README.md @@ -0,0 +1,25 @@ +# Migration tests for wallet-store-sql + +Runs migrations and tests their effects against all supported SQL dialects + +`/migration-test/data/` +vitest test files that must reflect `/migrations/` filenames with `.test.ts` extension + +`/migration-test/seeds/` +utils for inserting rows satisfying table schema that reflects that state of migrations being applied up to the migration with related filename + +`/migration-test/helpers.ts` +utils for validating schema + +`/migration-test/coverage.test.ts` +test that validates that every migration has a test. if not - fail the tests. intention is to assure that added migrations are validated against all supported SQL dialects + +`/migration-test/global-setup.ts` +spins up postgres with testcontainers + +`/migrations.lock.json` +contains hashes of all migration files. intention is to prevent accidental modifications of existing migrations. + +`yarn migrations:check-lock` recomputes hashes from migration files and compares them against lock file + +`yarn migrations:update-lock` recomputes hashes and updates lock file. use it after adding a new migration, or in case of intentionally editing old migration (i.e. when adding a comment), altering logic in old migrations should be avoided diff --git a/core/signing-store-sql/src/migrations-test/coverage.test.ts b/core/signing-store-sql/src/migrations-test/coverage.test.ts new file mode 100644 index 000000000..e89b97718 --- /dev/null +++ b/core/signing-store-sql/src/migrations-test/coverage.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' +import { describe, expect, test } from 'vitest' + +const here = dirname(fileURLToPath(import.meta.url)) +const MIGRATIONS_DIR = resolve(here, '../migrations') +const TESTS_DIR = resolve(here, 'data') + +const migrationNames = (): string[] => + readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith('.ts')) + .map((f) => f.replace(/\.ts$/, '')) + +const testNames = (): string[] => + readdirSync(TESTS_DIR) + .filter((f) => f.endsWith('.test.ts')) + .map((f) => f.replace(/\.test\.ts$/, '')) + +describe('migration test coverage', () => { + const migrations = new Set(migrationNames()) + const tests = new Set(testNames()) + + test('every migration has a sibling test file with the same name', () => { + const missing = [...migrations] + .filter((name) => !tests.has(name)) + .map((name) => `expected src/migrations-test/data/${name}.test.ts`) + expect( + missing, + `Migrations without corresponding tests:\n ${missing.join('\n ')}` + ).toEqual([]) + }) + + test('every migration test file matches an existing migration', () => { + const orphans = [...tests] + .filter((name) => !migrations.has(name)) + .map((name) => `no migration src/migrations/${name}.ts found`) + expect( + orphans, + `Test files without a backing migration:\n ${orphans.join('\n ')}` + ).toEqual([]) + }) +}) diff --git a/core/signing-store-sql/src/migrations-test/data/001-init.test.ts b/core/signing-store-sql/src/migrations-test/data/001-init.test.ts new file mode 100644 index 000000000..933b877eb --- /dev/null +++ b/core/signing-store-sql/src/migrations-test/data/001-init.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' + +import { + columnNames, + forEachDialect, + indexExists, + migrateUpThrough, + primaryKeyColumns, + tableExists, +} from '../helpers.js' +import { + insertSigningDriverConfig, + insertSigningKey, + insertSigningTransaction, +} from '../seeds/001-init.js' + +const TARGET = 1 + +forEachDialect('migration 001 - init signing store schema', ({ getDb }) => { + test('creates base tables, columns, primary keys, and indexes', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + for (const table of [ + 'signing_keys', + 'signing_transactions', + 'signing_driver_configs', + ]) { + expect(await tableExists(db, table)).toBe(true) + } + + expect(await columnNames(db, 'signing_keys')).toEqual( + [ + 'created_at', + 'id', + 'metadata', + 'name', + 'private_key', + 'public_key', + 'updated_at', + 'user_id', + ].sort() + ) + expect(await columnNames(db, 'signing_transactions')).toEqual( + [ + 'created_at', + 'hash', + 'id', + 'metadata', + 'public_key', + 'signature', + 'status', + 'updated_at', + 'user_id', + ].sort() + ) + expect(await columnNames(db, 'signing_driver_configs')).toEqual( + ['config', 'driver_id', 'user_id'].sort() + ) + + expect(await primaryKeyColumns(db, 'signing_keys')).toEqual(['id']) + expect(await primaryKeyColumns(db, 'signing_transactions')).toEqual([ + 'id', + ]) + expect(await primaryKeyColumns(db, 'signing_driver_configs')).toEqual([ + 'user_id', + 'driver_id', + ]) + + expect( + await indexExists(db, 'signing_keys', 'idx_signing_keys_user_id') + ).toBe(true) + expect( + await indexExists(db, 'signing_keys', 'idx_signing_keys_public_key') + ).toBe(true) + expect( + await indexExists( + db, + 'signing_transactions', + 'idx_signing_transactions_user_id' + ) + ).toBe(true) + expect( + await indexExists( + db, + 'signing_transactions', + 'idx_signing_transactions_status' + ) + ).toBe(true) + expect( + await indexExists( + db, + 'signing_transactions', + 'idx_signing_transactions_created_at' + ) + ).toBe(true) + }) + + test('enforces unique constraint (user_id, id) on signing_keys', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + await insertSigningKey(db, { + id: 'same-key-id', + userId: 'same-user', + name: 'first', + publicKey: 'pk1', + }) + + await expect( + insertSigningKey(db, { + id: 'same-key-id', + userId: 'same-user', + name: 'duplicate', + publicKey: 'pk2', + }) + ).rejects.toThrow() + }) + + test('enforces unique constraint (user_id, id) on signing_transactions', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + await insertSigningTransaction(db, { + id: 'same-key-id', + userId: 'same-user', + publicKey: 'pk1', + }) + + await expect( + insertSigningTransaction(db, { + id: 'same-key-id', + userId: 'same-user', + publicKey: 'pk2', + }) + ).rejects.toThrow() + }) + + test('enforces primary key constraint (user_id, driver_id) on signing_driver_configs', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + await insertSigningDriverConfig(db, { + driverId: 'same-driver-id', + userId: 'same-user', + }) + + await expect( + insertSigningDriverConfig(db, { + driverId: 'same-driver-id', + userId: 'same-user', + }) + ).rejects.toThrow() + }) +}) diff --git a/core/signing-store-sql/src/migrations-test/data/002-add-signed-at.test.ts b/core/signing-store-sql/src/migrations-test/data/002-add-signed-at.test.ts new file mode 100644 index 000000000..48c1ffb51 --- /dev/null +++ b/core/signing-store-sql/src/migrations-test/data/002-add-signed-at.test.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' + +import { + forEachDialect, + hasColumn, + listColumns, + migrateDownThrough, + migrateUpThrough, +} from '../helpers.js' + +const TARGET = 2 + +forEachDialect('migration 002 - add signed_at', ({ getDb }) => { + test('adds nullable signed_at on signing_transactions', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + expect(await hasColumn(db, 'signing_transactions', 'signed_at')).toBe( + true + ) + + const cols = await listColumns(db, 'signing_transactions') + const signedAt = cols.find((c) => c.name === 'signed_at') + expect(signedAt?.nullable).toBe(true) + }) + + test('down removes signed_at', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + await migrateDownThrough(db, TARGET) + + expect(await hasColumn(db, 'signing_transactions', 'signed_at')).toBe( + false + ) + }) +}) diff --git a/core/signing-store-sql/src/migrations-test/data/003-alter-date-fields.test.ts b/core/signing-store-sql/src/migrations-test/data/003-alter-date-fields.test.ts new file mode 100644 index 000000000..e4e75be63 --- /dev/null +++ b/core/signing-store-sql/src/migrations-test/data/003-alter-date-fields.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' +import { sql } from 'kysely' + +import { + columnNames, + forEachDialect, + indexExists, + listColumns, + migrateDownThrough, + migrateUpThrough, + migrateUpToBefore, +} from '../helpers.js' +import { + insertSigningKey, + insertSigningTransaction, +} from '../seeds/001-init.js' +import { insertSigningTransaction002 } from '../seeds/002-add-signed-at.js' + +const TARGET = 3 + +forEachDialect('migration 003 - alter date fields to text', ({ getDb }) => { + test('switches signing_keys timestamps from integer to text and casts existing values to strings', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + const before = await listColumns(db, 'signing_keys') + const beforeByName = new Map(before.map((c) => [c.name, c])) + expect(beforeByName.get('created_at')?.dataType).toBe('integer') + expect(beforeByName.get('updated_at')?.dataType).toBe('integer') + + await insertSigningKey(db, { + id: 'key-1', + userId: 'user1', + createdAt: 1000, + updatedAt: 2000, + }) + + await migrateUpThrough(db, TARGET) + + const after = await listColumns(db, 'signing_keys') + const afterByName = new Map(after.map((c) => [c.name, c])) + expect(afterByName.get('created_at')?.dataType).toBe('text') + expect(afterByName.get('updated_at')?.dataType).toBe('text') + expect(afterByName.get('created_at')?.nullable).toBe(false) + expect(afterByName.get('updated_at')?.nullable).toBe(false) + + const rows = await sql<{ + id: string + createdAt: string + updatedAt: string + }>` + SELECT id, created_at, updated_at FROM signing_keys + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toEqual({ + id: 'key-1', + createdAt: '1000', + updatedAt: '2000', + }) + }) + + test('switches signing_transactions timestamps from integer to text and preserves signed_at', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + const before = await listColumns(db, 'signing_transactions') + const beforeByName = new Map(before.map((c) => [c.name, c])) + expect(beforeByName.get('created_at')?.dataType).toBe('integer') + expect(beforeByName.get('updated_at')?.dataType).toBe('integer') + expect(beforeByName.get('signed_at')?.dataType).toBe('text') + + await insertSigningTransaction002(db, { + id: 'tx-int-only', + userId: 'user1', + createdAt: 100, + updatedAt: 200, + }) + await insertSigningTransaction002(db, { + id: 'tx-with-signed-at', + userId: 'user1', + createdAt: 300, + updatedAt: 400, + signedAt: '2026-04-30T12:34:56.000Z', + }) + + await migrateUpThrough(db, TARGET) + + const after = await listColumns(db, 'signing_transactions') + const afterByName = new Map(after.map((c) => [c.name, c])) + expect(afterByName.get('created_at')?.dataType).toBe('text') + expect(afterByName.get('updated_at')?.dataType).toBe('text') + expect(afterByName.get('signed_at')?.dataType).toBe('text') + expect(afterByName.get('signed_at')?.nullable).toBe(true) + + const rows = await sql<{ + id: string + createdAt: string + updatedAt: string + signedAt: string | null + }>` + SELECT id, created_at, updated_at, signed_at + FROM signing_transactions + ORDER BY id + `.execute(db) + expect(rows.rows).toEqual([ + { + id: 'tx-int-only', + createdAt: '100', + updatedAt: '200', + signedAt: null, + }, + { + id: 'tx-with-signed-at', + createdAt: '300', + updatedAt: '400', + signedAt: '2026-04-30T12:34:56.000Z', + }, + ]) + }) + + test('keeps the post-003 column set and recreates indexes', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + expect(await columnNames(db, 'signing_keys')).toEqual( + [ + 'created_at', + 'id', + 'metadata', + 'name', + 'private_key', + 'public_key', + 'updated_at', + 'user_id', + ].sort() + ) + expect(await columnNames(db, 'signing_transactions')).toEqual( + [ + 'created_at', + 'hash', + 'id', + 'metadata', + 'public_key', + 'signature', + 'signed_at', + 'status', + 'updated_at', + 'user_id', + ].sort() + ) + + expect( + await indexExists(db, 'signing_keys', 'idx_signing_keys_user_id') + ).toBe(true) + expect( + await indexExists(db, 'signing_keys', 'idx_signing_keys_public_key') + ).toBe(true) + expect( + await indexExists( + db, + 'signing_transactions', + 'idx_signing_transactions_user_id' + ) + ).toBe(true) + expect( + await indexExists( + db, + 'signing_transactions', + 'idx_signing_transactions_status' + ) + ).toBe(true) + expect( + await indexExists( + db, + 'signing_transactions', + 'idx_signing_transactions_created_at' + ) + ).toBe(true) + }) + + test('down reverts timestamps back to integer and casts text values to numbers', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertSigningKey(db, { + id: 'key-down', + userId: 'user1', + createdAt: 7777, + updatedAt: 8888, + }) + await insertSigningTransaction(db, { + id: 'tx-down', + userId: 'user1', + createdAt: 11, + updatedAt: 22, + }) + + await migrateUpThrough(db, TARGET) + await migrateDownThrough(db, TARGET) + + const keyCols = await listColumns(db, 'signing_keys') + const keyByName = new Map(keyCols.map((c) => [c.name, c])) + expect(keyByName.get('created_at')?.dataType).toBe('integer') + expect(keyByName.get('updated_at')?.dataType).toBe('integer') + + const txCols = await listColumns(db, 'signing_transactions') + const txByName = new Map(txCols.map((c) => [c.name, c])) + expect(txByName.get('created_at')?.dataType).toBe('integer') + expect(txByName.get('updated_at')?.dataType).toBe('integer') + + const keys = await sql<{ + id: string + createdAt: number + updatedAt: number + }>` + SELECT id, created_at, updated_at FROM signing_keys + `.execute(db) + expect(keys.rows).toHaveLength(1) + expect(keys.rows[0]?.id).toBe('key-down') + expect(Number(keys.rows[0]?.createdAt)).toBe(7777) + expect(Number(keys.rows[0]?.updatedAt)).toBe(8888) + + const txs = await sql<{ + id: string + createdAt: number + updatedAt: number + }>` + SELECT id, created_at, updated_at FROM signing_transactions + `.execute(db) + expect(txs.rows).toHaveLength(1) + expect(txs.rows[0]?.id).toBe('tx-down') + expect(Number(txs.rows[0]?.createdAt)).toBe(11) + expect(Number(txs.rows[0]?.updatedAt)).toBe(22) + }) +}) diff --git a/core/signing-store-sql/src/migrations-test/global-setup.ts b/core/signing-store-sql/src/migrations-test/global-setup.ts new file mode 100644 index 000000000..b29f2e064 --- /dev/null +++ b/core/signing-store-sql/src/migrations-test/global-setup.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PostgreSqlContainer, + StartedPostgreSqlContainer, +} from '@testcontainers/postgresql' + +let container: StartedPostgreSqlContainer | undefined + +export const PG_ENV = { + HOST: 'MIG_TEST_PG_HOST', + PORT: 'MIG_TEST_PG_PORT', + USER: 'MIG_TEST_PG_USER', + PASSWORD: 'MIG_TEST_PG_PASSWORD', + DATABASE: 'MIG_TEST_PG_DATABASE', +} as const + +export default async function setup() { + container = await new PostgreSqlContainer('postgres:16-alpine').start() + + process.env[PG_ENV.HOST] = container.getHost() + process.env[PG_ENV.PORT] = String(container.getPort()) + process.env[PG_ENV.USER] = container.getUsername() + process.env[PG_ENV.PASSWORD] = container.getPassword() + process.env[PG_ENV.DATABASE] = container.getDatabase() + + return async () => { + await container?.stop({ timeout: 10_000 }) + container = undefined + } +} diff --git a/core/signing-store-sql/src/migrations-test/helpers.ts b/core/signing-store-sql/src/migrations-test/helpers.ts new file mode 100644 index 000000000..01966a38f --- /dev/null +++ b/core/signing-store-sql/src/migrations-test/helpers.ts @@ -0,0 +1,336 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe } from 'vitest' +import { Kysely, sql } from 'kysely' +import pg from 'pg' + +import { connection } from '../store-sql.js' +import { migrator } from '../migrator.js' +import { DB } from '../schema.js' +import { PG_ENV } from './global-setup.js' +import { isPostgres } from '../utils' + +export type Dialect = 'sqlite' | 'postgres' + +export interface DialectContext { + dialect: Dialect + getDb: () => Kysely +} + +export const SUPPORTED_DIALECTS: Dialect[] = ['sqlite', 'postgres'] + +const pgConnection = () => ({ + host: requireEnv(PG_ENV.HOST), + port: Number(requireEnv(PG_ENV.PORT)), + user: requireEnv(PG_ENV.USER), + password: requireEnv(PG_ENV.PASSWORD), + database: requireEnv(PG_ENV.DATABASE), +}) + +function requireEnv(key: string): string { + const v = process.env[key] + if (!v) { + throw new Error(`Postgres test container env var ${key} not set`) + } + return v +} + +const adminPool = (): pg.Pool => new pg.Pool(pgConnection()) + +async function createPgDatabase(name: string): Promise { + const pool = adminPool() + try { + await pool.query(`CREATE DATABASE "${name}"`) + } finally { + await pool.end() + } +} + +async function dropPgDatabase(name: string): Promise { + const pool = adminPool() + try { + await pool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [name] + ) + await pool.query(`DROP DATABASE IF EXISTS "${name}"`) + } finally { + await pool.end() + } +} + +export interface FreshDb { + db: Kysely + dispose: () => Promise +} + +export async function freshDb(dialect: Dialect): Promise { + if (dialect === 'sqlite') { + const db = connection({ connection: { type: 'memory' } }) + return { + db, + dispose: async () => { + await db.destroy() + }, + } + } + + const dbName = `mig_${randomUUID().replace(/-/g, '')}` + await createPgDatabase(dbName) + const conn = pgConnection() + const db = connection({ + connection: { + type: 'postgres', + host: conn.host, + port: conn.port, + user: conn.user, + password: conn.password, + database: dbName, + }, + }) + return { + db, + dispose: async () => { + await db.destroy() + await dropPgDatabase(dbName) + }, + } +} + +let cachedNames: string[] | undefined + +export async function getAllMigrationNames(): Promise { + if (cachedNames) return cachedNames + const db = connection({ connection: { type: 'memory' } }) + try { + const m = migrator(db) + cachedNames = (await m.pending()).map((x) => x.name) + return cachedNames + } finally { + await db.destroy() + } +} + +// Get migration name by 1-based index +export async function migrationName(migrationNumber: number): Promise { + const names = await getAllMigrationNames() + const idx = migrationNumber - 1 + if (idx < 0 || idx >= names.length) { + throw new Error(`Migration index ${migrationNumber} out of bounds`) + } + return names[idx] +} + +export async function migrateUpTo( + db: Kysely, + name?: string +): Promise { + const m = migrator(db) + if (name === undefined) { + await m.up() + } else { + await m.up({ to: name }) + } +} + +export async function migrateUpToBefore( + db: Kysely, + target1Based: number +): Promise { + if (target1Based <= 1) return + await migrateUpTo(db, await migrationName(target1Based - 1)) +} + +export async function migrateUpThrough( + db: Kysely, + target1Based: number +): Promise { + await migrateUpTo(db, await migrationName(target1Based)) +} + +export async function migrateDownTo( + db: Kysely, + name: string | 0 +): Promise { + const m = migrator(db) + if (name === 0) { + await m.down({ to: 0 }) + } else { + await m.down({ to: name }) + } +} + +export async function migrateDownThrough( + db: Kysely, + target1Based: number +): Promise { + if (target1Based <= 1) { + await migrateDownTo(db, 0) + return + } + await migrateDownTo(db, await migrationName(target1Based)) +} + +export function forEachDialect( + title: string, + body: (ctx: DialectContext) => void +): void { + describe.each(SUPPORTED_DIALECTS)(`${title} [%s]`, (dialect) => { + let current: FreshDb | undefined + + beforeEach(async () => { + current = await freshDb(dialect) + }) + + afterEach(async () => { + try { + await current?.dispose() + } finally { + current = undefined + } + }) + + body({ + dialect, + getDb: () => { + if (!current) { + throw new Error('getDb() called outside of a test scope') + } + return current.db + }, + }) + }) +} + +export interface ColumnInfo { + name: string + nullable: boolean + dataType: string +} + +export async function listColumns( + db: Kysely, + table: string +): Promise { + if (await isPostgres(db)) { + const res = await sql<{ + columnName: string + isNullable: string + dataType: string + }>` + SELECT column_name, is_nullable, data_type + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ${table} + ORDER BY ordinal_position + `.execute(db) + return res.rows.map((r) => ({ + name: r.columnName.toLowerCase(), + nullable: r.isNullable.toUpperCase() === 'YES', + dataType: r.dataType.toLowerCase(), + })) + } + const res = await sql<{ name: string; notnull: number; type: string }>` + SELECT name, "notnull", "type" FROM pragma_table_info(${table}) + `.execute(db) + return res.rows.map((r) => ({ + name: r.name.toLowerCase(), + nullable: r.notnull === 0, + dataType: (r.type ?? '').toLowerCase(), + })) +} + +export async function columnNames( + db: Kysely, + table: string +): Promise { + return (await listColumns(db, table)).map((c) => c.name).sort() +} + +export async function hasColumn( + db: Kysely, + table: string, + column: string +): Promise { + const cols = await columnNames(db, table) + return cols.includes(column.toLowerCase()) +} + +export async function tableExists( + db: Kysely, + table: string +): Promise { + if (await isPostgres(db)) { + const res = await sql<{ exists: boolean }>` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = ${table} + ) AS exists + `.execute(db) + return res.rows[0]?.exists ?? false + } + const res = await sql<{ name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'table' AND name = ${table} + `.execute(db) + return res.rows.length > 0 +} + +export async function primaryKeyColumns( + db: Kysely, + table: string +): Promise { + if (await isPostgres(db)) { + const res = await sql<{ columnName: string; position: number }>` + SELECT a.attname AS column_name, + array_position(c.conkey, a.attnum) AS position + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_attribute a + ON a.attrelid = c.conrelid + AND a.attnum = ANY (c.conkey) + WHERE c.contype = 'p' + AND n.nspname = 'public' + AND t.relname = ${table} + ORDER BY position + `.execute(db) + return res.rows.map((r) => r.columnName.toLowerCase()) + } + const res = await sql<{ name: string; pk: number }>` + SELECT name, pk FROM pragma_table_info(${table}) + WHERE pk > 0 + ORDER BY pk + `.execute(db) + return res.rows.map((r) => r.name.toLowerCase()) +} + +export async function indexExists( + db: Kysely, + table: string, + indexName: string +): Promise { + const name = indexName.toLowerCase() + const tbl = table.toLowerCase() + if (await isPostgres(db)) { + const res = await sql<{ exists: boolean }>` + SELECT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'public' + AND lower(tablename) = ${tbl} + AND lower(indexname) = ${name} + ) AS exists + `.execute(db) + return res.rows[0]?.exists ?? false + } + const res = await sql<{ name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'index' + AND lower(tbl_name) = ${tbl} + AND lower(name) = ${name} + `.execute(db) + return res.rows.length > 0 +} diff --git a/core/signing-store-sql/src/migrations-test/seeds/001-init.ts b/core/signing-store-sql/src/migrations-test/seeds/001-init.ts new file mode 100644 index 000000000..02b7e328e --- /dev/null +++ b/core/signing-store-sql/src/migrations-test/seeds/001-init.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely, sql } from 'kysely' + +import { DB } from '../../schema.js' + +export async function insertSigningKey( + db: Kysely, + row: { + id: string + userId: string + name?: string + publicKey?: string + privateKey?: string | null + metadata?: string | null + createdAt?: number + updatedAt?: number + } +): Promise { + const created = row.createdAt ?? 123 + const updated = row.updatedAt ?? created + await sql` + INSERT INTO signing_keys ( + id, user_id, name, public_key, private_key, metadata, created_at, updated_at + ) + VALUES ( + ${row.id}, + ${row.userId}, + ${row.name ?? 'key'}, + ${row.publicKey ?? 'pk'}, + ${row.privateKey ?? null}, + ${row.metadata ?? null}, + ${created}, + ${updated} + ) + `.execute(db) +} + +export async function insertSigningTransaction( + db: Kysely, + row: { + id: string + userId: string + hash?: string + signature?: string | null + publicKey?: string + status?: string + metadata?: string | null + createdAt?: number + updatedAt?: number + } +): Promise { + const created = row.createdAt ?? 123 + const updated = row.updatedAt ?? created + await sql` + INSERT INTO signing_transactions ( + id, user_id, hash, signature, public_key, status, metadata, created_at, updated_at + ) + VALUES ( + ${row.id}, + ${row.userId}, + ${row.hash ?? 'hash'}, + ${row.signature ?? null}, + ${row.publicKey ?? 'pk'}, + ${row.status ?? 'pending'}, + ${row.metadata ?? null}, + ${created}, + ${updated} + ) + `.execute(db) +} + +export async function insertSigningDriverConfig( + db: Kysely, + row: { + userId: string + driverId: string + config?: string + } +): Promise { + await sql` + INSERT INTO signing_driver_configs (user_id, driver_id, config) + VALUES (${row.userId}, ${row.driverId}, ${row.config ?? '{}'}) + `.execute(db) +} diff --git a/core/signing-store-sql/src/migrations-test/seeds/002-add-signed-at.ts b/core/signing-store-sql/src/migrations-test/seeds/002-add-signed-at.ts new file mode 100644 index 000000000..9b8b60c0e --- /dev/null +++ b/core/signing-store-sql/src/migrations-test/seeds/002-add-signed-at.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely, sql } from 'kysely' + +import { DB } from '../../schema.js' +export async function insertSigningTransaction002( + db: Kysely, + row: { + id: string + userId: string + hash?: string + signature?: string | null + publicKey?: string + status?: string + metadata?: string | null + createdAt?: number + updatedAt?: number + signedAt?: string | null + } +): Promise { + const created = row.createdAt ?? 123 + const updated = row.updatedAt ?? created + await sql` + INSERT INTO signing_transactions ( + id, user_id, hash, signature, public_key, status, metadata, + created_at, updated_at, signed_at + ) + VALUES ( + ${row.id}, + ${row.userId}, + ${row.hash ?? 'hash'}, + ${row.signature ?? null}, + ${row.publicKey ?? 'pk'}, + ${row.status ?? 'pending'}, + ${row.metadata ?? null}, + ${created}, + ${updated}, + ${row.signedAt ?? null} + ) + `.execute(db) +} diff --git a/core/signing-store-sql/src/migrations-test/seeds/003-alter-date-fields.ts b/core/signing-store-sql/src/migrations-test/seeds/003-alter-date-fields.ts new file mode 100644 index 000000000..2a4535fad --- /dev/null +++ b/core/signing-store-sql/src/migrations-test/seeds/003-alter-date-fields.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely, sql } from 'kysely' + +import { DB } from '../../schema.js' + +export async function insertSigningKey( + db: Kysely, + row: { + id: string + userId: string + name?: string + publicKey?: string + privateKey?: string | null + metadata?: string | null + createdAt?: string + updatedAt?: string + } +): Promise { + const created = row.createdAt ?? '2026-01-01T00:00:00.000Z' + const updated = row.updatedAt ?? created + await sql` + INSERT INTO signing_keys ( + id, user_id, name, public_key, private_key, metadata, created_at, updated_at + ) + VALUES ( + ${row.id}, + ${row.userId}, + ${row.name ?? 'key'}, + ${row.publicKey ?? 'pk'}, + ${row.privateKey ?? null}, + ${row.metadata ?? null}, + ${created}, + ${updated} + ) + `.execute(db) +} + +export async function insertSigningTransaction( + db: Kysely, + row: { + id: string + userId: string + hash?: string + signature?: string | null + publicKey?: string + status?: string + metadata?: string | null + createdAt?: string + updatedAt?: string + signedAt?: string | null + } +): Promise { + const created = row.createdAt ?? '2026-01-01T00:00:00.000Z' + const updated = row.updatedAt ?? created + await sql` + INSERT INTO signing_transactions ( + id, user_id, hash, signature, public_key, status, metadata, + created_at, updated_at, signed_at + ) + VALUES ( + ${row.id}, + ${row.userId}, + ${row.hash ?? 'hash'}, + ${row.signature ?? null}, + ${row.publicKey ?? 'pk'}, + ${row.status ?? 'pending'}, + ${row.metadata ?? null}, + ${created}, + ${updated}, + ${row.signedAt ?? null} + ) + `.execute(db) +} diff --git a/core/signing-store-sql/vitest.config.ts b/core/signing-store-sql/vitest.config.ts index 67370d096..1641280dd 100644 --- a/core/signing-store-sql/vitest.config.ts +++ b/core/signing-store-sql/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ test: { coverage: { include: ['src/**/*.ts'], + exclude: ['src/migrations-test/**'], provider: 'v8', reporter: ['text', 'html', 'lcov'], thresholds: { @@ -22,6 +23,17 @@ export default defineConfig({ name: 'node', environment: 'node', include: ['src/**/*.test.ts'], + exclude: ['src/migrations-test/**'], + }, + }), + defineProject({ + test: { + name: 'migrations', + environment: 'node', + include: ['src/migrations-test/**/*.test.ts'], + globalSetup: ['src/migrations-test/global-setup.ts'], + testTimeout: 120_000, + hookTimeout: 120_000, }, }), ], diff --git a/core/wallet-store-sql/migrations.lock.json b/core/wallet-store-sql/migrations.lock.json new file mode 100644 index 000000000..90b6cfc34 --- /dev/null +++ b/core/wallet-store-sql/migrations.lock.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "migrations": { + "001-init": "512600aeaffd9f6cf8f5fc94409d4ec3566cbd6fd93c673ff5b0cf31678baed3", + "002-add-transaction-timestamps": "379689a1d5054311956042ac634a2fe5d82bceda7a40f736e42c2af5d55fbd99", + "003-add-transaction-origin": "db2df4618e81e17f1f208d99e155ee5e5feae84077d9c28807f4c60189d3e270", + "004-add-session-id": "48b1c9b136f2eb5a4bc218f84bbf2df48ce358b2cd6a140a462c7ef16d0971e6", + "005-add-wallet-disabled-reason": "b236bb61deb7b722230dea97137d540e8b39bc7577b405b661eee6fbfa8cbe03", + "006-change-wallet-primary-key-to-composite": "a0018a67e4dd1ecb58de056d79b581233d0b702e71f14974a9a17e3eedd026cf", + "007-add-unique-primary-per-network": "cd2704d82d7af85524ba96aa45396395daad674f2224bc4325dd8e472b7d0fb2", + "008-add-transaction-external-tx-id": "190a514ed7d5c825c61a004dcb7126e88f9eb6525e30eae4d874300ca3f227c8", + "009-add-wallet-rights-columns": "e967a0db45118b5a736b139b6c484c6f715d2357027633c0c9be4329f3aea6c0", + "010-add-transaction-network-id": "e6dfb3ec57036a8b36d2e55f17c619874f87257d2bedc9791010deb661aadd7f", + "011-add-transaction-id-primary-key": "8cf57d47b2d3e0a3cd1e76fb6dda00d5eeb44741b8eb22e3419f20f9de069c20" + } +} diff --git a/core/wallet-store-sql/package.json b/core/wallet-store-sql/package.json index 8b230239e..cdbc696fe 100644 --- a/core/wallet-store-sql/package.json +++ b/core/wallet-store-sql/package.json @@ -23,7 +23,10 @@ "flatpack": "yarn pack --out \"$FLATPACK_OUTDIR\"", "clean": "tsc -b --clean; rm -rf dist", "test": "vitest run --project node", - "test:coverage": "vitest run --project node --coverage" + "test:coverage": "vitest run --project node --coverage", + "test:migrations": "vitest run --project migrations", + "migrations:check-lock": "tsx ../../scripts/src/check-migration-lock.ts", + "migrations:update-lock": "tsx ../../scripts/src/check-migration-lock.ts --update" }, "dependencies": { "@canton-network/core-ledger-client": "workspace:^", @@ -39,6 +42,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@testcontainers/postgresql": "^11.14.0", "@types/better-sqlite3": "^7.6.13", "@types/pg": "^8.18.0", "@vitest/coverage-v8": "^4.1.2", diff --git a/core/wallet-store-sql/src/migrations-test/README.md b/core/wallet-store-sql/src/migrations-test/README.md new file mode 100644 index 000000000..68fc988da --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/README.md @@ -0,0 +1,25 @@ +# Migration tests for wallet-store-sql + +Runs migrations and tests their effects against all supported SQL dialects + +`/migration-test/data/` +vitest test files that must reflect `/migrations/` filenames with `.test.ts` extension + +`/migration-test/seeds/` +utils for inserting rows satisfying table schema that reflects that state of migrations being applied up to the migration with related filename + +`/migration-test/helpers.ts` +utils for validating schema + +`/migration-test/coverage.test.ts` +test that validates that every migration has a test. if not - fail the tests. intention is to assure that added migrations are validated against all supported SQL dialects + +`/migration-test/global-setup.ts` +spins up postgres with testcontainers + +`/migrations.lock.json` +contains hashes of all migration files. intention is to prevent accidental modifications of existing migrations. + +`yarn migrations:check-lock` recomputes hashes from migration files and compares them against lock file + +`yarn migrations:update-lock` recomputes hashes and updates lock file. use it after adding a new migration, or in case of intentionally editing old migration (i.e. when adding a comment), altering logic in old migrations should be avoided diff --git a/core/wallet-store-sql/src/migrations-test/coverage.test.ts b/core/wallet-store-sql/src/migrations-test/coverage.test.ts new file mode 100644 index 000000000..e89b97718 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/coverage.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' +import { describe, expect, test } from 'vitest' + +const here = dirname(fileURLToPath(import.meta.url)) +const MIGRATIONS_DIR = resolve(here, '../migrations') +const TESTS_DIR = resolve(here, 'data') + +const migrationNames = (): string[] => + readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith('.ts')) + .map((f) => f.replace(/\.ts$/, '')) + +const testNames = (): string[] => + readdirSync(TESTS_DIR) + .filter((f) => f.endsWith('.test.ts')) + .map((f) => f.replace(/\.test\.ts$/, '')) + +describe('migration test coverage', () => { + const migrations = new Set(migrationNames()) + const tests = new Set(testNames()) + + test('every migration has a sibling test file with the same name', () => { + const missing = [...migrations] + .filter((name) => !tests.has(name)) + .map((name) => `expected src/migrations-test/data/${name}.test.ts`) + expect( + missing, + `Migrations without corresponding tests:\n ${missing.join('\n ')}` + ).toEqual([]) + }) + + test('every migration test file matches an existing migration', () => { + const orphans = [...tests] + .filter((name) => !migrations.has(name)) + .map((name) => `no migration src/migrations/${name}.ts found`) + expect( + orphans, + `Test files without a backing migration:\n ${orphans.join('\n ')}` + ).toEqual([]) + }) +}) diff --git a/core/wallet-store-sql/src/migrations-test/data/001-init.test.ts b/core/wallet-store-sql/src/migrations-test/data/001-init.test.ts new file mode 100644 index 000000000..2a94ef507 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/data/001-init.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' +import { sql } from 'kysely' + +import { + forEachDialect, + migrateUpThrough, + columnNames, + primaryKeyColumns, + tableExists, +} from '../helpers.js' +import { insertIdp, insertNetwork, insertWallet } from '../seeds/001-init' + +const TARGET = 1 + +forEachDialect('migration 001 - init schema', ({ getDb }) => { + test('creates all base tables with the expected columns and primary keys', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + for (const table of [ + 'idps', + 'networks', + 'wallets', + 'transactions', + 'sessions', + ]) { + expect(await tableExists(db, table)).toBe(true) + } + + expect(await columnNames(db, 'idps')).toEqual( + ['id', 'type', 'issuer', 'config_url'].sort() + ) + expect(await columnNames(db, 'networks')).toEqual( + [ + 'id', + 'name', + 'synchronizer_id', + 'description', + 'ledger_api_base_url', + 'user_id', + 'identity_provider_id', + 'auth', + 'admin_auth', + ].sort() + ) + expect(await columnNames(db, 'wallets')).toEqual( + [ + 'party_id', + 'primary', + 'hint', + 'public_key', + 'namespace', + 'user_id', + 'network_id', + 'signing_provider_id', + 'status', + 'external_tx_id', + 'topology_transactions', + ].sort() + ) + expect(await columnNames(db, 'transactions')).toEqual( + [ + 'command_id', + 'status', + 'prepared_transaction', + 'prepared_transaction_hash', + 'payload', + 'user_id', + ].sort() + ) + expect(await columnNames(db, 'sessions')).toEqual( + ['network', 'access_token', 'user_id'].sort() + ) + + expect(await primaryKeyColumns(db, 'idps')).toEqual(['id']) + expect(await primaryKeyColumns(db, 'networks')).toEqual(['id']) + expect(await primaryKeyColumns(db, 'wallets')).toEqual(['party_id']) + expect(await primaryKeyColumns(db, 'transactions')).toEqual([ + 'command_id', + ]) + }) + + test('deleting a network cascades to its wallets', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertNetwork(db, { id: 'net2', idpId: 'idp1' }) + await insertWallet(db, { + partyId: 'party-net1', + userId: 'user1', + networkId: 'net1', + }) + await insertWallet(db, { + partyId: 'party-net2', + userId: 'user1', + networkId: 'net2', + }) + + await sql`DELETE FROM networks WHERE id = 'net1'`.execute(db) + + const wallets = await sql<{ partyId: string; networkId: string }>` + SELECT party_id, network_id FROM wallets + `.execute(db) + expect(wallets.rows).toEqual([ + { partyId: 'party-net2', networkId: 'net2' }, + ]) + }) + + test('deleting an idp cascades through networks to wallets', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertIdp(db, { id: 'idp2' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertNetwork(db, { id: 'net2', idpId: 'idp2' }) + await insertWallet(db, { + partyId: 'party-1', + userId: 'user1', + networkId: 'net1', + }) + await insertWallet(db, { + partyId: 'party-2', + userId: 'user1', + networkId: 'net2', + }) + + await sql`DELETE FROM idps WHERE id = 'idp1'`.execute(db) + + const networks = await sql<{ id: string }>` + SELECT id FROM networks + `.execute(db) + expect(networks.rows).toEqual([{ id: 'net2' }]) + + const wallets = await sql<{ partyId: string; networkId: string }>` + SELECT party_id, network_id FROM wallets + `.execute(db) + expect(wallets.rows).toEqual([ + { partyId: 'party-2', networkId: 'net2' }, + ]) + }) +}) diff --git a/core/wallet-store-sql/src/migrations-test/data/002-add-transaction-timestamps.test.ts b/core/wallet-store-sql/src/migrations-test/data/002-add-transaction-timestamps.test.ts new file mode 100644 index 000000000..f2300b311 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/data/002-add-transaction-timestamps.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' +import { sql } from 'kysely' + +import { + forEachDialect, + migrateDownThrough, + migrateUpThrough, + migrateUpToBefore, + hasColumn, + listColumns, +} from '../helpers' +import { + insertIdp, + insertNetwork, + insertTransaction as insertTransaction001, +} from '../seeds/001-init' +import { insertTransaction as insertTransaction002 } from '../seeds/002-transaction-timestamps' + +const TARGET = 2 + +forEachDialect('migration 002 - add transaction timestamps', ({ getDb }) => { + test('adds nullable created_at and signed_at columns and preserves existing rows', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertTransaction001(db, { + commandId: 'cmd-001', + userId: 'user1', + }) + + await migrateUpThrough(db, TARGET) + + const cols = await listColumns(db, 'transactions') + const byName = new Map(cols.map((c) => [c.name, c])) + expect(byName.get('created_at')?.nullable).toBe(true) + expect(byName.get('signed_at')?.nullable).toBe(true) + + const rows = await sql` + SELECT command_id, created_at, signed_at FROM transactions + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + commandId: 'cmd-001', + createdAt: null, + signedAt: null, + }) + }) + + test('down removes the timestamp columns and preserves existing rows', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertTransaction002(db, { + commandId: 'cmd-002', + userId: 'user1', + createdAt: new Date('2026-01-01').toISOString(), + signedAt: new Date('2026-01-02').toISOString(), + }) + + await migrateDownThrough(db, TARGET) + + expect(await hasColumn(db, 'transactions', 'created_at')).toBe(false) + expect(await hasColumn(db, 'transactions', 'signed_at')).toBe(false) + + const rows = await sql<{ commandId: string }>` + SELECT * FROM transactions + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + commandId: 'cmd-002', + userId: 'user1', + }) + expect(rows.rows[0]).not.toHaveProperty('createdAt') + expect(rows.rows[0]).not.toHaveProperty('signedAt') + }) +}) diff --git a/core/wallet-store-sql/src/migrations-test/data/003-add-transaction-origin.test.ts b/core/wallet-store-sql/src/migrations-test/data/003-add-transaction-origin.test.ts new file mode 100644 index 000000000..12869eeeb --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/data/003-add-transaction-origin.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' +import { sql } from 'kysely' + +import { + forEachDialect, + migrateDownThrough, + migrateUpThrough, + migrateUpToBefore, + hasColumn, + listColumns, +} from '../helpers' +import { insertIdp, insertNetwork } from '../seeds/001-init' +import { insertTransaction as insertTransaction002 } from '../seeds/002-transaction-timestamps' +import { insertTransaction as insertTransaction003 } from '../seeds/003-transaction-origin' + +const TARGET = 3 + +forEachDialect('migration 003 - add transaction origin', ({ getDb }) => { + test('adds nullable origin column and preserves existing rows', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertTransaction002(db, { + commandId: 'cmd-001', + userId: 'user1', + }) + + await migrateUpThrough(db, TARGET) + + const cols = await listColumns(db, 'transactions') + const byName = new Map(cols.map((c) => [c.name, c])) + expect(byName.get('origin')?.nullable).toBe(true) + + const rows = await sql` + SELECT command_id, origin FROM transactions + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + commandId: 'cmd-001', + origin: null, + }) + }) + + test('down removes the origin column and preserves existing rows', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertTransaction003(db, { + commandId: 'cmd-002', + userId: 'user1', + origin: 'http://localhost:8080', + }) + + await migrateDownThrough(db, TARGET) + + expect(await hasColumn(db, 'transactions', 'origin')).toBe(false) + + const rows = await sql` + SELECT * FROM transactions + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + commandId: 'cmd-002', + userId: 'user1', + }) + expect(rows.rows[0]).not.toHaveProperty('origin') + }) +}) diff --git a/core/wallet-store-sql/src/migrations-test/data/004-add-session-id.test.ts b/core/wallet-store-sql/src/migrations-test/data/004-add-session-id.test.ts new file mode 100644 index 000000000..1539eda24 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/data/004-add-session-id.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' +import { sql } from 'kysely' + +import { + forEachDialect, + migrateDownThrough, + migrateUpThrough, + migrateUpToBefore, + hasColumn, + listColumns, +} from '../helpers' +import { insertSession as insertSession001 } from '../seeds/001-init' +import { insertSession as insertSession004 } from '../seeds/004-add-session-id' + +const TARGET = 4 + +forEachDialect('migration 004 - add session id', ({ getDb }) => { + test('adds nullable id column and preserves existing rows', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertSession001(db, { + network: 'net1', + accessToken: 'token-1', + userId: 'user1', + }) + + await migrateUpThrough(db, TARGET) + + const cols = await listColumns(db, 'sessions') + const byName = new Map(cols.map((c) => [c.name, c])) + expect(byName.get('id')?.nullable).toBe(true) + + const rows = await sql` + SELECT id, network, access_token, user_id FROM sessions + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + id: null, + network: 'net1', + accessToken: 'token-1', + userId: 'user1', + }) + }) + + test('down removes the id column and preserves existing rows', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + await insertSession004(db, { + id: 'session-xyz', + network: 'net1', + accessToken: 'token-2', + userId: 'user1', + }) + + await migrateDownThrough(db, TARGET) + + expect(await hasColumn(db, 'sessions', 'id')).toBe(false) + + const rows = await sql` + SELECT * FROM sessions + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + network: 'net1', + accessToken: 'token-2', + userId: 'user1', + }) + expect(rows.rows[0]).not.toHaveProperty('id') + }) +}) diff --git a/core/wallet-store-sql/src/migrations-test/data/005-add-wallet-disabled-reason.test.ts b/core/wallet-store-sql/src/migrations-test/data/005-add-wallet-disabled-reason.test.ts new file mode 100644 index 000000000..da9a240f3 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/data/005-add-wallet-disabled-reason.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' +import { sql } from 'kysely' + +import { + forEachDialect, + migrateDownThrough, + migrateUpThrough, + migrateUpToBefore, + hasColumn, + listColumns, +} from '../helpers' +import { + insertIdp, + insertNetwork, + insertWallet as insertWallet001, +} from '../seeds/001-init' +import { insertWallet as insertWallet005 } from '../seeds/005-add-wallet-disabled-reason' + +const TARGET = 5 + +forEachDialect('migration 005 - add wallet disabled / reason', ({ getDb }) => { + test('adds disabled (NOT NULL) and reason (nullable) columns and preserves existing rows', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertWallet001(db, { + partyId: 'party-1', + userId: 'user1', + networkId: 'net1', + }) + + await migrateUpThrough(db, TARGET) + + const cols = await listColumns(db, 'wallets') + const byName = new Map(cols.map((c) => [c.name, c])) + expect(byName.get('disabled')?.nullable).toBe(false) + expect(byName.get('reason')?.nullable).toBe(true) + + const rows = await sql` + SELECT party_id, disabled, reason FROM wallets + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + partyId: 'party-1', + disabled: 0, + reason: null, + }) + }) + + test('down removes the disabled and reason columns and preserves existing rows', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertWallet005(db, { + partyId: 'party-2', + userId: 'user1', + networkId: 'net1', + disabled: 1, + reason: 'disabled_reason', + }) + + await migrateDownThrough(db, TARGET) + + expect(await hasColumn(db, 'wallets', 'disabled')).toBe(false) + expect(await hasColumn(db, 'wallets', 'reason')).toBe(false) + + const rows = await sql` + SELECT * FROM wallets + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + partyId: 'party-2', + userId: 'user1', + networkId: 'net1', + }) + expect(rows.rows[0]).not.toHaveProperty('disabled') + expect(rows.rows[0]).not.toHaveProperty('reason') + }) +}) diff --git a/core/wallet-store-sql/src/migrations-test/data/006-change-wallet-primary-key-to-composite.test.ts b/core/wallet-store-sql/src/migrations-test/data/006-change-wallet-primary-key-to-composite.test.ts new file mode 100644 index 000000000..5dbb256af --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/data/006-change-wallet-primary-key-to-composite.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' +import { sql } from 'kysely' + +import { + forEachDialect, + migrateDownThrough, + migrateUpThrough, + migrateUpToBefore, + listColumns, + primaryKeyColumns, +} from '../helpers.js' +import { insertIdp, insertNetwork } from '../seeds/001-init' +import { insertWallet as insertWallet005 } from '../seeds/005-add-wallet-disabled-reason' + +const TARGET = 6 + +forEachDialect('migration 006 - composite wallet primary key', ({ getDb }) => { + test('changes wallets PK to (party_id, network_id, user_id), enforces network_id NOT NULL, preserves rows', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertWallet005(db, { + partyId: 'party-1', + userId: 'user1', + networkId: 'net1', + disabled: 1, + reason: 'pre-existing', + }) + + expect(await primaryKeyColumns(db, 'wallets')).toEqual(['party_id']) + + await migrateUpThrough(db, TARGET) + + expect(await primaryKeyColumns(db, 'wallets')).toEqual([ + 'party_id', + 'network_id', + 'user_id', + ]) + + const cols = await listColumns(db, 'wallets') + const byName = new Map(cols.map((c) => [c.name, c])) + expect(byName.get('network_id')?.nullable).toBe(false) + + const rows = await sql<{ + partyId: string + userId: string + networkId: string + disabled: number + reason: string | null + }>` + SELECT party_id, user_id, network_id, disabled, reason + FROM wallets + `.execute(db) + expect(rows.rows).toEqual([ + { + partyId: 'party-1', + userId: 'user1', + networkId: 'net1', + disabled: 1, + reason: 'pre-existing', + }, + ]) + }) + + test('after up, the same party_id can exist on a different network', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertNetwork(db, { id: 'net2', idpId: 'idp1' }) + + await insertWallet005(db, { + partyId: 'party-shared', + userId: 'user1', + networkId: 'net1', + }) + await expect( + insertWallet005(db, { + partyId: 'party-shared', + userId: 'user1', + networkId: 'net2', + }) + ).resolves.toBeUndefined() + + await expect( + insertWallet005(db, { + partyId: 'party-shared', + userId: 'user1', + networkId: 'net1', + }) + ).rejects.toThrow() + }) + + test('down reverts PK to party_id, restores network_id nullability, deduplicates rows by party_id keeping the first inserted', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertNetwork(db, { id: 'net2', idpId: 'idp1' }) + + await insertWallet005(db, { + partyId: 'party-shared', + userId: 'user1', + networkId: 'net1', + hint: 'first', + }) + await insertWallet005(db, { + partyId: 'party-shared', + userId: 'user1', + networkId: 'net2', + hint: 'second', + }) + await insertWallet005(db, { + partyId: 'party-unique', + userId: 'user1', + networkId: 'net1', + hint: 'lonely', + }) + + await migrateDownThrough(db, TARGET) + + expect(await primaryKeyColumns(db, 'wallets')).toEqual(['party_id']) + + const cols = await listColumns(db, 'wallets') + const byName = new Map(cols.map((c) => [c.name, c])) + expect(byName.get('network_id')?.nullable).toBe(true) + + const rows = await sql<{ partyId: string; hint: string }>` + SELECT party_id, hint FROM wallets ORDER BY party_id + `.execute(db) + expect(rows.rows).toEqual([ + { partyId: 'party-shared', hint: 'first' }, + { partyId: 'party-unique', hint: 'lonely' }, + ]) + }) +}) diff --git a/core/wallet-store-sql/src/migrations-test/data/007-add-unique-primary-per-network.test.ts b/core/wallet-store-sql/src/migrations-test/data/007-add-unique-primary-per-network.test.ts new file mode 100644 index 000000000..5eb5f9b84 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/data/007-add-unique-primary-per-network.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' +import { sql } from 'kysely' + +import { + forEachDialect, + migrateDownThrough, + migrateUpThrough, + migrateUpToBefore, + indexExists, +} from '../helpers' +import { insertIdp, insertNetwork } from '../seeds/001-init' +import { insertWallet } from '../seeds/005-add-wallet-disabled-reason' + +const TARGET = 7 +const INDEX_NAME = 'wallets_one_primary_per_network_user' + +forEachDialect( + 'migration 007 - unique primary per network per user', + ({ getDb }) => { + test('up clears duplicate primaries for the same network and user, then creates partial unique index', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + + await insertWallet(db, { + partyId: 'party1:1', + userId: 'user1', + networkId: 'net1', + primary: true, + hint: 'party1', + }) + await insertWallet(db, { + partyId: 'party2:1', + userId: 'user1', + networkId: 'net1', + primary: true, + hint: 'party2', + }) + + expect(await indexExists(db, 'wallets', INDEX_NAME)).toBe(false) + + await migrateUpThrough(db, TARGET) + + expect(await indexExists(db, 'wallets', INDEX_NAME)).toBe(true) + + const rows = await sql<{ + partyId: string + primary: boolean | number + }>` + SELECT party_id, "primary" FROM wallets ORDER BY party_id + `.execute(db) + expect(rows.rows).toHaveLength(2) + + const primaryCount = rows.rows.filter( + (r) => r.primary === true || r.primary === 1 + ).length + expect(primaryCount).toBe(1) + + const kept = rows.rows.find( + (r) => r.primary === true || r.primary === 1 + ) + expect(kept?.partyId).toBe('party1:1') + }) + + test('up forbids a second primary wallet for the same network and user', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertWallet(db, { + partyId: 'party1:1', + userId: 'user1', + networkId: 'net1', + hint: 'party1', + primary: true, + }) + + await migrateUpThrough(db, TARGET) + + await expect( + insertWallet(db, { + partyId: 'party2:1', + userId: 'user1', + networkId: 'net1', + hint: 'party2', + primary: true, + }) + ).rejects.toThrow() + }) + + test('down drops the index so two primaries for the same network and user are allowed again', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertWallet(db, { + partyId: 'party1:1', + userId: 'user1', + networkId: 'net1', + hint: 'party1', + primary: true, + }) + + expect(await indexExists(db, 'wallets', INDEX_NAME)).toBe(true) + + await migrateDownThrough(db, TARGET) + + expect(await indexExists(db, 'wallets', INDEX_NAME)).toBe(false) + + await insertWallet(db, { + partyId: 'party2:1', + userId: 'user1', + networkId: 'net1', + hint: 'party2', + primary: true, + }) + + const primaries = await sql<{ + partyId: string + primary: boolean | number + }>` + SELECT party_id, "primary" FROM wallets ORDER BY party_id + `.execute(db) + expect(primaries.rows).toHaveLength(2) + expect( + primaries.rows.every( + (r) => r.primary === true || r.primary === 1 + ) + ).toBe(true) + }) + } +) diff --git a/core/wallet-store-sql/src/migrations-test/data/008-add-transaction-external-tx-id.test.ts b/core/wallet-store-sql/src/migrations-test/data/008-add-transaction-external-tx-id.test.ts new file mode 100644 index 000000000..8e6a31caa --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/data/008-add-transaction-external-tx-id.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' +import { sql } from 'kysely' + +import { + forEachDialect, + migrateDownThrough, + migrateUpThrough, + migrateUpToBefore, + hasColumn, + listColumns, +} from '../helpers' +import { insertIdp, insertNetwork } from '../seeds/001-init' +import { insertTransaction as insertTransaction003 } from '../seeds/003-transaction-origin' +import { insertTransaction as insertTransaction008 } from '../seeds/008-transaction-external-tx-id' + +const TARGET = 8 + +forEachDialect('migration 008 - transaction external_tx_id', ({ getDb }) => { + test('adds nullable external_tx_id and preserves existing rows', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertTransaction003(db, { + commandId: 'cmd-001', + userId: 'user1', + origin: 'ledger', + }) + + await migrateUpThrough(db, TARGET) + + const cols = await listColumns(db, 'transactions') + const byName = new Map(cols.map((c) => [c.name, c])) + expect(byName.get('external_tx_id')?.nullable).toBe(true) + + const rows = await sql` + SELECT command_id, external_tx_id FROM transactions + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + commandId: 'cmd-001', + externalTxId: null, + }) + }) + + test('down removes external_tx_id and preserves existing rows', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertTransaction008(db, { + commandId: 'cmd-002', + userId: 'user1', + externalTxId: 'ext-123', + }) + + await migrateDownThrough(db, TARGET) + + expect(await hasColumn(db, 'transactions', 'external_tx_id')).toBe( + false + ) + + const rows = await sql` + SELECT * FROM transactions + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + commandId: 'cmd-002', + userId: 'user1', + }) + expect(rows.rows[0]).not.toHaveProperty('externalTxId') + }) +}) diff --git a/core/wallet-store-sql/src/migrations-test/data/009-add-wallet-rights-columns.test.ts b/core/wallet-store-sql/src/migrations-test/data/009-add-wallet-rights-columns.test.ts new file mode 100644 index 000000000..ab29b910f --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/data/009-add-wallet-rights-columns.test.ts @@ -0,0 +1,258 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PartyLevelRight, + UserLevelRight, +} from '@canton-network/core-wallet-store' +import { expect, test } from 'vitest' +import { Kysely, sql } from 'kysely' + +import { DB } from '../../schema' + +import { + columnNames, + forEachDialect, + migrateDownThrough, + migrateUpThrough, + migrateUpToBefore, + primaryKeyColumns, + tableExists, +} from '../helpers' +import { insertIdp, insertNetwork } from '../seeds/001-init' +import { insertWallet } from '../seeds/005-add-wallet-disabled-reason' +import { + insertUserPartyRight, + insertUserRight, +} from '../seeds/009-add-wallet-rights-columns' + +const TARGET = 9 + +async function baseStoresThrough008(db: Kysely): Promise { + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertWallet(db, { + partyId: 'party1:1', + userId: 'user1', + networkId: 'net1', + primary: true, + }) +} + +forEachDialect('migration 009 - rights tables', ({ getDb }) => { + test('up creates user_party_rights and user_rights with expected primary keys', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + await baseStoresThrough008(db) + + await migrateUpThrough(db, TARGET) + + expect(await tableExists(db, 'user_party_rights')).toBe(true) + expect(await tableExists(db, 'user_rights')).toBe(true) + + expect(await primaryKeyColumns(db, 'user_party_rights')).toEqual([ + 'user_id', + 'network_id', + 'party_id', + 'right', + ]) + expect(await primaryKeyColumns(db, 'user_rights')).toEqual([ + 'user_id', + 'network_id', + 'right', + ]) + + expect(await columnNames(db, 'user_party_rights')).toEqual( + ['network_id', 'party_id', 'right', 'user_id'].sort() + ) + expect(await columnNames(db, 'user_rights')).toEqual( + ['network_id', 'right', 'user_id'].sort() + ) + }) + + test('user_party_rights: insert succeeds when a matching wallet row exists', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + await baseStoresThrough008(db) + await migrateUpThrough(db, TARGET) + + await expect( + insertUserPartyRight(db, { + userId: 'user1', + networkId: 'net1', + partyId: 'party1:1', + right: PartyLevelRight.CanReadAs, + }) + ).resolves.toBeUndefined() + }) + + test('user_party_rights: composite FK rejects rows with no matching wallet (wrong party_id)', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + await baseStoresThrough008(db) + await migrateUpThrough(db, TARGET) + + await expect( + insertUserPartyRight(db, { + userId: 'user1', + networkId: 'net1', + partyId: 'party-unknown', + right: PartyLevelRight.CanActAs, + }) + ).rejects.toThrow() + }) + + test('user_party_rights: composite FK rejects rows when user_id does not match the wallet triple', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + await baseStoresThrough008(db) + await migrateUpThrough(db, TARGET) + + await expect( + insertUserPartyRight(db, { + userId: 'user2', + networkId: 'net1', + partyId: 'party1:1', + right: PartyLevelRight.CanActAs, + }) + ).rejects.toThrow() + }) + + test('user_party_rights: composite FK rejects rows when network_id does not match the wallet triple', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertNetwork(db, { id: 'net2', idpId: 'idp1' }) + await insertWallet(db, { + partyId: 'party1:1', + userId: 'user1', + networkId: 'net1', + primary: true, + }) + await migrateUpThrough(db, TARGET) + + await expect( + insertUserPartyRight(db, { + userId: 'user1', + networkId: 'net2', + partyId: 'party1:1', + right: PartyLevelRight.CanReadAs, + }) + ).rejects.toThrow() + }) + + test('user_party_rights: duplicate primary key is rejected', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + await baseStoresThrough008(db) + await migrateUpThrough(db, TARGET) + + await insertUserPartyRight(db, { + userId: 'user1', + networkId: 'net1', + partyId: 'party1:1', + right: PartyLevelRight.CanReadAs, + }) + + await expect( + insertUserPartyRight(db, { + userId: 'user1', + networkId: 'net1', + partyId: 'party1:1', + right: PartyLevelRight.CanReadAs, + }) + ).rejects.toThrow() + }) + + test('user_party_rights: deleting the wallet cascades away dependent rights rows', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + await baseStoresThrough008(db) + await migrateUpThrough(db, TARGET) + + await insertUserPartyRight(db, { + userId: 'user1', + networkId: 'net1', + partyId: 'party1:1', + right: PartyLevelRight.CanExecuteAs, + }) + + await sql` + DELETE FROM wallets + WHERE party_id = 'party1:1' + AND network_id = 'net1' + AND user_id = 'user1' + `.execute(db) + + const rows = await sql<{ n: string | number }>` + SELECT COUNT(*) AS n FROM user_party_rights + `.execute(db) + expect(Number(rows.rows[0]?.n)).toBe(0) + }) + + test('user_rights: insert succeeds when the network exists', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + await baseStoresThrough008(db) + await migrateUpThrough(db, TARGET) + + await expect( + insertUserRight(db, { + userId: 'user1', + networkId: 'net1', + right: UserLevelRight.CanReadAsAnyParty, + }) + ).resolves.toBeUndefined() + }) + + test('user_rights: FK rejects inserts for an unknown network_id', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + await baseStoresThrough008(db) + await migrateUpThrough(db, TARGET) + + await expect( + insertUserRight(db, { + userId: 'user1', + networkId: 'net-missing', + right: UserLevelRight.CanExecuteAsAnyParty, + }) + ).rejects.toThrow() + }) + + test('user_rights: deleting a network cascades away user_rights for that network', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertNetwork(db, { id: 'net2', idpId: 'idp1' }) + await migrateUpThrough(db, TARGET) + + await insertUserRight(db, { + userId: 'user1', + networkId: 'net2', + right: UserLevelRight.CanReadAsAnyParty, + }) + + await sql`DELETE FROM networks WHERE id = 'net2'`.execute(db) + + const rows = await sql<{ n: string | number }>` + SELECT COUNT(*) AS n FROM user_rights WHERE network_id = 'net2' + `.execute(db) + expect(Number(rows.rows[0]?.n)).toBe(0) + }) + + test('down drops both rights tables', async () => { + const db = getDb() + await migrateUpThrough(db, TARGET) + + expect(await tableExists(db, 'user_party_rights')).toBe(true) + expect(await tableExists(db, 'user_rights')).toBe(true) + + await migrateDownThrough(db, TARGET) + + expect(await tableExists(db, 'user_party_rights')).toBe(false) + expect(await tableExists(db, 'user_rights')).toBe(false) + }) +}) diff --git a/core/wallet-store-sql/src/migrations-test/data/010-add-transaction-network-id.test.ts b/core/wallet-store-sql/src/migrations-test/data/010-add-transaction-network-id.test.ts new file mode 100644 index 000000000..f0061ad3a --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/data/010-add-transaction-network-id.test.ts @@ -0,0 +1,236 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' +import { Kysely, sql } from 'kysely' + +import { + forEachDialect, + migrateDownThrough, + migrateUpThrough, + migrateUpToBefore, + hasColumn, + listColumns, + primaryKeyColumns, +} from '../helpers' +import { DB } from '../../schema' +import { insertIdp, insertNetwork } from '../seeds/001-init' +import { insertTransaction as insertTransaction003 } from '../seeds/003-transaction-origin' +import { insertTransaction as insertTransaction008 } from '../seeds/008-transaction-external-tx-id' + +const TARGET = 10 + +async function insertOwnedNetwork( + db: Kysely, + networkId: string, + userId: string +): Promise { + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { + id: networkId, + idpId: 'idp1', + userId, + }) +} + +forEachDialect('migration 010 - transaction network_id', ({ getDb }) => { + test('adds NOT NULL network_id and preserves other transaction columns after backfill', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertOwnedNetwork(db, 'net1', 'user1') + await insertTransaction008(db, { + commandId: 'cmd-schema', + userId: 'user1', + origin: 'ledger', + externalTxId: null, + }) + + await migrateUpThrough(db, TARGET) + + expect(await hasColumn(db, 'transactions', 'network_id')).toBe(true) + const cols = await listColumns(db, 'transactions') + const byName = new Map(cols.map((c) => [c.name, c])) + expect(byName.get('network_id')?.nullable).toBe(false) + + expect(await primaryKeyColumns(db, 'transactions')).toEqual([ + 'command_id', + ]) + + const rows = await sql` + SELECT command_id, user_id, network_id, origin, external_tx_id FROM transactions + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + commandId: 'cmd-schema', + userId: 'user1', + networkId: 'net1', + origin: 'ledger', + externalTxId: null, + }) + }) + + test('deleting a network cascades away transactions that reference it', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertOwnedNetwork(db, 'net-cascade', 'user1') + await insertTransaction003(db, { + commandId: 'cmd-fk', + userId: 'user1', + }) + + await migrateUpThrough(db, TARGET) + + await sql`DELETE FROM networks WHERE id = 'net-cascade'`.execute(db) + + const rows = await sql<{ n: string | number }>` + SELECT COUNT(*) AS n FROM transactions + `.execute(db) + expect(Number(rows.rows[0]?.n)).toBe(0) + }) + + test('backfills network_id when the transaction owner has exactly one user-owned network', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertOwnedNetwork(db, 'net1', 'user1') + await insertTransaction003(db, { + commandId: 'cmd-single', + userId: 'user1', + origin: 'app', + }) + + await migrateUpThrough(db, TARGET) + + const cols = await listColumns(db, 'transactions') + expect(cols.find((c) => c.name === 'network_id')?.nullable).toBe(false) + + const rows = await sql` + SELECT command_id, network_id, user_id FROM transactions + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + commandId: 'cmd-single', + networkId: 'net1', + userId: 'user1', + }) + }) + + test('removes transactions when the owner has no user-owned network (only global networks)', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { + id: 'net-global', + idpId: 'idp1', + userId: null, + }) + await insertTransaction003(db, { + commandId: 'cmd-orphan', + userId: 'user1', + }) + + await migrateUpThrough(db, TARGET) + + const rows = await sql<{ n: string | number }>` + SELECT COUNT(*) AS n FROM transactions + `.execute(db) + expect(Number(rows.rows[0]?.n)).toBe(0) + }) + + test('removes transactions when the owner has more than one user-owned network', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { + id: 'net-a', + idpId: 'idp1', + userId: 'user1', + }) + await insertNetwork(db, { + id: 'net-b', + idpId: 'idp1', + userId: 'user1', + }) + await insertTransaction003(db, { + commandId: 'cmd-ambiguous', + userId: 'user1', + }) + + await migrateUpThrough(db, TARGET) + + const rows = await sql<{ n: string | number }>` + SELECT COUNT(*) AS n FROM transactions + `.execute(db) + expect(Number(rows.rows[0]?.n)).toBe(0) + }) + + test('backfills each user transaction from that user single owned network', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { + id: 'net-u1', + idpId: 'idp1', + userId: 'user1', + }) + await insertNetwork(db, { + id: 'net-u2', + idpId: 'idp1', + userId: 'user2', + }) + await insertTransaction003(db, { + commandId: 'cmd-u1', + userId: 'user1', + }) + await insertTransaction003(db, { + commandId: 'cmd-u2', + userId: 'user2', + }) + + await migrateUpThrough(db, TARGET) + + const rows = await sql<{ commandId: string; networkId: string }>` + SELECT command_id, network_id FROM transactions ORDER BY command_id + `.execute(db) + expect(rows.rows).toEqual([ + { commandId: 'cmd-u1', networkId: 'net-u1' }, + { commandId: 'cmd-u2', networkId: 'net-u2' }, + ]) + }) + + test('down removes network_id and preserves transaction rows', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertOwnedNetwork(db, 'net1', 'user1') + await insertTransaction008(db, { + commandId: 'cmd-down', + userId: 'user1', + externalTxId: 'ext', + }) + + await migrateUpThrough(db, TARGET) + + expect(await hasColumn(db, 'transactions', 'network_id')).toBe(true) + + await migrateDownThrough(db, TARGET) + + expect(await hasColumn(db, 'transactions', 'network_id')).toBe(false) + + const rows = await sql` + SELECT command_id, user_id, external_tx_id FROM transactions + `.execute(db) + expect(rows.rows).toHaveLength(1) + expect(rows.rows[0]).toMatchObject({ + commandId: 'cmd-down', + userId: 'user1', + externalTxId: 'ext', + }) + expect(rows.rows[0]).not.toHaveProperty('networkId') + }) +}) diff --git a/core/wallet-store-sql/src/migrations-test/data/011-add-transaction-id-primary-key.test.ts b/core/wallet-store-sql/src/migrations-test/data/011-add-transaction-id-primary-key.test.ts new file mode 100644 index 000000000..3d19ecb79 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/data/011-add-transaction-id-primary-key.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' +import { sql } from 'kysely' + +import { + forEachDialect, + migrateDownThrough, + migrateUpThrough, + migrateUpToBefore, + hasColumn, + indexExists, + listColumns, + primaryKeyColumns, +} from '../helpers' +import { insertIdp, insertNetwork } from '../seeds/001-init' +import { insertTransaction } from '../seeds/010-transaction-with-network' + +const TARGET = 11 +const COMMAND_NETWORK_IDX = 'transactions_command_user_network_idx' + +forEachDialect('migration 011 - transaction id primary key', ({ getDb }) => { + test('adds non-null id as primary key and command/network/user index', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertTransaction(db, { + commandId: 'cmd-schema', + userId: 'user1', + networkId: 'net1', + origin: 'ledger', + externalTxId: null, + }) + + await migrateUpThrough(db, TARGET) + + expect(await primaryKeyColumns(db, 'transactions')).toEqual(['id']) + + const cols = await listColumns(db, 'transactions') + const byName = new Map(cols.map((c) => [c.name, c])) + expect(byName.get('id')?.nullable).toBe(false) + expect(byName.get('command_id')?.nullable).toBe(false) + + expect(await indexExists(db, 'transactions', COMMAND_NETWORK_IDX)).toBe( + true + ) + }) + + test('assigns each row a new id and preserves other columns', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertTransaction(db, { + commandId: 'cmd-preserve', + userId: 'user1', + networkId: 'net1', + origin: 'app', + externalTxId: 'ext', + }) + + await migrateUpThrough(db, TARGET) + + const rows = await sql<{ + id: string + commandId: string + userId: string + networkId: string + origin: string | null + externalTxId: string | null + }>` + SELECT id, command_id, user_id, network_id, origin, external_tx_id + FROM transactions + `.execute(db) + + expect(rows.rows).toHaveLength(1) + const row = rows.rows[0]! + expect(row.commandId).toBe('cmd-preserve') + expect(row.userId).toBe('user1') + expect(row.networkId).toBe('net1') + expect(row.origin).toBe('app') + expect(row.externalTxId).toBe('ext') + expect(String(row.id ?? '').trim().length).toBeGreaterThan(0) + }) + + test('deleting a network cascades to transactions after the migration', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net-delete', idpId: 'idp1' }) + await insertTransaction(db, { + commandId: 'cmd-cascade', + userId: 'user1', + networkId: 'net-delete', + externalTxId: null, + }) + + await migrateUpThrough(db, TARGET) + + await sql`DELETE FROM networks WHERE id = 'net-delete'`.execute(db) + + const n = await sql<{ c: string | number }>` + SELECT COUNT(*) AS c FROM transactions + `.execute(db) + expect(Number(n.rows[0]?.c)).toBe(0) + }) + + test('down drops id, restores command_id as primary key, and appends id to command_id', async () => { + const db = getDb() + await migrateUpToBefore(db, TARGET) + + await insertIdp(db, { id: 'idp1' }) + await insertNetwork(db, { id: 'net1', idpId: 'idp1' }) + await insertTransaction(db, { + commandId: 'cmd-down', + userId: 'user1', + networkId: 'net1', + externalTxId: null, + }) + + await migrateUpThrough(db, TARGET) + + const before = await sql<{ id: string; commandId: string }>` + SELECT id, command_id FROM transactions + `.execute(db) + const idVal = before.rows[0]?.id + const cmdVal = before.rows[0]?.commandId + expect(idVal).toBeDefined() + expect(cmdVal).toBe('cmd-down') + + await migrateDownThrough(db, TARGET) + + expect(await hasColumn(db, 'transactions', 'id')).toBe(false) + expect(await primaryKeyColumns(db, 'transactions')).toEqual([ + 'command_id', + ]) + expect(await indexExists(db, 'transactions', COMMAND_NETWORK_IDX)).toBe( + false + ) + + const after = await sql<{ commandId: string }>` + SELECT command_id FROM transactions + `.execute(db) + expect(after.rows[0]?.commandId).toBe(`${cmdVal}:${idVal}`) + }) +}) diff --git a/core/wallet-store-sql/src/migrations-test/global-setup.ts b/core/wallet-store-sql/src/migrations-test/global-setup.ts new file mode 100644 index 000000000..b29f2e064 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/global-setup.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PostgreSqlContainer, + StartedPostgreSqlContainer, +} from '@testcontainers/postgresql' + +let container: StartedPostgreSqlContainer | undefined + +export const PG_ENV = { + HOST: 'MIG_TEST_PG_HOST', + PORT: 'MIG_TEST_PG_PORT', + USER: 'MIG_TEST_PG_USER', + PASSWORD: 'MIG_TEST_PG_PASSWORD', + DATABASE: 'MIG_TEST_PG_DATABASE', +} as const + +export default async function setup() { + container = await new PostgreSqlContainer('postgres:16-alpine').start() + + process.env[PG_ENV.HOST] = container.getHost() + process.env[PG_ENV.PORT] = String(container.getPort()) + process.env[PG_ENV.USER] = container.getUsername() + process.env[PG_ENV.PASSWORD] = container.getPassword() + process.env[PG_ENV.DATABASE] = container.getDatabase() + + return async () => { + await container?.stop({ timeout: 10_000 }) + container = undefined + } +} diff --git a/core/wallet-store-sql/src/migrations-test/helpers.ts b/core/wallet-store-sql/src/migrations-test/helpers.ts new file mode 100644 index 000000000..c61f949f4 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/helpers.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe } from 'vitest' +import { Kysely, sql } from 'kysely' +import pg from 'pg' + +import { connection } from '../store-sql.js' +import { migrator } from '../migrator.js' +import { DB } from '../schema.js' +import { PG_ENV } from './global-setup.js' +import { isPostgres } from '../utils' + +export type Dialect = 'sqlite' | 'postgres' + +export interface DialectContext { + dialect: Dialect + getDb: () => Kysely +} + +export const SUPPORTED_DIALECTS: Dialect[] = ['sqlite', 'postgres'] + +const pgConnection = () => ({ + host: requireEnv(PG_ENV.HOST), + port: Number(requireEnv(PG_ENV.PORT)), + user: requireEnv(PG_ENV.USER), + password: requireEnv(PG_ENV.PASSWORD), + database: requireEnv(PG_ENV.DATABASE), +}) + +function requireEnv(key: string): string { + const v = process.env[key] + if (!v) { + throw new Error(`Postgres test container env var ${key} not set`) + } + return v +} + +const adminPool = (): pg.Pool => new pg.Pool(pgConnection()) + +async function createPgDatabase(name: string): Promise { + const pool = adminPool() + try { + await pool.query(`CREATE DATABASE "${name}"`) + } finally { + await pool.end() + } +} + +async function dropPgDatabase(name: string): Promise { + const pool = adminPool() + try { + await pool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [name] + ) + await pool.query(`DROP DATABASE IF EXISTS "${name}"`) + } finally { + await pool.end() + } +} + +export interface FreshDb { + db: Kysely + dispose: () => Promise +} + +export async function freshDb(dialect: Dialect): Promise { + if (dialect === 'sqlite') { + const db = connection({ connection: { type: 'memory' } }) + return { + db, + dispose: async () => { + await db.destroy() + }, + } + } + + const dbName = `mig_${randomUUID().replace(/-/g, '')}` + await createPgDatabase(dbName) + const conn = pgConnection() + const db = connection({ + connection: { + type: 'postgres', + host: conn.host, + port: conn.port, + user: conn.user, + password: conn.password, + database: dbName, + }, + }) + return { + db, + dispose: async () => { + await db.destroy() + await dropPgDatabase(dbName) + }, + } +} + +let cachedNames: string[] | undefined + +export async function getAllMigrationNames(): Promise { + if (cachedNames) return cachedNames + const db = connection({ connection: { type: 'memory' } }) + try { + const m = migrator(db) + cachedNames = (await m.pending()).map((x) => x.name) + return cachedNames + } finally { + await db.destroy() + } +} + +// Get migration name by 1-based index +export async function migrationName(migrationNumber: number): Promise { + const names = await getAllMigrationNames() + const idx = migrationNumber - 1 + if (idx < 0 || idx >= names.length) { + throw new Error(`Migration index ${migrationNumber} out of bounds`) + } + return names[idx] +} + +export async function migrateUpTo( + db: Kysely, + name?: string +): Promise { + const m = migrator(db) + if (name === undefined) { + await m.up() + } else { + await m.up({ to: name }) + } +} + +export async function migrateUpToBefore( + db: Kysely, + target1Based: number +): Promise { + if (target1Based <= 1) return + await migrateUpTo(db, await migrationName(target1Based - 1)) +} + +export async function migrateUpThrough( + db: Kysely, + target1Based: number +): Promise { + await migrateUpTo(db, await migrationName(target1Based)) +} + +export async function migrateDownTo( + db: Kysely, + name: string | 0 +): Promise { + const m = migrator(db) + if (name === 0) { + await m.down({ to: 0 }) + } else { + await m.down({ to: name }) + } +} + +export async function migrateDownThrough( + db: Kysely, + target1Based: number +): Promise { + if (target1Based <= 1) { + await migrateDownTo(db, 0) + return + } + await migrateDownTo(db, await migrationName(target1Based)) +} + +export function forEachDialect( + title: string, + body: (ctx: DialectContext) => void +): void { + describe.each(SUPPORTED_DIALECTS)(`${title} [%s]`, (dialect) => { + let current: FreshDb | undefined + + beforeEach(async () => { + current = await freshDb(dialect) + }) + + afterEach(async () => { + try { + await current?.dispose() + } finally { + current = undefined + } + }) + + body({ + dialect, + getDb: () => { + if (!current) { + throw new Error('getDb() called outside of a test scope') + } + return current.db + }, + }) + }) +} + +export interface ColumnInfo { + name: string + nullable: boolean +} + +export async function listColumns( + db: Kysely, + table: string +): Promise { + if (await isPostgres(db)) { + const res = await sql<{ columnName: string; isNullable: string }>` + SELECT column_name, is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ${table} + ORDER BY ordinal_position + `.execute(db) + return res.rows.map((r) => ({ + name: r.columnName.toLowerCase(), + nullable: r.isNullable.toUpperCase() === 'YES', + })) + } + const res = await sql<{ name: string; notnull: number }>` + SELECT name, "notnull" FROM pragma_table_info(${table}) + `.execute(db) + return res.rows.map((r) => ({ + name: r.name.toLowerCase(), + nullable: r.notnull === 0, + })) +} + +export async function columnNames( + db: Kysely, + table: string +): Promise { + return (await listColumns(db, table)).map((c) => c.name).sort() +} + +export async function hasColumn( + db: Kysely, + table: string, + column: string +): Promise { + const cols = await columnNames(db, table) + return cols.includes(column.toLowerCase()) +} + +export async function tableExists( + db: Kysely, + table: string +): Promise { + if (await isPostgres(db)) { + const res = await sql<{ exists: boolean }>` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = ${table} + ) AS exists + `.execute(db) + return res.rows[0]?.exists ?? false + } + const res = await sql<{ name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'table' AND name = ${table} + `.execute(db) + return res.rows.length > 0 +} + +export async function primaryKeyColumns( + db: Kysely, + table: string +): Promise { + if (await isPostgres(db)) { + const res = await sql<{ columnName: string; position: number }>` + SELECT a.attname AS column_name, + array_position(c.conkey, a.attnum) AS position + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_attribute a + ON a.attrelid = c.conrelid + AND a.attnum = ANY (c.conkey) + WHERE c.contype = 'p' + AND n.nspname = 'public' + AND t.relname = ${table} + ORDER BY position + `.execute(db) + return res.rows.map((r) => r.columnName.toLowerCase()) + } + const res = await sql<{ name: string; pk: number }>` + SELECT name, pk FROM pragma_table_info(${table}) + WHERE pk > 0 + ORDER BY pk + `.execute(db) + return res.rows.map((r) => r.name.toLowerCase()) +} + +export async function indexExists( + db: Kysely, + table: string, + indexName: string +): Promise { + const name = indexName.toLowerCase() + const tbl = table.toLowerCase() + if (await isPostgres(db)) { + const res = await sql<{ exists: boolean }>` + SELECT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'public' + AND lower(tablename) = ${tbl} + AND lower(indexname) = ${name} + ) AS exists + `.execute(db) + return res.rows[0]?.exists ?? false + } + const res = await sql<{ name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'index' + AND lower(tbl_name) = ${tbl} + AND lower(name) = ${name} + `.execute(db) + return res.rows.length > 0 +} diff --git a/core/wallet-store-sql/src/migrations-test/seeds/001-init.ts b/core/wallet-store-sql/src/migrations-test/seeds/001-init.ts new file mode 100644 index 000000000..0ef7c61de --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/seeds/001-init.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely, sql } from 'kysely' +import { DB } from '../../schema.js' + +export async function insertIdp( + db: Kysely, + row: { + id: string + type?: 'oauth' | 'self_signed' + issuer?: string + configUrl?: string | null + } +): Promise { + await sql` + INSERT INTO idps (id, type, issuer, config_url) + VALUES (${row.id}, ${row.type ?? 'self_signed'}, ${ + row.issuer ?? 'http://issuer.local' + }, ${row.configUrl ?? null}) + `.execute(db) +} + +export async function insertNetwork( + db: Kysely, + row: { + id: string + name?: string + idpId: string + userId?: string | null + ledgerApiBaseUrl?: string + synchronizerId?: string | null + description?: string | null + auth?: string + } +): Promise { + await sql` + INSERT INTO networks ( + id, name, synchronizer_id, description, ledger_api_base_url, + user_id, identity_provider_id, auth + ) + VALUES ( + ${row.id}, + ${row.name ?? row.id}, + ${row.synchronizerId ?? null}, + ${row.description ?? null}, + ${row.ledgerApiBaseUrl ?? 'http://ledger.local'}, + ${row.userId ?? null}, + ${row.idpId}, + ${row.auth ?? '{"method":"self_signed"}'} + ) + `.execute(db) +} + +export async function insertWallet( + db: Kysely, + row: { + partyId: string + primary?: boolean + hint?: string + publicKey?: string + namespace?: string + userId: string + networkId: string + signingProviderId?: string + status?: string | null + } +): Promise { + await sql` + INSERT INTO wallets ( + party_id, "primary", hint, public_key, namespace, user_id, + network_id, signing_provider_id, status + ) + VALUES ( + ${row.partyId}, + ${row.primary ? 1 : 0}, + ${row.hint ?? 'hint'}, + ${row.publicKey ?? 'pk'}, + ${row.namespace ?? 'ns'}, + ${row.userId}, + ${row.networkId}, + ${row.signingProviderId ?? 'internal'}, + ${row.status ?? null} + ) + `.execute(db) +} + +export async function insertTransaction( + db: Kysely, + row: { + commandId: string + status?: string + preparedTransaction?: string + preparedTransactionHash?: string + payload?: string | null + userId: string + } +): Promise { + await sql` + INSERT INTO transactions ( + command_id, status, prepared_transaction, prepared_transaction_hash, + payload, user_id + ) + VALUES ( + ${row.commandId}, + ${row.status ?? 'pending'}, + ${row.preparedTransaction ?? 'prep'}, + ${row.preparedTransactionHash ?? 'hash'}, + ${row.payload ?? null}, + ${row.userId} + ) + `.execute(db) +} + +export async function insertSession( + db: Kysely, + row: { + network: string + accessToken: string + userId: string + } +): Promise { + await sql` + INSERT INTO sessions (network, access_token, user_id) + VALUES (${row.network}, ${row.accessToken}, ${row.userId}) + `.execute(db) +} diff --git a/core/wallet-store-sql/src/migrations-test/seeds/002-transaction-timestamps.ts b/core/wallet-store-sql/src/migrations-test/seeds/002-transaction-timestamps.ts new file mode 100644 index 000000000..277061bc0 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/seeds/002-transaction-timestamps.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { DB } from '../../schema' +import { Kysely, sql } from 'kysely' + +export async function insertTransaction( + db: Kysely, + row: { + commandId: string + status?: string + preparedTransaction?: string + preparedTransactionHash?: string + payload?: string | null + userId: string + createdAt?: string | null + signedAt?: string | null + } +): Promise { + await sql` + INSERT INTO transactions ( + command_id, status, prepared_transaction, prepared_transaction_hash, + payload, user_id, created_at, signed_at + ) + VALUES ( + ${row.commandId}, + ${row.status ?? 'pending'}, + ${row.preparedTransaction ?? 'prep'}, + ${row.preparedTransactionHash ?? 'hash'}, + ${row.payload ?? null}, + ${row.userId}, + ${row.createdAt ?? null}, + ${row.signedAt ?? null} + ) + `.execute(db) +} diff --git a/core/wallet-store-sql/src/migrations-test/seeds/003-transaction-origin.ts b/core/wallet-store-sql/src/migrations-test/seeds/003-transaction-origin.ts new file mode 100644 index 000000000..54530795c --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/seeds/003-transaction-origin.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely, sql } from 'kysely' +import { DB } from '../../schema' + +export async function insertTransaction( + db: Kysely, + row: { + commandId: string + status?: string + preparedTransaction?: string + preparedTransactionHash?: string + payload?: string | null + userId: string + createdAt?: string | null + signedAt?: string | null + origin?: string | null + } +): Promise { + await sql` + INSERT INTO transactions ( + command_id, status, prepared_transaction, prepared_transaction_hash, + payload, user_id, created_at, signed_at, origin + ) + VALUES ( + ${row.commandId}, + ${row.status ?? 'pending'}, + ${row.preparedTransaction ?? 'prep'}, + ${row.preparedTransactionHash ?? 'hash'}, + ${row.payload ?? null}, + ${row.userId}, + ${row.createdAt ?? null}, + ${row.signedAt ?? null}, + ${row.origin ?? null} + ) + `.execute(db) +} diff --git a/core/wallet-store-sql/src/migrations-test/seeds/004-add-session-id.ts b/core/wallet-store-sql/src/migrations-test/seeds/004-add-session-id.ts new file mode 100644 index 000000000..635d910c8 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/seeds/004-add-session-id.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely, sql } from 'kysely' +import { DB } from '../../schema' + +export async function insertSession( + db: Kysely, + row: { + id?: string | null + network: string + accessToken: string + userId: string + } +): Promise { + await sql` + INSERT INTO sessions (id, network, access_token, user_id) + VALUES ( + ${row.id ?? null}, + ${row.network}, + ${row.accessToken}, + ${row.userId} + ) + `.execute(db) +} diff --git a/core/wallet-store-sql/src/migrations-test/seeds/005-add-wallet-disabled-reason.ts b/core/wallet-store-sql/src/migrations-test/seeds/005-add-wallet-disabled-reason.ts new file mode 100644 index 000000000..318df6eda --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/seeds/005-add-wallet-disabled-reason.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely, sql } from 'kysely' +import { DB } from '../../schema' + +export async function insertWallet( + db: Kysely, + row: { + partyId: string + primary?: boolean + hint?: string + publicKey?: string + namespace?: string + userId: string + networkId: string + signingProviderId?: string + status?: string | null + disabled?: number + reason?: string | null + } +): Promise { + await sql` + INSERT INTO wallets ( + party_id, "primary", hint, public_key, namespace, user_id, + network_id, signing_provider_id, status, disabled, reason + ) + VALUES ( + ${row.partyId}, + ${row.primary ? 1 : 0}, + ${row.hint ?? 'hint'}, + ${row.publicKey ?? 'pk'}, + ${row.namespace ?? 'ns'}, + ${row.userId}, + ${row.networkId}, + ${row.signingProviderId ?? 'internal'}, + ${row.status ?? null}, + ${row.disabled ?? 0}, + ${row.reason ?? null} + ) + `.execute(db) +} diff --git a/core/wallet-store-sql/src/migrations-test/seeds/008-transaction-external-tx-id.ts b/core/wallet-store-sql/src/migrations-test/seeds/008-transaction-external-tx-id.ts new file mode 100644 index 000000000..f8463ca02 --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/seeds/008-transaction-external-tx-id.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely, sql } from 'kysely' +import { DB } from '../../schema' + +export async function insertTransaction( + db: Kysely, + row: { + commandId: string + status?: string + preparedTransaction?: string + preparedTransactionHash?: string + payload?: string | null + userId: string + createdAt?: string | null + signedAt?: string | null + origin?: string | null + externalTxId?: string | null + } +): Promise { + await sql` + INSERT INTO transactions ( + command_id, status, prepared_transaction, prepared_transaction_hash, + payload, user_id, created_at, signed_at, origin, external_tx_id + ) + VALUES ( + ${row.commandId}, + ${row.status ?? 'pending'}, + ${row.preparedTransaction ?? 'prep'}, + ${row.preparedTransactionHash ?? 'hash'}, + ${row.payload ?? null}, + ${row.userId}, + ${row.createdAt ?? null}, + ${row.signedAt ?? null}, + ${row.origin ?? null}, + ${row.externalTxId ?? null} + ) + `.execute(db) +} diff --git a/core/wallet-store-sql/src/migrations-test/seeds/009-add-wallet-rights-columns.ts b/core/wallet-store-sql/src/migrations-test/seeds/009-add-wallet-rights-columns.ts new file mode 100644 index 000000000..f83ce29ca --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/seeds/009-add-wallet-rights-columns.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely, sql } from 'kysely' +import { DB } from '../../schema' + +export async function insertUserPartyRight( + db: Kysely, + row: { + userId: string + networkId: string + partyId: string + right: string + } +): Promise { + await sql` + INSERT INTO user_party_rights (user_id, network_id, party_id, "right") + VALUES (${row.userId}, ${row.networkId}, ${row.partyId}, ${row.right}) + `.execute(db) +} + +export async function insertUserRight( + db: Kysely, + row: { + userId: string + networkId: string + right: string + } +): Promise { + await sql` + INSERT INTO user_rights (user_id, network_id, "right") + VALUES (${row.userId}, ${row.networkId}, ${row.right}) + `.execute(db) +} diff --git a/core/wallet-store-sql/src/migrations-test/seeds/010-transaction-with-network.ts b/core/wallet-store-sql/src/migrations-test/seeds/010-transaction-with-network.ts new file mode 100644 index 000000000..f0833829f --- /dev/null +++ b/core/wallet-store-sql/src/migrations-test/seeds/010-transaction-with-network.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely, sql } from 'kysely' +import { DB } from '../../schema' + +export async function insertTransaction( + db: Kysely, + row: { + commandId: string + networkId: string + userId: string + status?: string + preparedTransaction?: string + preparedTransactionHash?: string + payload?: string | null + createdAt?: string | null + signedAt?: string | null + origin?: string | null + externalTxId?: string | null + } +): Promise { + await sql` + INSERT INTO transactions ( + command_id, status, prepared_transaction, prepared_transaction_hash, + payload, user_id, created_at, signed_at, origin, external_tx_id, + network_id + ) + VALUES ( + ${row.commandId}, + ${row.status ?? 'pending'}, + ${row.preparedTransaction ?? 'prep'}, + ${row.preparedTransactionHash ?? 'hash'}, + ${row.payload ?? null}, + ${row.userId}, + ${row.createdAt ?? null}, + ${row.signedAt ?? null}, + ${row.origin ?? null}, + ${row.externalTxId ?? null}, + ${row.networkId} + ) + `.execute(db) +} diff --git a/core/wallet-store-sql/vitest.config.ts b/core/wallet-store-sql/vitest.config.ts index 67370d096..1641280dd 100644 --- a/core/wallet-store-sql/vitest.config.ts +++ b/core/wallet-store-sql/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ test: { coverage: { include: ['src/**/*.ts'], + exclude: ['src/migrations-test/**'], provider: 'v8', reporter: ['text', 'html', 'lcov'], thresholds: { @@ -22,6 +23,17 @@ export default defineConfig({ name: 'node', environment: 'node', include: ['src/**/*.test.ts'], + exclude: ['src/migrations-test/**'], + }, + }), + defineProject({ + test: { + name: 'migrations', + environment: 'node', + include: ['src/migrations-test/**/*.test.ts'], + globalSetup: ['src/migrations-test/global-setup.ts'], + testTimeout: 120_000, + hookTimeout: 120_000, }, }), ], diff --git a/nx.json b/nx.json index c21316c00..28e4c9cc2 100644 --- a/nx.json +++ b/nx.json @@ -65,6 +65,18 @@ "dependsOn": ["^build"], "cache": true }, + "test:migrations": { + "dependsOn": ["^build"], + "cache": true + }, + "migrations:check-lock": { + "cache": true, + "inputs": [ + "{projectRoot}/src/migrations/**/*", + "{projectRoot}/migrations.lock.json", + "{workspaceRoot}/scripts/src/check-migration-lock.ts" + ] + }, "typecheck": { "dependsOn": ["^build"] }, diff --git a/package.json b/package.json index 058020791..bfd7e50e6 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "*": "prettier --write --ignore-unknown", "*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --fix" }, - "packageManager": "yarn@4.12.0", + "packageManager": "yarn@4.9.4", "dependencies": { "@nx/js": "22.5.4", "nx": "22.5.4" diff --git a/scripts/src/check-migration-lock.ts b/scripts/src/check-migration-lock.ts new file mode 100644 index 000000000..4fb41fdd4 --- /dev/null +++ b/scripts/src/check-migration-lock.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const LOCK_VERSION = 1 + +interface LockFile { + version: number + migrations: Record +} + +const cwd = process.cwd() +const migrationsDir = resolve(cwd, 'src/migrations') +const lockPath = resolve(cwd, 'migrations.lock.json') + +const update = process.argv.slice(2).includes('--update') + +const computeCurrent = (): Record => { + if (!existsSync(migrationsDir)) { + console.error(`No migrations directory found at ${migrationsDir}`) + process.exit(1) + } + const entries: Record = {} + const files = readdirSync(migrationsDir) + .filter((f) => f.endsWith('.ts')) + .sort() + for (const file of files) { + const content = readFileSync(join(migrationsDir, file)) + const hash = createHash('sha256').update(content).digest('hex') + entries[file.replace(/\.ts$/, '')] = hash + } + return entries +} + +const writeLock = (migrations: Record) => { + const lock: LockFile = { version: LOCK_VERSION, migrations } + writeFileSync(lockPath, JSON.stringify(lock, null, 4) + '\n') +} + +const readLock = (): LockFile | null => { + if (!existsSync(lockPath)) return null + return JSON.parse(readFileSync(lockPath, 'utf8')) as LockFile +} + +const current = computeCurrent() + +if (update) { + writeLock(current) + console.log( + `Updated ${lockPath} (${Object.keys(current).length} migrations)` + ) + process.exit(0) +} + +const lock = readLock() +if (!lock) { + console.error( + `migrations.lock.json not found at ${lockPath}. Run "yarn migrations:update-lock" to create it.` + ) + process.exit(1) +} + +const errors: string[] = [] +const seen = new Set() + +for (const [name, hash] of Object.entries(current)) { + seen.add(name) + const locked = lock.migrations[name] + if (locked === undefined) { + errors.push(` ${name}: new migration not present in lock file`) + } else if (locked !== hash) { + errors.push(` ${name}: contents changed since lock was last updated`) + } +} + +for (const name of Object.keys(lock.migrations)) { + if (!seen.has(name)) { + errors.push(` ${name}: missing in files but present in lock file`) + } +} + +if (errors.length > 0) { + console.error('Migration lock check failed:\n' + errors.join('\n')) + console.info( + 'Migrations are immutable once committed. If you added a new one, or consciously want to edit existing one run yarn migrations:update-lock and commit the updated migrations.lock.json.' + ) + process.exit(1) +} + +console.log( + `Migration lock OK (${Object.keys(current).length} migrations verified)` +) diff --git a/yarn.lock b/yarn.lock index 0d2780bf3..ec4762862 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1410,6 +1410,13 @@ __metadata: languageName: node linkType: hard +"@balena/dockerignore@npm:^1.0.2": + version: 1.0.2 + resolution: "@balena/dockerignore@npm:1.0.2" + checksum: 10c0/0bcb067e86f6734ab943ce4ce9a7c8611f2e983a70bccebf9d2309db57695c09dded7faf5be49c929c4c9e9a9174ae55fc625626de0fb9958823c37423d12f4e + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -1818,6 +1825,7 @@ __metadata: "@canton-network/core-wallet-auth": "workspace:^" "@canton-network/core-wallet-store": "workspace:^" "@swc/core": "npm:^1.15.18" + "@testcontainers/postgresql": "npm:^11.14.0" "@types/better-sqlite3": "npm:^7.6.13" "@types/pg": "npm:^8.18.0" "@vitest/coverage-v8": "npm:^4.1.2" @@ -2094,6 +2102,7 @@ __metadata: "@canton-network/core-rpc-errors": "workspace:^" "@canton-network/core-wallet-auth": "workspace:^" "@canton-network/core-wallet-store": "workspace:^" + "@testcontainers/postgresql": "npm:^11.14.0" "@types/better-sqlite3": "npm:^7.6.13" "@types/pg": "npm:^8.18.0" "@vitest/coverage-v8": "npm:^4.1.2" @@ -3504,7 +3513,7 @@ __metadata: languageName: node linkType: hard -"@grpc/grpc-js@npm:^1.14.3": +"@grpc/grpc-js@npm:^1.11.1, @grpc/grpc-js@npm:^1.14.3": version: 1.14.3 resolution: "@grpc/grpc-js@npm:1.14.3" dependencies: @@ -3514,6 +3523,20 @@ __metadata: languageName: node linkType: hard +"@grpc/proto-loader@npm:^0.7.13": + version: 0.7.15 + resolution: "@grpc/proto-loader@npm:0.7.15" + dependencies: + lodash.camelcase: "npm:^4.3.0" + long: "npm:^5.0.0" + protobufjs: "npm:^7.2.5" + yargs: "npm:^17.7.2" + bin: + proto-loader-gen-types: build/bin/proto-loader-gen-types.js + checksum: 10c0/514a134a724b56d73d0a202b7e02c84479da21e364547bacb2f4995ebc0d52412a1a21653add9f004ebd146c1e6eb4bcb0b8846fdfe1bfa8a98ed8f3d203da4a + languageName: node + linkType: hard + "@grpc/proto-loader@npm:^0.8.0": version: 0.8.0 resolution: "@grpc/proto-loader@npm:0.8.0" @@ -4959,6 +4982,15 @@ __metadata: languageName: node linkType: hard +"@kwsites/file-exists@npm:^1.1.1": + version: 1.1.1 + resolution: "@kwsites/file-exists@npm:1.1.1" + dependencies: + debug: "npm:^4.1.1" + checksum: 10c0/39e693239a72ccd8408bb618a0200e4a8d61682057ca7ae2c87668d7e69196e8d7e2c9cde73db6b23b3b0230169a15e5f1bfe086539f4be43e767b2db68e8ee4 + languageName: node + linkType: hard + "@lit-labs/ssr-dom-shim@npm:^1.5.0": version: 1.5.1 resolution: "@lit-labs/ssr-dom-shim@npm:1.5.1" @@ -8138,6 +8170,15 @@ __metadata: languageName: node linkType: hard +"@testcontainers/postgresql@npm:^11.14.0": + version: 11.14.0 + resolution: "@testcontainers/postgresql@npm:11.14.0" + dependencies: + testcontainers: "npm:^11.14.0" + checksum: 10c0/2841b2362e847224a2b4dc47b289d32a8df770e20b6cac097b0af0eae96db5e4448090d99591feadada309aa0cc6c17b6c103d801632e909ff8d0bc42800244d + languageName: node + linkType: hard + "@testing-library/jest-dom@npm:^6.6.3": version: 6.9.1 resolution: "@testing-library/jest-dom@npm:6.9.1" @@ -8350,6 +8391,27 @@ __metadata: languageName: node linkType: hard +"@types/docker-modem@npm:*": + version: 3.0.6 + resolution: "@types/docker-modem@npm:3.0.6" + dependencies: + "@types/node": "npm:*" + "@types/ssh2": "npm:*" + checksum: 10c0/d3ffd273148bc883ff9b1a972b1f84c1add6d9a197d2f4fc9774db4c814f39c2e51cc649385b55d781c790c16fb0bf9c1f4c62499bd0f372a4b920190919445d + languageName: node + linkType: hard + +"@types/dockerode@npm:^4.0.1": + version: 4.0.1 + resolution: "@types/dockerode@npm:4.0.1" + dependencies: + "@types/docker-modem": "npm:*" + "@types/node": "npm:*" + "@types/ssh2": "npm:*" + checksum: 10c0/d504d5568624e629663633da9df4a88757d55548e399f2001c478bcdc55ee2e2a4c2fc8a903c4616ebe820a411e256ad5a5caa9c0fb244dca0a5dbc0eb333e45 + languageName: node + linkType: hard + "@types/esquery@npm:^1.5.0": version: 1.5.4 resolution: "@types/esquery@npm:1.5.4" @@ -8551,6 +8613,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^18.11.18": + version: 18.19.130 + resolution: "@types/node@npm:18.19.130" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10c0/22ba2bc9f8863101a7e90a56aaeba1eb3ebdc51e847cef4a6d188967ab1acbce9b4f92251372fd0329ecb924bbf610509e122c3dfe346c04dbad04013d4ad7d0 + languageName: node + linkType: hard + "@types/node@npm:^20.10.7, @types/node@npm:^20.14.9": version: 20.19.34 resolution: "@types/node@npm:20.19.34" @@ -8670,6 +8741,34 @@ __metadata: languageName: node linkType: hard +"@types/ssh2-streams@npm:*": + version: 0.1.13 + resolution: "@types/ssh2-streams@npm:0.1.13" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/c0734417ae1d964bcc0681e4cd45f6d25d49e87c1eba54a934dc9a78c40bf76ba4935a414561b6dec5fe0c9c42fe7ad94ef79a4e1592940d934f9c75a704ebb0 + languageName: node + linkType: hard + +"@types/ssh2@npm:*": + version: 1.15.5 + resolution: "@types/ssh2@npm:1.15.5" + dependencies: + "@types/node": "npm:^18.11.18" + checksum: 10c0/750e402ce60d6dd67011bf1a811dcbbe638da14baca30c0952b50bad646c4ef8d6fc400894e20f5d2f8882e38b4c35eb6d4f5fe2ecd1d1b1a2f9efef9cf6e773 + languageName: node + linkType: hard + +"@types/ssh2@npm:^0.5.48": + version: 0.5.52 + resolution: "@types/ssh2@npm:0.5.52" + dependencies: + "@types/node": "npm:*" + "@types/ssh2-streams": "npm:*" + checksum: 10c0/95c52fd3438dedae6a59ca87b6558cb36568db6b9144c6c8a28c168739e04c51e27c02908aae14950b7b5020e1c40fea039b1203ae2734c356a40a050fd51c84 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" @@ -9806,6 +9905,15 @@ __metadata: languageName: node linkType: hard +"abort-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: "npm:^5.0.0" + checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5 + languageName: node + linkType: hard + "accepts@npm:^2.0.0": version: 2.0.0 resolution: "accepts@npm:2.0.0" @@ -10133,6 +10241,36 @@ __metadata: languageName: node linkType: hard +"archiver-utils@npm:^5.0.0, archiver-utils@npm:^5.0.2": + version: 5.0.2 + resolution: "archiver-utils@npm:5.0.2" + dependencies: + glob: "npm:^10.0.0" + graceful-fs: "npm:^4.2.0" + is-stream: "npm:^2.0.1" + lazystream: "npm:^1.0.0" + lodash: "npm:^4.17.15" + normalize-path: "npm:^3.0.0" + readable-stream: "npm:^4.0.0" + checksum: 10c0/3782c5fa9922186aa1a8e41ed0c2867569faa5f15c8e5e6418ea4c1b730b476e21bd68270b3ea457daf459ae23aaea070b2b9f90cf90a59def8dc79b9e4ef538 + languageName: node + linkType: hard + +"archiver@npm:^7.0.1": + version: 7.0.1 + resolution: "archiver@npm:7.0.1" + dependencies: + archiver-utils: "npm:^5.0.2" + async: "npm:^3.2.4" + buffer-crc32: "npm:^1.0.0" + readable-stream: "npm:^4.0.0" + readdir-glob: "npm:^1.1.2" + tar-stream: "npm:^3.0.0" + zip-stream: "npm:^6.0.1" + checksum: 10c0/02afd87ca16f6184f752db8e26884e6eff911c476812a0e7f7b26c4beb09f06119807f388a8e26ed2558aa8ba9db28646ebd147a4f99e46813b8b43158e1438e + languageName: node + linkType: hard + "arg@npm:^4.1.0": version: 4.1.3 resolution: "arg@npm:4.1.3" @@ -10191,6 +10329,15 @@ __metadata: languageName: node linkType: hard +"asn1@npm:^0.2.6": + version: 0.2.6 + resolution: "asn1@npm:0.2.6" + dependencies: + safer-buffer: "npm:~2.1.0" + checksum: 10c0/00c8a06c37e548762306bcb1488388d2f76c74c36f70c803f0c081a01d3bdf26090fc088cd812afc5e56a6d49e33765d451a5f8a68ab9c2b087eba65d2e980e0 + languageName: node + linkType: hard + "assertion-error@npm:^2.0.1": version: 2.0.1 resolution: "assertion-error@npm:2.0.1" @@ -10241,6 +10388,13 @@ __metadata: languageName: node linkType: hard +"async-lock@npm:^1.4.1": + version: 1.4.1 + resolution: "async-lock@npm:1.4.1" + checksum: 10c0/f696991c7d894af1dc91abc81cc4f14b3785190a35afb1646d8ab91138238d55cabd83bfdd56c42663a008d72b3dc39493ff83797e550effc577d1ccbde254af + languageName: node + linkType: hard + "async-mutex@npm:^0.5.0": version: 0.5.0 resolution: "async-mutex@npm:0.5.0" @@ -10250,7 +10404,7 @@ __metadata: languageName: node linkType: hard -"async@npm:3.2.6, async@npm:^3.2.0, async@npm:^3.2.6, async@npm:~3.2.0": +"async@npm:3.2.6, async@npm:^3.2.0, async@npm:^3.2.4, async@npm:^3.2.6, async@npm:~3.2.0": version: 3.2.6 resolution: "async@npm:3.2.6" checksum: 10c0/36484bb15ceddf07078688d95e27076379cc2f87b10c03b6dd8a83e89475a3c8df5848859dd06a4c95af1e4c16fc973de0171a77f18ea00be899aca2a4f85e70 @@ -10312,6 +10466,18 @@ __metadata: languageName: node linkType: hard +"b4a@npm:^1.6.4": + version: 1.8.1 + resolution: "b4a@npm:1.8.1" + peerDependencies: + react-native-b4a: "*" + peerDependenciesMeta: + react-native-b4a: + optional: true + checksum: 10c0/344d8c94b244ec7a9cb516ea43a98216312454cb72478e4b7628a679ee343be237564c53bbe73995ab10ea9bc923b420236081b180b3cf78fd0c945bfc886798 + languageName: node + linkType: hard + "babel-dead-code-elimination@npm:^1.0.12": version: 1.0.12 resolution: "babel-dead-code-elimination@npm:1.0.12" @@ -10502,6 +10668,82 @@ __metadata: languageName: node linkType: hard +"bare-events@npm:^2.5.4, bare-events@npm:^2.7.0": + version: 2.8.2 + resolution: "bare-events@npm:2.8.2" + peerDependencies: + bare-abort-controller: "*" + peerDependenciesMeta: + bare-abort-controller: + optional: true + checksum: 10c0/53fef240cf2cdcca62f78b6eead90ddb5a59b0929f414b13a63764c2b4f9de98ea8a578d033b04d64bb7b86dfbc402e937984e69950855cc3754c7b63da7db21 + languageName: node + linkType: hard + +"bare-fs@npm:^4.5.5": + version: 4.7.1 + resolution: "bare-fs@npm:4.7.1" + dependencies: + bare-events: "npm:^2.5.4" + bare-path: "npm:^3.0.0" + bare-stream: "npm:^2.6.4" + bare-url: "npm:^2.2.2" + fast-fifo: "npm:^1.3.2" + peerDependencies: + bare-buffer: "*" + peerDependenciesMeta: + bare-buffer: + optional: true + checksum: 10c0/4dc67f6dd0264b817941c2b8cbfc42b6abc3980984cdfd129c4d1f22517cb29f6b99a69fc1e3e87f3a9c997e8c94114604bb67fff10574b2adf0966510cf0222 + languageName: node + linkType: hard + +"bare-os@npm:^3.0.1": + version: 3.9.1 + resolution: "bare-os@npm:3.9.1" + checksum: 10c0/65219ea4ae8b843395bc91be8c65d4ab6d7479d4b38a247efdde80341523c17fc242d5b0b8f09f89d6e54ef7ebec9700b3d9d4334559ffd4c1398b15cf93fa03 + languageName: node + linkType: hard + +"bare-path@npm:^3.0.0": + version: 3.0.0 + resolution: "bare-path@npm:3.0.0" + dependencies: + bare-os: "npm:^3.0.1" + checksum: 10c0/56a3ca82a9f808f4976cb1188640ac206546ce0ddff582afafc7bd2a6a5b31c3bd16422653aec656eeada2830cfbaa433c6cbf6d6b4d9eba033d5e06d60d9a68 + languageName: node + linkType: hard + +"bare-stream@npm:^2.6.4": + version: 2.13.1 + resolution: "bare-stream@npm:2.13.1" + dependencies: + streamx: "npm:^2.25.0" + teex: "npm:^1.0.1" + peerDependencies: + bare-abort-controller: "*" + bare-buffer: "*" + bare-events: "*" + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + checksum: 10c0/2c35e0b4e56667265e9023e9f51b77652ce043fd6611497575871ce62e833760dd3e5919ccc0cebe1af40959c4350035162b47541a1277d6488709f61f199754 + languageName: node + linkType: hard + +"bare-url@npm:^2.2.2": + version: 2.4.3 + resolution: "bare-url@npm:2.4.3" + dependencies: + bare-path: "npm:^3.0.0" + checksum: 10c0/c3286d1d4aa0c7a174995b1bd651083889303183537528c8847d3289f7d1689a8d3d35e803e664dc8996aefcb90ec66251e59c944850d53d775b50b1e18cc029 + languageName: node + linkType: hard + "base64-js@npm:1.5.1, base64-js@npm:^1.1.2, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -10534,6 +10776,15 @@ __metadata: languageName: node linkType: hard +"bcrypt-pbkdf@npm:^1.0.2": + version: 1.0.2 + resolution: "bcrypt-pbkdf@npm:1.0.2" + dependencies: + tweetnacl: "npm:^0.14.3" + checksum: 10c0/ddfe85230b32df25aeebfdccfbc61d3bc493ace49c884c9c68575de1f5dcf733a5d7de9def3b0f318b786616b8d85bad50a28b1da1750c43e0012c93badcc148 + languageName: node + linkType: hard + "better-sqlite3@npm:^12.6.2": version: 12.6.2 resolution: "better-sqlite3@npm:12.6.2" @@ -10773,6 +11024,13 @@ __metadata: languageName: node linkType: hard +"buffer-crc32@npm:^1.0.0": + version: 1.0.0 + resolution: "buffer-crc32@npm:1.0.0" + checksum: 10c0/8b86e161cee4bb48d5fa622cbae4c18f25e4857e5203b89e23de59e627ab26beb82d9d7999f2b8de02580165f61f83f997beaf02980cdf06affd175b651921ab + languageName: node + linkType: hard + "buffer-crc32@npm:~0.2.3": version: 0.2.13 resolution: "buffer-crc32@npm:0.2.13" @@ -10814,6 +11072,13 @@ __metadata: languageName: node linkType: hard +"buildcheck@npm:~0.0.6": + version: 0.0.7 + resolution: "buildcheck@npm:0.0.7" + checksum: 10c0/987c605267b1b6311bb2ac0638b073d322370267445a6d059da27985fce0b41f85a59d3a9aa9af839e8ac2d63da8af07be6dc737f8bd5323e1dfe6779ad67228 + languageName: node + linkType: hard + "bundle-name@npm:^4.1.0": version: 4.1.0 resolution: "bundle-name@npm:4.1.0" @@ -10834,6 +11099,13 @@ __metadata: languageName: node linkType: hard +"byline@npm:^5.0.0": + version: 5.0.0 + resolution: "byline@npm:5.0.0" + checksum: 10c0/33fb64cd84440b3652a99a68d732c56ef18a748ded495ba38e7756a242fab0d4654b9b8ce269fd0ac14c5f97aa4e3c369613672b280a1f60b559b34223105c85 + languageName: node + linkType: hard + "bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" @@ -11435,6 +11707,19 @@ __metadata: languageName: node linkType: hard +"compress-commons@npm:^6.0.2": + version: 6.0.2 + resolution: "compress-commons@npm:6.0.2" + dependencies: + crc-32: "npm:^1.2.0" + crc32-stream: "npm:^6.0.0" + is-stream: "npm:^2.0.1" + normalize-path: "npm:^3.0.0" + readable-stream: "npm:^4.0.0" + checksum: 10c0/2347031b7c92c8ed5011b07b93ec53b298fa2cd1800897532ac4d4d1aeae06567883f481b6e35f13b65fc31b190c751df6635434d525562f0203fde76f1f0814 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -11684,6 +11969,17 @@ __metadata: languageName: node linkType: hard +"cpu-features@npm:~0.0.10": + version: 0.0.10 + resolution: "cpu-features@npm:0.0.10" + dependencies: + buildcheck: "npm:~0.0.6" + nan: "npm:^2.19.0" + node-gyp: "npm:latest" + checksum: 10c0/0c4a12904657b22477ffbcfd2b4b2bdd45b174f283616b18d9e1ade495083f9f6098493feb09f4ae2d0b36b240f9ecd32cfb4afe210cf0d0f8f0cc257bd58e54 + languageName: node + linkType: hard + "crc-32@npm:^1.2.0": version: 1.2.2 resolution: "crc-32@npm:1.2.2" @@ -11693,6 +11989,16 @@ __metadata: languageName: node linkType: hard +"crc32-stream@npm:^6.0.0": + version: 6.0.0 + resolution: "crc32-stream@npm:6.0.0" + dependencies: + crc-32: "npm:^1.2.0" + readable-stream: "npm:^4.0.0" + checksum: 10c0/bf9c84571ede2d119c2b4f3a9ef5eeb9ff94b588493c0d3862259af86d3679dcce1c8569dd2b0a6eff2f35f5e2081cc1263b846d2538d4054da78cf34f262a3d + languageName: node + linkType: hard + "create-require@npm:^1.1.0": version: 1.1.1 resolution: "create-require@npm:1.1.1" @@ -12119,6 +12425,42 @@ __metadata: languageName: node linkType: hard +"docker-compose@npm:^1.4.2": + version: 1.4.2 + resolution: "docker-compose@npm:1.4.2" + dependencies: + yaml: "npm:^2.2.2" + checksum: 10c0/2cd9182dafeac8ac3fed57e5aac85be5dec18eaa0241026d202e418577bfe185ab97ae2e066be01ade4ae55ffdcab04f4b40c754f3b2d11c314d6ca0117b51fa + languageName: node + linkType: hard + +"docker-modem@npm:^5.0.7": + version: 5.0.7 + resolution: "docker-modem@npm:5.0.7" + dependencies: + debug: "npm:^4.1.1" + readable-stream: "npm:^3.5.0" + split-ca: "npm:^1.0.1" + ssh2: "npm:^1.15.0" + checksum: 10c0/987dd7b04de57241d4e0fbdb5c44d41f898f5f520a3f6dbc6542c27cf9e84c91c44bf0c1bee2469be83096cb2941ea5e4a1bd3f57f60eb508c1d790d27ada8f9 + languageName: node + linkType: hard + +"dockerode@npm:^4.0.10": + version: 4.0.12 + resolution: "dockerode@npm:4.0.12" + dependencies: + "@balena/dockerignore": "npm:^1.0.2" + "@grpc/grpc-js": "npm:^1.11.1" + "@grpc/proto-loader": "npm:^0.7.13" + docker-modem: "npm:^5.0.7" + protobufjs: "npm:^7.3.2" + tar-fs: "npm:^2.1.4" + uuid: "npm:^10.0.0" + checksum: 10c0/7bd7eae9c399f481964be0068118b0cb24a6baa24c69cb7fab27b1f5bbf7e171c25b2fc7793f401bb8caa0f61be5810656606614befe12d9ae36c5ffbbd1f7e7 + languageName: node + linkType: hard + "docs-wallet-integration-guide-examples@workspace:docs/wallet-integration-guide/examples": version: 0.0.0-use.local resolution: "docs-wallet-integration-guide-examples@workspace:docs/wallet-integration-guide/examples" @@ -13096,6 +13438,13 @@ __metadata: languageName: node linkType: hard +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b + languageName: node + linkType: hard + "eventemitter2@npm:5.0.1, eventemitter2@npm:~5.0.1": version: 5.0.1 resolution: "eventemitter2@npm:5.0.1" @@ -13131,6 +13480,15 @@ __metadata: languageName: node linkType: hard +"events-universal@npm:^1.0.0": + version: 1.0.1 + resolution: "events-universal@npm:1.0.1" + dependencies: + bare-events: "npm:^2.7.0" + checksum: 10c0/a1d9a5e9f95843650f8ec240dd1221454c110189a9813f32cdf7185759b43f1f964367ac7dca4ebc69150b59043f2d77c7e122b0d03abf7c25477ea5494785a5 + languageName: node + linkType: hard + "events@npm:3.3.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" @@ -13302,6 +13660,13 @@ __metadata: languageName: node linkType: hard +"fast-fifo@npm:^1.2.0, fast-fifo@npm:^1.3.2": + version: 1.3.2 + resolution: "fast-fifo@npm:1.3.2" + checksum: 10c0/d53f6f786875e8b0529f784b59b4b05d4b5c31c651710496440006a398389a579c8dbcd2081311478b5bf77f4b0b21de69109c5a4eabea9d8e8783d1eb864e4c + languageName: node + linkType: hard + "fast-glob@npm:^3.3.2": version: 3.3.3 resolution: "fast-glob@npm:3.3.3" @@ -13843,6 +14208,13 @@ __metadata: languageName: node linkType: hard +"get-port@npm:^7.2.0": + version: 7.2.0 + resolution: "get-port@npm:7.2.0" + checksum: 10c0/4ed741d9008ad15a24e2098c8971918025cc8241624245e704ecc62bb65160db5c79de5d7112acdaabccbe0714cd0704008c74d43a1f7a24a5875e58b84621be + languageName: node + linkType: hard + "get-proto@npm:1.0.1, get-proto@npm:^1.0.1": version: 1.0.1 resolution: "get-proto@npm:1.0.1" @@ -14015,7 +14387,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -14666,7 +15038,7 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^2.0.0": +"is-stream@npm:^2.0.0, is-stream@npm:^2.0.1": version: 2.0.1 resolution: "is-stream@npm:2.0.1" checksum: 10c0/7c284241313fc6efc329b8d7f08e16c0efeb6baab1b4cd0ba579eb78e5af1aa5da11e68559896a2067cd6c526bd29241dda4eb1225e627d5aa1a89a76d4635a5 @@ -15777,6 +16149,15 @@ __metadata: languageName: node linkType: hard +"lazystream@npm:^1.0.0": + version: 1.0.1 + resolution: "lazystream@npm:1.0.1" + dependencies: + readable-stream: "npm:^2.0.5" + checksum: 10c0/ea4e509a5226ecfcc303ba6782cc269be8867d372b9bcbd625c88955df1987ea1a20da4643bf9270336415a398d33531ebf0d5f0d393b9283dc7c98bfcbd7b69 + languageName: node + linkType: hard + "leven@npm:^3.1.0": version: 3.1.0 resolution: "leven@npm:3.1.0" @@ -16080,7 +16461,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.18.1": +"lodash@npm:^4.17.15, lodash@npm:^4.18.1": version: 4.18.1 resolution: "lodash@npm:4.18.1" checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27 @@ -16501,7 +16882,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^5.0.1": +"minimatch@npm:^5.0.1, minimatch@npm:^5.1.0": version: 5.1.9 resolution: "minimatch@npm:5.1.9" dependencies: @@ -16618,6 +16999,15 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:^3.0.1": + version: 3.0.1 + resolution: "mkdirp@npm:3.0.1" + bin: + mkdirp: dist/cjs/src/bin.js + checksum: 10c0/9f2b975e9246351f5e3a40dcfac99fcd0baa31fbfab615fe059fb11e51f10e4803c63de1f384c54d656e4db31d000e4767e9ef076a22e12a641357602e31d57d + languageName: node + linkType: hard + "mlly@npm:^1.7.4": version: 1.8.0 resolution: "mlly@npm:1.8.0" @@ -16723,6 +17113,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.19.0, nan@npm:^2.23.0": + version: 2.27.0 + resolution: "nan@npm:2.27.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/38eb00b06e40f0c65b6a98d75795f17d651a8b7b52f03873dff6902d0053f12e7638d7f64fc52bda6c8f8ec454d69636e3988c8a9eb2bc749c2d5c255ba55f4c + languageName: node + linkType: hard + "nanoid@npm:^3.3.11": version: 3.3.11 resolution: "nanoid@npm:3.3.11" @@ -18483,6 +18882,13 @@ __metadata: languageName: node linkType: hard +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3 + languageName: node + linkType: hard + "promise-toolbox@npm:0.21.0": version: 0.21.0 resolution: "promise-toolbox@npm:0.21.0" @@ -18512,6 +18918,27 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:^4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: "npm:^4.2.4" + retry: "npm:^0.12.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/2f265dbad15897a43110a02dae55105c04d356ec4ed560723dcb9f0d34bc4fb2f13f79bb930e7561be10278e2314db5aca2527d5d3dcbbdee5e6b331d1571f6d + languageName: node + linkType: hard + +"properties-reader@npm:^3.0.1": + version: 3.0.1 + resolution: "properties-reader@npm:3.0.1" + dependencies: + "@kwsites/file-exists": "npm:^1.1.1" + mkdirp: "npm:^3.0.1" + checksum: 10c0/271fae77b717e25aa5773ab1e769f416ccfdc3606a62f25cd76b2cceeb04278f2ee0e4d671ff2c06391a5e093b4b1097f9ce3916fddd4de34077a4a6e92ccb48 + languageName: node + linkType: hard + "proto-list@npm:~1.2.1": version: 1.2.4 resolution: "proto-list@npm:1.2.4" @@ -18519,7 +18946,7 @@ __metadata: languageName: node linkType: hard -"protobufjs@npm:^7.5.3": +"protobufjs@npm:^7.2.5, protobufjs@npm:^7.3.2, protobufjs@npm:^7.5.3": version: 7.5.8 resolution: "protobufjs@npm:7.5.8" dependencies: @@ -18818,7 +19245,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:3.6.2, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0": +"readable-stream@npm:3.6.2, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.5.0": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -18829,7 +19256,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.2.2, readable-stream@npm:~2.3.6": +"readable-stream@npm:^2.0.5, readable-stream@npm:^2.2.2, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -18844,6 +19271,28 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^4.0.0": + version: 4.7.0 + resolution: "readable-stream@npm:4.7.0" + dependencies: + abort-controller: "npm:^3.0.0" + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + process: "npm:^0.11.10" + string_decoder: "npm:^1.3.0" + checksum: 10c0/fd86d068da21cfdb10f7a4479f2e47d9c0a9b0c862fc0c840a7e5360201580a55ac399c764b12a4f6fa291f8cee74d9c4b7562e0d53b3c4b2769f2c98155d957 + languageName: node + linkType: hard + +"readdir-glob@npm:^1.1.2": + version: 1.1.3 + resolution: "readdir-glob@npm:1.1.3" + dependencies: + minimatch: "npm:^5.1.0" + checksum: 10c0/a37e0716726650845d761f1041387acd93aa91b28dd5381950733f994b6c349ddc1e21e266ec7cc1f9b92e205a7a972232f9b89d5424d07361c2c3753d5dbace + languageName: node + linkType: hard + "readdirp@npm:^4.0.1": version: 4.1.2 resolution: "readdirp@npm:4.1.2" @@ -19120,6 +19569,13 @@ __metadata: languageName: node linkType: hard +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 10c0/59933e8501727ba13ad73ef4a04d5280b3717fd650408460c987392efe9d7be2040778ed8ebe933c5cbd63da3dcc37919c141ef8af0a54a6e4fca5a2af177bfe + languageName: node + linkType: hard + "retry@npm:^0.13.1": version: 0.13.1 resolution: "retry@npm:0.13.1" @@ -19327,7 +19783,7 @@ __metadata: languageName: node linkType: hard -"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0": +"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:~2.1.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 @@ -19825,6 +20281,13 @@ __metadata: languageName: node linkType: hard +"split-ca@npm:^1.0.1": + version: 1.0.1 + resolution: "split-ca@npm:1.0.1" + checksum: 10c0/f339170b84c6b4706fcf4c60cc84acb36574c0447566bd713301a8d9b4feff7f4627efc8c334bec24944a3e2f35bc596bd58c673c9980d6bfe3137aae1116ba7 + languageName: node + linkType: hard + "split2@npm:^4.0.0, split2@npm:^4.1.0, split2@npm:^4.2.0": version: 4.2.0 resolution: "split2@npm:4.2.0" @@ -19855,6 +20318,33 @@ __metadata: languageName: node linkType: hard +"ssh-remote-port-forward@npm:^1.0.4": + version: 1.0.4 + resolution: "ssh-remote-port-forward@npm:1.0.4" + dependencies: + "@types/ssh2": "npm:^0.5.48" + ssh2: "npm:^1.4.0" + checksum: 10c0/33a441af12817577ea30d089b03c19f980d2fb2370933123a35026dc6be40f2dfce067e4dfc173e23d745464537ff647aa1bb7469be5571cc21f7cdb25181c09 + languageName: node + linkType: hard + +"ssh2@npm:^1.15.0, ssh2@npm:^1.4.0": + version: 1.17.0 + resolution: "ssh2@npm:1.17.0" + dependencies: + asn1: "npm:^0.2.6" + bcrypt-pbkdf: "npm:^1.0.2" + cpu-features: "npm:~0.0.10" + nan: "npm:^2.23.0" + dependenciesMeta: + cpu-features: + optional: true + nan: + optional: true + checksum: 10c0/637c1b7e8070fc8a3027f8abf771cd98419f56eaf3817171180e768004d4dea26c65fb3763294ed2f784429857f196c83c4f6889d2c31cc0e2648ea5ad730665 + languageName: node + linkType: hard + "ssri@npm:^13.0.0": version: 13.0.1 resolution: "ssri@npm:13.0.1" @@ -19955,6 +20445,17 @@ __metadata: languageName: node linkType: hard +"streamx@npm:^2.12.5, streamx@npm:^2.15.0, streamx@npm:^2.25.0": + version: 2.25.0 + resolution: "streamx@npm:2.25.0" + dependencies: + events-universal: "npm:^1.0.0" + fast-fifo: "npm:^1.3.2" + text-decoder: "npm:^1.1.0" + checksum: 10c0/1ecc4b722050e9088b99cde59d035e846ac97cedc3ef14a00b196d9c0b6f47d9fd18df454a19f56f0f586ab4f23fb7229069b9e8eaf22072a21bd9c909d4e0ea + languageName: node + linkType: hard + "string-argv@npm:^0.3.2, string-argv@npm:~0.3.1": version: 0.3.2 resolution: "string-argv@npm:0.3.2" @@ -20015,7 +20516,7 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:1.3.0, string_decoder@npm:^1.1.1": +"string_decoder@npm:1.3.0, string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: @@ -20279,6 +20780,18 @@ __metadata: languageName: node linkType: hard +"tar-stream@npm:^3.0.0": + version: 3.2.0 + resolution: "tar-stream@npm:3.2.0" + dependencies: + b4a: "npm:^1.6.4" + bare-fs: "npm:^4.5.5" + fast-fifo: "npm:^1.2.0" + streamx: "npm:^2.15.0" + checksum: 10c0/8a06c915f93c9b0906e79867e36a9cfe197da4d41b72e89ec0de99577ae755505d14815c1346b70c2410aa09d3145c3e3af2ff5802b6af84990cdd6c60dbb997 + languageName: node + linkType: hard + "tar@npm:^7.4.0, tar@npm:^7.5.4": version: 7.5.11 resolution: "tar@npm:7.5.11" @@ -20292,6 +20805,15 @@ __metadata: languageName: node linkType: hard +"teex@npm:^1.0.1": + version: 1.0.1 + resolution: "teex@npm:1.0.1" + dependencies: + streamx: "npm:^2.12.5" + checksum: 10c0/8df9166c037ba694b49d32a49858e314c60e513d55ac5e084dbf1ddbb827c5fa43cc389a81e87684419c21283308e9d68bb068798189c767ec4c252f890b8a77 + languageName: node + linkType: hard + "test-exclude@npm:^6.0.0": version: 6.0.0 resolution: "test-exclude@npm:6.0.0" @@ -20303,6 +20825,38 @@ __metadata: languageName: node linkType: hard +"testcontainers@npm:^11.14.0": + version: 11.14.0 + resolution: "testcontainers@npm:11.14.0" + dependencies: + "@balena/dockerignore": "npm:^1.0.2" + "@types/dockerode": "npm:^4.0.1" + archiver: "npm:^7.0.1" + async-lock: "npm:^1.4.1" + byline: "npm:^5.0.0" + debug: "npm:^4.4.3" + docker-compose: "npm:^1.4.2" + dockerode: "npm:^4.0.10" + get-port: "npm:^7.2.0" + proper-lockfile: "npm:^4.1.2" + properties-reader: "npm:^3.0.1" + ssh-remote-port-forward: "npm:^1.0.4" + tar-fs: "npm:^3.1.2" + tmp: "npm:^0.2.5" + undici: "npm:^7.24.5" + checksum: 10c0/a94294bb5f51a05c01252b7e0cdaa321696bed92a42d5d72e1467ae27d2a6547a63e287d8153b748dda3578df1ee08c1bf882919e6223ed3a26fefe91da88326 + languageName: node + linkType: hard + +"text-decoder@npm:^1.1.0": + version: 1.2.7 + resolution: "text-decoder@npm:1.2.7" + dependencies: + b4a: "npm:^1.6.4" + checksum: 10c0/929938ed154fbadb660a7f3d1aca30b7e53649a731af7583168fcfba0c158046325d35d945926e2a512bb62d1a49a7818151c987ea38b48853f01e1615722fc5 + languageName: node + linkType: hard + "thenify-all@npm:^1.0.0": version: 1.6.0 resolution: "thenify-all@npm:1.6.0" @@ -20682,6 +21236,13 @@ __metadata: languageName: node linkType: hard +"tweetnacl@npm:^0.14.3": + version: 0.14.5 + resolution: "tweetnacl@npm:0.14.5" + checksum: 10c0/4612772653512c7bc19e61923fbf42903f5e0389ec76a4a1f17195859d114671ea4aa3b734c2029ce7e1fa7e5cc8b80580f67b071ecf0b46b5636d030a0102a2 + languageName: node + linkType: hard + "tweetnacl@npm:^1.0.3": version: 1.0.3 resolution: "tweetnacl@npm:1.0.3" @@ -20942,6 +21503,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 10c0/bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501 + languageName: node + linkType: hard + "undici-types@npm:~6.21.0": version: 6.21.0 resolution: "undici-types@npm:6.21.0" @@ -20970,6 +21538,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:^7.24.5": + version: 7.25.0 + resolution: "undici@npm:7.25.0" + checksum: 10c0/02a0b45dc14eb91bc488948750232450fe52f27a6b08086d6ac6736bb47908d600fe3a96d346f12eab24729c782e5c2f693bc8e8eca6696d4e4c09b1ed4cb4ec + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.1 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.1" @@ -21292,6 +21867,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^10.0.0": + version: 10.0.0 + resolution: "uuid@npm:10.0.0" + bin: + uuid: dist/bin/uuid + checksum: 10c0/eab18c27fe4ab9fb9709a5d5f40119b45f2ec8314f8d4cf12ce27e4c6f4ffa4a6321dc7db6c515068fa373c075b49691ba969f0010bf37f44c37ca40cd6bf7fe + languageName: node + linkType: hard + "uuid@npm:^14.0.0": version: 14.0.0 resolution: "uuid@npm:14.0.0" @@ -21985,6 +22569,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.2.2": + version: 2.9.0 + resolution: "yaml@npm:2.9.0" + bin: + yaml: bin.mjs + checksum: 10c0/f340718df45e97a9551b9bf9dac61c80050bc464513b710debfb5067c380c8472e3b67809cffacb4ab5ffb5e66ef9310816c88b05f371cec60abfedd8c88e0a2 + languageName: node + linkType: hard + "yaml@npm:^2.6.0, yaml@npm:^2.8.0, yaml@npm:^2.8.1, yaml@npm:^2.8.2": version: 2.8.2 resolution: "yaml@npm:2.8.2" @@ -22093,6 +22686,17 @@ __metadata: languageName: node linkType: hard +"zip-stream@npm:^6.0.1": + version: 6.0.1 + resolution: "zip-stream@npm:6.0.1" + dependencies: + archiver-utils: "npm:^5.0.0" + compress-commons: "npm:^6.0.2" + readable-stream: "npm:^4.0.0" + checksum: 10c0/50f2fb30327fb9d09879abf7ae2493705313adf403e794b030151aaae00009162419d60d0519e807673ec04d442e140c8879ca14314df0a0192de3b233e8f28b + languageName: node + linkType: hard + "zod-validation-error@npm:^3.5.0 || ^4.0.0": version: 4.0.2 resolution: "zod-validation-error@npm:4.0.2"