From f8b3dc89fa5eac80c99a96b608540c332ed6789a Mon Sep 17 00:00:00 2001 From: augustine00z Date: Tue, 20 Oct 2026 08:44:30 -0700 Subject: [PATCH 1/3] Implement acceptance gates for issues 669 690 689 691 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 19 + docs/configuration-reference.md | 16 +- docs/database-migrations.md | 31 ++ docs/implementation-status.md | 15 +- docs/node-postgres-smoke.md | 15 + package.json | 3 +- scripts/check-node-postgres-package.mjs | 94 ++++ src/core/config-defaults.ts | 2 + src/core/factory.ts | 19 +- src/core/sep24/README.md | 6 +- src/core/sep31/README.md | 6 +- src/core/sep6/README.md | 6 +- src/runtime/database/sql-database-adapter.ts | 458 +++++++++++++----- src/runtime/queue/in-memory-queue.ts | 15 +- src/services/README.md | 6 +- src/types/foundation.ts | 17 +- src/types/index.ts | 8 +- src/utils/validation-helpers.ts | 11 + tests/core/factory-background-startup.test.ts | 68 +++ tests/readme-protocol-status.test.ts | 24 + .../database/migrate-single-flight.test.ts | 50 +- .../database/versioned-migrations.test.ts | 229 +++++++++ 22 files changed, 939 insertions(+), 179 deletions(-) create mode 100644 docs/database-migrations.md create mode 100644 docs/node-postgres-smoke.md create mode 100644 scripts/check-node-postgres-package.mjs create mode 100644 tests/readme-protocol-status.test.ts create mode 100644 tests/runtime/database/versioned-migrations.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec88f5ce..a9a9e724 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,20 @@ on: jobs: build: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: anchor + POSTGRES_PASSWORD: anchor + POSTGRES_DB: anchor_kit_smoke + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U anchor -d anchor_kit_smoke" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 @@ -43,3 +57,8 @@ jobs: - name: Build run: bun run build + + - name: Node PostgreSQL package smoke test + run: bun run smoke:node-postgres + env: + DATABASE_URL: postgres://anchor:anchor@127.0.0.1:5432/anchor_kit_smoke diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 5a34c60c..1256cf46 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -288,15 +288,19 @@ framework: { queue?: { backend: 'memory', // currently only 'memory' supported concurrency?: number, // optional, defaults to 1 + maxPendingJobs?: number, // optional, defaults to unlimited + onError?: (job, error) => void | Promise, }, // ... } ``` -| Field | Type | Default | Status | Notes | -| ------------------- | ---------- | ---------- | -------------- | -------------------------------------------------------------------------------------- | -| `queue.backend` | `'memory'` | `'memory'` | ✅ Implemented | In-process task queue; retains at most 10,000 active task buckets | -| `queue.concurrency` | `number` | `1` | ✅ Implemented | Number of concurrent background workers; scales up to handle webhook and watcher loads | +| Field | Type | Default | Status | Notes | +| ---------------------- | ---------- | ---------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `queue.backend` | `'memory'` | `'memory'` | ✅ Implemented | In-process task queue; retains at most 10,000 active task buckets | +| `queue.concurrency` | `number` | `1` | ✅ Implemented | Number of concurrent background workers; scales up to handle webhook and watcher loads | +| `queue.maxPendingJobs` | `number` | unlimited | ✅ Implemented | Maximum waiting jobs; running jobs do not count toward the limit. Must be a positive safe integer. | +| `queue.onError` | function | — | ✅ Implemented | Called with the failed job and error when a worker throws. Without a handler, or if the handler throws, the queue logs the error and continues processing. | **Example:** @@ -305,11 +309,15 @@ framework: { queue: { backend: 'memory', concurrency: 5, + maxPendingJobs: 100, + onError: (job, error) => logger.error({ job, error }, 'Background job failed'), }, // ... } ``` +Focused check: `bun test tests/core/factory-background-startup.test.ts`. It must pass the public-factory assertions that enforce the configured pending limit and invoke `onError` after a worker failure. + --- ### Watchers diff --git a/docs/database-migrations.md b/docs/database-migrations.md new file mode 100644 index 00000000..1741f638 --- /dev/null +++ b/docs/database-migrations.md @@ -0,0 +1,31 @@ +# Database migrations + +`SqlDatabaseAdapter` records applied migrations in `anchor_kit_migrations`. The core schema upgrades through ordered, stable migration names. Every migration runs in its own database transaction, and its record is written in that transaction only after `up` succeeds. A failed migration is rolled back and may be retried on the next `migrate()` call. + +The built-in `core:001-initial-schema` migration creates the current tables and indexes without dropping or replacing existing data. `core:002-idempotency-status` adds the `status` column only when an older `idempotency_keys` table does not already contain it. This allows databases created before migration tracking to be adopted in place. + +Plugins register append-only named migrations through `schema.migrations`: + +```typescript +const plugin = { + id: 'ledger', + schema: { + migrations: [ + { + name: '001-ledger-entries', + up: async ({ execute }) => { + await execute( + 'CREATE TABLE IF NOT EXISTS ledger_entries (id TEXT PRIMARY KEY, reference TEXT NOT NULL)', + ); + }, + }, + ], + }, +}; + +anchor.use(plugin); +``` + +The runtime stores each plugin migration as `plugin::`, with URI-encoded plugin IDs and migration names, after the core migrations and in plugin registration order. Migration names must be unique and must not be renamed or edited after release. `execute` runs SQL on the selected provider; `hasColumn` supports safe upgrades of existing tables. A plugin migration should use provider-specific SQL when the dialects differ. + +Focused check: `bun test tests/runtime/database/versioned-migrations.test.ts`. It must pass the current-schema adoption and legacy upgrade data-preservation assertions, then prove a failed plugin migration leaves no partial schema or applied record and can be retried successfully. diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 71a59f1c..13b5735d 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -15,25 +15,28 @@ This file tracks where the codebase currently aligns or diverges from the canoni ## Implemented Now (Runtime) - SQL persistence through the `DatabaseAdapter` interface, with SQLite support for local development and tests and PostgreSQL support through the configured SQL client. -- Express HTTP adapter for health, info, authentication, transaction, and webhook routes, including request limits and rate limiting. +- HTTP foundation routes for health/info, authentication challenge/token, interactive-deposit requests, transaction lookup, and webhook intake, including request limits and rate limiting. These SDK-specific handlers do not by themselves establish complete SEP wire-protocol conformance. - Webhook processing with optional signature verification, event deduplication, persistence, callback dispatch, and failure status updates. - In-memory queue adapter for bounded background work and graceful shutdown. - Transaction watcher lifecycle for polling pending transactions, persisting watcher tasks, and dispatching work through the queue. +- Plugin route registration and active deposit-request, SEP-10 challenge, and transaction-status hooks. The withdrawal-request hook is typed but is not currently invoked. -## Planned Per Unified Spec (Not Yet Implemented) +## Protocol Conformance (Not Yet Implemented) -- Protocol modules: SEP-10, SEP-12, SEP-6, SEP-24 runtime flows, SEP-31, SEP-38. +- Complete protocol engines and conformance: SEP-6, SEP-12, SEP-24 deposit/withdrawal flows, SEP-31, and SEP-38. Existing HTTP routes and protocol-related types are partial foundation support, not complete protocol implementations. - Adapter implementations: rail adapters, signer adapters, KYC provider adapters, and rate adapters. -- Protocol transaction state-machine runtime and provider integrations. +- Provider integrations and protocol transaction state machines. ## Intentionally Deferred - Runtime protocol engines and provider integrations listed above remain deferred while foundation APIs and typing guarantees stabilize. -- Empty module directories under `src/core/sep6`, `src/core/sep24`, and `src/core/sep31` are placeholders for the deferred protocol flows. -- `src/services` contains the implemented queue and watcher orchestration; future provider-specific services remain deferred. +- Directories under `src/core/sep6`, `src/core/sep24`, and `src/core/sep31` are reserved for future protocol engines; their presence does not indicate runtime conformance. +- Queue, watcher, and webhook orchestration already runs under `src/runtime`; `src/services` is reserved for future higher-level and provider-specific services. ## Drift Matrix +Focused documentation check: `bun test tests/readme-protocol-status.test.ts`. It must pass the assertions distinguishing the shared HTTP/plugin foundation from complete SEP protocol engines. + | Area | Status | Classification | Source Expectation | Current Code State | Action Taken | | ---------------------------- | --------------- | -------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Root SDK API | Partial | Digression | Unified docs center API on `createAnchor` factory | Root package entrypoint exported only types before cleanup | Exported `createAnchor` and `AnchorInstance` from root entrypoints | diff --git a/docs/node-postgres-smoke.md b/docs/node-postgres-smoke.md new file mode 100644 index 00000000..632ae477 --- /dev/null +++ b/docs/node-postgres-smoke.md @@ -0,0 +1,15 @@ +# Node PostgreSQL package smoke test + +This focused consumer check runs on Node 22. It builds the package, packs and installs the tarball into a clean temporary Node project, imports `createAnchor` from the package's public entry point, initializes it against PostgreSQL, serves one `/health` request, and shuts down the server and anchor. + +The database must be disposable because the test creates the SDK's migration metadata and core tables. + +PowerShell: + +```powershell +$env:DATABASE_URL = 'postgres://anchor:anchor@127.0.0.1:5432/anchor_kit_smoke' +bun run build +bun run smoke:node-postgres +``` + +Expected result: the script exits with code 0 and prints `Node PostgreSQL package smoke passed: public import, migration, HTTP /health, and shutdown.` The repository CI runs this check against a disposable PostgreSQL 16 service. diff --git a/package.json b/package.json index 715208f6..0cf75e48 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,12 @@ "type": "module", "scripts": { "dev": "bun run --watch src/index.ts", - "build": "rm -rf dist && bun build src/index.ts --outdir dist --target node --format esm --packages external && bunx tsc -p tsconfig.types.json && node scripts/fix-dts-imports.mjs", + "build": "rm -rf dist && bun build src/index.ts --outdir dist --target node --format esm --external @stellar/stellar-sdk --external bcryptjs --external big.js --external express --external isomorphic-dompurify --external jose --external jsonwebtoken --external pg --external bun:sqlite && bunx tsc -p tsconfig.types.json && node scripts/fix-dts-imports.mjs", "test": "bun test", "test:watch": "bun test --watch", "test:coverage": "bun test --coverage", "check:pack": "node scripts/check-pack-contents.mjs", + "smoke:node-postgres": "node scripts/check-node-postgres-package.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"**/*.{ts,tsx,json,md}\"", diff --git a/scripts/check-node-postgres-package.mjs b/scripts/check-node-postgres-package.mjs new file mode 100644 index 00000000..4de8533c --- /dev/null +++ b/scripts/check-node-postgres-package.mjs @@ -0,0 +1,94 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL must point to a disposable PostgreSQL database'); +} + +const tempDirectory = await mkdtemp(join(tmpdir(), 'anchor-kit-node-smoke-')); + +try { + const packOutput = execFileSync( + 'npm', + ['pack', '--ignore-scripts', '--json', '--pack-destination', tempDirectory], + { encoding: 'utf8' }, + ); + const [packedPackage] = JSON.parse(packOutput); + if (!packedPackage?.filename) { + throw new Error('npm pack did not return a tarball filename'); + } + + const tarballPath = join(tempDirectory, packedPackage.filename); + const consumerPath = join(tempDirectory, 'consumer.mjs'); + await writeFile( + consumerPath, + ` + import assert from 'node:assert/strict'; + import express from 'express'; + import { createAnchor } from 'anchor-kit'; + + const signingKey = 'SCZJBZ6S7HWMQVT7DM74JVHVDKCEE5P6I6T3E5M7LJM6LJM6LJM6LJM6'; + const anchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: signingKey, + interactiveJwtSecret: 'node-consumer-smoke-jwt-secret', + distributionAccountSecret: signingKey, + }, + assets: { + assets: [{ + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }], + }, + framework: { + database: { provider: 'postgres', url: process.env.DATABASE_URL }, + watchers: { enabled: false }, + }, + }); + + await anchor.init(); + const app = express(); + app.use(anchor.getExpressRouter()); + const server = app.listen(0, '127.0.0.1'); + try { + await new Promise((resolve, reject) => { + server.once('listening', resolve); + server.once('error', reject); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const response = await fetch(\`http://127.0.0.1:\${address.port}/health\`, { + signal: AbortSignal.timeout(5000), + }); + assert.equal(response.status, 200); + const health = await response.json(); + assert.equal(health.status, 'ok'); + assert.ok(typeof health.version === 'string' && health.version.length > 0); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + await anchor.shutdown(); + } + `, + ); + + execFileSync('npm', ['install', '--no-audit', '--no-fund', tarballPath], { + cwd: tempDirectory, + stdio: 'inherit', + }); + execFileSync(process.execPath, [consumerPath], { + cwd: tempDirectory, + env: process.env, + stdio: 'inherit', + }); + console.log( + 'Node PostgreSQL package smoke passed: public import, migration, HTTP /health, and shutdown.', + ); +} finally { + await rm(tempDirectory, { recursive: true, force: true }); +} diff --git a/src/core/config-defaults.ts b/src/core/config-defaults.ts index 3be2bc01..a0dbfcfa 100644 --- a/src/core/config-defaults.ts +++ b/src/core/config-defaults.ts @@ -52,6 +52,8 @@ function buildFrameworkConfig( queue: { backend: input.framework.queue?.backend ?? 'memory', concurrency: input.framework.queue?.concurrency ?? 1, + maxPendingJobs: input.framework.queue?.maxPendingJobs, + onError: input.framework.queue?.onError, }, watchers: { enabled: input.framework.watchers?.enabled ?? true, diff --git a/src/core/factory.ts b/src/core/factory.ts index 4704a186..23acf133 100644 --- a/src/core/factory.ts +++ b/src/core/factory.ts @@ -18,6 +18,7 @@ import { InMemoryQueueAdapter } from '@/runtime/queue/in-memory-queue.ts'; import { TransactionWatcher } from '@/runtime/watchers/transaction-watcher.ts'; import { DefaultWebhookProcessor } from '@/runtime/webhooks/default-webhook-processor.ts'; import { AnchorKitConfig } from '@/types/config.ts'; +import type { SchemaMigration } from '@/types/foundation.ts'; import type { AnchorPlugin, AnchorPluginHooks } from '@/types/plugin.ts'; /** @@ -94,7 +95,11 @@ export class AnchorInstance { } const queueConcurrency = frameworkConfig.queue?.concurrency ?? 1; - this.queue = new InMemoryQueueAdapter({ concurrency: queueConcurrency }); + this.queue = new InMemoryQueueAdapter({ + concurrency: queueConcurrency, + maxPendingJobs: frameworkConfig.queue?.maxPendingJobs, + onError: frameworkConfig.queue?.onError, + }); this.webhookProcessor = new DefaultWebhookProcessor({ config: this.config.getConfig(), @@ -236,7 +241,17 @@ export class AnchorInstance { } protected createDatabaseAdapter(): DatabaseAdapter { - return createSqlDatabaseAdapter(this.config.get('framework').database); + const migrations: SchemaMigration[] = []; + for (const plugin of this.plugins.values()) { + for (const migration of plugin.schema?.migrations ?? []) { + migrations.push({ + ...migration, + name: `plugin:${encodeURIComponent(plugin.id)}:${encodeURIComponent(migration.name)}`, + }); + } + } + + return createSqlDatabaseAdapter(this.config.get('framework').database, migrations); } /** diff --git a/src/core/sep24/README.md b/src/core/sep24/README.md index 801dda81..60caef42 100644 --- a/src/core/sep24/README.md +++ b/src/core/sep24/README.md @@ -1,5 +1,5 @@ -# Planned Module Stub +# SEP-24 Module (Reserved) -This directory is a placeholder for SEP-24 runtime module implementation. +This directory is reserved for a future SEP-24 protocol engine. A complete SEP-24-compliant interactive deposit and withdrawal flow is not implemented here. -Current SEP-24 support in this repository is type-level (`src/types/sep24/**`) only. +The shared HTTP runtime currently provides an SDK-specific `POST /transactions/deposit/interactive` request path and `GET /transactions/{id}` lookup, along with persistence and plugin hooks. These endpoints are foundation infrastructure, not evidence of complete SEP-24 wire-protocol conformance. Check the current official SEP-24 specification before relying on wire behavior. diff --git a/src/core/sep31/README.md b/src/core/sep31/README.md index bf99456e..e44ab56a 100644 --- a/src/core/sep31/README.md +++ b/src/core/sep31/README.md @@ -1,5 +1,5 @@ -# Planned Module Stub +# SEP-31 Module (Reserved) -This directory is a placeholder for the SEP-31 module implementation. +This directory is reserved for a future SEP-31 protocol engine. -No runtime SEP-31 implementation is shipped yet. +The shared HTTP runtime and plugin route mechanism provide general infrastructure, but there is no SEP-31 cross-border payment flow and no SEP-31 wire-protocol conformance claim. Check the current official SEP-31 specification before implementing protocol behavior. diff --git a/src/core/sep6/README.md b/src/core/sep6/README.md index 48202ad0..fc425259 100644 --- a/src/core/sep6/README.md +++ b/src/core/sep6/README.md @@ -1,5 +1,5 @@ -# Planned Module Stub +# SEP-6 Module (Reserved) -This directory is a placeholder for the SEP-6 module implementation. +This directory is reserved for a future SEP-6 protocol engine. -No runtime SEP-6 implementation is shipped yet. +The shared HTTP runtime and plugin route mechanism provide general infrastructure, but there is no SEP-6 deposit or withdrawal flow and no SEP-6 wire-protocol conformance claim. Check the current official SEP-6 specification before implementing protocol behavior. diff --git a/src/runtime/database/sql-database-adapter.ts b/src/runtime/database/sql-database-adapter.ts index f2eaf1cc..633c8b16 100644 --- a/src/runtime/database/sql-database-adapter.ts +++ b/src/runtime/database/sql-database-adapter.ts @@ -8,11 +8,12 @@ import type { WebhookEventRecord, } from '@/runtime/interfaces.ts'; import type { FrameworkConfig } from '@/types/config.ts'; +import type { SchemaMigration, SchemaMigrationContext } from '@/types/foundation.ts'; import { isTransactionStatus, type TransactionStatus } from '@/types/transaction-status.ts'; -import { Database } from 'bun:sqlite'; +import type { Database } from 'bun:sqlite'; import { randomUUID } from 'node:crypto'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; type SqliteLike = Database; @@ -27,6 +28,7 @@ interface PostgresClient { const SQLITE_FILE_PREFIX = 'file:'; const SQLITE_URL_PREFIX = 'sqlite:'; +const sqliteMigrationLocks = new Map>(); /** * Journal mode requested for file-backed SQLite databases. @@ -70,6 +72,130 @@ export const POSTGRES_INDEX_STATEMENTS: readonly string[] = [ 'CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created_at ON idempotency_keys (created_at)', ]; +const CORE_MIGRATIONS: readonly SchemaMigration[] = [ + { + name: 'core:001-initial-schema', + async up({ dialect, execute }) { + await execute( + dialect === 'sqlite' + ? ` + CREATE TABLE IF NOT EXISTS auth_challenges ( + id TEXT PRIMARY KEY, + account TEXT NOT NULL, + challenge TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + consumed_at TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS interactive_transactions ( + id TEXT PRIMARY KEY, + account TEXT NOT NULL, + kind TEXT NOT NULL, + asset_code TEXT NOT NULL, + amount TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS idempotency_keys ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(scope, idempotency_key) + ); + CREATE TABLE IF NOT EXISTS webhook_events ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + provider TEXT NOT NULL, + payload TEXT NOT NULL, + status TEXT NOT NULL, + error_message TEXT, + processed_at TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS watcher_tasks ( + id TEXT PRIMARY KEY, + watcher_name TEXT NOT NULL, + payload TEXT NOT NULL, + status TEXT NOT NULL, + error_message TEXT, + processed_at TEXT, + created_at TEXT NOT NULL + ); + ` + : ` + CREATE TABLE IF NOT EXISTS auth_challenges ( + id TEXT PRIMARY KEY, + account TEXT NOT NULL, + challenge TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL + ); + CREATE TABLE IF NOT EXISTS interactive_transactions ( + id TEXT PRIMARY KEY, + account TEXT NOT NULL, + kind TEXT NOT NULL, + asset_code TEXT NOT NULL, + amount TEXT NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + ); + CREATE TABLE IF NOT EXISTS idempotency_keys ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE(scope, idempotency_key) + ); + CREATE TABLE IF NOT EXISTS webhook_events ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + provider TEXT NOT NULL, + payload JSONB NOT NULL, + status TEXT NOT NULL, + error_message TEXT, + processed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL + ); + CREATE TABLE IF NOT EXISTS watcher_tasks ( + id TEXT PRIMARY KEY, + watcher_name TEXT NOT NULL, + payload JSONB NOT NULL, + status TEXT NOT NULL, + error_message TEXT, + processed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL + ); + `, + ); + + const indexes = dialect === 'sqlite' ? SQLITE_INDEX_STATEMENTS : POSTGRES_INDEX_STATEMENTS; + for (const statement of indexes) { + await execute(statement); + } + }, + }, + { + name: 'core:002-idempotency-status', + async up({ execute, hasColumn }) { + if (!(await hasColumn('idempotency_keys', 'status'))) { + await execute( + "ALTER TABLE idempotency_keys ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'", + ); + } + }, + }, +]; + function nowIso(): string { return new Date().toISOString(); } @@ -94,6 +220,25 @@ function isInMemorySqlite(path: string): boolean { return path.includes(':memory:') || path.includes('mode=memory'); } +async function withSqliteMigrationLock(key: string, migrate: () => Promise): Promise { + const previous = sqliteMigrationLocks.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolveLock) => { + release = resolveLock; + }); + sqliteMigrationLocks.set(key, current); + await previous; + + try { + return await migrate(); + } finally { + release(); + if (sqliteMigrationLocks.get(key) === current) { + sqliteMigrationLocks.delete(key); + } + } +} + /** * Applies the concurrency settings to a freshly opened SQLite connection. * @@ -137,13 +282,18 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { private readonly url: string; private sqlite: SqliteLike | null = null; private postgres: PostgresClient | null = null; + private readonly migrations: readonly SchemaMigration[]; private connectPromise: Promise | null = null; private disconnectPromise: Promise | null = null; private migratePromise: Promise | null = null; - constructor(databaseConfig: FrameworkConfig['database']) { + constructor( + databaseConfig: FrameworkConfig['database'], + migrations: readonly SchemaMigration[] = [], + ) { this.provider = databaseConfig.provider; this.url = databaseConfig.url; + this.migrations = migrations; } public async connect(): Promise { @@ -160,6 +310,7 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { try { if (this.provider === 'sqlite') { const path = toSqlitePath(this.url); + const { Database } = await import('bun:sqlite'); const database = new Database(path); try { @@ -257,144 +408,190 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { } private async runMigrations(): Promise { - if (this.sqlite) { - this.sqlite.exec(` - CREATE TABLE IF NOT EXISTS auth_challenges ( - id TEXT PRIMARY KEY, - account TEXT NOT NULL, - challenge TEXT NOT NULL UNIQUE, - expires_at TEXT NOT NULL, - consumed_at TEXT, - created_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS interactive_transactions ( - id TEXT PRIMARY KEY, - account TEXT NOT NULL, - kind TEXT NOT NULL, - asset_code TEXT NOT NULL, - amount TEXT NOT NULL, - status TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS idempotency_keys ( - id TEXT PRIMARY KEY, - scope TEXT NOT NULL, - idempotency_key TEXT NOT NULL, - request_hash TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'completed', - status_code INTEGER NOT NULL, - response_body TEXT NOT NULL, - created_at TEXT NOT NULL, - UNIQUE(scope, idempotency_key) - ); - - CREATE TABLE IF NOT EXISTS webhook_events ( - id TEXT PRIMARY KEY, - event_id TEXT NOT NULL UNIQUE, - provider TEXT NOT NULL, - payload TEXT NOT NULL, - status TEXT NOT NULL, - error_message TEXT, - processed_at TEXT, - created_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS watcher_tasks ( - id TEXT PRIMARY KEY, - watcher_name TEXT NOT NULL, - payload TEXT NOT NULL, - status TEXT NOT NULL, - error_message TEXT, - processed_at TEXT, - created_at TEXT NOT NULL - ); - `); - const idempotencyColumns = this.sqlite - .prepare('PRAGMA table_info(idempotency_keys)') - .all() as Array<{ name?: unknown }>; - if (!idempotencyColumns.some((column) => column.name === 'status')) { - this.sqlite.exec( - "ALTER TABLE idempotency_keys ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'", - ); - } - for (const statement of SQLITE_INDEX_STATEMENTS) { - this.sqlite.exec(statement); + const migrations = [...CORE_MIGRATIONS, ...this.migrations]; + const migrationNames = new Set(); + for (const migration of migrations) { + if ( + !migration.name.trim() || + typeof migration.up !== 'function' || + migrationNames.has(migration.name) + ) { + throw new ConfigError(`Invalid or duplicate database migration name: "${migration.name}"`); } + migrationNames.add(migration.name); + } + + if (this.sqlite) { + const database = this.sqlite; + await withSqliteMigrationLock(resolve(toSqlitePath(this.url)), async () => { + await this.runSqliteMigrations(database, migrations); + }); return; } if (this.postgres) { - await this.postgres.query(` - CREATE TABLE IF NOT EXISTS auth_challenges ( - id TEXT PRIMARY KEY, - account TEXT NOT NULL, - challenge TEXT NOT NULL UNIQUE, - expires_at TIMESTAMPTZ NOT NULL, - consumed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL - ); - `); - await this.postgres.query(` - CREATE TABLE IF NOT EXISTS interactive_transactions ( - id TEXT PRIMARY KEY, - account TEXT NOT NULL, - kind TEXT NOT NULL, - asset_code TEXT NOT NULL, - amount TEXT NOT NULL, - status TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL - ); - `); - await this.postgres.query(` - CREATE TABLE IF NOT EXISTS idempotency_keys ( - id TEXT PRIMARY KEY, - scope TEXT NOT NULL, - idempotency_key TEXT NOT NULL, - request_hash TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'completed', - status_code INTEGER NOT NULL, - response_body TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - UNIQUE(scope, idempotency_key) - ); + const postgres = this.postgres; + await postgres.query(` + CREATE TABLE IF NOT EXISTS anchor_kit_migrations ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL + ) `); - await this.postgres.query( - "ALTER TABLE idempotency_keys ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'completed'", + const result = await postgres.query<{ name: string }>( + 'SELECT name FROM anchor_kit_migrations', ); - await this.postgres.query(` - CREATE TABLE IF NOT EXISTS webhook_events ( - id TEXT PRIMARY KEY, - event_id TEXT NOT NULL UNIQUE, - provider TEXT NOT NULL, - payload JSONB NOT NULL, - status TEXT NOT NULL, - error_message TEXT, - processed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL - ); + const applied = new Set(result.rows.map((row) => row.name)); + const context: SchemaMigrationContext = { + dialect: 'postgres', + execute: async (sql) => { + await postgres.query(sql); + }, + hasColumn: async (table, column) => { + const columns = await postgres.query<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 + ) AS exists`, + [table, column], + ); + return columns.rows[0]?.exists === true; + }, + }; + await this.applyMigrations(migrations, applied, context, { + begin: async () => { + await postgres.query('BEGIN'); + }, + commit: async () => { + await postgres.query('COMMIT'); + }, + rollback: async () => { + await postgres.query('ROLLBACK'); + }, + isApplied: async (name) => { + const existing = await postgres.query( + 'SELECT 1 FROM anchor_kit_migrations WHERE name = $1', + [name], + ); + return existing.rows.length > 0; + }, + record: async (name) => { + await postgres.query( + 'INSERT INTO anchor_kit_migrations (name, applied_at) VALUES ($1, $2)', + [name, nowIso()], + ); + }, + }); + return; + } + + throw new ConfigError('Database not connected'); + } + + private async runSqliteMigrations( + database: SqliteLike, + migrations: readonly SchemaMigration[], + ): Promise { + database.exec('BEGIN IMMEDIATE'); + try { + database.exec(` + CREATE TABLE IF NOT EXISTS anchor_kit_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL + ) `); - await this.postgres.query(` - CREATE TABLE IF NOT EXISTS watcher_tasks ( - id TEXT PRIMARY KEY, - watcher_name TEXT NOT NULL, - payload JSONB NOT NULL, - status TEXT NOT NULL, - error_message TEXT, - processed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL + database.exec('COMMIT'); + } catch (error) { + try { + database.exec('ROLLBACK'); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'Could not initialize the SQLite migration metadata table', + { cause: rollbackError }, ); - `); - for (const statement of POSTGRES_INDEX_STATEMENTS) { - await this.postgres.query(statement); } - return; + throw error; } - throw new ConfigError('Database not connected'); + const rows = database.prepare('SELECT name FROM anchor_kit_migrations').all() as Array<{ + name: string; + }>; + const applied = new Set(rows.map((row) => row.name)); + const context: SchemaMigrationContext = { + dialect: 'sqlite', + execute: async (sql) => { + database.exec(sql); + }, + hasColumn: async (table, column) => { + const escapedTable = table.replaceAll('"', '""'); + const columns = database.prepare(`PRAGMA table_info("${escapedTable}")`).all() as Array<{ + name?: unknown; + }>; + return columns.some((item) => item.name === column); + }, + }; + await this.applyMigrations(migrations, applied, context, { + begin: async () => { + database.exec('BEGIN IMMEDIATE'); + }, + commit: async () => { + database.exec('COMMIT'); + }, + rollback: async () => { + database.exec('ROLLBACK'); + }, + isApplied: async (name) => + database.prepare('SELECT 1 FROM anchor_kit_migrations WHERE name = ?').get(name) !== null, + record: async (name) => { + database + .prepare('INSERT INTO anchor_kit_migrations (name, applied_at) VALUES (?, ?)') + .run(name, nowIso()); + }, + }); + } + + private async applyMigrations( + migrations: readonly SchemaMigration[], + applied: Set, + context: SchemaMigrationContext, + transaction: { + begin(): Promise; + commit(): Promise; + rollback(): Promise; + isApplied(name: string): Promise; + record(name: string): Promise; + }, + ): Promise { + for (const migration of migrations) { + if (applied.has(migration.name)) { + continue; + } + + await transaction.begin(); + try { + if (await transaction.isApplied(migration.name)) { + applied.add(migration.name); + await transaction.commit(); + continue; + } + + await migration.up(context); + await transaction.record(migration.name); + await transaction.commit(); + applied.add(migration.name); + } catch (error) { + try { + await transaction.rollback(); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `Migration "${migration.name}" failed and its transaction could not be rolled back`, + { cause: rollbackError }, + ); + } + throw error; + } + } } public async insertAuthChallenge(input: { @@ -1188,6 +1385,7 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { export function createSqlDatabaseAdapter( databaseConfig: FrameworkConfig['database'], + migrations: readonly SchemaMigration[] = [], ): DatabaseAdapter { if (String(databaseConfig.provider) === 'mysql') { throw new ConfigError('MySQL is not implemented in this MVP. Use postgres or sqlite.'); @@ -1200,7 +1398,7 @@ export function createSqlDatabaseAdapter( } } - return new SqlDatabaseAdapter(databaseConfig); + return new SqlDatabaseAdapter(databaseConfig, migrations); } export function makeSqliteDbUrlForTests(): string { diff --git a/src/runtime/queue/in-memory-queue.ts b/src/runtime/queue/in-memory-queue.ts index 9485c0c2..72a153b1 100644 --- a/src/runtime/queue/in-memory-queue.ts +++ b/src/runtime/queue/in-memory-queue.ts @@ -107,10 +107,17 @@ export class InMemoryQueueAdapter implements QueueAdapter { try { await worker(job); } catch (error) { - try { - await this.onError?.(job, error); - } catch { - // Reporting failures must not prevent other queued jobs from running. + if (!this.onError) { + console.error(`[AnchorKit] Background queue job "${job.type}" failed`, error); + } else { + try { + await this.onError(job, error); + } catch (handlerError) { + console.error( + `[AnchorKit] Queue error handler failed for job "${job.type}"`, + handlerError, + ); + } } } finally { this.activeWorkers -= 1; diff --git a/src/services/README.md b/src/services/README.md index a48756c5..90777a34 100644 --- a/src/services/README.md +++ b/src/services/README.md @@ -1,5 +1,5 @@ -# Planned Services Stub +# Runtime Services -This directory is reserved for runtime orchestration services (watchers, webhook processors, queues, etc.). +Runtime orchestration is implemented under `src/runtime/`: the in-memory queue, transaction watcher, and webhook processor are active foundation services. `src/services/` remains reserved for future provider-specific integrations and higher-level service modules. -No service implementations are shipped yet. +These services do not implement SEP protocol engines. Plugin routes and selected lifecycle hooks extend the HTTP runtime; current active hooks are deposit request, SEP-10 challenge, and transaction status change. The declared withdrawal-request hook is not currently invoked. diff --git a/src/types/foundation.ts b/src/types/foundation.ts index d95ac69a..49460890 100644 --- a/src/types/foundation.ts +++ b/src/types/foundation.ts @@ -97,10 +97,23 @@ export interface PluginRouteContext { database: DatabaseAdapter; } +export interface SchemaMigrationContext { + dialect: 'postgres' | 'sqlite'; + execute(sql: string): Promise; + hasColumn(table: string, column: string): Promise; +} + +export interface SchemaMigration { + /** Stable migration name. Once applied, a migration must not be renamed or edited. */ + name: string; + up(context: SchemaMigrationContext): Promise | void; +} + /** * SchemaDefinition - Defines database schema extensions for a plugin. */ export interface SchemaDefinition { - name: string; - tables: unknown[]; + name?: string; + migrations?: SchemaMigration[]; + tables?: unknown[]; } diff --git a/src/types/index.ts b/src/types/index.ts index 10cef901..550bc022 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -42,4 +42,10 @@ export type { DepositRequestHookContext, Context as PluginContext, } from './plugin'; -export type { RouteDefinition, SchemaDefinition, SepErrorCode } from './foundation'; +export type { + RouteDefinition, + SchemaDefinition, + SchemaMigration, + SchemaMigrationContext, + SepErrorCode, +} from './foundation'; diff --git a/src/utils/validation-helpers.ts b/src/utils/validation-helpers.ts index 293621f3..4dc342f7 100644 --- a/src/utils/validation-helpers.ts +++ b/src/utils/validation-helpers.ts @@ -125,6 +125,17 @@ function validateFrameworkNumbers(framework: AnchorKitConfig['framework']): bool throw new Error('framework.queue.concurrency must be a finite integer >= 1'); } + if ( + framework.queue?.maxPendingJobs !== undefined && + !isPositiveSafeInteger(framework.queue.maxPendingJobs) + ) { + throw new Error('framework.queue.maxPendingJobs must be a positive safe integer'); + } + + if (framework.queue?.onError !== undefined && typeof framework.queue.onError !== 'function') { + throw new Error('framework.queue.onError must be a function'); + } + const watchersEnabled = framework.watchers?.enabled; if (watchersEnabled !== undefined && typeof watchersEnabled !== 'boolean') { throw new Error('framework.watchers.enabled must be a boolean'); diff --git a/tests/core/factory-background-startup.test.ts b/tests/core/factory-background-startup.test.ts index 45499b96..cd5b19ca 100644 --- a/tests/core/factory-background-startup.test.ts +++ b/tests/core/factory-background-startup.test.ts @@ -4,6 +4,7 @@ import { InMemoryQueueAdapter } from '@/runtime/queue/in-memory-queue.ts'; import { TransactionWatcher } from '@/runtime/watchers/transaction-watcher.ts'; import { describe, expect, it } from 'vitest'; import { createAnchor } from '@/index.ts'; +import type { AnchorKitConfig } from '@/types/config.ts'; function deferred() { let resolve!: () => void; @@ -14,6 +15,33 @@ function deferred() { return { promise, resolve }; } +function createQueueTestAnchor( + queue: Partial>, +): ReturnType { + return createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: Keypair.random().secret(), + interactiveJwtSecret: 'jwt-test-secret', + distributionAccountSecret: 'distribution-test-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + ], + }, + framework: { + database: { provider: 'sqlite', url: 'file::memory:' }, + queue: { backend: 'memory', ...queue }, + watchers: { enabled: false }, + }, + }); +} + class MockQueueAdapter { public started = false; public startCalls = 0; @@ -63,6 +91,46 @@ class MockWatcher { } describe('AnchorInstance concurrent background startup', () => { + it('forwards queue capacity and error handling options from the public factory', async () => { + const queueErrors: Array<{ job: QueueJob; error: unknown }> = []; + let signalWorkerError!: () => void; + const workerFailed = new Promise((resolve) => { + signalWorkerError = resolve; + }); + const anchor = createQueueTestAnchor({ + maxPendingJobs: 1, + onError: (job, error) => { + queueErrors.push({ job, error }); + signalWorkerError(); + }, + }); + + await anchor.init(); + const queue = (anchor as unknown as { queue: InMemoryQueueAdapter }).queue; + const failingJob = { type: 'unsupported', payload: {} } as unknown as QueueJob; + await queue.enqueue(failingJob); + await expect(queue.enqueue(failingJob)).rejects.toThrow(/queue is full/i); + + await anchor.startBackgroundJobs(); + await workerFailed; + + expect(queueErrors).toHaveLength(1); + expect(queueErrors[0]?.job).toBe(failingJob); + expect(queueErrors[0]?.error).toMatchObject({ message: 'Unknown queue job type: unsupported' }); + await anchor.shutdown(); + }); + + it('validates configured queue capacity and error handler types', () => { + expect(() => createQueueTestAnchor({ maxPendingJobs: 0 })).toThrow( + /framework\.queue\.maxPendingJobs must be a positive safe integer/, + ); + expect(() => + createQueueTestAnchor({ + onError: 'not a function' as unknown as (job: QueueJob, error: unknown) => void, + }), + ).toThrow(/framework\.queue\.onError must be a function/); + }); + it('exposes queue counts without exposing queued job payloads', async () => { const anchor = createAnchor({ network: { network: 'testnet' }, diff --git a/tests/readme-protocol-status.test.ts b/tests/readme-protocol-status.test.ts new file mode 100644 index 00000000..5b06b5d8 --- /dev/null +++ b/tests/readme-protocol-status.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const sep6 = readFileSync(new URL('../src/core/sep6/README.md', import.meta.url), 'utf8'); +const sep24 = readFileSync(new URL('../src/core/sep24/README.md', import.meta.url), 'utf8'); +const sep31 = readFileSync(new URL('../src/core/sep31/README.md', import.meta.url), 'utf8'); +const services = readFileSync(new URL('../src/services/README.md', import.meta.url), 'utf8'); +const status = readFileSync(new URL('../docs/implementation-status.md', import.meta.url), 'utf8'); + +describe('protocol implementation status documentation', () => { + it('distinguishes reserved protocol engines from the implemented HTTP foundation', () => { + for (const stub of [sep6, sep24, sep31]) { + expect(stub).toMatch(/reserved for a future/i); + expect(stub).toMatch(/no .* conformance claim|not evidence of complete .* conformance/i); + expect(stub).toMatch(/shared HTTP runtime/i); + } + + expect(sep24).toContain('POST /transactions/deposit/interactive'); + expect(services).toContain('in-memory queue'); + expect(services).toContain('transaction watcher'); + expect(status).toContain('Complete protocol engines and conformance'); + expect(status).toContain('not complete protocol implementations'); + }); +}); diff --git a/tests/runtime/database/migrate-single-flight.test.ts b/tests/runtime/database/migrate-single-flight.test.ts index bba205ce..c499f610 100644 --- a/tests/runtime/database/migrate-single-flight.test.ts +++ b/tests/runtime/database/migrate-single-flight.test.ts @@ -17,6 +17,7 @@ interface FakePostgresQuery { */ function createDelayedFakePostgres(delayMs = 5) { const queries: FakePostgresQuery[] = []; + const appliedMigrations = new Set(); let inFlight = 0; let maxInFlight = 0; @@ -31,7 +32,13 @@ function createDelayedFakePostgres(delayMs = 5) { maxInFlight = Math.max(maxInFlight, inFlight); await new Promise((resolve) => setTimeout(resolve, delayMs)); inFlight -= 1; - return { rows: [] as Record[] }; + if (sql.startsWith('INSERT INTO anchor_kit_migrations')) { + appliedMigrations.add(String(values?.[0])); + } + const rows = sql.startsWith('SELECT name FROM anchor_kit_migrations') + ? Array.from(appliedMigrations, (name) => ({ name })) + : []; + return { rows }; }, async end() { // no-op @@ -81,9 +88,9 @@ describe('Concurrent database migrations (#600)', () => { await Promise.all(Array.from({ length: 10 }, () => adapter.migrate())); - // Ten statements define one migration sequence; concurrent callers - // must share it rather than each replaying the whole sequence. - expect(client.queries).toHaveLength(10); + expect( + client.queries.filter((query) => query.sql.startsWith('INSERT INTO anchor_kit_migrations')), + ).toHaveLength(2); expect(client.maxInFlight).toBe(1); }); @@ -98,7 +105,9 @@ describe('Concurrent database migrations (#600)', () => { const results = await Promise.all(Array.from({ length: 5 }, () => adapter.migrate())); expect(results).toEqual([undefined, undefined, undefined, undefined, undefined]); - expect(client.queries).toHaveLength(10); + expect( + client.queries.filter((query) => query.sql.startsWith('INSERT INTO anchor_kit_migrations')), + ).toHaveLength(2); }); it('serializes SQLite migration calls into a single sequence', async () => { @@ -110,16 +119,15 @@ describe('Concurrent database migrations (#600)', () => { await adapter.connect(); const sqlite = (adapter as unknown as { sqlite: Database }).sqlite; - let execCount = 0; - const originalExec = sqlite.exec.bind(sqlite); - sqlite.exec = ((sql: string) => { - execCount += 1; - return originalExec(sql); - }) as typeof sqlite.exec; - await Promise.all(Array.from({ length: 5 }, () => adapter.migrate())); - expect(execCount).toBe(5); + const applied = sqlite + .prepare('SELECT name FROM anchor_kit_migrations ORDER BY name') + .all() as Array<{ name: string }>; + expect(applied.map((row) => row.name)).toEqual([ + 'core:001-initial-schema', + 'core:002-idempotency-status', + ]); // The schema is usable after the shared migration. await adapter.insertAuthChallenge({ @@ -174,7 +182,9 @@ describe('Concurrent database migrations (#600)', () => { expect(client.queries).toHaveLength(1); await expect(adapter.migrate()).resolves.toBeUndefined(); - expect(client.queries).toHaveLength(11); + expect( + client.queries.filter((query) => query.sql.startsWith('INSERT INTO anchor_kit_migrations')), + ).toHaveLength(2); }); it('lets a fresh caller retry once the in-flight migration has settled', async () => { @@ -189,9 +199,15 @@ describe('Concurrent database migrations (#600)', () => { const second = adapter.migrate(); await Promise.all([first, second]); - // A later, non-overlapping call runs its own sequence because the guard is - // cleared after the shared sequence settles. + // A later call reads the persisted migration records and skips applied work. await adapter.migrate(); - expect(client.queries).toHaveLength(20); + expect( + client.queries.filter((query) => query.sql.startsWith('INSERT INTO anchor_kit_migrations')), + ).toHaveLength(2); + expect( + client.queries.filter((query) => + query.sql.startsWith('SELECT name FROM anchor_kit_migrations'), + ), + ).toHaveLength(2); }); }); diff --git a/tests/runtime/database/versioned-migrations.test.ts b/tests/runtime/database/versioned-migrations.test.ts new file mode 100644 index 00000000..218c5a13 --- /dev/null +++ b/tests/runtime/database/versioned-migrations.test.ts @@ -0,0 +1,229 @@ +import { createAnchor, makeSqliteDbUrlForTests } from '@/index.ts'; +import { SqlDatabaseAdapter } from '@/runtime/database/sql-database-adapter.ts'; +import type { SchemaMigration } from '@/types/foundation.ts'; +import { Database } from 'bun:sqlite'; +import { unlinkSync } from 'node:fs'; +import { Keypair } from '@stellar/stellar-sdk'; +import { describe, expect, it } from 'vitest'; + +function removeSqliteFile(path: string): void { + try { + unlinkSync(path); + } catch { + // SQLite may not have created the file if initialization failed early. + } +} + +describe('versioned database migrations', () => { + it('adopts the current schema without losing existing records', async () => { + const url = makeSqliteDbUrlForTests(); + const path = url.slice('file:'.length); + const current = new Database(path); + current.exec(` + CREATE TABLE auth_challenges ( + id TEXT PRIMARY KEY, + account TEXT NOT NULL, + challenge TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + consumed_at TEXT, + created_at TEXT NOT NULL + ); + INSERT INTO auth_challenges + (id, account, challenge, expires_at, consumed_at, created_at) + VALUES ('existing-challenge', 'GABC', 'challenge-1', '2099-01-01', NULL, '2025-01-01'); + + CREATE TABLE idempotency_keys ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'completed', + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(scope, idempotency_key) + ); + INSERT INTO idempotency_keys + (id, scope, idempotency_key, request_hash, status, status_code, response_body, created_at) + VALUES ('existing-key', 'deposit', 'request-1', 'hash-1', 'pending', 202, '{"status":"pending"}', '2025-01-01'); + `); + current.close(); + + const adapter = new SqlDatabaseAdapter({ provider: 'sqlite', url }); + try { + await adapter.connect(); + await adapter.migrate(); + + const database = (adapter as unknown as { sqlite: Database }).sqlite; + expect(database.prepare('SELECT id, challenge FROM auth_challenges').get()).toEqual({ + id: 'existing-challenge', + challenge: 'challenge-1', + }); + expect( + database.prepare('SELECT id, status, response_body FROM idempotency_keys').get(), + ).toEqual({ + id: 'existing-key', + status: 'pending', + response_body: '{"status":"pending"}', + }); + } finally { + await adapter.disconnect(); + removeSqliteFile(path); + } + }); + + it('upgrades a legacy schema without losing records and is repeatable', async () => { + const url = makeSqliteDbUrlForTests(); + const path = url.slice('file:'.length); + const legacy = new Database(path); + legacy.exec(` + CREATE TABLE idempotency_keys ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(scope, idempotency_key) + ); + INSERT INTO idempotency_keys + (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + VALUES ('legacy-1', 'deposit', 'request-1', 'hash-1', 202, '{"status":"pending"}', '2025-01-01'); + `); + legacy.close(); + + const adapter = new SqlDatabaseAdapter({ provider: 'sqlite', url }); + try { + await adapter.connect(); + await adapter.migrate(); + await adapter.migrate(); + + const database = (adapter as unknown as { sqlite: Database }).sqlite; + expect(database.prepare('SELECT id, status FROM idempotency_keys').get()).toEqual({ + id: 'legacy-1', + status: 'completed', + }); + expect( + database.prepare('SELECT name FROM anchor_kit_migrations ORDER BY name').all(), + ).toEqual([{ name: 'core:001-initial-schema' }, { name: 'core:002-idempotency-status' }]); + } finally { + await adapter.disconnect(); + removeSqliteFile(path); + } + }); + + it('rolls back failed migrations and allows a successful retry', async () => { + const url = makeSqliteDbUrlForTests(); + const path = url.slice('file:'.length); + let failOnce = true; + const migration: SchemaMigration = { + name: 'fixture:retryable', + async up({ execute }) { + await execute('CREATE TABLE retryable_migration (value TEXT NOT NULL)'); + if (failOnce) { + failOnce = false; + throw new Error('transient migration failure'); + } + }, + }; + const adapter = new SqlDatabaseAdapter({ provider: 'sqlite', url }, [migration]); + + try { + await adapter.connect(); + await expect(adapter.migrate()).rejects.toThrow('transient migration failure'); + + let database = (adapter as unknown as { sqlite: Database }).sqlite; + expect( + database + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('retryable_migration'), + ).toBeNull(); + expect( + database + .prepare('SELECT name FROM anchor_kit_migrations WHERE name = ?') + .get('fixture:retryable'), + ).toBeNull(); + + await adapter.migrate(); + database = (adapter as unknown as { sqlite: Database }).sqlite; + expect( + database + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('retryable_migration'), + ).toEqual({ name: 'retryable_migration' }); + expect( + database + .prepare('SELECT name FROM anchor_kit_migrations WHERE name = ?') + .get('fixture:retryable'), + ).toEqual({ name: 'fixture:retryable' }); + } finally { + await adapter.disconnect(); + removeSqliteFile(path); + } + }); + + it('registers plugin migrations through the public factory', async () => { + const url = makeSqliteDbUrlForTests(); + const path = url.slice('file:'.length); + const key = Keypair.random().secret(); + const anchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: key, + interactiveJwtSecret: 'plugin-migration-test-secret', + distributionAccountSecret: key, + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + ], + }, + framework: { + database: { provider: 'sqlite', url }, + watchers: { enabled: false }, + }, + }); + anchor.use({ + id: 'ledger', + schema: { + migrations: [ + { + name: '001-ledger-entries', + async up({ execute }) { + await execute( + 'CREATE TABLE ledger_entries (id TEXT PRIMARY KEY, reference TEXT NOT NULL)', + ); + }, + }, + ], + }, + }); + + try { + await anchor.init(); + const database = new Database(path); + try { + expect( + database + .prepare('SELECT name FROM anchor_kit_migrations WHERE name = ?') + .get('plugin:ledger:001-ledger-entries'), + ).toEqual({ name: 'plugin:ledger:001-ledger-entries' }); + expect( + database + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('ledger_entries'), + ).toEqual({ name: 'ledger_entries' }); + } finally { + database.close(); + } + } finally { + await anchor.shutdown(); + removeSqliteFile(path); + } + }); +}); From 7a473acf6393345026b6f8fac7d085f549a0840a Mon Sep 17 00:00:00 2001 From: 0xNgoo <113470324+0xNgoo@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:45:26 +0100 Subject: [PATCH 2/3] Fix Node package smoke and alias build checks --- ci/fallow-health-baseline.json | 2 +- package.json | 2 +- scripts/check-node-postgres-package.mjs | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ci/fallow-health-baseline.json b/ci/fallow-health-baseline.json index dbb99d99..da62f865 100644 --- a/ci/fallow-health-baseline.json +++ b/ci/fallow-health-baseline.json @@ -1,4 +1,4 @@ { "healthScore": 60, - "hotspotCount": 40 + "hotspotCount": 41 } diff --git a/package.json b/package.json index d97defc6..788110b6 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "type": "module", "scripts": { "dev": "bun run --watch src/index.ts", - "build": "rm -rf dist && bun build src/index.ts --outdir dist --target node --format esm --packages external && bunx tsc -p tsconfig.types.json && node scripts/fix-dts-imports.mjs", + "build": "rm -rf dist && bun x rolldown --config rolldown.config.mjs && bunx tsc -p tsconfig.types.json && node scripts/fix-dts-imports.mjs && node scripts/check-alias-imports.mjs", "check:aliases": "node scripts/check-alias-imports.mjs", "check:consumer": "node fixtures/consumer/verify.mjs && bun run check:package-consumer", "check:package-consumer": "bun run build && node scripts/check-package-consumer.mjs", diff --git a/scripts/check-node-postgres-package.mjs b/scripts/check-node-postgres-package.mjs index 4de8533c..416804b0 100644 --- a/scripts/check-node-postgres-package.mjs +++ b/scripts/check-node-postgres-package.mjs @@ -15,7 +15,11 @@ try { ['pack', '--ignore-scripts', '--json', '--pack-destination', tempDirectory], { encoding: 'utf8' }, ); - const [packedPackage] = JSON.parse(packOutput); + const packJsonStart = packOutput.indexOf('['); + if (packJsonStart < 0) { + throw new Error('npm pack did not return JSON metadata'); + } + const [packedPackage] = JSON.parse(packOutput.slice(packJsonStart)); if (!packedPackage?.filename) { throw new Error('npm pack did not return a tarball filename'); } @@ -77,7 +81,7 @@ try { `, ); - execFileSync('npm', ['install', '--no-audit', '--no-fund', tarballPath], { + execFileSync('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarballPath], { cwd: tempDirectory, stdio: 'inherit', }); From e756465baf9d7b2e06026bef2fc5bb49af09cfda Mon Sep 17 00:00:00 2001 From: 0xNgoo <113470324+0xNgoo@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:47:55 +0100 Subject: [PATCH 3/3] fix: use valid key in PostgreSQL package smoke test --- scripts/check-node-postgres-package.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-node-postgres-package.mjs b/scripts/check-node-postgres-package.mjs index 416804b0..b9a607d9 100644 --- a/scripts/check-node-postgres-package.mjs +++ b/scripts/check-node-postgres-package.mjs @@ -33,7 +33,7 @@ try { import express from 'express'; import { createAnchor } from 'anchor-kit'; - const signingKey = 'SCZJBZ6S7HWMQVT7DM74JVHVDKCEE5P6I6T3E5M7LJM6LJM6LJM6LJM6'; + const signingKey = 'SAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC5MY'; const anchor = createAnchor({ network: { network: 'testnet' }, server: { interactiveDomain: 'https://anchor.example.com' },