Skip to content

Commit 46cc297

Browse files
docs updates
1 parent c7254a4 commit 46cc297

7 files changed

Lines changed: 74 additions & 27 deletions

File tree

crates/core/src/crud_vtab.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,10 @@ impl SimpleCrudTransactionMode {
248248
fn record_local_write(&mut self, db: *mut sqlite::sqlite3) -> Result<(), ResultCode> {
249249
if !self.had_writes {
250250
// Also clear the seen/applied high-water marks: checkpoint request ids observed before
251-
// this write can't acknowledge it, and they may come from an incompatible id namespace
252-
// (legacy write checkpoints migrated by v14, or state from before a counter restart).
253-
// Keeping them around could open the apply gate for a newly allocated target id that
254-
// compares below a stale seen value. The legacy `$local` bookkeeping had the same
255-
// behavior by resetting the entire row on local writes.
251+
// this write can't acknowledge it, and stale values may predate a request counter
252+
// restart. Keeping them around could open the apply gate for a newly allocated target
253+
// id that compares below a stale seen value. The legacy `$local` bookkeeping had the
254+
// same behavior by resetting the entire row on local writes.
256255
db.exec_safe(formatcp!(
257256
"INSERT OR REPLACE INTO ps_kv(key, value) VALUES('local_target_op', {MAX_OP_ID});
258257
DELETE FROM ps_kv WHERE key IN ('last_seen_checkpoint_request_id', 'last_applied_checkpoint_request_id')"

crates/core/src/sync/interface.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,11 @@ pub enum Instruction {
143143
request: StreamingSyncRequest,
144144
/// The latest checkpoint request id known locally before opening this stream.
145145
///
146-
/// SDKs can use a missing value as a cue to fetch checkpoint request state from the service
147-
/// and report it back with `seed_checkpoint_request_id`.
146+
/// This is simply the client's current counter state. SDKs use it on every connection
147+
/// attempt to re-affirm checkpoint request state with the service, which may have deleted
148+
/// its record. The re-affirmation works bidirectionally: it can restore the service-side
149+
/// value from this hint or bump the local counter from the service's response, which is
150+
/// reported back with `seed_checkpoint_request_id`.
148151
last_checkpoint_request_id: Option<i64>,
149152
},
150153
FetchCredentials {

dart/test/crud_test.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ void main() {
250250
});
251251

252252
test('updates local target op and updated rows', () {
253-
// Stale high-water marks (e.g. migrated legacy write checkpoints) must be cleared by a
254-
// local write, so they can't open the apply gate for a smaller new target id.
253+
// Stale high-water marks (e.g. from before a request counter restart) must be cleared
254+
// by a local write, so they can't open the apply gate for a smaller new target id.
255255
db.execute('''
256256
INSERT INTO ps_kv(key, value) VALUES
257257
('last_seen_checkpoint_request_id', 6),

dart/test/sync_test.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,8 +611,8 @@ void _syncTests<T>({
611611
expect(lastAppliedCheckpointRequestId(), 5);
612612

613613
// A local write can only be acknowledged by a checkpoint request id observed after it. Stale
614-
// seen/applied values (which may come from another id namespace, like migrated legacy write
615-
// checkpoints) must not remain to open the apply gate for a smaller new target id.
614+
// seen/applied values (which may predate a request counter restart) must not remain to open
615+
// the apply gate for a smaller new target id.
616616
db.execute("insert into items (id, col) values ('local', 'data');");
617617

618618
expect(lastAppliedCheckpointRequestId(), isNull);

docs/schema.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@ a checkpoint and that we have validated its checksum).
3333
Schema version 14 removes the legacy `target_op` column after migrating `$local.target_op` to
3434
`ps_kv.local_target_op`, and deletes the `$local` row so `ps_buckets` only contains real sync
3535
buckets. This makes older SDKs fail with a hard SQLite error if they try to keep using the migrated
36-
database without downgrading. The down migration restores `target_op` and recreates the `$local`
37-
row from `ps_kv` for older schema versions.
36+
database without downgrading. That failure is deliberate — including for multi-process deployments
37+
where processes with mixed SDK versions share one database — because an older SDK silently
38+
maintaining `$local` state the new implementation no longer reads would be worse than a loud error.
39+
The down migration restores `target_op` and recreates the `$local` row from `ps_kv` for older
40+
schema versions.
3841

3942
## `ps_crud`
4043

docs/sync.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,14 @@ The following commands are supported:
4747
ahead, or restore the service-side value when the service has cleared stale state but core still
4848
has a local hint. Then seed core with the reconciled value. Core stores the seeded value
4949
verbatim and does not enforce monotonicity; SDKs own the reconciliation and must not seed a
50-
stale value. `NULL` means neither side has a record for the client yet; core stores `0` in that
51-
case so the state counts as seeded and the first allocation returns `1`. If both the client and
52-
service have lost the value, the counter may restart.
50+
stale value. A `NULL` payload is accepted for completeness (core stores `0`, marking the state
51+
as seeded so the first allocation returns `1`), but SDKs should not need it in practice:
52+
posting a checkpoint request with an id of at least `1` during reconciliation and seeding the
53+
service's response covers the no-record case and doubles as a probe of the service's
54+
checkpoint-request support. Blindly forwarding a raw `NULL` service response while core holds a
55+
counter would reset it, since the store is verbatim (see
56+
`docs/write-checkpoint-requests.md`). If both the client and service have lost the value, the
57+
counter may restart.
5358

5459
When uploads request a write checkpoint, SDKs should call
5560
`powersync_control('next_checkpoint_request_id', NULL)` inside a transaction to allocate the id to
@@ -78,7 +83,9 @@ only real sync buckets in `ps_buckets`, and drops `ps_buckets.target_op` so
7883
older SDKs fail hard if they try to keep using the migrated database directly. Downgrading restores
7984
the column, and restores a `$local` row only when `local_target_op` exists, so older SDKs can keep
8085
using target-op based blocking without inventing a synthetic local bucket when there was no local
81-
target state. Because the down migration keeps the `ps_kv` keys around, the up migration clears
86+
target state. A restored concrete target remains satisfiable after a downgrade because checkpoint
87+
request ids and legacy write checkpoint ids share one namespace: the service reports accepted
88+
checkpoint request ids as the `write_checkpoint` values older-protocol clients observe. Because the down migration keeps the `ps_kv` keys around, the up migration clears
8289
them before copying, so re-upgrading a downgraded database takes the `$local` row (including any
8390
progress an older SDK made) as the source of truth instead of failing on the existing keys.
8491

@@ -124,11 +131,11 @@ interface LogLine {
124131
}
125132

126133
// Instructs client SDKs to open a connection to the sync service.
127-
// last_checkpoint_request_id is core's local counter value before this stream request. On connect,
128-
// SDKs can use it to re-request this client's last checkpoint request state from the service, then
129-
// call powersync_control('seed_checkpoint_request_id', value) with the actual response for
130-
// reconciliation. `value` may be null when the service has no checkpoint request state for this
131-
// client.
134+
// last_checkpoint_request_id is the client's current counter state before this stream request.
135+
// On every connect, SDKs use it to re-affirm checkpoint request state with the service (which may
136+
// have deleted its record). The re-affirmation is bidirectional: the hint can restore the
137+
// service-side value, or the service's response can bump the local counter via
138+
// powersync_control('seed_checkpoint_request_id', response).
132139
interface EstablishSyncStream {
133140
request: any // The JSON-encoded StreamingSyncRequest to send to the sync service
134141
last_checkpoint_request_id: null | number

docs/write-checkpoint-requests.md

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,7 @@ WHERE key IN ('last_seen_checkpoint_request_id', 'last_applied_checkpoint_reques
4949

5050
Clearing the high-water marks mirrors how the legacy flow reset the whole `$local` row on local
5151
writes. A checkpoint request id observed before the write cannot acknowledge it, and a stale seen
52-
value may even come from an incompatible id namespace — a legacy write checkpoint migrated by v14,
53-
or state from before a request counter restart. If such a value stayed around, a newly allocated
52+
value may even predate a request counter restart. If such a value stayed around, a newly allocated
5453
target id could compare below it and open the apply gate before the service acknowledged the write.
5554
After a local write, only checkpoint request ids observed from that point on count towards the gate.
5655

@@ -154,9 +153,16 @@ local hint can restore the service-side value and keep the counter from moving b
154153
client lost local state but the service still has a record, the service response restores the seed
155154
locally. If both sides have lost the value, it is acceptable for the counter to restart; this can
156155
happen after local state is cleared and stale service state expires, or when multiple user ids share
157-
the same client id. After reconciliation, `value` may be `NULL` when neither side has a record for
158-
the client; core stores `0` in that case so the state counts as seeded and the first allocation
159-
returns `1`. SDKs may also refresh service state when their user/client context changes.
156+
the same client id. SDKs may also refresh service state when their user/client context changes.
157+
158+
Because seeds are stored verbatim, seeding `NULL` (or a low id) while core holds a higher counter
159+
resets that counter, and previously allocated ids would be handed out again. SDKs must therefore
160+
never forward a raw service response without reconciling: the recommended pattern is to always post
161+
an id of at least `1` (the maximum of the local hint and any concrete local target) during
162+
reconciliation and seed core with the service's response to that request. In practice SDKs never
163+
need to seed `NULL` at all — when there is no local record, posting a checkpoint request with id
164+
`1` works and doubles as a probe of the service's checkpoint-request support. Core still accepts a
165+
`NULL` seed for completeness.
160166

161167
`powersync_control('next_checkpoint_request_id', NULL)` must be called inside a transaction during
162168
an active sync iteration after `last_requested_checkpoint_request_id` exists locally. It increments
@@ -171,6 +177,14 @@ RETURNING value;
171177

172178
This command only allocates an id. It does not update `local_target_op`.
173179

180+
Calling it without an active iteration or before seeding raises a state error. This is a normal
181+
part of the connection lifecycle (for example a `requestCheckpoint` call racing a stream restart),
182+
not a programming error — SDKs should surface it as a retryable condition.
183+
184+
The increment participates in the caller's transaction. If the transaction rolls back after the id
185+
was already posted to the service, the same id is allocated and posted again on retry; this is safe
186+
because the service treats the latest posted id as the effective request state.
187+
174188
Note on sequences: SQLite does not have standalone sequences. The sequence-like alternatives are
175189
either an `AUTOINCREMENT` table backed by SQLite's internal `sqlite_sequence`, or a dedicated
176190
single-row counter table like the existing `ps_tx` transaction counter. The checkpoint request
@@ -298,6 +312,12 @@ greater than or equal to any previously requested id and core emits a fresh
298312
migration/downgrade state and debugging; SDKs should use `CheckpointRequestApplied` instructions
299313
to resolve `CheckpointRequest` waits.
300314

315+
`powersync_clear` deletes all of these keys in both clear modes (it removes every `ps_kv` entry
316+
except `client_id`). This is deliberate and mirrors the legacy behavior of deleting the `$local`
317+
row: pending CRUD is wiped in the same operation, so no apply gate is needed, and the request
318+
counter is restored by the connect-time reconciliation described above. If the service has also
319+
lost its record, the counter restarting is acceptable.
320+
301321
## Migration from `$local`
302322

303323
Migration v14 moves the old `$local` bucket state into `ps_kv`:
@@ -344,3 +364,18 @@ The down migration restores `ps_buckets.target_op` and rebuilds a `$local` row o
344364

345365
This keeps older SDKs able to use the historic target-op gate after a downgrade without inventing a
346366
synthetic `$local` bucket when there was no local target state.
367+
368+
Two properties make the restored gate safe rather than a potential stall:
369+
370+
- **Shared id namespace.** Client-created checkpoint request ids and legacy write checkpoint ids
371+
are one namespace, compatible in both directions — including values migrated from the historic
372+
service-generated write checkpoint scheme. The service reports the checkpoint request ids it
373+
accepted as the `write_checkpoint` values older-protocol clients observe, so a restored concrete
374+
`$local.target_op` is satisfiable by the next write checkpoint the downgraded SDK sees; it does
375+
not wait on an incomparable id sequence.
376+
- **Downgrade fidelity.** `last_seen_checkpoint_request_id` and
377+
`last_applied_checkpoint_request_id` only advance on full checkpoint completions — but so did the
378+
legacy `$local.last_op`/`last_applied_op` bookkeeping (partial priority applies never updated
379+
`$local`, which is not a real bucket). The rebuilt `$local` row therefore matches exactly what a
380+
legacy client would have recorded at the same point in the stream; the down migration cannot lag
381+
behind legacy behavior.

0 commit comments

Comments
 (0)