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
132pub const CREATE_CONCEPTS_TABLE: &str = concat!(
133 r#"
134CREATE TABLE IF NOT EXISTS concepts (
135 id TEXT PRIMARY KEY,
136 title TEXT NOT NULL,
137 content TEXT NOT NULL DEFAULT '',
138 embedding_model TEXT,
139 valid_from TEXT NOT NULL,
140 valid_to TEXT NOT NULL DEFAULT '9999-12-31T23:59:59.999999Z',
141 recorded_at TEXT NOT NULL,
142 retired INTEGER NOT NULL DEFAULT 0,
143 "#,
144 canonical_ts_check!("valid_from", "valid_to", "recorded_at"),
145 r#"
146);
147"#
148);
149
150pub const CREATE_LINKS_TABLE: &str = concat!(
151 r#"
152CREATE TABLE IF NOT EXISTS links (
153 source_id TEXT NOT NULL REFERENCES concepts(id),
154 target_id TEXT NOT NULL REFERENCES concepts(id),
155 edge_type TEXT NOT NULL,
156 valid_from TEXT NOT NULL,
157 recorded_at TEXT NOT NULL,
158 valid_to TEXT NOT NULL DEFAULT '9999-12-31T23:59:59.999999Z',
159 weight REAL NOT NULL DEFAULT 1.0,
160 properties TEXT NOT NULL DEFAULT '{}',
161 PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at),
162 "#,
163 weight_check!(),
164 r#",
165 "#,
166 canonical_ts_check!("valid_from", "valid_to", "recorded_at"),
167 r#"
168);
169"#
170);
171
172pub const CREATE_LINKS_CURRENT_TABLE: &str = concat!(
173 r#"
174CREATE TABLE IF NOT EXISTS links_current (
175 source_id TEXT NOT NULL,
176 target_id TEXT NOT NULL,
177 edge_type TEXT NOT NULL,
178 valid_from TEXT NOT NULL,
179 valid_to TEXT NOT NULL,
180 weight REAL NOT NULL,
181 properties TEXT NOT NULL,
182 recorded_at TEXT NOT NULL,
183 PRIMARY KEY (source_id, target_id, edge_type, valid_from),
184 "#,
185 canonical_ts_check!("valid_from", "valid_to", "recorded_at"),
186 r#"
187);
188"#
189);
190
191pub const CREATE_TRANSACTION_LOG_TABLE: &str = concat!(
192 r#"
193CREATE TABLE IF NOT EXISTS transaction_log (
194 seq_id INTEGER PRIMARY KEY AUTOINCREMENT,
195 table_name TEXT NOT NULL,
196 entity_id TEXT NOT NULL,
197 operation TEXT NOT NULL,
198 payload TEXT NOT NULL,
199 recorded_at TEXT NOT NULL,
200 "#,
201 canonical_ts_check!("recorded_at"),
202 r#"
203);
204"#
205);
206
207/// The per-model embedding table (§4.1, D-005), for a validated model name.
208///
209/// A function rather than a `const` because the table's identity *and its
210/// column type* both depend on the model: `F32_BLOB(dim)` carries the declared
211/// dimension in the schema, which is what [`crate::vector::declared_dimension`]
212/// reads back so the crate never keeps a second copy of it.
213///
214/// Deliberately not part of the baseline migration. Which models exist is an
215/// application's choice made over time, not a property of the schema version,
216/// and D-036 classifies these tables as disposable periphery: a migration may
217/// drop one and re-embed. `IF NOT EXISTS` makes registration idempotent.
218///
219/// No temporal columns, on purpose. Doctrine VII makes an embedding a derived
220/// artifact of a model applied to content — it has no valid time of its own, and
221/// giving it a `recorded_at` would put a third clock next to the two §2 permits
222/// and invite queries that mix them.
223pub fn create_embeddings_table(model: &crate::vector::ModelName, dim: usize) -> String {
224 format!(
225 "CREATE TABLE IF NOT EXISTS {table} (
226 concept_id TEXT PRIMARY KEY REFERENCES concepts(id),
227 embedding F32_BLOB({dim}) NOT NULL
228);",
229 table = model.table(),
230 )
231}
232
233/// The DiskANN index over a model's vectors.
234///
235/// **Load-bearing for correctness, not only for speed.** Measured against
236/// libSQL 0.9.30: a blob of the wrong length inserted into an `F32_BLOB(4)`
237/// column is *accepted* while no vector index exists, and rejected — with the
238/// row not landing — once one does. §4.1 previously claimed the column type
239/// enforced its own dimension at insert time; it does not. So this index is
240/// created together with the table it indexes and is never optional, and
241/// dropping it to speed up a bulk load would silently disarm the only
242/// storage-layer check on dimension.
243pub fn create_embeddings_index(model: &crate::vector::ModelName) -> String {
244 format!(
245 "CREATE INDEX IF NOT EXISTS {index} ON {table} (libsql_vector_idx(embedding));",
246 index = model.index(),
247 table = model.table(),
248 )
249}
250
251/// Derived analytics output, keyed by concept and label (§5.4, D-041).
252///
253/// Deliberately outside the ledger. Three properties are load-bearing and each
254/// is the opposite of what the four normative tables above do.
255///
256/// **No log trigger.** Nothing in [`CREATE_TRIGGERS`] fires on this table, so an
257/// annotation never reaches `transaction_log`. That is Doctrine VII's reasoning
258/// about embeddings applied to the other derived artifact: a community label is
259/// a function of an algorithm, a version of that algorithm, and a graph — not a
260/// statement about the world, and a ledger that records it is recording the
261/// analytics schedule as though it were history. A reconstruction that wants
262/// labels recomputes them, which is the only honest way to ask what a past
263/// graph's communities *were*.
264///
265/// **No delete guard.** Doctrine V protects the hot ledger tables; this table is
266/// derivative state in Doctrine VI's second category, so wiping it must stay a
267/// legal, ordinary operation — a rerun replaces the previous pass, and dropping
268/// the whole table costs nothing but the recomputation.
269///
270/// **Upsert on `(concept_id, label)`.** One current value per label per concept.
271/// Storing a history of successive runs here would be the ledger again, by
272/// another name.
273///
274/// The foreign key is safe in a way `links_current`'s omitted ones are not:
275/// concepts are never physically deleted (D-022), and this table is rebuilt by
276/// re-running an algorithm that read `concepts` in the first place, so there is
277/// no insertion-order problem to solve.
278pub const CREATE_ANALYTICS_ANNOTATIONS_TABLE: &str = concat!(
279 r#"
280CREATE TABLE IF NOT EXISTS analytics_annotations (
281 concept_id TEXT NOT NULL REFERENCES concepts(id),
282 label TEXT NOT NULL,
283 value TEXT NOT NULL,
284 computed_at TEXT NOT NULL,
285 PRIMARY KEY (concept_id, label),
286 "#,
287 canonical_ts_check!("computed_at"),
288 r#"
289);
290"#
291);
292
293/// The keyword half of hybrid search: an FTS5 index over concept text (§5.9).
294///
295/// **External content.** The table declares `content='concepts'`, so the tokens
296/// are indexed but the text itself is not duplicated — FTS5 reads it back from
297/// `concepts` by rowid when it needs a column value. Two reasons beyond the
298/// storage saving, and the second is the one that decided it:
299///
300/// * There is exactly one copy of the text, so the index cannot disagree with
301/// the concept about what the concept says. A standalone FTS table would be a
302/// second description of data the ledger already holds, which is the failure
303/// class D-030 and D-035 exist to prevent.
304/// * `INSERT INTO concepts_fts(concepts_fts) VALUES('rebuild')` reconstructs the
305/// whole index from the content table in one statement. D-036 requires every
306/// derivative table to be rebuildable from the ledger, and here that is the
307/// engine's own operation rather than code of ours that has to be kept honest.
308///
309/// The cost is that external-content tables do not maintain themselves: an
310/// `UPDATE` must retract the *old* terms before adding the new ones, using the
311/// old column values. That is what `trg_concepts_fts_update` does, and getting
312/// it wrong leaves an index that still matches text no concept contains.
313pub const CREATE_CONCEPTS_FTS: &str = r#"
314CREATE VIRTUAL TABLE IF NOT EXISTS concepts_fts USING fts5(
315 title,
316 content,
317 content='concepts',
318 content_rowid='rowid'
319);
320"#;
321
322/// FTS5's own consistency check — **and it cannot see the failure that matters**
323/// (§5.9, D-071).
324///
325/// Kept as a named constant so the finding has somewhere to live, and used by
326/// `an_emptied_fts_index_still_passes_integrity_check`, which is a tripwire
327/// rather than a guarantee.
328///
329/// On this libSQL build (0.9.30), `'integrity-check'` verifies the index's
330/// *internal* consistency and not its agreement with the content table. Measured:
331/// after `'delete-all'` the index answers zero matches where it answered ten, and
332/// both `'integrity-check'` and `'integrity-check', 0` still report success. So a
333/// `verify_fts()` built on this would report a healthy index for an empty one —
334/// which is why there is no `verify_fts()`. See D-071.
335pub const VERIFY_CONCEPTS_FTS: &str =
336 "INSERT INTO concepts_fts (concepts_fts) VALUES ('integrity-check');";
337
338/// Reconstruct the FTS index from `concepts` (§5.9, D-036).
339///
340/// The engine's own operation, so the rebuild path is not a second
341/// implementation of the triggers that could drift from them.
342pub const REBUILD_CONCEPTS_FTS: &str =
343 "INSERT INTO concepts_fts (concepts_fts) VALUES ('rebuild');";
344
345pub const CREATE_INDICES: &[&str] = &[
346 "CREATE INDEX IF NOT EXISTS idx_annotations_label ON analytics_annotations (label);",
347 // Covering index for the traversal CTE (§5.2, D-042).
348 //
349 // Column order is load-bearing and was measured with EXPLAIN QUERY PLAN.
350 // The seek column is `source_id`; everything after it is there so the
351 // recursive step never touches the base table. The two range columns come
352 // next and `edge_type` comes *after* them, because `edge_types` is empty
353 // unless a caller sets it: with `edge_type` in second position SQLite
354 // declines the index for the unfiltered traversal — the default one — and
355 // silently falls back to a non-covering plan.
356 //
357 // (source_id, edge_type, valid_from, ...) filtered: COVERING
358 // unfiltered: NOT covering
359 // (source_id, valid_from, valid_to, weight, edge_type, target_id)
360 // both: COVERING
361 //
362 // This subsumes the former idx_lc_src_active (source_id, valid_to): same
363 // prefix column, strictly more payload. Keeping both would pay two index
364 // writes per assertion on a table that already takes three writes.
365 "CREATE INDEX IF NOT EXISTS idx_lc_traversal_cover ON links_current \
366 (source_id, valid_from, valid_to, weight, edge_type, target_id);",
367 "CREATE INDEX IF NOT EXISTS idx_lc_tgt_active ON links_current (target_id, valid_to);",
368 // The single-open-interval probe's own index (D-059, shipped v5 -> v6).
369 //
370 // `trg_links_single_open` runs an `EXISTS` on every edge insert, keyed on
371 // (source_id, target_id, edge_type, valid_to) with valid_from as an
372 // inequality. Before this index the planner served that probe from
373 // `idx_lc_traversal_cover` with only `source_id` bound — it wins as a
374 // covering index over the primary-key autoindex, which lacks `valid_to` —
375 // so **every insert scanned its source's entire out-degree**. Measured on a
376 // fixed 90-row chunk: 4.4 ms into an empty table, 18.4 ms into a
377 // 2,000-edge hub, 47.7 ms into an 8,000-edge one, and 1.06 s into 90,000.
378 // Growth in the table, not in the chunk.
379 //
380 // With this index the same 90 rows into the 8,000-edge hub take 8.0 ms and
381 // stay flat. It matters beyond bulk import: the probe is on the insert path,
382 // so an interactive `assert_edge` against a high-degree node paid the same
383 // scan, and that is the path CHUNK_BUDGET's 3 ms exists to protect.
384 //
385 // Column order follows the trigger's WHERE exactly — the three equalities
386 // first, then `valid_to` which is compared to the sentinel, then
387 // `valid_from` which is the `<>` and cannot be a seek column. This does not
388 // subsume `idx_lc_traversal_cover` and is not subsumed by it: that one leads
389 // on `source_id` alone for the recursive walk, this one needs all three
390 // equality columns bound. Both are kept, which is a fourth index write per
391 // assertion buying a scan's removal from the same operation.
392 "CREATE INDEX IF NOT EXISTS idx_lc_open_interval ON links_current \
393 (source_id, target_id, edge_type, valid_to, valid_from);",
394 "CREATE INDEX IF NOT EXISTS idx_txlog_time ON transaction_log (recorded_at);",
395 "CREATE INDEX IF NOT EXISTS idx_txlog_entity ON transaction_log (entity_id);",
396];
397
398/// Every trigger the schema declares.
399///
400/// **`IF NOT EXISTS` means a changed body does not reach an existing file.**
401/// `migrations::verify` checks trigger *presence by name*, which is deliberate
402/// (a count refuses healthy databases) but does not and cannot notice that a
403/// trigger present under the right name carries an older body. A database
404/// stamped v5 by an earlier build therefore keeps whatever trigger text it was
405/// created with until a rung drops and recreates it.
406///
407/// This is why the payload carries a version. Changing a log trigger's payload
408/// splits the database population in two — files created after the change write
409/// the new shape, files created before keep writing the old one — and the only
410/// thing that makes that survivable is that every reader accepts both. A
411/// payload change that did *not* bump `v` would be indistinguishable at read
412/// time from corruption, which is the case `DbError::PayloadVersion` exists for.
413///
414/// The v1 → v2 concept payload (defect V) is deliberately left to ride along on
415/// the next rung that has to move `user_version` anyway rather than claiming one
416/// of its own: an old file loses `embedding_model` from its temporal reads, which
417/// is exactly the behaviour it had before, and gains it the moment it is
418/// migrated. Nothing regresses in the meantime.
419pub const CREATE_TRIGGERS: &[&str] = &[
420 r#"
421 CREATE TRIGGER IF NOT EXISTS trg_links_current_sync
422 AFTER INSERT ON links
423 BEGIN
424 INSERT INTO links_current
425 (source_id, target_id, edge_type, valid_from, valid_to,
426 weight, properties, recorded_at)
427 VALUES
428 (NEW.source_id, NEW.target_id, NEW.edge_type, NEW.valid_from,
429 NEW.valid_to, NEW.weight, NEW.properties, NEW.recorded_at)
430 ON CONFLICT(source_id, target_id, edge_type, valid_from) DO UPDATE SET
431 valid_to = excluded.valid_to,
432 weight = excluded.weight,
433 properties = excluded.properties,
434 recorded_at = excluded.recorded_at
435 WHERE excluded.recorded_at > links_current.recorded_at;
436 END;
437 "#,
438 concat!(
439 r#"
440 CREATE TRIGGER IF NOT EXISTS trg_links_single_open
441 BEFORE INSERT ON links
442 WHEN NEW.valid_to = '9999-12-31T23:59:59.999999Z'
443 AND EXISTS (
444 SELECT 1 FROM links_current
445 WHERE source_id = NEW.source_id
446 AND target_id = NEW.target_id
447 AND edge_type = NEW.edge_type
448 AND valid_from <> NEW.valid_from
449 AND valid_to = '9999-12-31T23:59:59.999999Z'
450 )
451 BEGIN
452 SELECT RAISE(ABORT, '"#,
453 abort_single_open!(),
454 r#"');
455 END;
456 "#
457 ),
458 concat!(
459 r#"
460 CREATE TRIGGER IF NOT EXISTS trg_concepts_monotonic_ra
461 BEFORE UPDATE ON concepts
462 WHEN NEW.recorded_at <= OLD.recorded_at
463 BEGIN
464 SELECT RAISE(ABORT, '"#,
465 abort_monotonic_ra!(),
466 r#"');
467 END;
468 "#
469 ),
470 // Payload v2 adds `embedding_model` (defect V). Before it, the field was
471 // written by nobody and read by two — `replay::fold_delta` and
472 // `as_of::hydrate_attributes` both asked the payload for it and both always
473 // saw null, so `AttributeMode::AtTime`, the faithful mode Doctrine VIII
474 // exists to offer, returned a *less* complete record than `Current`.
475 //
476 // The version number moves because the shape is a compat surface: readers
477 // must be able to tell "this build wrote no model" from "this payload
478 // predates the field". v1 is still accepted and folds with the field absent,
479 // which is what makes this safe without a migration rung — see the note on
480 // [`CREATE_TRIGGERS`].
481 r#"
482 CREATE TRIGGER IF NOT EXISTS trg_concepts_log_insert
483 AFTER INSERT ON concepts
484 BEGIN
485 INSERT INTO transaction_log (table_name, entity_id, operation, payload, recorded_at)
486 VALUES ('concepts', NEW.id, 'I',
487 json_object('v', 2, 'title', NEW.title, 'content', NEW.content,
488 'valid_from', NEW.valid_from, 'valid_to', NEW.valid_to,
489 'retired', NEW.retired,
490 'embedding_model', NEW.embedding_model),
491 NEW.recorded_at);
492 END;
493 "#,
494 r#"
495 CREATE TRIGGER IF NOT EXISTS trg_concepts_log_update
496 AFTER UPDATE ON concepts
497 BEGIN
498 INSERT INTO transaction_log (table_name, entity_id, operation, payload, recorded_at)
499 VALUES ('concepts', NEW.id, 'U',
500 json_object('v', 2, 'title', NEW.title, 'content', NEW.content,
501 'valid_from', NEW.valid_from, 'valid_to', NEW.valid_to,
502 'retired', NEW.retired,
503 'embedding_model', NEW.embedding_model),
504 NEW.recorded_at);
505 END;
506 "#,
507 r#"
508 CREATE TRIGGER IF NOT EXISTS trg_links_log_insert
509 AFTER INSERT ON links
510 BEGIN
511 INSERT INTO transaction_log (table_name, entity_id, operation, payload, recorded_at)
512 VALUES ('links',
513 NEW.source_id || '|' || NEW.target_id || '|' || NEW.edge_type || '|' || NEW.valid_from,
514 'I',
515 json_object('v', 1, 'source_id', NEW.source_id, 'target_id', NEW.target_id,
516 'edge_type', NEW.edge_type, 'valid_from', NEW.valid_from,
517 'valid_to', NEW.valid_to, 'weight', NEW.weight,
518 'properties', json(NEW.properties)),
519 NEW.recorded_at);
520 END;
521 "#,
522 // Concepts are NEVER physically archived (D-022), so this guard is
523 // unconditional -- there is no session in which the delete becomes legal.
524 r#"
525 CREATE TRIGGER IF NOT EXISTS trg_concepts_guard_delete
526 BEFORE DELETE ON concepts
527 BEGIN
528 SELECT RAISE(ABORT, 'macrame: concepts are never physically archived (D-022)');
529 END;
530 "#,
531 // D-008 (revised): probe main.sqlite_master for the archive-session marker.
532 // SQLite forbids a trigger in `main` from referencing objects in another
533 // database, temp included, so the original temp.sqlite_master probe fails
534 // at CREATE TRIGGER time and is unimplementable.
535 concat!(
536 r#"
537 CREATE TRIGGER IF NOT EXISTS trg_links_guard_delete
538 BEFORE DELETE ON links
539 WHEN NOT EXISTS (
540 SELECT 1 FROM sqlite_master
541 WHERE type = 'table' AND name = 'macrame_archive_session'
542 )
543 BEGIN
544 SELECT RAISE(ABORT, '"#,
545 abort_delete_guard!(),
546 r#"');
547 END;
548 "#
549 ),
550 concat!(
551 r#"
552 CREATE TRIGGER IF NOT EXISTS trg_txlog_guard_delete
553 BEFORE DELETE ON transaction_log
554 WHEN NOT EXISTS (
555 SELECT 1 FROM sqlite_master
556 WHERE type = 'table' AND name = 'macrame_archive_session'
557 )
558 BEGIN
559 SELECT RAISE(ABORT, '"#,
560 abort_delete_guard!(),
561 r#"');
562 END;
563 "#
564 ),
565 // --- FTS sync (§5.9) ------------------------------------------------
566 //
567 // These write to `concepts_fts` and to nothing else. In particular they do
568 // not touch `transaction_log`: an FTS index is derived from concept text
569 // the ledger already records, so logging it would record the same fact
570 // twice — the reasoning Doctrine VII applies to embeddings, and the reason
571 // `doctrine_static_tests` scans this array.
572 r#"
573 CREATE TRIGGER IF NOT EXISTS trg_concepts_fts_insert
574 AFTER INSERT ON concepts
575 BEGIN
576 INSERT INTO concepts_fts (rowid, title, content)
577 VALUES (NEW.rowid, NEW.title, NEW.content);
578 END;
579 "#,
580 // The retraction is not optional and not symmetric with the insert. An
581 // external-content FTS5 index stores terms, not text, so replacing a row
582 // means telling it which terms to *remove* — and it needs the old column
583 // values to work that out. Omit this and the index keeps matching words the
584 // concept no longer contains, with no error and no way to notice except by
585 // searching for something that is no longer there.
586 r#"
587 CREATE TRIGGER IF NOT EXISTS trg_concepts_fts_update
588 AFTER UPDATE ON concepts
589 BEGIN
590 INSERT INTO concepts_fts (concepts_fts, rowid, title, content)
591 VALUES ('delete', OLD.rowid, OLD.title, OLD.content);
592 INSERT INTO concepts_fts (rowid, title, content)
593 VALUES (NEW.rowid, NEW.title, NEW.content);
594 END;
595 "#,
596 // There is deliberately no delete trigger. `trg_concepts_guard_delete` is
597 // unconditional (D-022) — concepts are never physically deleted, not even
598 // inside an archive session — so a delete path does not exist to keep in
599 // sync. If that guard ever becomes conditional, this array needs a third
600 // trigger issuing the same `'delete'` command, and the index is silently
601 // stale until it gets one.
602];
603
604#[cfg(test)]
605mod tests {
606 use crate::util::timestamp::{CANONICAL_TS_GLOB, OPEN_SENTINEL};
607
608 /// The DDL's CHECK pattern and the Rust-side pattern must be the same
609 /// pattern. If they drift, one layer accepts what the other rejects and the
610 /// canonical-form invariant is enforced in name only.
611 #[test]
612 fn ddl_glob_matches_the_rust_canonical_pattern() {
613 assert_eq!(format!("'{}'", ts_glob!()), CANONICAL_TS_GLOB);
614 }
615
616 /// Every DDL statement that declares a temporal default must use the
617 /// canonical sentinel; a second-precision default would be rejected by the
618 /// very CHECK sitting next to it.
619 #[test]
620 fn ddl_defaults_use_the_canonical_sentinel() {
621 for ddl in [
622 super::CREATE_CONCEPTS_TABLE,
623 super::CREATE_LINKS_TABLE,
624 super::CREATE_LINKS_CURRENT_TABLE,
625 super::CREATE_TRANSACTION_LOG_TABLE,
626 ] {
627 assert!(
628 !ddl.contains("9999-12-31T23:59:59Z"),
629 "DDL still carries the pre-0.5.4 second-precision sentinel: {ddl}"
630 );
631 }
632 for trigger in super::CREATE_TRIGGERS {
633 assert!(
634 !trigger.contains("9999-12-31T23:59:59Z"),
635 "trigger still carries the pre-0.5.4 sentinel: {trigger}"
636 );
637 }
638 assert!(super::CREATE_LINKS_TABLE.contains(OPEN_SENTINEL));
639 }
640
641 /// Every abort message the classifier matches on must actually appear in the
642 /// DDL that emits it. `concat!` makes this true by construction today; the
643 /// test is what keeps it true if someone re-inlines a literal.
644 #[test]
645 fn every_abort_message_appears_in_a_trigger() {
646 for msg in [
647 super::ABORT_SINGLE_OPEN,
648 super::ABORT_MONOTONIC_RA,
649 super::ABORT_DELETE_GUARD,
650 ] {
651 assert!(
652 super::CREATE_TRIGGERS.iter().any(|t| t.contains(msg)),
653 "no trigger emits {msg:?}, so its typed error is unreachable"
654 );
655 }
656 }
657}