macrame/schema/ddl.rs
1//! DDL statements for the Macrame bitemporal schema as specified in §4.
2
3/// GLOB pattern matching the canonical timestamp form `YYYY-MM-DDTHH:MM:SS.ffffffZ`.
4///
5/// A macro rather than a `const` so it can be spliced into the DDL literals by
6/// `concat!`, which only accepts literals. Kept byte-identical to
7/// [`crate::util::timestamp::CANONICAL_TS_GLOB`] by the unit test at the bottom
8/// of this file — the storage-layer guard and the Rust-layer guard must agree
9/// or one of them is decorative.
10macro_rules! ts_glob {
11 () => {
12 "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z"
13 };
14}
15
16/// Table-level CHECK asserting every temporal column is canonical (§4.1, 0.5.4).
17///
18/// Timestamps are compared lexicographically everywhere — in SQL predicates, in
19/// `MAX(recorded_at)` when the clock recovers its floor, and in Rust `str`
20/// ordering. That is sound only if every value has the same width, so mixing
21/// `...T00:00:00Z` with `...T00:00:00.000000Z` makes `<=` disagree with
22/// chronology and traversals return empty sets with no error. The `Z` suffix
23/// alone does not achieve this; a fixed width does, and a CHECK is what makes
24/// it a property of the data rather than a convention.
25macro_rules! canonical_ts_check {
26 ($($col:literal),+ $(,)?) => {
27 concat!("CHECK (", $( $col, " GLOB '", ts_glob!(), "' AND ", )+ "1)")
28 };
29}
30
31/// A macro rather than a `const` for the same reason as [`ts_glob`]: `concat!`
32/// splices it into the table DDL and only accepts literals. [`WEIGHT_CHECK`] is
33/// the same text as a value, and carries the reasoning.
34macro_rules! weight_check {
35 () => {
36 "CHECK (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real')"
37 };
38}
39
40/// Table-level CHECK on `links.weight` (§4.7, T2.1, D-083).
41///
42/// Three clauses. Only the first is the one the item asked for; the other two
43/// were found by probing what the first still admits.
44///
45/// `weight >= 0.0` is the item as written: shortest-path analytics are unsound
46/// over negative weights, so Dijkstra and A\* refuse the graph at load time
47/// (D-039). Until now that refusal was the *only* place the property was
48/// enforced, which made it §4.7's one genuinely open gap — a database this crate
49/// wrote by itself could hold a row this crate would not read back.
50///
51/// `typeof(weight) = 'real'` closes a hole the item does not mention and which
52/// probing found. `REAL` in SQLite is an **affinity**, not a type: values that
53/// can be converted are, and values that cannot are stored as they came. `'abc'`
54/// cannot become a number, so it is stored as TEXT — and in SQLite's type
55/// ordering every text value sorts above every numeric one, so `'abc' >= 0.0`
56/// is *true* and the first clause passes it through.
57///
58/// That is not a wrong answer on the read side. It is a **panic**: reading a
59/// text `weight` as `f64` reaches `unreachable!("invalid value type")` inside
60/// libsql 0.9.30, in whatever unrelated query first touches the row. Measured,
61/// not reasoned about — see `examples/weight_check_probe.rs`.
62///
63/// The clause costs one `typeof` per insert and refuses nothing legitimate:
64/// `3`, `'5'` and `1.0` all arrive as REAL through affinity conversion and pass.
65/// It is taken **now** rather than in a later rung because SQLite has no
66/// `ADD CONSTRAINT` — every clause added later costs another full rebuild of the
67/// largest table in the schema.
68///
69/// `weight < 9e999` refuses `+∞`, and the reason is not the one anybody
70/// predicted. The plan expected the CHECK to admit infinity and argued the
71/// loader guard would catch it; the guard tests `< 0.0` and `is_nan()`, so it
72/// does not. The next guess — mine — was that this is harmless, since IEEE
73/// infinity propagates through addition and stays totally ordered, leaving
74/// Dijkstra terminating with "that edge is unusable": an odd answer, not a wrong
75/// one.
76///
77/// Both were wrong, and a test found it. **An infinite weight makes the
78/// transaction log unreplayable.** The log trigger serialises the row to JSON,
79/// and JSON has no representation for infinity, so the payload round-trips into
80/// `ReplayCorrupt { reason: "number out of range" }` — every later
81/// `reconstruct()` fails, including the one `close()` performs. The ledger is
82/// the source of truth under Doctrine III, so a value that cannot survive the
83/// log is not an eccentric weight, it is a corrupt one.
84///
85/// `9e999` is the idiom because SQLite has no `isinf`: the literal overflows to
86/// `+∞` on parse, and `inf < inf` is false. Finite values, including `1e308`,
87/// pass.
88///
89/// The loader guard still **stays**, for the reason the constraint cannot cover:
90/// `links_current` carries no CHECK, and neither do cold files created before
91/// this rung.
92pub const WEIGHT_CHECK: &str = weight_check!();
93
94/// The `RAISE(ABORT, …)` messages the schema's guards emit (§4.3).
95///
96/// Spliced into the trigger DDL *and* matched by [`crate::error::abort_kind`],
97/// so the guard and its classifier cannot drift. When they drift the failure is
98/// silent in the worst direction: the guard still fires, but the typed error
99/// (`SingleOpenViolation`, `RecordedAtRegression`, `ArchiveViolation`) degrades
100/// into an opaque `Engine` error that no caller can match on.
101macro_rules! abort_single_open {
102 () => {
103 "macrame: edge already has an open interval; retire it first"
104 };
105}
106macro_rules! abort_monotonic_ra {
107 () => {
108 "macrame: concept recorded_at must be strictly increasing"
109 };
110}
111macro_rules! abort_delete_guard {
112 () => {
113 "macrame: physical delete blocked outside archive session"
114 };
115}
116
117pub const ABORT_SINGLE_OPEN: &str = abort_single_open!();
118pub const ABORT_MONOTONIC_RA: &str = abort_monotonic_ra!();
119pub const ABORT_DELETE_GUARD: &str = abort_delete_guard!();
120
121/// Marker table probed by the delete guards (D-008 revised).
122///
123/// The archive session creates this table and drops it again inside the single
124/// `BEGIN IMMEDIATE … COMMIT` archive transaction, so it never exists as
125/// committed state. Connection-locality — the property the original
126/// `temp.sqlite_master` probe was reaching for — is preserved by two
127/// independent mechanisms: uncommitted DDL is visible only to the writing
128/// connection, and the archive transaction holds the write lock for its
129/// duration, so no other connection can reach the guard at all.
130pub const ARCHIVE_SESSION_MARKER: &str = "macrame_archive_session";
131
132/// The concepts insert log trigger, **marker-gated since v10** (0.9.0, C3).
133///
134/// # Why an archive session must not log a concept insert
135///
136/// Rehydration is a physical move back and mints no transaction-time facts
137/// (§2.3): the concept returns to the hot table, the log entries describing it
138/// were never removed, and nothing about what was believed — or when — has
139/// changed. An unconditional `AFTER INSERT` makes that impossible to honour,
140/// because the move *is* an insert.
141///
142/// **And the damage is worse than a spurious row, which is what forced the
143/// rung.** The rehydrated row carries its **original** `recorded_at`, but the
144/// log row it would write gets a **new** `seq_id` at the end of the log. The
145/// fold partitions by `(table_name, entity_id)` and takes
146/// `ROW_NUMBER() OVER (… ORDER BY seq_id DESC) = 1` — last writer wins by
147/// *sequence*, not by timestamp. So the rehydration `'I'` would outrank the
148/// later `'U'` that retired the concept, and every `reconstruct` after the
149/// original creation time would return it **un-retired**. Rehydration would
150/// resurrect a belief the ledger had superseded, silently and retroactively,
151/// which is precisely what [Doctrine III] forbids.
152///
153/// Only the *insert* trigger is gated. `trg_concepts_log_update` stays
154/// unconditional because nothing inside a session updates a concept — archival
155/// deletes and rehydration inserts — so gating it would suppress nothing and
156/// widen the hole for no reason.
157pub const CREATE_CONCEPTS_LOG_INSERT: &str = concat!(
158 r#"
159 CREATE TRIGGER IF NOT EXISTS trg_concepts_log_insert
160 AFTER INSERT ON concepts
161 WHEN NOT EXISTS (
162 SELECT 1 FROM sqlite_master
163 WHERE type = 'table' AND name = '"#,
164 "macrame_archive_session",
165 r#"'
166 )
167 BEGIN
168 INSERT INTO transaction_log (table_name, entity_id, operation, payload, recorded_at)
169 VALUES ('concepts', NEW.id, 'I',
170 json_object('v', 2, 'title', NEW.title, 'content', NEW.content,
171 'valid_from', NEW.valid_from, 'valid_to', NEW.valid_to,
172 'retired', NEW.retired,
173 'embedding_model', NEW.embedding_model),
174 NEW.recorded_at);
175 END;
176 "#
177);
178
179/// The concepts delete guard, **marker-gated since v9** (0.9.0, C2, D-126).
180///
181/// A `pub const` rather than an anonymous entry in [`CREATE_TRIGGERS`] because
182/// two readers need exactly this text: the baseline, which installs it on a new
183/// database, and the `v8 → v9` rung, which replaces the v8 body on an existing
184/// one. A second copy is a copy that drifts, and this trigger is the one whose
185/// body carries a doctrine decision.
186///
187/// # What changed, and why re-issuing the baseline could not do it
188///
189/// Through v8 this guard was **unconditional**: `BEFORE DELETE ON concepts`
190/// aborting every time, on the reasoning that concepts are never physically
191/// archived ([D-022](../../docs/architecture/s13-decision-register.md)). C2
192/// makes that false — a declared archive session may now move a retired,
193/// unreferenced concept to the cold file — so the guard takes the same shape its
194/// two siblings have had since 0.5.3: it fires **unless** the archive-session
195/// marker is present.
196///
197/// It needs a rung of its own, and that was measured rather than assumed
198/// (D-126). `CREATE TRIGGER IF NOT EXISTS` on an existing name keeps the **old
199/// body** — re-issuing the baseline against a v8 database leaves the
200/// unconditional guard exactly where it was — and `verify` compared `type` and
201/// `name` and never bodies, so the stale guard passed verification in silence.
202/// Both halves are now closed: the rung drops and recreates, and `verify`
203/// checks that every delete guard's body probes the marker.
204pub const CREATE_CONCEPTS_GUARD_DELETE: &str = concat!(
205 r#"
206 CREATE TRIGGER IF NOT EXISTS trg_concepts_guard_delete
207 BEFORE DELETE ON concepts
208 WHEN NOT EXISTS (
209 SELECT 1 FROM sqlite_master
210 WHERE type = 'table' AND name = '"#,
211 "macrame_archive_session",
212 r#"'
213 )
214 BEGIN
215 SELECT RAISE(ABORT, '"#,
216 abort_delete_guard!(),
217 r#"');
218 END;
219 "#
220);
221
222/// The `concepts` ledger table (§4.1).
223///
224/// # `rowid_pk` is explicit, and that is the whole point (v8, D-119)
225///
226/// Through v7 this table declared `id TEXT PRIMARY KEY`, which left its rowid
227/// **implicit** — and `concepts_fts` is external-content keyed on that rowid.
228/// `VACUUM` renumbers implicit rowids, which would silently decouple the search
229/// index from the rows it indexes: no error, no integrity-check failure, just
230/// results that stop matching.
231///
232/// [D-071](../../docs/architecture/s13-decision-register.md) proved the hazard
233/// unreachable *by consequence rather than by design* — `trg_concepts_guard_delete`
234/// is unconditional, so rowids are dense `1..n` and `VACUUM`'s renumbering is
235/// the identity map. 0.9.0's archival makes them sparse and makes the hazard
236/// real, so v8 replaces the accident with a column: an `INTEGER PRIMARY KEY` is
237/// a stored value, and `VACUUM` preserves it whether the numbering is dense or
238/// not (measured in `examples/concepts_rebuild_probe.rs` §5).
239///
240/// SQLite permits one primary key per table, so `id` becomes `NOT NULL UNIQUE`.
241/// That keeps it a valid foreign-key parent for `links.source_id` /
242/// `links.target_id` and keeps `ON CONFLICT(id)` working, but it **is** a
243/// primary-key change — which [D-036](../../docs/architecture/s13-decision-register.md)
244/// forbids outright after 1.0. Taken pre-1.0 on purpose, or never.
245pub const CREATE_CONCEPTS_TABLE: &str = concat!(
246 r#"
247CREATE TABLE IF NOT EXISTS concepts (
248 rowid_pk INTEGER PRIMARY KEY,
249 id TEXT NOT NULL UNIQUE,
250 title TEXT NOT NULL,
251 content TEXT NOT NULL DEFAULT '',
252 embedding_model TEXT,
253 valid_from TEXT NOT NULL,
254 valid_to TEXT NOT NULL DEFAULT '9999-12-31T23:59:59.999999Z',
255 recorded_at TEXT NOT NULL,
256 retired INTEGER NOT NULL DEFAULT 0,
257 "#,
258 canonical_ts_check!("valid_from", "valid_to", "recorded_at"),
259 r#"
260);
261"#
262);
263
264pub const CREATE_LINKS_TABLE: &str = concat!(
265 r#"
266CREATE TABLE IF NOT EXISTS links (
267 source_id TEXT NOT NULL REFERENCES concepts(id),
268 target_id TEXT NOT NULL REFERENCES concepts(id),
269 edge_type TEXT NOT NULL,
270 valid_from TEXT NOT NULL,
271 recorded_at TEXT NOT NULL,
272 valid_to TEXT NOT NULL DEFAULT '9999-12-31T23:59:59.999999Z',
273 weight REAL NOT NULL DEFAULT 1.0,
274 properties TEXT NOT NULL DEFAULT '{}',
275 PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at),
276 "#,
277 weight_check!(),
278 r#",
279 "#,
280 canonical_ts_check!("valid_from", "valid_to", "recorded_at"),
281 r#"
282);
283"#
284);
285
286pub const CREATE_LINKS_CURRENT_TABLE: &str = concat!(
287 r#"
288CREATE TABLE IF NOT EXISTS links_current (
289 source_id TEXT NOT NULL,
290 target_id TEXT NOT NULL,
291 edge_type TEXT NOT NULL,
292 valid_from TEXT NOT NULL,
293 valid_to TEXT NOT NULL,
294 weight REAL NOT NULL,
295 properties TEXT NOT NULL,
296 recorded_at TEXT NOT NULL,
297 PRIMARY KEY (source_id, target_id, edge_type, valid_from),
298 "#,
299 canonical_ts_check!("valid_from", "valid_to", "recorded_at"),
300 r#"
301);
302"#
303);
304
305pub const CREATE_TRANSACTION_LOG_TABLE: &str = concat!(
306 r#"
307CREATE TABLE IF NOT EXISTS transaction_log (
308 seq_id INTEGER PRIMARY KEY AUTOINCREMENT,
309 table_name TEXT NOT NULL,
310 entity_id TEXT NOT NULL,
311 operation TEXT NOT NULL,
312 payload TEXT NOT NULL,
313 recorded_at TEXT NOT NULL,
314 "#,
315 canonical_ts_check!("recorded_at"),
316 r#"
317);
318"#
319);
320
321/// The per-model embedding table (§4.1, D-005), for a validated model name.
322///
323/// A function rather than a `const` because the table's identity *and its
324/// column type* both depend on the model: `F32_BLOB(dim)` carries the declared
325/// dimension in the schema, which is what [`crate::vector::declared_dimension`]
326/// reads back so the crate never keeps a second copy of it.
327///
328/// Deliberately not part of the baseline migration. Which models exist is an
329/// application's choice made over time, not a property of the schema version,
330/// and D-036 classifies these tables as disposable periphery: a migration may
331/// drop one and re-embed. `IF NOT EXISTS` makes registration idempotent.
332///
333/// No temporal columns, on purpose. Doctrine VII makes an embedding a derived
334/// artifact of a model applied to content — it has no valid time of its own, and
335/// giving it a `recorded_at` would put a third clock next to the two §2 permits
336/// and invite queries that mix them.
337pub fn create_embeddings_table(model: &crate::vector::ModelName, dim: usize) -> String {
338 format!(
339 "CREATE TABLE IF NOT EXISTS {table} (
340 concept_id TEXT PRIMARY KEY REFERENCES concepts(id),
341 embedding F32_BLOB({dim}) NOT NULL
342);",
343 table = model.table(),
344 )
345}
346
347/// The DiskANN index over a model's vectors.
348///
349/// **Load-bearing for correctness, not only for speed.** Measured against
350/// libSQL 0.9.30: a blob of the wrong length inserted into an `F32_BLOB(4)`
351/// column is *accepted* while no vector index exists, and rejected — with the
352/// row not landing — once one does. §4.1 previously claimed the column type
353/// enforced its own dimension at insert time; it does not. So this index is
354/// created together with the table it indexes and is never optional, and
355/// dropping it to speed up a bulk load would silently disarm the only
356/// storage-layer check on dimension.
357pub fn create_embeddings_index(model: &crate::vector::ModelName) -> String {
358 format!(
359 "CREATE INDEX IF NOT EXISTS {index} ON {table} (libsql_vector_idx(embedding));",
360 index = model.index(),
361 table = model.table(),
362 )
363}
364
365/// Derived analytics output, keyed by concept and label (§5.4, D-041).
366///
367/// Deliberately outside the ledger. Three properties are load-bearing and each
368/// is the opposite of what the four normative tables above do.
369///
370/// **No log trigger.** Nothing in [`CREATE_TRIGGERS`] fires on this table, so an
371/// annotation never reaches `transaction_log`. That is Doctrine VII's reasoning
372/// about embeddings applied to the other derived artifact: a community label is
373/// a function of an algorithm, a version of that algorithm, and a graph — not a
374/// statement about the world, and a ledger that records it is recording the
375/// analytics schedule as though it were history. A reconstruction that wants
376/// labels recomputes them, which is the only honest way to ask what a past
377/// graph's communities *were*.
378///
379/// **No delete guard.** Doctrine V protects the hot ledger tables; this table is
380/// derivative state in Doctrine VI's second category, so wiping it must stay a
381/// legal, ordinary operation — a rerun replaces the previous pass, and dropping
382/// the whole table costs nothing but the recomputation.
383///
384/// **Upsert on `(concept_id, label)`.** One current value per label per concept.
385/// Storing a history of successive runs here would be the ledger again, by
386/// another name.
387///
388/// The foreign key is safe in a way `links_current`'s omitted ones are not:
389/// concepts are never physically deleted (D-022), and this table is rebuilt by
390/// re-running an algorithm that read `concepts` in the first place, so there is
391/// no insertion-order problem to solve.
392pub const CREATE_ANALYTICS_ANNOTATIONS_TABLE: &str = concat!(
393 r#"
394CREATE TABLE IF NOT EXISTS analytics_annotations (
395 concept_id TEXT NOT NULL REFERENCES concepts(id),
396 label TEXT NOT NULL,
397 value TEXT NOT NULL,
398 computed_at TEXT NOT NULL,
399 PRIMARY KEY (concept_id, label),
400 "#,
401 canonical_ts_check!("computed_at"),
402 r#"
403);
404"#
405);
406
407/// The keyword half of hybrid search: an FTS5 index over concept text (§5.9).
408///
409/// **External content.** The table declares `content='concepts'`, so the tokens
410/// are indexed but the text itself is not duplicated — FTS5 reads it back from
411/// `concepts` by rowid when it needs a column value. Two reasons beyond the
412/// storage saving, and the second is the one that decided it:
413///
414/// * There is exactly one copy of the text, so the index cannot disagree with
415/// the concept about what the concept says. A standalone FTS table would be a
416/// second description of data the ledger already holds, which is the failure
417/// class D-030 and D-035 exist to prevent.
418/// * `INSERT INTO concepts_fts(concepts_fts) VALUES('rebuild')` reconstructs the
419/// whole index from the content table in one statement. D-036 requires every
420/// derivative table to be rebuildable from the ledger, and here that is the
421/// engine's own operation rather than code of ours that has to be kept honest.
422///
423/// The cost is that external-content tables do not maintain themselves: an
424/// `UPDATE` must retract the *old* terms before adding the new ones, using the
425/// old column values. That is what `trg_concepts_fts_update` does, and getting
426/// it wrong leaves an index that still matches text no concept contains.
427///
428/// **`content_rowid` names `rowid_pk`, not `rowid` (v8, D-119).** They are the
429/// same value — an `INTEGER PRIMARY KEY` *is* the rowid — but naming the column
430/// is what makes the key a declared one rather than an implicit one `VACUUM` is
431/// free to renumber. See [`CREATE_CONCEPTS_TABLE`].
432pub const CREATE_CONCEPTS_FTS: &str = r#"
433CREATE VIRTUAL TABLE IF NOT EXISTS concepts_fts USING fts5(
434 title,
435 content,
436 content='concepts',
437 content_rowid='rowid_pk'
438);
439"#;
440
441/// FTS5's own consistency check — **and it cannot see the failure that matters**
442/// (§5.9, D-071).
443///
444/// Kept as a named constant so the finding has somewhere to live, and used by
445/// `an_emptied_fts_index_still_passes_integrity_check`, which is a tripwire
446/// rather than a guarantee.
447///
448/// On this libSQL build (0.9.30), `'integrity-check'` verifies the index's
449/// *internal* consistency and not its agreement with the content table. Measured:
450/// after `'delete-all'` the index answers zero matches where it answered ten, and
451/// both `'integrity-check'` and `'integrity-check', 0` still report success. So a
452/// `verify_fts()` built on this would report a healthy index for an empty one —
453/// which is why there is no `verify_fts()`. See D-071.
454pub const VERIFY_CONCEPTS_FTS: &str =
455 "INSERT INTO concepts_fts (concepts_fts) VALUES ('integrity-check');";
456
457/// Reconstruct the FTS index from `concepts` (§5.9, D-036).
458///
459/// The engine's own operation, so the rebuild path is not a second
460/// implementation of the triggers that could drift from them.
461pub const REBUILD_CONCEPTS_FTS: &str =
462 "INSERT INTO concepts_fts (concepts_fts) VALUES ('rebuild');";
463
464/// Refresh the query planner's statistics (0.12.4, D-149).
465///
466/// # Why this exists at all
467///
468/// Until 0.12.4 nothing in this crate ever ran `ANALYZE`, so `sqlite_stat1` did
469/// not exist in any database Macrame had created and **every plan was costed
470/// against SQLite's built-in defaults**: assume ~1M rows, assume each bound
471/// equality column divides the search by ten. That estimate is *structural* — a
472/// function of how many columns a query binds, not of what the table holds.
473///
474/// Which is a restatement of this schema's own worst recurring defect. From
475/// `tests/index_plan_tests.rs`: *"a covering index captures a query because it
476/// contains the columns, not because it discriminates."* D-042, D-059 and D-064
477/// are three instances of a planner doing the only thing available to it.
478/// [`CREATE_INDICES`] declares two indices that both lead on `source_id`, and
479/// with no statistics the planner separates them by column count alone.
480///
481/// # Bounded by construction
482///
483/// `ANALYZE` is a **write** — it writes `sqlite_stat1` and takes the write lock —
484/// so unbounded on a populated `links_current` it is exactly the kind of
485/// unbudgeted hold `CHUNK_BUDGET` exists to prevent. [`ANALYSIS_LIMIT`], set once
486/// per connection in `configure`, caps the rows examined per index and makes the
487/// cost a function of the index count rather than the table size. That is what
488/// lets this be scheduled as ordinary low-priority work.
489pub const ANALYZE: &str = "ANALYZE;";
490
491/// Re-analyse only what has gone stale (0.12.4, D-149).
492///
493/// SQLite tracks how much each table has changed since its last analysis and
494/// runs `ANALYZE` only where it believes the statistics no longer hold. A no-op
495/// when nothing has moved, which is what makes it safe to call on a schedule
496/// rather than only on demand.
497///
498/// Bounded by [`ANALYSIS_LIMIT`] like everything else on the connection.
499pub const OPTIMIZE: &str = "PRAGMA optimize;";
500
501/// The row cap that makes [`ANALYZE`] budgetable.
502///
503/// 400 is SQLite's own documented recommendation. It buys approximate statistics
504/// in roughly constant time instead of exact statistics in time proportional to
505/// the table — and approximate is emphatically enough here, because the decision
506/// being informed is *which of two indices discriminates*, not a cardinality
507/// estimate anyone reads.
508///
509/// Set on the connection rather than around each call, so it also bounds the
510/// analysis [`OPTIMIZE`] triggers internally. A limit that applied only to the
511/// explicit path would leave the scheduled one unbounded, which is the half that
512/// runs without anybody watching.
513///
514/// # Measured in 0.12.23: it is a constant factor, not a bound (D-166)
515///
516/// D-149 claimed this makes `ANALYZE`'s cost "a function of the index count
517/// rather than the table size". Measured on this schema — `examples/analyze_hold.rs`,
518/// which times the crate's own hold beside the same file analysed with the
519/// pragma off and on:
520///
521/// | edges | crate's hold | limit off | limit 400 |
522/// |---|---|---|---|
523/// | 10,000 | 5.26 ms | 18.4 ms | 6.01 ms |
524/// | 40,000 | 19.1 ms | 78.6 ms | 19.4 ms |
525///
526/// The pragma **is** in force — the crate's hold tracks the capped arm and not
527/// the uncapped one, which is how it is established at all, since the
528/// connection that runs `ANALYZE` is the actor's and no test can reach it. It
529/// is worth 3.1× at 10,000 edges and 4.1× at 40,000.
530///
531/// What it does not do is remove the table from the equation: over that 4×
532/// range the capped time grew 3.2×. So `analyze()` on a 40,000-edge ledger
533/// holds the write lock for ~19 ms, about 6× [`crate::CHUNK_BUDGET`], and
534/// [`crate::metrics::CommandKind::Analyze`] is **not** budget-exempt — it
535/// appears in `metrics().budget_violations()` and always had.
536pub const ANALYSIS_LIMIT: &str = "PRAGMA analysis_limit = 400";
537
538/// Every index the schema declares.
539///
540/// # Two entries left in v8, and why the list is now allowed to be short
541///
542/// `idx_annotations_label` and `idx_lc_tgt_active` were dropped by the v7 → v8
543/// rung ([D-089](../../docs/architecture/s13-decision-register.md), completed by
544/// D-118). Neither had a reader anywhere in the crate — `analytics_annotations`
545/// is never selected from here at all, and no query seeks on
546/// `links_current.target_id` as a leading column — so each was an index write
547/// per insert, forever, buying nothing. One of them was on the crate's hottest
548/// write path.
549///
550/// `tests/index_plan_tests.rs` now requires the unread set to be **empty**,
551/// which turns "these two are known bad" into "an index with no reader is a red
552/// test". That is the guarantee this list is kept short by.
553///
554/// # `idx_links_target` is not `idx_lc_tgt_active` coming back
555///
556/// The two look like the same index and are not, which is worth stating because
557/// the resemblance is the trap. `idx_lc_tgt_active` was `(target_id, valid_to)`
558/// on **`links_current`**, the materialized projection, and it was dropped
559/// because *nothing in the crate seeks on it* — no reader, pure write cost.
560/// `idx_links_target` is `(target_id)` on **`links`**, the ledger, and it exists
561/// because `CONCEPTS_ARCHIVABLE` seeks on exactly that column and the plan is
562/// measured before and after.
563///
564/// D-089's rule was never "no index on a target column". It was "an index needs
565/// a named query that seeks on it", and the registry is what enforces the
566/// difference rather than this paragraph.
567pub const CREATE_INDICES: &[&str] = &[
568 // Covering index for the traversal CTE (§5.2, D-042).
569 //
570 // Column order is load-bearing and was measured with EXPLAIN QUERY PLAN.
571 // The seek column is `source_id`; everything after it is there so the
572 // recursive step never touches the base table. The two range columns come
573 // next and `edge_type` comes *after* them, because `edge_types` is empty
574 // unless a caller sets it: with `edge_type` in second position SQLite
575 // declines the index for the unfiltered traversal — the default one — and
576 // silently falls back to a non-covering plan.
577 //
578 // (source_id, edge_type, valid_from, ...) filtered: COVERING
579 // unfiltered: NOT covering
580 // (source_id, valid_from, valid_to, weight, edge_type, target_id)
581 // both: COVERING
582 //
583 // This subsumes the former idx_lc_src_active (source_id, valid_to): same
584 // prefix column, strictly more payload. Keeping both would pay two index
585 // writes per assertion on a table that already takes three writes.
586 "CREATE INDEX IF NOT EXISTS idx_lc_traversal_cover ON links_current \
587 (source_id, valid_from, valid_to, weight, edge_type, target_id);",
588 // The single-open-interval probe's own index (D-059, shipped v5 -> v6).
589 //
590 // `trg_links_single_open` runs an `EXISTS` on every edge insert, keyed on
591 // (source_id, target_id, edge_type, valid_to) with valid_from as an
592 // inequality. Before this index the planner served that probe from
593 // `idx_lc_traversal_cover` with only `source_id` bound — it wins as a
594 // covering index over the primary-key autoindex, which lacks `valid_to` —
595 // so **every insert scanned its source's entire out-degree**. Measured on a
596 // fixed 90-row chunk: 4.4 ms into an empty table, 18.4 ms into a
597 // 2,000-edge hub, 47.7 ms into an 8,000-edge one, and 1.06 s into 90,000.
598 // Growth in the table, not in the chunk.
599 //
600 // With this index the same 90 rows into the 8,000-edge hub take 8.0 ms and
601 // stay flat. It matters beyond bulk import: the probe is on the insert path,
602 // so an interactive `assert_edge` against a high-degree node paid the same
603 // scan, and that is the path CHUNK_BUDGET's 3 ms exists to protect.
604 //
605 // Column order follows the trigger's WHERE exactly — the three equalities
606 // first, then `valid_to` which is compared to the sentinel, then
607 // `valid_from` which is the `<>` and cannot be a seek column. This does not
608 // subsume `idx_lc_traversal_cover` and is not subsumed by it: that one leads
609 // on `source_id` alone for the recursive walk, this one needs all three
610 // equality columns bound. Both are kept, which is a fourth index write per
611 // assertion buying a scan's removal from the same operation.
612 "CREATE INDEX IF NOT EXISTS idx_lc_open_interval ON links_current \
613 (source_id, target_id, edge_type, valid_to, valid_from);",
614 "CREATE INDEX IF NOT EXISTS idx_txlog_time ON transaction_log (recorded_at);",
615 "CREATE INDEX IF NOT EXISTS idx_txlog_entity ON transaction_log (entity_id);",
616 // The archive cutoff's seek column on the ledger table (0.12.6, W3.1,
617 // D-151, review §2.1, shipped v10 -> v11).
618 //
619 // `links` carried a primary key and nothing else. `LINKS_ARCHIVABLE` opens
620 // with `recorded_at < :cutoff`, and the primary key leads on `source_id`, so
621 // there was nothing for that bound to seek on: both the archiving SELECT and
622 // the archiving DELETE scanned the entire ledger. Measured on the
623 // populated-and-analysed fixture in `tests/index_plan_tests.rs`:
624 //
625 // before SCAN links | CORRELATED SCALAR SUBQUERY 1 | SEARCH newer ...
626 // after SEARCH links USING INDEX idx_links_recorded_at (recorded_at<?)
627 //
628 // The inner supersession probe was never the problem — it binds the whole
629 // primary-key prefix and always did.
630 //
631 // **This index is justified on those two queries and not on the clock
632 // floor.** Review §2.1 led with `recorded_at_floor`, the `MAX(recorded_at)`
633 // read on every `open()`, and counted it among the scans this would close.
634 // It is not one: SQLite already served the bare `MAX()` from the primary
635 // key's covering index without traversing the table, and after this index it
636 // does the same thing through a different covering index. The startup cost
637 // the review predicted did not exist, so the justification rests entirely on
638 // the archive path — see D-150 for how that was caught, and D-089 for why an
639 // index bought on a believed benefit is the failure mode being avoided.
640 "CREATE INDEX IF NOT EXISTS idx_links_recorded_at ON links (recorded_at);",
641 // The other half of the concept-archival reachability check (0.12.6, W3.2,
642 // D-151, review §2.2, shipped v10 -> v11).
643 //
644 // `CONCEPTS_ARCHIVABLE` asks whether any surviving link mentions a concept
645 // *in either direction*: `links.source_id = concepts.id OR links.target_id =
646 // concepts.id`. The primary key serves the left arm. Nothing served the
647 // right one, and an `OR` is only as seekable as its worst arm, so the whole
648 // correlated subquery degraded to a scan of `links` **once per candidate
649 // concept** — O(concepts x links).
650 //
651 // before CORRELATED SCALAR SUBQUERY 1
652 // | SCAN links USING COVERING INDEX sqlite_autoindex_links_1
653 // after CORRELATED SCALAR SUBQUERY 1 | MULTI-INDEX OR
654 // | INDEX 1 | SEARCH links USING COVERING INDEX
655 // sqlite_autoindex_links_1 (source_id=?)
656 // | INDEX 2 | SEARCH links USING INDEX
657 // idx_links_target (target_id=?)
658 //
659 // `MULTI-INDEX OR` is SQLite deciding to run both arms as seeks and union
660 // the rowids, which is exactly the plan the index was added to make
661 // available. Both the SELECT and the DELETE form pick it up.
662 //
663 // A single-column index on the ledger's hottest write path needs the
664 // strongest justification available, and it has one beyond the plan shape:
665 // *before* this index the planner was building `AUTOMATIC COVERING INDEX
666 // (target_id=?)` at query time to answer the same question. It had already
667 // concluded the index was worth having and was paying to construct a
668 // throwaway copy per statement.
669 "CREATE INDEX IF NOT EXISTS idx_links_target ON links (target_id);",
670];
671
672/// Every trigger the schema declares.
673///
674/// **`IF NOT EXISTS` means a changed body does not reach an existing file.**
675/// `migrations::verify` checks trigger *presence by name*, which is deliberate
676/// (a count refuses healthy databases) but does not and cannot notice that a
677/// trigger present under the right name carries an older body. A database
678/// stamped v5 by an earlier build therefore keeps whatever trigger text it was
679/// created with until a rung drops and recreates it.
680///
681/// This is why the payload carries a version. Changing a log trigger's payload
682/// splits the database population in two — files created after the change write
683/// the new shape, files created before keep writing the old one — and the only
684/// thing that makes that survivable is that every reader accepts both. A
685/// payload change that did *not* bump `v` would be indistinguishable at read
686/// time from corruption, which is the case `DbError::PayloadVersion` exists for.
687///
688/// The v1 → v2 concept payload (defect V) is deliberately left to ride along on
689/// the next rung that has to move `user_version` anyway rather than claiming one
690/// of its own: an old file loses `embedding_model` from its temporal reads, which
691/// is exactly the behaviour it had before, and gains it the moment it is
692/// migrated. Nothing regresses in the meantime.
693pub const CREATE_TRIGGERS: &[&str] = &[
694 r#"
695 CREATE TRIGGER IF NOT EXISTS trg_links_current_sync
696 AFTER INSERT ON links
697 BEGIN
698 INSERT INTO links_current
699 (source_id, target_id, edge_type, valid_from, valid_to,
700 weight, properties, recorded_at)
701 VALUES
702 (NEW.source_id, NEW.target_id, NEW.edge_type, NEW.valid_from,
703 NEW.valid_to, NEW.weight, NEW.properties, NEW.recorded_at)
704 ON CONFLICT(source_id, target_id, edge_type, valid_from) DO UPDATE SET
705 valid_to = excluded.valid_to,
706 weight = excluded.weight,
707 properties = excluded.properties,
708 recorded_at = excluded.recorded_at
709 WHERE excluded.recorded_at > links_current.recorded_at;
710 END;
711 "#,
712 concat!(
713 r#"
714 CREATE TRIGGER IF NOT EXISTS trg_links_single_open
715 BEFORE INSERT ON links
716 WHEN NEW.valid_to = '9999-12-31T23:59:59.999999Z'
717 AND EXISTS (
718 SELECT 1 FROM links_current
719 WHERE source_id = NEW.source_id
720 AND target_id = NEW.target_id
721 AND edge_type = NEW.edge_type
722 AND valid_from <> NEW.valid_from
723 AND valid_to = '9999-12-31T23:59:59.999999Z'
724 )
725 BEGIN
726 SELECT RAISE(ABORT, '"#,
727 abort_single_open!(),
728 r#"');
729 END;
730 "#
731 ),
732 concat!(
733 r#"
734 CREATE TRIGGER IF NOT EXISTS trg_concepts_monotonic_ra
735 BEFORE UPDATE ON concepts
736 WHEN NEW.recorded_at <= OLD.recorded_at
737 BEGIN
738 SELECT RAISE(ABORT, '"#,
739 abort_monotonic_ra!(),
740 r#"');
741 END;
742 "#
743 ),
744 // Payload v2 adds `embedding_model` (defect V). Before it, the field was
745 // written by nobody and read by two — `replay::fold_delta` and
746 // `as_of::hydrate_attributes` both asked the payload for it and both always
747 // saw null, so `AttributeMode::AtTime`, the faithful mode Doctrine VIII
748 // exists to offer, returned a *less* complete record than `Current`.
749 //
750 // The version number moves because the shape is a compat surface: readers
751 // must be able to tell "this build wrote no model" from "this payload
752 // predates the field". v1 is still accepted and folds with the field absent,
753 // which is what makes this safe without a migration rung — see the note on
754 // [`CREATE_TRIGGERS`].
755 CREATE_CONCEPTS_LOG_INSERT,
756 r#"
757 CREATE TRIGGER IF NOT EXISTS trg_concepts_log_update
758 AFTER UPDATE ON concepts
759 BEGIN
760 INSERT INTO transaction_log (table_name, entity_id, operation, payload, recorded_at)
761 VALUES ('concepts', NEW.id, 'U',
762 json_object('v', 2, 'title', NEW.title, 'content', NEW.content,
763 'valid_from', NEW.valid_from, 'valid_to', NEW.valid_to,
764 'retired', NEW.retired,
765 'embedding_model', NEW.embedding_model),
766 NEW.recorded_at);
767 END;
768 "#,
769 r#"
770 CREATE TRIGGER IF NOT EXISTS trg_links_log_insert
771 AFTER INSERT ON links
772 BEGIN
773 INSERT INTO transaction_log (table_name, entity_id, operation, payload, recorded_at)
774 VALUES ('links',
775 NEW.source_id || '|' || NEW.target_id || '|' || NEW.edge_type || '|' || NEW.valid_from,
776 'I',
777 json_object('v', 1, 'source_id', NEW.source_id, 'target_id', NEW.target_id,
778 'edge_type', NEW.edge_type, 'valid_from', NEW.valid_from,
779 'valid_to', NEW.valid_to, 'weight', NEW.weight,
780 'properties', json(NEW.properties)),
781 NEW.recorded_at);
782 END;
783 "#,
784 CREATE_CONCEPTS_GUARD_DELETE,
785 // D-008 (revised): probe main.sqlite_master for the archive-session marker.
786 // SQLite forbids a trigger in `main` from referencing objects in another
787 // database, temp included, so the original temp.sqlite_master probe fails
788 // at CREATE TRIGGER time and is unimplementable.
789 concat!(
790 r#"
791 CREATE TRIGGER IF NOT EXISTS trg_links_guard_delete
792 BEFORE DELETE ON links
793 WHEN NOT EXISTS (
794 SELECT 1 FROM sqlite_master
795 WHERE type = 'table' AND name = 'macrame_archive_session'
796 )
797 BEGIN
798 SELECT RAISE(ABORT, '"#,
799 abort_delete_guard!(),
800 r#"');
801 END;
802 "#
803 ),
804 concat!(
805 r#"
806 CREATE TRIGGER IF NOT EXISTS trg_txlog_guard_delete
807 BEFORE DELETE ON transaction_log
808 WHEN NOT EXISTS (
809 SELECT 1 FROM sqlite_master
810 WHERE type = 'table' AND name = 'macrame_archive_session'
811 )
812 BEGIN
813 SELECT RAISE(ABORT, '"#,
814 abort_delete_guard!(),
815 r#"');
816 END;
817 "#
818 ),
819 // --- FTS sync (§5.9) ------------------------------------------------
820 //
821 // These write to `concepts_fts` and to nothing else. In particular they do
822 // not touch `transaction_log`: an FTS index is derived from concept text
823 // the ledger already records, so logging it would record the same fact
824 // twice — the reasoning Doctrine VII applies to embeddings, and the reason
825 // `doctrine_static_tests` scans this array.
826 r#"
827 CREATE TRIGGER IF NOT EXISTS trg_concepts_fts_insert
828 AFTER INSERT ON concepts
829 BEGIN
830 INSERT INTO concepts_fts (rowid, title, content)
831 VALUES (NEW.rowid_pk, NEW.title, NEW.content);
832 END;
833 "#,
834 // The retraction is not optional and not symmetric with the insert. An
835 // external-content FTS5 index stores terms, not text, so replacing a row
836 // means telling it which terms to *remove* — and it needs the old column
837 // values to work that out. Omit this and the index keeps matching words the
838 // concept no longer contains, with no error and no way to notice except by
839 // searching for something that is no longer there.
840 r#"
841 CREATE TRIGGER IF NOT EXISTS trg_concepts_fts_update
842 AFTER UPDATE ON concepts
843 BEGIN
844 INSERT INTO concepts_fts (concepts_fts, rowid, title, content)
845 VALUES ('delete', OLD.rowid_pk, OLD.title, OLD.content);
846 INSERT INTO concepts_fts (rowid, title, content)
847 VALUES (NEW.rowid_pk, NEW.title, NEW.content);
848 END;
849 "#,
850 // The third trigger, installed **inert** by v8 (§4.6, D-119).
851 //
852 // Through v7 this array had no delete trigger, and the stated reason was
853 // that `trg_concepts_guard_delete` is unconditional (D-022) so no delete
854 // path exists to keep in sync. That was true and it was the wrong shape:
855 // the index's correctness depended on a *different* trigger staying
856 // unconditional, and nothing connected the two except a comment.
857 //
858 // It cannot fire today — the guard is a `BEFORE DELETE` that always aborts,
859 // so the statement never reaches `AFTER DELETE`. It is here because 0.9.0's
860 // archive session is what makes the guard conditional, and the moment that
861 // lands the index would go silently stale without this. Installing the
862 // capability in the rung that is already rebuilding the table costs nothing.
863 //
864 // It does **not** mean 0.9.0 needs no migration of its own — that claim was
865 // written here and it is wrong (D-126, corrected 0.8.0 pre-tag). This trigger
866 // is C2's step 3; step 2 is making `trg_concepts_guard_delete` conditional,
867 // and that is a `v8 → v9` rung, because `CREATE TRIGGER IF NOT EXISTS` on an
868 // existing name keeps the **old body** and `verify()` compares names only, so
869 // a re-issued baseline would leave the unconditional guard in place and pass.
870 // Deliberately not fixed here: the archive-session marker exists during
871 // *links* archival too, so a conditional concepts guard shipped in 0.8.0
872 // would leave concepts deletable during those sessions.
873 //
874 // `the_fts_delete_trigger_is_installed_and_inert` (wave1_regression_tests)
875 // pins both halves rather than assuming either.
876 r#"
877 CREATE TRIGGER IF NOT EXISTS trg_concepts_fts_delete
878 AFTER DELETE ON concepts
879 BEGIN
880 INSERT INTO concepts_fts (concepts_fts, rowid, title, content)
881 VALUES ('delete', OLD.rowid_pk, OLD.title, OLD.content);
882 END;
883 "#,
884];
885
886#[cfg(test)]
887mod tests {
888 use crate::util::timestamp::{CANONICAL_TS_GLOB, OPEN_SENTINEL};
889
890 /// The DDL's CHECK pattern and the Rust-side pattern must be the same
891 /// pattern. If they drift, one layer accepts what the other rejects and the
892 /// canonical-form invariant is enforced in name only.
893 #[test]
894 fn ddl_glob_matches_the_rust_canonical_pattern() {
895 assert_eq!(format!("'{}'", ts_glob!()), CANONICAL_TS_GLOB);
896 }
897
898 /// Every DDL statement that declares a temporal default must use the
899 /// canonical sentinel; a second-precision default would be rejected by the
900 /// very CHECK sitting next to it.
901 #[test]
902 fn ddl_defaults_use_the_canonical_sentinel() {
903 for ddl in [
904 super::CREATE_CONCEPTS_TABLE,
905 super::CREATE_LINKS_TABLE,
906 super::CREATE_LINKS_CURRENT_TABLE,
907 super::CREATE_TRANSACTION_LOG_TABLE,
908 ] {
909 assert!(
910 !ddl.contains("9999-12-31T23:59:59Z"),
911 "DDL still carries the pre-0.5.4 second-precision sentinel: {ddl}"
912 );
913 }
914 for trigger in super::CREATE_TRIGGERS {
915 assert!(
916 !trigger.contains("9999-12-31T23:59:59Z"),
917 "trigger still carries the pre-0.5.4 sentinel: {trigger}"
918 );
919 }
920 assert!(super::CREATE_LINKS_TABLE.contains(OPEN_SENTINEL));
921 }
922
923 /// Every abort message the classifier matches on must actually appear in the
924 /// DDL that emits it. `concat!` makes this true by construction today; the
925 /// test is what keeps it true if someone re-inlines a literal.
926 #[test]
927 fn every_abort_message_appears_in_a_trigger() {
928 for msg in [
929 super::ABORT_SINGLE_OPEN,
930 super::ABORT_MONOTONIC_RA,
931 super::ABORT_DELETE_GUARD,
932 ] {
933 assert!(
934 super::CREATE_TRIGGERS.iter().any(|t| t.contains(msg)),
935 "no trigger emits {msg:?}, so its typed error is unreachable"
936 );
937 }
938 }
939}