Skip to main content

coven_core/
db.rs

1//! coven's bookkeeping schema.
2//!
3//! coven owns its device-local bookkeeping tables — `protocol_state`,
4//! `materialized_commits`, `snapshot_coverage`, `store_writes`,
5//! `outbound_membership_mutation`, `outbound_store_snapshot`,
6//! `local_blob_refs`, `local_cleanup_intents`, exact prepared Store objects, and
7//! row-bound blob locators — all
8//! created STRICT by [`apply_coven_schema`], which coven
9//! runs against the connection it owns during open. The host does not implement
10//! any of this; app SQL goes through [`crate::CovenHandle::sql`] or
11//! [`crate::CovenHandle::write`].
12
13macro_rules! coven_tables {
14    ($visit:ident) => {
15        $visit!(
16            protocol_state,
17            "
18    key TEXT PRIMARY KEY,
19    value TEXT NOT NULL
20"
21        );
22        $visit!(
23            retained_replay_baselines,
24            "
25    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
26    generation INTEGER NOT NULL CHECK (generation >= 0),
27    exact_cut TEXT NOT NULL CHECK (json_valid(exact_cut)),
28    schema_version INTEGER NOT NULL CHECK (schema_version >= 0),
29    routing_hash TEXT NOT NULL CHECK (length(routing_hash) = 64),
30    image_hash TEXT NOT NULL CHECK (length(image_hash) = 64),
31    image_bytes BLOB NOT NULL CHECK (length(image_bytes) > 0),
32    authority_bytes BLOB NOT NULL CHECK (length(authority_bytes) > 0)
33"
34        );
35        $visit!(
36            circle_bootstrap_coverage,
37            "
38    circle_id TEXT PRIMARY KEY,
39    control_coord TEXT NOT NULL CHECK (json_valid(control_coord)),
40    activation_commit TEXT NOT NULL CHECK (json_valid(activation_commit)),
41    exact_cut TEXT NOT NULL CHECK (json_valid(exact_cut)),
42    image_hash TEXT NOT NULL CHECK (length(image_hash) = 64),
43    image_bytes BLOB NOT NULL CHECK (length(image_bytes) > 0),
44    bootstrap_ref BLOB NOT NULL CHECK (length(bootstrap_ref) > 0)
45"
46        );
47        $visit!(
48            circle_close_exclusions,
49            "
50    circle_id TEXT PRIMARY KEY,
51    close_id TEXT NOT NULL,
52    excluded_registration TEXT NOT NULL CHECK (json_valid(excluded_registration)),
53    successor_control TEXT NOT NULL CHECK (json_valid(successor_control)),
54    activating_commit TEXT NOT NULL CHECK (json_valid(activating_commit))
55"
56        );
57        $visit!(
58            retained_merge_materializations,
59            "
60    device_id TEXT NOT NULL,
61    seq INTEGER NOT NULL CHECK (seq > 0),
62    commit_ref TEXT NOT NULL CHECK (json_valid(commit_ref)),
63    input_hash TEXT NOT NULL CHECK (length(input_hash) = 64),
64    canonical_input BLOB NOT NULL CHECK (length(canonical_input) > 0),
65    PRIMARY KEY (device_id, seq),
66    UNIQUE (device_id, seq, commit_ref, input_hash)
67"
68        );
69        $visit!(
70            merge_retraction_cleanups,
71            "
72    device_id TEXT NOT NULL,
73    seq INTEGER NOT NULL CHECK (seq > 0),
74    commit_ref TEXT NOT NULL CHECK (json_valid(commit_ref)),
75    cleanup_hash TEXT NOT NULL CHECK (length(cleanup_hash) = 64),
76    canonical_cleanup BLOB NOT NULL CHECK (length(canonical_cleanup) > 0),
77    PRIMARY KEY (device_id, seq),
78    UNIQUE (commit_ref)
79"
80        );
81        $visit!(
82            materialized_commits,
83            "
84    device_id TEXT NOT NULL,
85    seq INTEGER NOT NULL CHECK (seq > 0),
86    commit_ref TEXT NOT NULL CHECK (json_valid(commit_ref)),
87    retained_commit_ref TEXT NOT NULL CHECK (json_valid(retained_commit_ref)),
88    retained_input_hash TEXT NOT NULL CHECK (length(retained_input_hash) = 64),
89    PRIMARY KEY (device_id, seq),
90    FOREIGN KEY (device_id, seq, retained_commit_ref, retained_input_hash)
91        REFERENCES retained_merge_materializations(device_id, seq, commit_ref, input_hash)
92"
93        );
94        $visit!(
95            stream_activations,
96            "
97    activation_id TEXT PRIMARY KEY CHECK (length(activation_id) = 64),
98    author_stream_id TEXT NOT NULL UNIQUE CHECK (length(author_stream_id) = 64),
99    activation BLOB NOT NULL,
100    activating_commit TEXT NOT NULL CHECK (json_valid(activating_commit))
101"
102        );
103        $visit!(
104            snapshot_coverage,
105            "
106    device_id TEXT PRIMARY KEY,
107    seq INTEGER NOT NULL CHECK (seq > 0),
108    commit_ref TEXT NOT NULL CHECK (json_valid(commit_ref)),
109    snapshot_hash TEXT NOT NULL CHECK (length(snapshot_hash) = 64)
110"
111        );
112        $visit!(
113            local_blob_refs,
114            "
115    table_name TEXT NOT NULL,
116    row_id TEXT NOT NULL,
117    column_name TEXT NOT NULL,
118    row_stamp TEXT NOT NULL,
119    namespace TEXT NOT NULL,
120    blob_id TEXT NOT NULL,
121    path TEXT NOT NULL,
122    plaintext_size INTEGER NOT NULL CHECK (plaintext_size >= 0),
123    plaintext_hash TEXT NOT NULL CHECK (length(plaintext_hash) = 64),
124    PRIMARY KEY (table_name, row_id, column_name, row_stamp)
125"
126        );
127        $visit!(
128            cloud_outbox,
129            "
130    id INTEGER PRIMARY KEY AUTOINCREMENT,
131    operation TEXT NOT NULL CHECK (operation IN ('upload', 'delete')),
132    table_name TEXT,
133    row_id TEXT,
134    column_name TEXT,
135    row_stamp TEXT,
136    root_table TEXT,
137    root_id TEXT,
138    row_ref TEXT CHECK (row_ref IS NULL OR json_valid(row_ref)),
139    upload_state TEXT CHECK (upload_state IS NULL OR json_valid(upload_state)),
140    stored_ref TEXT CHECK (stored_ref IS NULL OR json_valid(stored_ref)),
141    source_path TEXT,
142    retain_pinned INTEGER CHECK (retain_pinned IS NULL OR retain_pinned IN (0, 1)),
143    created_at TEXT NOT NULL,
144    attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
145    last_error TEXT,
146    last_attempt_at TEXT,
147    CHECK (
148        (operation = 'upload' AND table_name IS NOT NULL AND row_id IS NOT NULL
149         AND column_name IS NOT NULL AND row_stamp IS NOT NULL
150         AND root_table IS NOT NULL AND root_id IS NOT NULL AND row_ref IS NOT NULL
151         AND stored_ref IS NULL AND source_path IS NOT NULL AND retain_pinned IS NOT NULL
152         AND upload_state IS NOT NULL)
153        OR
154        (operation = 'delete' AND table_name IS NULL AND row_id IS NULL
155         AND column_name IS NULL AND row_stamp IS NULL
156         AND root_table IS NULL AND root_id IS NULL AND row_ref IS NULL
157         AND stored_ref IS NOT NULL AND source_path IS NULL AND retain_pinned IS NULL
158         AND upload_state IS NULL)
159    ),
160    UNIQUE (operation, table_name, row_id, column_name, row_stamp),
161    UNIQUE (stored_ref)
162"
163        );
164        $visit!(
165            blob_make_remote_intents,
166            "
167    root_table TEXT NOT NULL,
168    root_id TEXT NOT NULL,
169    retain_pinned INTEGER NOT NULL CHECK (retain_pinned IN (0, 1)),
170    state TEXT NOT NULL CHECK (state IN ('uploading', 'cancelling', 'publishing')),
171    write_id TEXT UNIQUE,
172    CHECK ((state = 'publishing') = (write_id IS NOT NULL)),
173    PRIMARY KEY (root_table, root_id)
174"
175        );
176        $visit!(
177            local_cleanup_intents,
178            "
179    namespace TEXT NOT NULL,
180    blob_id   TEXT NOT NULL,
181    copy_identity TEXT NOT NULL CHECK (copy_identity = 'local' OR length(copy_identity) = 64),
182    PRIMARY KEY (namespace, blob_id, copy_identity)
183"
184        );
185        $visit!(
186            published_blob_drop_intents,
187            "
188    seq INTEGER NOT NULL CHECK (seq > 0),
189    namespace TEXT NOT NULL,
190    blob_id TEXT NOT NULL,
191    size INTEGER NOT NULL CHECK (size >= 0),
192    plaintext_hash TEXT NOT NULL,
193    locator_hash TEXT NOT NULL,
194    disposition TEXT NOT NULL CHECK (disposition IN ('drop', 'cache', 'pin')),
195    PRIMARY KEY (seq, namespace, blob_id, locator_hash)
196"
197        );
198        $visit!(
199            store_writes,
200            "
201    ordinal INTEGER PRIMARY KEY AUTOINCREMENT,
202    write_id TEXT NOT NULL UNIQUE,
203    status TEXT NOT NULL CHECK (json_valid(status)),
204    affected_rows TEXT NOT NULL CHECK (json_valid(affected_rows)),
205    changeset BLOB NOT NULL,
206    inverse_changeset BLOB NOT NULL,
207    base TEXT NOT NULL CHECK (json_valid(base)),
208    blob_facts TEXT NOT NULL CHECK (json_valid(blob_facts)),
209    prepared TEXT CHECK (prepared IS NULL OR json_valid(prepared))
210"
211        );
212        $visit!(
213            store_write_blob_leases,
214            "
215    write_id TEXT NOT NULL,
216    namespace TEXT NOT NULL,
217    blob_id TEXT NOT NULL,
218    PRIMARY KEY (write_id, namespace, blob_id),
219    FOREIGN KEY (write_id) REFERENCES store_writes(write_id)
220"
221        );
222        $visit!(
223            store_write_partitions,
224            "
225    write_id TEXT NOT NULL,
226    audience TEXT NOT NULL,
227    control_coord TEXT,
228    changeset BLOB NOT NULL,
229    PRIMARY KEY (write_id, audience),
230    FOREIGN KEY (write_id) REFERENCES store_writes(write_id) ON DELETE CASCADE,
231    CHECK (
232        (audience IN ('store', 'local') AND control_coord IS NULL)
233        OR
234        (audience NOT IN ('store', 'local') AND json_valid(control_coord))
235    )
236"
237        );
238        $visit!(
239            remote_objects,
240            "
241    object_id TEXT PRIMARY KEY CHECK (length(object_id) = 64),
242    state TEXT NOT NULL CHECK (json_valid(state))
243"
244        );
245        $visit!(
246            retained_replay_objects,
247            "
248    device_id TEXT NOT NULL,
249    seq INTEGER NOT NULL CHECK (seq > 0),
250    commit_ref TEXT NOT NULL CHECK (json_valid(commit_ref)),
251    input_hash TEXT NOT NULL CHECK (length(input_hash) = 64),
252    object_id TEXT NOT NULL CHECK (length(object_id) = 64),
253    PRIMARY KEY (device_id, seq, object_id),
254    FOREIGN KEY (device_id, seq, commit_ref, input_hash)
255        REFERENCES retained_merge_materializations(device_id, seq, commit_ref, input_hash),
256    FOREIGN KEY (object_id) REFERENCES remote_objects(object_id)
257"
258        );
259        $visit!(
260            protocol_inert_objects,
261            "
262    object_id TEXT PRIMARY KEY CHECK (length(object_id) = 64),
263    state TEXT NOT NULL CHECK (json_valid(state))
264"
265        );
266        $visit!(
267            reclaimed_store_packages,
268            "
269    object_id TEXT PRIMARY KEY CHECK (length(object_id) = 64),
270    authorization_hash TEXT NOT NULL UNIQUE CHECK (length(authorization_hash) = 64),
271    state TEXT NOT NULL CHECK (json_valid(state)),
272    FOREIGN KEY (authorization_hash) REFERENCES store_reclaim_operations(authorization_hash)
273"
274        );
275        $visit!(
276            store_write_packages,
277            "
278    write_id TEXT NOT NULL,
279    audience TEXT NOT NULL,
280    remote_object_id TEXT NOT NULL CHECK (length(remote_object_id) = 64),
281    PRIMARY KEY (write_id, audience),
282    FOREIGN KEY (write_id) REFERENCES store_writes(write_id) ON DELETE CASCADE,
283    FOREIGN KEY (remote_object_id) REFERENCES remote_objects(object_id)
284"
285        );
286        $visit!(
287            store_write_blobs,
288            "
289    write_id TEXT NOT NULL,
290    audience TEXT NOT NULL,
291    locator_hash TEXT NOT NULL CHECK (length(locator_hash) = 64),
292    remote_object_id TEXT NOT NULL CHECK (length(remote_object_id) = 64),
293    spool_path TEXT,
294    PRIMARY KEY (write_id, audience, remote_object_id),
295    FOREIGN KEY (write_id) REFERENCES store_writes(write_id) ON DELETE CASCADE,
296    FOREIGN KEY (remote_object_id) REFERENCES remote_objects(object_id)
297"
298        );
299        $visit!(
300            blob_locators,
301            "
302    remote_object_id TEXT PRIMARY KEY CHECK (length(remote_object_id) = 64),
303    locator_hash TEXT NOT NULL CHECK (length(locator_hash) = 64),
304    FOREIGN KEY (remote_object_id) REFERENCES remote_objects(object_id)
305"
306        );
307        $visit!(
308            row_blob_locators,
309            "
310    table_name TEXT NOT NULL,
311    row_id TEXT NOT NULL,
312    column_name TEXT NOT NULL,
313    row_stamp TEXT NOT NULL,
314    audience_authority TEXT NOT NULL CHECK (json_valid(audience_authority)),
315    remote_object_id TEXT NOT NULL CHECK (length(remote_object_id) = 64),
316    PRIMARY KEY (table_name, row_id, column_name, row_stamp),
317    FOREIGN KEY (remote_object_id) REFERENCES blob_locators(remote_object_id)
318"
319        );
320        $visit!(
321            outbound_membership_mutation,
322            "
323    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
324    intent_hash TEXT NOT NULL CHECK (length(intent_hash) = 64),
325    plan_bytes BLOB NOT NULL,
326    progress_bytes BLOB NOT NULL
327"
328        );
329        $visit!(
330            outbound_store_snapshot,
331            "
332    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
333    snapshot_ref TEXT NOT NULL CHECK (json_valid(snapshot_ref)),
334    meta_prepared TEXT NOT NULL CHECK (json_valid(meta_prepared)),
335    image_ref TEXT NOT NULL CHECK (json_valid(image_ref)),
336    image_prepared TEXT NOT NULL CHECK (json_valid(image_prepared)),
337    image_bytes BLOB NOT NULL,
338    meta_bytes BLOB NOT NULL,
339    blobs TEXT NOT NULL CHECK (json_valid(blobs))
340"
341        );
342        $visit!(
343            published_store_snapshot,
344            "
345    generation INTEGER PRIMARY KEY CHECK (generation >= 0),
346    snapshot_ref TEXT NOT NULL CHECK (json_valid(snapshot_ref)),
347    successor_slot TEXT NOT NULL CHECK (json_valid(successor_slot)),
348    meta_bytes BLOB NOT NULL
349"
350        );
351        $visit!(
352            snapshot_blob_spool_cleanup,
353            "
354    path TEXT PRIMARY KEY
355"
356        );
357        $visit!(
358            outbound_circle_snapshot,
359            "
360    circle_id TEXT PRIMARY KEY,
361    snapshot_ref TEXT NOT NULL CHECK (json_valid(snapshot_ref)),
362    meta_prepared TEXT NOT NULL CHECK (json_valid(meta_prepared)),
363    image_ref TEXT NOT NULL CHECK (json_valid(image_ref)),
364    image_prepared TEXT NOT NULL CHECK (json_valid(image_prepared)),
365    image_bytes BLOB NOT NULL,
366    meta_bytes BLOB NOT NULL,
367    blobs TEXT NOT NULL CHECK (json_valid(blobs))
368"
369        );
370        $visit!(
371            published_circle_snapshot,
372            "
373    circle_id TEXT NOT NULL,
374    generation INTEGER NOT NULL CHECK (generation >= 0),
375    snapshot_ref TEXT NOT NULL CHECK (json_valid(snapshot_ref)),
376    successor_slot TEXT NOT NULL CHECK (json_valid(successor_slot)),
377    cut TEXT NOT NULL CHECK (json_valid(cut)),
378    meta_bytes BLOB NOT NULL,
379    PRIMARY KEY (circle_id, generation)
380"
381        );
382        $visit!(
383            outbound_store_acks,
384            "
385    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
386    ack_ref TEXT NOT NULL CHECK (json_valid(ack_ref)),
387    ack_bytes BLOB NOT NULL,
388    prepared_object TEXT NOT NULL CHECK (json_valid(prepared_object)),
389    activation TEXT NOT NULL CHECK (json_valid(activation))
390"
391        );
392        $visit!(
393            published_store_acks,
394            "
395    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
396    ack_ref TEXT NOT NULL CHECK (json_valid(ack_ref)),
397    successor_slot TEXT NOT NULL CHECK (json_valid(successor_slot))
398"
399        );
400        $visit!(
401            outbound_circle_acks,
402            "
403    circle_id TEXT PRIMARY KEY,
404    ack_ref TEXT NOT NULL CHECK (json_valid(ack_ref)),
405    ack_bytes BLOB NOT NULL,
406    prepared_object TEXT NOT NULL CHECK (json_valid(prepared_object))
407"
408        );
409        $visit!(
410            published_circle_acks,
411            "
412    circle_id TEXT PRIMARY KEY,
413    ack_ref TEXT NOT NULL CHECK (json_valid(ack_ref)),
414    successor_slot TEXT NOT NULL CHECK (json_valid(successor_slot)),
415    store_cut TEXT NOT NULL CHECK (json_valid(store_cut)),
416    control_coord TEXT NOT NULL CHECK (json_valid(control_coord))
417"
418        );
419        $visit!(
420            activated_circle_acks,
421            "
422    circle_id TEXT NOT NULL,
423    device_id TEXT NOT NULL,
424    ack_ref TEXT NOT NULL CHECK (json_valid(ack_ref)),
425    activating_commit TEXT NOT NULL CHECK (json_valid(activating_commit)),
426    PRIMARY KEY (circle_id, device_id)
427"
428        );
429        $visit!(
430            activated_store_acks,
431            "
432    device_id TEXT PRIMARY KEY,
433    ack_ref TEXT NOT NULL CHECK (json_valid(ack_ref)),
434    activating_commit TEXT NOT NULL CHECK (json_valid(activating_commit))
435"
436        );
437        $visit!(
438            store_device_exclusion_freezes,
439            "
440    proposal_id TEXT PRIMARY KEY CHECK (length(proposal_id) = 64),
441    proposal_ref TEXT NOT NULL CHECK (json_valid(proposal_ref)),
442    target_cut TEXT NOT NULL CHECK (json_valid(target_cut))
443"
444        );
445        $visit!(
446            outbound_store_device_exclusion,
447            "
448    operation_id TEXT PRIMARY KEY CHECK (length(operation_id) = 64),
449    active_key INTEGER UNIQUE CHECK (active_key IS NULL OR active_key = 1),
450    state TEXT NOT NULL CHECK (json_valid(state))
451"
452        );
453        $visit!(
454            store_reclaim_operations,
455            "
456    authorization_hash TEXT PRIMARY KEY CHECK (length(authorization_hash) = 64),
457    state TEXT NOT NULL CHECK (json_valid(state))
458"
459        );
460        $visit!(
461            store_protocol_root_authority,
462            "
463    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
464    store_root_hash TEXT NOT NULL CHECK (length(store_root_hash) = 64),
465    store_protocol_root_bytes BLOB NOT NULL,
466    store_root_object TEXT NOT NULL CHECK (json_valid(store_root_object))
467"
468        );
469        $visit!(
470            local_store_protocol_root,
471            "
472    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
473    store_root_hash TEXT NOT NULL CHECK (length(store_root_hash) = 64),
474    store_protocol_root_bytes BLOB NOT NULL,
475    prepared_object TEXT NOT NULL CHECK (json_valid(prepared_object))
476"
477        );
478        $visit!(
479            local_store_device_registration,
480            "
481    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
482    device_id TEXT NOT NULL UNIQUE,
483    registration_hash TEXT NOT NULL UNIQUE CHECK (length(registration_hash) = 64),
484    registration_bytes BLOB NOT NULL,
485    prepared_object TEXT NOT NULL CHECK (json_valid(prepared_object)),
486    initial_ack_ref TEXT NOT NULL CHECK (json_valid(initial_ack_ref)),
487    initial_ack_bytes BLOB NOT NULL,
488    initial_ack_prepared TEXT NOT NULL CHECK (json_valid(initial_ack_prepared)),
489    state TEXT NOT NULL CHECK (json_valid(state))
490"
491        );
492        $visit!(
493            local_store_founder_graph,
494            "
495    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
496    membership_graph TEXT NOT NULL CHECK (json_valid(membership_graph))
497"
498        );
499        $visit!(
500            store_device_registration_activations,
501            "
502    device_id TEXT PRIMARY KEY,
503    registration_hash TEXT NOT NULL CHECK (length(registration_hash) = 64),
504    author_pubkey TEXT NOT NULL,
505    device_signing_pubkey TEXT NOT NULL,
506    registration_bytes BLOB NOT NULL,
507    registration_object TEXT NOT NULL CHECK (json_valid(registration_object)),
508    activation_authority TEXT NOT NULL CHECK (json_valid(activation_authority)),
509    UNIQUE (device_id, registration_hash),
510    UNIQUE (registration_object)
511"
512        );
513        $visit!(
514            store_device_state_snapshots,
515            "
516    commit_ref TEXT PRIMARY KEY CHECK (json_valid(commit_ref)),
517    state TEXT NOT NULL CHECK (json_valid(state))
518"
519        );
520        $visit!(
521            store_author_exclusion_activations,
522            "
523    exclusion_ref TEXT PRIMARY KEY CHECK (json_valid(exclusion_ref)),
524    accepted_cut TEXT NOT NULL CHECK (json_valid(accepted_cut)),
525    activation_commit TEXT NOT NULL CHECK (json_valid(activation_commit)),
526    activation_head TEXT NOT NULL CHECK (json_valid(activation_head))
527"
528        );
529        $visit!(
530            circle_control_activations,
531            "
532    circle_id TEXT NOT NULL,
533    control_coord TEXT NOT NULL CHECK (json_valid(control_coord)),
534    stream_id TEXT NOT NULL,
535    seq INTEGER NOT NULL CHECK (seq > 0),
536    commit_hash TEXT NOT NULL CHECK (length(commit_hash) = 64),
537    control_bytes BLOB NOT NULL,
538    PRIMARY KEY (circle_id, control_coord),
539    UNIQUE (circle_id, stream_id, seq)
540"
541        );
542        $visit!(
543            circle_operations,
544            "
545    operation_id TEXT PRIMARY KEY,
546    circle_id TEXT NOT NULL UNIQUE,
547    payload BLOB NOT NULL
548"
549        );
550        $visit!(
551            circle_access_cache,
552            "
553    circle_id TEXT NOT NULL,
554    control_coord TEXT NOT NULL CHECK (json_valid(control_coord)),
555    owner_pubkey TEXT NOT NULL,
556    disposition TEXT NOT NULL CHECK (disposition IN ('active', 'inactive')),
557    access_bytes BLOB NOT NULL,
558    PRIMARY KEY (circle_id, control_coord, owner_pubkey),
559    FOREIGN KEY (circle_id, control_coord)
560        REFERENCES circle_control_activations(circle_id, control_coord)
561"
562        );
563        $visit!(
564            circle_roster_cache,
565            "
566    circle_id TEXT NOT NULL,
567    control_coord TEXT NOT NULL CHECK (json_valid(control_coord)),
568    roster_bytes BLOB NOT NULL,
569    PRIMARY KEY (circle_id, control_coord),
570    FOREIGN KEY (circle_id, control_coord)
571        REFERENCES circle_control_activations(circle_id, control_coord)
572"
573        );
574        $visit!(
575            circle_metadata_cache,
576            "
577    circle_id TEXT NOT NULL,
578    control_coord TEXT NOT NULL CHECK (json_valid(control_coord)),
579    metadata_bytes BLOB NOT NULL,
580    PRIMARY KEY (circle_id, control_coord),
581    FOREIGN KEY (circle_id, control_coord)
582        REFERENCES circle_control_activations(circle_id, control_coord)
583"
584        );
585        $visit!(
586            circle_current_state,
587            "
588    circle_id TEXT PRIMARY KEY,
589    state BLOB NOT NULL
590"
591        );
592    };
593}
594
595macro_rules! coven_routing_tables {
596    ($visit:ident) => {
597        $visit!(
598            _coven_audience,
599            "
600    routing_id TEXT PRIMARY KEY,
601    circle_id TEXT,
602    _updated_at TEXT NOT NULL
603"
604        );
605        $visit!(
606            _coven_row_routes,
607            "
608    routing_id TEXT PRIMARY KEY,
609    table_name TEXT NOT NULL,
610    row_id TEXT NOT NULL,
611    _updated_at TEXT NOT NULL,
612    UNIQUE (table_name, row_id)
613"
614        );
615    };
616}
617
618const OBJECT_OWNERSHIP_TRIGGERS: &str = "
619CREATE TRIGGER IF NOT EXISTS remote_object_identity_must_not_be_inert_on_insert
620BEFORE INSERT ON remote_objects
621WHEN EXISTS (
622    SELECT 1 FROM protocol_inert_objects WHERE object_id = NEW.object_id
623)
624BEGIN
625    SELECT RAISE(ABORT, 'remote object identity is protocol-inert');
626END;
627CREATE TRIGGER IF NOT EXISTS remote_object_identity_must_not_be_inert_on_update
628BEFORE UPDATE OF object_id ON remote_objects
629WHEN EXISTS (
630    SELECT 1 FROM protocol_inert_objects WHERE object_id = NEW.object_id
631)
632BEGIN
633    SELECT RAISE(ABORT, 'remote object identity is protocol-inert');
634END;
635CREATE TRIGGER IF NOT EXISTS inert_object_identity_must_not_be_remote_on_insert
636BEFORE INSERT ON protocol_inert_objects
637WHEN EXISTS (
638    SELECT 1 FROM remote_objects WHERE object_id = NEW.object_id
639)
640BEGIN
641    SELECT RAISE(ABORT, 'protocol-inert object identity has active ownership');
642END;
643CREATE TRIGGER IF NOT EXISTS inert_object_identity_must_not_be_remote_on_update
644BEFORE UPDATE OF object_id ON protocol_inert_objects
645WHEN EXISTS (
646    SELECT 1 FROM remote_objects WHERE object_id = NEW.object_id
647)
648BEGIN
649    SELECT RAISE(ABORT, 'protocol-inert object identity has active ownership');
650END;
651CREATE TRIGGER IF NOT EXISTS remote_object_identity_must_not_be_reclaimed_on_insert
652BEFORE INSERT ON remote_objects
653WHEN EXISTS (
654    SELECT 1 FROM reclaimed_store_packages WHERE object_id = NEW.object_id
655)
656BEGIN
657    SELECT RAISE(ABORT, 'remote object identity is a reclaimed Store package');
658END;
659CREATE TRIGGER IF NOT EXISTS remote_object_identity_must_not_be_reclaimed_on_update
660BEFORE UPDATE OF object_id ON remote_objects
661WHEN EXISTS (
662    SELECT 1 FROM reclaimed_store_packages WHERE object_id = NEW.object_id
663)
664BEGIN
665    SELECT RAISE(ABORT, 'remote object identity is a reclaimed Store package');
666END;
667CREATE TRIGGER IF NOT EXISTS inert_object_identity_must_not_be_reclaimed_on_insert
668BEFORE INSERT ON protocol_inert_objects
669WHEN EXISTS (
670    SELECT 1 FROM reclaimed_store_packages WHERE object_id = NEW.object_id
671)
672BEGIN
673    SELECT RAISE(ABORT, 'protocol-inert object identity is a reclaimed Store package');
674END;
675CREATE TRIGGER IF NOT EXISTS inert_object_identity_must_not_be_reclaimed_on_update
676BEFORE UPDATE OF object_id ON protocol_inert_objects
677WHEN EXISTS (
678    SELECT 1 FROM reclaimed_store_packages WHERE object_id = NEW.object_id
679)
680BEGIN
681    SELECT RAISE(ABORT, 'protocol-inert object identity is a reclaimed Store package');
682END;
683CREATE TRIGGER IF NOT EXISTS reclaimed_store_package_identity_must_be_closed_on_insert
684BEFORE INSERT ON reclaimed_store_packages
685WHEN EXISTS (
686    SELECT 1 FROM remote_objects WHERE object_id = NEW.object_id
687    UNION ALL
688    SELECT 1 FROM protocol_inert_objects WHERE object_id = NEW.object_id
689)
690BEGIN
691    SELECT RAISE(ABORT, 'reclaimed Store package identity has another ownership state');
692END;
693CREATE TRIGGER IF NOT EXISTS reclaimed_store_package_identity_must_be_closed_on_update
694BEFORE UPDATE OF object_id ON reclaimed_store_packages
695WHEN EXISTS (
696    SELECT 1 FROM remote_objects WHERE object_id = NEW.object_id
697    UNION ALL
698    SELECT 1 FROM protocol_inert_objects WHERE object_id = NEW.object_id
699)
700BEGIN
701    SELECT RAISE(ABORT, 'reclaimed Store package identity has another ownership state');
702END;
703";
704
705#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
706#[serde(deny_unknown_fields)]
707pub(crate) struct CovenSchemaManifest {
708    objects: Vec<CovenSchemaObject>,
709    tables: Vec<CovenTableShape>,
710}
711
712impl CovenSchemaManifest {
713    pub(crate) fn is_empty(&self) -> bool {
714        self.objects.is_empty() && self.tables.is_empty()
715    }
716}
717
718#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
719#[serde(deny_unknown_fields)]
720struct CovenSchemaObject {
721    kind: String,
722    name: String,
723    table_name: String,
724    sql: Option<String>,
725}
726
727#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
728#[serde(deny_unknown_fields)]
729struct CovenTableShape {
730    name: String,
731    columns: i64,
732    without_rowid: bool,
733    strict: bool,
734}
735
736fn normalize_schema_sql(sql: &str) -> String {
737    let bytes = sql.as_bytes();
738    let mut normalized = String::with_capacity(sql.len());
739    let mut index = 0;
740    let mut pending_separator = false;
741    let mut quote = None;
742
743    while index < bytes.len() {
744        let byte = bytes[index];
745        if let Some(delimiter) = quote {
746            normalized.push(char::from(byte));
747            if byte == delimiter {
748                if bytes.get(index + 1) == Some(&delimiter) {
749                    normalized.push(char::from(delimiter));
750                    index += 1;
751                } else {
752                    quote = None;
753                }
754            }
755            index += 1;
756            continue;
757        }
758
759        if byte == b'-' && bytes.get(index + 1) == Some(&b'-') {
760            index += 2;
761            while index < bytes.len() && !matches!(bytes[index], b'\n' | b'\r') {
762                index += 1;
763            }
764            pending_separator = true;
765            continue;
766        }
767        if byte.is_ascii_whitespace() {
768            pending_separator = true;
769            index += 1;
770            continue;
771        }
772        if matches!(byte, b'\'' | b'"' | b'`') {
773            if pending_separator
774                && normalized
775                    .as_bytes()
776                    .last()
777                    .is_some_and(u8::is_ascii_alphanumeric)
778            {
779                normalized.push(' ');
780            }
781            pending_separator = false;
782            quote = Some(byte);
783            normalized.push(char::from(byte));
784            index += 1;
785            continue;
786        }
787        if pending_separator
788            && byte.is_ascii_alphanumeric()
789            && normalized
790                .as_bytes()
791                .last()
792                .is_some_and(u8::is_ascii_alphanumeric)
793        {
794            normalized.push(' ');
795        }
796        pending_separator = false;
797        normalized.push(char::from(byte.to_ascii_lowercase()));
798        index += 1;
799    }
800
801    normalized
802}
803
804fn all_coven_table_names() -> std::collections::BTreeSet<&'static str> {
805    let mut names = std::collections::BTreeSet::new();
806    macro_rules! collect_name {
807        ($name:ident, $columns:literal) => {
808            names.insert(stringify!($name));
809        };
810    }
811
812    coven_tables!(collect_name);
813    coven_routing_tables!(collect_name);
814    names
815}
816
817#[cfg(test)]
818pub(crate) fn all_table_names() -> std::collections::BTreeSet<&'static str> {
819    all_coven_table_names()
820}
821
822pub(crate) fn live_coven_schema_manifest(
823    conn: &rusqlite::Connection,
824) -> rusqlite::Result<CovenSchemaManifest> {
825    let names = all_coven_table_names();
826    let mut objects = conn
827        .prepare(
828            "SELECT type, name, tbl_name, sql
829             FROM main.sqlite_schema
830             WHERE type IN ('table', 'index', 'trigger')
831             ORDER BY type, name",
832        )?
833        .query_map([], |row| {
834            Ok(CovenSchemaObject {
835                kind: row.get(0)?,
836                name: row.get(1)?,
837                table_name: row.get(2)?,
838                sql: row
839                    .get::<_, Option<String>>(3)?
840                    .map(|sql| normalize_schema_sql(&sql)),
841            })
842        })?
843        .collect::<rusqlite::Result<Vec<_>>>()?;
844    objects.retain(|object| names.contains(object.table_name.as_str()));
845
846    let mut tables = conn
847        .prepare("PRAGMA main.table_list")?
848        .query_map([], |row| {
849            Ok(CovenTableShape {
850                name: row.get(1)?,
851                columns: row.get(3)?,
852                without_rowid: row.get::<_, i64>(4)? != 0,
853                strict: row.get::<_, i64>(5)? != 0,
854            })
855        })?
856        .collect::<rusqlite::Result<Vec<_>>>()?;
857    tables.retain(|table| names.contains(table.name.as_str()));
858    tables.sort_by(|left, right| left.name.cmp(&right.name));
859
860    Ok(CovenSchemaManifest { objects, tables })
861}
862
863pub(crate) fn expected_coven_schema_manifest(
864    include_routing: bool,
865) -> rusqlite::Result<CovenSchemaManifest> {
866    let conn = rusqlite::Connection::open_in_memory()?;
867    apply_coven_schema(&conn)?;
868    if include_routing {
869        apply_coven_routing_schema(&conn)?;
870    }
871    live_coven_schema_manifest(&conn)
872}
873
874/// Creates Coven's bookkeeping tables after the fresh host schema has passed
875/// sync-routing validation, inside the same open transaction. Idempotent (`IF
876/// NOT EXISTS`). STRICT: every column here is already TEXT/INTEGER/BLOB, so
877/// STRICT only forecloses a future column drifting off its declared affinity.
878pub(crate) fn apply_coven_schema(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
879    macro_rules! apply_table {
880        ($name:ident, $columns:literal) => {
881            conn.execute_batch(concat!(
882                "CREATE TABLE IF NOT EXISTS ",
883                stringify!($name),
884                " (",
885                $columns,
886                ") STRICT;"
887            ))?;
888        };
889    }
890
891    coven_tables!(apply_table);
892    conn.execute_batch(OBJECT_OWNERSHIP_TRIGGERS)?;
893    Ok(())
894}
895
896/// Create the audience mirror and private route map. A snapshot already carries
897/// these schemas, so creation is idempotent; the caller validates their exact
898/// shape before committing initialization.
899pub(crate) fn apply_coven_routing_schema(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
900    macro_rules! apply_table {
901        ($name:ident, $columns:literal) => {
902            conn.execute_batch(concat!(
903                "CREATE TABLE IF NOT EXISTS ",
904                stringify!($name),
905                " (",
906                $columns,
907                ") STRICT, WITHOUT ROWID;"
908            ))?;
909        };
910    }
911
912    coven_routing_tables!(apply_table);
913    Ok(())
914}
915
916/// Whether `name` is a table coven owns for sync bookkeeping. Hosts may not
917/// declare these as synced tables.
918pub(crate) fn is_reserved_table_name(name: &str) -> bool {
919    macro_rules! matches_table {
920        ($table:ident, $columns:literal) => {
921            if name == stringify!($table) {
922                return true;
923            }
924        };
925    }
926
927    coven_tables!(matches_table);
928    coven_routing_tables!(matches_table);
929    false
930}
931
932/// Every application and Coven table in the main schema, excluding SQLite's
933/// internal tables. Snapshot and retained-replay projections share this one
934/// enumeration so a new table cannot be omitted by one image path.
935pub(crate) fn user_table_names(conn: &rusqlite::Connection) -> rusqlite::Result<Vec<String>> {
936    let mut statement = conn.prepare(
937        "SELECT name FROM main.sqlite_schema
938         WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
939         ORDER BY name",
940    )?;
941    let tables = statement
942        .query_map([], |row| row.get::<_, String>(0))?
943        .collect::<rusqlite::Result<Vec<_>>>()?;
944    Ok(tables)
945}
946
947/// The name of every table [`apply_coven_schema`] creates, for a test to assert a
948/// schema property (STRICT) holds across all of them without re-listing the set
949/// by hand.
950#[cfg(test)]
951pub(crate) fn table_names() -> Vec<&'static str> {
952    let mut names = Vec::new();
953    macro_rules! collect_name {
954        ($name:ident, $columns:literal) => {
955            names.push(stringify!($name));
956        };
957    }
958
959    coven_tables!(collect_name);
960    names
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    #[test]
968    fn schema_sql_normalization_ignores_formatting_but_keeps_constraints() {
969        let formatted = normalize_schema_sql(
970            "CREATE TABLE t (
971                id INTEGER PRIMARY KEY, -- explanatory text
972                value TEXT CHECK (length(value) = 64)
973             ) STRICT",
974        );
975        let compact = normalize_schema_sql(
976            "create table t(id integer primary key,value text check(length(value)=64)) strict",
977        );
978        let changed = normalize_schema_sql(
979            "create table t(id integer primary key,value text check(length(value)=32)) strict",
980        );
981
982        assert_eq!(formatted, compact);
983        assert_ne!(formatted, changed);
984    }
985
986    /// Every bookkeeping table [`apply_coven_schema`] creates is declared STRICT
987    /// — the same guarantee the synced-table contract now requires of the host's
988    /// own tables, so coven does not exempt itself from the invariant it enforces
989    /// on the host.
990    #[test]
991    fn every_bookkeeping_table_is_strict() {
992        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
993        apply_coven_schema(&conn).expect("apply coven schema");
994        for name in table_names() {
995            let sql = format!(
996                "PRAGMA table_list({})",
997                crate::sync::session::quote_ident(name)
998            );
999            let mut stmt = conn.prepare(&sql).expect("prepare table_list");
1000            let strict: i64 = stmt
1001                .query_row([], |row| row.get(5))
1002                .unwrap_or_else(|e| panic!("PRAGMA table_list({name}): {e}"));
1003            assert_eq!(strict, 1, "{name} must be STRICT");
1004        }
1005    }
1006
1007    #[test]
1008    fn active_and_protocol_inert_object_identities_are_disjoint() {
1009        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1010        apply_coven_schema(&conn).expect("apply coven schema");
1011        let active_first = "a".repeat(64);
1012        conn.execute(
1013            "INSERT INTO remote_objects (object_id, state) VALUES (?1, '{}')",
1014            [&active_first],
1015        )
1016        .expect("insert active object");
1017        assert!(
1018            conn.execute(
1019                "INSERT INTO protocol_inert_objects (object_id, state) VALUES (?1, '{}')",
1020                [&active_first],
1021            )
1022            .is_err(),
1023            "an active object identity must not also become protocol-inert"
1024        );
1025
1026        let inert_first = "b".repeat(64);
1027        conn.execute(
1028            "INSERT INTO protocol_inert_objects (object_id, state) VALUES (?1, '{}')",
1029            [&inert_first],
1030        )
1031        .expect("insert protocol-inert object");
1032        assert!(
1033            conn.execute(
1034                "INSERT INTO remote_objects (object_id, state) VALUES (?1, '{}')",
1035                [&inert_first],
1036            )
1037            .is_err(),
1038            "a protocol-inert object identity must not return to active ownership"
1039        );
1040        assert!(
1041            conn.execute(
1042                "UPDATE remote_objects SET object_id = ?1 WHERE object_id = ?2",
1043                [&inert_first, &active_first],
1044            )
1045            .is_err(),
1046            "an active identity update must not collide with protocol-inert ownership"
1047        );
1048        assert!(
1049            conn.execute(
1050                "UPDATE protocol_inert_objects SET object_id = ?1 WHERE object_id = ?2",
1051                [&active_first, &inert_first],
1052            )
1053            .is_err(),
1054            "a protocol-inert identity update must not collide with active ownership"
1055        );
1056    }
1057
1058    #[test]
1059    fn circle_operation_journal_has_one_progress_representation() {
1060        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1061        apply_coven_schema(&conn).expect("apply coven schema");
1062        let mut statement = conn
1063            .prepare("PRAGMA table_info(circle_operations)")
1064            .expect("prepare circle_operations table_info");
1065        let columns = statement
1066            .query_map([], |row| row.get::<_, String>(1))
1067            .expect("query circle_operations columns")
1068            .collect::<rusqlite::Result<Vec<_>>>()
1069            .expect("read circle_operations columns");
1070
1071        assert_eq!(columns, ["operation_id", "circle_id", "payload"]);
1072    }
1073
1074    #[test]
1075    fn author_exclusion_activation_locator_has_one_exact_row_shape() {
1076        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1077        apply_coven_schema(&conn).expect("apply coven schema");
1078        let columns = conn
1079            .prepare("PRAGMA table_info(store_author_exclusion_activations)")
1080            .expect("prepare exclusion activation table_info")
1081            .query_map([], |row| {
1082                Ok((row.get::<_, String>(1)?, row.get::<_, i64>(5)?))
1083            })
1084            .expect("query exclusion activation columns")
1085            .map(|row| row.expect("read exclusion activation column"))
1086            .collect::<Vec<_>>();
1087
1088        assert_eq!(
1089            columns,
1090            [
1091                ("exclusion_ref".to_string(), 1),
1092                ("accepted_cut".to_string(), 0),
1093                ("activation_commit".to_string(), 0),
1094                ("activation_head".to_string(), 0),
1095            ]
1096        );
1097    }
1098
1099    #[test]
1100    fn merge_materialization_requires_its_exact_retained_input() {
1101        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1102        conn.pragma_update(None, "foreign_keys", "ON")
1103            .expect("enable foreign keys");
1104        apply_coven_schema(&conn).expect("apply coven schema");
1105
1106        conn.execute(
1107            "INSERT INTO materialized_commits (device_id, seq, commit_ref)
1108             VALUES ('merge-stream', 1, '{}')",
1109            [],
1110        )
1111        .expect_err("Merge materialization without retained input must fail");
1112
1113        conn.execute(
1114            "INSERT INTO retained_merge_materializations
1115             (device_id, seq, commit_ref, input_hash, canonical_input)
1116             VALUES ('merge-stream', 1, '{}', ?1, x'7b7d')",
1117            ["a".repeat(64)],
1118        )
1119        .expect("retain exact Merge input");
1120        conn.execute(
1121            "INSERT INTO materialized_commits
1122             (device_id, seq, commit_ref, retained_commit_ref, retained_input_hash)
1123             VALUES ('merge-stream', 1, '{}', '{}', ?1)",
1124            ["a".repeat(64)],
1125        )
1126        .expect("materialize Merge commit with retained input");
1127    }
1128
1129    #[test]
1130    fn retained_replay_baseline_has_one_closed_active_row() {
1131        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1132        apply_coven_schema(&conn).expect("apply coven schema");
1133        let insert = |singleton: i64, generation: i64| {
1134            conn.execute(
1135                "INSERT INTO retained_replay_baselines
1136                 (singleton, generation, exact_cut, schema_version,
1137                  routing_hash, image_hash, image_bytes, authority_bytes)
1138                 VALUES (?1, ?2, '{}', 1, ?3, ?4, x'01', x'01')",
1139                rusqlite::params![singleton, generation, "a".repeat(64), "b".repeat(64)],
1140            )
1141        };
1142
1143        insert(1, -1).expect_err("baseline generation cannot be negative");
1144        insert(1, 0).expect("insert generation-zero baseline");
1145        insert(1, 1).expect_err("a second active baseline must fail");
1146        insert(2, 1).expect_err("the baseline key must remain the singleton");
1147        insert(0, 1).expect_err("baseline generation cannot use another singleton key");
1148    }
1149
1150    #[test]
1151    fn retained_replay_object_index_requires_both_exact_records() {
1152        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1153        conn.pragma_update(None, "foreign_keys", "ON")
1154            .expect("enable foreign keys");
1155        apply_coven_schema(&conn).expect("apply coven schema");
1156        let input_hash = "a".repeat(64);
1157        let object_id = "b".repeat(64);
1158        conn.execute(
1159            "INSERT INTO retained_merge_materializations
1160             (device_id, seq, commit_ref, input_hash, canonical_input)
1161             VALUES ('merge-stream', 1, '{}', ?1, x'7b7d')",
1162            [&input_hash],
1163        )
1164        .expect("retain exact Merge input");
1165        conn.execute(
1166            "INSERT INTO remote_objects (object_id, state) VALUES (?1, '{}')",
1167            [&object_id],
1168        )
1169        .expect("insert exact remote object");
1170        conn.execute(
1171            "INSERT INTO retained_replay_objects
1172             (device_id, seq, commit_ref, input_hash, object_id)
1173             VALUES ('merge-stream', 1, '{}', ?1, ?2)",
1174            [&input_hash, &object_id],
1175        )
1176        .expect("index retained replay ownership");
1177
1178        assert!(conn
1179            .execute(
1180                "DELETE FROM retained_merge_materializations
1181                 WHERE device_id = 'merge-stream' AND seq = 1",
1182                [],
1183            )
1184            .is_err());
1185        assert!(conn
1186            .execute(
1187                "DELETE FROM remote_objects WHERE object_id = ?1",
1188                [&object_id],
1189            )
1190            .is_err());
1191    }
1192
1193    #[test]
1194    fn circle_operation_journal_allows_one_pending_operation_per_circle() {
1195        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1196        apply_coven_schema(&conn).expect("apply coven schema");
1197        conn.execute(
1198            "INSERT INTO circle_operations (operation_id, circle_id, payload)
1199             VALUES ('operation-a', 'circle-a', x'7b7d')",
1200            [],
1201        )
1202        .expect("insert first pending Circle operation");
1203
1204        let error = conn
1205            .execute(
1206                "INSERT INTO circle_operations (operation_id, circle_id, payload)
1207                 VALUES ('operation-b', 'circle-a', x'7b7d')",
1208                [],
1209            )
1210            .expect_err("a Circle cannot have a second pending operation");
1211
1212        assert!(error.to_string().contains("circle_operations.circle_id"));
1213    }
1214
1215    #[test]
1216    fn prepared_blob_identity_is_the_exact_remote_object() {
1217        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1218        apply_coven_schema(&conn).expect("apply coven schema");
1219        let primary_key = conn
1220            .prepare("PRAGMA table_info(store_write_blobs)")
1221            .expect("prepare store_write_blobs table_info")
1222            .query_map([], |row| {
1223                Ok((row.get::<_, String>(1)?, row.get::<_, i64>(5)?))
1224            })
1225            .expect("query store_write_blobs columns")
1226            .filter_map(|row| {
1227                let (name, position) = row.expect("read store_write_blobs column");
1228                (position != 0).then_some((position, name))
1229            })
1230            .collect::<std::collections::BTreeMap<_, _>>();
1231
1232        assert_eq!(
1233            primary_key.into_values().collect::<Vec<_>>(),
1234            ["write_id", "audience", "remote_object_id"]
1235        );
1236    }
1237
1238    #[test]
1239    fn routing_tables_are_strict_without_rowid() {
1240        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1241        apply_coven_routing_schema(&conn).expect("apply routing schema");
1242        for name in ["_coven_audience", "_coven_row_routes"] {
1243            let sql = format!(
1244                "PRAGMA table_list({})",
1245                crate::sync::session::quote_ident(name)
1246            );
1247            let (wr, strict): (i64, i64) = conn
1248                .query_row(&sql, [], |row| Ok((row.get(4)?, row.get(5)?)))
1249                .expect("table_list");
1250            assert_eq!((wr, strict), (1, 1), "{name}");
1251        }
1252    }
1253}
1254
1255/// An external user-owned file a blob id resolves to, read back from a
1256/// `local_blob_refs` row. The blob's plaintext lives at `path` (an absolute file
1257/// coven references but does not own); `size` is its registered plaintext length,
1258/// combined with the row's signed content hash to validate the exact file. The `namespace`
1259/// stays on the row but is not part of the read shape, so it is not carried here.
1260#[derive(Debug, Clone, PartialEq, Eq)]
1261pub struct ExternalBlob {
1262    /// Absolute path to the external file coven reads but does not own.
1263    pub path: std::path::PathBuf,
1264    /// The file's plaintext length at registration. A read fails loud if the
1265    /// file's current length differs (truncated, replaced) — validate-on-read.
1266    pub size: u64,
1267}
1268
1269/// How far a gated root's make-remote transition has got, as its durable
1270/// intent records it. The write id a publication carries is bookkeeping the
1271/// transition owns, so it is not part of this.
1272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1273pub enum MakeRemoteProgress {
1274    /// Blobs are queued or uploading.
1275    Uploading,
1276    /// A cancellation is unwinding the transition.
1277    Cancelling,
1278    /// Every upload landed; the Store write that publishes them is pending.
1279    Publishing,
1280}
1281
1282/// One cloud object the durable queue is holding a tombstone for.
1283///
1284/// A delete carries only the stored object it removes — there is no row left to
1285/// name, which is the point: the row is gone and this is what still has to
1286/// happen in the cloud.
1287#[derive(Debug, Clone, PartialEq, Eq)]
1288pub struct QueuedDelete {
1289    /// The namespace the removed blob lived in.
1290    pub namespace: String,
1291    /// The removed blob's id within that namespace.
1292    pub blob_id: String,
1293    /// Failed removal attempts so far; 0 for one never yet tried.
1294    pub attempt_count: u64,
1295    /// Why the last attempt failed, if one has.
1296    pub last_error: Option<String>,
1297    /// When the tombstone was enqueued.
1298    pub created_at: String,
1299    /// When it was last attempted, if it has been.
1300    pub last_attempt_at: Option<String>,
1301}
1302
1303/// One upload the durable cloud queue is holding, as a host renders it.
1304///
1305/// This is a projection of the queue row, not the drain's working entry: it
1306/// carries what a person needs to see — which blob, which row it belongs to,
1307/// whether it is retried and why — and none of the transfer bookkeeping the
1308/// drain needs. `attempt_count` is 0 and `last_error` is `None` until a
1309/// transfer has actually been tried and failed, so a freshly queued upload is
1310/// distinguishable from a retrying one.
1311#[derive(Debug, Clone, PartialEq, Eq)]
1312pub struct QueuedUpload {
1313    /// The blob's namespace, from the queued row reference.
1314    pub namespace: String,
1315    /// The blob's id within that namespace.
1316    pub blob_id: String,
1317    /// The blob-bearing row this upload belongs to.
1318    pub table_name: String,
1319    pub row_id: String,
1320    /// The gated root whose make-remote enqueued this upload. Every upload for
1321    /// one root shares this pair, and the root is what a host groups by.
1322    pub root_table: String,
1323    pub root_id: String,
1324    /// Whether the transition asked for the plaintext to stay cached locally
1325    /// once the upload lands.
1326    pub retain_pinned: bool,
1327    /// Failed transfer attempts so far; 0 for an upload never yet tried.
1328    pub attempt_count: u64,
1329    /// Why the last attempt failed, if one has.
1330    pub last_error: Option<String>,
1331    /// When the upload was enqueued.
1332    pub created_at: String,
1333    /// When it was last attempted, if it has been.
1334    pub last_attempt_at: Option<String>,
1335}
1336
1337#[derive(Debug, Clone, PartialEq, Eq)]
1338pub struct OutboxEntry {
1339    pub id: i64,
1340    pub attempt_count: i64,
1341    pub last_attempt_at: Option<String>,
1342    pub operation: OutboxOperation,
1343}
1344
1345#[derive(Debug, Clone, PartialEq, Eq)]
1346pub enum OutboxOperation {
1347    Upload {
1348        root_table: String,
1349        root_id: String,
1350        row: crate::blob::RowBlobRef,
1351        source_path: std::path::PathBuf,
1352        retain_pinned: bool,
1353        state: OutboxUploadState,
1354    },
1355    Delete {
1356        stored: crate::blob::locator::StoredBlobRef,
1357    },
1358}
1359
1360#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1361#[serde(rename_all = "snake_case", deny_unknown_fields)]
1362pub enum OutboxUploadState {
1363    Pending,
1364    Prepared {
1365        authority: crate::sync::audience_package::PackageAudience,
1366        stored: crate::blob::locator::StoredBlobRef,
1367        spool_path: std::path::PathBuf,
1368    },
1369    Created {
1370        authority: crate::sync::audience_package::PackageAudience,
1371        stored: crate::blob::locator::StoredBlobRef,
1372        spool_path: std::path::PathBuf,
1373    },
1374}