Rindle docs and package mapSkip to main content

rindle_replica/
lib.rs

1//! # Embedded SQLite with incremental live queries
2//!
3//! [`Db`] provides one controlled SQL writer, ordinary SQL reads, and live queries
4//! over file-backed SQLite. It captures row changes automatically and derives query
5//! deltas through the `rindle` engine. Use it when an application owns its database
6//! and needs live results without implementing change capture or transaction ordering.
7//!
8//! [`Cluster`] distributes queries across worker threads. Its channel exposes
9//! provisional deltas and commit progress; [`ClusterConsumer`] owns the drain loop
10//! and adds normalized subscriptions. The raw `rindle` crate is a lower-level choice
11//! for applications that already own source storage and change capture.
12//!
13//! ## Transactions and delivery
14//!
15//! Every row write must pass through the controlled writer. The preupdate hook
16//! captures changes, and the engine derives against a read-only pre-commit snapshot
17//! plus an in-memory batch overlay. Derivation does not write SQL or replay triggers.
18//!
19//! [`Db`] derives before commit, commits the durable data, then calls subscribers
20//! synchronously. [`Cluster`] workers can emit `Changed` slices before commit; callers
21//! must stage them until the hosting worker emits [`ClusterEvent::Progressed`].
22//! See [`Update`] and [`ClusterEvent`] for recovery and delivery boundaries.
23//!
24//! Query handles expose changes rather than maintaining an application view. Apply
25//! changes in order, or use a higher-level consumer. Subscribe immediately after
26//! registration and call [`Query::destroy`] when finished; dropping a handle does not
27//! unregister it. `Db` and the `Cluster` coordinator are `!Send` and stay on one thread.
28//!
29//! ## Storage and schema
30//!
31//! Fresh embedded databases use ordinary WAL. Existing WAL/WAL2 files retain their
32//! mode; [`OpenOptions`] selects a journal explicitly. Foreign keys are enforced by
33//! default. Register every user table that a transaction can write, including cascade
34//! targets. Captured foreign-key cascades become ordinary row changes.
35//!
36//! Triggers remain unsupported on registered tables, and generated columns are
37//! rejected. Use the schema DDL methods before registration. To change a registered
38//! table's shape, close the runtime, migrate the database, then reopen and register it.
39//!
40//! Number-domain INTEGER cells must round-trip exactly through `f64`; admission and
41//! capture reject values that would round. The storage layer can preserve exact
42//! `BIGINT`/`INT64` cells, but maintained queries reject a required exact-integer
43//! column. See the [schema guide](https://rindle.sh/docs/schema) for surface limits.
44//!
45//! ```no_run
46//! use rindle::table;
47//! use rindle_replica::{Db, QueryId};
48//!
49//! # fn main() -> Result<(), rindle_replica::ReplicaError> {
50//! let db = Db::open("app.db")?;
51//! db.exec_ddl("CREATE TABLE IF NOT EXISTS issue (id INTEGER PRIMARY KEY, title TEXT NOT NULL)")?;
52//! db.register_table("issue")?;
53//! let query = db.query(QueryId(1), table("issue").build())?;
54//! query.subscribe(|update| println!("{update:?}"));
55//!
56//! let mut tx = db.write()?;
57//! tx.exec("INSERT INTO issue (title) VALUES ('First live query')", &[])?;
58//! tx.commit()?; // subscribers run after the SQL commit
59//! let count: i64 = db.read(|conn| conn.query_row("SELECT count(*) FROM issue", [], |row| row.get(0)))?;
60//! assert!(count >= 1);
61//! query.destroy();
62//! # Ok(()) }
63//! ```
64
65use std::cell::Cell;
66use std::cell::RefCell;
67use std::collections::HashMap;
68use std::path::Path;
69use std::rc::Rc;
70
71use rindle::Ast;
72use rusqlite::{Connection, OptionalExtension};
73
74mod analyze;
75mod cluster;
76mod consumer;
77mod derivation_pool;
78mod drain;
79mod engine;
80mod initial_snapshot;
81mod maintenance;
82mod mutations;
83mod parallel;
84mod progress;
85mod query;
86mod schema;
87// Local, not re-exported: it adds the engine-coupled assembled-snapshot rendering to the write
88// plane's codec. See the module doc.
89pub mod wire_json;
90mod writer;
91
92// The normalized fold + protocol envelope moved to the wasm-clean `rindle-wire`
93// (RINDLE-REALTIME-DESIGN.md §2.4) so the wasm room shares them. These module
94// re-imports keep every internal `crate::normalize{,_protocol}::` path working, and the
95// `pub use` blocks below keep the public surface byte-identical.
96pub(crate) use rindle_wire::{normalize, normalize_protocol};
97
98// The engine-free CDC apply plane moved to its own leaf crate (design 309): the store
99// half of the cluster (connections + capture + the write-txn state machine + DDL), the
100// CommitFanout seam the worker pool implements, and the headless ApplyConsumer that
101// `rindle-backup-sqlite` (and any external stream consumer) drives without booting
102// engines. This alias keeps every internal `crate::apply::` path working; the moved
103// public items re-export at their old paths below and in `cluster`/`consumer`/
104// `writer`/`maintenance`/`parallel`.
105pub(crate) use rindle_cdc_apply as apply;
106
107pub use analyze::{
108    AnalyzeJoin, AnalyzePlan, AnalyzeReport, AnalyzeSource, AnalyzeTiming, AnalyzeTotals,
109};
110pub use cluster::{Cluster, ClusterWriteTxn, DdlMigrationError};
111pub use consumer::{
112    ClusterConsumer, ClusterMutationWrite, RoomFlushOutcome, SharedQueryInfo, SourceHead,
113    APPLIED_DDL_TABLE,
114};
115// The base-table shape vocabulary moved to `table_shape` (write-plane extraction step 1); these
116// keep `rindle_replica::{ColType, Mutation, create_table_ddl, ...}` byte-identical for every caller.
117pub use drain::{ConnId, Drain, DrainHandle, DrainSink, SubId};
118pub use rindle_writeplane::ReplicaError;
119// The write plane's own modules, re-exported at their old paths: `rindle_replica::session`,
120// `::sql` and `::writeplane` are what the daemon, the master and the wire suites import, and they
121// must not move. `::wire_json` is a local module that re-exports the write plane's codec whole and
122// adds the engine-coupled assembled-snapshot rendering on top, so that import path is unchanged
123// too. `table_shape` was private here and stays crate-internal.
124pub use initial_snapshot::{
125    InitialSnapshotColumn, InitialSnapshotCommit, InitialSnapshotIdentity, InitialSnapshotOpen,
126    InitialSnapshotStore, InitialSnapshotTable, StoredSourceCheckpoint, CDC_BOOTSTRAP_TABLE,
127};
128pub use maintenance::{MaintenanceOptions, MaintenanceReport, WalCheckpoint};
129pub use mutations::{
130    MutationEnvelope, MutationOutcome, MutationReject, MutationSql, MutatorRegistry, ServerMutator,
131};
132pub use progress::ProgressTracker;
133pub use query::Query;
134/// A parameterized query family's identity and template (design 310 §3), re-exported from
135/// `rindle-wire` so a daemon groups subscriptions with the same types the engine binds on.
136pub use rindle_wire::family_key::{
137    extract_family, Binding, FamilyExtraction, FamilyKey, FamilyTemplate,
138};
139pub use rindle_wire::normalize::{
140    agg_table_name, agg_table_schemas, rewrite_aggregates, rewrite_aggregates_with_local,
141    table_tree, AggTable, NormalizeFold, NormalizedOp, PkMap, TableNode,
142};
143pub use rindle_wire::normalize_protocol::{
144    build_query_parts, hello_from_parts, normalized_fp, unsafe_int_in_normalized_batch,
145    NormalizedApplied, NormalizedBatch, NormalizedHello, NormalizedProtocolError,
146    NormalizedPublisher, NormalizedSubscriber, ProgressFrame, ProjMap, TableWireSchema,
147};
148pub(crate) use rindle_writeplane::table_shape;
149pub use rindle_writeplane::{
150    build_mutation_sql, create_table_ddl, is_internal_schema_name, BaseColumn, BaseTableSchema,
151    ColType, Mutation, ReplicatedColumn, ReplicatedTableSchema, TableMeta,
152};
153pub use rindle_writeplane::{
154    canonical_migration_checksum, classify_statement, decode_wire_value, encode_wire_value,
155    install_public_authorizer, normalize_migration_statements, run_ddl_statement, run_statement,
156    split_migration_file, split_sql_script, split_sql_script_allow_empty, sql_cell_to_owned,
157    statement_is_insert, PublicAuthorizerGuard, SqlArgs, SqlColumn, SqlStatementRequest,
158    StatementClass, StatementResult, StatementRunError, SQL_BIND_LIMIT, SQL_BIND_VALUE_BYTE_LIMIT,
159    SQL_RESULT_BYTE_LIMIT, SQL_RESULT_ROW_LIMIT, SQL_TEXT_LIMIT,
160};
161pub use rindle_writeplane::{
162    cas_cell_matches, RoomRowConflict, CLIENT_MUTATIONS_TABLE, OUTCOME_RETENTION_LMIDS,
163    ROOM_CLIENT_MUTATIONS_TABLE, ROOM_MUTATION_OUTCOMES_TABLE, ROOM_PLACEMENT_TABLE,
164    ROOM_WATERMARK_TABLE, SCOPE_SESSIONS_TABLE, SOURCE_OFFSETS_TABLE, SOURCE_OFFSET_WHOLE_RUN,
165};
166pub use rindle_writeplane::{
167    introspect_replicated_table, introspect_schema_envelope, validate_schema_envelope,
168    value_type_of, DdlActions, DdlApplyReport, DdlStep,
169};
170pub use rindle_writeplane::{schema_envelope, session, sql, writeplane};
171pub use rindle_writeplane::{QueryId, TxId};
172pub use rindle_writeplane::{
173    ReadConn, SqlReadRows, READ_ROW_CAP, READ_TIME_BUDGET, READ_VM_BUDGET,
174};
175pub use writer::{CommitInfo, WriteTxn};
176
177pub use engine::{
178    analyze_standalone, AnalyzeSpec, JoinPrecheckReport, OperatorStorage, StorageReport,
179};
180// `JournalMode` + the restore path moved to `rindle_writeplane::restore` (step 6): both are
181// `rusqlite` + `std::fs`, and keeping them here made every restore caller link the engine.
182pub use rindle_writeplane::{restore_bootstrap, restore_bootstrap_with_journal, JournalMode};
183// The foreign-key enforcement posture (and its audit) lives beside them for the same reason:
184// the write master and the restore paths choose one without linking the engine.
185pub use rindle_writeplane::{
186    foreign_key_check, set_foreign_keys, ForeignKeyAudit, ForeignKeyViolation, ForeignKeys,
187    FOREIGN_KEY_AUDIT_ROW_CAP,
188};
189
190// --- The embedded-engine seam (design 308) --------------------------------------
191// `rindle-sqlite-ext` — the SQL-native subscribe surface — drives the engine from a
192// commit hook it installs on a HOST-owned writer connection, so it needs the
193// building blocks the `Db`/`Cluster` openers wire together internally: the
194// single-thread engine, the multi-thread worker pool ([`DerivationPool`] — the
195// `Cluster` handshake for a host that owns its own writer + capture), schema
196// discovery (+ the PK unique index `TableSource` requires), and the journal-aware
197// connection opener. Narrow, documented re-exports for embedders that own their
198// write path — not a general API; prefer [`Db`]/[`Cluster`] unless you are one.
199pub use derivation_pool::{DerivationPool, PoolGate, PoolTxn};
200pub use engine::Engine;
201pub use parallel::open_journal;
202use query::{QueryEntry, RegId};
203use rindle_cdc::CaptureCtx;
204pub use schema::{discover as discover_table_schema, ensure_unique_pk_index, TableSchema};
205
206/// An owned, fully-materialized change event off the pipeline — the raw delta the
207/// engine emits (re-exported from the engine, where it is `CaughtChange`). Nested
208/// relationships are carried in [`NodeData::relationships`].
209pub use rindle::CaughtChange as ChangeEvent;
210/// An owned, fully-materialized node: a row plus its relationship subtrees (keyed by
211/// relationship slot). Re-exported from the engine (where it is `CaughtNode`).
212pub use rindle::CaughtNode as NodeData;
213
214/// A query baseline or incremental changes. A `Hydrated` contains the full result
215/// as `Add` events and replaces any previous baseline. `Changed` contains deltas to
216/// apply in order. Delivery depends on the runtime:
217///
218/// - [`Db`] calls subscribers with `Changed` after the SQL commit.
219/// - [`Cluster`] can emit several provisional `Changed` slices for one transaction,
220///   before commit. Stage them until the hosting worker's [`ClusterEvent::Progressed`].
221///
222/// `Hydrated` can fire AGAIN after a **delta-overflow shed** (design 306 D4): a
223/// transaction whose folded rows outgrow the derivation memory budget still
224/// commits, but it cannot be derived incrementally — the engine rebuilds and every
225/// query re-hydrates from the committed state. A re-delivered `Hydrated` REPLACES the
226/// subscriber's entire view state (drop what you hold, apply the `Add`s); it is the
227/// single-thread [`Db`]'s analogue of the cluster's `Faulted` → re-register →
228/// re-hydrate cycle, folded into the existing subscription.
229#[derive(Clone, Debug)]
230pub enum Update {
231    Hydrated {
232        tx_id: TxId,
233        changes: Vec<ChangeEvent>,
234    },
235    Changed {
236        tx_id: TxId,
237        changes: Vec<ChangeEvent>,
238    },
239    /// One **partition** of a parameterized query family hydrated (design 310 §5.2 / impl
240    /// plan D8): the initial `Add` set for `binding`, tagged with the committed watermark
241    /// it reflects — emitted once per bound partition (at family registration, and again
242    /// for every later bind) in place of the singleton's `Hydrated`. A family's later
243    /// `Changed` events carry every partition's deltas together; the consumer demuxes
244    /// them by the root row's partition key (the drain does this for the daemon).
245    PartitionHydrated {
246        tx_id: TxId,
247        binding: Binding,
248        changes: Vec<ChangeEvent>,
249    },
250}
251
252/// Events from a [`Cluster`]'s bounded channel. Registration emits a committed
253/// `Hydrated` baseline. Later `Changed` slices can arrive before the transaction
254/// commits. Buffer them until `Progressed` from the worker that hosts the query.
255/// For a combined result across workers, wait for every relevant worker.
256///
257/// `Faulted` is terminal. Discard provisional changes for that query and re-register
258/// for a fresh baseline. Derivation failures, worker loss, and rollback after
259/// speculative delivery can fault a query; no further updates follow for that registration.
260///
261/// `Faulted` lives here, not as an [`Update`] variant, because it is an out-of-band
262/// lifecycle signal, not an incremental data change — and the single-thread [`Db`]
263/// never produces it (its ordinary derive errors abort the transaction; a
264/// delta-overflow shed re-delivers `Update::Hydrated` instead).
265#[derive(Clone, Debug)]
266pub enum ClusterEvent {
267    /// A query's incremental update.
268    Update { query_id: QueryId, update: Update },
269    /// A query was destroyed after a derivation fault on its worker; `reason`
270    /// describes the underlying error and `cause` classifies it — a push-deadline
271    /// bail must survive to the host distinguishable from an ordinary derive fault
272    /// (FOLLOWER-LAG-SHED §6.6: it is shed trigger (b)). No further events arrive
273    /// for this query.
274    Faulted {
275        query_id: QueryId,
276        reason: String,
277        cause: FaultCause,
278    },
279    /// Transaction `tx_id` committed, and `worker` emitted all its `Changed` slices
280    /// before this marker on the same ordered channel. Receiving this event permits
281    /// release of that worker's buffered changes. It does not acknowledge application
282    /// callbacks or downstream transport delivery.
283    ///
284    /// A combined subscription waits for every relevant worker before advancing
285    /// `cv_min`. The marker also advances queries whose results did not change.
286    Progressed { worker: usize, tx_id: TxId },
287}
288
289/// Why a query faulted (`ClusterEvent::Faulted`) — the classification the host's mode
290/// machine consumes (FOLLOWER-LAG-SHED §6.6 item 3: today the error's identity died inside
291/// the worker as a bare `faulted = true`, making a deadline bail indistinguishable from an
292/// ordinary derive fault).
293#[derive(Clone, Copy, Debug, PartialEq, Eq)]
294pub enum FaultCause {
295    /// A derive raised an error (bad state, SQLite I/O, an internal panic) — the ordinary
296    /// fault; recovery is re-register + re-hydrate.
297    Derive,
298    /// A single push blew the host-armed deadline (`RindleError::PushDeadlineExceeded`) —
299    /// the runaway-push bail, FOLLOWER-LAG-SHED §6.1 trigger (b): the host should consult
300    /// its shed machinery BEFORE re-registering.
301    PushDeadline,
302    /// The worker thread itself was lost (died / unresponsive → detached + respawned);
303    /// every query on the shard faulted with it.
304    WorkerLost,
305}
306
307/// The shared single-thread state behind a [`Db`] handle. Held via `Rc` so cheap
308/// `Db` clones, query handles, and write handles all reference the same engine + conns.
309pub(crate) struct Inner {
310    /// The ONE read-write connection. Every durable mutation goes through here; the
311    /// preupdate hook observes them.
312    pub(crate) writer: Rc<Connection>,
313    /// A read-only connection exposed via [`Db::read`] for ad-hoc `SELECT`s.
314    pub(crate) reader: Rc<Connection>,
315    /// The per-thread IVM engine (one `Graph`, shared sources, the queries).
316    pub(crate) engine: RefCell<Engine>,
317    /// The preupdate-hook capture context (installed on `writer`). Observes every
318    /// row mutation on the writer connection and turns it into a `SourceChange`.
319    pub(crate) cdc: Rc<CaptureCtx>,
320    /// Per-query subscriptions, keyed by the stable [`RegId`] (NOT the engine's sink
321    /// `NodeId`, which a delta-overflow shed rebuild replaces; the current sink lives
322    /// inside each entry).
323    pub(crate) subs: RefCell<HashMap<RegId, QueryEntry>>,
324    /// The next [`RegId`] to hand out.
325    pub(crate) next_reg: Cell<u64>,
326    /// Every registered table's discovered schema, cached so a delta-overflow shed
327    /// (design 306 D4) can rebuild the engine with the sources re-registered — the
328    /// single-thread twin of the worker threads' `tables` cache.
329    pub(crate) tables: RefCell<Vec<(String, schema::TableSchema)>>,
330    /// The last durably-committed tx (resume / fencing). 0 = nothing committed yet.
331    pub(crate) committed_tx: Cell<u64>,
332    /// Single-writer guard: true while a `WriteTxn` is open.
333    pub(crate) in_write: Cell<bool>,
334}
335
336impl Drop for Inner {
337    fn drop(&mut self) {
338        // Remove the preupdate hook before the connection (and the capture context)
339        // are torn down, so a stray late callback can't deref freed memory.
340        rindle_cdc::uninstall(&self.writer);
341    }
342}
343
344/// Everything an opener can be configured with, in one struct-with-`Default` — so
345/// every combination (planner × operator storage × journal) is expressible without
346/// a ladder of positional `open_with_*` rungs. Taken by [`Db::open_with`],
347/// [`Cluster::open_with`](crate::Cluster::open_with), and
348/// [`ClusterConsumer::open_with`](crate::ClusterConsumer::open_with); the named
349/// rungs (`open`, `open_with_planning`, `open_with_journal`, …) are conveniences
350/// that fill one field each.
351///
352/// ```no_run
353/// # use rindle_replica::{Db, JournalMode, OpenOptions};
354/// // An explicitly-wal2 store with the defaults otherwise:
355/// let db = Db::open_with(
356///     "app.db",
357///     OpenOptions {
358///         journal: JournalMode::Wal2,
359///         ..OpenOptions::default()
360///     },
361/// ).unwrap();
362/// ```
363#[derive(Clone, Copy, Debug)]
364pub struct OpenOptions {
365    /// Run the cost-based join-flip planner at query registration (default **true**,
366    /// matching `open`). Result-preserving; the plan is frozen per registration.
367    pub plan_queries: bool,
368    /// Where stateful operators keep scratch state (default in-process memory).
369    pub operator_storage: OperatorStorage,
370    /// The journal mode a fresh (or explicitly-wal2) store gets (design 306 D5;
371    /// default [`JournalMode::Wal`]). [`JournalMode::Wal2`] is a requirement, not a
372    /// preference — the opener fails if the store does not end up in wal2.
373    pub journal: JournalMode,
374    /// `PRAGMA wal_autocheckpoint` for the **writer** connection, in pages (design 410
375    /// §3.5). `None` (the default) leaves SQLite's own default of 1000 pages — what
376    /// every server-side opener wants. `Some(0)` disables the automatic checkpoint
377    /// entirely, so the WAL folds back only on an explicit
378    /// [`maintain`](Db::maintain) pass: the embedded/mobile posture, where an
379    /// unpredictable checkpoint landing inside whichever commit happens to cross the
380    /// threshold is worse than a deliberate one at a moment the app picks (gesture end,
381    /// idle, backgrounding). Connection-local, so it must be re-passed at every open.
382    pub wal_autocheckpoint: Option<u32>,
383    /// Whether SQLite enforces declared foreign keys on this store's read-write
384    /// connections (default [`ForeignKeys::Enforced`], matching the vendored build's
385    /// compile-time default).
386    ///
387    /// [`ForeignKeys::Enforced`] is the **origin** posture — right for an embedded
388    /// `Db`, a directly-driven `Cluster`, and the standalone daemon's write plane,
389    /// where a violated constraint is the application's bug. An **apply** plane — a
390    /// follower replaying a master's journal — passes [`ForeignKeys::Unenforced`],
391    /// because those rows were validated upstream, arrive in the stream's order rather
392    /// than a topological one, and carry the authority's cascade effects as ordinary
393    /// row changes that must not be re-run locally. See
394    /// [`rindle_writeplane::foreign_keys`] for the full argument, and
395    /// [`Cluster::foreign_key_audit`](crate::Cluster::foreign_key_audit) for the audit
396    /// that proves referential integrity either way.
397    pub foreign_keys: ForeignKeys,
398}
399
400impl Default for OpenOptions {
401    fn default() -> OpenOptions {
402        OpenOptions {
403            plan_queries: true,
404            operator_storage: OperatorStorage::default(),
405            journal: JournalMode::default(),
406            wal_autocheckpoint: None,
407            foreign_keys: ForeignKeys::default(),
408        }
409    }
410}
411
412/// A single-thread SQLite runtime with live queries. Clones share the same writer,
413/// engine, and registrations through `Rc`; they do not create independent databases.
414/// This handle is `!Send`. Query and write handles keep the shared runtime alive.
415#[derive(Clone)]
416pub struct Db {
417    inner: Rc<Inner>,
418}
419
420impl Db {
421    /// Open a replica over a **file-backed** SQLite database (derivation needs WAL +
422    /// multiple connections, which an in-memory DB cannot provide).
423    ///
424    /// A fresh file gets ordinary `journal_mode = wal` (design 306 D5 — the file stays
425    /// openable by any stock SQLite build); an existing `wal` or `wal2` file keeps its
426    /// mode. Asserts `sqlite3_threadsafe() != 0`, failing loud rather than silently
427    /// degrading.
428    pub fn open(path: impl AsRef<Path>) -> Result<Db, ReplicaError> {
429        Self::open_with(path, OpenOptions::default())
430    }
431
432    /// Like [`open`](Self::open), but with explicit control over the cost-based join-flip
433    /// planner. When `plan_queries` is true, [`query`](Self::query) runs the planner (a
434    /// `SqliteCostModel` over the worker connection, cached) at registration to annotate
435    /// `flip` before lowering. Result-preserving; the plan is frozen per registration.
436    /// The planner is server-side (table-source) only and `open` enables it by default;
437    /// pass `false` here to opt out (e.g. to pin the unplanned path).
438    pub fn open_with_planning(
439        path: impl AsRef<Path>,
440        plan_queries: bool,
441    ) -> Result<Db, ReplicaError> {
442        Self::open_with(
443            path,
444            OpenOptions {
445                plan_queries,
446                ..OpenOptions::default()
447            },
448        )
449    }
450
451    /// Like [`open_with_planning`](Self::open_with_planning), but also selects the
452    /// operator-scratch-state backend. Pass [`OperatorStorage::SqliteSpill`] to spill
453    /// stateful-operator state (`take`/`cap`/`reduce`) to a private on-disk temp
454    /// database instead of keeping it in RAM. This reduces operator scratch memory;
455    /// it does not cap all memory used by the runtime or the application.
456    pub fn open_with_options(
457        path: impl AsRef<Path>,
458        plan_queries: bool,
459        operator_storage: OperatorStorage,
460    ) -> Result<Db, ReplicaError> {
461        Self::open_with(
462            path,
463            OpenOptions {
464                plan_queries,
465                operator_storage,
466                ..OpenOptions::default()
467            },
468        )
469    }
470
471    /// The full-combination opener: every [`OpenOptions`] field is honored, so any
472    /// planner × operator-storage × journal combination is one call — the named
473    /// rungs above are conveniences over this.
474    pub fn open_with(path: impl AsRef<Path>, opts: OpenOptions) -> Result<Db, ReplicaError> {
475        let OpenOptions {
476            plan_queries,
477            operator_storage,
478            journal,
479            wal_autocheckpoint,
480            foreign_keys,
481        } = opts;
482        // The engine confines a connection to one thread, but the C library must still
483        // be compiled threadsafe (serialized) for one-connection-per-thread to be sound.
484        if unsafe { rusqlite::ffi::sqlite3_threadsafe() } == 0 {
485            return Err(ReplicaError::NotThreadsafe);
486        }
487
488        let path = path.as_ref();
489        // Shared per-connection setup (the journal mode + maintenance/hardening pragmas)
490        // lives in `parallel`; the public read callback gets a physically read-only handle.
491        let open_one = || -> Result<Rc<Connection>, ReplicaError> {
492            crate::parallel::open_journal(path, journal, foreign_keys).map(Rc::new)
493        };
494
495        let writer = open_one()?;
496        // The WAL-growth knob, on the writer only (the sole connection that appends
497        // frames) and BEFORE the preupdate hook is installed, so the pragma cannot be
498        // observed as part of a captured transaction.
499        if let Some(pages) = wal_autocheckpoint {
500            crate::parallel::set_wal_autocheckpoint(&writer, pages)?;
501        }
502        let worker = open_one()?;
503        let reader = Rc::new(crate::parallel::open_journal_read_only(path)?);
504
505        // Our bookkeeping: a single-row watermark table carrying the committed tx id.
506        // Created before the hook is installed; its "__"-prefixed name is skipped by CDC.
507        writer
508            .execute_batch(
509                "CREATE TABLE IF NOT EXISTS __replica_meta (id INTEGER PRIMARY KEY, tx_id INTEGER NOT NULL)",
510            )
511            .map_err(|e| ReplicaError::sqlite("create __replica_meta", e))?;
512        let committed: u64 = writer
513            .query_row("SELECT tx_id FROM __replica_meta WHERE id = 0", [], |r| {
514                r.get::<_, i64>(0)
515            })
516            .optional()
517            .map_err(|e| ReplicaError::sqlite("read watermark", e))?
518            .map(|v| v as u64)
519            .unwrap_or(0);
520
521        // Observe every row mutation on the writer via the preupdate hook (raw FFI).
522        let cdc = CaptureCtx::new();
523        rindle_cdc::install(&writer, &cdc);
524
525        let engine = Engine::new(worker, plan_queries, operator_storage)?;
526        Ok(Db {
527            inner: Rc::new(Inner {
528                writer,
529                reader,
530                engine: RefCell::new(engine),
531                cdc,
532                subs: RefCell::new(HashMap::new()),
533                next_reg: Cell::new(0),
534                tables: RefCell::new(Vec::new()),
535                committed_tx: Cell::new(committed),
536                in_write: Cell::new(false),
537            }),
538        })
539    }
540
541    /// Run one **online maintenance pass** on the writer connection — see
542    /// [`Cluster::maintain`](crate::Cluster::maintain) for the full contract. Best-effort and
543    /// bounded; **skipped** (reporting `MaintenanceReport::skipped()`) while a write transaction
544    /// is open so it never disturbs in-flight work.
545    pub fn maintain(&self, opts: &MaintenanceOptions) -> Result<MaintenanceReport, ReplicaError> {
546        if self.inner.in_write.get() {
547            return Ok(MaintenanceReport::skipped());
548        }
549        maintenance::run(&self.inner.writer, opts)
550    }
551
552    /// Run a **full** `ANALYZE` on the writer to build deep planner statistics (`sqlite_stat4`) —
553    /// the single-thread parity for [`Cluster::analyze_full`](crate::Cluster::analyze_full). Call
554    /// once after a bulk load so the planner can seek (not scan) an `ORDER BY … LIMIT n` view's
555    /// displacement re-fetch (GitHub #68). Restores the bounded `analysis_limit` afterward.
556    /// Rejected while a write transaction is open.
557    pub fn analyze_full(&self) -> Result<(), ReplicaError> {
558        if self.inner.in_write.get() {
559            return Err(ReplicaError::Open(
560                "cannot run a full ANALYZE while a write transaction is open".into(),
561            ));
562        }
563        maintenance::analyze_full(&self.inner.writer)
564    }
565
566    /// Register a base table with the engine: discover its columns + primary key from
567    /// the schema, ensure the PK has the UNIQUE index `TableSource` requires, and build
568    /// the shared source. Idempotent per table. Rejects BLOB-typed columns and tables
569    /// without a primary key.
570    ///
571    /// The table must be **plain** — SQL triggers and generated columns are rejected (see the
572    /// crate-level "supported usage" docs). Foreign-key cascades are represented by the explicit
573    /// row deltas observed by the preupdate hook.
574    pub fn register_table(&self, table: &str) -> Result<(), ReplicaError> {
575        if self.inner.engine.borrow().has_source(table) {
576            return Ok(());
577        }
578        // Registering a NEW table while a write txn is open would run its
579        // `CREATE UNIQUE INDEX` inside the uncommitted transaction (invisible to the
580        // engine's separate worker connection until commit) — reject the nonsensical
581        // sequence rather than build a source against a not-yet-visible index.
582        if self.inner.in_write.get() {
583            return Err(ReplicaError::Open(
584                "cannot register a new table while a write transaction is open".into(),
585            ));
586        }
587        let ts = schema::discover(&self.inner.writer, table)?;
588        schema::ensure_unique_pk_index(&self.inner.writer, table, &ts)?;
589        // Teach the capture hook this table's column types (for leaf-matching coercion).
590        let col_types: Vec<_> = ts.columns.iter().map(|c| c.ty).collect();
591        self.inner
592            .cdc
593            .set_table_with_primary_key(table, &col_types, &ts.primary_key);
594        self.inner
595            .engine
596            .borrow_mut()
597            .register_table(table, ts.clone())?;
598        // Cache the schema AFTER a successful registration, so a delta-overflow shed can
599        // rebuild the engine with exactly the sources it had (the `has_source` guard
600        // above keeps this dup-free).
601        self.inner.tables.borrow_mut().push((table.to_string(), ts));
602        Ok(())
603    }
604
605    /// Register a live query from a Zero-wire [`Ast`] (build it with `rindle::table(..)`
606    /// or deserialize via [`Db::query_json`]) under the caller-supplied [`QueryId`] tag
607    /// (echoed via [`Query::id`]; the engine never interprets it — see [`QueryId`]).
608    /// Lowers the AST into the shared engine, hydrates it, and returns a [`Query`] handle
609    /// to subscribe to. `BuildError` (unknown table/column, unsupported shape) is
610    /// surfaced synchronously here.
611    ///
612    /// Each call builds its **own** pipeline — the engine does not de-duplicate, even
613    /// for an identical `query_id`/AST. A caller that wants to share one pipeline across
614    /// requesters dedups at its own layer (it holds the per-requester hydration state the
615    /// raw change stream does not). Subscribe before any later writes: the handle's
616    /// initial hydration is cached at registration, not kept current. Explicitly call
617    /// [`Query::destroy`] to unregister; dropping the handle does not stop the query.
618    pub fn query(&self, query_id: QueryId, ast: Ast) -> Result<Query, ReplicaError> {
619        let (sink, initial) = self
620            .inner
621            .engine
622            .borrow_mut()
623            .register_query(query_id, &ast)?;
624        let tx = self.inner.committed_tx.get();
625        let reg = RegId(self.inner.next_reg.get());
626        self.inner.next_reg.set(reg.0 + 1);
627        self.inner.subs.borrow_mut().insert(
628            reg,
629            QueryEntry {
630                sink,
631                query_id,
632                ast,
633                hydrated: initial,
634                hydrated_tx: tx,
635                callbacks: Vec::new(),
636            },
637        );
638        Ok(Query {
639            inner: self.inner.clone(),
640            reg,
641            query_id,
642        })
643    }
644
645    /// Convenience: parse a Zero-wire AST from JSON, then [`Db::query`] under `query_id`.
646    pub fn query_json(&self, query_id: QueryId, json: &str) -> Result<Query, ReplicaError> {
647        let ast: Ast = serde_json::from_str(json)
648            .map_err(|e| ReplicaError::Schema(format!("invalid AST JSON: {e}")))?;
649        self.query(query_id, ast)
650    }
651
652    /// Analyze `ast` COLD and report where its time and rows go
653    /// (`ANALYZE-QUERY-DESIGN.md`) — the single-thread parity for
654    /// [`Cluster::analyze_query`](crate::Cluster::analyze_query). Read-only: it builds and
655    /// drops a throwaway instrumented pipeline over a pinned read snapshot, registering no
656    /// materialization and leaving every live query untouched. `BuildError` (unknown
657    /// table/column, unsupported shape) surfaces as `Err`. Rejected while a write
658    /// transaction is open (its snapshot is not yet committed).
659    pub fn analyze_query(&self, ast: &Ast) -> Result<AnalyzeReport, ReplicaError> {
660        if self.inner.in_write.get() {
661            return Err(ReplicaError::Open(
662                "cannot analyze a query while a write transaction is open".into(),
663            ));
664        }
665        self.inner.engine.borrow().analyze_query(ast)
666    }
667
668    /// Tune the **derivation memory budget** (design 306 D4): the estimated bytes of
669    /// folded rows one transaction may hold — across every table it touches, not per
670    /// table — before its derivation gives up. The default is
671    /// [`rindle_sqlite::DEFAULT_MAX_DELTA_BYTES`] (256 MiB).
672    ///
673    /// Overflow is a **shed**, not a failure: the transaction still commits; the engine
674    /// rebuilds and every registered query re-hydrates from the committed state
675    /// (subscribers receive a fresh [`Update::Hydrated`] — see [`Update`]). Lower it to
676    /// bound derivation memory harder (shedding sooner); raise it to keep huge bulk
677    /// loads deriving incrementally at the cost of memory. Applies to every registered
678    /// table, now and later, and survives the shed rebuild itself.
679    ///
680    /// The accounting is an upper-bound estimate of the delta's own heap — row buffers
681    /// plus per-index bookkeeping — described at [`rindle_sqlite::DeltaBudget`]. It is
682    /// not a process RSS limit: SQLite's page cache, the query pipelines, and the
683    /// re-hydration this shed triggers are all outside it.
684    pub fn set_max_delta_bytes(&self, max_bytes: usize) {
685        self.inner.engine.borrow().set_max_delta_bytes(max_bytes);
686    }
687
688    /// Open the single-writer transaction. Run ordinary SQL through the returned
689    /// [`WriteTxn`]; `commit` derives + delivers each query's incremental events,
690    /// `rollback` (or drop) leaves every view untouched. Errors if a write txn is
691    /// already open (there is exactly one writer).
692    pub fn write(&self) -> Result<WriteTxn, ReplicaError> {
693        writer::begin(self.inner.clone())
694    }
695
696    /// The last durably-committed global tx id (0 if none yet).
697    pub fn committed_tx_id(&self) -> TxId {
698        TxId(self.inner.committed_tx.get())
699    }
700
701    /// Run SQL against a physically read-only connection using SQLite snapshot semantics.
702    /// This separate connection cannot see an open [`WriteTxn`]'s uncommitted writes.
703    /// `SQLITE_OPEN_READ_ONLY` prevents a callback from bypassing capture with writes.
704    /// Schema changes belong in [`Self::exec_ddl`], and row mutations in [`Self::write`].
705    pub fn read<T>(
706        &self,
707        f: impl FnOnce(&Connection) -> rusqlite::Result<T>,
708    ) -> Result<T, ReplicaError> {
709        f(&self.inner.reader).map_err(|e| ReplicaError::sqlite("read", e))
710    }
711
712    /// Walk every declared foreign key and report violating rows — the opt-in audit.
713    ///
714    /// `PRAGMA foreign_key_check` reports what is in the file, not what SQLite would have
715    /// refused, so it answers the same question whatever
716    /// [`OpenOptions::foreign_keys`] this replica was opened with. It is a full scan of
717    /// the referencing tables — a deliberate operation, never a per-commit step. Pass
718    /// [`FOREIGN_KEY_AUDIT_ROW_CAP`] unless you have a reason not to, or `0` for no cap.
719    pub fn foreign_key_audit(&self, max_rows: usize) -> Result<ForeignKeyAudit, ReplicaError> {
720        foreign_key_check(&self.inner.reader, max_rows)
721    }
722
723    /// The hierarchical view [`rindle::value::Schema`] `ast` materializes to over the registered
724    /// tables (shape + sort + `singular` + in-view relationships) — derived identically to
725    /// [`query`](Db::query). A layer that ships views to a remote (the flat-change/wire
726    /// schema + fingerprint) needs this without registering the query. `BuildError`
727    /// (unknown table/column) surfaces as `Err`.
728    pub fn view_schema(&self, ast: &Ast) -> Result<rindle::value::Schema, ReplicaError> {
729        self.inner.engine.borrow().view_schema(ast)
730    }
731
732    /// Fetch the registered query through its pipeline as a fresh set of hydration
733    /// `Add` events. This traverses current sources; it does not read a cached view
734    /// assembled from prior `Changed` events. It does not consume subscriber events.
735    /// An unregistered query yields an empty snapshot. Read failures return `Err`.
736    /// Use distinct query IDs when reading snapshots by ID.
737    pub fn read_snapshot(&self, query_id: QueryId) -> Result<Vec<ChangeEvent>, ReplicaError> {
738        Ok(self
739            .inner
740            .engine
741            .borrow()
742            .read_snapshot(query_id)?
743            .unwrap_or_default())
744    }
745
746    /// Run schema DDL (`CREATE TABLE …`) against the writer connection in autocommit —
747    /// the supported way to define the plain base tables you then [`Self::register_table`] and
748    /// write through (crate-level "supported usage"). Rejected while a write transaction is
749    /// open (DDL there would be invisible to the engine's separate worker until commit).
750    /// Accepts schema DDL (`CREATE`/`ALTER`/`DROP`/`REINDEX`) plus the historical bounded
751    /// `ANALYZE` maintenance call; row-changing statements are rejected. The complete batch and
752    /// schema-envelope validation commit atomically, and row-producing DDL is rejected too.
753    pub fn exec_ddl(&self, sql: &str) -> Result<(), ReplicaError> {
754        if self.inner.in_write.get() {
755            return Err(ReplicaError::Open(
756                "cannot run DDL while a write transaction is open".into(),
757            ));
758        }
759        schema::ensure_embedded_ddl_batch(sql)?;
760        schema::transactionally_apply_schema(
761            &self.inner.writer,
762            &self.inner.cdc,
763            "exec_ddl",
764            true,
765            false,
766            || {
767                self.inner
768                    .writer
769                    .execute_batch(sql)
770                    .map_err(|error| ReplicaError::sqlite("exec_ddl", error))
771            },
772        )
773    }
774}