Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build/external-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function externalPlugins(desktop: boolean): Plugin {
'readwise',
'fsrs-optimizer',
'study-statistics',
'assistant',
]
: [
'review-history',
Expand Down
90 changes: 90 additions & 0 deletions crates/workspace-store/schema/entities.json
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,70 @@
"required": ["id", "pluginId", "operationId", "kind", "fingerprint", "result", "createdAt"],
"additionalProperties": false
},
"automationReceipts": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": { "type": "string", "minLength": 1, "maxLength": 150 },
"actorId": { "type": "string", "minLength": 1, "maxLength": 200 },
"requestId": { "type": "string", "pattern": "^[a-zA-Z0-9._-]{1,100}$" },
"fingerprint": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"operationId": { "type": "string", "minLength": 1, "maxLength": 150 },
"result": {
"type": "object",
"properties": {
"revision": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 },
"resultIds": {
"type": "object",
"properties": {
"sources": {
"maxItems": 100000,
"type": "array",
"items": { "type": "string", "minLength": 1, "maxLength": 150 }
},
"folders": {
"maxItems": 100000,
"type": "array",
"items": { "type": "string", "minLength": 1, "maxLength": 150 }
},
"collections": {
"default": [],
"maxItems": 10000,
"type": "array",
"items": { "type": "string", "minLength": 1, "maxLength": 150 }
},
"notes": {
"maxItems": 100000,
"type": "array",
"items": { "type": "string", "minLength": 1, "maxLength": 150 }
},
"decks": {
"maxItems": 100000,
"type": "array",
"items": { "type": "string", "minLength": 1, "maxLength": 150 }
},
"cards": {
"maxItems": 300000,
"type": "array",
"items": { "type": "string", "minLength": 1, "maxLength": 150 }
}
},
"required": ["sources", "folders", "collections", "notes", "decks", "cards"],
"additionalProperties": false
}
},
"required": ["revision", "resultIds"],
"additionalProperties": false
},
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": ["id", "actorId", "requestId", "fingerprint", "operationId", "result", "createdAt"],
"additionalProperties": false
},
"materialFolders": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
Expand All @@ -1120,6 +1184,32 @@
"required": ["id", "name", "order", "createdAt"],
"additionalProperties": false
},
"materialCollections": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": { "type": "string", "minLength": 1, "maxLength": 150 },
"name": { "type": "string", "minLength": 1, "maxLength": 160 },
"sourceIds": {
"maxItems": 10000,
"type": "array",
"items": { "type": "string", "minLength": 1, "maxLength": 150 }
},
"archived": { "default": false, "type": "boolean" },
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"updatedAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": ["id", "name", "sourceIds", "archived", "createdAt", "updatedAt"],
"additionalProperties": false
},
"sourceConnections": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
Expand Down
10 changes: 8 additions & 2 deletions crates/workspace-store/src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,13 @@ fn counts(db: &Connection) -> Result<BTreeMap<String, u64>> {
"values_store",
"assets",
]) {
if ["source_drafts", "materialFolders"].contains(&table)
if [
"source_drafts",
"materialFolders",
"materialCollections",
"automationReceipts",
]
.contains(&table)
&& !db.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
[table],
Expand Down Expand Up @@ -651,7 +657,7 @@ fn validate_database_at(root: &Path, database_path: &Path, manifest: &Manifest)
db.pragma_update(None, "trusted_schema", "OFF")?;
let app: i64 = db.pragma_query_value(None, "application_id", |r| r.get(0))?;
let version: u32 = db.pragma_query_value(None, "user_version", |r| r.get(0))?;
if app != 0x52505459 || ![10, 11, 12, 13, 14, 15, 16].contains(&version) {
if app != 0x52505459 || ![10, 11, 12, 13, 14, 15, 16, 17].contains(&version) {
return Err(Error("backup-incompatible"));
}
let identity: String = db.query_row("SELECT id FROM workspace_identity", [], |r| r.get(0))?;
Expand Down
2 changes: 2 additions & 0 deletions crates/workspace-store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,6 @@ mod p4_tests;
mod p5_tests;
#[cfg(test)]
mod p6_tests;
#[cfg(test)]
mod p7_tests;
mod study;
2 changes: 1 addition & 1 deletion crates/workspace-store/src/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ impl Library {
.query_row("SELECT id FROM workspace_identity", [], |r| r.get(0))
.map_err(|_| fail("workspace-incompatible"))?;
if app != APP_ID
|| ![10, 11, 12, 13, 14, 15, 16].contains(&version)
|| ![10, 11, 12, 13, 14, 15, 16, 17].contains(&version)
|| identity != marker.id
{
return Err(fail("workspace-incompatible"));
Expand Down
4 changes: 2 additions & 2 deletions crates/workspace-store/src/materials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ mod tests {
let mut db = Database::open(&path, assets.clone()).unwrap();
assert_eq!(version(&db), 11);
let library = db.load().unwrap()["library"].clone();
assert_eq!(library["version"], 18);
assert_eq!(library["version"], 19);
assert!(library["sources"].as_array().unwrap().contains(&imported));
assert_eq!(library["materialReceipts"][0], receipt);
assert_eq!(
Expand All @@ -424,7 +424,7 @@ mod tests {
);
assert_eq!(db.load().unwrap()["revision"], 2);
db.connection
.pragma_update(None, "user_version", 17)
.pragma_update(None, "user_version", 18)
.unwrap();
drop(db);
assert!(Database::open(&path, assets).is_err());
Expand Down
4 changes: 2 additions & 2 deletions crates/workspace-store/src/p6_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ fn study_settings_events_activity_models_survive_checkpoints_reopen_and_complete
))
.unwrap();
let old = library.db.backup(&checkpoint).unwrap();
assert_eq!(old["version"], 18);
assert_eq!(old["version"], 19);
assert_eq!(old["studySettings"][0]["dayStartHour"], 4);
assert_eq!(old["studyEvents"][0], event);
let current = library.db.load().unwrap();
Expand Down Expand Up @@ -281,7 +281,7 @@ fn future_reader_gate_is_rejected_by_library_open_and_complete_backup() {
library
.db
.connection
.pragma_update(None, "user_version", 17)
.pragma_update(None, "user_version", 18)
.unwrap();
let archive = directory.path().join("future.repeaty-workspace");
assert!(backup::create(&library, &archive, &AtomicBool::new(false)).is_err());
Expand Down
197 changes: 197 additions & 0 deletions crates/workspace-store/src/p7_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
use crate::{
backup,
library::Library,
records::{Commit, Patch},
};
use serde_json::{Value, json};
use std::sync::atomic::AtomicBool;

fn receipt(id: &str, actor: &str, request: &str) -> Value {
json!({"id":id,"actorId":actor,"requestId":request,"operationId":"undo-1","fingerprint":"a".repeat(64),"createdAt":"2026-10-30T00:00:00.000Z","result":{"revision":1,"resultIds":{"sources":[],"folders":[],"collections":[],"notes":[],"decks":["historical-deck"],"cards":[]}}})
}
fn patch(value: Value, position: i64) -> Patch {
Patch {
store: "automationReceipts".into(),
id: value["id"].as_str().unwrap().into(),
value: Some(value),
position: Some(position),
position_only: false,
}
}
fn commit(revision: i64, patches: Vec<Patch>) -> Commit {
Commit {
expected_revision: revision,
patches,
reason: None,
operation_history: None,
retired: None,
}
}
fn version(library: &Library) -> u32 {
library
.db
.connection
.pragma_query_value(None, "user_version", |row| row.get(0))
.unwrap()
}

#[test]
fn p7_receipts_are_actor_scoped_immutable_atomic_and_gate_old_readers() {
let parent = tempfile::tempdir().unwrap();
let mut library = Library::create(parent.path()).unwrap();
let original = receipt("receipt", "actor", "request");
let mut bad = original.clone();
bad["fingerprint"] = json!("bad");
assert!(library.db.commit(commit(0, vec![patch(bad, 0)])).is_err());
assert_eq!(version(&library), 10);
library
.db
.commit(commit(0, vec![patch(original.clone(), 0)]))
.unwrap();
assert_eq!(version(&library), 17);
let saved = library.db.load().unwrap();
assert_eq!(saved["library"]["version"], 19);
for bad in [receipt("other-id", "actor", "request"), {
let mut changed = original.clone();
changed["result"]["revision"] = json!(2);
changed
}] {
assert!(library.db.commit(commit(1, vec![patch(bad, 0)])).is_err());
assert_eq!(library.db.load().unwrap(), saved);
}
assert!(
library
.db
.commit(commit(
0,
vec![patch(receipt("second", "other-actor", "request"), 1)]
))
.is_err()
);
library
.db
.commit(commit(
1,
vec![patch(receipt("second", "other-actor", "request"), 1)],
))
.unwrap();
let mut undo = commit(2, vec![]);
undo.operation_history = Some(
json!({"schemaVersion":1,"undo":[{"changes":[{"store":"automationReceipts"}]}],"redo":[],"mediaIds":[]}),
);
assert!(library.db.commit(undo).is_err());
assert_eq!(library.db.load().unwrap()["revision"], 2);
}

#[test]
fn p7_receipts_keep_original_result_through_checkpoint_reopen_and_complete_backup() {
let parent = tempfile::tempdir().unwrap();
let root = parent.path().join("library");
std::fs::create_dir(&root).unwrap();
let mut library = Library::create(&root).unwrap();
let original = receipt("receipt", "actor", "request");
library
.db
.commit(commit(0, vec![patch(original.clone(), 0)]))
.unwrap();
library.db.backup_now(true).unwrap();
let checkpoint = library.db.backups().unwrap()[0]["id"]
.as_str()
.unwrap()
.to_owned();
library
.db
.commit(commit(
1,
vec![patch(receipt("second", "actor", "request-2"), 1)],
))
.unwrap();
assert_eq!(
library.db.backup(&checkpoint).unwrap()["automationReceipts"],
json!([original])
);
let current = library.db.load().unwrap();
drop(library);
let library = Library::open(&root).unwrap();
let archive = parent.path().join("full.repeaty-workspace");
let saved = backup::create(&library, &archive, &AtomicBool::new(false)).unwrap();
let restored_root = parent.path().join("restored");
backup::restore(
&archive,
&restored_root,
&saved.sha256,
&AtomicBool::new(false),
)
.unwrap();
let mut restored = Library::open(&restored_root).unwrap();
assert_eq!(version(&restored), 17);
assert_eq!(restored.db.load().unwrap()["library"], current["library"]);
}

#[test]
fn p7_collections_preserve_ordered_soft_members_through_checkpoint_and_restore() {
let parent = tempfile::tempdir().unwrap();
let root = parent.path().join("library");
std::fs::create_dir(&root).unwrap();
let mut library = Library::create(&root).unwrap();
let original = json!({"id":"collection","name":"Reading list","sourceIds":["missing-source","another-source"],"archived":false,"createdAt":"2026-10-30T00:00:00.000Z","updatedAt":"2026-10-30T00:00:00.000Z"});
let change = |value: Value| Patch {
store: "materialCollections".into(),
id: "collection".into(),
value: Some(value),
position: Some(0),
position_only: false,
};
let mut duplicate = original.clone();
duplicate["sourceIds"] = json!(["same", "same"]);
assert!(
library
.db
.commit(commit(0, vec![change(duplicate)]))
.is_err()
);
assert_eq!(version(&library), 10);
library
.db
.commit(commit(0, vec![change(original.clone())]))
.unwrap();
assert_eq!(version(&library), 17);
library.db.backup_now(true).unwrap();
let checkpoint = library.db.backups().unwrap()[0]["id"]
.as_str()
.unwrap()
.to_owned();
let mut archived = original.clone();
archived["archived"] = json!(true);
archived["sourceIds"] = json!(["another-source", "missing-source"]);
library
.db
.commit(commit(1, vec![change(archived.clone())]))
.unwrap();
assert_eq!(
library.db.backup(&checkpoint).unwrap()["materialCollections"],
json!([original])
);
drop(library);
let mut library = Library::open(&root).unwrap();
assert_eq!(
library.db.load().unwrap()["library"]["materialCollections"],
json!([archived])
);
let archive = parent.path().join("collections.repeaty-workspace");
let saved = backup::create(&library, &archive, &AtomicBool::new(false)).unwrap();
let restored_root = parent.path().join("restored");
backup::restore(
&archive,
&restored_root,
&saved.sha256,
&AtomicBool::new(false),
)
.unwrap();
let mut restored = Library::open(&restored_root).unwrap();
assert_eq!(version(&restored), 17);
assert_eq!(
restored.db.load().unwrap()["library"]["materialCollections"],
json!([archived])
);
}
Loading