Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/database-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ jobs:
# GHA `services:` blocks can't pass `-c wal_level=logical` to postgres,
# which the row-change tests require. Reuse the same docker-compose
# stack the harness uses locally for dev/CI parity.
- name: Install iii engine (latest from main)
- name: Install iii engine (next)
run: |
curl -fsSL --retry 3 --retry-connrefused --retry-delay 5 \
https://install.iii.dev/iii/main/install.sh | sh
https://install.iii.dev/iii/main/install.sh | sh -s -- --next
Comment on lines +46 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What installation flags does https://install.iii.dev/iii/main/install.sh support for pinning a specific iii engine version, and what is the recommended CI strategy for reproducible installs (exact version vs channel)?

💡 Result:

The iii engine installer script (https://install.iii.dev/iii/main/install.sh) supports these installation “pinning” arguments: 1) Pin an exact engine version - Usage: sh -s -- v0.9.0 (the docs show passing the version argument as vX.Y.Z) [1] - Behavior: when a specific VERSION is requested, the installer rejects prerelease versions (it checks the GitHub release JSON’s prerelease field, and errors if prerelease == true) [2] 2) Install “latest stable” (channel behavior) - Default behavior (no -v / version argument): the script “installing latest version” [2] - Under the hood: for latest-version selection it probes GitHub’s /releases/latest first, and only accepts a tag that matches the expected prefix (iii/v*) and is non-draft/non-prerelease; if that fails, it falls back to listing releases and filtering by prerelease==false and tag_name prefix iii/v* [2] So, for pinning reproducibly, prefer exact version pinning (e.g., v0.9.0) over “channel/latest” resolution [1][2]. Recommended CI strategy for reproducible installs (exact version vs channel) - Recommended: pin an exact version (e.g., use -v vX.Y.Z / pass vX.Y.Z as the version argument) in CI, not the moving “latest”/channel behavior, because “latest” is resolved at install time by querying GitHub releases/latest and filtering [2]. - Additionally, do not use prerelease channels in CI: the engine installer explicitly rejects prereleases even when a specific version is requested (“version $VERSION is a prerelease — use a stable release”) [2]. If you want stronger reproducibility than “install a single binary version,” use the project’s lockfile workflow for registry-managed workers: commit iii.lock with config.yaml and replay with iii worker sync so CI/dev/deploy environments install/repair the same pinned set (and the sync step verifies binaries by SHA-256 before activation) [3].

Citations:


🌐 Web query:

What does the iii engine installer script https://install.iii.dev/iii/main/install.sh interpret the --next flag as (prerelease/next channel vs something else), and how should CI handle it for reproducible installs?

💡 Result:

The --next flag in the iii engine installer script (https://install.iii.dev/iii/main/install.sh) directs the script to fetch and install the latest pre-release version of the iii engine [1]. Specifically, it triggers logic that queries GitHub releases for tags matching the -next. pattern (such as iii/v*-next.*) [1][2], rather than defaulting to the latest stable release [1][3]. How CI Should Handle It for Reproducible Installs: Because --next fetches the latest available pre-release dynamically, it is inherently non-deterministic and unsuitable for reproducible CI environments [1]. To ensure reproducibility in CI/CD pipelines, you should pin the engine to a specific version instead of using the --next flag [4][5]. 1. Pin to a Specific Version: Use the script's ability to accept a version argument directly. By providing a specific tag, you ensure the installer fetches a known, immutable release [4][5]. Example: curl -fsSL https://install.iii.dev/iii/main/install.sh | sh -s -- v0.12.0 2. Avoid --next in Production CI: The --next flag is intended for "work-in-progress" (WIP) testing and development environments where tracking the cutting edge is desired [1]. If you must test pre-release builds, pin to a specific pre-release tag (e.g., v0.12.0-next.1) rather than using the generic --next flag to avoid unexpected breaking changes between CI runs [1][2]. 3. Verify Dependencies: Ensure your CI environment validates the checksum or version of the installed binary to prevent supply chain drift [6][3]. Since the installer script itself can evolve [7][2], pinning the version and potentially caching the installer script or the downloaded binary are best practices for maintaining a locked environment [4].

Citations:


Pin iii engine to a specific release in CI (avoid --next)
In .github/workflows/database-e2e.yml, --next installs the latest pre-release dynamically (e.g., matching *-next.*), making E2E runs non-reproducible and prone to unexpected breakage. Pin an exact stable engine version (e.g., vX.Y.Z), or if you need WIP, pin a specific pre-release tag (e.g., vX.Y.Z-next.1) instead of using --next.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/database-e2e.yml around lines 46 - 49, The CI step that
runs the installer uses the flag "--next" (see the "Install iii engine (next)"
step and the curl ... | sh -s -- --next invocation) which pulls a moving
pre-release; replace the dynamic "--next" flag with a pinned release tag string
(e.g., "--version vX.Y.Z" or the specific pre-release "--version vX.Y.Z-next.1"
depending on desired stability) so the workflow installs a reproducible engine
version; update the step name accordingly if needed.

echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: Verify engine
Expand Down
66 changes: 52 additions & 14 deletions database/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,64 @@ iii worker add database@1.0.0

## Configure

Add a single `databases` block to your `config.yaml`. SQLite is the recommended starting point — no server, just a file:
Runtime settings live in the **`configuration` worker** under id **`database`**. The worker registers its JSON Schema at startup, reads the live value via `configuration::get`, and hot-reloads connection pools when the value changes.

Persisted values default to `./data/configuration/database.yaml` (fs adapter). Edit that file directly or call `configuration::set` — both propagate without a worker restart.

### Zero-config default

With no seed file and no stored configuration value, the worker uses a built-in default:

```yaml
workers:
- name: database
config:
databases:
primary:
url: sqlite:./data/iii.db
pool:
max: 10
idle_timeout_ms: 30000
acquire_timeout_ms: 5000
analytics:
url: ${ANALYTICS_URL} # postgres:// or mysql://
pool: { max: 5 }
databases:
primary:
url: sqlite:./data/iii.db
pool:
max: 10
idle_timeout_ms: 30000
acquire_timeout_ms: 5000
```

This is seeded into the `configuration` worker on first register and used as a runtime fallback when the stored value is `null`.

### Optional seed file

Pass `--config <path>` to supply a YAML seed file. When present, its `databases` block is passed as `initial_value` on `configuration::register` (overriding the built-in default for first-time registration). See [`config.yaml.example`](config.yaml.example).

Engine-managed deployments can inline config under the worker entry; the engine delivers it via `--config` as before.

### Value shape

SQLite is the recommended starting point — no server, just a file:

```yaml
databases:
primary:
url: sqlite:./data/iii.db
pool:
max: 10
idle_timeout_ms: 30000
acquire_timeout_ms: 5000
analytics:
url: ${ANALYTICS_URL:postgres://localhost/analytics}
pool: { max: 5 }
```

Set or replace the whole value:

```bash
iii trigger configuration::get id=database
iii trigger configuration::set id=database value='{"databases":{"primary":{"url":"sqlite:./data/iii.db"}}}'
```

Env placeholders use **`${VAR:default}`** syntax. The configuration worker expands them on every `configuration::get` call, so env changes propagate without a restart.

URL scheme picks the driver: `sqlite:`, `postgres://`, `postgresql://`, `mysql://`.

### Hot reload

When configuration changes (`configuration::set`, or an external edit to `./data/configuration/database.yaml`), the worker rebuilds connection pools in place. Invalid configs are rejected and the previous pools are kept. In-flight prepared-statement handles and open transactions continue on their original pool until they expire.

### TLS (postgres + mysql)

Postgres and mysql connections default to **`tls.mode: require`** — TLS handshake required, certificate chain validated against the system trust store, hostname verification skipped (matches libpq's `sslmode=require`). Override per-database:
Expand Down
7 changes: 0 additions & 7 deletions database/config.yaml

This file was deleted.

28 changes: 18 additions & 10 deletions database/config.yaml.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
workers:
- name: database
config:
databases:
primary:
url: sqlite:./data/iii.db
pool:
max: 10
idle_timeout_ms: 30000
acquire_timeout_ms: 5000
# Optional seed file for first-time registration (--config ./config.yaml.example).
# When omitted, the worker seeds the built-in default:
#
# databases:
# primary:
# url: sqlite:./data/iii.db
# pool: { max: 10, idle_timeout_ms: 30000, acquire_timeout_ms: 5000 }
#
# After the first boot, the runtime source of truth is the `configuration`
# worker entry `database`, persisted at ./data/configuration/database.yaml.

databases:
primary:
url: sqlite:./data/iii.db
pool:
max: 10
idle_timeout_ms: 30000
acquire_timeout_ms: 5000
117 changes: 117 additions & 0 deletions database/skills/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
name: database
description: >-
Run SQL against PostgreSQL, MySQL, or SQLite from the iii engine — reads,
writes, transactions, and prepared statements over managed connection pools.
---

# database

The database worker connects to PostgreSQL, MySQL, and SQLite through a
managed per-database connection pool. Every callable surface lives under
the `database::*` namespace. The driver is chosen from each database URL
scheme (`sqlite:`, `postgres://`, `postgresql://`, `mysql://`).

Runtime settings live in the `configuration` worker under id `database`;
pools hot-reload when the value changes. SQLite is the recommended starting
point. Placeholder syntax: `?` for SQLite and MySQL, `$1`/`$2`/… for Postgres.

## When to Use

- You need to read rows from a configured database (`database::query`).
- You need to insert, update, delete, or run DDL and read affected-row
counts or autoincrement ids (`database::execute`).
- Several statements must commit or roll back together as one unit
(`database::transaction` or the interactive transaction surface).
- The same parameterized SQL will run many times and you want to skip
per-call parse/plan cost (`database::prepareStatement` +
`database::runStatement`).
- You need read-your-writes across round-trips with logic between steps
(`database::beginTransaction` … `commitTransaction` / `rollbackTransaction`).
- You want to react to Postgres row-level changes once logical replication
streaming ships (`database::row-change` trigger — see below).

## Boundaries

- Not a migration tool, ORM, or schema designer — pass raw SQL only.
- Not a general pub/sub bus — use `database::row-change` only for Postgres
table change feeds, not for application events.
- `database::query` is read-oriented; use `database::execute` for writes.
Running a SELECT through `execute` discards rows.
- Prepared handles pin a pool connection until TTL expiry — not transactions.
Batch `database::transaction` needs every statement up front; use the
interactive surface when code must branch between steps.
- MySQL ignores the `returning` option on `execute` (warn-once). SQLite
degrades `read_committed` / `repeatable_read` isolation to serializable.
- For filesystem or shell operations, use the `shell` worker instead.

## Functions

- `database::query` — run read-only SQL and return rows, row count, and
column metadata.
- `database::execute` — run write SQL (INSERT/UPDATE/DELETE/DDL) and
return affected rows, optional last insert id, and optional RETURNING rows.
- `database::prepareStatement` — parse and plan SQL once; return a handle
that pins a pool connection until TTL expiry.
- `database::runStatement` — re-execute a prepared handle with new bind
params; response shape matches `query`.
- `database::transaction` — run an ordered batch of statements atomically;
rolls back on first failure and reports `failed_index`.
- `database::beginTransaction` — open an interactive transaction and
return an id plus expiry deadline.
- `database::transactionQuery` — read SQL inside an open interactive
transaction; same envelope as `query`.
- `database::transactionExecute` — write SQL inside an open interactive
transaction; same envelope as `execute`. Rejects bare transaction-control
SQL — finalize via `commitTransaction` or `rollbackTransaction`.
- `database::commitTransaction` — commit and finalize an interactive
transaction.
- `database::rollbackTransaction` — roll back and finalize an interactive
transaction.

Interactive transactions auto-roll back when `timeout_ms` elapses (default
30 s, max 5 min). Prepared handles default to a 1 h TTL (max 24 h) with no
explicit release call — let them expire or stop using them when done.

## Reactive triggers

Register a `database::row-change` trigger when a function should run
automatically on Postgres INSERT/UPDATE/DELETE for specific tables — without
polling with `database::query`.

Reach for it when:

- A downstream worker or workflow must react to row mutations in near real
time on Postgres.
- You need decoded row payloads (old/new values) from logical replication
rather than polling an outbox table.

Do not bind when:

- The writer already has the new row in its `execute` or `transactionExecute`
return payload.
- You are on SQLite or MySQL — this trigger type is Postgres-only.
- You need events today — v1.0.0 returns `UNSUPPORTED` on `registerTrigger`
pending an upstream `tokio-postgres` replication API release.
Comment on lines +94 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Conflicting database::row-change behavior description.

Line 94 states registerTrigger returns UNSUPPORTED, but the main README describes v1.0.0 as setup-only (slot/publication creation succeeds; streaming is stubbed). Please align both docs to one behavior contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@database/skills/SKILL.md` around lines 94 - 95, The SKILL.md entry for
database::row-change and the README disagree about v1.0.0 behavior; decide
whether v1.0.0 should (A) return UNSUPPORTED from registerTrigger or (B) succeed
on setup (slot/publication) but stub streaming. Update the SKILL.md text around
database::row-change and the registerTrigger description to match the chosen
contract, referencing the registerTrigger symbol and the database::row-change
capability so both docs consistently state the same behavior (either explicit
UNSUPPORTED return or “setup succeeds, streaming stubbed”) and include any notes
about pending tokio-postgres replication API availability.


### How to bind

1. Register a handler: `registerFunction('stream::on-row-change', handler)`.
2. Register the trigger:

```typescript
iii.registerTrigger({
type: 'database::row-change',
function_id: 'stream::on-row-change',
config: {
db: 'primary',
schema: 'public',
tables: ['orders', 'payments'],
// optional: slot_name, publication_name — see get function info
},
})
```

Config: `db`, `schema` (default `public`), `tables`. Slot/publication names
derive from `trigger_id` unless overridden. For event payload shape, call
`get function info` on the trigger type or handler function id.
136 changes: 0 additions & 136 deletions database/skills/iii-database/execute.md

This file was deleted.

Loading
Loading