Skip to main content

kcode_k1_groups_projection/
lib.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs,
4    hash::Hash,
5    path::{Path, PathBuf},
6    sync::{
7        Mutex, RwLock,
8        atomic::{AtomicBool, Ordering},
9    },
10    time::{Duration, Instant},
11};
12
13pub use kcode_k1_invites::UserId;
14use kcode_k1_transaction::{SubsystemId, Transaction};
15use kcode_k1_txn_ordering::K1TxnOrdering;
16pub use kcode_k1_txn_ordering::TxId;
17use rusqlite::{Connection, Error as SqlError, TransactionBehavior, params};
18
19const DATABASE_NAME: &str = "groups.sqlite3";
20const SUBSYSTEM: &str = "k1-groups-subsystem";
21const CREATE_METADATA: &str = "CREATE TABLE metadata (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), schema_version INTEGER NOT NULL, last_applied_txid BLOB)";
22const CREATE_GROUPS: &str =
23    "CREATE TABLE groups (group_id BLOB PRIMARY KEY, revision BLOB NOT NULL)";
24const CREATE_USERS: &str = "CREATE TABLE user_memberships (group_id BLOB NOT NULL, user_id BLOB NOT NULL, role INTEGER NOT NULL, PRIMARY KEY (group_id, user_id), FOREIGN KEY (group_id) REFERENCES groups(group_id) ON DELETE CASCADE)";
25const CREATE_MODELS: &str = "CREATE TABLE model_memberships (group_id BLOB NOT NULL, model_id BLOB NOT NULL, PRIMARY KEY (group_id, model_id), FOREIGN KEY (group_id) REFERENCES groups(group_id) ON DELETE CASCADE)";
26const CREATE_USER_INDEX: &str =
27    "CREATE INDEX user_memberships_by_user ON user_memberships (user_id, group_id)";
28const CREATE_MODEL_INDEX: &str =
29    "CREATE INDEX model_memberships_by_model ON model_memberships (model_id, group_id)";
30
31#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub struct GroupId(TxId);
33
34#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
35pub enum SentinelGroup {
36    AllUsers,
37    AllModels,
38    LocalModels,
39}
40
41pub const ALL_USERS: GroupId = GroupId::new(TxId::from_bytes([
42    255, 75, 49, 71, 82, 80, 0, 0, 0, 0, 0, 1,
43]));
44pub const ALL_MODELS: GroupId = GroupId::new(TxId::from_bytes([
45    255, 75, 49, 71, 82, 80, 0, 0, 0, 0, 0, 2,
46]));
47pub const LOCAL_MODELS: GroupId = GroupId::new(TxId::from_bytes([
48    255, 75, 49, 71, 82, 80, 0, 0, 0, 0, 0, 3,
49]));
50
51impl GroupId {
52    pub const fn new(txid: TxId) -> Self {
53        Self(txid)
54    }
55
56    pub const fn txid(self) -> TxId {
57        self.0
58    }
59
60    pub const fn sentinel(self) -> Option<SentinelGroup> {
61        match self {
62            ALL_USERS => Some(SentinelGroup::AllUsers),
63            ALL_MODELS => Some(SentinelGroup::AllModels),
64            LOCAL_MODELS => Some(SentinelGroup::LocalModels),
65            _ => None,
66        }
67    }
68}
69
70#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
71pub struct ModelId([u8; 32]);
72
73impl ModelId {
74    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
75        Self(bytes)
76    }
77
78    pub const fn as_bytes(&self) -> &[u8; 32] {
79        &self.0
80    }
81
82    pub const fn into_bytes(self) -> [u8; 32] {
83        self.0
84    }
85}
86
87#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
88pub enum GroupRole {
89    User,
90    Admin,
91    Owner,
92}
93
94impl GroupRole {
95    fn stored(self) -> i64 {
96        match self {
97            Self::User => 0,
98            Self::Admin => 1,
99            Self::Owner => 2,
100        }
101    }
102
103    fn from_stored(value: i64) -> Option<Self> {
104        match value {
105            0 => Some(Self::User),
106            1 => Some(Self::Admin),
107            2 => Some(Self::Owner),
108            _ => None,
109        }
110    }
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct GroupUser {
115    user_id: UserId,
116    role: GroupRole,
117}
118
119impl GroupUser {
120    pub fn user_id(&self) -> UserId {
121        self.user_id
122    }
123
124    pub fn role(&self) -> GroupRole {
125        self.role
126    }
127}
128
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct GroupRevision {
131    group_id: GroupId,
132    txid: TxId,
133}
134
135impl GroupRevision {
136    pub fn group_id(&self) -> GroupId {
137        self.group_id
138    }
139
140    pub fn txid(&self) -> TxId {
141        self.txid
142    }
143}
144
145#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct Group {
147    id: GroupId,
148    revision: GroupRevision,
149    users: Vec<GroupUser>,
150    models: Vec<ModelId>,
151}
152
153impl Group {
154    pub fn id(&self) -> GroupId {
155        self.id
156    }
157
158    pub fn revision(&self) -> &GroupRevision {
159        &self.revision
160    }
161
162    pub fn users(&self) -> &[GroupUser] {
163        &self.users
164    }
165
166    pub fn models(&self) -> &[ModelId] {
167        &self.models
168    }
169}
170
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct GroupMemberships {
173    revision: Option<TxId>,
174    user_groups: Vec<GroupId>,
175    model_groups: Vec<GroupId>,
176    shared_groups: Vec<GroupId>,
177}
178
179impl GroupMemberships {
180    pub fn revision(&self) -> Option<TxId> {
181        self.revision
182    }
183
184    pub fn user_groups(&self) -> &[GroupId] {
185        &self.user_groups
186    }
187
188    pub fn model_groups(&self) -> &[GroupId] {
189        &self.model_groups
190    }
191
192    pub fn shared_groups(&self) -> &[GroupId] {
193        &self.shared_groups
194    }
195}
196
197#[derive(Clone, Debug, Eq, PartialEq)]
198pub enum GroupAction {
199    Create {
200        owner: UserId,
201    },
202    SetUserRole {
203        group: GroupId,
204        actor: UserId,
205        user: UserId,
206        role: Option<GroupRole>,
207    },
208    SetModelMembership {
209        group: GroupId,
210        actor: UserId,
211        model: ModelId,
212        present: bool,
213    },
214}
215
216#[derive(Clone, Debug, Eq, PartialEq)]
217pub enum ApplyOutcome {
218    Applied(GroupRevision),
219    Unchanged(GroupRevision),
220    Rejected(String),
221}
222
223#[derive(Default)]
224struct State {
225    cursor: Option<TxId>,
226    groups: HashMap<GroupId, GroupState>,
227    user_groups: HashMap<UserId, HashSet<GroupId>>,
228    model_groups: HashMap<ModelId, HashSet<GroupId>>,
229}
230
231struct GroupState {
232    revision: TxId,
233    users: HashMap<UserId, GroupRole>,
234    models: HashSet<ModelId>,
235    owners: usize,
236}
237
238pub struct Projection {
239    state: RwLock<State>,
240    apply_lane: Mutex<()>,
241    connection: Mutex<Connection>,
242    unavailable: AtomicBool,
243}
244
245impl Projection {
246    pub fn open(root: &Path, ordering: &K1TxnOrdering) -> Result<(Self, Option<TxId>), String> {
247        let started = Instant::now();
248        let result = Self::open_inner(root, ordering);
249        let elapsed = started.elapsed();
250        if elapsed > Duration::from_millis(100) {
251            let outcome = if result.is_ok() { "ready" } else { "error" };
252            eprintln!(
253                "level=warn module=kcode-k1-groups-projection operation=open elapsed_us={} outcome={outcome}",
254                elapsed.as_micros()
255            );
256        }
257        result
258    }
259
260    fn open_inner(root: &Path, ordering: &K1TxnOrdering) -> Result<(Self, Option<TxId>), String> {
261        fs::create_dir_all(root)
262            .map_err(|error| format!("create projection root {}: {error}", root.display()))?;
263        let database = root.join(DATABASE_NAME);
264        let (connection, state) = load_or_rebuild(&database, ordering)?;
265        let cursor = state.cursor;
266        Ok((
267            Self {
268                state: RwLock::new(state),
269                apply_lane: Mutex::new(()),
270                connection: Mutex::new(connection),
271                unavailable: AtomicBool::new(false),
272            },
273            cursor,
274        ))
275    }
276
277    pub fn apply(&self, callback_txid: TxId, action: GroupAction) -> Result<ApplyOutcome, String> {
278        self.ensure_available()?;
279        let _lane = self.apply_lane.lock().map_err(|_| {
280            self.mark_unavailable();
281            "projection apply lane is unavailable until reopen".to_owned()
282        })?;
283        self.ensure_available()?;
284        let prepared = self.prepare(callback_txid, action)?;
285        {
286            let mut connection = self.connection.lock().map_err(|_| {
287                self.mark_unavailable();
288                "projection database lane is unavailable until reopen".to_owned()
289            })?;
290            if let Err(error) = persist_prepared(&mut connection, callback_txid, &prepared) {
291                self.mark_unavailable();
292                return Err(error);
293            }
294        }
295        self.publish(callback_txid, prepared)
296    }
297
298    pub fn get(&self, group: GroupId) -> Result<Option<Group>, String> {
299        self.ensure_available()?;
300        if group.sentinel().is_some() {
301            return Ok(None);
302        }
303        let state = self.state.read().map_err(|_| {
304            self.mark_unavailable();
305            "projection state is unavailable until reopen".to_owned()
306        })?;
307        self.ensure_available()?;
308        let Some(stored) = state.groups.get(&group) else {
309            return Ok(None);
310        };
311        let mut users = Vec::new();
312        users
313            .try_reserve(stored.users.len())
314            .map_err(|error| format!("reserve group users: {error}"))?;
315        users.extend(stored.users.iter().map(|(user_id, role)| GroupUser {
316            user_id: *user_id,
317            role: *role,
318        }));
319        let mut models = Vec::new();
320        models
321            .try_reserve(stored.models.len())
322            .map_err(|error| format!("reserve group models: {error}"))?;
323        models.extend(stored.models.iter().copied());
324        Ok(Some(Group {
325            id: group,
326            revision: GroupRevision {
327                group_id: group,
328                txid: stored.revision,
329            },
330            users,
331            models,
332        }))
333    }
334
335    pub fn groups_for_user(&self, user: UserId) -> Result<Vec<GroupId>, String> {
336        self.ensure_available()?;
337        let state = self.state.read().map_err(|_| {
338            self.mark_unavailable();
339            "projection state is unavailable until reopen".to_owned()
340        })?;
341        self.ensure_available()?;
342        copy_group_set_with_sentinel(state.user_groups.get(&user), ALL_USERS, "user groups")
343    }
344
345    pub fn groups_for_model(&self, model: ModelId) -> Result<Vec<GroupId>, String> {
346        self.ensure_available()?;
347        let state = self.state.read().map_err(|_| {
348            self.mark_unavailable();
349            "projection state is unavailable until reopen".to_owned()
350        })?;
351        self.ensure_available()?;
352        copy_group_set_with_sentinel(state.model_groups.get(&model), ALL_MODELS, "model groups")
353    }
354
355    pub fn memberships(&self, user: UserId, model: ModelId) -> Result<GroupMemberships, String> {
356        self.ensure_available()?;
357        let state = self.state.read().map_err(|_| {
358            self.mark_unavailable();
359            "projection state is unavailable until reopen".to_owned()
360        })?;
361        self.ensure_available()?;
362        let user_set = state.user_groups.get(&user);
363        let model_set = state.model_groups.get(&model);
364        let user_groups =
365            copy_group_set_with_sentinel(user_set, ALL_USERS, "membership user groups")?;
366        let model_groups =
367            copy_group_set_with_sentinel(model_set, ALL_MODELS, "membership model groups")?;
368        let mut shared_groups = Vec::new();
369        let shared_capacity = user_set
370            .map(HashSet::len)
371            .unwrap_or_default()
372            .min(model_set.map(HashSet::len).unwrap_or_default());
373        shared_groups
374            .try_reserve(shared_capacity)
375            .map_err(|error| format!("reserve shared groups: {error}"))?;
376        if let (Some(users), Some(models)) = (user_set, model_set) {
377            let (smaller, larger) = if users.len() <= models.len() {
378                (users, models)
379            } else {
380                (models, users)
381            };
382            shared_groups.extend(
383                smaller
384                    .iter()
385                    .filter(|group| larger.contains(group))
386                    .copied(),
387            );
388        }
389        Ok(GroupMemberships {
390            revision: state.cursor,
391            user_groups,
392            model_groups,
393            shared_groups,
394        })
395    }
396
397    pub fn clear(&self) -> Result<(), String> {
398        self.ensure_available()?;
399        let _lane = self.apply_lane.lock().map_err(|_| {
400            self.mark_unavailable();
401            "projection apply lane is unavailable until reopen".to_owned()
402        })?;
403        self.ensure_available()?;
404        {
405            let mut connection = self.connection.lock().map_err(|_| {
406                self.mark_unavailable();
407                "projection database lane is unavailable until reopen".to_owned()
408            })?;
409            if let Err(error) = persist_clear(&mut connection) {
410                self.mark_unavailable();
411                return Err(error);
412            }
413        }
414        let old_state = {
415            let mut state = self.state.write().map_err(|_| {
416                self.mark_unavailable();
417                "projection state is unavailable until reopen".to_owned()
418            })?;
419            std::mem::take(&mut *state)
420        };
421        drop(old_state);
422        Ok(())
423    }
424
425    fn prepare(&self, callback_txid: TxId, action: GroupAction) -> Result<Prepared, String> {
426        let mut state = self.state.write().map_err(|_| {
427            self.mark_unavailable();
428            "projection state is unavailable until reopen".to_owned()
429        })?;
430        self.ensure_available()?;
431        match action {
432            GroupAction::Create { owner } => {
433                let group = GroupId::new(callback_txid);
434                if group.sentinel().is_some() {
435                    return Ok(Prepared::rejected("reserved sentinel group ID"));
436                }
437                if state.groups.contains_key(&group) {
438                    return Ok(Prepared::rejected("group already exists"));
439                }
440                state
441                    .groups
442                    .try_reserve(1)
443                    .map_err(|error| format!("reserve group index: {error}"))?;
444                let new_reverse = if let Some(reverse) = state.user_groups.get_mut(&owner) {
445                    reverse
446                        .try_reserve(1)
447                        .map_err(|error| format!("reserve owner reverse membership: {error}"))?;
448                    None
449                } else {
450                    state
451                        .user_groups
452                        .try_reserve(1)
453                        .map_err(|error| format!("reserve user reverse index: {error}"))?;
454                    let mut reverse = HashSet::new();
455                    reverse
456                        .try_reserve(1)
457                        .map_err(|error| format!("reserve owner reverse membership: {error}"))?;
458                    reverse.insert(group);
459                    Some(reverse)
460                };
461                let mut users = HashMap::new();
462                users
463                    .try_reserve(1)
464                    .map_err(|error| format!("reserve owner membership: {error}"))?;
465                users.insert(owner, GroupRole::Owner);
466                Ok(Prepared {
467                    result: PreparedResult::Applied(GroupRevision {
468                        group_id: group,
469                        txid: callback_txid,
470                    }),
471                    mutation: Mutation::Create {
472                        group,
473                        owner,
474                        stored: GroupState {
475                            revision: callback_txid,
476                            users,
477                            models: HashSet::new(),
478                            owners: 1,
479                        },
480                        new_reverse,
481                    },
482                })
483            }
484            GroupAction::SetUserRole {
485                group,
486                actor,
487                user,
488                role,
489            } => prepare_user_change(&mut state, group, actor, user, role, callback_txid),
490            GroupAction::SetModelMembership {
491                group,
492                actor,
493                model,
494                present,
495            } => prepare_model_change(&mut state, group, actor, model, present, callback_txid),
496        }
497    }
498
499    fn publish(&self, callback_txid: TxId, prepared: Prepared) -> Result<ApplyOutcome, String> {
500        let mut state = self.state.write().map_err(|_| {
501            self.mark_unavailable();
502            "projection state is unavailable until reopen".to_owned()
503        })?;
504        if !mutation_matches(&state, &prepared.mutation) {
505            self.mark_unavailable();
506            return Err(
507                "projection state contradicted committed database state; reopen required"
508                    .to_owned(),
509            );
510        }
511        match prepared.mutation {
512            Mutation::None => {}
513            Mutation::Create {
514                group,
515                owner,
516                stored,
517                new_reverse,
518            } => {
519                state.groups.insert(group, stored);
520                if let Some(reverse) = new_reverse {
521                    state.user_groups.insert(owner, reverse);
522                } else {
523                    state
524                        .user_groups
525                        .get_mut(&owner)
526                        .expect("validated owner reverse index")
527                        .insert(group);
528                }
529            }
530            Mutation::User {
531                group,
532                user,
533                from,
534                to,
535                previous_owners,
536                new_reverse,
537                ..
538            } => {
539                let stored = state.groups.get_mut(&group).expect("validated group");
540                match to {
541                    Some(role) => {
542                        stored.users.insert(user, role);
543                    }
544                    None => {
545                        stored.users.remove(&user);
546                    }
547                }
548                stored.owners = owner_count_after(previous_owners, from, to);
549                stored.revision = callback_txid;
550                match (from, to) {
551                    (None, Some(_)) => {
552                        if let Some(reverse) = new_reverse {
553                            state.user_groups.insert(user, reverse);
554                        } else {
555                            state
556                                .user_groups
557                                .get_mut(&user)
558                                .expect("validated user reverse index")
559                                .insert(group);
560                        }
561                    }
562                    (Some(_), None) => {
563                        let reverse = state
564                            .user_groups
565                            .get_mut(&user)
566                            .expect("validated user reverse index");
567                        reverse.remove(&group);
568                        if reverse.is_empty() {
569                            state.user_groups.remove(&user);
570                        }
571                    }
572                    _ => {}
573                }
574            }
575            Mutation::Model {
576                group,
577                model,
578                from,
579                to,
580                new_reverse,
581                ..
582            } => {
583                let stored = state.groups.get_mut(&group).expect("validated group");
584                if to {
585                    stored.models.insert(model);
586                } else {
587                    stored.models.remove(&model);
588                }
589                stored.revision = callback_txid;
590                match (from, to) {
591                    (false, true) => {
592                        if let Some(reverse) = new_reverse {
593                            state.model_groups.insert(model, reverse);
594                        } else {
595                            state
596                                .model_groups
597                                .get_mut(&model)
598                                .expect("validated model reverse index")
599                                .insert(group);
600                        }
601                    }
602                    (true, false) => {
603                        let reverse = state
604                            .model_groups
605                            .get_mut(&model)
606                            .expect("validated model reverse index");
607                        reverse.remove(&group);
608                        if reverse.is_empty() {
609                            state.model_groups.remove(&model);
610                        }
611                    }
612                    _ => {}
613                }
614            }
615        }
616        state.cursor = Some(callback_txid);
617        Ok(prepared.result.into_public())
618    }
619
620    fn ensure_available(&self) -> Result<(), String> {
621        if self.unavailable.load(Ordering::SeqCst) {
622            Err("projection is unavailable until reopen".to_owned())
623        } else {
624            Ok(())
625        }
626    }
627
628    fn mark_unavailable(&self) {
629        self.unavailable.store(true, Ordering::SeqCst);
630    }
631}
632
633fn prepare_user_change(
634    state: &mut State,
635    group: GroupId,
636    actor: UserId,
637    user: UserId,
638    to: Option<GroupRole>,
639    callback_txid: TxId,
640) -> Result<Prepared, String> {
641    if group.sentinel().is_some() {
642        return Ok(Prepared::rejected("sentinel groups are immutable"));
643    }
644    let Some(stored) = state.groups.get(&group) else {
645        return Ok(Prepared::rejected("group does not exist"));
646    };
647    let Some(actor_role) = stored.users.get(&actor).copied() else {
648        return Ok(Prepared::rejected("actor is not authorized"));
649    };
650    let from = stored.users.get(&user).copied();
651    let permitted = match actor_role {
652        GroupRole::User => false,
653        GroupRole::Admin => matches!(
654            (from, to),
655            (None, Some(GroupRole::User))
656                | (Some(GroupRole::User), None)
657                | (Some(GroupRole::User), Some(GroupRole::User))
658        ),
659        GroupRole::Owner => true,
660    };
661    if !permitted {
662        return Ok(Prepared::rejected(match actor_role {
663            GroupRole::Admin => "administrator transition is not permitted",
664            _ => "actor is not authorized",
665        }));
666    }
667    if actor_role == GroupRole::Owner
668        && from == Some(GroupRole::Owner)
669        && to != Some(GroupRole::Owner)
670        && stored.owners == 1
671    {
672        return Ok(Prepared::rejected(
673            "final owner cannot be removed or demoted",
674        ));
675    }
676    let previous_revision = stored.revision;
677    let previous_owners = stored.owners;
678    if from == to {
679        return Ok(Prepared::unchanged(group, previous_revision));
680    }
681    let mut new_reverse = None;
682    if from.is_none() && to.is_some() {
683        state
684            .groups
685            .get_mut(&group)
686            .expect("existing group")
687            .users
688            .try_reserve(1)
689            .map_err(|error| format!("reserve group user membership: {error}"))?;
690        if let Some(reverse) = state.user_groups.get_mut(&user) {
691            reverse
692                .try_reserve(1)
693                .map_err(|error| format!("reserve user reverse membership: {error}"))?;
694        } else {
695            state
696                .user_groups
697                .try_reserve(1)
698                .map_err(|error| format!("reserve user reverse index: {error}"))?;
699            let mut reverse = HashSet::new();
700            reverse
701                .try_reserve(1)
702                .map_err(|error| format!("reserve user reverse membership: {error}"))?;
703            reverse.insert(group);
704            new_reverse = Some(reverse);
705        }
706    }
707    Ok(Prepared {
708        result: PreparedResult::Applied(GroupRevision {
709            group_id: group,
710            txid: callback_txid,
711        }),
712        mutation: Mutation::User {
713            group,
714            user,
715            from,
716            to,
717            previous_revision,
718            previous_owners,
719            new_reverse,
720        },
721    })
722}
723
724fn prepare_model_change(
725    state: &mut State,
726    group: GroupId,
727    actor: UserId,
728    model: ModelId,
729    to: bool,
730    callback_txid: TxId,
731) -> Result<Prepared, String> {
732    if group.sentinel().is_some() {
733        return Ok(Prepared::rejected("sentinel groups are immutable"));
734    }
735    let Some(stored) = state.groups.get(&group) else {
736        return Ok(Prepared::rejected("group does not exist"));
737    };
738    if stored.users.get(&actor) != Some(&GroupRole::Owner) {
739        return Ok(Prepared::rejected("actor is not authorized"));
740    }
741    let from = stored.models.contains(&model);
742    let previous_revision = stored.revision;
743    if from == to {
744        return Ok(Prepared::unchanged(group, previous_revision));
745    }
746    let mut new_reverse = None;
747    if to {
748        state
749            .groups
750            .get_mut(&group)
751            .expect("existing group")
752            .models
753            .try_reserve(1)
754            .map_err(|error| format!("reserve group model membership: {error}"))?;
755        if let Some(reverse) = state.model_groups.get_mut(&model) {
756            reverse
757                .try_reserve(1)
758                .map_err(|error| format!("reserve model reverse membership: {error}"))?;
759        } else {
760            state
761                .model_groups
762                .try_reserve(1)
763                .map_err(|error| format!("reserve model reverse index: {error}"))?;
764            let mut reverse = HashSet::new();
765            reverse
766                .try_reserve(1)
767                .map_err(|error| format!("reserve model reverse membership: {error}"))?;
768            reverse.insert(group);
769            new_reverse = Some(reverse);
770        }
771    }
772    Ok(Prepared {
773        result: PreparedResult::Applied(GroupRevision {
774            group_id: group,
775            txid: callback_txid,
776        }),
777        mutation: Mutation::Model {
778            group,
779            model,
780            from,
781            to,
782            previous_revision,
783            new_reverse,
784        },
785    })
786}
787
788struct Prepared {
789    result: PreparedResult,
790    mutation: Mutation,
791}
792
793impl Prepared {
794    fn rejected(reason: &str) -> Self {
795        Self {
796            result: PreparedResult::Rejected(reason.to_owned()),
797            mutation: Mutation::None,
798        }
799    }
800
801    fn unchanged(group_id: GroupId, txid: TxId) -> Self {
802        Self {
803            result: PreparedResult::Unchanged(GroupRevision { group_id, txid }),
804            mutation: Mutation::None,
805        }
806    }
807}
808
809enum PreparedResult {
810    Applied(GroupRevision),
811    Unchanged(GroupRevision),
812    Rejected(String),
813}
814
815impl PreparedResult {
816    fn into_public(self) -> ApplyOutcome {
817        match self {
818            Self::Applied(revision) => ApplyOutcome::Applied(revision),
819            Self::Unchanged(revision) => ApplyOutcome::Unchanged(revision),
820            Self::Rejected(reason) => ApplyOutcome::Rejected(reason),
821        }
822    }
823}
824
825enum Mutation {
826    None,
827    Create {
828        group: GroupId,
829        owner: UserId,
830        stored: GroupState,
831        new_reverse: Option<HashSet<GroupId>>,
832    },
833    User {
834        group: GroupId,
835        user: UserId,
836        from: Option<GroupRole>,
837        to: Option<GroupRole>,
838        previous_revision: TxId,
839        previous_owners: usize,
840        new_reverse: Option<HashSet<GroupId>>,
841    },
842    Model {
843        group: GroupId,
844        model: ModelId,
845        from: bool,
846        to: bool,
847        previous_revision: TxId,
848        new_reverse: Option<HashSet<GroupId>>,
849    },
850}
851
852fn mutation_matches(state: &State, mutation: &Mutation) -> bool {
853    match mutation {
854        Mutation::None => true,
855        Mutation::Create {
856            group,
857            owner,
858            new_reverse,
859            ..
860        } => {
861            if state.groups.contains_key(group) {
862                return false;
863            }
864            match new_reverse {
865                Some(reverse) => !state.user_groups.contains_key(owner) && reverse.contains(group),
866                None => state
867                    .user_groups
868                    .get(owner)
869                    .is_some_and(|groups| !groups.contains(group)),
870            }
871        }
872        Mutation::User {
873            group,
874            user,
875            from,
876            to,
877            previous_revision,
878            previous_owners,
879            new_reverse,
880        } => {
881            let Some(stored) = state.groups.get(group) else {
882                return false;
883            };
884            if stored.revision != *previous_revision
885                || stored.owners != *previous_owners
886                || stored.users.get(user).copied() != *from
887            {
888                return false;
889            }
890            match (from, to) {
891                (None, Some(_)) => match new_reverse {
892                    Some(reverse) => {
893                        !state.user_groups.contains_key(user) && reverse.contains(group)
894                    }
895                    None => state
896                        .user_groups
897                        .get(user)
898                        .is_some_and(|groups| !groups.contains(group)),
899                },
900                (Some(_), None) => state
901                    .user_groups
902                    .get(user)
903                    .is_some_and(|groups| groups.contains(group)),
904                _ => true,
905            }
906        }
907        Mutation::Model {
908            group,
909            model,
910            from,
911            to,
912            previous_revision,
913            new_reverse,
914        } => {
915            let Some(stored) = state.groups.get(group) else {
916                return false;
917            };
918            if stored.revision != *previous_revision || stored.models.contains(model) != *from {
919                return false;
920            }
921            match (from, to) {
922                (false, true) => match new_reverse {
923                    Some(reverse) => {
924                        !state.model_groups.contains_key(model) && reverse.contains(group)
925                    }
926                    None => state
927                        .model_groups
928                        .get(model)
929                        .is_some_and(|groups| !groups.contains(group)),
930                },
931                (true, false) => state
932                    .model_groups
933                    .get(model)
934                    .is_some_and(|groups| groups.contains(group)),
935                _ => true,
936            }
937        }
938    }
939}
940
941fn owner_count_after(previous: usize, from: Option<GroupRole>, to: Option<GroupRole>) -> usize {
942    match (from == Some(GroupRole::Owner), to == Some(GroupRole::Owner)) {
943        (false, true) => previous + 1,
944        (true, false) => previous - 1,
945        _ => previous,
946    }
947}
948
949fn persist_prepared(
950    connection: &mut Connection,
951    callback_txid: TxId,
952    prepared: &Prepared,
953) -> Result<(), String> {
954    let transaction = connection
955        .transaction_with_behavior(TransactionBehavior::Immediate)
956        .map_err(|error| format!("begin projection apply transaction: {error}"))?;
957    match &prepared.mutation {
958        Mutation::None => {}
959        Mutation::Create {
960            group,
961            owner,
962            stored,
963            ..
964        } => {
965            expect_one(
966                transaction.execute(
967                    "INSERT INTO groups (group_id, revision) VALUES (?1, ?2)",
968                    params![
969                        group.txid().as_bytes().as_slice(),
970                        stored.revision.as_bytes().as_slice()
971                    ],
972                ),
973                "insert group",
974            )?;
975            let owner_txid = owner.as_tx_id();
976            expect_one(
977                transaction.execute(
978                    "INSERT INTO user_memberships (group_id, user_id, role) VALUES (?1, ?2, ?3)",
979                    params![
980                        group.txid().as_bytes().as_slice(),
981                        owner_txid.as_bytes().as_slice(),
982                        GroupRole::Owner.stored()
983                    ],
984                ),
985                "insert owner membership",
986            )?;
987        }
988        Mutation::User {
989            group,
990            user,
991            from,
992            to,
993            ..
994        } => {
995            let user_txid = user.as_tx_id();
996            match (from, to) {
997                (None, Some(role)) => expect_one(
998                    transaction.execute(
999                        "INSERT INTO user_memberships (group_id, user_id, role) VALUES (?1, ?2, ?3)",
1000                        params![
1001                            group.txid().as_bytes().as_slice(),
1002                            user_txid.as_bytes().as_slice(),
1003                            role.stored()
1004                        ],
1005                    ),
1006                    "insert user membership",
1007                )?,
1008                (Some(_), Some(role)) => expect_one(
1009                    transaction.execute(
1010                        "UPDATE user_memberships SET role = ?3 WHERE group_id = ?1 AND user_id = ?2",
1011                        params![
1012                            group.txid().as_bytes().as_slice(),
1013                            user_txid.as_bytes().as_slice(),
1014                            role.stored()
1015                        ],
1016                    ),
1017                    "update user membership",
1018                )?,
1019                (Some(_), None) => expect_one(
1020                    transaction.execute(
1021                        "DELETE FROM user_memberships WHERE group_id = ?1 AND user_id = ?2",
1022                        params![
1023                            group.txid().as_bytes().as_slice(),
1024                            user_txid.as_bytes().as_slice()
1025                        ],
1026                    ),
1027                    "delete user membership",
1028                )?,
1029                (None, None) => {
1030                    return Err("prepared user mutation has no state change".to_owned());
1031                }
1032            }
1033            update_group_revision(&transaction, *group, callback_txid)?;
1034        }
1035        Mutation::Model {
1036            group,
1037            model,
1038            from,
1039            to,
1040            ..
1041        } => {
1042            match (from, to) {
1043                (false, true) => expect_one(
1044                    transaction.execute(
1045                        "INSERT INTO model_memberships (group_id, model_id) VALUES (?1, ?2)",
1046                        params![
1047                            group.txid().as_bytes().as_slice(),
1048                            model.as_bytes().as_slice()
1049                        ],
1050                    ),
1051                    "insert model membership",
1052                )?,
1053                (true, false) => expect_one(
1054                    transaction.execute(
1055                        "DELETE FROM model_memberships WHERE group_id = ?1 AND model_id = ?2",
1056                        params![
1057                            group.txid().as_bytes().as_slice(),
1058                            model.as_bytes().as_slice()
1059                        ],
1060                    ),
1061                    "delete model membership",
1062                )?,
1063                _ => return Err("prepared model mutation has no state change".to_owned()),
1064            }
1065            update_group_revision(&transaction, *group, callback_txid)?;
1066        }
1067    }
1068    expect_one(
1069        transaction.execute(
1070            "UPDATE metadata SET last_applied_txid = ?1 WHERE singleton = 1",
1071            params![callback_txid.as_bytes().as_slice()],
1072        ),
1073        "advance projection cursor",
1074    )?;
1075    transaction
1076        .commit()
1077        .map_err(|error| format!("commit projection apply transaction: {error}"))
1078}
1079
1080fn update_group_revision(
1081    transaction: &rusqlite::Transaction<'_>,
1082    group: GroupId,
1083    callback_txid: TxId,
1084) -> Result<(), String> {
1085    expect_one(
1086        transaction.execute(
1087            "UPDATE groups SET revision = ?2 WHERE group_id = ?1",
1088            params![
1089                group.txid().as_bytes().as_slice(),
1090                callback_txid.as_bytes().as_slice()
1091            ],
1092        ),
1093        "update group revision",
1094    )
1095}
1096
1097fn expect_one(result: Result<usize, SqlError>, operation: &str) -> Result<(), String> {
1098    let changed = result.map_err(|error| format!("{operation}: {error}"))?;
1099    if changed == 1 {
1100        Ok(())
1101    } else {
1102        Err(format!("{operation}: expected one row, changed {changed}"))
1103    }
1104}
1105
1106fn persist_clear(connection: &mut Connection) -> Result<(), String> {
1107    let transaction = connection
1108        .transaction_with_behavior(TransactionBehavior::Immediate)
1109        .map_err(|error| format!("begin projection clear transaction: {error}"))?;
1110    transaction
1111        .execute("DELETE FROM user_memberships", [])
1112        .map_err(|error| format!("clear user memberships: {error}"))?;
1113    transaction
1114        .execute("DELETE FROM model_memberships", [])
1115        .map_err(|error| format!("clear model memberships: {error}"))?;
1116    transaction
1117        .execute("DELETE FROM groups", [])
1118        .map_err(|error| format!("clear groups: {error}"))?;
1119    expect_one(
1120        transaction.execute(
1121            "UPDATE metadata SET last_applied_txid = NULL WHERE singleton = 1",
1122            [],
1123        ),
1124        "clear projection cursor",
1125    )?;
1126    transaction
1127        .commit()
1128        .map_err(|error| format!("commit projection clear transaction: {error}"))
1129}
1130
1131fn copy_group_set_with_sentinel(
1132    set: Option<&HashSet<GroupId>>,
1133    sentinel: GroupId,
1134    operation: &str,
1135) -> Result<Vec<GroupId>, String> {
1136    let ordinary = set.map(HashSet::len).unwrap_or_default();
1137    let mut values = Vec::new();
1138    values
1139        .try_reserve(
1140            ordinary
1141                .checked_add(1)
1142                .ok_or_else(|| format!("{operation} result size overflow"))?,
1143        )
1144        .map_err(|error| format!("reserve {operation}: {error}"))?;
1145    if let Some(set) = set {
1146        values.extend(set.iter().copied());
1147    }
1148    values.push(sentinel);
1149    Ok(values)
1150}
1151
1152enum LoadError {
1153    Recoverable,
1154    Fatal(String),
1155}
1156
1157fn load_or_rebuild(
1158    database: &Path,
1159    ordering: &K1TxnOrdering,
1160) -> Result<(Connection, State), String> {
1161    let exists = database.try_exists().map_err(|error| {
1162        format!(
1163            "inspect projection database {}: {error}",
1164            database.display()
1165        )
1166    })?;
1167    if !exists {
1168        return initialize_database(database);
1169    }
1170    match load_existing(database, ordering) {
1171        Ok(loaded) => Ok(loaded),
1172        Err(LoadError::Recoverable) => {
1173            remove_database_files(database)?;
1174            initialize_database(database)
1175        }
1176        Err(LoadError::Fatal(error)) => Err(error),
1177    }
1178}
1179
1180fn initialize_database(database: &Path) -> Result<(Connection, State), String> {
1181    let mut connection = Connection::open(database)
1182        .map_err(|error| format!("create projection database {}: {error}", database.display()))?;
1183    let journal: String = connection
1184        .query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))
1185        .map_err(|error| format!("enable projection WAL: {error}"))?;
1186    if !journal.eq_ignore_ascii_case("wal") {
1187        return Err(format!("enable projection WAL: SQLite selected {journal}"));
1188    }
1189    connection
1190        .execute_batch("PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON;")
1191        .map_err(|error| format!("configure projection database: {error}"))?;
1192    let transaction = connection
1193        .transaction_with_behavior(TransactionBehavior::Immediate)
1194        .map_err(|error| format!("begin projection initialization: {error}"))?;
1195    for (statement, operation) in [
1196        (CREATE_METADATA, "create metadata schema"),
1197        (CREATE_GROUPS, "create groups schema"),
1198        (CREATE_USERS, "create user membership schema"),
1199        (CREATE_MODELS, "create model membership schema"),
1200        (CREATE_USER_INDEX, "create user reverse index"),
1201        (CREATE_MODEL_INDEX, "create model reverse index"),
1202    ] {
1203        transaction
1204            .execute(statement, [])
1205            .map_err(|error| format!("{operation}: {error}"))?;
1206    }
1207    expect_one(
1208        transaction.execute(
1209            "INSERT INTO metadata (singleton, schema_version, last_applied_txid) VALUES (1, 1, NULL)",
1210            [],
1211        ),
1212        "initialize projection metadata",
1213    )?;
1214    transaction
1215        .commit()
1216        .map_err(|error| format!("commit projection initialization: {error}"))?;
1217    Ok((connection, State::default()))
1218}
1219
1220fn load_existing(
1221    database: &Path,
1222    ordering: &K1TxnOrdering,
1223) -> Result<(Connection, State), LoadError> {
1224    let connection = Connection::open(database).map_err(|error| {
1225        classify_validation_error(
1226            error,
1227            &format!("open projection database {}", database.display()),
1228        )
1229    })?;
1230    let journal: String = validate_sql(
1231        connection.query_row("PRAGMA journal_mode", [], |row| row.get(0)),
1232        "read projection journal mode",
1233    )?;
1234    if !journal.eq_ignore_ascii_case("wal") {
1235        return Err(LoadError::Recoverable);
1236    }
1237    validate_sql(
1238        connection.execute_batch("PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON;"),
1239        "configure projection database",
1240    )?;
1241    validate_quick_check(&connection)?;
1242    validate_schema(&connection)?;
1243    validate_foreign_keys(&connection)?;
1244    let state = load_state(&connection, ordering)?;
1245    Ok((connection, state))
1246}
1247
1248fn validate_quick_check(connection: &Connection) -> Result<(), LoadError> {
1249    let mut statement = validate_sql(
1250        connection.prepare("PRAGMA quick_check"),
1251        "prepare projection quick_check",
1252    )?;
1253    let mut rows = validate_sql(statement.query([]), "run projection quick_check")?;
1254    let Some(row) = validate_sql(rows.next(), "read projection quick_check")? else {
1255        return Err(LoadError::Recoverable);
1256    };
1257    let result: String = validate_sql(row.get(0), "decode projection quick_check")?;
1258    if result != "ok" || validate_sql(rows.next(), "finish projection quick_check")?.is_some() {
1259        return Err(LoadError::Recoverable);
1260    }
1261    Ok(())
1262}
1263
1264#[derive(Eq, Ord, PartialEq, PartialOrd)]
1265struct SchemaObject {
1266    name: String,
1267    kind: String,
1268    table: String,
1269    sql: Option<String>,
1270}
1271
1272fn validate_schema(connection: &Connection) -> Result<(), LoadError> {
1273    let mut statement = validate_sql(
1274        connection.prepare(
1275            "SELECT name, type, tbl_name, sql FROM sqlite_master ORDER BY name, type, tbl_name",
1276        ),
1277        "prepare projection schema validation",
1278    )?;
1279    let mut rows = validate_sql(statement.query([]), "query projection schema")?;
1280    let mut actual = Vec::new();
1281    while let Some(row) = validate_sql(rows.next(), "read projection schema")? {
1282        actual.push(SchemaObject {
1283            name: validate_sql(row.get(0), "decode projection schema name")?,
1284            kind: validate_sql(row.get(1), "decode projection schema type")?,
1285            table: validate_sql(row.get(2), "decode projection schema table")?,
1286            sql: validate_sql(row.get(3), "decode projection schema SQL")?,
1287        });
1288    }
1289    let mut expected = vec![
1290        schema("groups", "table", "groups", Some(CREATE_GROUPS)),
1291        schema("metadata", "table", "metadata", Some(CREATE_METADATA)),
1292        schema(
1293            "model_memberships",
1294            "table",
1295            "model_memberships",
1296            Some(CREATE_MODELS),
1297        ),
1298        schema(
1299            "model_memberships_by_model",
1300            "index",
1301            "model_memberships",
1302            Some(CREATE_MODEL_INDEX),
1303        ),
1304        schema("sqlite_autoindex_groups_1", "index", "groups", None),
1305        schema(
1306            "sqlite_autoindex_model_memberships_1",
1307            "index",
1308            "model_memberships",
1309            None,
1310        ),
1311        schema(
1312            "sqlite_autoindex_user_memberships_1",
1313            "index",
1314            "user_memberships",
1315            None,
1316        ),
1317        schema(
1318            "user_memberships",
1319            "table",
1320            "user_memberships",
1321            Some(CREATE_USERS),
1322        ),
1323        schema(
1324            "user_memberships_by_user",
1325            "index",
1326            "user_memberships",
1327            Some(CREATE_USER_INDEX),
1328        ),
1329    ];
1330    expected.sort();
1331    if actual != expected {
1332        return Err(LoadError::Recoverable);
1333    }
1334    Ok(())
1335}
1336
1337fn schema(name: &str, kind: &str, table: &str, sql: Option<&str>) -> SchemaObject {
1338    SchemaObject {
1339        name: name.to_owned(),
1340        kind: kind.to_owned(),
1341        table: table.to_owned(),
1342        sql: sql.map(str::to_owned),
1343    }
1344}
1345
1346fn validate_foreign_keys(connection: &Connection) -> Result<(), LoadError> {
1347    let mut statement = validate_sql(
1348        connection.prepare("PRAGMA foreign_key_check"),
1349        "prepare projection foreign key validation",
1350    )?;
1351    let mut rows = validate_sql(statement.query([]), "run projection foreign key validation")?;
1352    if validate_sql(rows.next(), "read projection foreign key validation")?.is_some() {
1353        return Err(LoadError::Recoverable);
1354    }
1355    Ok(())
1356}
1357
1358fn load_state(connection: &Connection, ordering: &K1TxnOrdering) -> Result<State, LoadError> {
1359    let mut state = State::default();
1360    let mut metadata = validate_sql(
1361        connection.prepare(
1362            "SELECT singleton, schema_version, last_applied_txid FROM metadata ORDER BY singleton",
1363        ),
1364        "prepare projection metadata",
1365    )?;
1366    let mut metadata_rows = validate_sql(metadata.query([]), "query projection metadata")?;
1367    let Some(row) = validate_sql(metadata_rows.next(), "read projection metadata")? else {
1368        return Err(LoadError::Recoverable);
1369    };
1370    let singleton: i64 = validate_sql(row.get(0), "decode metadata singleton")?;
1371    let version: i64 = validate_sql(row.get(1), "decode metadata version")?;
1372    let cursor: Option<Vec<u8>> = validate_sql(row.get(2), "decode metadata cursor")?;
1373    if singleton != 1
1374        || version != 1
1375        || validate_sql(metadata_rows.next(), "finish projection metadata")?.is_some()
1376    {
1377        return Err(LoadError::Recoverable);
1378    }
1379    state.cursor = match cursor {
1380        Some(bytes) => Some(TxId::from_bytes(exact_bytes::<12>(&bytes)?)),
1381        None => None,
1382    };
1383
1384    let group_count = table_count(connection, "groups")?;
1385    state
1386        .groups
1387        .try_reserve(group_count)
1388        .map_err(|error| LoadError::Fatal(format!("reserve group state: {error}")))?;
1389    let mut groups = validate_sql(
1390        connection.prepare("SELECT group_id, revision FROM groups"),
1391        "prepare stored groups",
1392    )?;
1393    let mut group_rows = validate_sql(groups.query([]), "query stored groups")?;
1394    while let Some(row) = validate_sql(group_rows.next(), "read stored group")? {
1395        let group_bytes: Vec<u8> = validate_sql(row.get(0), "decode stored group ID")?;
1396        let revision_bytes: Vec<u8> = validate_sql(row.get(1), "decode stored group revision")?;
1397        let group = GroupId::new(TxId::from_bytes(exact_bytes::<12>(&group_bytes)?));
1398        if group.sentinel().is_some() {
1399            return Err(LoadError::Recoverable);
1400        }
1401        let revision = TxId::from_bytes(exact_bytes::<12>(&revision_bytes)?);
1402        if state
1403            .groups
1404            .insert(
1405                group,
1406                GroupState {
1407                    revision,
1408                    users: HashMap::new(),
1409                    models: HashSet::new(),
1410                    owners: 0,
1411                },
1412            )
1413            .is_some()
1414        {
1415            return Err(LoadError::Recoverable);
1416        }
1417    }
1418
1419    let mut users = validate_sql(
1420        connection.prepare("SELECT group_id, user_id, role FROM user_memberships"),
1421        "prepare stored user memberships",
1422    )?;
1423    let mut user_rows = validate_sql(users.query([]), "query stored user memberships")?;
1424    while let Some(row) = validate_sql(user_rows.next(), "read stored user membership")? {
1425        let group_bytes: Vec<u8> = validate_sql(row.get(0), "decode membership group ID")?;
1426        let user_bytes: Vec<u8> = validate_sql(row.get(1), "decode membership user ID")?;
1427        let role_value: i64 = validate_sql(row.get(2), "decode membership role")?;
1428        let group = GroupId::new(TxId::from_bytes(exact_bytes::<12>(&group_bytes)?));
1429        let user = UserId::from_tx_id(TxId::from_bytes(exact_bytes::<12>(&user_bytes)?));
1430        let Some(role) = GroupRole::from_stored(role_value) else {
1431            return Err(LoadError::Recoverable);
1432        };
1433        let Some(stored) = state.groups.get_mut(&group) else {
1434            return Err(LoadError::Recoverable);
1435        };
1436        stored
1437            .users
1438            .try_reserve(1)
1439            .map_err(|error| LoadError::Fatal(format!("reserve stored users: {error}")))?;
1440        if stored.users.insert(user, role).is_some() {
1441            return Err(LoadError::Recoverable);
1442        }
1443        if role == GroupRole::Owner {
1444            stored.owners = stored
1445                .owners
1446                .checked_add(1)
1447                .ok_or_else(|| LoadError::Fatal("stored owner count overflow".to_owned()))?;
1448        }
1449        insert_reverse(
1450            &mut state.user_groups,
1451            user,
1452            group,
1453            "stored user reverse index",
1454        )?;
1455    }
1456
1457    let mut models = validate_sql(
1458        connection.prepare("SELECT group_id, model_id FROM model_memberships"),
1459        "prepare stored model memberships",
1460    )?;
1461    let mut model_rows = validate_sql(models.query([]), "query stored model memberships")?;
1462    while let Some(row) = validate_sql(model_rows.next(), "read stored model membership")? {
1463        let group_bytes: Vec<u8> = validate_sql(row.get(0), "decode model membership group ID")?;
1464        let model_bytes: Vec<u8> = validate_sql(row.get(1), "decode model membership model ID")?;
1465        let group = GroupId::new(TxId::from_bytes(exact_bytes::<12>(&group_bytes)?));
1466        let model = ModelId::from_bytes(exact_bytes::<32>(&model_bytes)?);
1467        let Some(stored) = state.groups.get_mut(&group) else {
1468            return Err(LoadError::Recoverable);
1469        };
1470        stored
1471            .models
1472            .try_reserve(1)
1473            .map_err(|error| LoadError::Fatal(format!("reserve stored models: {error}")))?;
1474        if !stored.models.insert(model) {
1475            return Err(LoadError::Recoverable);
1476        }
1477        insert_reverse(
1478            &mut state.model_groups,
1479            model,
1480            group,
1481            "stored model reverse index",
1482        )?;
1483    }
1484
1485    if state.groups.values().any(|group| group.owners == 0)
1486        || (!state.groups.is_empty() && state.cursor.is_none())
1487    {
1488        return Err(LoadError::Recoverable);
1489    }
1490    validate_canonical_ids(&state, ordering)?;
1491    Ok(state)
1492}
1493
1494fn table_count(connection: &Connection, table: &str) -> Result<usize, LoadError> {
1495    let sql = format!("SELECT COUNT(*) FROM {table}");
1496    let count: i64 = validate_sql(
1497        connection.query_row(&sql, [], |row| row.get(0)),
1498        "count projection rows",
1499    )?;
1500    usize::try_from(count).map_err(|_| LoadError::Recoverable)
1501}
1502
1503fn insert_reverse<K>(
1504    reverse: &mut HashMap<K, HashSet<GroupId>>,
1505    key: K,
1506    group: GroupId,
1507    operation: &str,
1508) -> Result<(), LoadError>
1509where
1510    K: Copy + Eq + Hash,
1511{
1512    if !reverse.contains_key(&key) {
1513        reverse
1514            .try_reserve(1)
1515            .map_err(|error| LoadError::Fatal(format!("reserve {operation}: {error}")))?;
1516    }
1517    let groups = reverse.entry(key).or_default();
1518    groups
1519        .try_reserve(1)
1520        .map_err(|error| LoadError::Fatal(format!("reserve {operation}: {error}")))?;
1521    if !groups.insert(group) {
1522        return Err(LoadError::Recoverable);
1523    }
1524    Ok(())
1525}
1526
1527fn validate_canonical_ids(state: &State, ordering: &K1TxnOrdering) -> Result<(), LoadError> {
1528    let subsystem = SubsystemId::from_str(SUBSYSTEM)
1529        .map_err(|error| LoadError::Fatal(format!("construct groups subsystem ID: {error}")))?;
1530    let mut validated = HashSet::new();
1531    validated
1532        .try_reserve(
1533            state
1534                .groups
1535                .len()
1536                .checked_mul(2)
1537                .and_then(|count| count.checked_add(1))
1538                .ok_or_else(|| LoadError::Fatal("canonical ID count overflow".to_owned()))?,
1539        )
1540        .map_err(|error| LoadError::Fatal(format!("reserve canonical ID validation: {error}")))?;
1541    for txid in state
1542        .groups
1543        .iter()
1544        .flat_map(|(group, stored)| [group.txid(), stored.revision])
1545        .chain(state.cursor)
1546    {
1547        if validated.contains(&txid) {
1548            continue;
1549        }
1550        let bytes = ordering.get_txn(txid).map_err(|error| {
1551            LoadError::Fatal(format!("validate canonical transaction {txid}: {error}"))
1552        })?;
1553        let Some(bytes) = bytes else {
1554            return Err(LoadError::Recoverable);
1555        };
1556        let transaction = Transaction::parse(&bytes).map_err(|_| LoadError::Recoverable)?;
1557        if transaction.subsystem() != subsystem {
1558            return Err(LoadError::Recoverable);
1559        }
1560        validated.insert(txid);
1561    }
1562    Ok(())
1563}
1564
1565fn exact_bytes<const N: usize>(bytes: &[u8]) -> Result<[u8; N], LoadError> {
1566    bytes.try_into().map_err(|_| LoadError::Recoverable)
1567}
1568
1569fn validate_sql<T>(result: Result<T, SqlError>, operation: &str) -> Result<T, LoadError> {
1570    result.map_err(|error| classify_validation_error(error, operation))
1571}
1572
1573fn classify_validation_error(error: SqlError, operation: &str) -> LoadError {
1574    let recoverable = match &error {
1575        SqlError::SqliteFailure(code, _)
1576            if matches!(
1577                code.code,
1578                rusqlite::ffi::ErrorCode::DatabaseCorrupt | rusqlite::ffi::ErrorCode::NotADatabase
1579            ) =>
1580        {
1581            true
1582        }
1583        SqlError::FromSqlConversionFailure(..)
1584        | SqlError::IntegralValueOutOfRange(..)
1585        | SqlError::Utf8Error(..)
1586        | SqlError::InvalidColumnType(..)
1587        | SqlError::QueryReturnedNoRows => true,
1588        _ => false,
1589    };
1590    if recoverable {
1591        LoadError::Recoverable
1592    } else {
1593        LoadError::Fatal(format!("{operation}: {error}"))
1594    }
1595}
1596
1597fn remove_database_files(database: &Path) -> Result<(), String> {
1598    for path in [
1599        sidecar_path(database, "-wal"),
1600        sidecar_path(database, "-shm"),
1601        database.to_path_buf(),
1602    ] {
1603        match fs::remove_file(&path) {
1604            Ok(()) => {}
1605            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1606            Err(error) => {
1607                return Err(format!(
1608                    "remove recoverable projection file {}: {error}",
1609                    path.display()
1610                ));
1611            }
1612        }
1613    }
1614    Ok(())
1615}
1616
1617fn sidecar_path(database: &Path, suffix: &str) -> PathBuf {
1618    let mut path = database.as_os_str().to_os_string();
1619    path.push(suffix);
1620    PathBuf::from(path)
1621}
1622
1623#[cfg(test)]
1624mod internal_tests {
1625    use std::{
1626        sync::{Arc, TryLockError, mpsc},
1627        thread,
1628        time::{Duration, Instant},
1629    };
1630
1631    use kcode_k1_txn_ordering::K1TxnOrdering;
1632    use rusqlite::Connection;
1633    use tempfile::TempDir;
1634
1635    use super::{GroupAction, GroupId, Projection, TxId, UserId};
1636
1637    #[test]
1638    fn blocked_apply_does_not_block_queries() {
1639        let temporary = TempDir::new().unwrap();
1640        let ordering = K1TxnOrdering::open(&temporary.path().join("ordering")).unwrap();
1641        let projection_root = temporary.path().join("projection");
1642        let (projection, _) = Projection::open(&projection_root, &ordering).unwrap();
1643        let projection = Arc::new(projection);
1644        let blocker = Connection::open(projection_root.join("groups.sqlite3")).unwrap();
1645        blocker.execute_batch("BEGIN IMMEDIATE").unwrap();
1646        let applying = Arc::clone(&projection);
1647        let (apply_sent, apply_received) = mpsc::channel();
1648        let apply = thread::spawn(move || {
1649            apply_sent
1650                .send(applying.apply(
1651                    TxId::from_bytes([1; 12]),
1652                    GroupAction::Create {
1653                        owner: UserId::from_tx_id(TxId::from_bytes([2; 12])),
1654                    },
1655                ))
1656                .unwrap();
1657        });
1658        let waiting_started = Instant::now();
1659        loop {
1660            match projection.apply_lane.try_lock() {
1661                Err(TryLockError::WouldBlock) => break,
1662                Err(TryLockError::Poisoned(_)) => panic!("apply lane poisoned"),
1663                Ok(lane) => drop(lane),
1664            }
1665            assert!(waiting_started.elapsed() < Duration::from_secs(1));
1666            thread::yield_now();
1667        }
1668        assert!(matches!(
1669            apply_received.try_recv(),
1670            Err(mpsc::TryRecvError::Empty)
1671        ));
1672        let querying = Arc::clone(&projection);
1673        let (query_sent, query_received) = mpsc::channel();
1674        let query = thread::spawn(move || {
1675            query_sent
1676                .send(querying.get(GroupId::new(TxId::from_bytes([1; 12]))))
1677                .unwrap();
1678        });
1679        let query_result = query_received.recv_timeout(Duration::from_secs(1));
1680        blocker.execute_batch("ROLLBACK").unwrap();
1681        assert!(query_result.unwrap().unwrap().is_none());
1682        assert!(
1683            apply_received
1684                .recv_timeout(Duration::from_secs(1))
1685                .unwrap()
1686                .is_ok()
1687        );
1688        query.join().unwrap();
1689        apply.join().unwrap();
1690    }
1691}