//! Schema bootstrap (ADR-010 §8): the one engine-owned PostgreSQL //! schema and its table family — the dependency root every later //! wave-4 task rides. //! //! # Layout posture //! //! All engine-owned objects — job, dead, stream events, offsets, //! schedule, locks — live in one PostgreSQL schema, default //! [`DEFAULT_SCHEMA`] (`"alkstore"`), overridable per-engine option //! (the `PgOpts::schema` knob the `open` task wires), co-tenanted //! safely with consumer tables (deployment.md's co-tenancy posture; //! the alkblobs ADR-008 precedent). Queues are rows in one job table — //! no per-queue tables, no per-queue schemas (ADR-010 §8); per-queue //! configuration is stamped onto job rows (ADR-010 §3a), never stored //! in a registry. Queue creation is not a DDL operation. //! //! # Payload posture //! //! Payload columns are `BYTEA` — the engine stores exactly the bytes //! `encode_payload` returned, byte for byte (ADR-020 §4). `JSONB` is //! deliberately rejected: its normalization (key reordering, //! duplicate-key folding, number canonicalization) promises no byte //! identity, and the contract suite's byte-identical-rows row pins //! that rows enqueueing the same `Value` store identical bytes. //! //! # No migration machinery in v1 //! //! Tables are created fresh per schema via idempotent DDL //! (`CREATE … IF NOT EXISTS`); this module carries no ALTER/migration //! machinery. Append-column evolution is the SQLite substrate's //! discipline for its in-place database files; a greenfield pg schema //! needs none in v1 — wave 6's release pass revisits if the //! deployment story demands in-place upgrades. //! //! # Column sets //! //! Column sets follow the contract's value shapes — the //! `#[doc(hidden)]` engine constructors' field lists //! (`alkstore::Job::from_row`, `alkstore::StreamEvent::from_row`, //! `alkstore::Schedule::new`) are the lists of record (ADR-019 §3, //! ADR-021 §2, ADR-015 §3), with the `QueueOpts` stamp columns per //! ADR-010 §3a and the schedule row's boundary-state columns per //! ADR-009 §3/§4 (the substrate's `__alkstore_scheduler_tasks` //! concrete stamp set re-owned in pg form; `enabled` dropped — the //! collapse surface has no pause/resume, ADR-009 §1). Timestamps are //! unix-seconds `BIGINT` engine-wide — the engines' single clock //! source, second-precision integer arithmetic (honker parity); no //! `timestamptz`. //! //! # Identifier quoting //! //! The engine composes schema-qualified SQL from a consumer-supplied //! schema name — [`quote_identifier`] is the injection boundary: //! every identifier entering SQL text (schema name, table names, //! index names) is quoted through it. Two column names are //! reserved-word hazards in Postgres and must stay quoted in later //! tasks' SQL: the offsets checkpoint column `"offset"` and the //! events key column `"key"`. //! //! # Index-name qualification //! //! `CREATE INDEX` does not accept a schema-qualified index name — the //! index lands in the table's schema automatically — and //! `IF NOT EXISTS` on the resulting *bare* name checks existence //! across every schema on the search path: a co-tenant's unrelated //! `public.job_claim_idx` would silently skip our bootstrap's index //! creation. The bootstrap therefore names every index //! `{schema}_{table}_{suffix}` (schema-prefixed, quoted) — a name a //! non-engine co-tenant will not carry, and unique among engine //! instances with distinct schema names too. The canonical suffixes //! ([`indexes`]) are what the later mechanism tasks' SQL reuses. use std::fmt; use thiserror::Error; /// The default engine-owned schema name (ADR-010 §8). pub const DEFAULT_SCHEMA: &str = "alkstore"; /// A bootstrap failure: a DDL statement errored server-side (or the /// connection did). /// /// The `open` constructor (the next task) maps this onto the engine's /// `Error::Database` surface, source chain preserved. #[derive(Debug, Error)] #[error("schema bootstrap failed")] pub struct BootstrapError(#[source] pub tokio_postgres::Error); /// Result alias for [`bootstrap`]. pub type BootstrapResult = Result<(), BootstrapError>; /// Quote a SQL identifier (Postgres rule: double embedded `"`). /// /// Consumer-supplied schema names ride DDL text through this quoting; /// an identifier is never interpolated into SQL unquoted. pub fn quote_identifier(name: &str) -> String { let mut quoted = String::with_capacity(name.len() + 2); quoted.push('"'); for c in name.chars() { if c == '"' { quoted.push('"'); } quoted.push(c); } quoted.push('"'); quoted } /// A schema-qualified table or index reference: `"schema"."name"`. #[derive(Debug, Clone, Copy)] pub struct QualifiedTable<'a> { /// The schema name (unquoted form). pub schema: &'a str, /// The table/index name (unquoted form). pub name: &'a str, } impl fmt::Display for QualifiedTable<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{}.{}", quote_identifier(self.schema), quote_identifier(self.name) ) } } /// The engine-owned object names inside the schema — fixed constants, /// the one owner of the names the later mechanism tasks' SQL reuses. /// /// Object names do not ride engine configuration (only the schema /// name does, ADR-010 §8). pub mod tables { /// The job table (live rows — pending and processing). pub const JOB: &str = "job"; /// The dead-letter table (physical move target, ADR-010 §4). pub const DEAD: &str = "dead"; /// The stream events table (`id BIGSERIAL` = the offset). pub const EVENTS: &str = "stream_events"; /// The per-consumer offset checkpoint table. pub const OFFSETS: &str = "stream_offsets"; /// The schedule table (ADR-009's collapse shape). pub const SCHEDULE: &str = "schedule"; /// The TTL lock table. pub const LOCKS: &str = "locks"; } /// The engine-owned index names inside the schema — schema-prefixed /// (see the module docs' index-name qualification posture), /// schema-parameterized at DDL-composition time by [`bootstrap_ddl`]. pub mod indexes { /// The claim-ordering index (`priority DESC, run_at, id`). pub const JOB_CLAIM: &str = "job_claim_idx"; /// The pending-ready-time hot-path index. pub const JOB_PENDING: &str = "job_pending_idx"; /// The processing-claim-deadline index. pub const JOB_DEADLINE: &str = "job_deadline_idx"; /// The dead-rows retention index (`queue, died_at`). pub const DEAD_RETENTION: &str = "dead_retention_idx"; /// The events read index (`stream, id` — the offset ASC path). pub const EVENTS_READ: &str = "events_read_idx"; /// The schedule due-boundary index (`next_fire_at`). pub const SCHEDULE_FIRE: &str = "schedule_fire_idx"; /// The locks expiry index (lazy-acquire sweep support). pub const LOCKS_EXPIRY: &str = "locks_expiry_idx"; } /// Run the schema bootstrap for one engine-owned schema on `conn`. /// Idempotent. /// /// Creates the schema and the full table family + indexes (column /// sets per the module docs) with `CREATE … IF NOT EXISTS` — a second /// run is a no-op. Runnable at open on any connection; the deadpool /// pooled client is a `tokio_postgres::Client` (POC-verified), so a /// pool checkout passes directly. /// /// The schema name is engine configuration (default /// [`DEFAULT_SCHEMA`], decided at the `PgOpts` layer); object names /// inside the schema are fixed ([`tables`]). pub async fn bootstrap(conn: &tokio_postgres::Client, schema_name: &str) -> BootstrapResult { conn.batch_execute(&bootstrap_ddl(schema_name)) .await .map_err(BootstrapError) } fn bootstrap_ddl(schema: &str) -> String { // Index names are bare at the `CREATE INDEX` statement (Postgres // rejects a qualified index name there) but schema-prefixed in // their *text* — the qualification posture that keeps a // co-tenant's same-named index from silently skipping ours. let schema_q = quote_identifier(schema); let index = |suffix: &'static str| quote_identifier(&format!("{schema}_{suffix}")); let job = QualifiedTable { schema, name: tables::JOB, }; let dead = QualifiedTable { schema, name: tables::DEAD, }; let events = QualifiedTable { schema, name: tables::EVENTS, }; let offsets = QualifiedTable { schema, name: tables::OFFSETS, }; let schedule = QualifiedTable { schema, name: tables::SCHEDULE, }; let locks = QualifiedTable { schema, name: tables::LOCKS, }; let job_claim_idx = index(indexes::JOB_CLAIM); let job_pending_idx = index(indexes::JOB_PENDING); let job_deadline_idx = index(indexes::JOB_DEADLINE); let dead_retention_idx = index(indexes::DEAD_RETENTION); let events_read_idx = index(indexes::EVENTS_READ); let schedule_fire_idx = index(indexes::SCHEDULE_FIRE); let locks_expiry_idx = index(indexes::LOCKS_EXPIRY); format!( " CREATE SCHEMA IF NOT EXISTS {schema_q}; -- Job table (live rows, pending + processing; delete-on-ack — -- ADR-010 §1). Columns = Job's field list of record (ADR-019 §3, -- ADR-021 §2) + the stamped QueueOpts (ADR-010 §3a). CREATE TABLE IF NOT EXISTS {job} ( id BIGSERIAL PRIMARY KEY, queue TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'pending', payload BYTEA NOT NULL, priority BIGINT NOT NULL DEFAULT 0, run_at BIGINT NOT NULL, attempts BIGINT NOT NULL DEFAULT 0, max_attempts BIGINT NOT NULL DEFAULT 3, worker_id TEXT, claimed_at BIGINT, claim_expires_at BIGINT, created_at BIGINT NOT NULL, expires_at BIGINT, visibility_timeout_s BIGINT NOT NULL DEFAULT 300, backoff_base_s BIGINT NOT NULL DEFAULT 5, dead_letter_retention_s BIGINT ); -- Claim-ordering index (ADR-010 §1's ordering row: priority DESC, -- run_at ASC, enqueue order = id). Partial: dead rows ride the dead -- table, the claim path never scans them. CREATE INDEX IF NOT EXISTS {job_claim_idx} ON {job} (queue, priority DESC, run_at, id) WHERE state IN ('pending', 'processing'); CREATE INDEX IF NOT EXISTS {job_pending_idx} ON {job} (queue, run_at) WHERE state = 'pending'; CREATE INDEX IF NOT EXISTS {job_deadline_idx} ON {job} (queue, claim_expires_at) WHERE state = 'processing'; -- Dead table (ADR-010 §4): the job row's diagnosis fields + died_at; -- physical move target, off the claim path. Retention index for the -- sweep's dead-TTL enforcement (the only sweeper dead rows have). CREATE TABLE IF NOT EXISTS {dead} ( id BIGINT PRIMARY KEY, queue TEXT NOT NULL, payload BYTEA NOT NULL, priority BIGINT NOT NULL DEFAULT 0, run_at BIGINT NOT NULL DEFAULT 0, attempts BIGINT NOT NULL DEFAULT 0, max_attempts BIGINT NOT NULL DEFAULT 0, worker_id TEXT, claimed_at BIGINT, claim_expires_at BIGINT, created_at BIGINT NOT NULL, expires_at BIGINT, visibility_timeout_s BIGINT NOT NULL DEFAULT 300, backoff_base_s BIGINT NOT NULL DEFAULT 5, dead_letter_retention_s BIGINT, last_error TEXT, died_at BIGINT NOT NULL ); CREATE INDEX IF NOT EXISTS {dead_retention_idx} ON {dead} (queue, died_at); -- Stream events table (ADR-015 §3): `id BIGSERIAL` is the offset — -- engine-assigned, monotone, immutable, never renumbered; global -- FIFO per stream by id ASC; gaps legal after trim/delete. Nullable -- key column = carried metadata (ADR-015 §1). created_at is -- informational. CREATE TABLE IF NOT EXISTS {events} ( id BIGSERIAL PRIMARY KEY, stream TEXT NOT NULL, \"key\" TEXT, payload BYTEA NOT NULL, created_at BIGINT NOT NULL ); CREATE INDEX IF NOT EXISTS {events_read_idx} ON {events} (stream, id); -- Offsets table: (stream, consumer) → offset, the monotone -- checkpoint upsert target (the streams task's SQL owns the -- monotonicity rule, WHERE-guarded upsert, one owner). The boundary -- argument is total (ADR-023 §2): no domain CHECK — a negative seed -- is stored raw, reads anchor at it and resume forward identically. CREATE TABLE IF NOT EXISTS {offsets} ( stream TEXT NOT NULL, consumer TEXT NOT NULL, \"offset\" BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (stream, consumer) ); -- Schedule table (ADR-009 §3/§5): name (unique — the PK), the -- `@every` spec, target queue, payload, the ScheduleOpts stamps -- (priority / max_attempts / expires_s) over the resolved QueueOpts -- stamp set (the substrate's __alkstore_scheduler_tasks -- concrete-stamp shape re-owned; `enabled` dropped — the collapse -- surface has no pause/resume), and next_fire_at — the boundary -- column the tick math advances (`next = last + interval`). CREATE TABLE IF NOT EXISTS {schedule} ( name TEXT PRIMARY KEY, spec TEXT NOT NULL, queue TEXT NOT NULL, payload BYTEA NOT NULL, priority BIGINT NOT NULL DEFAULT 0, expires_s BIGINT, next_fire_at BIGINT NOT NULL, max_attempts BIGINT NOT NULL DEFAULT 3, visibility_timeout_s BIGINT NOT NULL DEFAULT 300, backoff_base_s BIGINT NOT NULL DEFAULT 5, dead_letter_retention_s BIGINT ); CREATE INDEX IF NOT EXISTS {schedule_fire_idx} ON {schedule} (next_fire_at); -- Locks table: the TTL lock machinery's storage (the substrate's -- lock-table semantics re-derived: PK on name prevents dual -- acquisition; expiry lazy — no sweeper, an expired lock is -- acquirable; the locks task's ops own the SQL). CREATE TABLE IF NOT EXISTS {locks} ( name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at BIGINT NOT NULL ); CREATE INDEX IF NOT EXISTS {locks_expiry_idx} ON {locks} (expires_at); ", schema_q = schema_q, job = job, job_claim_idx = job_claim_idx, job_pending_idx = job_pending_idx, job_deadline_idx = job_deadline_idx, dead = dead, dead_retention_idx = dead_retention_idx, events = events, events_read_idx = events_read_idx, offsets = offsets, schedule = schedule, schedule_fire_idx = schedule_fire_idx, locks = locks, locks_expiry_idx = locks_expiry_idx, ) }