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