Skip to content
  • Follow System
  • English
  • 中文

KB Sync — v1 vs v2

The Picora KB sync protocol got an upgrade in v0.35.0 (released 2026-08-22). This page explains what changed, why, and how to migrate your client. The v1 protocol continues to work — sunset is 2027-03-01.

TL;DR

Capabilityv1v2
Full manifest fetch
Incremental manifest (?since=<cursor>)
Tombstones (cross-client delete propagation)✅ (include=tombstones)
Optimistic locking (sourceHash enforced)⚠️ optional✅ required on update/move/rename
Conflict-preserving sync (preserve_both)
Sync ops max per batch100200

Opting into v2

Send the Picora-Sync-Version: 2 request header on /v1/kbs/:id/manifest and /v1/kbs/:id/sync. That’s it — same URLs, same body shape (with sourceHash now required where listed above).

GET /v1/kbs/V1StGXR8_Z5jdHi6B-myT/manifest?since=eyJ0cyI6...&include=tombstones
Authorization: Bearer <token>
Picora-Sync-Version: 2

For environments that can’t set custom headers, use ?syncVersion=2 query parameter instead. The header takes precedence when both are set.

Cursor-based incremental manifest

The big win in v2 is incremental sync. v1 always returns up to 1000 docs / 5 MB at once; large KBs paginate. v2 lets a client pass the cursor it received last time and get only the changes since:

GET /v1/kbs/<id>/manifest?since=<cursor>&include=tombstones

The cursor is base64url-encoded {"ts": "2026-08-23T10:00:00.000Z", "id": "doc_abc..."}. The server uses (updated_at ASC, id ASC) ordering with (updated_at, id) > cursor semantics, so same-millisecond batch commits don’t lose or duplicate rows.

First sync: omit since. The server returns the full set in pages, just like v1.

Tombstones

Deleting a doc on one client used to invisible to others — they’d keep showing the doc until they did a full re-sync. v2 fixes this with tombstones:

  • Every delete (soft or hard) writes a row to kb_doc_tombstones.
  • v2 manifest with include=tombstones returns tombstones since the cursor.
  • Tombstones are kept for 30 days (Trial / Pro) or 90 days (Pro+).
{
"tombstones": [
{
"docId": "old_doc_xyz",
"relativePath": "drafts/deprecated.md",
"sourceHash": "fa7c…",
"deletedAt": "2026-08-22T08:00:00.000Z"
}
]
}

When the client sees a tombstone, it should delete the matching local file. Optionally call op: "tombstone_ack" in the next sync to confirm — Picora uses these acks for analytics, not correctness.

Long-offline clients

If a client returns after 30+ days offline (Pro+: 90+ days), some tombstones may have been pruned. The server detects since < tombstoneRetentionBoundary and responds with:

{
"tombstoneCursorExpired": true,
"retentionDays": 30,
"hint": "Re-sync from scratch."
}

The client should drop its local cursor and call manifest without since to get a fresh baseline.

Required sourceHash

In v1, update ops could omit sourceHash and the server would happily overwrite with last-write-wins. v2 makes the hash mandatory on update, move, and rename:

{
"ops": [
{ "op": "update", "docId": "...", "content": "...", "sourceHash": "abc123…" }
]
}

Missing it returns 422 SYNC_HASH_REQUIRED. Computing the hash is straightforward:

const sha256 = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(content))
const hex = Array.from(new Uint8Array(sha256))
.map(b => b.toString(16).padStart(2, '0')).join('')

preserve_both conflict resolution

When the server’s current sourceHash differs from the client’s sourceHash, the default behavior is 409 SYNC_CONFLICT — the op is rejected, master is unchanged. Pass ?conflictResolution=preserve_both to keep both versions:

POST /v1/kbs/<id>/sync?conflictResolution=preserve_both

The server does not overwrite master. Instead it writes a row to kb_doc_conflict_branches and returns:

{
"results": [
{ "opIndex": 0, "status": "applied", "docId": "..." },
{
"opIndex": 1,
"status": "conflict_branch_created",
"docId": "doc_b",
"branchId": "br_xK9...",
"currentMasterHash": "xyz",
"currentMasterUpdatedAt": "2026-08-23T..."
}
]
}

The user resolves branches later in picora-center → KB → Conflicts:

  • Adopt branch → branch becomes master (POST /v1/kbs/:id/conflicts/:branchId/accept)
  • Discard branch → permanent (DELETE /v1/kbs/:id/conflicts/:branchId)

Branch quotas

LimitValue (Trial / Pro / Pro+)
Pending branches per document5
Pending branches per user (global)100
Max single branch size10 MB / 20 MB / 50 MB
Auto-discard pending branches after30 days (email user 7 days before)

Picora allows up to 20% short-term quota overage when writing a branch — your master + branches together can briefly exceed plan limits while the user decides. After 14 days, unresolved branches are force-discarded to release the overage.

Additions in v0.68.0

Three request-frequency / cost optimizations landed for high-traffic clients (e.g. Moraya Web). None break v2 — they are additive.

op: 'move' — rename / move a path, keeping docId stable

Previously a rename meant “create new + delete old”, which changed the docId and broke server-side references, share links, and history. The new move op renames in place:

// POST /v1/kbs/:id/sync (one op inside the ops[] array)
{ "op": "move", "fromPath": "notes/idea.md", "toPath": "archive/idea.md", "baseUpdatedAt": "2026-06-01T10:00:00.000Z" }
  • Pure path change — no content, no sourceHash (content is unchanged; baseUpdatedAt is the optimistic-lock check, same as upsert/delete).
  • docId is preserved — the applied item returns the same id, with relativePath = toPath and fromPath echoed back.
  • The server also writes a tombstone at fromPath, so incremental / path-keyed clients drop the old path locally and pick up the new one.

