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}