Sync with Tracker
IssueDB can synchronise issues, tags, dependencies and relations with a Tracker
server. Sync is dry run by default — nothing is written to your local
database until you pass --apply.
Signing in
A Tracker API key is stored once per machine. No database is created by these commands; they are statements about the machine, not about a project.
# Store a key (omit --token to be prompted; a token on the command line
# lands in your shell history)
issuedb-cli signin --token trk_...
# See which key is stored (the secret is never printed)
issuedb-cli whoami
# Remove the stored key. Prints the store path BEFORE removing anything —
# on a shared machine this is not private cleanup, and the path is the only
# way to see whose credential you are about to delete.
issuedb-cli signout
The key is stored in the XDG config directory with 0600 permissions.
To isolate a test from your real credential, set XDG_CONFIG_HOME — that is the only override this tool honours. Then check the path each command prints: an env var it does not read fails silently, and the first sign that your isolation did not work is a path you did not expect.
Running a sync
# Dry run — pull and show what WOULD happen, write nothing
issuedb-cli sync
# Apply the changes
issuedb-cli sync --apply
What a sync does, in order:
Handshake — confirms the server’s protocol range and project identity before anything local is touched. A protocol mismatch stops the sync with the database untouched.
Pull — reads the whole feed, following pagination until the server reports no more. The printed count is the full feed, not the first page.
Plan — renders every change as
CREATE/UPDATE/DELETE/SKIPwith a reason. A dry run and--applycompute the same plan, so what you are shown is what would be done.Apply (only with
--apply) — applies each change in its own transaction, then advances the cursor only to what was durably committed. A failure mid-apply keeps the cursor before the failure, so re-running retries from there.Coverage — states which local data has no sync entity to travel on (e.g. comments, audit logs), so “everything synced” is never silently assumed.
What to commit
.issuedb-project.json COMMIT THIS the project this repository belongs to
.issue.db DO NOT COMMIT your local replica
.issue.db is a binary SQLite file. Two developers each creating an issue
produce a conflict git cannot merge, and sharing issues is what sync is for —
a committed database and a synced one race over the same rows. Keep it in
.gitignore.
.issuedb-project.json is written on your first successful sync and holds
only the project id and server URL — nothing secret. Commit it. It is what
makes a fresh clone sync to the same project with no setup, and it is what makes
a clone REFUSE a server that names a different project: an API key pointing at
somebody else’s project would otherwise be adopted silently by a fresh
database, merging two backlogs.
State and identity
The cursor and replica identity live outside the database, keyed to the project. The project id itself is recorded inside the database and is write-once: if the server ever reports a different project for a database that already holds one, sync refuses rather than merge two projects’ rows.
Derived identity
Rows that have no identity of their own — a tag on an issue, a dependency between two issues — get a derived uid, so two replicas that independently record the same fact converge with no conflict machinery. Issues get a minted uid (a random one), because two replicas writing “the same” issue have genuinely written two.
from issuedb.sync import derived_uid, dependency_uid, relation_uid, mint_uid
derived_uid("issue_tag", project_uid, issue_uid, tag_name)
dependency_uid(project_uid, blocker_uid, blocked_uid)
relation_uid(project_uid, source_uid, rel_type, target_uid, symmetric_types)
symmetric_types is read from the handshake rather than hardcoded: if a
relation type means the same thing both ways round, its endpoints are sorted so
both directions derive one uid. Dependencies are never sorted — they are
directional, and reversing them must give a different uid or a cycle could not
be represented.
Note
The dependency_uid field order is specified by
contracts/sync/PROTOCOL.md line 150 –
"idep", project_uid, blocker_uid, blocked_uid – committed 2026-08-15,
a week before issuedb derived its first dependency uid.
It is independently confirmed: all 16 server-derived dependencies in
Tracker’s live feed match that order and none match the reverse. The frozen
vector at tests/data/vectors_issuedb/14-dependency-uid-derivation.json
pins the uid the server produced, not issuedb’s own output, so a
divergence from the specified order fails a test rather than silently
forking a row.
Pushing local changes
sync is bidirectional. A dry run reports both directions — what would
come in and what would go out — and --apply does both, pulling first so a
local row the server already knows is reconciled before it is offered back.
$ issuedb-cli sync
Pulled 12 change(s) from cursor c:0.
...
WOULD PUSH 3 local change(s): 1 issue_dependency · 2 issue
$ issuedb-cli sync --apply
Applied 12 change(s). Cursor now c:14.
Pushed 3 change(s).
What travels, and what does not:
issuesuid minted on first push and remembered in the ledger, so editing an issue never changes its identity.
issue_dependencies,issue_relationsuid derived from the frozen canonical form, so two replicas that independently record the same edge converge with no conflict machinery.
commentsuid minted per local comment and remembered, like an issue. A comment is not its text: two people writing “+1” on one issue have written two comments, so deriving from
(project, issue, text)would collapse them into one row and lose one with nothing erroring.issue_tagsrefused by issuedb, on purpose — not by the server. The outbox trigger records
issue_idas the local id and the ledger is keyed(entity, local_id), so two tags on one issue would collide on one key and one tag would be sent under another’s identity. Tracker does acceptissue_tag; this is our limitation. Sync says so in those words, because the coverage report a few lines later listsissue_tagamong the entities the server advertises, and the two together could otherwise read as the server refusing it. Fixing it needs a schema change (issuedb #13).- everything else
comments, templates, time entries and the rest have no entity on the wire. The coverage report names them on every sync rather than dropping them silently.
A per-uid rejection inside a 200 is not success: the outbox mark does
not advance past a rejected entry, so the change is offered again next sync
rather than lost.
Known limitation
The apply path applies issues, relations and dependencies, but not tags — a
sync reports tag changes as SKIP — issuedb does not apply entity 'issue_tag'
yet — see “Pushing local changes” above for why tags are held back.
Comments travel in both directions as of 2.36.0.