go-oversync

HTTP API

All /sync/* endpoints are expected to be mounted behind authentication. The handlers require an authenticated oversync.Actor{UserID, SourceID} in request context. Auxiliary GET /syncx/health and GET /syncx/status do not require an authenticated actor.

All visible sync keys are structured JSON objects whose values are strings on the wire. UUID-valued keys use canonical dashed lowercase UUID strings. Hidden server ownership columns such as _sync_scope_id never appear in client-visible payloads, conflicts, committed bundle rows, or snapshot rows.

Wire-facing row_version and base_row_version fields are bundle sequence values. The server stores the current version internally as sync.row_state.bundle_seq and returns it through the existing wire field names.

Oversync uses RFC 8785 JSON Canonicalization Scheme (JCS) for canonical bytes. PostgreSQL SMALLINT, INTEGER, BIGINT, NUMERIC, DECIMAL, REAL, and DOUBLE PRECISION business values are JSON strings on every wire surface. Floating strings use the RFC 8785 shortest finite binary64 spelling; negative zero becomes "0". SQLite-affinity clients may upload PostgreSQL Boolean values as the strict strings "0"/"1"; committed rows, pulls, conflicts, and snapshots use JSON Booleans. bundle_hash authenticates the committed logical row stream, while canonical_request_hash associates that commit with the immutable original upload. Hash-only protocol counters are decimal strings inside the logical hash model. byte_count is the length of those canonical logical bytes. This is standard JCS plus a typed wire schema, not an arbitrary-precision extension to JCS.

Oversync does not migrate incompatible stored hashes, server history, staged sessions, or durable client state. To adopt this contract, deploy compatible server and client versions together and recreate the affected server and client databases. Mixed-version operation is unsupported.

All authenticated /sync/* requests must send:

POST /sync/connect

Resolve the first-connect lifecycle for the authenticated (user_id, source_id).

Request:

{
  "has_local_pending_rows": true
}

Response:

{
  "resolution": "initialize_local",
  "initialization_id": "6df0d8dd-a84b-43b6-bbca-8de70432922a",
  "lease_expires_at": "2026-03-22T12:00:00Z"
}

Possible resolution values:

Failure contract:

Notes:

POST /sync/push-sessions

Create one staging push session for a logical dirty-set bundle.

Request:

{
  "source_bundle_id": 7,
  "planned_row_count": 1,
  "canonical_request_hash": "f9952b89b7b7ce1f4a2b9a3ea31ee7e8ca05d19bb6cb2cd400c8d18a1043fc83"
}

Response:

{
  "push_id": "6df0d8dd-a84b-43b6-bbca-8de70432922a",
  "status": "staging",
  "planned_row_count": 1,
  "next_expected_row_ordinal": 0,
  "canonical_request_hash": "f9952b89b7b7ce1f4a2b9a3ea31ee7e8ca05d19bb6cb2cd400c8d18a1043fc83"
}

Notes:

Create failure contract:

POST /sync/push-sessions/{push_id}/chunks

Upload one contiguous chunk into a staging push session.

Request:

{
  "start_row_ordinal": 0,
  "rows": [
    {
      "schema": "business",
      "table": "users",
      "key": {"id": "550e8400-e29b-41d4-a716-446655440000"},
      "op": "INSERT",
      "base_row_version": 0,
      "payload": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "name": "John Doe",
        "email": "john@example.com"
      }
    }
  ]
}

Response:

{
  "push_id": "6df0d8dd-a84b-43b6-bbca-8de70432922a",
  "next_expected_row_ordinal": 1
}

Failure contract:

POST /sync/push-sessions/{push_id}/commit

Commit the fully staged push session atomically.

Response:

{
  "bundle_seq": 143,
  "source_id": "source-1",
  "source_bundle_id": 7,
  "row_count": 1,
  "bundle_hash": "4c8d2d5f5d2c5a41d9aa6f4d2f3ac5d0d1d5d8bbf1d7a8c39f3b3a970f6af21a",
  "canonical_request_hash": "f9952b89b7b7ce1f4a2b9a3ea31ee7e8ca05d19bb6cb2cd400c8d18a1043fc83"
}

Failure contract:

commit_outcome_unknown means the server cannot prove whether the create, chunk, or commit transaction committed. Clients must keep the frozen source bundle ID and canonical request hash and restart POST /sync/push-sessions; they must not infer rollback from an absent or stale staging push_id.

Push conflict response:

{
  "error": "push_conflict",
  "message": "update conflict on business.users 550e8400-e29b-41d4-a716-446655440000: expected version 7, got 3",
  "conflict": {
    "schema": "business",
    "table": "users",
    "key": {"id": "550e8400-e29b-41d4-a716-446655440000"},
    "op": "UPDATE",
    "base_row_version": 3,
    "server_row_version": 7,
    "server_row_deleted": false,
    "server_row": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Jane Doe",
      "email": "jane@example.com"
    }
  }
}

GET /sync/committed-bundles/{bundle_seq}/rows

Fetch one deterministic page of authoritative committed rows for accepted-push replay.

Query:

Response:

{
  "bundle_seq": 143,
  "source_id": "source-1",
  "source_bundle_id": 7,
  "row_count": 1,
  "bundle_hash": "4c8d2d5f5d2c5a41d9aa6f4d2f3ac5d0d1d5d8bbf1d7a8c39f3b3a970f6af21a",
  "canonical_request_hash": "f9952b89b7b7ce1f4a2b9a3ea31ee7e8ca05d19bb6cb2cd400c8d18a1043fc83",
  "rows": [
    {
      "schema": "business",
      "table": "users",
      "key": {"id": "550e8400-e29b-41d4-a716-446655440000"},
      "op": "INSERT",
      "row_version": 143,
      "payload": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "name": "John Doe",
        "email": "john@example.com"
      }
    }
  ],
  "next_row_ordinal": 0,
  "has_more": false
}

DELETE /sync/push-sessions/{push_id}

Best-effort explicit cleanup for an abandoned uncommitted push session.

Response:

GET /sync/pull

Pull complete committed bundles after the client checkpoint.

Query:

Response:

{
  "stable_bundle_seq": 156,
  "bundles": [
    {
      "bundle_seq": 143,
      "source_id": "source-2",
      "source_bundle_id": 18,
      "rows": [
        {
          "schema": "business",
          "table": "users",
          "key": {"id": "550e8400-e29b-41d4-a716-446655440001"},
          "op": "INSERT",
          "row_version": 143,
          "payload": {
            "id": "550e8400-e29b-41d4-a716-446655440001",
            "name": "Jane Doe",
            "email": "jane@example.com"
          }
        }
      ]
    }
  ],
  "has_more": true
}

Notes:

Failure contract:

GET /sync/watch

Open an optional Server-Sent Events stream for metadata-only bundle wakeups.

Query:

Event:

event: bundle
data: {"bundle_seq":157,"source_id":"device-a","source_bundle_id":9}

Notes:

Failure contract:

POST /sync/snapshot-sessions

Create one frozen snapshot session for hydration or destructive recovery.

The request body is optional.

Keep-source rebuild request:

{}

Rotated rebuild request:

{
  "source_replacement": {
    "previous_source_id": "device-old",
    "new_source_id": "device-new",
    "reason": "history_pruned"
  }
}

Notes:

Response:

{
  "snapshot_id": "2f2d3f7a-cd1f-42b9-8d08-c4d2b45d9f88",
  "snapshot_bundle_seq": 156,
  "row_count": 124,
  "byte_count": 18240,
  "expires_at": "2026-03-22T12:00:00Z"
}

Failure contract:

snapshot_session_limit_exceeded applies to row_count, byte_count, and row_byte_count. Snapshot byte limits count complete encoded SnapshotRow UTF-8 JSON bytes, including schema, table, key, row version, payload, and per-row JSON framing; they exclude inter-row commas and the enclosing response object.

Structured source_retired response:

{
  "error": "source_retired",
  "message": "source device-old was retired for user user-123 and replaced by device-new",
  "source_id": "device-old",
  "replaced_by_source_id": "device-new"
}

GET /sync/snapshot-sessions/{snapshot_id}

Fetch one deterministic chunk from a frozen snapshot session.

Query:

Response:

{
  "snapshot_id": "2f2d3f7a-cd1f-42b9-8d08-c4d2b45d9f88",
  "snapshot_bundle_seq": 156,
  "rows": [
    {
      "schema": "business",
      "table": "users",
      "key": {"id": "550e8400-e29b-41d4-a716-446655440001"},
      "row_version": 143,
      "payload": {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "name": "Jane Doe",
        "email": "jane@example.com"
      }
    }
  ],
  "next_row_ordinal": 1,
  "has_more": true,
  "byte_count": 241
}

If the first eligible row does not fit max_bytes, the server returns 400 snapshot_chunk_too_small with its exact required_byte_count. Excess concurrent chunk reads return 429 snapshot_chunk_capacity with required positive delta-seconds Retry-After.

The Go client handles these two exact capacity codes with a dedicated elapsed-time policy (enabled by default, 30-second budget, one-second missing/invalid-header fallback) rather than consuming the generic transport retry-attempt budget.

DELETE /sync/snapshot-sessions/{snapshot_id}

Logically retire a completed or abandoned snapshot session. The bounded cleanup worker removes its rows and then its empty parent asynchronously.

Response:

GET /sync/capabilities

Returns the protocol version, schema version, app name, registered tables, registered table specs, feature flags, and bundle limits.

registered_table_specs is required and canonical. Every item requires non-blank schema and table strings plus exactly one non-blank sync_key_columns entry; duplicate schema/table specs are invalid. registered_tables is an optional legacy summary. Compatible clients compare the exact advertised schema/table and ordered key list with their validated local sync configuration before connect or data movement. This is table/key compatibility checking, not wire-profile or projection negotiation.

The breaking-development server protocol version is exactly v1. Updated clients must reject any other, empty, or unknown value before connect, outbox freeze, or remote synchronization work.

Important feature flags:

Important bundle limit fields:

GET /syncx/health

Readiness-oriented health response. Returns HTTP 200 when the service is healthy, and HTTP 503 when the service is unhealthy. A newly constructed runtime remains unhealthy and reports accepting_operations=false until Bootstrap() has committed schema validation, trigger installation, and any populated-table adoption.

GET /syncx/status

Returns a lifecycle and operability snapshot including lifecycle, registered tables, feature flags, bundle visibility, retained-floor visibility, and error counters. Unlike sync operations, status is available while bootstrap is incomplete so operators can observe the readiness gate.