Skip to content

feat: checkpoint requests#198

Open
stevensJourney wants to merge 29 commits into
mainfrom
checkpoint-requests
Open

feat: checkpoint requests#198
stevensJourney wants to merge 29 commits into
mainfrom
checkpoint-requests

Conversation

@stevensJourney

@stevensJourney stevensJourney commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Overview

This implements the core changes for the Checkpoint Requests proposals:

The proposals refactor write checkpoints into a general Checkpoint Requests methodology: clients
now generate and track checkpoint request ids themselves (previously the PowerSync service
generated write checkpoint ids). The service treats client-generated checkpoint request ids as the
write checkpoint ids it reports to older-protocol clients, so both flows share one id namespace.

For background on the legacy system, see docs/historic-write-checkpoints.md. For a detailed
overview of the new system, see docs/write-checkpoint-requests.md. The powersync_control
command and instruction reference in docs/sync.md has been updated accordingly.

Migrations

Write checkpoint state was previously tracked in a synthetic $local bucket in ps_buckets.
Migration v14 moves this state into dedicated ps_kv keys:

Legacy ($local) New (ps_kv)
last_applied_op last_applied_checkpoint_request_id
last_op last_seen_checkpoint_request_id
target_op target_checkpoint_request_id

The migration then deletes the $local row (so ps_buckets only contains real sync buckets) and
drops the ps_buckets.target_op column, so older SDKs fail hard rather than silently diverge if
they open a migrated database. A full down migration restores the v13 schema and rebuilds the
$local row from ps_kv; re-upgrading after a downgrade treats the $local row as the source of
truth, including any progress an older SDK made in between. A read/write audit confirmed the mapped
ps_kv usages are semantically identical to the legacy $local bookkeeping.

A new last_requested_checkpoint_request_id key holds the client-side allocation counter. It is
intentionally not seeded from migrated state: SDKs reconcile the counter with the service on every
connect (see below). See the "Migration from $local" section in
docs/write-checkpoint-requests.md for details, including the ambiguous max-op-sentinel case.

Additions

All new functionality is exposed through powersync_control, consistent with the rest of the sync
interface. Unlike most powersync_control commands, the three query/allocation commands below
return their result directly as a SQLite value instead of an instruction array — they are
responses to a function call, not sync-lifecycle instructions:

  • next_checkpoint_request_id — allocates, persists and returns the next checkpoint request
    id as an integer result. Requires an active sync iteration and seeded request state. It
    participates in the caller's transaction, so a rolled-back allocation can be retried safely.
  • current_checkpoint_request_id — returns the current sequence value (or NULL when
    unseeded) without allocating. SDKs use this to repost an existing checkpoint request after a
    crash or restart without burning a new id.
  • target_checkpoint_request_id — probes, updates, or clears (payload 0) the target used to
    block applying downloaded rows while local writes are outstanding, returning the previous value.
    Works outside a sync iteration. This is the split that keeps explicit checkpoint requests from
    blocking applies: only write checkpoints update the apply gate.
  • seed_checkpoint_request_id — seeds the allocation counter from service state. Rejects
    NULL and 0 payloads: a conforming SDK always has a positive id to seed after reconciliation.

Seeding and reconciliation

EstablishSyncStream now carries a last_checkpoint_request_id hint. On every connection attempt,
the SDK reconciles this hint with the service's checkpoint-request state (posting at least 1) and
seeds core with the response. Core stores seeded values verbatim — it does not enforce
monotonicity. The SDK owns id validity and cannot allocate new requests until seeding completes.
This connect-time reconciliation doubles as a re-request, so checkpoint-request waiters resolve in
every new session without durable applied state in core.

Notifying applied checkpoint requests

When a full checkpoint carrying a write_checkpoint is applied locally, core reports the applied
request id through two channels:

  • DidCompleteSync.applied_checkpoint_request_id — the canonical signal. The field is only
    serialized when the applied checkpoint carried a write checkpoint, so the instruction is
    unchanged for other checkpoints.
  • UpdateSyncStatus.status.internal_last_applied_checkpoint_request_id — an event-style field
    for SDKs that drive waiters from the sync status stream (e.g. web-worker setups where reacting
    to status is easier than plumbing an instruction through). It is set on the status update that
    follows the apply and cleared by later status updates without an applied id, including
    reconnects — SDKs react to the value when a status update carries it rather than polling it as a
    high-water mark. It is runtime-only SDK state, not app-visible progress, and is never reported
    by powersync_offline_sync_status.