Applied item shape:

{ "op": "move", "id": "doc_abc...", "relativePath": "archive/idea.md", "fromPath": "notes/idea.md", "updatedAt": "...", "sourceHash": "..." }

New conflict reasons: MOVE_SOURCE_MISSING (no active doc at fromPath) and MOVE_TARGET_EXISTS (toPath already occupied). REMOTE_NEWER / REMOTE_DELETED / BASE_MISSING apply as usual.

Conditional manifest — ETag / If-None-Match304

GET /v1/kbs/:id/manifest now returns a weak ETag computed from the KB’s high-water-mark plus your request parameters (since / cursor / limit / protocol version). Send it back as If-None-Match; if nothing changed, you get an empty 304 Not Modified:

GET /v1/kbs/<id>/manifest?since=<iso>
Authorization: Bearer <token>
If-None-Match: W/"3f9c1a2b4d5e6f708192a3b4c5d6e7f8"
→ 304 Not Modified (KB unchanged; near-zero cost for "no changes" polling)

Because the ETag is bound to your exact query, two clients with different since values never share a cache entry.

Batch content fetch — POST /v1/docs/raw:batch

Fetch up to 50 documents’ raw Markdown in a single round-trip, collapsing cold-start preview from N requests to 1:

// POST /v1/docs/raw:batch (Bearer optional — own + public with a token, public-only without)
{ "ids": ["doc_a...", "doc_b...", "doc_c..."] }
// 200
{ "success": true, "data": {
"docs": [{ "id": "doc_a...", "content": "# ...", "updatedAt": "...", "sourceHash": "..." }],
"failed": [{ "id": "doc_c...", "reason": "NOT_FOUND" }]
}}

docs[] preserves input order. Not-found / unauthorized / soft-deleted ids fold into failed[] as NOT_FOUND (existence is not leaked); a content-read failure is CONTENT_FETCH_FAILED. Read-only — no upload quota consumed.

Incremental ?since= is already available

The cursor / ?since=<ISO> incremental manifest described above has shipped since v2 — a client that stores the response serverTime and passes it as the next since only receives updatedAt/deletedAt > since entries plus tombstones. No new endpoint is needed for incremental sync; combine it with the ETag above to make “no changes” polling free.

Recommended client loop (fastest “did it change, then pull only the delta”):

  1. First loadGET /v1/kbs/:id/manifest (no since) → full active manifest, paginated (limit ≤ 1000, follow nextCursor). Persist the response serverTime as your local watermark.
  2. On each sync / focusGET /v1/kbs/:id/manifest?since=<watermark> with If-None-Match: <last ETag>:
    • 304 Not Modified → nothing changed, stop (near-zero cost).
    • 200 with items[] (+ tombstones[] under Picora-Sync-Version: 2) → these are only the docs changed since your watermark. Fetch their bodies with POST /v1/docs/raw:batch or GET /v1/kbs/:id/raw?path=. Advance your watermark to the new serverTime.

This is the whole “check-then-delta” flow in one endpoint — the since param + ETag together do both the change-detection and the incremental fetch. Don’t poll GET /v1/docs?kbId= for sync (it has no since filter; it’s for browse UIs, not sync clients).

Coarse per-KB signal: GET /v1/kbs returns each KB’s updatedAt, which bumps on any child-doc add / content-replace / delete and in-KB rename (rename fixed in v0.74.x). Use it only as a cheap “which KBs to check” hint; the manifest ETag/watermark remains the authoritative per-doc delta source.

Note (GET /v1/kbs/:id/manifest requires an active plan): the manifest/sync endpoints are plan-gated (see the plan-gate policy). Browsing an existing KB’s document list (GET /v1/docs?kbId=) stays available after a plan expires, but incremental sync via the manifest requires an active subscription.

Sunset of v1

DateBehavior
2026-08-22v0.35 ships. v2 is opt-in via header; v1 unchanged.
2026-09-01v1 responses include Sunset: 2027-03-01 and Deprecation: true.
2027-02-15Final email reminder to known v1 callers.
2027-03-01v1 endpoints return 410 SYNC_V1_SUNSET with link to this guide.

If you maintain a third-party client and need help migrating, file an issue at github.com/picora.

Error reference

HTTPCodeWhen
400INVALID_SYNC_VERSIONHeader value is non-numeric or 0
400SYNC_VERSION_UNSUPPORTEDHeader declares v3+ (server’s max is 2)
400INVALID_CURSORCursor decode failed / wrong shape
409SYNC_CONFLICTsourceHash mismatch in default mode
409SYNC_VERSION_MISMATCHHeader v2 + body looks like v1 (missing sourceHash)
410SYNC_V1_SUNSETAfter 2027-03-01, v1 endpoints
410TOMBSTONE_CURSOR_EXPIREDsince cursor older than tombstone retention
422SYNC_HASH_REQUIREDv2 update/move/rename missing sourceHash
422CONFLICT_BRANCH_LIMIT_*Per-doc / per-user / size limit hit
422INVALID_OP_BATCH_SIZEMore than 200 ops in one sync request

Migrating an existing v1 client (checklist)

  1. Update your sync URL and add Picora-Sync-Version: 2.
  2. Add a content hash function and pass sourceHash on update / move / rename.
  3. Cache the cursor returned by manifest and pass it as since next time.
  4. Process the tombstones array — delete locally, optionally call tombstone_ack for telemetry.
  5. Decide your conflict policy: stick with the default 409, or use ?conflictResolution=preserve_both if your UX can show pending branches.
  6. Handle the new error codes (SYNC_HASH_REQUIRED, TOMBSTONE_CURSOR_EXPIRED).

A reference implementation lives in @picora/sdk@0.2.0 (TBD); check @picora/sdk reference once available.