diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index 208478ce..9531b944 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -10,7 +10,7 @@ on: options: - acp - console - - iii-database + - database - iii-directory - iii-lsp - iii-lsp-vscode diff --git a/.github/workflows/iii-database-e2e.yml b/.github/workflows/database-e2e.yml similarity index 81% rename from .github/workflows/iii-database-e2e.yml rename to .github/workflows/database-e2e.yml index 3509d249..2b09fca3 100644 --- a/.github/workflows/iii-database-e2e.yml +++ b/.github/workflows/database-e2e.yml @@ -1,14 +1,14 @@ -name: iii-database E2E +name: database E2E on: pull_request: paths: - - 'iii-database/**' - - '.github/workflows/iii-database-e2e.yml' + - 'database/**' + - '.github/workflows/database-e2e.yml' workflow_dispatch: concurrency: - group: iii-database-e2e-${{ github.ref }} + group: database-e2e-${{ github.ref }} cancel-in-progress: true env: @@ -31,14 +31,14 @@ jobs: - name: Cache cargo registry & build uses: Swatinem/rust-cache@v2 with: - workspaces: iii-database + workspaces: database - name: Install Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - cache-dependency-path: iii-database/tests/e2e/workers/harness/package-lock.json + cache-dependency-path: database/tests/e2e/workers/harness/package-lock.json # GHA `services:` blocks can't pass `-c wal_level=logical` to postgres, # which the row-change tests require. Reuse the same docker-compose @@ -53,7 +53,7 @@ jobs: run: iii --version - name: Run harness - working-directory: iii-database/tests/e2e + working-directory: database/tests/e2e # --with-cargo-test runs `cargo test --all-features` against the # already-running postgres + mysql so the gated driver/pool tests # actually exercise their target DBs (otherwise they early-return). @@ -63,7 +63,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: iii-database-e2e-report + name: database-e2e-report path: | - iii-database/tests/e2e/reports/ + database/tests/e2e/reports/ retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e643bc3..0cc7d89d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: tags: - 'acp/v*' - 'console/v*' - - 'iii-database/v*' + - 'database/v*' - 'iii-directory/v*' - 'iii-lsp/v*' - 'image-resize/v*' diff --git a/.gitignore b/.gitignore index cac0c6b8..8af4a57b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ node_modules package-lock.json pnpm-lock.yaml yarn.lock -!iii-database/tests/e2e/workers/harness/package-lock.json +!database/tests/e2e/workers/harness/package-lock.json !shell/tests/e2e/workers/harness/package-lock.json !console/web/pnpm-lock.yaml diff --git a/README.md b/README.md index 02519b33..7fa0fc12 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ asset for the host from the workers registry API. |---|---|---| | [`acp`](acp/) | Rust | Agent Client Protocol surface — stdio JSON-RPC, exposes iii agents as ACP sessions. | | [`harness-node`](harness-node/) | Node | TS port of the iii harness stack — bundles `harness`, `turn-orchestrator`, `approval-gate`, `session`, `hook-fanout`, `auth-credentials`, `models-catalog`, `provider-anthropic`, `provider-openai`, `llm-budget`, and `context-compaction` as one pnpm monorepo. See [`harness-node/README.md`](harness-node/README.md). | -| [`iii-database`](iii-database/) | Rust | PostgreSQL, MySQL, and SQLite client — query, execute, transactions, prepared statements, and change feeds. | +| [`database`](database/) | Rust | PostgreSQL, MySQL, and SQLite client — query, execute, transactions, prepared statements, and change feeds. | | [`iii-directory`](iii-directory/) | Rust | Engine introspection (functions / triggers / workers), workers-registry proxy, and filesystem-backed skill + prompt reader. | | [`iii-lsp`](iii-lsp/) | Rust | Language Server for iii function ids, trigger configs, and worker discovery. Autocomplete / hover across JS/TS, Python, Rust. | | [`iii-lsp-vscode`](iii-lsp-vscode/) | Node | VS Code extension that embeds `iii-lsp`. | diff --git a/iii-database/.gitignore b/database/.gitignore similarity index 100% rename from iii-database/.gitignore rename to database/.gitignore diff --git a/iii-database/Cargo.lock b/database/Cargo.lock similarity index 99% rename from iii-database/Cargo.lock rename to database/Cargo.lock index 90b49779..cde4513c 100644 --- a/iii-database/Cargo.lock +++ b/database/Cargo.lock @@ -594,6 +594,46 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "database" +version = "0.0.4" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "clap", + "deadpool-postgres", + "futures-util", + "iii-sdk", + "mysql_async", + "postgres-protocol", + "postgres-types", + "r2d2", + "r2d2_sqlite", + "rusqlite", + "rust_decimal", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "rustls-webpki", + "schemars", + "serde", + "serde_json", + "serde_yml", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -1268,46 +1308,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "iii-database" -version = "0.0.4" -dependencies = [ - "anyhow", - "async-trait", - "base64 0.22.1", - "bytes", - "chrono", - "clap", - "deadpool-postgres", - "futures-util", - "iii-sdk", - "mysql_async", - "postgres-protocol", - "postgres-types", - "r2d2", - "r2d2_sqlite", - "rusqlite", - "rust_decimal", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "rustls-pki-types", - "rustls-webpki", - "schemars", - "serde", - "serde_json", - "serde_yml", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tokio-postgres", - "tokio-postgres-rustls", - "tracing", - "tracing-subscriber", - "url", - "uuid", -] - [[package]] name = "iii-sdk" version = "0.12.0-next.1" diff --git a/iii-database/Cargo.toml b/database/Cargo.toml similarity index 98% rename from iii-database/Cargo.toml rename to database/Cargo.toml index 3b945074..99ddcbf4 100644 --- a/iii-database/Cargo.toml +++ b/database/Cargo.toml @@ -1,13 +1,13 @@ [workspace] [package] -name = "iii-database" +name = "database" version = "0.0.4" edition = "2021" publish = false [[bin]] -name = "iii-database" +name = "database" path = "src/main.rs" [lib] diff --git a/iii-database/README.md b/database/README.md similarity index 81% rename from iii-database/README.md rename to database/README.md index b4fbb881..08039d42 100644 --- a/iii-database/README.md +++ b/database/README.md @@ -1,4 +1,4 @@ -# iii-database +# database > Connect to PostgreSQL, MySQL, and SQLite. Run queries, prepared statements, transactions, and subscribe to row-level change feeds. @@ -12,7 +12,7 @@ ## Install ```sh -iii worker add iii-database@1.0.0 +iii worker add database@1.0.0 ``` ## Configure @@ -21,7 +21,7 @@ Add a single `databases` block to your `config.yaml`. SQLite is the recommended ```yaml workers: - - name: iii-database + - name: database config: databases: primary: @@ -95,18 +95,18 @@ SQLite ignores the `tls` block (local-file driver). ```ts import { call } from 'iii-sdk' -await call('iii-database::execute', { +await call('database::execute', { db: 'primary', sql: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT)' }) -await call('iii-database::execute', { +await call('database::execute', { db: 'primary', sql: 'INSERT INTO users (email) VALUES (?), (?)', params: ['a@x', 'b@x'] }) -const { rows } = await call('iii-database::query', { +const { rows } = await call('database::query', { db: 'primary', sql: 'SELECT id, email FROM users ORDER BY id' }) @@ -116,27 +116,27 @@ const { rows } = await call('iii-database::query', { | Function | Purpose | |---|---| -| `iii-database::query` | Read SQL. Returns `{ rows, row_count, columns }`. | -| `iii-database::execute` | Write SQL. Returns `{ affected_rows, last_insert_id, returned_rows }`.
**`last_insert_id` semantics:** SQLite/MySQL surface the engine's `last_insert_rowid()` / `LAST_INSERT_ID()` (only populated for INSERT). Postgres has no equivalent — `last_insert_id` is set from the **first column of the first RETURNING row**, so put your PK first: `RETURNING id, name`, not `RETURNING name, id`. | -| `iii-database::prepareStatement` | Pin a connection and return `{ handle: { id, expires_at } }`. | -| `iii-database::runStatement` | Run a previously-prepared handle. (No `timeout_ms` — uses the pinned connection's session lifetime; configure via `ttl_seconds` on `prepareStatement`.) | -| `iii-database::transaction` | Atomic batch sequence; rolls back on first failure. One-shot — pass all statements together. | -| `iii-database::beginTransaction` | Open an interactive transaction. Returns `{ transaction: { id, expires_at } }`. Configurable `timeout_ms` (default 30 000, max 300 000) auto-rolls back if the deadline elapses. | -| `iii-database::transactionQuery` | Read SQL inside an interactive transaction. Same envelope as `query`. | -| `iii-database::transactionExecute` | Write SQL inside an interactive transaction. Same envelope as `execute`. Rejects bare `BEGIN`/`COMMIT`/`ROLLBACK`/`SAVEPOINT`/`SET TRANSACTION` with `INVALID_PARAM` — finalize via the dedicated handlers below. | -| `iii-database::commitTransaction` | Commit and finalize an interactive transaction. Subsequent calls against the same id return `TRANSACTION_NOT_FOUND`. | -| `iii-database::rollbackTransaction` | Rollback and finalize an interactive transaction. Subsequent calls against the same id return `TRANSACTION_NOT_FOUND`. | +| `database::query` | Read SQL. Returns `{ rows, row_count, columns }`. | +| `database::execute` | Write SQL. Returns `{ affected_rows, last_insert_id, returned_rows }`.
**`last_insert_id` semantics:** SQLite/MySQL surface the engine's `last_insert_rowid()` / `LAST_INSERT_ID()` (only populated for INSERT). Postgres has no equivalent — `last_insert_id` is set from the **first column of the first RETURNING row**, so put your PK first: `RETURNING id, name`, not `RETURNING name, id`. | +| `database::prepareStatement` | Pin a connection and return `{ handle: { id, expires_at } }`. | +| `database::runStatement` | Run a previously-prepared handle. (No `timeout_ms` — uses the pinned connection's session lifetime; configure via `ttl_seconds` on `prepareStatement`.) | +| `database::transaction` | Atomic batch sequence; rolls back on first failure. One-shot — pass all statements together. | +| `database::beginTransaction` | Open an interactive transaction. Returns `{ transaction: { id, expires_at } }`. Configurable `timeout_ms` (default 30 000, max 300 000) auto-rolls back if the deadline elapses. | +| `database::transactionQuery` | Read SQL inside an interactive transaction. Same envelope as `query`. | +| `database::transactionExecute` | Write SQL inside an interactive transaction. Same envelope as `execute`. Rejects bare `BEGIN`/`COMMIT`/`ROLLBACK`/`SAVEPOINT`/`SET TRANSACTION` with `INVALID_PARAM` — finalize via the dedicated handlers below. | +| `database::commitTransaction` | Commit and finalize an interactive transaction. Subsequent calls against the same id return `TRANSACTION_NOT_FOUND`. | +| `database::rollbackTransaction` | Rollback and finalize an interactive transaction. Subsequent calls against the same id return `TRANSACTION_NOT_FOUND`. | ## Triggers -### `iii-database::row-change` +### `database::row-change` Postgres only. Streams row-level changes via logical replication (`pgoutput`). > **NOTE (v1.0.0):** Event dispatch is not yet functional. The publication and replication slot are created at startup, but the streaming decode loop is stubbed pending an upstream `tokio-postgres` replication API release. Operators can pre-provision slots and publications now; events will start flowing in a later release. ```yaml triggers: - - type: iii-database::row-change + - type: database::row-change config: db: primary schema: public @@ -171,7 +171,7 @@ A few operations are no-ops on certain drivers. They emit a `tracing::warn!` rat | `execute` with `returning: [...]` | ✓ | ✓ | warn-once + ignore | | `transaction` `isolation: read_committed` / `repeatable_read` | warn + use serializable | ✓ | ✓ | | `transaction` `isolation: serializable` | ✓ (`BEGIN IMMEDIATE`) | ✓ | ✓ | -| `iii-database::row-change` trigger | — | setup-only in v1.0.0 (see above) | — | +| `database::row-change` trigger | — | setup-only in v1.0.0 (see above) | — | ## Troubleshooting diff --git a/iii-database/config.yaml b/database/config.yaml similarity index 100% rename from iii-database/config.yaml rename to database/config.yaml diff --git a/iii-database/config.yaml.example b/database/config.yaml.example similarity index 100% rename from iii-database/config.yaml.example rename to database/config.yaml.example diff --git a/iii-database/iii.worker.yaml b/database/iii.worker.yaml similarity index 83% rename from iii-database/iii.worker.yaml rename to database/iii.worker.yaml index d764755e..2698d88d 100644 --- a/iii-database/iii.worker.yaml +++ b/database/iii.worker.yaml @@ -1,7 +1,7 @@ iii: v1 -name: iii-database +name: database language: rust deploy: binary manifest: Cargo.toml -bin: iii-database +bin: database description: Talk to PostgreSQL, MySQL, and SQLite from iii — query, execute, transactions, prepared statements, and change feeds. diff --git a/iii-database/skills/iii-database/execute.md b/database/skills/iii-database/execute.md similarity index 85% rename from iii-database/skills/iii-database/execute.md rename to database/skills/iii-database/execute.md index 9e6ce272..9bcc6609 100644 --- a/iii-database/skills/iii-database/execute.md +++ b/database/skills/iii-database/execute.md @@ -1,12 +1,12 @@ --- type: how-to -function_id: iii-database::execute +function_id: database::execute title: Run a write statement and return affected rows --- # When to use -Call `iii-database::execute` for any write-side SQL — `INSERT`, `UPDATE`, +Call `database::execute` for any write-side SQL — `INSERT`, `UPDATE`, `DELETE`, or DDL (`CREATE TABLE`, `ALTER TABLE`, `DROP INDEX`, ...). The response carries `affected_rows`, an optional `last_insert_id`, and a `returned_rows` array populated when the caller asks for `RETURNING`-style @@ -21,12 +21,12 @@ Reach for it when: (e.g. server-defaulted `id` + `created_at`) — set `returning` on Postgres or SQLite. -Use [`iii-database::query`](iii://iii-database/query) instead when the +Use [`database::query`](iii://database/query) instead when the statement reads — `execute` does run a `SELECT` if you give it one but discards the rows and reports `affected_rows: 0`, which is rarely what a SELECT caller wants. -Use [`iii-database::transaction`](iii://iii-database/transaction) instead +Use [`database::transaction`](iii://database/transaction) instead when you need several writes to commit atomically — `execute` runs each call as its own implicit transaction. @@ -43,7 +43,7 @@ call as its own implicit transaction. `db` and `sql` are required. Empty/whitespace-only `sql` is rejected uniformly with `DRIVER_ERROR` carrying `message: "empty SQL"` (matches -[`iii-database::query`](iii://iii-database/query)'s contract). +[`database::query`](iii://database/query)'s contract). `params` accepts JSON primitives, arrays, and objects exactly like `query` — same per-driver placeholder syntax (`?` for sqlite/mysql, @@ -85,7 +85,7 @@ directly in `sql` for sqlite/postgres rather than passing `RETURNING id, name` works; `RETURNING name, id` returns `name` as `last_insert_id`. With no `RETURNING` clause, the field is `null`. - `returned_rows` mirrors the row-of-objects shape from - [`iii-database::query`](iii://iii-database/query). Empty `[]` when the + [`database::query`](iii://database/query). Empty `[]` when the statement omits `RETURNING` or runs on MySQL. # Worked example @@ -131,6 +131,6 @@ Returns `{ "affected_rows": 17, "last_insert_id": null, "returned_rows": [] }`. # Related -- `iii-database::query` — for read SQL; returns materialized rows + column metadata instead of `affected_rows`. -- `iii-database::transaction` — group several writes into one atomic batch with rollback on first failure. -- `iii-database::prepareStatement` + `iii-database::runStatement` — re-run the same parameterized write many times without re-parsing on each call. +- `database::query` — for read SQL; returns materialized rows + column metadata instead of `affected_rows`. +- `database::transaction` — group several writes into one atomic batch with rollback on first failure. +- `database::prepareStatement` + `database::runStatement` — re-run the same parameterized write many times without re-parsing on each call. diff --git a/iii-database/skills/iii-database/interactive-transaction.md b/database/skills/iii-database/interactive-transaction.md similarity index 85% rename from iii-database/skills/iii-database/interactive-transaction.md rename to database/skills/iii-database/interactive-transaction.md index 0edcd58c..8aa6fb00 100644 --- a/iii-database/skills/iii-database/interactive-transaction.md +++ b/database/skills/iii-database/interactive-transaction.md @@ -1,6 +1,6 @@ --- type: how-to -functions: [iii-database::beginTransaction, iii-database::transactionQuery, iii-database::transactionExecute, iii-database::commitTransaction, iii-database::rollbackTransaction] +functions: [database::beginTransaction, database::transactionQuery, database::transactionExecute, database::commitTransaction, database::rollbackTransaction] title: Run a stateful transaction across multiple RPC calls with a timeout-driven auto-rollback --- @@ -17,17 +17,17 @@ Reach for it when: - You need to take a decision in application code *between* statements that must commit together (the batch - [`iii-database::transaction`](iii://iii-database/transaction) requires + [`database::transaction`](iii://database/transaction) requires every statement up-front, so it can't carry inter-statement logic). - You want a single transaction id to thread through a long-running workflow without holding a network connection open in your code. - You need stronger-than-default isolation for a multi-step read+write flow (pass `isolation: "serializable"` to `beginTransaction`). -Use [`iii-database::transaction`](iii://iii-database/transaction) instead +Use [`database::transaction`](iii://database/transaction) instead when every statement is known in advance — it skips the registry overhead and the per-call round-trip cost. Use -[`iii-database::execute`](iii://iii-database/execute) for one-off writes; +[`database::execute`](iii://database/execute) for one-off writes; each `execute` call is its own auto-committed transaction. # Lifecycle @@ -47,7 +47,7 @@ configured `timeout_ms` elapses before either finalizer lands, the worker auto-rolls back and removes the id; the next call also gets `TRANSACTION_NOT_FOUND`. -# `iii-database::beginTransaction` +# `database::beginTransaction` ## Inputs @@ -60,7 +60,7 @@ auto-rolls back and removes the id; the next call also gets ``` `db` is required. `isolation` accepts the same three values as the batch -[`iii-database::transaction`](iii://iii-database/transaction); any other +[`database::transaction`](iii://database/transaction); any other string is rejected with `INVALID_PARAM`. On SQLite, `read_committed` / `repeatable_read` log a one-line `tracing::warn!` and fall back to `BEGIN IMMEDIATE` (serializable in practice). @@ -90,11 +90,11 @@ between functions if needed, but never share it across unrelated requests. already auto-rolled back. Re-issue `beginTransaction` and start over; the original id is gone. -# `iii-database::transactionQuery` / `iii-database::transactionExecute` +# `database::transactionQuery` / `database::transactionExecute` The envelopes are **identical to the standalone -[`query`](iii://iii-database/query) and -[`execute`](iii://iii-database/execute)** handlers — same row-of-objects +[`query`](iii://database/query) and +[`execute`](iii://database/execute)** handlers — same row-of-objects shape, same `columns` metadata, same `affected_rows` / `last_insert_id` / `returned_rows` semantics. The only difference: SQL runs on the pinned transaction connection. @@ -108,7 +108,7 @@ runs on the pinned transaction connection. ``` `transactionExecute` adds a `returning` array exactly like -[`execute`](iii://iii-database/execute) for Postgres + SQLite +[`execute`](iii://database/execute) for Postgres + SQLite `RETURNING` clauses; MySQL ignores it (logged warn-once). `transactionExecute` **rejects** bare `BEGIN`, `COMMIT`, `ROLLBACK`, @@ -122,7 +122,7 @@ Concurrent calls against the same `transaction_id` serialize on the per-conn mutex. The worker doesn't pipeline statements within one transaction. -# `iii-database::commitTransaction` / `iii-database::rollbackTransaction` +# `database::commitTransaction` / `database::rollbackTransaction` ```json { "transaction_id": "550e8400-..." } @@ -176,7 +176,7 @@ must see the updated balance before deciding whether to credit: ```ts const { transaction } = await iii.trigger({ - function_id: 'iii-database::beginTransaction', + function_id: 'database::beginTransaction', payload: { db: 'primary', isolation: 'serializable', @@ -186,7 +186,7 @@ const { transaction } = await iii.trigger({ try { const debit = await iii.trigger({ - function_id: 'iii-database::transactionExecute', + function_id: 'database::transactionExecute', payload: { transaction_id: transaction.id, sql: 'UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?', @@ -195,13 +195,13 @@ try { }) if (debit.affected_rows !== 1) { await iii.trigger({ - function_id: 'iii-database::rollbackTransaction', + function_id: 'database::rollbackTransaction', payload: { transaction_id: transaction.id }, }) throw new Error('insufficient funds') } await iii.trigger({ - function_id: 'iii-database::transactionExecute', + function_id: 'database::transactionExecute', payload: { transaction_id: transaction.id, sql: 'UPDATE accounts SET balance = balance + ? WHERE id = ?', @@ -209,7 +209,7 @@ try { }, }) await iii.trigger({ - function_id: 'iii-database::commitTransaction', + function_id: 'database::commitTransaction', payload: { transaction_id: transaction.id }, }) } catch (e) { @@ -217,7 +217,7 @@ try { // if a prior step already rolled it back or the deadline elapsed. try { await iii.trigger({ - function_id: 'iii-database::rollbackTransaction', + function_id: 'database::rollbackTransaction', payload: { transaction_id: transaction.id }, }) } catch {/* ignore */} @@ -227,14 +227,14 @@ try { # Related -- [`iii-database::transaction`](iii://iii-database/transaction) — atomic +- [`database::transaction`](iii://database/transaction) — atomic batch when every statement is known up-front; skip the round-trip overhead of the interactive surface. -- [`iii-database::query`](iii://iii-database/query) / - [`iii-database::execute`](iii://iii-database/execute) — one-off +- [`database::query`](iii://database/query) / + [`database::execute`](iii://database/execute) — one-off read/write outside any transaction. -- [`iii-database::prepareStatement` + - `runStatement`](iii://iii-database/prepared-statements) — pin a +- [`database::prepareStatement` + + `runStatement`](iii://database/prepared-statements) — pin a connection for repeated parameterized calls **without** transactional semantics. Useful for read-heavy workloads where the prepared plan is the bottleneck. diff --git a/iii-database/skills/iii-database/prepared-statements.md b/database/skills/iii-database/prepared-statements.md similarity index 82% rename from iii-database/skills/iii-database/prepared-statements.md rename to database/skills/iii-database/prepared-statements.md index 93c2bfbb..8ba45fb6 100644 --- a/iii-database/skills/iii-database/prepared-statements.md +++ b/database/skills/iii-database/prepared-statements.md @@ -1,6 +1,6 @@ --- type: how-to -functions: [iii-database::prepareStatement, iii-database::runStatement] +functions: [database::prepareStatement, database::runStatement] title: Prepare a SQL statement once, run it many times against a pinned connection --- @@ -15,10 +15,10 @@ re-executes that handle with new params on the same connection. | Question | Use this | |------------------------------------------------------------------|-----------------------------------------------------------| -| Will this exact SQL run more than a handful of times? | `iii-database::prepareStatement` first, then re-use. | -| Do I have a UUID handle already? | `iii-database::runStatement` with new `params`. | -| Need session-scoped state (e.g. a Postgres advisory lock) across calls? | `iii-database::prepareStatement` pins one connection. | -| Just running this SQL once? | [`iii-database::query`](iii://iii-database/query) — no handle, no pool pinning. | +| Will this exact SQL run more than a handful of times? | `database::prepareStatement` first, then re-use. | +| Do I have a UUID handle already? | `database::runStatement` with new `params`. | +| Need session-scoped state (e.g. a Postgres advisory lock) across calls? | `database::prepareStatement` pins one connection. | +| Just running this SQL once? | [`database::query`](iii://database/query) — no handle, no pool pinning. | **A live handle pins one pool connection until its TTL expires.** The default TTL is 1 hour and the cap is 24 hours; while a handle exists, @@ -27,14 +27,14 @@ many handles as you need concurrently, and let them expire (or simply stop calling them) when the workload ends — there is no `release` or `close` function. -Use [`iii-database::query`](iii://iii-database/query) instead when the +Use [`database::query`](iii://database/query) instead when the SQL runs only once. Use -[`iii-database::transaction`](iii://iii-database/transaction) instead +[`database::transaction`](iii://database/transaction) instead when several statements need atomic commit/rollback semantics — handles are not transactions; commit boundaries are defined by the SQL you run through them. -# `iii-database::prepareStatement` +# `database::prepareStatement` ## Inputs @@ -73,7 +73,7 @@ entry as a side effect. - `handle.expires_at` is when the worker will stop honouring the handle. After that point `runStatement` returns `STATEMENT_NOT_FOUND`. -# `iii-database::runStatement` +# `database::runStatement` ## Inputs @@ -89,7 +89,7 @@ expired ids return `STATEMENT_NOT_FOUND`; the response carries the `handle_id` echo so callers can correlate failures. `params` follows the same JSON-to-driver coercion as -[`iii-database::query`](iii://iii-database/query). The placeholder +[`database::query`](iii://database/query). The placeholder syntax matches the SQL given to `prepareStatement` (`?` for sqlite/mysql, `$1`/`$2`/... for postgres). @@ -104,7 +104,7 @@ configure the per-call ceiling via the connection's session lifetime ```json { - "rows": [{ "id": 1, "email": "a@x" }], // row-of-objects, same shape as `iii-database::query` + "rows": [{ "id": 1, "email": "a@x" }], // row-of-objects, same shape as `database::query` "row_count": 1, "columns": [ { "name": "id", "type_name": "INTEGER" }, @@ -114,13 +114,13 @@ configure the per-call ceiling via the connection's session lifetime ``` The envelope is bit-for-bit identical to -[`iii-database::query`](iii://iii-database/query) — same row coercion +[`database::query`](iii://database/query) — same row coercion rules, same `columns[i]` metadata, same empty-result handling. Callers can share one parser for both surfaces. `runStatement` does not surface write counts or `last_insert_id`. To re-run an INSERT/UPDATE/DELETE many times and read those fields, use -[`iii-database::execute`](iii://iii-database/execute) per call — write +[`database::execute`](iii://database/execute) per call — write statements are typically cheap to re-parse and the prepared path saves less than the pool-pinning cost. @@ -161,8 +161,8 @@ the last successfully-read cursor. Do **not** retry the same # Related -- `iii-database::query` — drop the handle altogether for one-shot reads; same response envelope. -- `iii-database::execute` — for repeated writes; pair its own `last_insert_id` with `affected_rows` per call. -- `iii-database::transaction` — group writes into an atomic batch instead of holding a pinned connection across many calls. +- `database::query` — drop the handle altogether for one-shot reads; same response envelope. +- `database::execute` — for repeated writes; pair its own `last_insert_id` with `affected_rows` per call. +- `database::transaction` — group writes into an atomic batch instead of holding a pinned connection across many calls. - Error code `STATEMENT_NOT_FOUND` — re-prepare and retry with the new `handle.id`; the old one is gone. - Error code `POOL_TIMEOUT` — too many live handles can starve the pool. Bump `pool.max` in your `databases:` config or shorten `ttl_seconds`. diff --git a/iii-database/skills/iii-database/query.md b/database/skills/iii-database/query.md similarity index 84% rename from iii-database/skills/iii-database/query.md rename to database/skills/iii-database/query.md index 8147699f..d81cd2ee 100644 --- a/iii-database/skills/iii-database/query.md +++ b/database/skills/iii-database/query.md @@ -1,12 +1,12 @@ --- type: how-to -function_id: iii-database::query +function_id: database::query title: Run a read-only SQL query and return rows --- # When to use -Call `iii-database::query` for any read-side SQL — `SELECT`, `WITH`, +Call `database::query` for any read-side SQL — `SELECT`, `WITH`, `PRAGMA`, `EXPLAIN`, anything that produces a result set you want materialized as JSON. The response carries the rows as objects keyed by column name plus a `columns` array with per-column type metadata, so @@ -21,11 +21,11 @@ Reach for it when: - You want column-name + driver-type metadata alongside the rows (`columns[i].name` and `columns[i].type_name`). -Use [`iii-database::execute`](iii://iii-database/execute) instead when the +Use [`database::execute`](iii://database/execute) instead when the statement writes (INSERT/UPDATE/DELETE/DDL) — `query` only returns rows; write counts and `last_insert_id` come from `execute`. -Use [`iii-database::prepareStatement` + `runStatement`](iii://iii-database/prepared-statements) +Use [`database::prepareStatement` + `runStatement`](iii://database/prepared-statements) instead when you'll re-run the same SQL many times in a hot loop — the prepared path skips the per-call parse/plan cost and pins a pool connection so isolation primitives like temp tables stay alive across @@ -121,6 +121,6 @@ shape-compatible. # Related -- `iii-database::execute` — for the write side (INSERT/UPDATE/DELETE/DDL); returns affected-row counts instead of materialized rows. -- `iii-database::prepareStatement` + `iii-database::runStatement` — re-run the same SQL many times without re-parsing; also pins a pool connection so session-scoped state (temp tables, `SET LOCAL`, ...) survives across calls. -- `iii-database::transaction` — group several statements (mixed read/write) into one atomic batch with a single `committed` flag. +- `database::execute` — for the write side (INSERT/UPDATE/DELETE/DDL); returns affected-row counts instead of materialized rows. +- `database::prepareStatement` + `database::runStatement` — re-run the same SQL many times without re-parsing; also pins a pool connection so session-scoped state (temp tables, `SET LOCAL`, ...) survives across calls. +- `database::transaction` — group several statements (mixed read/write) into one atomic batch with a single `committed` flag. diff --git a/iii-database/skills/iii-database/transaction.md b/database/skills/iii-database/transaction.md similarity index 80% rename from iii-database/skills/iii-database/transaction.md rename to database/skills/iii-database/transaction.md index 8194518c..6a954fa8 100644 --- a/iii-database/skills/iii-database/transaction.md +++ b/database/skills/iii-database/transaction.md @@ -1,12 +1,12 @@ --- type: how-to -function_id: iii-database::transaction +function_id: database::transaction title: Run a sequence of statements atomically with rollback on first failure --- # When to use -Call `iii-database::transaction` when several SQL statements must commit +Call `database::transaction` when several SQL statements must commit or roll back together — the canonical "transfer money between accounts" shape, plus any multi-step write that would leave the DB in an inconsistent state if a later statement failed. The worker opens a @@ -25,10 +25,10 @@ Reach for it when: - You want a single response that tells you *which* statement failed when something rolls back (`failed_index`). -Use [`iii-database::execute`](iii://iii-database/execute) instead for a +Use [`database::execute`](iii://database/execute) instead for a single write — `execute` is implicitly its own transaction and skips the multi-statement framing cost. Use -[`iii-database::prepareStatement` + `runStatement`](iii://iii-database/prepared-statements) +[`database::prepareStatement` + `runStatement`](iii://database/prepared-statements) instead when the goal is repeating one parameterized statement many times rather than committing several different statements as a unit. @@ -48,7 +48,7 @@ times rather than committing several different statements as a unit. `db` and `statements` are required; an empty `statements` array commits a no-op transaction (`committed: true`, `results: []`). Each statement carries its own `sql` (non-empty, like -[`iii-database::query`](iii://iii-database/query) and `execute`) plus +[`database::query`](iii://database/query) and `execute`) plus optional `params` with the same JSON-to-driver coercion rules. `isolation` is optional and accepts exactly three values: @@ -107,7 +107,7 @@ Failure (rollback): committed every statement or rolled back every statement; partial commit is impossible. - `results` is present only on success. Each entry mirrors the - `affected_rows` count `iii-database::execute` would have produced for + `affected_rows` count `database::execute` would have produced for that statement, plus a positional `rows` array (NOT keyed by column — this is intentionally lighter than `query`'s row-of-objects shape; parse with the input order in mind). @@ -159,7 +159,7 @@ failed step) once the underlying constraint condition is fixed. # Related -- [`iii-database::beginTransaction` + `transactionQuery` / `transactionExecute` + `commitTransaction` / `rollbackTransaction`](iii://iii-database/interactive-transaction) — **stateful interactive** transaction with a configurable timeout-driven auto-rollback. Use this surface when you need to take a decision in application code *between* statements (read-your-writes across round-trips). The batch handler on this page requires every statement up-front, so it can't carry that inter-statement logic. -- `iii-database::execute` — single-statement variant; skips the BEGIN/COMMIT framing for one-shot writes. -- `iii-database::query` — read-only; cannot be combined with writes inside this surface but is fine to mix into the `statements` array if you only need its rows for `affected_rows`-equivalent counts. -- `iii-database::prepareStatement` + `iii-database::runStatement` — for repeating one parameterized statement many times; not a substitute for atomic multi-statement commit. +- [`database::beginTransaction` + `transactionQuery` / `transactionExecute` + `commitTransaction` / `rollbackTransaction`](iii://database/interactive-transaction) — **stateful interactive** transaction with a configurable timeout-driven auto-rollback. Use this surface when you need to take a decision in application code *between* statements (read-your-writes across round-trips). The batch handler on this page requires every statement up-front, so it can't carry that inter-statement logic. +- `database::execute` — single-statement variant; skips the BEGIN/COMMIT framing for one-shot writes. +- `database::query` — read-only; cannot be combined with writes inside this surface but is fine to mix into the `statements` array if you only need its rows for `affected_rows`-equivalent counts. +- `database::prepareStatement` + `database::runStatement` — for repeating one parameterized statement many times; not a substitute for atomic multi-statement commit. diff --git a/database/skills/index.md b/database/skills/index.md new file mode 100644 index 00000000..31bdfdc6 --- /dev/null +++ b/database/skills/index.md @@ -0,0 +1,36 @@ +--- +type: index +title: database +--- + +# database + +Connect to PostgreSQL, MySQL, and SQLite from the iii engine. Run read-only +queries, write statements, atomic transactions, and prepared-statement +sequences over a managed per-database connection pool. Every callable +surface lives under the single `database::*` namespace; SQLite is the +recommended starting point because it needs no server, just a file. + +The worker resolves the driver from each database's URL scheme (`sqlite:`, +`postgres://`, `postgresql://`, `mysql://`). For the `databases:` config +block, TLS modes, error-code reference, and the per-driver compatibility +table (e.g. `returning:` is a no-op on MySQL; SQLite degrades +`read_committed` / `repeatable_read` to `serializable`), see +[the README](../README.md). + +## How-tos + +### `database::*` + +- [`database::query`](iii://database/query) — read-only SQL; returns `{ rows, row_count, columns }` for SELECT-style statements. +- [`database::execute`](iii://database/execute) — write SQL (INSERT/UPDATE/DELETE/DDL); returns `{ affected_rows, last_insert_id, returned_rows }`. +- [`database::prepareStatement` + `database::runStatement`](iii://database/prepared-statements) — prepare-once, run-many parameterized SQL; the prepare step pins a pool connection until TTL expiry, so always run before re-issuing the same statement many times. +- [`database::transaction`](iii://database/transaction) — atomic batch sequence; one call, pass every statement together, rolls back on first failure with a `failed_index` pointer at the offending step. +- [`database::beginTransaction` + `transactionQuery` / `transactionExecute` + `commitTransaction` / `rollbackTransaction`](iii://database/interactive-transaction) — stateful interactive transaction with a configurable timeout-driven auto-rollback. Use this when you need read-your-writes across several round-trips inside a single transaction. + +`database::row-change` (Postgres logical replication via `pgoutput`) is +registered as a trigger type but is **not yet functional in v1.0.0**: +`register_trigger` returns `UNSUPPORTED` while the streaming decode loop +waits on an upstream `tokio-postgres` replication API release. Operators +can pre-provision slots and publications now; see the **Triggers** section +of [the README](../README.md) for current status and cleanup commands. diff --git a/iii-database/src/config.rs b/database/src/config.rs similarity index 100% rename from iii-database/src/config.rs rename to database/src/config.rs diff --git a/iii-database/src/driver/mod.rs b/database/src/driver/mod.rs similarity index 100% rename from iii-database/src/driver/mod.rs rename to database/src/driver/mod.rs diff --git a/iii-database/src/driver/mysql.rs b/database/src/driver/mysql.rs similarity index 100% rename from iii-database/src/driver/mysql.rs rename to database/src/driver/mysql.rs diff --git a/iii-database/src/driver/postgres.rs b/database/src/driver/postgres.rs similarity index 100% rename from iii-database/src/driver/postgres.rs rename to database/src/driver/postgres.rs diff --git a/iii-database/src/driver/sqlite.rs b/database/src/driver/sqlite.rs similarity index 100% rename from iii-database/src/driver/sqlite.rs rename to database/src/driver/sqlite.rs diff --git a/iii-database/src/error.rs b/database/src/error.rs similarity index 100% rename from iii-database/src/error.rs rename to database/src/error.rs diff --git a/iii-database/src/handle.rs b/database/src/handle.rs similarity index 100% rename from iii-database/src/handle.rs rename to database/src/handle.rs diff --git a/iii-database/src/handlers/begin_transaction.rs b/database/src/handlers/begin_transaction.rs similarity index 98% rename from iii-database/src/handlers/begin_transaction.rs rename to database/src/handlers/begin_transaction.rs index 88ef9ef4..59d8cf2a 100644 --- a/iii-database/src/handlers/begin_transaction.rs +++ b/database/src/handlers/begin_transaction.rs @@ -1,4 +1,4 @@ -//! `iii-database::beginTransaction` — open an interactive transaction and +//! `database::beginTransaction` — open an interactive transaction and //! return a handle. The handle pins one pool connection inside a server- //! side `BEGIN ... COMMIT/ROLLBACK` until either the matching //! `commitTransaction` / `rollbackTransaction` call lands, or the diff --git a/iii-database/src/handlers/commit_transaction.rs b/database/src/handlers/commit_transaction.rs similarity index 98% rename from iii-database/src/handlers/commit_transaction.rs rename to database/src/handlers/commit_transaction.rs index faa09ef5..92d0b11e 100644 --- a/iii-database/src/handlers/commit_transaction.rs +++ b/database/src/handlers/commit_transaction.rs @@ -1,4 +1,4 @@ -//! `iii-database::commitTransaction` — finalize an interactive transaction +//! `database::commitTransaction` — finalize an interactive transaction //! by issuing `COMMIT`. Removes the entry from the registry before locking //! the connection so concurrent `transactionQuery` / `transactionExecute` //! calls against the same id fast-fail with `TRANSACTION_NOT_FOUND` once diff --git a/iii-database/src/handlers/execute.rs b/database/src/handlers/execute.rs similarity index 99% rename from iii-database/src/handlers/execute.rs rename to database/src/handlers/execute.rs index 6c27f37c..61ed210b 100644 --- a/iii-database/src/handlers/execute.rs +++ b/database/src/handlers/execute.rs @@ -1,4 +1,4 @@ -//! `iii-database::execute` — write SQL. +//! `database::execute` — write SQL. use super::AppState; use crate::driver; diff --git a/iii-database/src/handlers/mod.rs b/database/src/handlers/mod.rs similarity index 100% rename from iii-database/src/handlers/mod.rs rename to database/src/handlers/mod.rs diff --git a/iii-database/src/handlers/prepare.rs b/database/src/handlers/prepare.rs similarity index 98% rename from iii-database/src/handlers/prepare.rs rename to database/src/handlers/prepare.rs index cae29c6b..414d8111 100644 --- a/iii-database/src/handlers/prepare.rs +++ b/database/src/handlers/prepare.rs @@ -1,4 +1,4 @@ -//! `iii-database::prepareStatement` — pin a connection and return a UUID handle. +//! `database::prepareStatement` — pin a connection and return a UUID handle. use super::AppState; use crate::handle::HandleResponse; diff --git a/iii-database/src/handlers/query.rs b/database/src/handlers/query.rs similarity index 99% rename from iii-database/src/handlers/query.rs rename to database/src/handlers/query.rs index 24d1fad9..7d18c28e 100644 --- a/iii-database/src/handlers/query.rs +++ b/database/src/handlers/query.rs @@ -1,4 +1,4 @@ -//! `iii-database::query` — read-only SQL. +//! `database::query` — read-only SQL. use super::AppState; use crate::driver::{self, ColumnMeta}; diff --git a/iii-database/src/handlers/rollback_transaction.rs b/database/src/handlers/rollback_transaction.rs similarity index 98% rename from iii-database/src/handlers/rollback_transaction.rs rename to database/src/handlers/rollback_transaction.rs index 5d2ab21c..3375a19b 100644 --- a/iii-database/src/handlers/rollback_transaction.rs +++ b/database/src/handlers/rollback_transaction.rs @@ -1,4 +1,4 @@ -//! `iii-database::rollbackTransaction` — finalize an interactive transaction +//! `database::rollbackTransaction` — finalize an interactive transaction //! by issuing `ROLLBACK`. Like `commitTransaction`, takes the entry out of //! the registry first so concurrent statement calls fast-fail with //! `TRANSACTION_NOT_FOUND`. diff --git a/iii-database/src/handlers/run_statement.rs b/database/src/handlers/run_statement.rs similarity index 98% rename from iii-database/src/handlers/run_statement.rs rename to database/src/handlers/run_statement.rs index d79f2508..799fdc24 100644 --- a/iii-database/src/handlers/run_statement.rs +++ b/database/src/handlers/run_statement.rs @@ -1,4 +1,4 @@ -//! `iii-database::runStatement` — run a previously-prepared handle. +//! `database::runStatement` — run a previously-prepared handle. use super::AppState; use crate::driver; diff --git a/iii-database/src/handlers/transaction.rs b/database/src/handlers/transaction.rs similarity index 99% rename from iii-database/src/handlers/transaction.rs rename to database/src/handlers/transaction.rs index ece56ea0..e4e87b0b 100644 --- a/iii-database/src/handlers/transaction.rs +++ b/database/src/handlers/transaction.rs @@ -1,4 +1,4 @@ -//! `iii-database::transaction` — atomic sequence of statements. +//! `database::transaction` — atomic sequence of statements. use super::AppState; use crate::driver::{self, Isolation, TxStatement}; diff --git a/iii-database/src/handlers/transaction_execute.rs b/database/src/handlers/transaction_execute.rs similarity index 98% rename from iii-database/src/handlers/transaction_execute.rs rename to database/src/handlers/transaction_execute.rs index dbd351e3..d57d4d58 100644 --- a/iii-database/src/handlers/transaction_execute.rs +++ b/database/src/handlers/transaction_execute.rs @@ -1,5 +1,5 @@ -//! `iii-database::transactionExecute` — write SQL inside an interactive -//! transaction. Same response envelope as `iii-database::execute`. Bare +//! `database::transactionExecute` — write SQL inside an interactive +//! transaction. Same response envelope as `database::execute`. Bare //! `BEGIN` / `COMMIT` / `ROLLBACK` / `END` / `SAVEPOINT` / `RELEASE` / //! `SET TRANSACTION` / `START TRANSACTION` statements are rejected with //! `INVALID_PARAM` so callers cannot side-channel finalization through SQL — @@ -268,7 +268,7 @@ mod tests { /// Success-path INSERT inside an interactive transaction surfaces /// `affected_rows` and `last_insert_id` just like the standalone - /// `iii-database::execute`. Uses an on-disk sqlite so the txn's pinned + /// `database::execute`. Uses an on-disk sqlite so the txn's pinned /// conn and the post-commit verification share one database file. #[tokio::test(flavor = "multi_thread")] async fn execute_insert_inside_tx_returns_affected_rows_and_last_insert_id() { diff --git a/iii-database/src/handlers/transaction_query.rs b/database/src/handlers/transaction_query.rs similarity index 98% rename from iii-database/src/handlers/transaction_query.rs rename to database/src/handlers/transaction_query.rs index fe5a6962..0d49a295 100644 --- a/iii-database/src/handlers/transaction_query.rs +++ b/database/src/handlers/transaction_query.rs @@ -1,5 +1,5 @@ -//! `iii-database::transactionQuery` — read SQL inside an interactive -//! transaction. Same response envelope as `iii-database::query`; the only +//! `database::transactionQuery` — read SQL inside an interactive +//! transaction. Same response envelope as `database::query`; the only //! difference is the SQL runs on the pinned transaction connection rather //! than a freshly-pooled one. //! diff --git a/iii-database/src/handlers/tx_sql_guard.rs b/database/src/handlers/tx_sql_guard.rs similarity index 100% rename from iii-database/src/handlers/tx_sql_guard.rs rename to database/src/handlers/tx_sql_guard.rs diff --git a/iii-database/src/lib.rs b/database/src/lib.rs similarity index 69% rename from iii-database/src/lib.rs rename to database/src/lib.rs index a798c3f7..8db979e2 100644 --- a/iii-database/src/lib.rs +++ b/database/src/lib.rs @@ -1,4 +1,4 @@ -//! iii-database worker — public surface for the binary and tests. +//! database worker — public surface for the binary and tests. pub mod config; pub(crate) mod driver; @@ -11,5 +11,5 @@ pub mod triggers; pub mod value; pub fn worker_name() -> &'static str { - "iii-database" + "database" } diff --git a/iii-database/src/main.rs b/database/src/main.rs similarity index 79% rename from iii-database/src/main.rs rename to database/src/main.rs index c0568340..ff1fae42 100644 --- a/iii-database/src/main.rs +++ b/database/src/main.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result}; use clap::Parser; -use iii_database::config::WorkerConfig; -use iii_database::handle::HandleRegistry; -use iii_database::handlers::{ +use database::config::WorkerConfig; +use database::handle::HandleRegistry; +use database::handlers::{ begin_transaction::{self, BeginTxReq}, commit_transaction::{self, CommitTxReq}, execute::{self, ExecuteReq}, @@ -15,9 +15,9 @@ use iii_database::handlers::{ transaction_query::{self, TxQueryReq}, AppState, }; -use iii_database::pool; -use iii_database::transaction::TxRegistry; -use iii_database::triggers::handler::RowChangeTrigger; +use database::pool; +use database::transaction::TxRegistry; +use database::triggers::handler::RowChangeTrigger; use iii_sdk::{ register_worker, InitOptions, Logger, OtelConfig, RegisterFunction, RegisterTriggerType, }; @@ -26,8 +26,8 @@ use std::sync::Arc; #[derive(Parser, Debug)] #[command( - name = "iii-database", - about = "iii-database worker (PostgreSQL, MySQL, SQLite)" + name = "database", + about = "database worker (PostgreSQL, MySQL, SQLite)" )] struct Cli { /// Path to config.yaml file @@ -50,7 +50,7 @@ async fn main() -> Result<()> { let cli = Cli::parse(); tracing::info!( - name = iii_database::worker_name(), + name = database::worker_name(), config = %cli.config, url = %redact_url(&cli.url), "starting" @@ -94,7 +94,7 @@ async fn main() -> Result<()> { { let st = state.clone(); iii.register_function( - RegisterFunction::new_async("iii-database::query", move |req: QueryReq| { + RegisterFunction::new_async("database::query", move |req: QueryReq| { let st = st.clone(); async move { query::handle(&st, req).await } }) @@ -104,7 +104,7 @@ async fn main() -> Result<()> { { let st = state.clone(); iii.register_function( - RegisterFunction::new_async("iii-database::execute", move |req: ExecuteReq| { + RegisterFunction::new_async("database::execute", move |req: ExecuteReq| { let st = st.clone(); async move { execute::handle(&st, req).await } }) @@ -114,20 +114,17 @@ async fn main() -> Result<()> { { let st = state.clone(); iii.register_function( - RegisterFunction::new_async( - "iii-database::prepareStatement", - move |req: PrepareReq| { - let st = st.clone(); - async move { prepare::handle(&st, req).await } - }, - ) + RegisterFunction::new_async("database::prepareStatement", move |req: PrepareReq| { + let st = st.clone(); + async move { prepare::handle(&st, req).await } + }) .description("Prepare a parameterized statement once."), ); } { let st = state.clone(); iii.register_function( - RegisterFunction::new_async("iii-database::runStatement", move |req: RunReq| { + RegisterFunction::new_async("database::runStatement", move |req: RunReq| { let st = st.clone(); async move { run_statement::handle(&st, req).await } }) @@ -137,7 +134,7 @@ async fn main() -> Result<()> { { let st = state.clone(); iii.register_function( - RegisterFunction::new_async("iii-database::transaction", move |req: TxReq| { + RegisterFunction::new_async("database::transaction", move |req: TxReq| { let st = st.clone(); async move { transaction::handle(&st, req).await } }) @@ -147,13 +144,10 @@ async fn main() -> Result<()> { { let st = state.clone(); iii.register_function( - RegisterFunction::new_async( - "iii-database::beginTransaction", - move |req: BeginTxReq| { - let st = st.clone(); - async move { begin_transaction::handle(&st, req).await } - }, - ) + RegisterFunction::new_async("database::beginTransaction", move |req: BeginTxReq| { + let st = st.clone(); + async move { begin_transaction::handle(&st, req).await } + }) .description( "Open an interactive transaction; returns a handle to use with \ transactionQuery/transactionExecute/commitTransaction/rollbackTransaction.", @@ -163,13 +157,10 @@ async fn main() -> Result<()> { { let st = state.clone(); iii.register_function( - RegisterFunction::new_async( - "iii-database::transactionQuery", - move |req: TxQueryReq| { - let st = st.clone(); - async move { transaction_query::handle(&st, req).await } - }, - ) + RegisterFunction::new_async("database::transactionQuery", move |req: TxQueryReq| { + let st = st.clone(); + async move { transaction_query::handle(&st, req).await } + }) .description("Run a read-only SQL query inside an interactive transaction."), ); } @@ -177,7 +168,7 @@ async fn main() -> Result<()> { let st = state.clone(); iii.register_function( RegisterFunction::new_async( - "iii-database::transactionExecute", + "database::transactionExecute", move |req: TxExecuteReq| { let st = st.clone(); async move { transaction_execute::handle(&st, req).await } @@ -192,13 +183,10 @@ async fn main() -> Result<()> { { let st = state.clone(); iii.register_function( - RegisterFunction::new_async( - "iii-database::commitTransaction", - move |req: CommitTxReq| { - let st = st.clone(); - async move { commit_transaction::handle(&st, req).await } - }, - ) + RegisterFunction::new_async("database::commitTransaction", move |req: CommitTxReq| { + let st = st.clone(); + async move { commit_transaction::handle(&st, req).await } + }) .description("Commit and finalize an interactive transaction."), ); } @@ -206,7 +194,7 @@ async fn main() -> Result<()> { let st = state.clone(); iii.register_function( RegisterFunction::new_async( - "iii-database::rollbackTransaction", + "database::rollbackTransaction", move |req: RollbackTxReq| { let st = st.clone(); async move { rollback_transaction::handle(&st, req).await } @@ -217,16 +205,16 @@ async fn main() -> Result<()> { } let _row_change = iii.register_trigger_type(RegisterTriggerType::new( - "iii-database::row-change", + "database::row-change", "Postgres logical replication. Stubbed in v1.0 pending tokio-postgres replication API.", RowChangeTrigger, )); tracing::info!( - "iii-database worker registered 10 functions and 1 trigger type, waiting for invocations" + "database worker registered 10 functions and 1 trigger type, waiting for invocations" ); wait_for_shutdown_signal().await?; - tracing::info!("iii-database worker shutting down"); + tracing::info!("database worker shutting down"); iii.shutdown_async().await; Ok(()) } diff --git a/iii-database/src/pool/mod.rs b/database/src/pool/mod.rs similarity index 100% rename from iii-database/src/pool/mod.rs rename to database/src/pool/mod.rs diff --git a/iii-database/src/pool/mysql.rs b/database/src/pool/mysql.rs similarity index 100% rename from iii-database/src/pool/mysql.rs rename to database/src/pool/mysql.rs diff --git a/iii-database/src/pool/postgres.rs b/database/src/pool/postgres.rs similarity index 100% rename from iii-database/src/pool/postgres.rs rename to database/src/pool/postgres.rs diff --git a/iii-database/src/pool/sqlite.rs b/database/src/pool/sqlite.rs similarity index 100% rename from iii-database/src/pool/sqlite.rs rename to database/src/pool/sqlite.rs diff --git a/iii-database/src/pool/tls.rs b/database/src/pool/tls.rs similarity index 100% rename from iii-database/src/pool/tls.rs rename to database/src/pool/tls.rs diff --git a/iii-database/src/transaction.rs b/database/src/transaction.rs similarity index 100% rename from iii-database/src/transaction.rs rename to database/src/transaction.rs diff --git a/iii-database/src/triggers/handler.rs b/database/src/triggers/handler.rs similarity index 85% rename from iii-database/src/triggers/handler.rs rename to database/src/triggers/handler.rs index 3a00ff2e..77d7f95d 100644 --- a/iii-database/src/triggers/handler.rs +++ b/database/src/triggers/handler.rs @@ -1,4 +1,4 @@ -//! TriggerHandler implementations for `iii-database::row-change`. Wired into +//! TriggerHandler implementations for `database::row-change`. Wired into //! the worker via `iii.register_trigger_type` from main.rs. use async_trait::async_trait; @@ -8,7 +8,7 @@ fn iii_err(err: T) -> IIIError { IIIError::Handler(serde_json::to_string(&err).unwrap_or_else(|_| "{}".into())) } -/// `iii-database::row-change` trigger handler. v1.0 stubs the streaming decoder +/// `database::row-change` trigger handler. v1.0 stubs the streaming decoder /// pending an upstream tokio-postgres replication API release. `register_trigger` /// returns Unsupported so callers see a clear error instead of silently never /// receiving events. diff --git a/iii-database/src/triggers/mod.rs b/database/src/triggers/mod.rs similarity index 100% rename from iii-database/src/triggers/mod.rs rename to database/src/triggers/mod.rs diff --git a/iii-database/src/triggers/row_change.rs b/database/src/triggers/row_change.rs similarity index 100% rename from iii-database/src/triggers/row_change.rs rename to database/src/triggers/row_change.rs diff --git a/iii-database/src/value.rs b/database/src/value.rs similarity index 100% rename from iii-database/src/value.rs rename to database/src/value.rs diff --git a/iii-database/tests/e2e/.gitignore b/database/tests/e2e/.gitignore similarity index 100% rename from iii-database/tests/e2e/.gitignore rename to database/tests/e2e/.gitignore diff --git a/iii-database/tests/e2e/README.md b/database/tests/e2e/README.md similarity index 87% rename from iii-database/tests/e2e/README.md rename to database/tests/e2e/README.md index d710ca09..62f83155 100644 --- a/iii-database/tests/e2e/README.md +++ b/database/tests/e2e/README.md @@ -1,6 +1,6 @@ -# iii-database worker — end-to-end harness +# database worker — end-to-end harness -Self-asserting smoke harness for the `iii-database` worker. Validates the +Self-asserting smoke harness for the `database` worker. Validates the function surface (query / execute / prepareStatement / runStatement / transaction), the **interactive-transaction** surface (begin / transactionQuery / transactionExecute / commit / rollback), the @@ -9,7 +9,7 @@ finalization repros from the `/review` of branch `feat/database-and-skills` against real **SQLite**, **PostgreSQL 16**, and **MySQL 8.4** with one command. -Runs locally and in CI (`.github/workflows/iii-database-e2e.yml`). +Runs locally and in CI (`.github/workflows/database-e2e.yml`). ## Prerequisites @@ -33,7 +33,7 @@ Runs locally and in CI (`.github/workflows/iii-database-e2e.yml`). # when the worker hasn't shipped the fix yet) ``` -Builds the worker (`cargo build --release --bin iii-database`), brings up +Builds the worker (`cargo build --release --bin database`), brings up the docker stack with `wal_level=logical`, starts the engine, and runs the selected case groups across all 3 drivers. Exits 0 on PASS, 1 on any FAIL. @@ -55,10 +55,10 @@ overridden: | Var | Default | Purpose | |---|---|---| -| `WORKER_SRC` | `../..` (the `iii-database/` crate) | Where to `cargo build` | +| `WORKER_SRC` | `../..` (the `database/` crate) | Where to `cargo build` | | `III_BIN` | `$(command -v iii)` then `$HOME/.local/bin/iii` | Engine binary | -| `WORKER_BIN_TARGET` | `$WORKER_SRC/target/release/iii-database` | Built worker | -| `WORKER_BIN_LINK` | `$HOME/.iii/workers/iii-database` | Symlink the engine reads | +| `WORKER_BIN_TARGET` | `$WORKER_SRC/target/release/database` | Built worker | +| `WORKER_BIN_LINK` | `$HOME/.iii/workers/database` | Symlink the engine reads | | `COMPOSE` | `docker compose` | Compose command. Set to `podman-compose` for rootless podman; the script auto-switches its healthcheck strategy to `podman inspect` (since podman-compose 1.x doesn't implement compose v2's `--wait`). | | `HARNESS_MODE` | `full` | `full` / `no-bypass` / `bypass-only`. Set by the flags above; you usually don't need to set this directly. | | `HARNESS_TIMEOUT` | `180` | Seconds to wait for the test sentinel | @@ -93,7 +93,7 @@ accepted; outside-tx COUNT=1`). |---|---| | `run-tests.sh` | Orchestrator | | `docker-compose.yml` | Postgres (wal_level=logical) + MySQL with healthchecks | -| `config.yaml` | Engine config (queue, observability, iii-database, harness) | +| `config.yaml` | Engine config (queue, observability, database, harness) | | `workers/harness/` | TypeScript smoke-test worker (runs as a host process) | | `workers/harness/src/cases-interactive-tx.ts` | Interactive-transaction lifecycle cases | | `workers/harness/src/cases-tx-control-bypass.ts` | Side-channel-finalization repros | @@ -101,8 +101,8 @@ accepted; outside-tx COUNT=1`). ## CI -The harness runs in `.github/workflows/iii-database-e2e.yml` on any PR -that touches `iii-database/**`. The workflow installs the engine via the install +The harness runs in `.github/workflows/database-e2e.yml` on any PR +that touches `database/**`. The workflow installs the engine via the install script (always tracks `main`, no version pin), builds the worker, brings up the same docker compose stack used locally, and shells out to `./run-tests.sh`. diff --git a/iii-database/tests/e2e/config.yaml b/database/tests/e2e/config.yaml similarity index 90% rename from iii-database/tests/e2e/config.yaml rename to database/tests/e2e/config.yaml index dada83a0..7d87e3ed 100644 --- a/iii-database/tests/e2e/config.yaml +++ b/database/tests/e2e/config.yaml @@ -5,8 +5,8 @@ # WebSocket like any external client — sidesteps the libkrun-VM-based # managed-worker setup, which is overkill for a test harness. # -# The iii-database worker config is inlined under its worker entry. The -# engine serializes this `config:` value to /tmp/iii-database-config.yaml +# The database worker config is inlined under its worker entry. The +# engine serializes this `config:` value to /tmp/database-config.yaml # and threads `--config ` through `iii-worker start` to the spawned # binary (see engine/src/workers/registry_worker.rs::spawn and # crates/iii-worker/src/cli/managed.rs::start_binary_worker on branch @@ -21,12 +21,12 @@ workers: - name: iii-observability config: enabled: true - service_name: iii-database-tests + service_name: database-tests exporter: memory logs_console_output: true sampling_ratio: 1.0 - - name: iii-database + - name: database config: databases: sqlite_db: diff --git a/iii-database/tests/e2e/docker-compose.yml b/database/tests/e2e/docker-compose.yml similarity index 100% rename from iii-database/tests/e2e/docker-compose.yml rename to database/tests/e2e/docker-compose.yml diff --git a/iii-database/tests/e2e/data/.gitkeep b/database/tests/e2e/reports/.gitkeep similarity index 100% rename from iii-database/tests/e2e/data/.gitkeep rename to database/tests/e2e/reports/.gitkeep diff --git a/iii-database/tests/e2e/run-tests.sh b/database/tests/e2e/run-tests.sh similarity index 96% rename from iii-database/tests/e2e/run-tests.sh rename to database/tests/e2e/run-tests.sh index 99632298..e215e7a8 100755 --- a/iii-database/tests/e2e/run-tests.sh +++ b/database/tests/e2e/run-tests.sh @@ -4,13 +4,13 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Path overrides (set in CI; defaults assume the harness lives at -# iii-database/tests/e2e/ inside the workers repo and the iii engine is on +# database/tests/e2e/ inside the workers repo and the iii engine is on # $PATH or at $HOME/.local/bin/iii — which is where the install script # `curl -fsSL https://install.iii.dev/iii/main/install.sh | sh` puts it). WORKER_SRC="${WORKER_SRC:-$(cd "$ROOT_DIR/../.." && pwd)}" III_BIN="${III_BIN:-$(command -v iii 2>/dev/null || echo "$HOME/.local/bin/iii")}" -WORKER_BIN_TARGET="${WORKER_BIN_TARGET:-$WORKER_SRC/target/release/iii-database}" -WORKER_BIN_LINK="${WORKER_BIN_LINK:-$HOME/.iii/workers/iii-database}" +WORKER_BIN_TARGET="${WORKER_BIN_TARGET:-$WORKER_SRC/target/release/database}" +WORKER_BIN_LINK="${WORKER_BIN_LINK:-$HOME/.iii/workers/database}" # Container runtime. Defaults to `docker compose` (compose v2; what CI runs). # Override to `podman-compose` for rootless podman on dev laptops; the script @@ -45,7 +45,7 @@ Usage: $0 [--keep] [--no-build] [--with-cargo-test] [--filter=] [--bypass-only|--no-bypass] --keep Leave the compose stack running after the run. - --no-build Skip cargo build of the iii-database worker. + --no-build Skip cargo build of the database worker. --with-cargo-test Run \`cargo test --all-features\` after compose is healthy with TEST_POSTGRES_URL and TEST_MYSQL_URL pointing at the stack — exercises gated driver/pool tests with real DBs. @@ -61,8 +61,8 @@ Usage: $0 [--keep] [--no-build] [--with-cargo-test] Env overrides: WORKER_SRC Path to the database worker crate (default: ../..). III_BIN Path to the iii engine binary (default: \$(command -v iii) or \$HOME/.local/bin/iii). - WORKER_BIN_TARGET Path to the built worker binary (default: \$WORKER_SRC/target/release/iii-database). - WORKER_BIN_LINK Path to the symlink the engine reads (default: \$HOME/.iii/workers/iii-database). + WORKER_BIN_TARGET Path to the built worker binary (default: \$WORKER_SRC/target/release/database). + WORKER_BIN_LINK Path to the symlink the engine reads (default: \$HOME/.iii/workers/database). COMPOSE Compose command (default: 'docker compose'; set to 'podman-compose' for rootless podman). HARNESS_TIMEOUT Seconds to wait for the harness sentinel (default: 180). @@ -103,8 +103,8 @@ fi # 2. Build the worker (unless --no-build) if [[ "$NO_BUILD" -eq 0 ]]; then - echo "[run-tests] cargo build --release (iii-database worker)" - (cd "$WORKER_SRC" && cargo build --release --bin iii-database) + echo "[run-tests] cargo build --release (database worker)" + (cd "$WORKER_SRC" && cargo build --release --bin database) fi if [[ ! -x "$WORKER_BIN_TARGET" ]]; then echo "[run-tests] FATAL: worker binary missing at $WORKER_BIN_TARGET — run without --no-build" >&2 diff --git a/iii-database/tests/e2e/workers/harness/iii.worker.yaml b/database/tests/e2e/workers/harness/iii.worker.yaml similarity index 100% rename from iii-database/tests/e2e/workers/harness/iii.worker.yaml rename to database/tests/e2e/workers/harness/iii.worker.yaml diff --git a/iii-database/tests/e2e/workers/harness/package-lock.json b/database/tests/e2e/workers/harness/package-lock.json similarity index 100% rename from iii-database/tests/e2e/workers/harness/package-lock.json rename to database/tests/e2e/workers/harness/package-lock.json diff --git a/iii-database/tests/e2e/workers/harness/package.json b/database/tests/e2e/workers/harness/package.json similarity index 91% rename from iii-database/tests/e2e/workers/harness/package.json rename to database/tests/e2e/workers/harness/package.json index cd956afc..c7032dce 100644 --- a/iii-database/tests/e2e/workers/harness/package.json +++ b/database/tests/e2e/workers/harness/package.json @@ -1,5 +1,5 @@ { - "name": "iii-database-tests-harness", + "name": "database-tests-harness", "version": "0.1.0", "type": "module", "private": true, diff --git a/iii-database/tests/e2e/workers/harness/src/cases-boundary.ts b/database/tests/e2e/workers/harness/src/cases-boundary.ts similarity index 62% rename from iii-database/tests/e2e/workers/harness/src/cases-boundary.ts rename to database/tests/e2e/workers/harness/src/cases-boundary.ts index b6d4af36..b3f487b1 100644 --- a/iii-database/tests/e2e/workers/harness/src/cases-boundary.ts +++ b/database/tests/e2e/workers/harness/src/cases-boundary.ts @@ -1,5 +1,5 @@ -import type { TestCase } from './cases.ts'; -import { expect, expectEqual } from './cases.ts'; +import type { TestCase } from './cases.ts' +import { expect, expectEqual } from './cases.ts' /** * Boundary-value cases targeting type encoding, NULL handling, and string @@ -25,31 +25,31 @@ export const BOUNDARY_CASES: TestCase[] = [ // also drops to Int despite having distinct BIGINT type info available. applies: ['pg_db'], async run({ driver, call }) { - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_i64max' }); - await call('iii-database::execute', { db: driver, sql: 'CREATE TABLE bx_i64max (n BIGINT NOT NULL)' }); - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_i64max' }) + await call('database::execute', { db: driver, sql: 'CREATE TABLE bx_i64max (n BIGINT NOT NULL)' }) + await call('database::execute', { db: driver, sql: 'INSERT INTO bx_i64max (n) VALUES (9223372036854775807)', - }); - const q = await call('iii-database::query', { db: driver, sql: 'SELECT n FROM bx_i64max' }); - const v = q.rows[0].n; - expectEqual(v, '9223372036854775807', 'i64::MAX preserved as JSON string'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_i64max' }); + }) + const q = await call('database::query', { db: driver, sql: 'SELECT n FROM bx_i64max' }) + const v = q.rows[0].n + expectEqual(v, '9223372036854775807', 'i64::MAX preserved as JSON string') + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_i64max' }) }, }, { name: 'i64 min round-trip (BIGINT-as-string)', applies: ['pg_db'], async run({ driver, call }) { - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_i64min' }); - await call('iii-database::execute', { db: driver, sql: 'CREATE TABLE bx_i64min (n BIGINT NOT NULL)' }); - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_i64min' }) + await call('database::execute', { db: driver, sql: 'CREATE TABLE bx_i64min (n BIGINT NOT NULL)' }) + await call('database::execute', { db: driver, sql: 'INSERT INTO bx_i64min (n) VALUES (-9223372036854775808)', - }); - const q = await call('iii-database::query', { db: driver, sql: 'SELECT n FROM bx_i64min' }); - expectEqual(q.rows[0].n, '-9223372036854775808', 'i64::MIN preserved as JSON string'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_i64min' }); + }) + const q = await call('database::query', { db: driver, sql: 'SELECT n FROM bx_i64min' }) + expectEqual(q.rows[0].n, '-9223372036854775808', 'i64::MIN preserved as JSON string') + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_i64min' }) }, }, { @@ -60,20 +60,20 @@ export const BOUNDARY_CASES: TestCase[] = [ // contract while the BIGINT-as-string-test (pg-only) holds the bar above. applies: ['sqlite_db', 'mysql_db'], async run({ driver, call }) { - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_i64safe' }); - await call('iii-database::execute', { db: driver, sql: 'CREATE TABLE bx_i64safe (n BIGINT NOT NULL)' }); + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_i64safe' }) + await call('database::execute', { db: driver, sql: 'CREATE TABLE bx_i64safe (n BIGINT NOT NULL)' }) // 9007199254740991 = Number.MAX_SAFE_INTEGER - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: 'INSERT INTO bx_i64safe (n) VALUES (9007199254740991)', - }); - const q = await call('iii-database::query', { db: driver, sql: 'SELECT n FROM bx_i64safe' }); - const v = q.rows[0].n; + }) + const q = await call('database::query', { db: driver, sql: 'SELECT n FROM bx_i64safe' }) + const v = q.rows[0].n expect( v === 9007199254740991 || v === '9007199254740991', `MAX_SAFE_INTEGER round-trip: got ${JSON.stringify(v)}`, - ); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_i64safe' }); + ) + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_i64safe' }) }, }, { @@ -83,134 +83,130 @@ export const BOUNDARY_CASES: TestCase[] = [ // a 4-byte INT4 column, surfacing as `22P03 invalid_binary_representation`. // This case binds an i64-shaped JSON number (within Number.MAX_SAFE_INTEGER) // to a 32-bit-wide column type. Drivers must dispatch on column type width. - const ph1 = dialect.placeholder(1); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_int4' }); + const ph1 = dialect.placeholder(1) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_int4' }) // Use INT (postgres maps to INT4, mysql to INT, sqlite stores as INTEGER affinity). - await call('iii-database::execute', { db: driver, sql: 'CREATE TABLE bx_int4 (n INT NOT NULL)' }); - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: 'CREATE TABLE bx_int4 (n INT NOT NULL)' }) + await call('database::execute', { db: driver, sql: `INSERT INTO bx_int4 (n) VALUES (${ph1})`, params: [12345], - }); - const q = await call('iii-database::query', { db: driver, sql: 'SELECT n FROM bx_int4' }); - expectEqual(Number(q.rows[0].n), 12345, 'INT column round-trip'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_int4' }); + }) + const q = await call('database::query', { db: driver, sql: 'SELECT n FROM bx_int4' }) + expectEqual(Number(q.rows[0].n), 12345, 'INT column round-trip') + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_int4' }) }, }, { name: 'NULL param insert and select', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - const ph2 = dialect.placeholder(2); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_null' }); - await call('iii-database::execute', { db: driver, sql: 'CREATE TABLE bx_null (a INT NULL, b TEXT NULL)' }); - const r = await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + const ph2 = dialect.placeholder(2) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_null' }) + await call('database::execute', { db: driver, sql: 'CREATE TABLE bx_null (a INT NULL, b TEXT NULL)' }) + const r = await call('database::execute', { db: driver, sql: `INSERT INTO bx_null (a, b) VALUES (${ph1}, ${ph2})`, params: [null, null], - }); - expectEqual(r.affected_rows, 1, 'insert with null params'); - const q = await call('iii-database::query', { + }) + expectEqual(r.affected_rows, 1, 'insert with null params') + const q = await call('database::query', { db: driver, sql: 'SELECT a, b FROM bx_null WHERE a IS NULL AND b IS NULL', - }); - expectEqual(q.row_count, 1, 'one matching row with both nulls'); - expectEqual(q.rows[0].a, null, 'a is JSON null'); - expectEqual(q.rows[0].b, null, 'b is JSON null'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_null' }); + }) + expectEqual(q.row_count, 1, 'one matching row with both nulls') + expectEqual(q.rows[0].a, null, 'a is JSON null') + expectEqual(q.rows[0].b, null, 'b is JSON null') + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_null' }) }, }, { name: 'empty string vs NULL distinction', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - const ph2 = dialect.placeholder(2); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_emptynull' }); + const ph1 = dialect.placeholder(1) + const ph2 = dialect.placeholder(2) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_emptynull' }) // postgres/mysql/sqlite all distinguish '' from NULL; assert the worker doesn't conflate. - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: `CREATE TABLE bx_emptynull (id ${dialect.idColumnDDL()}, s TEXT NULL)`, - }); - await call('iii-database::execute', { + }) + await call('database::execute', { db: driver, sql: `INSERT INTO bx_emptynull (s) VALUES (${ph1}), (${ph2})`, params: ['', null], - }); - const q = await call('iii-database::query', { + }) + const q = await call('database::query', { db: driver, sql: 'SELECT s FROM bx_emptynull ORDER BY id', - }); - expectEqual(q.rows[0].s, '', 'first row is empty string, not null'); - expectEqual(q.rows[1].s, null, 'second row is null, not empty string'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_emptynull' }); + }) + expectEqual(q.rows[0].s, '', 'first row is empty string, not null') + expectEqual(q.rows[1].s, null, 'second row is null, not empty string') + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_emptynull' }) }, }, { name: 'UTF-8 round-trip (emoji + RTL + combining marks)', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_utf8' }); - await call('iii-database::execute', { db: driver, sql: 'CREATE TABLE bx_utf8 (s TEXT NOT NULL)' }); + const ph1 = dialect.placeholder(1) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_utf8' }) + await call('database::execute', { db: driver, sql: 'CREATE TABLE bx_utf8 (s TEXT NOT NULL)' }) // Mix: emoji (4-byte UTF-8), RTL Arabic, Latin with combining acute, ZWSP, Han ideograph. - const payload = '🔥مرحبا é​汉'; - await call('iii-database::execute', { + const payload = '🔥مرحبا é​汉' + await call('database::execute', { db: driver, sql: `INSERT INTO bx_utf8 (s) VALUES (${ph1})`, params: [payload], - }); - const q = await call('iii-database::query', { db: driver, sql: 'SELECT s FROM bx_utf8' }); - expectEqual(q.rows[0].s, payload, 'utf-8 round-trip exact equality'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_utf8' }); + }) + const q = await call('database::query', { db: driver, sql: 'SELECT s FROM bx_utf8' }) + expectEqual(q.rows[0].s, payload, 'utf-8 round-trip exact equality') + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_utf8' }) }, }, { name: 'long string round-trip (64KB)', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_long' }); + const ph1 = dialect.placeholder(1) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_long' }) // MySQL TEXT caps at 64KB; LONGTEXT is unbounded. Use LONGTEXT on mysql to stay clear of headers. - const colType = driver === 'mysql_db' ? 'LONGTEXT' : 'TEXT'; - await call('iii-database::execute', { + const colType = driver === 'mysql_db' ? 'LONGTEXT' : 'TEXT' + await call('database::execute', { db: driver, sql: `CREATE TABLE bx_long (s ${colType} NOT NULL)`, - }); - const payload = 'x'.repeat(64 * 1024 - 16); - await call('iii-database::execute', { + }) + const payload = 'x'.repeat(64 * 1024 - 16) + await call('database::execute', { db: driver, sql: `INSERT INTO bx_long (s) VALUES (${ph1})`, params: [payload], - }); - const q = await call('iii-database::query', { db: driver, sql: 'SELECT s FROM bx_long' }); - expectEqual( - (q.rows[0].s as string).length, - payload.length, - '64KB string length preserved', - ); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_long' }); + }) + const q = await call('database::query', { db: driver, sql: 'SELECT s FROM bx_long' }) + expectEqual((q.rows[0].s as string).length, payload.length, '64KB string length preserved') + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_long' }) }, }, { name: 'float values including small subnormal', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - const ph2 = dialect.placeholder(2); - const ph3 = dialect.placeholder(3); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_float' }); - await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + const ph2 = dialect.placeholder(2) + const ph3 = dialect.placeholder(3) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_float' }) + await call('database::execute', { db: driver, sql: `CREATE TABLE bx_float (id ${dialect.idColumnDDL()}, f DOUBLE PRECISION NOT NULL)`, - }); - await call('iii-database::execute', { + }) + await call('database::execute', { db: driver, sql: `INSERT INTO bx_float (f) VALUES (${ph1}), (${ph2}), (${ph3})`, params: [0.0, 2.5, 1.5e-300], - }); - const q = await call('iii-database::query', { db: driver, sql: 'SELECT f FROM bx_float ORDER BY id' }); - const fs = q.rows.map((r: any) => Number(r.f)); - expect(Math.abs(fs[0] - 0.0) < 1e-12, `f0 ≈ 0.0, got ${fs[0]}`); - expect(Math.abs(fs[1] - 2.5) < 1e-12, `f1 ≈ 2.5, got ${fs[1]}`); - expect(fs[2] < 1e-200 && fs[2] > 0, `f2 is small positive double, got ${fs[2]}`); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_float' }); + }) + const q = await call('database::query', { db: driver, sql: 'SELECT f FROM bx_float ORDER BY id' }) + const fs = q.rows.map((r: any) => Number(r.f)) + expect(Math.abs(fs[0] - 0.0) < 1e-12, `f0 ≈ 0.0, got ${fs[0]}`) + expect(Math.abs(fs[1] - 2.5) < 1e-12, `f1 ≈ 2.5, got ${fs[1]}`) + expect(fs[2] < 1e-200 && fs[2] > 0, `f2 is small positive double, got ${fs[2]}`) + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_float' }) }, }, { @@ -227,79 +223,79 @@ export const BOUNDARY_CASES: TestCase[] = [ // bind as TEXT, not JSONB — that's a different code path. applies: ['pg_db'], async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_jsonb' }); - await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_jsonb' }) + await call('database::execute', { db: driver, sql: `CREATE TABLE bx_jsonb (id ${dialect.idColumnDDL()}, body JSONB NOT NULL, label TEXT NOT NULL)`, - }); + }) // Each shape must round-trip exactly through the // RowValue::Json → into_json path without re-serialization quirks. const cases: Array<{ label: string; body: unknown }> = [ - { label: 'obj', body: { user: { id: 7, name: 'O\'Brien', tags: ['a', 'b'] }, count: 42 } }, + { label: 'obj', body: { user: { id: 7, name: "O'Brien", tags: ['a', 'b'] }, count: 42 } }, { label: 'arr', body: [1, 'two', null, true, { k: 'v' }] }, { label: 'with_null', body: { a: null, b: 0 } }, { label: 'empty_obj', body: {} }, { label: 'empty_arr', body: [] }, - ]; + ] for (const c of cases) { - const r = await call('iii-database::execute', { + const r = await call('database::execute', { db: driver, sql: `INSERT INTO bx_jsonb (body, label) VALUES (${ph1}, '${c.label}')`, params: [c.body], - }); - expectEqual(r.affected_rows, 1, `inserted ${c.label}`); + }) + expectEqual(r.affected_rows, 1, `inserted ${c.label}`) } - const q = await call('iii-database::query', { + const q = await call('database::query', { db: driver, sql: 'SELECT label, body FROM bx_jsonb ORDER BY id', - }); - expectEqual(q.row_count, cases.length, 'all jsonb rows returned'); + }) + expectEqual(q.row_count, cases.length, 'all jsonb rows returned') // Postgres jsonb canonicalizes both whitespace AND object key order // (alphabetical by key). Compare semantic equality with a stable-key // canonicalization on both sides. const canon = (v: unknown): unknown => { - if (Array.isArray(v)) return v.map(canon); + if (Array.isArray(v)) return v.map(canon) if (v !== null && typeof v === 'object') { - const out: Record = {}; + const out: Record = {} for (const k of Object.keys(v as Record).sort()) { - out[k] = canon((v as Record)[k]); + out[k] = canon((v as Record)[k]) } - return out; + return out } - return v; - }; + return v + } for (let i = 0; i < cases.length; i++) { - expectEqual(q.rows[i].label, cases[i].label, `row ${i} label`); - expectEqual(canon(q.rows[i].body), canon(cases[i].body), `row ${i} body (${cases[i].label}) round-trip`); + expectEqual(q.rows[i].label, cases[i].label, `row ${i} label`) + expectEqual(canon(q.rows[i].body), canon(cases[i].body), `row ${i} body (${cases[i].label}) round-trip`) } - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_jsonb' }); + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_jsonb' }) }, }, { name: 'special characters in string params (parameterized binding)', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_special' }); - await call('iii-database::execute', { db: driver, sql: 'CREATE TABLE bx_special (s TEXT NOT NULL)' }); + const ph1 = dialect.placeholder(1) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_special' }) + await call('database::execute', { db: driver, sql: 'CREATE TABLE bx_special (s TEXT NOT NULL)' }) // If the worker were string-interpolating, single-quote would terminate the literal // and the trailing "; DROP TABLE …" would execute. Proper parameter binding makes // the value inert — round-trip equality + table-still-exists asserts that. - const payload = `O'Brien "the\\quoted"\n\t\r-- ; DROP TABLE bx_special`; - await call('iii-database::execute', { + const payload = `O'Brien "the\\quoted"\n\t\r-- ; DROP TABLE bx_special` + await call('database::execute', { db: driver, sql: `INSERT INTO bx_special (s) VALUES (${ph1})`, params: [payload], - }); - const q = await call('iii-database::query', { db: driver, sql: 'SELECT s FROM bx_special' }); - expectEqual(q.rows[0].s, payload, 'special-char string round-trip'); - const q2 = await call('iii-database::query', { + }) + const q = await call('database::query', { db: driver, sql: 'SELECT s FROM bx_special' }) + expectEqual(q.rows[0].s, payload, 'special-char string round-trip') + const q2 = await call('database::query', { db: driver, sql: 'SELECT COUNT(*) AS c FROM bx_special', - }); - expectEqual(Number(q2.rows[0].c), 1, 'table not dropped by injection-shaped payload'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_special' }); + }) + expectEqual(Number(q2.rows[0].c), 1, 'table not dropped by injection-shaped payload') + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_special' }) }, }, { @@ -314,17 +310,21 @@ export const BOUNDARY_CASES: TestCase[] = [ name: 'NUMERIC edge cases route through binary fallback (postgres)', applies: ['pg_db'], async run({ driver, call }) { - const q = await call('iii-database::query', { + const q = await call('database::query', { db: driver, sql: `SELECT 'NaN'::numeric AS nan, 'Infinity'::numeric AS pinf, '-Infinity'::numeric AS ninf, 100000000000000000000000000000::numeric AS big`, - }); - expectEqual(q.rows[0].nan, 'NaN', 'NaN survives via binary fallback'); - expectEqual(q.rows[0].pinf, 'Infinity', '+Infinity survives via binary fallback'); - expectEqual(q.rows[0].ninf, '-Infinity', '-Infinity survives via binary fallback'); - expectEqual(q.rows[0].big, '100000000000000000000000000000', '10^29 (beyond rust_decimal) survives via binary fallback'); + }) + expectEqual(q.rows[0].nan, 'NaN', 'NaN survives via binary fallback') + expectEqual(q.rows[0].pinf, 'Infinity', '+Infinity survives via binary fallback') + expectEqual(q.rows[0].ninf, '-Infinity', '-Infinity survives via binary fallback') + expectEqual( + q.rows[0].big, + '100000000000000000000000000000', + '10^29 (beyond rust_decimal) survives via binary fallback', + ) }, }, { @@ -337,31 +337,31 @@ export const BOUNDARY_CASES: TestCase[] = [ name: 'NUMERIC columns decode to string (postgres)', applies: ['pg_db'], async run({ driver, call }) { - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_numeric' }); - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_numeric' }) + await call('database::execute', { db: driver, sql: 'CREATE TABLE bx_numeric (label TEXT NOT NULL, n NUMERIC NOT NULL)', - }); - await call('iii-database::execute', { + }) + await call('database::execute', { db: driver, sql: `INSERT INTO bx_numeric (label, n) VALUES ('exact', 12345.6789), ('negf', -0.001), ('zero', 0), ('big', 999999999999.99)`, - }); - const q = await call('iii-database::query', { + }) + const q = await call('database::query', { db: driver, sql: 'SELECT label, n FROM bx_numeric ORDER BY label', - }); - const byLabel = Object.fromEntries(q.rows.map((r: Record) => [r.label, r.n])); + }) + const byLabel = Object.fromEntries(q.rows.map((r: Record) => [r.label, r.n])) // rust_decimal canonical stringification — no trailing zeros beyond what // the Decimal carries. - expectEqual(byLabel.exact, '12345.6789', 'NUMERIC positive fractional'); - expectEqual(byLabel.negf, '-0.001', 'NUMERIC negative fractional'); - expectEqual(byLabel.zero, '0', 'NUMERIC zero'); - expectEqual(byLabel.big, '999999999999.99', 'NUMERIC large value'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_numeric' }); + expectEqual(byLabel.exact, '12345.6789', 'NUMERIC positive fractional') + expectEqual(byLabel.negf, '-0.001', 'NUMERIC negative fractional') + expectEqual(byLabel.zero, '0', 'NUMERIC zero') + expectEqual(byLabel.big, '999999999999.99', 'NUMERIC large value') + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_numeric' }) }, }, { @@ -375,24 +375,24 @@ export const BOUNDARY_CASES: TestCase[] = [ name: 'TIMESTAMP without time zone decodes (postgres)', applies: ['pg_db'], async run({ driver, call }) { - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_ts' }); - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_ts' }) + await call('database::execute', { db: driver, sql: 'CREATE TABLE bx_ts (naive TIMESTAMP NOT NULL, with_tz TIMESTAMPTZ NOT NULL)', - }); - await call('iii-database::execute', { + }) + await call('database::execute', { db: driver, sql: `INSERT INTO bx_ts (naive, with_tz) VALUES ('2026-04-29 12:00:00', '2026-04-29 12:00:00+00')`, - }); - const q = await call('iii-database::query', { + }) + const q = await call('database::query', { db: driver, sql: 'SELECT naive, with_tz FROM bx_ts', - }); + }) // Pre-fix, querying the `naive` column raised `WrongType` and the entire // call rejected. Now both columns surface as RFC 3339 UTC strings. - expectEqual(q.rows[0].naive, '2026-04-29T12:00:00Z', 'TIMESTAMP without tz round-trips'); - expectEqual(q.rows[0].with_tz, '2026-04-29T12:00:00Z', 'TIMESTAMPTZ round-trips'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_ts' }); + expectEqual(q.rows[0].naive, '2026-04-29T12:00:00Z', 'TIMESTAMP without tz round-trips') + expectEqual(q.rows[0].with_tz, '2026-04-29T12:00:00Z', 'TIMESTAMPTZ round-trips') + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_ts' }) }, }, { @@ -412,21 +412,18 @@ export const BOUNDARY_CASES: TestCase[] = [ // 3 rows × SLEEP(2) = ~6s of server-side row generation. With // timeout_ms=500 the wrapped pipeline must surface QUERY_TIMEOUT well // before the stream completes. - const start = Date.now(); + const start = Date.now() await expectError( () => - call('iii-database::query', { + call('database::query', { db: driver, sql: 'SELECT SLEEP(2), n FROM (SELECT 1 AS n UNION SELECT 2 UNION SELECT 3) AS t', timeout_ms: 500, }), 'QUERY_TIMEOUT', - ); - const elapsed = Date.now() - start; - expect( - elapsed < 3_000, - `timeout should fire well before the ~6s row stream completes; elapsed=${elapsed}ms`, - ); + ) + const elapsed = Date.now() - start + expect(elapsed < 3_000, `timeout should fire well before the ~6s row stream completes; elapsed=${elapsed}ms`) }, }, -]; +] diff --git a/iii-database/tests/e2e/workers/harness/src/cases-concurrency.ts b/database/tests/e2e/workers/harness/src/cases-concurrency.ts similarity index 64% rename from iii-database/tests/e2e/workers/harness/src/cases-concurrency.ts rename to database/tests/e2e/workers/harness/src/cases-concurrency.ts index cfa63e6b..a97fb9c5 100644 --- a/iii-database/tests/e2e/workers/harness/src/cases-concurrency.ts +++ b/database/tests/e2e/workers/harness/src/cases-concurrency.ts @@ -1,5 +1,5 @@ -import type { TestCase } from './cases.ts'; -import { expect, expectEqual } from './cases.ts'; +import type { TestCase } from './cases.ts' +import { expect, expectEqual } from './cases.ts' /** * Pool + handle concurrency cases. Pool max=10 / acquire_timeout=5s per @@ -11,49 +11,47 @@ export const CONCURRENCY_CASES: TestCase[] = [ name: '10 parallel SELECT 1 against same db', async run({ driver, call }) { // Pool max = 10. All 10 queries should finish without queue contention. - const promises = Array.from({ length: 10 }, () => - call('iii-database::query', { db: driver, sql: 'SELECT 1 AS n' }), - ); - const results = await Promise.all(promises); - expectEqual(results.length, 10, '10 results'); + const promises = Array.from({ length: 10 }, () => call('database::query', { db: driver, sql: 'SELECT 1 AS n' })) + const results = await Promise.all(promises) + expectEqual(results.length, 10, '10 results') for (const r of results) { - expectEqual(r.row_count, 1, 'each query returned 1 row'); + expectEqual(r.row_count, 1, 'each query returned 1 row') } }, }, { name: 'prepared statement reused 50 times sequentially', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); + const ph1 = dialect.placeholder(1) // Bare `SELECT ${ph1} AS v` defaults the param to `text` on Postgres // because the polymorphic param has no type context — sending int4 binary // there triggers SQL state 22021 ("character not in repertoire") at decode. // The fix: anchor the param's type via a real schema column (matches the // existing prepareStatement + runStatement test in cases.ts). - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_prep_50x' }); - await call('iii-database::execute', { db: driver, sql: 'CREATE TABLE bx_prep_50x (n INT NOT NULL)' }); + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS bx_prep_50x' }) + await call('database::execute', { db: driver, sql: 'CREATE TABLE bx_prep_50x (n INT NOT NULL)' }) // Seed 50 rows so each iteration can match a unique value. for (let i = 0; i < 50; i++) { - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: `INSERT INTO bx_prep_50x (n) VALUES (${ph1})`, params: [i], - }); + }) } - const prep = await call('iii-database::prepareStatement', { + const prep = await call('database::prepareStatement', { db: driver, sql: `SELECT n FROM bx_prep_50x WHERE n = ${ph1} LIMIT 1`, - }); - const handleId = prep.handle?.id; - expect(typeof handleId === 'string' && handleId.length > 0, 'handle id present'); + }) + const handleId = prep.handle?.id + expect(typeof handleId === 'string' && handleId.length > 0, 'handle id present') // Default TTL is far longer than 50 iterations; this catches handle-cache // lifetime bugs where a hot handle gets evicted mid-loop. for (let i = 0; i < 50; i++) { - const r = await call('iii-database::runStatement', { handle_id: handleId, params: [i] }); - expectEqual(r.row_count, 1, `iter ${i} row_count`); - expectEqual(Number(r.rows[0].n), i, `iter ${i} value`); + const r = await call('database::runStatement', { handle_id: handleId, params: [i] }) + expectEqual(r.row_count, 1, `iter ${i} row_count`) + expectEqual(Number(r.rows[0].n), i, `iter ${i} value`) } - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE bx_prep_50x' }); + await call('database::execute', { db: driver, sql: 'DROP TABLE bx_prep_50x' }) }, }, { @@ -63,31 +61,28 @@ export const CONCURRENCY_CASES: TestCase[] = [ // config.yaml; we hold connections 6s to force the timeout. applies: ['pg_db', 'mysql_db'], async run({ driver, call }) { - const sleepSql = driver === 'pg_db' ? 'SELECT pg_sleep(6)' : 'SELECT SLEEP(6)'; + const sleepSql = driver === 'pg_db' ? 'SELECT pg_sleep(6)' : 'SELECT SLEEP(6)' // 12 concurrent queries against a max=10 pool. Acquire timeout = 5s, query // hold = 6s, so the 11th and 12th waiters must time out before any holder // releases. We assert at least one rejection contains POOL_TIMEOUT — the // exact count depends on scheduler timing. const promises = Array.from({ length: 12 }, () => - call('iii-database::query', { db: driver, sql: sleepSql, timeout_ms: 30_000 }), - ); - const settled = await Promise.allSettled(promises); - const rejected = settled.filter((s) => s.status === 'rejected') as PromiseRejectedResult[]; - const fulfilled = settled.filter((s) => s.status === 'fulfilled'); - expect( - rejected.length >= 1, - `expected at least 1 POOL_TIMEOUT rejection, got 0 (fulfilled=${fulfilled.length})`, - ); + call('database::query', { db: driver, sql: sleepSql, timeout_ms: 30_000 }), + ) + const settled = await Promise.allSettled(promises) + const rejected = settled.filter((s) => s.status === 'rejected') as PromiseRejectedResult[] + const fulfilled = settled.filter((s) => s.status === 'fulfilled') + expect(rejected.length >= 1, `expected at least 1 POOL_TIMEOUT rejection, got 0 (fulfilled=${fulfilled.length})`) const sawPoolTimeout = rejected.some((r) => { - const msg = (r.reason as any)?.message ?? String(r.reason); - return msg.includes('POOL_TIMEOUT'); - }); + const msg = (r.reason as any)?.message ?? String(r.reason) + return msg.includes('POOL_TIMEOUT') + }) expect( sawPoolTimeout, `at least one rejection should be POOL_TIMEOUT; reasons: ${rejected .map((r) => (r.reason as any)?.message ?? String(r.reason)) .join(' | ')}`, - ); + ) }, }, -]; +] diff --git a/iii-database/tests/e2e/workers/harness/src/cases-interactive-tx.ts b/database/tests/e2e/workers/harness/src/cases-interactive-tx.ts similarity index 56% rename from iii-database/tests/e2e/workers/harness/src/cases-interactive-tx.ts rename to database/tests/e2e/workers/harness/src/cases-interactive-tx.ts index 17e540c4..16bc53ab 100644 --- a/iii-database/tests/e2e/workers/harness/src/cases-interactive-tx.ts +++ b/database/tests/e2e/workers/harness/src/cases-interactive-tx.ts @@ -1,5 +1,5 @@ -import type { TestCase } from './cases.ts'; -import { expect, expectEqual } from './cases.ts'; +import type { TestCase } from './cases.ts' +import { expect, expectEqual } from './cases.ts' /** * Interactive-transaction surface end-to-end cases. Drives the full @@ -15,121 +15,120 @@ export const INTERACTIVE_TX_CASES: TestCase[] = [ { name: 'interactive tx commit lands writes', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS itx_commit' }); - await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS itx_commit' }) + await call('database::execute', { db: driver, sql: `CREATE TABLE itx_commit (id ${dialect.idColumnDDL()}, n INT NOT NULL)`, - }); + }) try { - const begin = await call('iii-database::beginTransaction', { db: driver }); - const id = begin.transaction?.id; - expect(typeof id === 'string' && id.length === 36, `tx id must be UUID, got ${id}`); + const begin = await call('database::beginTransaction', { db: driver }) + const id = begin.transaction?.id + expect(typeof id === 'string' && id.length === 36, `tx id must be UUID, got ${id}`) - const ins = await call('iii-database::transactionExecute', { + const ins = await call('database::transactionExecute', { transaction_id: id, sql: `INSERT INTO itx_commit (n) VALUES (${ph1})`, params: [42], - }); - expectEqual(ins.affected_rows, 1, 'insert affected_rows inside tx'); + }) + expectEqual(ins.affected_rows, 1, 'insert affected_rows inside tx') // Read-your-writes: the INSERT must be visible from inside the same tx. - const ryw = await call('iii-database::transactionQuery', { + const ryw = await call('database::transactionQuery', { transaction_id: id, sql: 'SELECT n FROM itx_commit', - }); - expectEqual(ryw.row_count, 1, 'tx sees its own insert'); - expectEqual(Number(ryw.rows[0].n), 42, 'tx read-your-writes value'); + }) + expectEqual(ryw.row_count, 1, 'tx sees its own insert') + expectEqual(Number(ryw.rows[0].n), 42, 'tx read-your-writes value') - const c = await call('iii-database::commitTransaction', { transaction_id: id }); - expectEqual(c.committed, true, 'commit returns committed=true'); + const c = await call('database::commitTransaction', { transaction_id: id }) + expectEqual(c.committed, true, 'commit returns committed=true') // Verify outside the tx that the write landed. - const verify = await call('iii-database::query', { db: driver, sql: 'SELECT n FROM itx_commit' }); - expectEqual(verify.row_count, 1, 'committed write visible after commit'); - expectEqual(Number(verify.rows[0].n), 42, 'post-commit value'); + const verify = await call('database::query', { db: driver, sql: 'SELECT n FROM itx_commit' }) + expectEqual(verify.row_count, 1, 'committed write visible after commit') + expectEqual(Number(verify.rows[0].n), 42, 'post-commit value') } finally { - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE itx_commit' }); + await call('database::execute', { db: driver, sql: 'DROP TABLE itx_commit' }) } }, }, { name: 'interactive tx rollback discards writes', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS itx_rollback' }); - await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS itx_rollback' }) + await call('database::execute', { db: driver, sql: `CREATE TABLE itx_rollback (id ${dialect.idColumnDDL()}, n INT NOT NULL)`, - }); + }) try { - const begin = await call('iii-database::beginTransaction', { db: driver }); - const id = begin.transaction.id; - await call('iii-database::transactionExecute', { + const begin = await call('database::beginTransaction', { db: driver }) + const id = begin.transaction.id + await call('database::transactionExecute', { transaction_id: id, sql: `INSERT INTO itx_rollback (n) VALUES (${ph1})`, params: [99], - }); - const r = await call('iii-database::rollbackTransaction', { transaction_id: id }); - expectEqual(r.rolled_back, true, 'rollback returns rolled_back=true'); + }) + const r = await call('database::rollbackTransaction', { transaction_id: id }) + expectEqual(r.rolled_back, true, 'rollback returns rolled_back=true') - const verify = await call('iii-database::query', { + const verify = await call('database::query', { db: driver, sql: 'SELECT COUNT(*) AS c FROM itx_rollback', - }); - expectEqual(Number(verify.rows[0].c), 0, 'rolled-back insert not visible'); + }) + expectEqual(Number(verify.rows[0].c), 0, 'rolled-back insert not visible') } finally { - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE itx_rollback' }); + await call('database::execute', { db: driver, sql: 'DROP TABLE itx_rollback' }) } }, }, { name: 'interactive tx commit then query returns TRANSACTION_NOT_FOUND', async run({ driver, call, expectError }) { - const begin = await call('iii-database::beginTransaction', { db: driver }); - const id = begin.transaction.id; - await call('iii-database::commitTransaction', { transaction_id: id }); + const begin = await call('database::beginTransaction', { db: driver }) + const id = begin.transaction.id + await call('database::commitTransaction', { transaction_id: id }) await expectError( - () => call('iii-database::transactionQuery', { transaction_id: id, sql: 'SELECT 1' }), + () => call('database::transactionQuery', { transaction_id: id, sql: 'SELECT 1' }), 'TRANSACTION_NOT_FOUND', - ); + ) }, }, { name: 'interactive tx double-commit returns TRANSACTION_NOT_FOUND', async run({ driver, call, expectError }) { - const begin = await call('iii-database::beginTransaction', { db: driver }); - const id = begin.transaction.id; - await call('iii-database::commitTransaction', { transaction_id: id }); - await expectError( - () => call('iii-database::commitTransaction', { transaction_id: id }), - 'TRANSACTION_NOT_FOUND', - ); + const begin = await call('database::beginTransaction', { db: driver }) + const id = begin.transaction.id + await call('database::commitTransaction', { transaction_id: id }) + await expectError(() => call('database::commitTransaction', { transaction_id: id }), 'TRANSACTION_NOT_FOUND') }, }, { name: 'transactionExecute rejects COMMIT/ROLLBACK SQL with INVALID_PARAM', async run({ driver, call, expectError }) { - const begin = await call('iii-database::beginTransaction', { db: driver }); - const id = begin.transaction.id; + const begin = await call('database::beginTransaction', { db: driver }) + const id = begin.transaction.id try { await expectError( - () => call('iii-database::transactionExecute', { transaction_id: id, sql: 'COMMIT' }), + () => call('database::transactionExecute', { transaction_id: id, sql: 'COMMIT' }), 'INVALID_PARAM', - ); + ) await expectError( - () => call('iii-database::transactionExecute', { transaction_id: id, sql: 'ROLLBACK' }), + () => call('database::transactionExecute', { transaction_id: id, sql: 'ROLLBACK' }), 'INVALID_PARAM', - ); + ) await expectError( - () => call('iii-database::transactionExecute', { transaction_id: id, sql: 'BEGIN' }), + () => call('database::transactionExecute', { transaction_id: id, sql: 'BEGIN' }), 'INVALID_PARAM', - ); + ) } finally { // The tx is still open (rejections don't finalize); clean up. try { - await call('iii-database::rollbackTransaction', { transaction_id: id }); - } catch {/* ignore */} + await call('database::rollbackTransaction', { transaction_id: id }) + } catch { + /* ignore */ + } } }, }, @@ -141,37 +140,39 @@ export const INTERACTIVE_TX_CASES: TestCase[] = [ // and reached the driver, desyncing the registry from the conn's txn state. name: 'transactionExecute rejects comment-prefixed and START-form finalization', async run({ driver, call, expectError }) { - const begin = await call('iii-database::beginTransaction', { db: driver }); - const id = begin.transaction.id; + const begin = await call('database::beginTransaction', { db: driver }) + const id = begin.transaction.id try { await expectError( () => - call('iii-database::transactionExecute', { + call('database::transactionExecute', { transaction_id: id, sql: '/* sneak */COMMIT', }), 'INVALID_PARAM', - ); + ) await expectError( () => - call('iii-database::transactionExecute', { + call('database::transactionExecute', { transaction_id: id, sql: '-- sneak\nCOMMIT', }), 'INVALID_PARAM', - ); + ) await expectError( () => - call('iii-database::transactionExecute', { + call('database::transactionExecute', { transaction_id: id, sql: 'START TRANSACTION', }), 'INVALID_PARAM', - ); + ) } finally { try { - await call('iii-database::rollbackTransaction', { transaction_id: id }); - } catch {/* ignore */} + await call('database::rollbackTransaction', { transaction_id: id }) + } catch { + /* ignore */ + } } }, }, @@ -181,38 +182,39 @@ export const INTERACTIVE_TX_CASES: TestCase[] = [ // prepared-statement path used by `run_prepared`. Must now reject. name: 'transactionQuery rejects COMMIT/ROLLBACK and comment-prefixed forms', async run({ driver, call, expectError }) { - const begin = await call('iii-database::beginTransaction', { db: driver }); - const id = begin.transaction.id; + const begin = await call('database::beginTransaction', { db: driver }) + const id = begin.transaction.id try { await expectError( - () => call('iii-database::transactionQuery', { transaction_id: id, sql: 'COMMIT' }), + () => call('database::transactionQuery', { transaction_id: id, sql: 'COMMIT' }), 'INVALID_PARAM', - ); + ) await expectError( - () => - call('iii-database::transactionQuery', { transaction_id: id, sql: 'ROLLBACK' }), + () => call('database::transactionQuery', { transaction_id: id, sql: 'ROLLBACK' }), 'INVALID_PARAM', - ); + ) await expectError( () => - call('iii-database::transactionQuery', { + call('database::transactionQuery', { transaction_id: id, sql: '/* sneak */COMMIT', }), 'INVALID_PARAM', - ); + ) await expectError( () => - call('iii-database::transactionQuery', { + call('database::transactionQuery', { transaction_id: id, sql: 'START TRANSACTION', }), 'INVALID_PARAM', - ); + ) } finally { try { - await call('iii-database::rollbackTransaction', { transaction_id: id }); - } catch {/* ignore */} + await call('database::rollbackTransaction', { transaction_id: id }) + } catch { + /* ignore */ + } } }, }, @@ -220,10 +222,9 @@ export const INTERACTIVE_TX_CASES: TestCase[] = [ name: 'interactive tx beginTransaction unknown isolation returns INVALID_PARAM', async run({ driver, call, expectError }) { await expectError( - () => - call('iii-database::beginTransaction', { db: driver, isolation: 'bogus_level' }), + () => call('database::beginTransaction', { db: driver, isolation: 'bogus_level' }), 'INVALID_PARAM', - ); + ) }, }, { @@ -235,17 +236,17 @@ export const INTERACTIVE_TX_CASES: TestCase[] = [ name: 'interactive tx timeout auto-rolls-back and frees the id', applies: ['sqlite_db'], async run({ driver, call, expectError }) { - const begin = await call('iii-database::beginTransaction', { + const begin = await call('database::beginTransaction', { db: driver, timeout_ms: 300, - }); - const id = begin.transaction.id; + }) + const id = begin.transaction.id // Wait past the deadline + one watcher tick + a safety margin. - await new Promise((r) => setTimeout(r, 1800)); + await new Promise((r) => setTimeout(r, 1800)) await expectError( - () => call('iii-database::transactionQuery', { transaction_id: id, sql: 'SELECT 1' }), + () => call('database::transactionQuery', { transaction_id: id, sql: 'SELECT 1' }), 'TRANSACTION_NOT_FOUND', - ); + ) }, }, -]; +] diff --git a/iii-database/tests/e2e/workers/harness/src/cases-protocol.ts b/database/tests/e2e/workers/harness/src/cases-protocol.ts similarity index 66% rename from iii-database/tests/e2e/workers/harness/src/cases-protocol.ts rename to database/tests/e2e/workers/harness/src/cases-protocol.ts index 62fbb783..f94defdc 100644 --- a/iii-database/tests/e2e/workers/harness/src/cases-protocol.ts +++ b/database/tests/e2e/workers/harness/src/cases-protocol.ts @@ -1,5 +1,5 @@ -import type { TestCase } from './cases.ts'; -import { expect, expectEqual } from './cases.ts'; +import type { TestCase } from './cases.ts' +import { expect, expectEqual } from './cases.ts' /** * Protocol-misuse cases — assert the worker returns the documented error code @@ -12,19 +12,13 @@ export const PROTOCOL_CASES: TestCase[] = [ { name: 'unknown db rejects with UNKNOWN_DB', async run({ call, expectError }) { - await expectError( - () => call('iii-database::query', { db: 'no_such_db', sql: 'SELECT 1' }), - 'UNKNOWN_DB', - ); + await expectError(() => call('database::query', { db: 'no_such_db', sql: 'SELECT 1' }), 'UNKNOWN_DB') }, }, { name: 'empty SQL rejects with DRIVER_ERROR', async run({ driver, call, expectError }) { - await expectError( - () => call('iii-database::query', { db: driver, sql: '' }), - 'DRIVER_ERROR', - ); + await expectError(() => call('database::query', { db: driver, sql: '' }), 'DRIVER_ERROR') }, }, { @@ -32,51 +26,48 @@ export const PROTOCOL_CASES: TestCase[] = [ async run({ call, expectError }) { await expectError( () => - call('iii-database::runStatement', { + call('database::runStatement', { handle_id: '00000000-0000-0000-0000-000000000000', params: [], }), 'STATEMENT_NOT_FOUND', - ); + ) }, }, { name: 'runStatement with wrong param count rejects', async run({ driver, dialect, call, expectError }) { - const ph1 = dialect.placeholder(1); + const ph1 = dialect.placeholder(1) // Use a fresh prepare so test order doesn't matter. - const prep = await call('iii-database::prepareStatement', { + const prep = await call('database::prepareStatement', { db: driver, sql: `SELECT ${ph1} AS v`, - }); - const handleId = prep.handle?.id; - expect(typeof handleId === 'string' && handleId.length > 0, 'handle id present'); + }) + const handleId = prep.handle?.id + expect(typeof handleId === 'string' && handleId.length > 0, 'handle id present') // Driver should reject param-count mismatch. Exact code varies by driver // (DRIVER_ERROR with inner SQL state); we match on DRIVER_ERROR. - await expectError( - () => call('iii-database::runStatement', { handle_id: handleId, params: [] }), - 'DRIVER_ERROR', - ); + await expectError(() => call('database::runStatement', { handle_id: handleId, params: [] }), 'DRIVER_ERROR') }, }, { name: 'prepared statement after TTL expiry rejects', async run({ driver, dialect, call, expectError }) { - const ph1 = dialect.placeholder(1); - const prep = await call('iii-database::prepareStatement', { + const ph1 = dialect.placeholder(1) + const prep = await call('database::prepareStatement', { db: driver, sql: `SELECT ${ph1} AS v`, ttl_seconds: 1, - }); - const handleId = prep.handle?.id; - expect(typeof handleId === 'string' && handleId.length > 0, 'handle id present'); + }) + const handleId = prep.handle?.id + expect(typeof handleId === 'string' && handleId.length > 0, 'handle id present') // TTL is 1s; the registry evictor sweeps periodically. Wait long enough // that any reasonable evictor cadence will have run. - await new Promise((r) => setTimeout(r, 1500)); + await new Promise((r) => setTimeout(r, 1500)) await expectError( - () => call('iii-database::runStatement', { handle_id: handleId, params: [42] }), + () => call('database::runStatement', { handle_id: handleId, params: [42] }), 'STATEMENT_NOT_FOUND', - ); + ) }, }, { @@ -86,9 +77,9 @@ export const PROTOCOL_CASES: TestCase[] = [ // gracefully. affected_rows is undefined for SELECT in most drivers; the // worker normalizes that to 0. Asserting "no throw" is the main goal — // the value of affected_rows is a softer assertion. - const r = await call('iii-database::execute', { db: driver, sql: 'SELECT 1 AS v' }); - expect(typeof r === 'object' && r !== null, 'response is object'); - expectEqual(typeof r.affected_rows, 'number', 'affected_rows is a number'); + const r = await call('database::execute', { db: driver, sql: 'SELECT 1 AS v' }) + expect(typeof r === 'object' && r !== null, 'response is object') + expectEqual(typeof r.affected_rows, 'number', 'affected_rows is a number') }, }, -]; +] diff --git a/iii-database/tests/e2e/workers/harness/src/cases-row-change.ts b/database/tests/e2e/workers/harness/src/cases-row-change.ts similarity index 72% rename from iii-database/tests/e2e/workers/harness/src/cases-row-change.ts rename to database/tests/e2e/workers/harness/src/cases-row-change.ts index eb671379..ecc69107 100644 --- a/iii-database/tests/e2e/workers/harness/src/cases-row-change.ts +++ b/database/tests/e2e/workers/harness/src/cases-row-change.ts @@ -1,9 +1,9 @@ -import type { TestCase } from './cases.ts'; -import { expect, expectEqual } from './cases.ts'; +import type { TestCase } from './cases.ts' +import { expect, expectEqual } from './cases.ts' /** * row-change trigger validation. The streaming decoder is stubbed in v1.0 - * (worker rejects `iii-database::row-change` registration with `UNSUPPORTED`), + * (worker rejects `database::row-change` registration with `UNSUPPORTED`), * so we can't exercise the dispatch path end-to-end yet. What we CAN validate * is the slot/publication name derivation contract that the worker pins in * its README — distinct caller-supplied `trigger_id`s must produce distinct @@ -22,13 +22,13 @@ import { expect, expectEqual } from './cases.ts'; /** FNV-1a-32 over UTF-8 bytes. Mirrors `triggers/row_change.rs::fnv1a_32`. */ function fnv1a32(s: string): string { - let hash = 0x811c9dc5 >>> 0; - const bytes = Buffer.from(s, 'utf8'); + let hash = 0x811c9dc5 >>> 0 + const bytes = Buffer.from(s, 'utf8') for (const b of bytes) { - hash = (hash ^ b) >>> 0; - hash = Math.imul(hash, 0x01000193) >>> 0; + hash = (hash ^ b) >>> 0 + hash = Math.imul(hash, 0x01000193) >>> 0 } - return hash.toString(16).padStart(8, '0'); + return hash.toString(16).padStart(8, '0') } /** @@ -41,8 +41,8 @@ function deriveSlotName(triggerId: string): string { const sanitized = Array.from(triggerId) .map((c) => (/[a-zA-Z0-9]/.test(c) ? c.toLowerCase() : '_')) .slice(0, 40) - .join(''); - return `iii_slot_${sanitized}_${fnv1a32(triggerId)}`; + .join('') + return `iii_slot_${sanitized}_${fnv1a32(triggerId)}` } async function dropSlotIfExists( @@ -53,16 +53,16 @@ async function dropSlotIfExists( // pg_drop_replication_slot errors if the slot is missing; pre-check then drop. // Quote-escape the slot name as a SQL literal: replace any `'` with `''` // (slot names from derive_names are `[a-z0-9_]` only, so this is defensive). - const lit = slot.replace(/'/g, "''"); - const exists = await call('iii-database::query', { + const lit = slot.replace(/'/g, "''") + const exists = await call('database::query', { db: driver, sql: `SELECT 1 FROM pg_replication_slots WHERE slot_name = '${lit}'`, - }); + }) if (exists.row_count > 0) { - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: `SELECT pg_drop_replication_slot('${lit}')`, - }); + }) } } @@ -76,45 +76,45 @@ export const ROW_CHANGE_CASES: TestCase[] = [ // makes them distinct. We use 3 (not the full 5) because the docker // postgres image is configured with `max_replication_slots=4`, leaving // headroom for the long-trigger-id test that runs immediately after. - const ids = ['Orders.v1', 'orders-v1', 'orders v1']; - const slots = ids.map(deriveSlotName); + const ids = ['Orders.v1', 'orders-v1', 'orders v1'] + const slots = ids.map(deriveSlotName) // Sanity: TS-derived names must all be distinct. - const unique = new Set(slots); - expectEqual(unique.size, ids.length, 'TS-derived slot names must be unique across collision-prone inputs'); + const unique = new Set(slots) + expectEqual(unique.size, ids.length, 'TS-derived slot names must be unique across collision-prone inputs') // Each slot must respect Postgres' 63-byte limit. for (const s of slots) { - expect(s.length <= 63, `slot name too long (${s.length} bytes): ${s}`); + expect(s.length <= 63, `slot name too long (${s.length} bytes): ${s}`) } // Pre-clean any leftovers from a previous run. for (const slot of slots) { - await dropSlotIfExists(call, driver, slot); + await dropSlotIfExists(call, driver, slot) } try { // Create all five slots. If two collided, the second create call would // fail with `replication slot ... already exists`. for (const slot of slots) { - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: `SELECT * FROM pg_create_logical_replication_slot('${slot}', 'pgoutput')`, - }); + }) } // Verify Postgres now lists all five as distinct slots. - const inList = slots.map((s) => `'${s}'`).join(', '); - const q = await call('iii-database::query', { + const inList = slots.map((s) => `'${s}'`).join(', ') + const q = await call('database::query', { db: driver, sql: `SELECT slot_name FROM pg_replication_slots WHERE slot_name IN (${inList}) ORDER BY slot_name`, - }); - expectEqual(q.row_count, ids.length, 'all collision-prone inputs produced distinct slots in postgres'); + }) + expectEqual(q.row_count, ids.length, 'all collision-prone inputs produced distinct slots in postgres') } finally { // Cleanup so re-running the harness against the same docker volume is idempotent. for (const slot of slots) { try { - await dropSlotIfExists(call, driver, slot); + await dropSlotIfExists(call, driver, slot) } catch { /* best-effort cleanup */ } @@ -129,37 +129,45 @@ export const ROW_CHANGE_CASES: TestCase[] = [ // Pathological trigger_id: 200 chars. Without truncation the derived // name would exceed Postgres' 63-byte slot_name cap and slot creation // would fail; the hash suffix preserves uniqueness across the truncation. - const a = 'a'.repeat(200); - const b = 'a'.repeat(200) + 'b'; // distinct trigger_id, same first-40 sanitized prefix - const slotA = deriveSlotName(a); - const slotB = deriveSlotName(b); + const a = 'a'.repeat(200) + const b = 'a'.repeat(200) + 'b' // distinct trigger_id, same first-40 sanitized prefix + const slotA = deriveSlotName(a) + const slotB = deriveSlotName(b) - expect(slotA !== slotB, `long trigger_ids collided: ${slotA}`); - expect(slotA.length <= 63, `slotA too long (${slotA.length}): ${slotA}`); - expect(slotB.length <= 63, `slotB too long (${slotB.length}): ${slotB}`); + expect(slotA !== slotB, `long trigger_ids collided: ${slotA}`) + expect(slotA.length <= 63, `slotA too long (${slotA.length}): ${slotA}`) + expect(slotB.length <= 63, `slotB too long (${slotB.length}): ${slotB}`) // Pre-clean. - await dropSlotIfExists(call, driver, slotA); - await dropSlotIfExists(call, driver, slotB); + await dropSlotIfExists(call, driver, slotA) + await dropSlotIfExists(call, driver, slotB) try { - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: `SELECT * FROM pg_create_logical_replication_slot('${slotA}', 'pgoutput')`, - }); - await call('iii-database::execute', { + }) + await call('database::execute', { db: driver, sql: `SELECT * FROM pg_create_logical_replication_slot('${slotB}', 'pgoutput')`, - }); - const q = await call('iii-database::query', { + }) + const q = await call('database::query', { db: driver, sql: `SELECT slot_name FROM pg_replication_slots WHERE slot_name IN ('${slotA}', '${slotB}')`, - }); - expectEqual(q.row_count, 2, 'long-trigger-id slots created and distinct'); + }) + expectEqual(q.row_count, 2, 'long-trigger-id slots created and distinct') } finally { - try { await dropSlotIfExists(call, driver, slotA); } catch { /* best-effort */ } - try { await dropSlotIfExists(call, driver, slotB); } catch { /* best-effort */ } + try { + await dropSlotIfExists(call, driver, slotA) + } catch { + /* best-effort */ + } + try { + await dropSlotIfExists(call, driver, slotB) + } catch { + /* best-effort */ + } } }, }, -]; +] diff --git a/iii-database/tests/e2e/workers/harness/src/cases-transaction.ts b/database/tests/e2e/workers/harness/src/cases-transaction.ts similarity index 64% rename from iii-database/tests/e2e/workers/harness/src/cases-transaction.ts rename to database/tests/e2e/workers/harness/src/cases-transaction.ts index a4c433b7..cb7fe7a1 100644 --- a/iii-database/tests/e2e/workers/harness/src/cases-transaction.ts +++ b/database/tests/e2e/workers/harness/src/cases-transaction.ts @@ -1,5 +1,5 @@ -import type { TestCase } from './cases.ts'; -import { expect, expectEqual } from './cases.ts'; +import type { TestCase } from './cases.ts' +import { expect, expectEqual } from './cases.ts' /** * Transaction edge cases. The function suite covers commit + rollback at @@ -14,93 +14,93 @@ export const TRANSACTION_EDGE_CASES: TestCase[] = [ async run({ driver, call }) { // Spec ambiguity: an empty txn is a no-op. Drivers commit an empty // transaction without error; the worker should pass that through. - const r = await call('iii-database::transaction', { db: driver, statements: [] }); - expectEqual(r.committed, true, 'empty transaction commits'); - expectEqual(Array.isArray(r.results) ? r.results.length : 0, 0, 'no results'); + const r = await call('database::transaction', { db: driver, statements: [] }) + expectEqual(r.committed, true, 'empty transaction commits') + expectEqual(Array.isArray(r.results) ? r.results.length : 0, 0, 'no results') }, }, { name: 'transaction with single statement', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS tx_single' }); - await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS tx_single' }) + await call('database::execute', { db: driver, sql: `CREATE TABLE tx_single (id ${dialect.idColumnDDL()}, n INT NOT NULL)`, - }); - const r = await call('iii-database::transaction', { + }) + const r = await call('database::transaction', { db: driver, statements: [{ sql: `INSERT INTO tx_single (n) VALUES (${ph1})`, params: [7] }], - }); - expectEqual(r.committed, true, 'committed=true for single-statement txn'); - const verify = await call('iii-database::query', { + }) + expectEqual(r.committed, true, 'committed=true for single-statement txn') + const verify = await call('database::query', { db: driver, sql: 'SELECT n FROM tx_single', - }); - expectEqual(Number(verify.rows[0].n), 7, 'row landed'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE tx_single' }); + }) + expectEqual(Number(verify.rows[0].n), 7, 'row landed') + await call('database::execute', { db: driver, sql: 'DROP TABLE tx_single' }) }, }, { name: 'transaction read-your-writes (mixed INSERT/SELECT/INSERT)', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS tx_ryw' }); - await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS tx_ryw' }) + await call('database::execute', { db: driver, sql: `CREATE TABLE tx_ryw (id ${dialect.idColumnDDL()}, n INT NOT NULL)`, - }); - const r = await call('iii-database::transaction', { + }) + const r = await call('database::transaction', { db: driver, statements: [ { sql: `INSERT INTO tx_ryw (n) VALUES (${ph1})`, params: [100] }, { sql: `SELECT n FROM tx_ryw`, params: [] }, { sql: `INSERT INTO tx_ryw (n) VALUES (${ph1})`, params: [200] }, ], - }); - expectEqual(r.committed, true, 'committed=true for mixed txn'); - expect(Array.isArray(r.results), 'results is array'); - expectEqual(r.results.length, 3, 'three results'); + }) + expectEqual(r.committed, true, 'committed=true for mixed txn') + expect(Array.isArray(r.results), 'results is array') + expectEqual(r.results.length, 3, 'three results') // Note: txn results carry rows positionally (Vec>) per // transaction.rs:64-67 — no column names. This differs from query/runStatement // which return column-keyed objects. The first SELECT row is `[100]`, not `{n: 100}`. - const selectResult = r.results[1]; + const selectResult = r.results[1] expect( Array.isArray(selectResult.rows) && selectResult.rows.length === 1, `select sees exactly one row, got ${JSON.stringify(selectResult)}`, - ); - const firstRow = selectResult.rows[0]; - expect(Array.isArray(firstRow), `row is positional array, got ${JSON.stringify(firstRow)}`); - expectEqual(Number(firstRow[0]), 100, 'read-your-writes: select sees the just-inserted value'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE tx_ryw' }); + ) + const firstRow = selectResult.rows[0] + expect(Array.isArray(firstRow), `row is positional array, got ${JSON.stringify(firstRow)}`) + expectEqual(Number(firstRow[0]), 100, 'read-your-writes: select sees the just-inserted value') + await call('database::execute', { db: driver, sql: 'DROP TABLE tx_ryw' }) }, }, { name: 'transaction failure at index 0 reports failed_index=0', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS tx_fail0' }); - await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS tx_fail0' }) + await call('database::execute', { db: driver, sql: `CREATE TABLE tx_fail0 (id ${dialect.idColumnDDL()}, n INT NOT NULL)`, - }); + }) // First statement violates NOT NULL; second never runs. failed_index must be 0. - const r = await call('iii-database::transaction', { + const r = await call('database::transaction', { db: driver, statements: [ { sql: `INSERT INTO tx_fail0 (n) VALUES (${ph1})`, params: [null] }, { sql: `INSERT INTO tx_fail0 (n) VALUES (${ph1})`, params: [99] }, ], - }); - expectEqual(r.committed, false, 'committed=false'); - expectEqual(r.failed_index, 0, 'failed_index=0'); + }) + expectEqual(r.committed, false, 'committed=false') + expectEqual(r.failed_index, 0, 'failed_index=0') // Confirm rollback: zero rows. - const verify = await call('iii-database::query', { + const verify = await call('database::query', { db: driver, sql: 'SELECT COUNT(*) AS c FROM tx_fail0', - }); - expectEqual(Number(verify.rows[0].c), 0, 'rollback dropped all writes'); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE tx_fail0' }); + }) + expectEqual(Number(verify.rows[0].c), 0, 'rollback dropped all writes') + await call('database::execute', { db: driver, sql: 'DROP TABLE tx_fail0' }) }, }, { @@ -117,19 +117,16 @@ export const TRANSACTION_EDGE_CASES: TestCase[] = [ name: 'transaction handles CTE SELECT and VALUES (sqlite)', applies: ['sqlite_db'], async run({ driver, call }) { - const r = await call('iii-database::transaction', { + const r = await call('database::transaction', { db: driver, - statements: [ - { sql: 'WITH cte AS (SELECT 1 AS n) SELECT n FROM cte' }, - { sql: 'VALUES (10), (20), (30)' }, - ], - }); - expectEqual(r.committed, true, 'CTE+VALUES tx committed'); - expect(Array.isArray(r.results) && r.results.length === 2, 'two step results'); + statements: [{ sql: 'WITH cte AS (SELECT 1 AS n) SELECT n FROM cte' }, { sql: 'VALUES (10), (20), (30)' }], + }) + expectEqual(r.committed, true, 'CTE+VALUES tx committed') + expect(Array.isArray(r.results) && r.results.length === 2, 'two step results') // CTE SELECT → 1 row - expectEqual(r.results[0].rows.length, 1, 'CTE step row count'); + expectEqual(r.results[0].rows.length, 1, 'CTE step row count') // VALUES → 3 rows - expectEqual(r.results[1].rows.length, 3, 'VALUES step row count'); + expectEqual(r.results[1].rows.length, 3, 'VALUES step row count') }, }, -]; +] diff --git a/iii-database/tests/e2e/workers/harness/src/cases-tx-control-bypass.ts b/database/tests/e2e/workers/harness/src/cases-tx-control-bypass.ts similarity index 66% rename from iii-database/tests/e2e/workers/harness/src/cases-tx-control-bypass.ts rename to database/tests/e2e/workers/harness/src/cases-tx-control-bypass.ts index 73b265e9..1daa6ab4 100644 --- a/iii-database/tests/e2e/workers/harness/src/cases-tx-control-bypass.ts +++ b/database/tests/e2e/workers/harness/src/cases-tx-control-bypass.ts @@ -1,12 +1,12 @@ -import type { TestCase } from './cases.ts'; -import { expect, expectEqual } from './cases.ts'; +import type { TestCase } from './cases.ts' +import { expect, expectEqual } from './cases.ts' /** * Side-channel finalization bypass repros. * * Each case demonstrates a separate way the * `transactionExecute`/`transactionQuery` defense in - * `iii-database/src/handlers/transaction_execute.rs::is_transaction_control_sql` + * `database/src/handlers/transaction_execute.rs::is_transaction_control_sql` * can be bypassed to finalize an interactive transaction without going through * `commitTransaction` / `rollbackTransaction`. * @@ -38,40 +38,40 @@ export const TX_CONTROL_BYPASS_CASES: TestCase[] = [ // until the watcher fires. name: 'bypass #1: transactionExecute /* */COMMIT must be rejected', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - const table = 'bypass_comment_commit'; - await call('iii-database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }); - await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + const table = 'bypass_comment_commit' + await call('database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }) + await call('database::execute', { db: driver, sql: `CREATE TABLE ${table} (id ${dialect.idColumnDDL()}, n INT NOT NULL)`, - }); + }) - const begin = await call('iii-database::beginTransaction', { db: driver }); - const id = begin.transaction.id; + const begin = await call('database::beginTransaction', { db: driver }) + const id = begin.transaction.id try { // Stage a row inside the txn. Visible from inside, not yet committed. - await call('iii-database::transactionExecute', { + await call('database::transactionExecute', { transaction_id: id, sql: `INSERT INTO ${table} (n) VALUES (${ph1})`, params: [1], - }); + }) // The bypass attempt. The defense MUST reject this — the user must // be told to use `commitTransaction`. - let rejected = false; - let acceptedErr: unknown = null; + let rejected = false + let acceptedErr: unknown = null try { - await call('iii-database::transactionExecute', { + await call('database::transactionExecute', { transaction_id: id, sql: '/* sneak */COMMIT', - }); + }) } catch (e: any) { - const msg = e?.message ?? String(e); + const msg = e?.message ?? String(e) if (msg.includes('INVALID_PARAM')) { - rejected = true; + rejected = true } else { - acceptedErr = e; + acceptedErr = e } } @@ -79,25 +79,27 @@ export const TX_CONTROL_BYPASS_CASES: TestCase[] = [ // The worker accepted the SQL. Now prove it desynced the state. // Read from outside the txn — if the COMMIT landed at the driver // level, the staged row is visible from a fresh connection. - const outside = await call('iii-database::query', { + const outside = await call('database::query', { db: driver, sql: `SELECT COUNT(*) AS c FROM ${table}`, - }); - const leakedCount = Number(outside.rows[0].c); + }) + const leakedCount = Number(outside.rows[0].c) throw new Error( `BYPASS CONFIRMED [#1 comment-prefix]: '/* */COMMIT' was accepted ` + `by transactionExecute; outside-tx COUNT=${leakedCount} ` + `(>0 means the side-channel COMMIT landed at the driver, ` + `desyncing the registry from the connection state)` + (acceptedErr ? ` — non-INVALID_PARAM error: ${acceptedErr}` : ''), - ); + ) } - expect(rejected, 'transactionExecute must reject /* */COMMIT with INVALID_PARAM'); + expect(rejected, 'transactionExecute must reject /* */COMMIT with INVALID_PARAM') } finally { try { - await call('iii-database::rollbackTransaction', { transaction_id: id }); - } catch {/* may already be gone if the bypass succeeded */} - await call('iii-database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }); + await call('database::rollbackTransaction', { transaction_id: id }) + } catch { + /* may already be gone if the bypass succeeded */ + } + await call('database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }) } }, }, @@ -109,60 +111,62 @@ export const TX_CONTROL_BYPASS_CASES: TestCase[] = [ // pinned connection until the watcher sweeps. name: 'bypass #2: transactionQuery COMMIT must be rejected', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - const table = 'bypass_query_commit'; - await call('iii-database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }); - await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + const table = 'bypass_query_commit' + await call('database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }) + await call('database::execute', { db: driver, sql: `CREATE TABLE ${table} (id ${dialect.idColumnDDL()}, n INT NOT NULL)`, - }); + }) - const begin = await call('iii-database::beginTransaction', { db: driver }); - const id = begin.transaction.id; + const begin = await call('database::beginTransaction', { db: driver }) + const id = begin.transaction.id try { - await call('iii-database::transactionExecute', { + await call('database::transactionExecute', { transaction_id: id, sql: `INSERT INTO ${table} (n) VALUES (${ph1})`, params: [2], - }); + }) - let rejected = false; - let acceptedErr: unknown = null; + let rejected = false + let acceptedErr: unknown = null try { - await call('iii-database::transactionQuery', { + await call('database::transactionQuery', { transaction_id: id, sql: 'COMMIT', - }); + }) } catch (e: any) { - const msg = e?.message ?? String(e); + const msg = e?.message ?? String(e) if (msg.includes('INVALID_PARAM')) { - rejected = true; + rejected = true } else { - acceptedErr = e; + acceptedErr = e } } if (!rejected) { - const outside = await call('iii-database::query', { + const outside = await call('database::query', { db: driver, sql: `SELECT COUNT(*) AS c FROM ${table}`, - }); - const leakedCount = Number(outside.rows[0].c); + }) + const leakedCount = Number(outside.rows[0].c) throw new Error( `BYPASS CONFIRMED [#2 transactionQuery]: 'COMMIT' was accepted by ` + `transactionQuery (no is_transaction_control_sql guard exists ` + `on the query path); outside-tx COUNT=${leakedCount} ` + `(>0 means the side-channel COMMIT landed at the driver)` + (acceptedErr ? ` — non-INVALID_PARAM error: ${acceptedErr}` : ''), - ); + ) } - expect(rejected, 'transactionQuery must reject COMMIT with INVALID_PARAM'); + expect(rejected, 'transactionQuery must reject COMMIT with INVALID_PARAM') } finally { try { - await call('iii-database::rollbackTransaction', { transaction_id: id }); - } catch {/* ignore */} - await call('iii-database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }); + await call('database::rollbackTransaction', { transaction_id: id }) + } catch { + /* ignore */ + } + await call('database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }) } }, }, @@ -181,55 +185,57 @@ export const TX_CONTROL_BYPASS_CASES: TestCase[] = [ name: 'bypass #3: transactionExecute START TRANSACTION must be rejected (mysql)', applies: ['mysql_db'], async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - const table = 'bypass_start_transaction'; - await call('iii-database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }); - await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + const table = 'bypass_start_transaction' + await call('database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }) + await call('database::execute', { db: driver, sql: `CREATE TABLE ${table} (id ${dialect.idColumnDDL()}, n INT NOT NULL)`, - }); + }) - const begin = await call('iii-database::beginTransaction', { db: driver }); - const id = begin.transaction.id; + const begin = await call('database::beginTransaction', { db: driver }) + const id = begin.transaction.id try { // Stage a row in the OUTER txn that — on a healthy worker — would be // discarded when we `rollbackTransaction` below. If `START TRANSACTION` // is accepted, MySQL implicitly COMMITs that row before opening the // new (untracked) txn, leaking the row past the eventual rollback. - await call('iii-database::transactionExecute', { + await call('database::transactionExecute', { transaction_id: id, sql: `INSERT INTO ${table} (n) VALUES (${ph1})`, params: [3], - }); + }) - let rejected = false; - let acceptedErr: unknown = null; + let rejected = false + let acceptedErr: unknown = null try { - await call('iii-database::transactionExecute', { + await call('database::transactionExecute', { transaction_id: id, sql: 'START TRANSACTION', - }); + }) } catch (e: any) { - const msg = e?.message ?? String(e); + const msg = e?.message ?? String(e) if (msg.includes('INVALID_PARAM')) { - rejected = true; + rejected = true } else { - acceptedErr = e; + acceptedErr = e } } if (!rejected) { // Roll back the (now-second) txn the user thinks they're in. try { - await call('iii-database::rollbackTransaction', { transaction_id: id }); - } catch {/* ignore */} + await call('database::rollbackTransaction', { transaction_id: id }) + } catch { + /* ignore */ + } - const outside = await call('iii-database::query', { + const outside = await call('database::query', { db: driver, sql: `SELECT COUNT(*) AS c FROM ${table}`, - }); - const leakedCount = Number(outside.rows[0].c); + }) + const leakedCount = Number(outside.rows[0].c) throw new Error( `BYPASS CONFIRMED [#3 START TRANSACTION on MySQL]: ` + `'START TRANSACTION' was accepted by transactionExecute; ` + @@ -237,14 +243,16 @@ export const TX_CONTROL_BYPASS_CASES: TestCase[] = [ `have undone the staged INSERT (>0 means MySQL implicitly ` + `COMMITed the outer txn when START TRANSACTION ran)` + (acceptedErr ? ` — non-INVALID_PARAM error: ${acceptedErr}` : ''), - ); + ) } - expect(rejected, 'transactionExecute must reject START TRANSACTION with INVALID_PARAM'); + expect(rejected, 'transactionExecute must reject START TRANSACTION with INVALID_PARAM') } finally { try { - await call('iii-database::rollbackTransaction', { transaction_id: id }); - } catch {/* may already be gone */} - await call('iii-database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }); + await call('database::rollbackTransaction', { transaction_id: id }) + } catch { + /* may already be gone */ + } + await call('database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }) } }, }, @@ -260,61 +268,63 @@ export const TX_CONTROL_BYPASS_CASES: TestCase[] = [ // predictable. PG/MySQL both reliably strip the line comment. applies: ['pg_db', 'mysql_db'], async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - const table = 'bypass_line_comment'; - await call('iii-database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }); - await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + const table = 'bypass_line_comment' + await call('database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }) + await call('database::execute', { db: driver, sql: `CREATE TABLE ${table} (id ${dialect.idColumnDDL()}, n INT NOT NULL)`, - }); + }) - const begin = await call('iii-database::beginTransaction', { db: driver }); - const id = begin.transaction.id; + const begin = await call('database::beginTransaction', { db: driver }) + const id = begin.transaction.id try { - await call('iii-database::transactionExecute', { + await call('database::transactionExecute', { transaction_id: id, sql: `INSERT INTO ${table} (n) VALUES (${ph1})`, params: [4], - }); + }) - let rejected = false; - let acceptedErr: unknown = null; + let rejected = false + let acceptedErr: unknown = null try { // Newline is required after `--` so the server sees COMMIT as a // statement; otherwise the comment swallows the rest of the line. - await call('iii-database::transactionExecute', { + await call('database::transactionExecute', { transaction_id: id, sql: '-- sneak\nCOMMIT', - }); + }) } catch (e: any) { - const msg = e?.message ?? String(e); + const msg = e?.message ?? String(e) if (msg.includes('INVALID_PARAM')) { - rejected = true; + rejected = true } else { - acceptedErr = e; + acceptedErr = e } } if (!rejected) { - const outside = await call('iii-database::query', { + const outside = await call('database::query', { db: driver, sql: `SELECT COUNT(*) AS c FROM ${table}`, - }); - const leakedCount = Number(outside.rows[0].c); + }) + const leakedCount = Number(outside.rows[0].c) throw new Error( `BYPASS CONFIRMED [#1b line-comment]: '-- sneak\\nCOMMIT' was ` + `accepted by transactionExecute; outside-tx COUNT=${leakedCount}` + (acceptedErr ? ` — non-INVALID_PARAM error: ${acceptedErr}` : ''), - ); + ) } - expectEqual(rejected, true, 'transactionExecute must reject --COMMIT'); + expectEqual(rejected, true, 'transactionExecute must reject --COMMIT') } finally { try { - await call('iii-database::rollbackTransaction', { transaction_id: id }); - } catch {/* ignore */} - await call('iii-database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }); + await call('database::rollbackTransaction', { transaction_id: id }) + } catch { + /* ignore */ + } + await call('database::execute', { db: driver, sql: `DROP TABLE IF EXISTS ${table}` }) } }, }, -]; +] diff --git a/iii-database/tests/e2e/workers/harness/src/cases.ts b/database/tests/e2e/workers/harness/src/cases.ts similarity index 55% rename from iii-database/tests/e2e/workers/harness/src/cases.ts rename to database/tests/e2e/workers/harness/src/cases.ts index 1712ac00..51ba19df 100644 --- a/iii-database/tests/e2e/workers/harness/src/cases.ts +++ b/database/tests/e2e/workers/harness/src/cases.ts @@ -1,37 +1,37 @@ -import type { ISdk } from 'iii-sdk'; -import type { DriverKey, Dialect } from './dialect.ts'; +import type { ISdk } from 'iii-sdk' +import type { DriverKey, Dialect } from './dialect.ts' export interface CaseContext { - driver: DriverKey; - dialect: Dialect; + driver: DriverKey + dialect: Dialect /** Calls a database worker function; returns parsed JSON or throws on engine error. */ - call: (functionId: string, payload: unknown) => Promise; + call: (functionId: string, payload: unknown) => Promise /** Direct SDK access for trigger-config edge-case tests. */ - iii: ISdk; + iii: ISdk /** * Asserts that `fn()` rejects and the rejection message contains `expectedCode`. * The worker wraps DbError as `IIIError::Handler(json_string)`, which the engine * surfaces as the JS Error message; substring match is more resilient than * strict JSON parsing across SDK versions. */ - expectError: (fn: () => Promise, expectedCode: string) => Promise; + expectError: (fn: () => Promise, expectedCode: string) => Promise } export interface TestCase { - name: string; + name: string /** If set, this case only runs on the listed drivers; otherwise it runs on all. */ - applies?: readonly DriverKey[]; - run(ctx: CaseContext): Promise; + applies?: readonly DriverKey[] + run(ctx: CaseContext): Promise } export function expectEqual(actual: unknown, expected: unknown, msg: string): void { if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error(`${msg}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + throw new Error(`${msg}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) } } export function expect(cond: boolean, msg: string): asserts cond { - if (!cond) throw new Error(msg); + if (!cond) throw new Error(msg) } export const SCHEMA_RESET: TestCase = { @@ -41,113 +41,113 @@ export const SCHEMA_RESET: TestCase = { // surface that was removed on feat/database-and-skills. Drop defensively // so re-runs against a stale data volume (docker / podman named volumes // preserved across `--keep`) still come up clean. - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS outbox' }); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS __iii_cursors' }); - await call('iii-database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS t' }); - await call('iii-database::execute', { + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS outbox' }) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS __iii_cursors' }) + await call('database::execute', { db: driver, sql: 'DROP TABLE IF EXISTS t' }) + await call('database::execute', { db: driver, sql: `CREATE TABLE t (id ${dialect.idColumnDDL()}, n INT NOT NULL)`, - }); + }) }, -}; +} export const FUNCTION_CASES: TestCase[] = [ { name: 'query SELECT 1', async run({ driver, call }) { - const r = await call('iii-database::query', { db: driver, sql: 'SELECT 1 AS n' }); - expectEqual(r.row_count, 1, 'row_count'); - expect(Array.isArray(r.columns), 'columns is array'); - expect(r.columns.length === 1, 'one column'); - expectEqual(r.columns[0].name, 'n', 'column name'); + const r = await call('database::query', { db: driver, sql: 'SELECT 1 AS n' }) + expectEqual(r.row_count, 1, 'row_count') + expect(Array.isArray(r.columns), 'columns is array') + expect(r.columns.length === 1, 'one column') + expectEqual(r.columns[0].name, 'n', 'column name') // Value may be number or numeric string depending on driver — accept either. - const v = r.rows[0].n; - expect(v === 1 || v === '1', `n value: ${JSON.stringify(v)}`); + const v = r.rows[0].n + expect(v === 1 || v === '1', `n value: ${JSON.stringify(v)}`) }, }, { name: 'execute INSERT (multi-row)', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - const ph2 = dialect.placeholder(2); - const r = await call('iii-database::execute', { + const ph1 = dialect.placeholder(1) + const ph2 = dialect.placeholder(2) + const r = await call('database::execute', { db: driver, sql: `INSERT INTO t (n) VALUES (${ph1}), (${ph2})`, params: [10, 20], - }); - expectEqual(r.affected_rows, 2, 'affected_rows after multi-row insert'); + }) + expectEqual(r.affected_rows, 2, 'affected_rows after multi-row insert') }, }, { name: 'query SELECT after insert', async run({ driver, call }) { - const r = await call('iii-database::query', { + const r = await call('database::query', { db: driver, sql: 'SELECT n FROM t ORDER BY id', - }); - expectEqual(r.row_count, 2, 'two rows returned'); - const ns = r.rows.map((row: any) => Number(row.n)); - expectEqual(ns, [10, 20], 'row values'); + }) + expectEqual(r.row_count, 2, 'two rows returned') + const ns = r.rows.map((row: any) => Number(row.n)) + expectEqual(ns, [10, 20], 'row values') }, }, { name: 'prepareStatement + runStatement', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - const prep = await call('iii-database::prepareStatement', { + const ph1 = dialect.placeholder(1) + const prep = await call('database::prepareStatement', { db: driver, sql: `SELECT n FROM t WHERE n = ${ph1}`, - }); - const handleId = prep.handle?.id; - expect(typeof handleId === 'string' && handleId.length > 0, 'handle id present'); + }) + const handleId = prep.handle?.id + expect(typeof handleId === 'string' && handleId.length > 0, 'handle id present') - const r1 = await call('iii-database::runStatement', { handle_id: handleId, params: [10] }); - expectEqual(r1.row_count, 1, 'first runStatement row_count'); - expectEqual(Number(r1.rows[0].n), 10, 'first runStatement value'); + const r1 = await call('database::runStatement', { handle_id: handleId, params: [10] }) + expectEqual(r1.row_count, 1, 'first runStatement row_count') + expectEqual(Number(r1.rows[0].n), 10, 'first runStatement value') - const r2 = await call('iii-database::runStatement', { handle_id: handleId, params: [20] }); - expectEqual(r2.row_count, 1, 'second runStatement row_count'); - expectEqual(Number(r2.rows[0].n), 20, 'second runStatement value'); + const r2 = await call('database::runStatement', { handle_id: handleId, params: [20] }) + expectEqual(r2.row_count, 1, 'second runStatement row_count') + expectEqual(Number(r2.rows[0].n), 20, 'second runStatement value') }, }, { name: 'transaction commit', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - const r = await call('iii-database::transaction', { + const ph1 = dialect.placeholder(1) + const r = await call('database::transaction', { db: driver, statements: [ { sql: `UPDATE t SET n = n + 1 WHERE n = ${ph1}`, params: [10] }, { sql: `UPDATE t SET n = n + 1 WHERE n = ${ph1}`, params: [20] }, ], - }); - expectEqual(r.committed, true, 'committed'); - const verify = await call('iii-database::query', { db: driver, sql: 'SELECT n FROM t ORDER BY id' }); - const ns = verify.rows.map((row: any) => Number(row.n)); - expectEqual(ns, [11, 21], 'post-commit values'); + }) + expectEqual(r.committed, true, 'committed') + const verify = await call('database::query', { db: driver, sql: 'SELECT n FROM t ORDER BY id' }) + const ns = verify.rows.map((row: any) => Number(row.n)) + expectEqual(ns, [11, 21], 'post-commit values') }, }, { name: 'transaction rollback', async run({ driver, dialect, call }) { - const ph1 = dialect.placeholder(1); - const before = await call('iii-database::query', { db: driver, sql: 'SELECT COUNT(*) AS c FROM t' }); - const beforeCount = Number(before.rows[0].c); + const ph1 = dialect.placeholder(1) + const before = await call('database::query', { db: driver, sql: 'SELECT COUNT(*) AS c FROM t' }) + const beforeCount = Number(before.rows[0].c) - const r = await call('iii-database::transaction', { + const r = await call('database::transaction', { db: driver, statements: [ { sql: `INSERT INTO t (n) VALUES (${ph1})`, params: [999] }, // Second statement violates NOT NULL — forces rollback. { sql: `INSERT INTO t (n) VALUES (${ph1})`, params: [null] }, ], - }); - expectEqual(r.committed, false, 'committed=false'); - expectEqual(r.failed_index, 1, 'failed_index=1'); - expect(typeof r.error === 'object' && r.error !== null, 'structured error object'); + }) + expectEqual(r.committed, false, 'committed=false') + expectEqual(r.failed_index, 1, 'failed_index=1') + expect(typeof r.error === 'object' && r.error !== null, 'structured error object') - const after = await call('iii-database::query', { db: driver, sql: 'SELECT COUNT(*) AS c FROM t' }); - expectEqual(Number(after.rows[0].c), beforeCount, 'row count unchanged after rollback'); + const after = await call('database::query', { db: driver, sql: 'SELECT COUNT(*) AS c FROM t' }) + expectEqual(Number(after.rows[0].c), beforeCount, 'row count unchanged after rollback') }, }, -]; +] diff --git a/iii-database/tests/e2e/workers/harness/src/dialect.test.ts b/database/tests/e2e/workers/harness/src/dialect.test.ts similarity index 100% rename from iii-database/tests/e2e/workers/harness/src/dialect.test.ts rename to database/tests/e2e/workers/harness/src/dialect.test.ts diff --git a/iii-database/tests/e2e/workers/harness/src/dialect.ts b/database/tests/e2e/workers/harness/src/dialect.ts similarity index 100% rename from iii-database/tests/e2e/workers/harness/src/dialect.ts rename to database/tests/e2e/workers/harness/src/dialect.ts diff --git a/iii-database/tests/e2e/workers/harness/src/runner.ts b/database/tests/e2e/workers/harness/src/runner.ts similarity index 66% rename from iii-database/tests/e2e/workers/harness/src/runner.ts rename to database/tests/e2e/workers/harness/src/runner.ts index cfe6c790..52fcc139 100644 --- a/iii-database/tests/e2e/workers/harness/src/runner.ts +++ b/database/tests/e2e/workers/harness/src/runner.ts @@ -1,27 +1,22 @@ -import { writeFileSync, mkdirSync } from 'node:fs'; -import { resolve } from 'node:path'; -import type { ISdk } from 'iii-sdk'; -import { DRIVER_KEYS, dialects, type DriverKey } from './dialect.ts'; -import { - SCHEMA_RESET, - FUNCTION_CASES, - type CaseContext, - type TestCase, -} from './cases.ts'; -import { BOUNDARY_CASES } from './cases-boundary.ts'; -import { PROTOCOL_CASES } from './cases-protocol.ts'; -import { TRANSACTION_EDGE_CASES } from './cases-transaction.ts'; -import { INTERACTIVE_TX_CASES } from './cases-interactive-tx.ts'; -import { CONCURRENCY_CASES } from './cases-concurrency.ts'; -import { ROW_CHANGE_CASES } from './cases-row-change.ts'; -import { TX_CONTROL_BYPASS_CASES } from './cases-tx-control-bypass.ts'; +import { writeFileSync, mkdirSync } from 'node:fs' +import { resolve } from 'node:path' +import type { ISdk } from 'iii-sdk' +import { DRIVER_KEYS, dialects, type DriverKey } from './dialect.ts' +import { SCHEMA_RESET, FUNCTION_CASES, type CaseContext, type TestCase } from './cases.ts' +import { BOUNDARY_CASES } from './cases-boundary.ts' +import { PROTOCOL_CASES } from './cases-protocol.ts' +import { TRANSACTION_EDGE_CASES } from './cases-transaction.ts' +import { INTERACTIVE_TX_CASES } from './cases-interactive-tx.ts' +import { CONCURRENCY_CASES } from './cases-concurrency.ts' +import { ROW_CHANGE_CASES } from './cases-row-change.ts' +import { TX_CONTROL_BYPASS_CASES } from './cases-tx-control-bypass.ts' interface CaseResult { - driver: DriverKey; - case: string; - status: 'PASS' | 'FAIL'; - error?: string; - duration_ms: number; + driver: DriverKey + case: string + status: 'PASS' | 'FAIL' + error?: string + duration_ms: number } /** @@ -35,37 +30,37 @@ interface CaseResult { * - `bypass-only` — ONLY the bypass repros. Use this to focus on the * /review findings (post-fix it must be all-PASS). */ -export type RunnerMode = 'full' | 'no-bypass' | 'bypass-only'; +export type RunnerMode = 'full' | 'no-bypass' | 'bypass-only' export interface RunnerOptions { - iii: ISdk; - reportPath: string; - filterDriver?: DriverKey; - mode?: RunnerMode; + iii: ISdk + reportPath: string + filterDriver?: DriverKey + mode?: RunnerMode } export class Runner { constructor(private opts: RunnerOptions) {} private async callOnce(functionId: string, payload: unknown): Promise { - return await this.opts.iii.trigger({ function_id: functionId, payload }); + return await this.opts.iii.trigger({ function_id: functionId, payload }) } private async callWithRetry(functionId: string, payload: unknown, attempts = 10): Promise { - let lastErr: unknown; + let lastErr: unknown for (let i = 0; i < attempts; i++) { try { - return await this.callOnce(functionId, payload); + return await this.callOnce(functionId, payload) } catch (e) { - lastErr = e; - await new Promise((r) => setTimeout(r, 200)); + lastErr = e + await new Promise((r) => setTimeout(r, 200)) } } - throw lastErr; + throw lastErr } private async runCase(driver: DriverKey, c: TestCase): Promise { - const start = Date.now(); + const start = Date.now() const ctx: CaseContext = { driver, dialect: dialects[driver], @@ -73,20 +68,20 @@ export class Runner { iii: this.opts.iii, expectError: async (fn, expectedCode) => { try { - await fn(); + await fn() } catch (e: any) { - const msg = e?.message ?? String(e); + const msg = e?.message ?? String(e) if (!msg.includes(expectedCode)) { - throw new Error(`expected error code "${expectedCode}", got: ${msg}`); + throw new Error(`expected error code "${expectedCode}", got: ${msg}`) } - return; + return } - throw new Error(`expected throw with code "${expectedCode}", but call resolved`); + throw new Error(`expected throw with code "${expectedCode}", but call resolved`) }, - }; + } try { - await c.run(ctx); - return { driver, case: c.name, status: 'PASS', duration_ms: Date.now() - start }; + await c.run(ctx) + return { driver, case: c.name, status: 'PASS', duration_ms: Date.now() - start } } catch (e: any) { return { driver, @@ -94,24 +89,23 @@ export class Runner { status: 'FAIL', error: e?.message ?? String(e), duration_ms: Date.now() - start, - }; + } } } private async waitForDatabaseWorker(driver: DriverKey): Promise { // Probe with a no-op query until it succeeds; tolerates worker-startup race. - await this.callWithRetry('iii-database::query', { db: driver, sql: 'SELECT 1' }); + await this.callWithRetry('database::query', { db: driver, sql: 'SELECT 1' }) } async runAll(): Promise<{ pass: number; total: number; results: CaseResult[] }> { - const mode: RunnerMode = this.opts.mode ?? 'full'; - const drivers: DriverKey[] = this.opts.filterDriver ? [this.opts.filterDriver] : [...DRIVER_KEYS]; + const mode: RunnerMode = this.opts.mode ?? 'full' + const drivers: DriverKey[] = this.opts.filterDriver ? [this.opts.filterDriver] : [...DRIVER_KEYS] // Wait for the database worker to be reachable on the first driver before kicking off. - await this.waitForDatabaseWorker(drivers[0]); + await this.waitForDatabaseWorker(drivers[0]) - const results: CaseResult[] = []; - const matchesDriver = (driver: DriverKey, c: TestCase) => - !c.applies || c.applies.includes(driver); + const results: CaseResult[] = [] + const matchesDriver = (driver: DriverKey, c: TestCase) => !c.applies || c.applies.includes(driver) // Stream each case result to stdout as it completes, instead of buffering // until runAll returns. Slow tests (TTL expiry, pool exhaustion) take 5+ @@ -120,34 +114,34 @@ export class Runner { // Color the PASS/FAIL tag green/red, but only when stdout is a TTY. When // run-tests.sh redirects stdout to a log file, isTTY is false and we // emit plain text (otherwise ANSI escapes show up as garbage in the log). - const useColor = process.stdout.isTTY === true; - const GREEN = useColor ? '\x1b[32m' : ''; - const RED = useColor ? '\x1b[31m' : ''; - const RESET = useColor ? '\x1b[0m' : ''; + const useColor = process.stdout.isTTY === true + const GREEN = useColor ? '\x1b[32m' : '' + const RED = useColor ? '\x1b[31m' : '' + const RESET = useColor ? '\x1b[0m' : '' const record = (r: CaseResult): CaseResult => { - const color = r.status === 'PASS' ? GREEN : RED; - const err = r.error ? ' — ' + r.error : ''; - console.log(`[harness] ${color}${r.status}${RESET} ${r.driver} :: ${r.case} (${r.duration_ms}ms)${err}`); - results.push(r); - return r; - }; + const color = r.status === 'PASS' ? GREEN : RED + const err = r.error ? ' — ' + r.error : '' + console.log(`[harness] ${color}${r.status}${RESET} ${r.driver} :: ${r.case} (${r.duration_ms}ms)${err}`) + results.push(r) + return r + } // The full suite (`full` and `no-bypass`) needs the shared `t` table from // SCHEMA_RESET. `bypass-only` doesn't, since every bypass case manages its // own scratch table — skip the reset to avoid letting a stale schema from // a prior run block the focused repro. - const includeFullSuite = mode === 'full' || mode === 'no-bypass'; - const includeBypassRepros = mode === 'full' || mode === 'bypass-only'; + const includeFullSuite = mode === 'full' || mode === 'no-bypass' + const includeBypassRepros = mode === 'full' || mode === 'bypass-only' for (const driver of drivers) { if (includeFullSuite) { // Always run the schema reset; not a counted case but failures abort this driver. - const reset = record(await this.runCase(driver, SCHEMA_RESET)); - if (reset.status === 'FAIL') continue; + const reset = record(await this.runCase(driver, SCHEMA_RESET)) + if (reset.status === 'FAIL') continue // Function suite. for (const c of FUNCTION_CASES) { - record(await this.runCase(driver, c)); + record(await this.runCase(driver, c)) } // Boundary, protocol, transaction-edge, interactive-tx, concurrency, @@ -161,8 +155,8 @@ export class Runner { ...CONCURRENCY_CASES, ...ROW_CHANGE_CASES, ]) { - if (!matchesDriver(driver, c)) continue; - record(await this.runCase(driver, c)); + if (!matchesDriver(driver, c)) continue + record(await this.runCase(driver, c)) } } @@ -172,21 +166,18 @@ export class Runner { // together in the per-case summary as the documented coverage for // the /review findings on this branch. for (const c of TX_CONTROL_BYPASS_CASES) { - if (!matchesDriver(driver, c)) continue; - record(await this.runCase(driver, c)); + if (!matchesDriver(driver, c)) continue + record(await this.runCase(driver, c)) } } } - const counted = results.filter((r) => r.case !== 'schema-reset'); - const pass = counted.filter((r) => r.status === 'PASS').length; + const counted = results.filter((r) => r.case !== 'schema-reset') + const pass = counted.filter((r) => r.status === 'PASS').length - mkdirSync(resolve(this.opts.reportPath, '..'), { recursive: true }); - writeFileSync( - this.opts.reportPath, - JSON.stringify({ pass, total: counted.length, results, mode }, null, 2), - ); + mkdirSync(resolve(this.opts.reportPath, '..'), { recursive: true }) + writeFileSync(this.opts.reportPath, JSON.stringify({ pass, total: counted.length, results, mode }, null, 2)) - return { pass, total: counted.length, results }; + return { pass, total: counted.length, results } } } diff --git a/database/tests/e2e/workers/harness/src/test.ts b/database/tests/e2e/workers/harness/src/test.ts new file mode 100644 index 00000000..4f721721 --- /dev/null +++ b/database/tests/e2e/workers/harness/src/test.ts @@ -0,0 +1,17 @@ +import { call } from 'iii-sdk' + +await call('database::execute', { + db: 'primary', + sql: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT)', +}) + +await call('database::execute', { + db: 'primary', + sql: 'INSERT INTO users (email) VALUES (?), (?)', + params: ['a@x', 'b@x'], +}) + +const { rows } = await call('database::query', { + db: 'primary', + sql: 'SELECT id, email FROM users ORDER BY id', +}) diff --git a/iii-database/tests/e2e/workers/harness/src/worker.ts b/database/tests/e2e/workers/harness/src/worker.ts similarity index 100% rename from iii-database/tests/e2e/workers/harness/src/worker.ts rename to database/tests/e2e/workers/harness/src/worker.ts diff --git a/iii-database/tests/e2e/workers/harness/tsconfig.json b/database/tests/e2e/workers/harness/tsconfig.json similarity index 100% rename from iii-database/tests/e2e/workers/harness/tsconfig.json rename to database/tests/e2e/workers/harness/tsconfig.json diff --git a/iii-database/tests/integration.rs b/database/tests/integration.rs similarity index 75% rename from iii-database/tests/integration.rs rename to database/tests/integration.rs index dba1429b..d8aa02c7 100644 --- a/iii-database/tests/integration.rs +++ b/database/tests/integration.rs @@ -1,16 +1,16 @@ //! Integration: build a local AppState from a YAML config and exercise each //! function handler end-to-end against an in-memory SQLite database. -use iii_database::config::WorkerConfig; -use iii_database::handle::HandleRegistry; -use iii_database::handlers::execute::ExecuteReq; -use iii_database::handlers::prepare::PrepareReq; -use iii_database::handlers::query::QueryReq; -use iii_database::handlers::run_statement::RunReq; -use iii_database::handlers::transaction::TxReq; -use iii_database::handlers::{execute, prepare, query, run_statement, transaction, AppState}; -use iii_database::pool; -use iii_database::transaction::TxRegistry; +use database::config::WorkerConfig; +use database::handle::HandleRegistry; +use database::handlers::execute::ExecuteReq; +use database::handlers::prepare::PrepareReq; +use database::handlers::query::QueryReq; +use database::handlers::run_statement::RunReq; +use database::handlers::transaction::TxReq; +use database::handlers::{execute, prepare, query, run_statement, transaction, AppState}; +use database::pool; +use database::transaction::TxRegistry; use iii_sdk::{Logger, RegisterFunction}; use serde_json::json; use std::collections::HashMap; @@ -128,7 +128,7 @@ async fn end_to_end_query_execute_prepare_run_transaction() { #[test] fn binary_name_matches_manifest() { - assert_eq!(iii_database::worker_name(), "iii-database"); + assert_eq!(database::worker_name(), "database"); } /// Regression: every RPC function must register through the typed @@ -176,39 +176,39 @@ fn registered_functions_carry_request_and_response_schemas() { unreachable!() } async fn _bt( - _: iii_database::handlers::begin_transaction::BeginTxReq, - ) -> Result { + _: database::handlers::begin_transaction::BeginTxReq, + ) -> Result { unreachable!() } async fn _tq( - _: iii_database::handlers::transaction_query::TxQueryReq, + _: database::handlers::transaction_query::TxQueryReq, ) -> Result { unreachable!() } async fn _te( - _: iii_database::handlers::transaction_execute::TxExecuteReq, - ) -> Result { + _: database::handlers::transaction_execute::TxExecuteReq, + ) -> Result { unreachable!() } async fn _ct( - _: iii_database::handlers::commit_transaction::CommitTxReq, - ) -> Result { + _: database::handlers::commit_transaction::CommitTxReq, + ) -> Result { unreachable!() } async fn _rt( - _: iii_database::handlers::rollback_transaction::RollbackTxReq, - ) -> Result { + _: database::handlers::rollback_transaction::RollbackTxReq, + ) -> Result { unreachable!() } - assert_schemas("iii-database::query", _q); - assert_schemas("iii-database::execute", _e); - assert_schemas("iii-database::prepareStatement", _p); - assert_schemas("iii-database::runStatement", _r); - assert_schemas("iii-database::transaction", _t); - assert_schemas("iii-database::beginTransaction", _bt); - assert_schemas("iii-database::transactionQuery", _tq); - assert_schemas("iii-database::transactionExecute", _te); - assert_schemas("iii-database::commitTransaction", _ct); - assert_schemas("iii-database::rollbackTransaction", _rt); + assert_schemas("database::query", _q); + assert_schemas("database::execute", _e); + assert_schemas("database::prepareStatement", _p); + assert_schemas("database::runStatement", _r); + assert_schemas("database::transaction", _t); + assert_schemas("database::beginTransaction", _bt); + assert_schemas("database::transactionQuery", _tq); + assert_schemas("database::transactionExecute", _te); + assert_schemas("database::commitTransaction", _ct); + assert_schemas("database::rollbackTransaction", _rt); } diff --git a/iii-database/tests/value_coercion.rs b/database/tests/value_coercion.rs similarity index 95% rename from iii-database/tests/value_coercion.rs rename to database/tests/value_coercion.rs index 0872fd08..a82f27c5 100644 --- a/iii-database/tests/value_coercion.rs +++ b/database/tests/value_coercion.rs @@ -1,6 +1,6 @@ //! Cross-cutting coercion tests — every JSON shape, both directions. -use iii_database::value::{JsonParam, RowValue}; +use database::value::{JsonParam, RowValue}; use serde_json::json; #[test] diff --git a/iii-database/skills/index.md b/iii-database/skills/index.md deleted file mode 100644 index aceaa6b2..00000000 --- a/iii-database/skills/index.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -type: index -title: iii-database ---- - -# iii-database - -Connect to PostgreSQL, MySQL, and SQLite from the iii engine. Run read-only -queries, write statements, atomic transactions, and prepared-statement -sequences over a managed per-database connection pool. Every callable -surface lives under the single `iii-database::*` namespace; SQLite is the -recommended starting point because it needs no server, just a file. - -The worker resolves the driver from each database's URL scheme (`sqlite:`, -`postgres://`, `postgresql://`, `mysql://`). For the `databases:` config -block, TLS modes, error-code reference, and the per-driver compatibility -table (e.g. `returning:` is a no-op on MySQL; SQLite degrades -`read_committed` / `repeatable_read` to `serializable`), see -[the README](../README.md). - -## How-tos - -### `iii-database::*` - -- [`iii-database::query`](iii://iii-database/query) — read-only SQL; returns `{ rows, row_count, columns }` for SELECT-style statements. -- [`iii-database::execute`](iii://iii-database/execute) — write SQL (INSERT/UPDATE/DELETE/DDL); returns `{ affected_rows, last_insert_id, returned_rows }`. -- [`iii-database::prepareStatement` + `iii-database::runStatement`](iii://iii-database/prepared-statements) — prepare-once, run-many parameterized SQL; the prepare step pins a pool connection until TTL expiry, so always run before re-issuing the same statement many times. -- [`iii-database::transaction`](iii://iii-database/transaction) — atomic batch sequence; one call, pass every statement together, rolls back on first failure with a `failed_index` pointer at the offending step. -- [`iii-database::beginTransaction` + `transactionQuery` / `transactionExecute` + `commitTransaction` / `rollbackTransaction`](iii://iii-database/interactive-transaction) — stateful interactive transaction with a configurable timeout-driven auto-rollback. Use this when you need read-your-writes across several round-trips inside a single transaction. - -`iii-database::row-change` (Postgres logical replication via `pgoutput`) is -registered as a trigger type but is **not yet functional in v1.0.0**: -`register_trigger` returns `UNSUPPORTED` while the streaming decode loop -waits on an upstream `tokio-postgres` replication API release. Operators -can pre-provision slots and publications now; see the **Triggers** section -of [the README](../README.md) for current status and cleanup commands. diff --git a/iii-database/tests/e2e/reports/.gitkeep b/iii-database/tests/e2e/reports/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/iii-database/tests/e2e/workers/harness/src/test.ts b/iii-database/tests/e2e/workers/harness/src/test.ts deleted file mode 100644 index a45789e9..00000000 --- a/iii-database/tests/e2e/workers/harness/src/test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { call } from 'iii-sdk' - -await call('iii-database::execute', { - db: 'primary', - sql: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT)' -}) - -await call('iii-database::execute', { - db: 'primary', - sql: 'INSERT INTO users (email) VALUES (?), (?)', - params: ['a@x', 'b@x'] -}) - -const { rows } = await call('iii-database::query', { - db: 'primary', - sql: 'SELECT id, email FROM users ORDER BY id' -}) \ No newline at end of file diff --git a/shell/tests/e2e/README.md b/shell/tests/e2e/README.md index 23df1553..f80d1b4d 100644 --- a/shell/tests/e2e/README.md +++ b/shell/tests/e2e/README.md @@ -5,7 +5,7 @@ functions (`shell::exec`, `shell::exec_bg`, `shell::kill`, `shell::status`, `shell::list`), every safety guardrail (allowlist, denylist, timeout, output truncation, env scrubbing), and the background-job lifecycle in one command. -Modeled on `iii-database/tests/e2e/`. +Modeled on `database/tests/e2e/`. ## Prerequisites diff --git a/storage/src/error.rs b/storage/src/error.rs index 921bc28d..ee4e4b6a 100644 --- a/storage/src/error.rs +++ b/storage/src/error.rs @@ -1,5 +1,5 @@ //! Discriminated, stable error codes returned to the engine. Mirrors -//! `iii-database/src/error.rs`. The `code` field is the contract; the rest +//! `database/src/error.rs`. The `code` field is the contract; the rest //! is diagnostic. use crate::backend::BackendError; @@ -77,7 +77,7 @@ pub enum StorageError { impl StorageError { /// Convert into the JSON string body that handlers return as their `Err`. - /// Mirrors `iii-database`'s `err_to_str`. + /// Mirrors `database`'s `err_to_str`. pub fn to_wire_string(&self) -> String { serde_json::to_string(self).unwrap_or_else(|_| { format!( diff --git a/storage/src/handlers/mod.rs b/storage/src/handlers/mod.rs index 1a912c4a..51df982b 100644 --- a/storage/src/handlers/mod.rs +++ b/storage/src/handlers/mod.rs @@ -31,7 +31,7 @@ impl AppState { } } -/// JSON-string error body, mirroring iii-database's err_to_str. +/// JSON-string error body, mirroring database's err_to_str. pub fn err_to_str(e: StorageError) -> String { e.to_wire_string() } diff --git a/storage/src/triggers/dispatcher.rs b/storage/src/triggers/dispatcher.rs index 8684376e..d31eabbe 100644 --- a/storage/src/triggers/dispatcher.rs +++ b/storage/src/triggers/dispatcher.rs @@ -77,7 +77,7 @@ impl EventDispatcher for EngineDispatcher { .await { Ok(Ok(value)) => { - // Match iii-database's contract: `null` from a void-returning + // Match database's contract: `null` from a void-returning // function counts as ack=true; otherwise the function may // return `{ack: bool}` to control redelivery explicitly. if value.is_null() { diff --git a/storage/tests/e2e/config.yaml b/storage/tests/e2e/config.yaml index 25c6f14d..a961c21c 100644 --- a/storage/tests/e2e/config.yaml +++ b/storage/tests/e2e/config.yaml @@ -8,7 +8,7 @@ # The storage worker config is inlined under its worker entry. The # engine serializes this `config:` value to a temp YAML and threads # `--config ` through to the spawned binary (same delivery path -# iii-database uses for its e2e config). +# database uses for its e2e config). # # Triggers are registered DYNAMICALLY by the harness via the SDK # (`registerTrigger` in runner.ts) so the harness can route dispatches to diff --git a/storage/tests/e2e/run-tests.sh b/storage/tests/e2e/run-tests.sh index 760d085e..3505f8c5 100755 --- a/storage/tests/e2e/run-tests.sh +++ b/storage/tests/e2e/run-tests.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # E2E orchestrator for the storage worker, modelled on -# iii-database/tests/e2e/run-tests.sh. Builds the worker binary, starts the +# database/tests/e2e/run-tests.sh. Builds the worker binary, starts the # engine, launches the TS harness, greps for the HARNESS_DONE sentinel, # pretty-prints the report, and propagates pass/fail as the exit code. #