Partial (priority) checkpoint completions never report an applied request id and never advance the
seen/applied state.

A general SDK requestCheckpoint flow:

  1. Allocate an id: powersync_control('next_checkpoint_request_id', NULL) (in a transaction).
  2. Post the id to the service's checkpoint request endpoint.
  3. For write checkpoints only, set the apply gate:
    powersync_control('target_checkpoint_request_id', id).
  4. Wait for a DidCompleteSync instruction (or status update) with an applied id >= id.

Local writes set target_checkpoint_request_id to the max-op sentinel and clear the seen/applied
high-water marks, preserving the legacy behavior that only checkpoint request ids observed after
a write can satisfy the apply gate.

Testing

  • Dart tests cover the new control commands (including direct return values and payload
    validation), seeding semantics (verbatim/non-monotonic stores, NULL/0 rejection), apply-gate
    behavior, and applied-id reporting through both DidCompleteSync and the sync status for full
    vs. partial checkpoints.
  • Migration tests cover up, down, and re-upgrade-after-downgrade paths, including sentinel target
    ops and schema-equality assertions against fixtures.

Related PRs for the PowerSync service and the initial Swift SDK implementation will be linked here.

Historical context and alternatives considered

Alternatives considered

  • Standalone SQLite functions — the initial implementation exposed
    powersync_next_checkpoint_request_id() and powersync_probe_local_target_op() as dedicated
    SQLite functions, e.g.:

    let requestId = try await db.writeTransaction { ctx in
        try ctx.get(sql: "SELECT powersync_next_checkpoint_request_id()", parameters: []) { cursor in
            try cursor.getInt64(index: 0)
        }
    }

    These were replaced with powersync_control commands so the checkpoint request lifecycle lives
    in the same interface (and connection) as the rest of the sync client.

  • Instruction-shaped responses — after the move to powersync_control, the query/allocation
    commands initially returned dedicated CheckpointRequestId and LocalTargetOp instructions.
    These weren't really instructions — they were responses to a function call — and handling them
    required special cases in SDKs. They now return their values directly as SQLite results, handled
    in control() without ticking the sync client (mirroring how subscriptions is handled).

  • A dedicated CheckpointRequestApplied instruction — applied checkpoint requests were first
    notified through a separate instruction. Since it was only ever emitted together with
    DidCompleteSync for a fully-applied checkpoint, it was merged into
    DidCompleteSync.applied_checkpoint_request_id, which also removed any ordering question
    between the two instructions.

  • Sync status as the notification stream — an earlier revision emitted the applied request id
    only on the SyncStatus stream. That was first dropped in favor of the dedicated instruction
    (to keep an internal high-water mark from being presented as meaningful sync progress), then
    partially reinstated: the status now carries internal_last_applied_checkpoint_request_id as an
    event-style, runtime-only field alongside the canonical DidCompleteSync signal, because some
    SDK architectures (e.g. web workers) find it much easier to react to the status stream than to
    individual instructions. The internal_ prefix and the clear-on-later-updates semantics keep it
    from being mistaken for persisted state or user-facing progress.

  • max() seeding in core — seeding originally used max(local, service) semantics so the
    counter could never move backwards. This was dropped in favor of storing seeded values verbatim:
    the SDK performs the bidirectional reconciliation with the service and is the only party that can
    judge which value is correct (e.g. after switching users or a service-side reset). The same
    reasoning applies to last_applied_checkpoint_request_id, which is always the id from the last
    applied checkpoint as sent by the service.

  • Allowing NULL seeds — an earlier revision accepted NULL as a seed to mean "no service
    state". Since a conforming SDK always posts at least 1 during reconciliation and seeds the
    accepted response, NULL (and 0) seeds are now rejected by core.

  • Naming: local_target_op — the apply-gate key and control command were originally named
    local_target_op, carrying over the legacy ps_buckets.target_op terminology. Since target_op
    referred to a bucket concept that no longer exists on the client, both were renamed to
    target_checkpoint_request_id. The legacy column name survives only in the v13 schema, the down
    migration that restores it, and the historic docs.

  • Single JSON value vs. separate ps_kv keys — the migrated state could have been stored as
    one JSON document under a single key. Separate keys were chosen to keep querying simple (both for
    core's apply-gate SQL and for debugging).

  • Seeding the request counter from migrated target_op — a concrete legacy $local.target_op
    could seed last_requested_checkpoint_request_id during migration. This was left out as
    redundant: SDKs reconcile the counter with service state on connect before advancing it.

  • Keeping the target_op column — the column could have been retained for compatibility.
    Dropping it was chosen deliberately so that older SDK versions fail with a hard SQLite error on
    local writes instead of silently maintaining state the new implementation no longer reads. The
    down migration restores the column for supported downgrades.


AI Disclosure: I initially implemented the work by hand without AI. Codex 5.5 assisted with
creating tests and writing docs; Claude Code assisted with a review pass, cleanups, and doc
updates. All AI changes have been manually guided and verified.

Comment thread crates/core/src/sync/storage_adapter.rs
Comment thread crates/core/src/sync/interface.rs Outdated
Comment thread crates/core/src/sync/interface.rs Outdated
@stevensJourney stevensJourney changed the title wip: write checkpoints and checkpoint requests feat: checkpoint requests Jul 7, 2026

@rkistner rkistner left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy with the model overall, but I'll leave review of the technical implementation to @simolus3.

Reading the docs, one part that's not 100% clear to me is seed_checkpoint_request_id:

  1. When exactly is this meant to be called? seed typically implies it's during setup only, or perhaps once per connection. Or is that potentially after every request to the server? Describing the SDK implementation like for other operations would help a lot here.
  2. Are there concurrency risks here? For example: (1) client sends 3x requests to the server, each incremental last_checkpoint_request_id (2) client receives a response back from the server, calls seed_checkpoint_request_id, which then sets it back to an older value. Current docs mention the client SDK is responsible for reconsiling these, which can include dealing with concurrency issues like that, but the docs should be clear on what the client SDK should do.

On the docs: It's nice to have all the details, and the SDK implementation descriptions are really useful. But some of the other docs are quite verbose and still difficult to follow: It describes a lot of detailed mechanics, but I'm missing the high-level purpose. For example, it took some digging to see what exactly each of the ps_kv fields are used for, and why we need the 4x separate fields.

Comment thread crates/core/src/sync/storage_adapter.rs Outdated
@stevensJourney

Copy link
Copy Markdown
Contributor Author
  1. When exactly is this meant to be called? seed typically implies it's during setup only, or perhaps once per connection. Or is that potentially after every request to the server? Describing the SDK implementation like for other operations would help a lot here.

This currently happens once per connection. This is used for the case where the PowerSync service might have a checkpoint record, but the client doesn't - perhaps after a disconnectAndClear.

Are there concurrency risks here? For example: (1) client sends 3x requests to the server, each incremental last_checkpoint_request_id (2) client receives a response back from the server, calls seed_checkpoint_request_id, which then sets it back to an older value. Current docs mention the client SDK is responsible for reconsiling these, which can include dealing with concurrency issues like that, but the docs should be clear on what the client SDK should do.

We limit the concurrency on the SDK level currently. Essentially, we do 1 request to the service on each connect iteration. We wait for the response from the server before allowing other checkpoint-requests from executing. The client's sequence is then seeded from the server response. Subsequent checkpoint requests then use the client's sequencing.

On the service side, the PowerSync service avoids concurrency issues by only advancing the checkpoint_request_id if it's increasing. Clients then receive the larger (potentially service side state) response - which they wait for.

On the docs: It's nice to have all the details, and the SDK implementation descriptions are really useful. But some of the other docs are quite verbose and still difficult to follow: It describes a lot of detailed mechanics, but I'm missing the high-level purpose. For example, it took some digging to see what exactly each of the ps_kv fields are used for, and why we need the 4x separate fields.

That's fair feedback. I'll use this to improve the docs.

@simolus3 simolus3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that more information on checkpoint request seeding and reconciliation would be helpful, the docs say that this is owned by SDKs but having an overview of the process and more details on the "recommended pattern" part would help.

Comment thread crates/core/src/sync/interface.rs Outdated
Comment thread crates/core/src/sync/interface.rs Outdated
Comment thread crates/core/src/sync/interface.rs
Comment thread crates/core/src/view_admin.rs Outdated
Comment thread docs/write-checkpoint-requests.md Outdated
Comment thread crates/core/src/sync/interface.rs Outdated
@stevensJourney stevensJourney marked this pull request as ready for review July 9, 2026 14:50
@stevensJourney stevensJourney requested a review from simolus3 July 9, 2026 14:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants