Skip to main content

objectiveai_cli/db/
init.rs

1//! Pool construction + schema bootstrap.
2//!
3//! No migration framework — schema lives inline as `CREATE TABLE IF
4//! NOT EXISTS` etc. that we run on every cold `init`. Idempotent: a
5//! second invocation against an already-populated database is a no-op.
6//!
7//! Connection URL strategy: the caller (`Context::db_client()`)
8//! hands [`init`] a base connect URL WITHOUT a database path — either
9//! the `postgresql://...` published in the `db` spawn lock, or one
10//! composed from `config db` via [`config_url`] when `db.address`
11//! points at a remote postgres — plus the application database name
12//! (`config db.database`, default `objectiveai`).
13//!
14//! We open two pools sequentially: a small admin pool against the
15//! base URL as-is (no path segment, so it lands on the connecting
16//! user's default database, `postgres`), used only to
17//! `CREATE DATABASE` the application database when it doesn't exist;
18//! then the real application pool against the freshly-ensured
19//! database. Schema runs inside one transaction.
20
21use sqlx::postgres::PgPoolOptions;
22use sqlx::{Executor as _, Row as _};
23
24use super::{Error, Pool};
25
26/// Inline schema applied on every cold `init`. `CREATE TABLE IF NOT
27/// EXISTS` + `CREATE INDEX IF NOT EXISTS` everywhere keeps the call
28/// idempotent — re-running against an existing database is a no-op.
29/// No migration framework: nobody is on this DB yet, and adding one
30/// would add ceremony we don't need.
31const SCHEMA: &str = r#"
32-- Every base objectiveai table lives in the `objectiveai` schema —
33-- one namespace for what used to be split across `public` and
34-- `logs`. Plugin/tool compartments (see `db::compartment`) get
35-- readonly over exactly this schema and never touch `public`.
36CREATE SCHEMA IF NOT EXISTS objectiveai;
37
38-- `tag_groups`: explicit grouping container that lets many tags
39-- share one resolved `AgentSpec` + parent lineage. The cli's
40-- `agents tags apply` either creates a group on the fly (for the
41-- `--agent` arm) or joins the new tag into an existing group (for
42-- the `--agent-tag` arm). When any one tag in a group is picked
43-- up by a live spawn, the conduit-driven upgrade flips every tag
44-- in the group from `tag_group` to `agent_instance_hierarchy` in
45-- one UPDATE inside the read transaction — see
46-- `db::message_queue::read_pending_and_upgrade_tag`.
47CREATE TABLE IF NOT EXISTS objectiveai.tag_groups (
48    id                              BIGSERIAL PRIMARY KEY,
49    -- Resolved `agents::spawn::AgentSpec`; serialized
50    -- as JSONB. References are resolved at apply-time, never at
51    -- spawn-time, so this column is always inline-or-remote.
52    agent_spec                      JSONB  NOT NULL,
53    -- The lineage prefix the spawn-by-tag will compose its AIH
54    -- against. NOT NULL: callers (CLI handlers) substitute the
55    -- cli's own `Config.agent_instance_hierarchy` when the user
56    -- omits the argument.
57    parent_agent_instance_hierarchy TEXT   NOT NULL,
58    created_at                      BIGINT NOT NULL
59);
60
61CREATE TABLE IF NOT EXISTS objectiveai.tags (
62    name                     TEXT PRIMARY KEY NOT NULL,
63    -- BOUND when set: this tag resolves to a live AIH.
64    agent_instance_hierarchy TEXT,
65    -- GROUPED when set: this tag's resolution is the tag_group
66    -- row's (agent_spec, parent). Exactly one of the two columns
67    -- is non-null at any time.
68    tag_group                BIGINT,
69    updated_at               BIGINT NOT NULL,
70    CHECK (
71        (agent_instance_hierarchy IS NOT NULL AND tag_group IS NULL)
72        OR
73        (agent_instance_hierarchy IS NULL AND tag_group IS NOT NULL)
74    ),
75    FOREIGN KEY (tag_group) REFERENCES objectiveai.tag_groups(id) ON DELETE CASCADE
76);
77CREATE INDEX IF NOT EXISTS tags_hierarchy_idx
78    ON objectiveai.tags(agent_instance_hierarchy);
79CREATE INDEX IF NOT EXISTS tags_tag_group_idx
80    ON objectiveai.tags(tag_group);
81
82-- Latest continuation token per agent_instance_hierarchy. Upserted
83-- per streamed chunk by the chunk-yielder loops in `agents spawn`
84-- and `functions execute`. No GC, no history — querying it gives
85-- the single most recent continuation for that AIH.
86CREATE TABLE IF NOT EXISTS objectiveai.agent_continuations (
87    agent_instance_hierarchy TEXT PRIMARY KEY NOT NULL,
88    continuation             TEXT             NOT NULL,
89    updated_at               BIGINT           NOT NULL
90);
91
92CREATE TABLE IF NOT EXISTS objectiveai.message_queue (
93    id                              BIGSERIAL PRIMARY KEY,
94    agent_instance_hierarchy        TEXT,
95    agent_tag                       TEXT,
96    -- AIH of the caller who enqueued this row (sourced from
97    -- `ctx.config.agent_instance_hierarchy` at enqueue time).
98    -- Surfaced on `agents queue read pending` so callers can
99    -- audit "who asked for this" without a join.
100    sender_agent_instance_hierarchy TEXT   NOT NULL,
101    -- Content rows live in `message_queue_contents` (PK
102    -- `id BIGSERIAL`, FK `message_queue_id` → here). Readers
103    -- JOIN; no denormalized JSON shadow column lives here.
104    enqueued_at                     BIGINT NOT NULL,
105    key                             TEXT,
106    -- Soft-delete marker. Rows start at TRUE and flip to FALSE
107    -- when consumed (either via the LogWriter's MessageQueue row
108    -- write or via `db::message_queue::delete_by_id`'s in-flight
109    -- lock-race-released path). Every reader filters
110    -- `WHERE active = TRUE`, so flipped rows are invisible.
111    -- Content stays around in `message_queue_contents` (the old
112    -- `ON DELETE CASCADE` chain no longer fires because we don't
113    -- DELETE).
114    active                          BOOLEAN NOT NULL DEFAULT TRUE,
115    CHECK (
116        (agent_instance_hierarchy IS NOT NULL AND agent_tag IS NULL)
117        OR
118        (agent_instance_hierarchy IS NULL AND agent_tag IS NOT NULL)
119    )
120);
121CREATE INDEX IF NOT EXISTS message_queue_hierarchy_idx
122    ON objectiveai.message_queue(agent_instance_hierarchy, id)
123    WHERE agent_instance_hierarchy IS NOT NULL;
124CREATE INDEX IF NOT EXISTS message_queue_tag_idx
125    ON objectiveai.message_queue(agent_tag, id)
126    WHERE agent_tag IS NOT NULL;
127-- Per-target idempotency keys. The `AND active = TRUE` clause
128-- means inactive prior rows don't count toward uniqueness — an
129-- `agents message --enqueue-with-key k` after a prior consumption
130-- inserts cleanly without UNIQUE-violating the soft-flipped row.
131CREATE UNIQUE INDEX IF NOT EXISTS message_queue_key_hierarchy_unique_idx
132    ON objectiveai.message_queue(agent_instance_hierarchy, key)
133    WHERE agent_instance_hierarchy IS NOT NULL
134      AND key IS NOT NULL
135      AND active = TRUE;
136CREATE UNIQUE INDEX IF NOT EXISTS message_queue_key_tag_unique_idx
137    ON objectiveai.message_queue(agent_tag, key)
138    WHERE agent_tag IS NOT NULL
139      AND key IS NOT NULL
140      AND active = TRUE;
141
142CREATE TABLE IF NOT EXISTS objectiveai.message_queue_contents (
143    id               BIGSERIAL PRIMARY KEY,
144    message_queue_id BIGINT NOT NULL,
145    kind             TEXT   NOT NULL
146        CHECK (kind IN ('text','image','audio','video','file')),
147    FOREIGN KEY (message_queue_id) REFERENCES objectiveai.message_queue(id) ON DELETE CASCADE
148);
149CREATE INDEX IF NOT EXISTS message_queue_contents_parent_idx
150    ON objectiveai.message_queue_contents(message_queue_id);
151
152CREATE TABLE IF NOT EXISTS objectiveai.message_queue_texts (
153    id   BIGINT PRIMARY KEY,
154    text TEXT   NOT NULL,
155    FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
156);
157
158CREATE TABLE IF NOT EXISTS objectiveai.message_queue_images (
159    id     BIGINT PRIMARY KEY,
160    url    TEXT   NOT NULL,
161    detail TEXT,
162    FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
163);
164
165CREATE TABLE IF NOT EXISTS objectiveai.message_queue_audios (
166    id     BIGINT PRIMARY KEY,
167    data   TEXT   NOT NULL,
168    format TEXT   NOT NULL,
169    FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
170);
171
172CREATE TABLE IF NOT EXISTS objectiveai.message_queue_videos (
173    id  BIGINT PRIMARY KEY,
174    url TEXT   NOT NULL,
175    FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
176);
177
178CREATE TABLE IF NOT EXISTS objectiveai.message_queue_files (
179    id        BIGINT PRIMARY KEY,
180    file_data TEXT,
181    file_id   TEXT,
182    filename  TEXT,
183    file_url  TEXT,
184    FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
185);
186
187CREATE TABLE IF NOT EXISTS objectiveai.schedules (
188    id                       BIGSERIAL PRIMARY KEY,
189    name                     TEXT   NOT NULL,
190    command                  TEXT   NOT NULL,
191    description              TEXT   NOT NULL,
192    agent_instance_hierarchy TEXT   NOT NULL,
193    interval_seconds         BIGINT
194        CHECK (interval_seconds IS NULL OR interval_seconds >= 0),
195    agent_arguments          TEXT   NOT NULL,
196    -- Versions are separate rows: `tasks schedule --overwrite` INSERTS
197    -- a new row with version = max+1 that shadows the older versions
198    -- of the same (name, aih). Shadowed rows never run again and never
199    -- list, but stay on disk so `tasks_runs` history is per-version.
200    -- Rows are NEVER deleted.
201    version                  BIGINT NOT NULL DEFAULT 1,
202    -- Plugin coordinate of the plugin that registered this schedule,
203    -- captured from `ctx.plugin` at schedule time. All three NULL when
204    -- not scheduled by a plugin; all three set when it was (enforced by
205    -- the CHECK below).
206    plugin_owner             TEXT,
207    plugin_repository        TEXT,
208    plugin_version           TEXT,
209    created_at               BIGINT NOT NULL,
210    -- A schedule name is unique per agent instance hierarchy and
211    -- version — two different agents may reuse the same name, and one
212    -- agent's schedule keeps every version it ever had as rows.
213    UNIQUE (name, agent_instance_hierarchy, version),
214    CHECK (
215        (plugin_owner IS NULL AND plugin_repository IS NULL AND plugin_version IS NULL)
216        OR
217        (plugin_owner IS NOT NULL AND plugin_repository IS NOT NULL AND plugin_version IS NOT NULL)
218    )
219);
220
221-- One row per schedule firing. A oneshot is exhausted the moment it
222-- has a run; a recurring row's readiness keys off its newest run.
223-- Written atomically by the same claim query that selects the tasks
224-- `tasks run` fires, so concurrent runners never double-claim.
225CREATE TABLE IF NOT EXISTS objectiveai.tasks_runs (
226    id          BIGSERIAL PRIMARY KEY,
227    schedule_id BIGINT NOT NULL REFERENCES objectiveai.schedules(id),
228    ran_at      BIGINT NOT NULL
229);
230CREATE INDEX IF NOT EXISTS tasks_runs_schedule_idx
231    ON objectiveai.tasks_runs(schedule_id, ran_at DESC);
232
233-- One row per item a fired task emitted on `tasks run`'s stream,
234-- serialized exactly as the wire envelope, linked to its run.
235CREATE TABLE IF NOT EXISTS objectiveai.tasks_logs (
236    id         BIGSERIAL PRIMARY KEY,
237    run_id     BIGINT NOT NULL REFERENCES objectiveai.tasks_runs(id),
238    value      TEXT   NOT NULL,
239    created_at BIGINT NOT NULL
240);
241CREATE INDEX IF NOT EXISTS tasks_logs_run_idx
242    ON objectiveai.tasks_logs(run_id, id);
243
244-- AFTER-UPDATE trigger on `message_queue.active`: every soft-flip
245-- (TRUE → FALSE) emits a `NOTIFY message_queue_inactive '<id>'`
246-- so the cli's `db::message_queue::subscribe_delivered` listener
247-- wakes up the instant a consumption flip lands. Pure native
248-- LISTEN/NOTIFY — no polling. We no longer hard-delete, so the
249-- prior AFTER DELETE trigger is gone.
250CREATE OR REPLACE FUNCTION objectiveai.notify_message_queue_inactive()
251RETURNS trigger AS $$
252BEGIN
253    IF OLD.active = TRUE AND NEW.active = FALSE THEN
254        PERFORM pg_notify('message_queue_inactive', NEW.id::text);
255    END IF;
256    RETURN NEW;
257END;
258$$ LANGUAGE plpgsql;
259CREATE OR REPLACE TRIGGER message_queue_inactive_notify
260AFTER UPDATE OF active ON objectiveai.message_queue
261FOR EACH ROW EXECUTE FUNCTION objectiveai.notify_message_queue_inactive();
262
263"#;
264
265/// `logs.*` schema. Pulled from `src/db/logs/schema.sql` so the
266/// canonical definitions live in a real .sql file (readable by
267/// tooling, syntax-highlighted by editors) instead of as a string
268/// constant baked into Rust source.
269const LOGS_SCHEMA: &str = include_str!("logs/schema.sql");
270
271/// The shared readonly group every plugin/tool compartment role
272/// joins (see [`super::compartment`]): USAGE + SELECT over the base
273/// `objectiveai` schema, with default privileges so tables the base
274/// schema grows LATER are covered automatically. Applied inside the
275/// same advisory-locked schema-apply step as the table DDL;
276/// `CREATE ROLE` has no `IF NOT EXISTS`, hence the DO block (roles
277/// are cluster-wide — a sibling database in the same cluster may
278/// have created it already; the GRANTs are per-database and re-run
279/// for each).
280const READER_GROUP: &str = r#"
281DO $$
282BEGIN
283    CREATE ROLE objectiveai_read NOLOGIN;
284EXCEPTION WHEN duplicate_object THEN
285    NULL;
286END
287$$;
288GRANT USAGE ON SCHEMA objectiveai TO objectiveai_read;
289GRANT SELECT ON ALL TABLES IN SCHEMA objectiveai TO objectiveai_read;
290ALTER DEFAULT PRIVILEGES IN SCHEMA objectiveai GRANT SELECT ON TABLES TO objectiveai_read;
291"#;
292
293/// Open the admin pool against `url` (no database path — lands on the
294/// connecting user's default database, `postgres`), ensure `database`
295/// exists, then open the application pool and apply the inline
296/// schema. Idempotent across cold and warm starts — re-running
297/// against an already-bootstrapped database is a no-op (every CREATE
298/// uses `IF NOT EXISTS`).
299pub async fn init(url: &str, database: &str) -> Result<Pool, Error> {
300    let app_url = format!(
301        "{}/{}",
302        url.trim_end_matches('/'),
303        percent_encoding::utf8_percent_encode(database, percent_encoding::NON_ALPHANUMERIC),
304    );
305
306    // 1. Admin pool: ensure the application database exists.
307    //    `CREATE DATABASE` cannot run inside a transaction, so we use
308    //    a fresh single-connection pool just for this check + insert.
309    let admin = PgPoolOptions::new()
310        .max_connections(1)
311        .connect(url)
312        .await
313        .map_err(|e| unreachable_hint(url, e))?;
314    let exists: bool = {
315        let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)")
316            .bind(database)
317            .fetch_one(&admin)
318            .await?;
319        row.try_get::<bool, _>(0)?
320    };
321    if !exists {
322        // `CREATE DATABASE` can't be parameterised; the name comes
323        // from config, so quote it as an identifier (double-quote
324        // wrapping with embedded quotes doubled) to keep arbitrary
325        // names safe.
326        //
327        // Race: two concurrent cli processes can both observe
328        // `exists = false` and race the CREATE. The second to
329        // commit gets SQLSTATE 42P04 (`duplicate_database`); swallow
330        // that exact code (any other error still propagates).
331        let quoted = database.replace('"', "\"\"");
332        match admin
333            .execute(format!("CREATE DATABASE \"{quoted}\"").as_str())
334            .await
335        {
336            Ok(_) => {}
337            // 42P04 = `duplicate_database` (the high-level check
338            // postgres fires when it sees an existing matching
339            // datname row in pg_database).
340            // 23505 = `unique_violation` on `pg_database_datname_index`
341            // (the low-level catalog insert losing the race).
342            // Either way, the database exists now; continue.
343            Err(sqlx::Error::Database(db))
344                if matches!(db.code().as_deref(), Some("42P04") | Some("23505")) => {}
345            Err(e) => return Err(e.into()),
346        }
347    }
348    admin.close().await;
349
350    // 2. App pool: connect to the just-ensured database, apply schema
351    //    inside one transaction.
352    //
353    //    Concurrency: `CREATE … IF NOT EXISTS` is not atomic across
354    //    parallel sessions (two can both see "doesn't exist" and
355    //    both try to insert into `pg_class`; the loser gets 23505
356    //    on `pg_class_relname_nsp_index` or a deadlock against
357    //    another session writing the same catalog row).
358    //    Serialize the schema-apply step behind a session-level
359    //    advisory lock so only one process at a time runs it; the
360    //    `IF NOT EXISTS` clauses then make every subsequent run a
361    //    no-op.
362    let pool = PgPoolOptions::new()
363        .max_connections(8)
364        .connect(&app_url)
365        .await
366        .map_err(|e| unreachable_hint(&app_url, e))?;
367    {
368        // Arbitrary 64-bit constant; the pair `(database, key)`
369        // defines the lock identity, so as long as every process
370        // uses the same key against the same db they serialize on
371        // schema-apply.
372        const SCHEMA_LOCK_KEY: i64 = 0x0B7EC71AE_15CBE_AA_i64;
373        let mut conn = pool.acquire().await?;
374        sqlx::query("SELECT pg_advisory_lock($1)")
375            .bind(SCHEMA_LOCK_KEY)
376            .execute(&mut *conn)
377            .await?;
378        let apply_result: Result<(), Error> = async {
379            conn.execute(SCHEMA).await?;
380            conn.execute(LOGS_SCHEMA).await?;
381            conn.execute(READER_GROUP).await?;
382            Ok(())
383        }
384        .await;
385        // Best-effort release; if the connection died the lock
386        // releases on session end anyway.
387        let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
388            .bind(SCHEMA_LOCK_KEY)
389            .execute(&mut *conn)
390            .await;
391        apply_result?;
392    }
393
394    Ok(Pool(pool))
395}
396
397/// Compose the base connect URL (no database path) for a remote
398/// postgres configured via `config db` — used by `Context::db_client`
399/// when `db.address` is set, in place of the spawn lock's URL. The
400/// address may embed a port (`host:5432`); a bare host gets postgres's
401/// default port. User and password are percent-encoded so arbitrary
402/// config values can't break the URL shape.
403pub(crate) fn config_url(address: &str, user: &str, password: &str) -> String {
404    let user =
405        percent_encoding::utf8_percent_encode(user, percent_encoding::NON_ALPHANUMERIC);
406    let password =
407        percent_encoding::utf8_percent_encode(password, percent_encoding::NON_ALPHANUMERIC);
408    format!("postgres://{user}:{password}@{address}")
409}
410
411/// Map a pool-connect failure to the actionable error: the database
412/// at the resolved URL isn't reachable, and the fix is either
413/// `objectiveai db spawn` (local objectiveai-db) or `db config
414/// address` (remote postgres). Non-connect errors (auth failures,
415/// TLS, ...) get the same wrapper — the remedy hint is still the
416/// right one.
417fn unreachable_hint(url: &str, source: sqlx::Error) -> Error {
418    Error::DbUnreachable {
419        url: redact_url_password(url),
420        source: Box::new(source),
421    }
422}
423
424/// `scheme://user:password@rest` → `scheme://user:***@rest`, so the
425/// connect URL can ride in an error message without leaking the
426/// password. Returned unchanged when there is no userinfo or no
427/// password segment.
428fn redact_url_password(url: &str) -> String {
429    let Some(scheme_end) = url.find("://") else {
430        return url.to_string();
431    };
432    let rest = &url[scheme_end + 3..];
433    let authority = &rest[..rest.find('/').unwrap_or(rest.len())];
434    let Some(at) = authority.rfind('@') else {
435        return url.to_string();
436    };
437    let userinfo = &authority[..at];
438    let Some(colon) = userinfo.find(':') else {
439        return url.to_string();
440    };
441    format!(
442        "{}:***{}",
443        &url[..scheme_end + 3 + colon],
444        &url[scheme_end + 3 + at..],
445    )
446}
447
448#[cfg(test)]
449mod tests {
450    use super::redact_url_password;
451
452    #[test]
453    fn redact_url_password_cases() {
454        assert_eq!(
455            redact_url_password("postgresql://postgres:s3cr%40t@127.0.0.1:5432"),
456            "postgresql://postgres:***@127.0.0.1:5432",
457        );
458        assert_eq!(
459            redact_url_password("postgres://u:p@h:5432/objectiveai"),
460            "postgres://u:***@h:5432/objectiveai",
461        );
462        // No password segment in the userinfo.
463        assert_eq!(
464            redact_url_password("postgres://postgres@127.0.0.1:5432"),
465            "postgres://postgres@127.0.0.1:5432",
466        );
467        // No userinfo at all.
468        assert_eq!(
469            redact_url_password("postgres://127.0.0.1:5432/db"),
470            "postgres://127.0.0.1:5432/db",
471        );
472        // Not even a scheme.
473        assert_eq!(redact_url_password("127.0.0.1:5432"), "127.0.0.1:5432");
474    }
475}