Zum Inhalt springen
Docs

Befehlspalette

Zu einer Seite oder Ansicht springen oder ein Todo per Name finden.

Änderungen

Sync protocol

The repository's architectural decision, rationale and consequences.

Markdown

Status: accepted · Date: 2026-10-31 · Owner: t11-sync-contract-freeze

Enforcement: packages/api/src/features/sync/*.test.ts cover every acceptance criterion of ticket 11 (catalog + registry completeness, CONFLICTING / FIELD_MERGE sets, ciphertext payload rejection, syncable-table coverage, push/pull/snapshot JSON round-trip, deferred SYNC_KIND_UNAVAILABLE stub); pnpm --filter @wundklare/api exec vitest run src/features/sync runs them; scripts/leaf-modules.test.mjs keeps sync.contract.ts, sync.mutations.ts and sync.rows.ts leaf modules (web and mobile import them). The push/pull handlers themselves land with tickets 21/23; the client engine with ticket 16; the per-feature apply<Kind> with each feature's ticket.

Decision

The server, the device engine and every offline feature share one frozen sync contract (plan §1.7). It freezes four shapes:

  1. The wire envelope: Mutation, PushInput/PushResult, PullInput/PullResult, SnapshotInput/SnapshotResult, IdBlock.
  2. The mutation catalog: 26 kinds, each with its zod payload schema and a behaviour descriptor (insert-only · field-merge · conflicting · terminal-monotonic).
  3. The row schemas: one per syncable table of plan §1.2, delivered inside sync.pull.changes[].row and sync.snapshot.rows[].row.
  4. The handler signature: MutationHandler<K> runs an Effect program against a drizzle transaction and a ClinicalContext, returning an ApplyResult (applied | duplicate | conflict) or a tagged ApplyFailure { severity: "transient" | "permanent" }.

A registry maps every kind to its feature's apply<Kind>. Until a feature ships, its handler is a stub that returns the transient SYNC_KIND_UNAVAILABLE — the device keeps the mutation and retries.

Mutation envelope

{
  id: string           // UUIDv7 — client-generated, unique per organization
  kind: MutationKind
  entityId: string     // the row the mutation targets (not necessarily the patient)
  patientId?: string   // scope hint; the server authoritatively resolves
  baseRev?: number     // for field-merge and conflicting kinds
  dependsOn: string[]  // other mutation ids that must land first
  occurredAt: string   // client wall clock, ISO 8601 with offset
  deviceSeq: number    // monotonic per device; the client tiebreaker
  payload: PayloadFor<K>
  objects?: ObjectStageRef[]  // required by handlers that attach bytes
}

Behaviour by kind

BehaviourKindsServer rule
insert-onlypatient.create, patient.risks, consent.record, consent.revoke, wound.create, wound.classify, photo.capture, photo.attach, measurement.record, measurement.correct, assessment.record, assessment.correct, visit.record, stock.consume, stock.adjust, stock.reorder, prescription.record, prescription.requestFollowUp, report.create, report.deliver, conflict.recordClient id; replay via ledger. Same id + different payload hash → permanent SYNC_ID_CONFLICT.
field mergepatient.update, patient.contactPayload carries changed fields with their base value; disjoint fields merge; same field changed concurrently → server order wins, previous value kept in the trail, result merged (no user conflict — matches product R6).
conflictingplan.propose, reminder.update (due date)Stale baseRev → conflict { theirs }, nothing written.
terminal-monotonicwound.close, reminder.update (status)Terminal state wins; later non-terminal changes ignored.

reminder.update is dual-behaviour on purpose: the dispatcher classifies by which field moved. The catalog records the concurrency-sensitive half (conflicting) so planners pessimistically assume the hard case.

Push

sync.push({ deviceId, mutations[≤100] }). Each mutation runs its own transaction with: boundary (clinicalMutationAccess, ticket 17) → handler → ledger row. Results per mutation (plan §1.7):

  • applied { rev, merged? } — new revision; merged lists the fields a field-merge kind wrote.
  • duplicate — ledger replay by id.
  • conflict { theirs } — stale baseRev on a conflicting kind.
  • rejected { code, permanent: true } — permanent failure (SYNC_DEPENDENCY_REJECTED, SYNC_ID_CONFLICT, SYNC_SCHEMA_INVALID, SYNC_PERMISSION_DENIED, SYNC_SCOPE_DENIED (forged envelope or cross-patient reference), PATIENT_OUT_OF_SCOPE (the patient is outside the user's clinical scope), OBJECT_INVALID); the ledger records the rejection and dependants surface permanently in the sync centre.
  • deferred { code } — transient failure (SYNC_KIND_UNAVAILABLE, SYNC_DEPENDENCY_PENDING, OBJECT_PENDING, SYNC_RATE_LIMITED, timeouts); nothing is written to the ledger, the device retries with exponential backoff.

dependsOn: each id must be applied or duplicate in the ledger, or earlier in the batch; a ledger rejection permanently rejects the dependant (SYNC_DEPENDENCY_REJECTED) even in a later batch.

The response carries deviceState, accessState, policyVersion and ID blocks (≥ 10 free numbers per kind) so the device can keep enrolling patients and wounds offline.

Staged objects

Bytes never become clinical records on their own (plan §1.7, ticket 22). objects.stage writes an intent and returns a presigned PUT with the SHA-256 checksum; the referencing mutation's handler calls attachStagedObjects in its transaction (same org and uploader device, HEAD size and checksum match); missing → deferred OBJECT_PENDING; mismatch → rejected OBJECT_INVALID; unattached stages expire after 7 days (objectsCleanup).

Photos split into photo.capture (metadata, applies at once so measurements and assessments do not wait for Wi-Fi) and photo.attach (objects, follows when the upload is done).

Pull

sync.pull({ deviceId, cursor?, limit ≤ 500 }) → { changes, cursor, hasMore, scopeAdded, scopeRemoved, deviceState, accessState }. Each change is { op: upsert|delete, entity, id, rev, patientId, row? } where row matches ROW_SCHEMAS[entity]; delete tombstones omit the row.

Cursor = opaque (txid, id); the query is where (txid, id) > cursor and txid < pg_snapshot_xmin(pg_current_snapshot()) and the scope filter, ordered by (txid, id). The pg_snapshot_xmin guard means pull never skips a late-committing transaction (ticket 23).

Tombstones come from DELETE triggers (retention, ticket 74). Scope = §1.3.1 rule, evaluated per pull against device_scope: new patients land in scopeAdded (the device snapshots them, so history older than the cursor arrives); leaving patients land in scopeRemoved (device purges unless pending).

Snapshot

sync.snapshot({ deviceId, date?, patientIds? }) runs one REPEATABLE READ transaction: patients, risks, contacts, wounds, last 9 measurements, last 2 assessments, active plan, prescriptions, stock, open reminders, last photo metadata with crop/thumb keys (ghost image), the date's schedule, policy, access state; redaction applied; watermark = pg_snapshot_xmin(pg_current_snapshot()). Pull continues from the watermark: anything not in the snapshot has txid ≥ xmin and is delivered; rows delivered twice are ignored by rev.

Trust and typing

  • Ciphertext fields are typed as wk1.<keyRef>.<iv>.<ct> strings (CIPHERTEXT_PATTERN); a payload that smuggles plaintext into a ciphertext column is rejected at the schema. Column parity between row schemas and the drizzle schema is enforced by ticket 21.
  • Payload schemas are .strict() so unknown fields fail parsing instead of leaking across versions.
  • The envelope is the one source of truth for kind — the discriminated mutationSchema keys each kind to its payload schema; a kind without a payload schema is a type error at build time.

Scoped namespaces and backfill

The device engine (tickets 15, 23) partitions its local store per scope wk:<serverOrigin>:<userId>:<organizationId>. A patient entering scope triggers scopeAdded on the next pull; the device issues a sync.snapshot for that id so history older than the cursor arrives. Leaving triggers scopeRemoved; the device purges unless pending.

Alternatives considered and rejected

  • A single patient.update that writes the whole row. Rejected: offline nurses update different fields of the same patient concurrently; whole-row writes make every late update a conflict screen (R6).
  • Server-generated ids. Rejected: offline-created rows must keep the id the device chose, so references land even before the row is pushed (§1.2 names clinical ids UUIDv7 for exactly this reason).
  • Durable queue (D1, Workflows) for background application. Rejected (ADR-0022): a leased Postgres queue in the one cron tick is cheaper, debuggable, and the sync path is interactive — the dispatcher must answer the device in-request.
  • A single mutable conflict state across kinds. Rejected: wound.close is terminal (never comes back), plan.propose conflicts need both versions kept for the resolve UI, and patient.update must merge disjoint fields silently. One behaviour tag per kind drives the right branch.
Auf GitHub bearbeitenDie Dokumentation ist auf Englisch.