From 5d73115c78681d9a98fa4068d583468969100c87 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 25 Jun 2026 09:07:00 +0200 Subject: [PATCH 1/8] initial logic --- crates/core/src/macros.rs | 46 ++++++ crates/core/src/sync/interface.rs | 81 ++++++++++- crates/core/src/sync/storage_adapter.rs | 106 ++++++++++++++ crates/core/src/sync/streaming_sync.rs | 31 +++- crates/core/src/sync/sync_status.rs | 18 ++- dart/test/goldens/simple_iteration.json | 14 +- dart/test/goldens/starting_stream.json | 5 +- dart/test/sync_test.dart | 180 ++++++++++++++++++++---- dart/test/utils/test_utils.dart | 7 +- docs/sync.md | 15 ++ 10 files changed, 462 insertions(+), 41 deletions(-) diff --git a/crates/core/src/macros.rs b/crates/core/src/macros.rs index 4e60c953..0bc8dc5a 100644 --- a/crates/core/src/macros.rs +++ b/crates/core/src/macros.rs @@ -43,3 +43,49 @@ macro_rules! create_sqlite_optional_text_fn { } }; } + +#[macro_export] +macro_rules! create_sqlite_int_fn { + ($fn_name:ident, $fn_impl_name:ident, $description:literal) => { + extern "C" fn $fn_name( + ctx: *mut sqlite::context, + argc: c_int, + argv: *mut *mut sqlite::value, + ) { + let args = sqlite::args!(argc, argv); + + let result = $fn_impl_name(ctx, args); + + if let Err(err) = result { + PowerSyncError::from(err).apply_to_ctx($description, ctx); + } else if let Ok(r) = result { + ctx.result_int64(r); + } + } + }; +} + +#[macro_export] +macro_rules! create_sqlite_optional_int_fn { + ($fn_name:ident, $fn_impl_name:ident, $description:literal) => { + extern "C" fn $fn_name( + ctx: *mut sqlite::context, + argc: c_int, + argv: *mut *mut sqlite::value, + ) { + let args = sqlite::args!(argc, argv); + + let result = $fn_impl_name(ctx, args); + + if let Err(err) = result { + PowerSyncError::from(err).apply_to_ctx($description, ctx); + } else if let Ok(r) = result { + if let Some(i) = r { + ctx.result_int64(i); + } else { + ctx.result_null(); + } + } + } + }; +} diff --git a/crates/core/src/sync/interface.rs b/crates/core/src/sync/interface.rs index e09eba94..e1fd66e9 100644 --- a/crates/core/src/sync/interface.rs +++ b/crates/core/src/sync/interface.rs @@ -4,12 +4,12 @@ use core::ffi::{c_int, c_void}; use super::streaming_sync::SyncClient; use super::sync_status::DownloadSyncStatus; use crate::constants::SUBTYPE_JSON; -use crate::create_sqlite_text_fn; use crate::error::PowerSyncError; use crate::schema::Schema; use crate::state::DatabaseState; use crate::sync::diagnostics::{DiagnosticOptions, DiagnosticsEvent}; use crate::sync::subscriptions::{StreamKey, apply_subscriptions}; +use crate::{create_sqlite_int_fn, create_sqlite_optional_int_fn, create_sqlite_text_fn}; use alloc::borrow::Cow; use alloc::boxed::Box; use alloc::rc::Rc; @@ -324,6 +324,28 @@ pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<() Some(DatabaseState::destroy_rc), )?; + db.create_function_v2( + "powersync_probe_local_target_op", + 1, + sqlite::UTF8 | sqlite::DIRECTONLY, + Some(Rc::into_raw(state.clone()) as *mut c_void), + Some(powersync_probe_local_target_op), + None, + None, + Some(DatabaseState::destroy_rc), + )?; + + db.create_function_v2( + "powersync_next_checkpoint_request_id", + 0, + sqlite::UTF8 | sqlite::DIRECTONLY, + Some(Rc::into_raw(state) as *mut c_void), + Some(powersync_next_checkpoint_request_id), + None, + None, + Some(DatabaseState::destroy_rc), + )?; + Ok(()) } @@ -340,8 +362,65 @@ fn powersync_offline_sync_status_impl( Ok(serialized) } +fn powersync_probe_local_target_op_impl( + ctx: *mut sqlite::context, + args: &[*mut sqlite::value], +) -> Result, PowerSyncError> { + if args.len() != 1 { + return Err(PowerSyncError::argument_error( + "powersync_probe_local_target_op takes one argument", + )); + } + + let arg = args[0]; + let new_target_op = match arg.value_type() { + ColumnType::Null => None, + ColumnType::Integer => Some(arg.int64()), + _ => { + return Err(PowerSyncError::argument_error( + "target op must be an integer or null", + )); + } + }; + + let db = ctx.db_handle(); + let db_state = unsafe { DatabaseState::from_context(&ctx) }; + let adapter = db_state.storage_adapter(db)?; + adapter.probe_local_target_op(new_target_op) +} + +fn powersync_next_checkpoint_request_id_impl( + ctx: *mut sqlite::context, + args: &[*mut sqlite::value], +) -> Result { + if !args.is_empty() { + return Err(PowerSyncError::argument_error( + "powersync_next_checkpoint_request_id does not take arguments", + )); + } + + let db = ctx.db_handle(); + verify_in_transaction(db)?; + + let db_state = unsafe { DatabaseState::from_context(&ctx) }; + let adapter = db_state.storage_adapter(db)?; + adapter.next_checkpoint_request_id() +} + create_sqlite_text_fn!( powersync_offline_sync_status, powersync_offline_sync_status_impl, "powersync_offline_sync_status" ); + +create_sqlite_optional_int_fn!( + powersync_probe_local_target_op, + powersync_probe_local_target_op_impl, + "powersync_probe_local_target_op" +); + +create_sqlite_int_fn!( + powersync_next_checkpoint_request_id, + powersync_next_checkpoint_request_id_impl, + "powersync_next_checkpoint_request_id" +); diff --git a/crates/core/src/sync/storage_adapter.rs b/crates/core/src/sync/storage_adapter.rs index 7b5298f3..2801afee 100644 --- a/crates/core/src/sync/storage_adapter.rs +++ b/crates/core/src/sync/storage_adapter.rs @@ -26,6 +26,9 @@ use super::{ bucket_priority::BucketPriority, interface::BucketRequest, streaming_sync::OwnedCheckpoint, }; +const LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY: &str = "last_requested_checkpoint_request_id"; +const LAST_SYNCED_CHECKPOINT_REQUEST_ID_KEY: &str = "last_synced_checkpoint_request_id"; + /// An adapter for storing sync state. /// /// This is used to encapsulate some SQL queries used for the sync implementation, making the code @@ -112,6 +115,9 @@ impl StorageAdapter { items }; + let last_synced_checkpoint_request_id = + self.read_i64_kv(LAST_SYNCED_CHECKPOINT_REQUEST_ID_KEY)?; + let mut streams = Vec::new(); self.iterate_local_subscriptions(|sub| { streams.push(ActiveStreamSubscription::from_local(&sub)); @@ -123,6 +129,7 @@ impl StorageAdapter { priority_status: priority_items, downloading: None, streams, + last_synced_checkpoint_request_id, }) } @@ -512,6 +519,105 @@ WHERE bucket = ?1", None }) } + + /// Probes and optionally updates the local bucket target op for older services where the SDK + /// cannot create checkpoint requests explicitly. + /// + /// The target op can also be used internally as a sentinel value such as max op id while local + /// writes are pending, so it must not be interpreted as a checkpoint request id. + /// + /// When the target op is a positive, non-sentinel checkpoint request id, it also updates + /// `last_requested_checkpoint_request_id` so clients can migrate from the legacy target-op + /// flow to client-created checkpoint requests. `0` and sentinel values such as max op id must + /// not update the last requested id. + /// + /// Returns the target op value from before this call. When `target_op` is `None`, this only + /// reads the current value. + pub fn probe_local_target_op( + &self, + target_op: Option, + ) -> Result, PowerSyncError> { + let previous_target_op = self.local_state()?.map(|state| state.target_op); + + let Some(target_op) = target_op else { + return Ok(previous_target_op); + }; + + if target_op < 0 { + return Err(PowerSyncError::argument_error( + "target op must be a non-negative integer", + )); + } + + let stmt = self.db.prepare_v2( + "INSERT INTO ps_buckets(name, pending_delete, last_op, target_op) +VALUES('$local', 1, 0, ?1) +ON CONFLICT(name) DO UPDATE SET target_op = excluded.target_op", + )?; + stmt.bind_int64(1, target_op)?; + stmt.exec()?; + + if target_op > 0 && target_op != i64::MAX { + self.write_i64_kv(LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY, target_op)?; + } + + Ok(previous_target_op) + } + + /// Persists the checkpoint request id returned in a fully applied checkpoint. + pub fn persist_last_synced_checkpoint_request_id( + &self, + request_id: i64, + ) -> Result<(), PowerSyncError> { + self.write_i64_kv(LAST_SYNCED_CHECKPOINT_REQUEST_ID_KEY, request_id) + } + + /// Increments, persists and returns the next client-created checkpoint request id. + pub fn next_checkpoint_request_id(&self) -> Result { + let statement = self.db.prepare_v2( + "INSERT INTO ps_kv(key, value) +VALUES(?1, 1) +ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1 +RETURNING value", + )?; + statement.bind_text( + 1, + LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY, + sqlite::Destructor::STATIC, + )?; + + if statement.step()? == ResultCode::ROW { + Ok(statement.column_int64(0)) + } else { + Err(PowerSyncError::unknown_internal()) + } + } + + fn read_i64_kv(&self, key: &'static str) -> Result, PowerSyncError> { + let statement = self + .db + .prepare_v2("SELECT value FROM ps_kv WHERE key = ?1") + .into_db_result(self.db)?; + statement.bind_text(1, key, sqlite::Destructor::STATIC)?; + + Ok(if statement.step()? == ResultCode::ROW { + Some(statement.column_int64(0)) + } else { + None + }) + } + + fn write_i64_kv(&self, key: &'static str, value: i64) -> Result<(), PowerSyncError> { + let stmt = self.db.prepare_v2( + "INSERT INTO ps_kv(key, value) +VALUES(?1, ?2) +ON CONFLICT(key) DO UPDATE SET value = excluded.value ", + )?; + stmt.bind_text(1, key, sqlite::Destructor::STATIC)?; + stmt.bind_int64(2, value)?; + stmt.exec()?; + Ok(()) + } } pub struct LocalState { diff --git a/crates/core/src/sync/streaming_sync.rs b/crates/core/src/sync/streaming_sync.rs index 5c680ce3..c39129dd 100644 --- a/crates/core/src/sync/streaming_sync.rs +++ b/crates/core/src/sync/streaming_sync.rs @@ -363,7 +363,9 @@ impl StreamingSyncIteration { line: "Validated and applied checkpoint".into(), }); event.instructions.push(Instruction::FlushFileSystem {}); + SyncStateMachineTransition::SyncLocalChangesApplied { + synced_checkpoint_request_id: target.write_checkpoint, partial: None, timestamp, } @@ -400,6 +402,7 @@ impl StreamingSyncIteration { } SyncLocalResult::ChangesApplied { timestamp } => { SyncStateMachineTransition::SyncLocalChangesApplied { + synced_checkpoint_request_id: target.checkpoint.write_checkpoint, partial: Some(priority), timestamp, } @@ -497,7 +500,11 @@ impl StreamingSyncIteration { } => { self.validated_but_not_applied = Some(validated_but_not_applied); } - SyncStateMachineTransition::SyncLocalChangesApplied { partial, timestamp } => { + SyncStateMachineTransition::SyncLocalChangesApplied { + synced_checkpoint_request_id, + partial, + timestamp, + } => { if let Some(priority) = partial { self.status.update( |status| { @@ -506,7 +513,7 @@ impl StreamingSyncIteration { &mut event.instructions, ); } else { - self.handle_checkpoint_applied(event, timestamp); + self.handle_checkpoint_applied(event, timestamp, synced_checkpoint_request_id); } } SyncStateMachineTransition::Empty => {} @@ -647,7 +654,7 @@ impl StreamingSyncIteration { line: "Applied pending checkpoint after completed upload".into(), }); - self.handle_checkpoint_applied(event, timestamp); + self.handle_checkpoint_applied(event, timestamp, checkpoint.write_checkpoint); } _ => { event.instructions.push(Instruction::LogLine { @@ -871,6 +878,14 @@ impl StreamingSyncIteration { } } } + + // This is restored into the sync status when initializing a new client. + if priority.is_none() { + if let Some(write_checkpoint) = target.write_checkpoint { + self.adapter + .persist_last_synced_checkpoint_request_id(write_checkpoint)?; + } + } } Ok(result) @@ -931,11 +946,16 @@ impl StreamingSyncIteration { }) } - fn handle_checkpoint_applied(&mut self, event: &mut ActiveEvent, timestamp: TimestampMicros) { + fn handle_checkpoint_applied( + &mut self, + event: &mut ActiveEvent, + timestamp: TimestampMicros, + synced_checkpoint_request_id: Option, + ) { event.instructions.push(Instruction::DidCompleteSync {}); self.status.update( - |status| status.applied_checkpoint(timestamp), + |status| status.applied_checkpoint(timestamp, synced_checkpoint_request_id), &mut event.instructions, ); } @@ -1113,6 +1133,7 @@ enum SyncStateMachineTransition<'a> { validated_but_not_applied: OwnedCheckpoint, }, SyncLocalChangesApplied { + synced_checkpoint_request_id: Option, partial: Option, timestamp: TimestampMicros, }, diff --git a/crates/core/src/sync/sync_status.rs b/crates/core/src/sync/sync_status.rs index eb7daeea..123fb47e 100644 --- a/crates/core/src/sync/sync_status.rs +++ b/crates/core/src/sync/sync_status.rs @@ -53,6 +53,8 @@ pub struct DownloadSyncStatus { /// received), information about how far the download has progressed. pub downloading: Option, pub streams: Vec, + /// The last fully applied checkpoint request id acknowledged by the sync stream. + pub last_synced_checkpoint_request_id: Option, } impl DownloadSyncStatus { @@ -117,9 +119,16 @@ impl DownloadSyncStatus { self.debug_assert_priority_status_is_sorted(); } - pub fn applied_checkpoint(&mut self, now: TimestampMicros) { + pub fn applied_checkpoint( + &mut self, + now: TimestampMicros, + synced_checkpoint_request_id: Option, + ) { self.downloading = None; self.priority_status.clear(); + if let Some(request_id) = synced_checkpoint_request_id { + self.last_synced_checkpoint_request_id = Some(request_id); + } self.priority_status.push(SyncPriorityStatus { priority: BucketPriority::SENTINEL, @@ -137,6 +146,7 @@ impl Default for DownloadSyncStatus { downloading: None, priority_status: Vec::new(), streams: Vec::new(), + last_synced_checkpoint_request_id: None, } } } @@ -180,12 +190,16 @@ impl Serialize for DownloadSyncStatus { } } - let mut serializer = serializer.serialize_struct("DownloadSyncStatus", 4)?; + let mut serializer = serializer.serialize_struct("DownloadSyncStatus", 6)?; serializer.serialize_field("connected", &self.connected)?; serializer.serialize_field("connecting", &self.connecting)?; serializer.serialize_field("priority_status", &self.priority_status)?; serializer.serialize_field("downloading", &self.downloading)?; serializer.serialize_field("streams", &SerializeStreamsWithProgress(self))?; + serializer.serialize_field( + "last_synced_checkpoint_request_id", + &self.last_synced_checkpoint_request_id, + )?; serializer.end() } diff --git a/dart/test/goldens/simple_iteration.json b/dart/test/goldens/simple_iteration.json index 32c1db89..5bc1bb3f 100644 --- a/dart/test/goldens/simple_iteration.json +++ b/dart/test/goldens/simple_iteration.json @@ -10,7 +10,8 @@ "connecting": true, "priority_status": [], "downloading": null, - "streams": [] + "streams": [], + "last_synced_checkpoint_request_id": null } } }, @@ -65,7 +66,8 @@ } } }, - "streams": [] + "streams": [], + "last_synced_checkpoint_request_id": null } } } @@ -115,7 +117,8 @@ } } }, - "streams": [] + "streams": [], + "last_synced_checkpoint_request_id": null } } } @@ -154,7 +157,8 @@ } ], "downloading": null, - "streams": [] + "streams": [], + "last_synced_checkpoint_request_id": null } } } @@ -173,4 +177,4 @@ } ] } -] \ No newline at end of file +] diff --git a/dart/test/goldens/starting_stream.json b/dart/test/goldens/starting_stream.json index 2549905a..8d55ebee 100644 --- a/dart/test/goldens/starting_stream.json +++ b/dart/test/goldens/starting_stream.json @@ -14,7 +14,8 @@ "connecting": true, "priority_status": [], "downloading": null, - "streams": [] + "streams": [], + "last_synced_checkpoint_request_id": null } } }, @@ -38,4 +39,4 @@ } ] } -] \ No newline at end of file +] diff --git a/dart/test/sync_test.dart b/dart/test/sync_test.dart index 579ece80..b14896c4 100644 --- a/dart/test/sync_test.dart +++ b/dart/test/sync_test.dart @@ -4,12 +4,12 @@ import 'dart:typed_data'; import 'package:bson/bson.dart'; import 'package:file/local.dart'; +import 'package:path/path.dart'; import 'package:sqlite3/common.dart'; import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3_test/sqlite3_test.dart'; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; -import 'package:path/path.dart'; import 'utils/native_test_utils.dart'; import 'utils/test_utils.dart'; @@ -135,6 +135,32 @@ void _syncTests({ return syncLine(checkpointComplete(priority: priority, lastOpId: lastOpId)); } + Object? lastRequestedCheckpointRequestId() { + final rows = db.select( + "SELECT value FROM ps_kv WHERE key = 'last_requested_checkpoint_request_id'"); + return rows.isEmpty ? null : rows.single.columnAt(0); + } + + int nextCheckpointRequestId() { + db.execute('begin'); + + try { + final [row] = db.select('SELECT powersync_next_checkpoint_request_id()'); + db.execute('commit'); + return row.columnAt(0) as int; + } catch (e) { + db.execute('rollback'); + rethrow; + } + } + + Object? probeLocalTargetOp([int? opId]) { + final [row] = db.select('SELECT powersync_probe_local_target_op(?)', [ + opId, + ]); + return row.columnAt(0); + } + ResultSet fetchRows() { return db.select('select * from items'); } @@ -318,7 +344,7 @@ void _syncTests({ syncTest('remembers sync state', (controller) { invokeControl('start', null); - pushCheckpoint(buckets: priorityBuckets); + pushCheckpoint(buckets: priorityBuckets, writeCheckpoint: '1'); pushCheckpointComplete(); controller.elapse(Duration(minutes: 10)); @@ -331,26 +357,28 @@ void _syncTests({ instructions, contains( containsPair( - 'UpdateSyncStatus', - containsPair( - 'status', + 'UpdateSyncStatus', containsPair( - 'priority_status', - [ - { - 'priority': 2, - 'last_synced_at': timestamp(), - 'has_synced': true - }, - { - 'priority': 2147483647, - 'last_synced_at': timestamp(plusMinutes: -10), - 'has_synced': true - }, - ], - ), - ), - ), + 'status', + allOf( + containsPair('last_synced_checkpoint_request_id', 1), + containsPair( + 'priority_status', + [ + { + 'priority': 2, + 'last_synced_at': timestamp(), + 'has_synced': true + }, + { + 'priority': 2147483647, + 'last_synced_at': timestamp(plusMinutes: -10), + 'has_synced': true + }, + ], + ), + ), + )), ), ); @@ -358,6 +386,7 @@ void _syncTests({ expect(json.decode(row[0]), { 'connected': false, 'connecting': false, + 'last_synced_checkpoint_request_id': 1, 'priority_status': [ {'priority': 2, 'last_synced_at': timestamp(), 'has_synced': true}, { @@ -371,6 +400,106 @@ void _syncTests({ }); }); + syncTest('allocates requested checkpoint request ids', (_) { + expect(nextCheckpointRequestId(), 1); + expect(lastRequestedCheckpointRequestId(), 1); + + expect(nextCheckpointRequestId(), 2); + expect(lastRequestedCheckpointRequestId(), 2); + }); + + syncTest('probes and updates local target op with checkpoint request id', (_) { + expect(probeLocalTargetOp(), isNull); + expect(probeLocalTargetOp(1), isNull); + expect(lastRequestedCheckpointRequestId(), 1); + expect(probeLocalTargetOp(), 1); + expect(probeLocalTargetOp(2), 1); + expect(lastRequestedCheckpointRequestId(), 2); + expect(probeLocalTargetOp(), 2); + + invokeControl('start', null); + + expect(db.select(r"SELECT target_op FROM ps_buckets WHERE name = '$local'"), + [ + {'target_op': 2} + ]); + }); + + syncTest('does not store non-request target ops as checkpoint request id', (_) { + expect(probeLocalTargetOp(0), isNull); + expect(lastRequestedCheckpointRequestId(), isNull); + + expect(probeLocalTargetOp(9223372036854775807), 0); + expect(lastRequestedCheckpointRequestId(), isNull); + expect(probeLocalTargetOp(), 9223372036854775807); + }); + + syncTest('does not persist placeholder checkpoint request id', (_) { + db.execute("insert into items (id, col) values ('local', 'data');"); + + invokeControl('start', null); + + expect(lastRequestedCheckpointRequestId(), isNull); + }); + + syncTest( + 'does not mark checkpoint request id as synced for partial checkpoint', + (_) { + invokeControl('start', null); + + pushCheckpoint(buckets: priorityBuckets, writeCheckpoint: '1'); + final instructions = pushCheckpointComplete(priority: 2); + + expect( + instructions, + contains( + containsPair( + 'UpdateSyncStatus', + containsPair( + 'status', + containsPair('last_synced_checkpoint_request_id', null), + ), + ), + ), + ); + + final [row] = db.select('select powersync_offline_sync_status();'); + expect( + json.decode(row[0]), + containsPair('last_synced_checkpoint_request_id', null), + ); + }, + ); + + syncTest('keeps synced checkpoint request id across normal checkpoints', (_) { + invokeControl('start', null); + + pushCheckpoint(buckets: priorityBuckets, writeCheckpoint: '1'); + pushCheckpointComplete(); + + pushCheckpoint(buckets: priorityBuckets); + final instructions = pushCheckpointComplete(); + + expect( + instructions, + contains( + containsPair( + 'UpdateSyncStatus', + containsPair( + 'status', + containsPair('last_synced_checkpoint_request_id', 1), + ), + ), + ), + ); + + final [row] = db.select('select powersync_offline_sync_status();'); + expect( + json.decode(row[0]), + containsPair('last_synced_checkpoint_request_id', 1), + ); + }); + test('clearing database clears sync status', () { invokeControl('start', null); pushCheckpoint(buckets: priorityBuckets); @@ -816,7 +945,7 @@ void _syncTests({ ]); // Now complete the upload process. - db.execute(r"UPDATE ps_buckets SET target_op = 1 WHERE name = '$local'"); + probeLocalTargetOp(1); invokeControl('completed_upload', null); // This should apply the pending write checkpoint. @@ -832,8 +961,9 @@ void _syncTests({ // Complete upload process db.execute('DELETE FROM ps_crud'); - db.execute(r"UPDATE ps_buckets SET target_op = 1 WHERE name = '$local'"); + probeLocalTargetOp(1); expect(invokeControl('completed_upload', null), isEmpty); + expect(lastRequestedCheckpointRequestId(), 1); // Sync afterwards containing data and write checkpoint. pushCheckpoint(buckets: priorityBuckets, writeCheckpoint: '1'); @@ -861,7 +991,7 @@ void _syncTests({ ]); // Now the upload is complete and requests a write checkpoint - db.execute(r"UPDATE ps_buckets SET target_op = 1 WHERE name = '$local'"); + probeLocalTargetOp(1); expect(invokeControl('completed_upload', null), isEmpty); // Which triggers a new iteration @@ -898,7 +1028,7 @@ void _syncTests({ db.execute("insert into items (id, col) values ('local2', 'data2');"); // Now the upload is complete and requests a write checkpoint - db.execute(r"UPDATE ps_buckets SET target_op = 1 WHERE name = '$local'"); + probeLocalTargetOp(1); expect(invokeControl('completed_upload', null), [ containsPair('LogLine', { 'severity': 'WARNING', diff --git a/dart/test/utils/test_utils.dart b/dart/test/utils/test_utils.dart index 943f632d..eb310ac2 100644 --- a/dart/test/utils/test_utils.dart +++ b/dart/test/utils/test_utils.dart @@ -23,11 +23,16 @@ Object stream(String name, bool isDefault, {List errors = const []}) { } /// Creates a `checkpoint_complete` or `partial_checkpoint_complete` line. -Object checkpointComplete({int? priority, String lastOpId = '1'}) { +Object checkpointComplete({ + int? priority, + String lastOpId = '1', + String? writeCheckpoint, +}) { return { priority == null ? 'checkpoint_complete' : 'partial_checkpoint_complete': { 'last_op_id': lastOpId, if (priority != null) 'priority': priority, + if (writeCheckpoint != null) 'write_checkpoint': writeCheckpoint, }, }; } diff --git a/docs/sync.md b/docs/sync.md index 8737cee0..bc33520c 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -37,6 +37,19 @@ The following commands are supported: If a new subscription is created, or when a subscription without a TTL has been removed, the client will ask to restart the connection. +When uploads request a write checkpoint, SDKs should call +`powersync_next_checkpoint_request_id()` inside a transaction to allocate the id to pass to the +request-checkpoint API. + +`powersync_probe_local_target_op(op_id)` is only for compatibility when a new SDK is used with an +older PowerSync service that does not yet support client-created checkpoint requests. In that mode, +call it after the service-side request is made. Pass `NULL` to probe the current internal `$local` +bucket target op without updating it, or pass an integer to update that target op. In both cases it +returns the value from before the call, or `NULL` if no value existed. Updating to a positive, +non-sentinel target op also stores it as `last_requested_checkpoint_request_id` to support migrating +to client-created checkpoint requests. `0` and sentinel values such as max op id are not stored as +requested checkpoint ids. + `powersync_control` returns a JSON-encoded array of instructions for the client: ```typescript @@ -68,6 +81,8 @@ interface UpdateSyncStatus { connecting: boolean, priority_status: [], downloading: null | DownloadProgress, + streams: [], + last_synced_checkpoint_request_id: null | number, } // Instructs SDKs to refresh credentials from the backend connector. From 696d9502fbda45a34f1ed441838547a543629c93 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 26 Jun 2026 09:10:23 +0200 Subject: [PATCH 2/8] migrations --- crates/core/src/crud_vtab.rs | 4 +- crates/core/src/migrations.rs | 90 +++++++++++++++++++++++- crates/core/src/sync/interface.rs | 22 +++--- crates/core/src/sync/storage_adapter.rs | 80 +++++++++++++-------- crates/core/src/sync/sync_local.rs | 6 +- dart/test/crud_test.dart | 19 +++-- dart/test/migration_test.dart | 92 +++++++++++++++++++++++++ dart/test/sync_test.dart | 17 +++-- dart/test/utils/migration_fixtures.dart | 24 +++++-- docs/sync.md | 41 ++++++++--- 10 files changed, 326 insertions(+), 69 deletions(-) diff --git a/crates/core/src/crud_vtab.rs b/crates/core/src/crud_vtab.rs index 4c0f4070..e14122a9 100644 --- a/crates/core/src/crud_vtab.rs +++ b/crates/core/src/crud_vtab.rs @@ -247,7 +247,9 @@ impl SimpleCrudTransactionMode { fn record_local_write(&mut self, db: *mut sqlite::sqlite3) -> Result<(), ResultCode> { if !self.had_writes { - db.exec_safe(formatcp!("INSERT OR REPLACE INTO ps_buckets(name, last_op, target_op) VALUES('$local', 0, {MAX_OP_ID})"))?; + db.exec_safe(formatcp!( + "INSERT OR REPLACE INTO ps_kv(key, value) VALUES('local_target_op', {MAX_OP_ID})" + ))?; self.had_writes = true; } diff --git a/crates/core/src/migrations.rs b/crates/core/src/migrations.rs index 06f22146..d3a10138 100644 --- a/crates/core/src/migrations.rs +++ b/crates/core/src/migrations.rs @@ -16,7 +16,7 @@ use crate::fix_data::apply_v035_fix; use crate::schema::inspection::ExistingView; use crate::sync::BucketPriority; -pub const LATEST_VERSION: i32 = 13; +pub const LATEST_VERSION: i32 = 14; pub fn powersync_migrate( ctx: *mut sqlite::context, @@ -460,6 +460,94 @@ DROP TABLE ps_sync_state_old; track_migration.exec()?; } + if current_version < 14 && target_version >= 14 { + // Move the legacy `$local` checkpoint bookkeeping into ps_kv. + // + // In older databases, `$local.last_applied_op` represented the latest legacy write + // checkpoint that was actually applied, so it becomes the last synced checkpoint request. + // `$local.last_op` represented the latest legacy write checkpoint seen in the sync stream, + // so it becomes the last seen checkpoint request. + // + // `$local.target_op` can either be a concrete checkpoint request id or a sentinel such as + // i64::MAX while local writes are pending. Store it separately as `local_target_op`, but + // only treat concrete values as requested checkpoint ids. We intentionally don't seed + // `last_requested_checkpoint_request_id` from `$local.last_applied_op` because that is a + // synced value, not necessarily the current requested target. + // + // When the target op is not concrete and there is no existing requested checkpoint id, + // `last_requested_checkpoint_request_id` remains undefined. That migration path is + // ambiguous: a new client-created request would start at 1, or another value lower than + // the service's legacy counter. SDKs should detect the undefined value, create one old + // write checkpoint, persist that returned concrete target through + // `powersync_probe_local_target_op`, and only then start creating checkpoint requests. + let up = "\ +INSERT INTO ps_kv(key, value) +SELECT 'last_synced_checkpoint_request_id', last_applied_op + FROM ps_buckets + WHERE name = '$local' + AND last_applied_op > 0 + AND last_applied_op != 9223372036854775807; + +INSERT INTO ps_kv(key, value) +SELECT 'last_seen_checkpoint_request_id', last_op + FROM ps_buckets + WHERE name = '$local' + AND last_op > 0 + AND last_op != 9223372036854775807; + +INSERT INTO ps_kv(key, value) +SELECT 'last_requested_checkpoint_request_id', target_op + FROM ps_buckets + WHERE name = '$local' + AND target_op > 0 + AND target_op != 9223372036854775807; + +INSERT INTO ps_kv(key, value) +SELECT 'local_target_op', target_op + FROM ps_buckets + WHERE name = '$local' + AND target_op > 0; +"; + local_db.exec_safe(up).into_db_result(local_db)?; + + // Downgrading needs to rebuild the old `$local` row from the new ps_kv state so older SDKs + // can keep using their target-op based blocking behavior. In that model, `$local.last_op` + // tracked the latest seen legacy write checkpoint and was compared with `$local.target_op` + // to decide whether downloaded changes could be applied. The `$local.last_applied_op` + // value represented the synced checkpoint that had actually been applied locally. + // `$local.pending_delete = 1` marked this as a synthetic local-only bucket instead of a + // normal service bucket. Restore each old progress column from its matching ps_kv key. + // The 0 defaults cover a local target that exists before any checkpoint has been seen or + // applied. If `local_target_op` is absent, don't create a `$local` row: the old + // implementation also didn't have a `$local` bucket unless there was local target state to + // track. + const DOWN_STATEMENTS: &[&str] = &[ + "INSERT INTO ps_buckets(name, pending_delete, last_op, last_applied_op, target_op) +SELECT '$local', 1, seen, synced, target + FROM ( + SELECT + IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = 'last_seen_checkpoint_request_id'), 0) AS seen, + IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = 'last_synced_checkpoint_request_id'), 0) AS synced, + (SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = 'local_target_op') AS target + ) + WHERE EXISTS ( + SELECT 1 FROM ps_kv WHERE key = 'local_target_op' + ) +ON CONFLICT(name) DO UPDATE SET + pending_delete = excluded.pending_delete, + last_op = excluded.last_op, + last_applied_op = excluded.last_applied_op, + target_op = excluded.target_op", + "DELETE FROM ps_migration WHERE id >= 14", + ]; + let down = serialize_down_statements(DOWN_STATEMENTS)?; + let track_migration = + local_db.prepare_v2("INSERT INTO ps_migration(id, down_migrations) VALUES (?, ?)")?; + track_migration.bind_int(1, 14)?; + track_migration.bind_text(2, &down, Destructor::STATIC)?; + track_migration.exec()?; + } + Ok(()) } diff --git a/crates/core/src/sync/interface.rs b/crates/core/src/sync/interface.rs index e1fd66e9..967cfd66 100644 --- a/crates/core/src/sync/interface.rs +++ b/crates/core/src/sync/interface.rs @@ -373,15 +373,19 @@ fn powersync_probe_local_target_op_impl( } let arg = args[0]; - let new_target_op = match arg.value_type() { - ColumnType::Null => None, - ColumnType::Integer => Some(arg.int64()), - _ => { - return Err(PowerSyncError::argument_error( - "target op must be an integer or null", - )); - } - }; + let new_target_op = + match arg.value_type() { + ColumnType::Null => None, + ColumnType::Integer => Some(arg.int64()), + ColumnType::Text => Some(arg.text().parse::().map_err(|_| { + PowerSyncError::argument_error("target op must be an integer string") + })?), + _ => { + return Err(PowerSyncError::argument_error( + "target op must be an integer, integer string, or null", + )); + } + }; let db = ctx.db_handle(); let db_state = unsafe { DatabaseState::from_context(&ctx) }; diff --git a/crates/core/src/sync/storage_adapter.rs b/crates/core/src/sync/storage_adapter.rs index 2801afee..5c5eeae6 100644 --- a/crates/core/src/sync/storage_adapter.rs +++ b/crates/core/src/sync/storage_adapter.rs @@ -27,8 +27,14 @@ use super::{ }; const LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY: &str = "last_requested_checkpoint_request_id"; +const LAST_SEEN_CHECKPOINT_REQUEST_ID_KEY: &str = "last_seen_checkpoint_request_id"; const LAST_SYNCED_CHECKPOINT_REQUEST_ID_KEY: &str = "last_synced_checkpoint_request_id"; +// Tracks the local target used to block applying downloaded rows while local writes are +// outstanding. When present, this is normally either the max-op sentinel for pending local writes or +// a concrete checkpoint request id also stored in LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY. +const LOCAL_TARGET_OP_KEY: &str = "local_target_op"; + /// An adapter for storing sync state. /// /// This is used to encapsulate some SQL queries used for the sync implementation, making the code @@ -267,10 +273,8 @@ WHERE bucket = ?1", } } - if let (None, Some(write_checkpoint)) = (&priority, &checkpoint.write_checkpoint) { - update_bucket.bind_int64(1, *write_checkpoint)?; - update_bucket.bind_text(2, "$local", sqlite::Destructor::STATIC)?; - update_bucket.exec()?; + if let (None, Some(checkpoint_request_id)) = (&priority, &checkpoint.write_checkpoint) { + self.persist_last_seen_checkpoint_request_id(*checkpoint_request_id)?; } #[derive(Serialize)] @@ -507,29 +511,26 @@ WHERE bucket = ?1", } pub fn local_state(&self) -> Result, PowerSyncError> { - let stmt = self - .db - .prepare_v2("SELECT target_op FROM ps_buckets WHERE name = ?")?; - stmt.bind_text(1, "$local", sqlite::Destructor::STATIC)?; - - Ok(if stmt.step()? == ResultCode::ROW { - let target_op = stmt.column_int64(0); - Some(LocalState { target_op }) - } else { - None - }) + Ok(self + .read_i64_kv(LOCAL_TARGET_OP_KEY)? + .map(|target_op| LocalState { target_op })) } - /// Probes and optionally updates the local bucket target op for older services where the SDK - /// cannot create checkpoint requests explicitly. + /// Probes and optionally updates the local target op used to block applying downloaded rows + /// while local writes are outstanding. + /// + /// In the write-checkpoint flow, callers allocate a checkpoint request id, post it to the + /// service, and then update this from the max-op sentinel to the concrete checkpoint request id + /// once the request succeeds. This is also used for older services where the SDK cannot create + /// checkpoint requests explicitly. /// /// The target op can also be used internally as a sentinel value such as max op id while local - /// writes are pending, so it must not be interpreted as a checkpoint request id. + /// writes are pending, so it must not always be interpreted as a checkpoint request id. /// /// When the target op is a positive, non-sentinel checkpoint request id, it also updates /// `last_requested_checkpoint_request_id` so clients can migrate from the legacy target-op - /// flow to client-created checkpoint requests. `0` and sentinel values such as max op id must - /// not update the last requested id. + /// flow to client-created checkpoint requests. `0` clears the local target, and sentinel + /// values such as max op id must not update the last requested id. /// /// Returns the target op value from before this call. When `target_op` is `None`, this only /// reads the current value. @@ -539,32 +540,45 @@ WHERE bucket = ?1", ) -> Result, PowerSyncError> { let previous_target_op = self.local_state()?.map(|state| state.target_op); + // Return the previous op if no new target_op has been provided let Some(target_op) = target_op else { return Ok(previous_target_op); }; + // Set the new target op + if target_op < 0 { return Err(PowerSyncError::argument_error( "target op must be a non-negative integer", )); } - let stmt = self.db.prepare_v2( - "INSERT INTO ps_buckets(name, pending_delete, last_op, target_op) -VALUES('$local', 1, 0, ?1) -ON CONFLICT(name) DO UPDATE SET target_op = excluded.target_op", - )?; - stmt.bind_int64(1, target_op)?; - stmt.exec()?; + if target_op == 0 { + self.delete_kv(LOCAL_TARGET_OP_KEY)?; + return Ok(previous_target_op); + } - if target_op > 0 && target_op != i64::MAX { + self.write_i64_kv(LOCAL_TARGET_OP_KEY, target_op)?; + + if target_op != i64::MAX { self.write_i64_kv(LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY, target_op)?; } Ok(previous_target_op) } - /// Persists the checkpoint request id returned in a fully applied checkpoint. + /// Persists the checkpoint request id observed in a complete sync checkpoint. + /// + /// This replaces the legacy `$local.last_op` bookkeeping used to decide whether downloaded + /// data can be applied after local uploads complete. + pub fn persist_last_seen_checkpoint_request_id( + &self, + request_id: i64, + ) -> Result<(), PowerSyncError> { + self.write_i64_kv(LAST_SEEN_CHECKPOINT_REQUEST_ID_KEY, request_id) + } + + /// Persists the checkpoint request id that was applied locally. pub fn persist_last_synced_checkpoint_request_id( &self, request_id: i64, @@ -574,6 +588,7 @@ ON CONFLICT(name) DO UPDATE SET target_op = excluded.target_op", /// Increments, persists and returns the next client-created checkpoint request id. pub fn next_checkpoint_request_id(&self) -> Result { + // Wonder, should this be a sequence instead let statement = self.db.prepare_v2( "INSERT INTO ps_kv(key, value) VALUES(?1, 1) @@ -618,6 +633,13 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value ", stmt.exec()?; Ok(()) } + + fn delete_kv(&self, key: &'static str) -> Result<(), PowerSyncError> { + let stmt = self.db.prepare_v2("DELETE FROM ps_kv WHERE key = ?1")?; + stmt.bind_text(1, key, sqlite::Destructor::STATIC)?; + stmt.exec()?; + Ok(()) + } } pub struct LocalState { diff --git a/crates/core/src/sync/sync_local.rs b/crates/core/src/sync/sync_local.rs index 1293ef33..aef8f8d4 100644 --- a/crates/core/src/sync/sync_local.rs +++ b/crates/core/src/sync/sync_local.rs @@ -67,7 +67,11 @@ impl<'a> SyncOperation<'a> { if needs_check { // language=SQLite let statement = self.db.prepare_v2( - "SELECT 1 FROM ps_buckets WHERE target_op > last_op AND name = '$local'", + "SELECT 1 +FROM ps_kv AS target +LEFT JOIN ps_kv AS seen ON seen.key = 'last_seen_checkpoint_request_id' +WHERE target.key = 'local_target_op' + AND CAST(target.value AS INTEGER) > COALESCE(CAST(seen.value AS INTEGER), 0)", )?; if statement.step()? == ResultCode::ROW { diff --git a/dart/test/crud_test.dart b/dart/test/crud_test.dart index 5d6d379b..0f561a48 100644 --- a/dart/test/crud_test.dart +++ b/dart/test/crud_test.dart @@ -249,7 +249,7 @@ void main() { }); }); - test('updates local bucket and updated rows', () { + test('updates local target op and updated rows', () { db.execute( 'INSERT INTO powersync_crud (op, id, type, data) VALUES (?, ?, ?, ?)', [ @@ -262,12 +262,17 @@ void main() { expect(db.select('SELECT * FROM ps_updated_rows'), [ {'row_type': 'users', 'row_id': 'foo'} ]); - expect(db.select('SELECT * FROM ps_buckets'), [ - allOf( - containsPair('name', r'$local'), - containsPair('target_op', 9223372036854775807), - ) - ]); + expect(db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"), + isEmpty); + expect( + db.select( + "SELECT key, value FROM ps_kv WHERE key = 'local_target_op'"), + [ + { + 'key': 'local_target_op', + 'value': 9223372036854775807, + } + ]); }); test('does not require data', () { diff --git a/dart/test/migration_test.dart b/dart/test/migration_test.dart index 9203f74f..b8d6b7cf 100644 --- a/dart/test/migration_test.dart +++ b/dart/test/migration_test.dart @@ -97,6 +97,98 @@ void main() { }); } + test('migrates local checkpoint state to ps_kv', () async { + db.execute(fixtures.expectedState[13]!); + db.execute(r''' +INSERT INTO ps_buckets(id, name, last_applied_op, last_op, target_op, add_checksum, op_checksum, pending_delete, count_at_last, count_since_last, downloaded_size) +VALUES(1, '$local', 5, 6, 7, 0, 0, 1, 0, 0, 0); +'''); + + db.executeInTx('select powersync_init()'); + + expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), containsAll([ + {'key': 'last_seen_checkpoint_request_id', 'value': 6}, + {'key': 'last_requested_checkpoint_request_id', 'value': 7}, + {'key': 'last_synced_checkpoint_request_id', 'value': 5}, + {'key': 'local_target_op', 'value': 7}, + ])); + }); + + test('does not migrate last applied op as requested checkpoint id', + () async { + db.execute(fixtures.expectedState[13]!); + // Simulate pending local writes during migration. The legacy target op is the max-op + // sentinel, while last_applied_op is the last write checkpoint applied locally. + db.execute(r''' +INSERT INTO ps_buckets(id, name, last_applied_op, last_op, target_op, add_checksum, op_checksum, pending_delete, count_at_last, count_since_last, downloaded_size) +VALUES(1, '$local', 5, 6, 9223372036854775807, 0, 0, 1, 0, 0, 0); +'''); + + db.executeInTx('select powersync_init()'); + + // last_applied_op becomes the synced checkpoint id, but it must not seed the requested + // checkpoint counter. The sentinel target is preserved for blocking, but is not concrete + // enough to become last_requested_checkpoint_request_id. + expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [ + {'key': 'last_seen_checkpoint_request_id', 'value': 6}, + {'key': 'last_synced_checkpoint_request_id', 'value': 5}, + {'key': 'local_target_op', 'value': 9223372036854775807}, + ]); + }); + + test('does not migrate sentinel target op as requested checkpoint id', + () async { + db.execute(fixtures.expectedState[13]!); + db.execute(r''' +INSERT INTO ps_buckets(id, name, last_applied_op, last_op, target_op, add_checksum, op_checksum, pending_delete, count_at_last, count_since_last, downloaded_size) +VALUES(1, '$local', 0, 0, 9223372036854775807, 0, 0, 1, 0, 0, 0); +'''); + + db.executeInTx('select powersync_init()'); + + // The max-op sentinel is valid local target state, but it is not a concrete checkpoint + // request id and must not seed last_requested_checkpoint_request_id. + expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [ + {'key': 'local_target_op', 'value': 9223372036854775807}, + ]); + }); + + test('restores local checkpoint state on downgrade', () async { + db.execute(fixtures.finalState); + db.execute(r''' +INSERT INTO ps_kv(key, value) VALUES + ('last_requested_checkpoint_request_id', 7), + ('last_seen_checkpoint_request_id', 6), + ('last_synced_checkpoint_request_id', 5), + ('local_target_op', 7); +'''); + + db.executeInTx('select powersync_test_migration(13)'); + + expect( + db.select( + r"SELECT pending_delete, last_op, last_applied_op, target_op FROM ps_buckets WHERE name = '$local'"), + [ + { + 'pending_delete': 1, + 'last_op': 6, + 'last_applied_op': 5, + 'target_op': 7, + } + ], + ); + }); + + test('does not restore local bucket without local target on downgrade', + () async { + db.execute(fixtures.finalState); + + db.executeInTx('select powersync_test_migration(13)'); + + expect(db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"), + isEmpty); + }); + /// Here we apply a developer schema _after_ migrating test('schema after migration', () async { db.execute(fixtures.expectedState[2]!); diff --git a/dart/test/sync_test.dart b/dart/test/sync_test.dart index b14896c4..b1628b57 100644 --- a/dart/test/sync_test.dart +++ b/dart/test/sync_test.dart @@ -154,7 +154,7 @@ void _syncTests({ } } - Object? probeLocalTargetOp([int? opId]) { + Object? probeLocalTargetOp([Object? opId]) { final [row] = db.select('SELECT powersync_probe_local_target_op(?)', [ opId, ]); @@ -418,18 +418,20 @@ void _syncTests({ expect(probeLocalTargetOp(), 2); invokeControl('start', null); + }); - expect(db.select(r"SELECT target_op FROM ps_buckets WHERE name = '$local'"), - [ - {'target_op': 2} - ]); + syncTest('accepts text checkpoint request ids for local target op', (_) { + expect(probeLocalTargetOp('1'), isNull); + expect(lastRequestedCheckpointRequestId(), 1); + expect(probeLocalTargetOp(), 1); }); syncTest('does not store non-request target ops as checkpoint request id', (_) { expect(probeLocalTargetOp(0), isNull); expect(lastRequestedCheckpointRequestId(), isNull); + expect(probeLocalTargetOp(), isNull); - expect(probeLocalTargetOp(9223372036854775807), 0); + expect(probeLocalTargetOp(9223372036854775807), isNull); expect(lastRequestedCheckpointRequestId(), isNull); expect(probeLocalTargetOp(), 9223372036854775807); }); @@ -498,6 +500,9 @@ void _syncTests({ json.decode(row[0]), containsPair('last_synced_checkpoint_request_id', 1), ); + + expect(db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"), + isEmpty); }); test('clearing database clears sync status', () { diff --git a/dart/test/utils/migration_fixtures.dart b/dart/test/utils/migration_fixtures.dart index 9566a182..c52ab845 100644 --- a/dart/test/utils/migration_fixtures.dart +++ b/dart/test/utils/migration_fixtures.dart @@ -1,9 +1,12 @@ /// The current database version -const databaseVersion = 13; +const databaseVersion = 14; /// This is the base database state that we expect at various schema versions. /// Generated by loading the specific library version, and exporting the schema. -const expectedState = { +final expectedState = _expectedState(); + +Map _expectedState() { + final state = { 2: r''' ;CREATE TABLE ps_buckets( name TEXT PRIMARY KEY, @@ -537,12 +540,19 @@ const expectedState = { ;INSERT INTO ps_migration(id, down_migrations) VALUES(11, '[{"sql":"DROP TABLE ps_stream_subscriptions"},{"sql":"DELETE FROM ps_migration WHERE id >= 11"}]') ;INSERT INTO ps_migration(id, down_migrations) VALUES(12, '[{"sql":"ALTER TABLE ps_buckets DROP COLUMN downloaded_size"},{"sql":"DELETE FROM ps_migration WHERE id >= 12"}]') ;INSERT INTO ps_migration(id, down_migrations) VALUES(13, '[{"sql":"UPDATE ps_stream_subscriptions SET expires_at = expires_at / 1000000, last_synced_at = last_synced_at / 1000000"},{"sql":"ALTER TABLE ps_sync_state RENAME TO ps_sync_state_new"},{"sql":"CREATE TABLE ps_sync_state (\n priority INTEGER NOT NULL PRIMARY KEY,\n last_synced_at TEXT NOT NULL\n) STRICT;"},{"sql":"INSERT INTO ps_sync_state (priority, last_synced_at) SELECT priority, datetime(last_synced_at / 1000000, ''unixepoch'') FROM ps_sync_state_new"},{"sql":"DROP TABLE ps_sync_state_new"},{"sql":"DELETE FROM ps_migration WHERE id >= 13"}]')''', -}; + }; + state[14] = '''${state[13]!.trim()} +;INSERT INTO ps_migration(id, down_migrations) VALUES(14, '[{"sql":"INSERT INTO ps_buckets(name, pending_delete, last_op, last_applied_op, target_op)\\nSELECT ''\$local'', 1, seen, synced, target\\n FROM (\\n SELECT\\n IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''last_seen_checkpoint_request_id''), 0) AS seen,\\n IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''last_synced_checkpoint_request_id''), 0) AS synced,\\n (SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''local_target_op'') AS target\\n )\\n WHERE EXISTS (\\n SELECT 1 FROM ps_kv WHERE key = ''local_target_op''\\n )\\nON CONFLICT(name) DO UPDATE SET\\n pending_delete = excluded.pending_delete,\\n last_op = excluded.last_op,\\n last_applied_op = excluded.last_applied_op,\\n target_op = excluded.target_op"},{"sql":"DELETE FROM ps_migration WHERE id >= 14"}]')'''; + return state; +} final finalState = expectedState[databaseVersion]!; /// data to test "up" migrations -const data1 = { +final data1 = _data1(); + +Map _data1() { + final data = { 2: r''' ;INSERT INTO ps_buckets(name, last_applied_op, last_op, target_op, add_checksum, pending_delete) VALUES ('b1', 0, 0, 0, 0, 0), @@ -672,7 +682,10 @@ const data1 = { ;INSERT INTO ps_updated_rows(row_type, row_id) VALUES ('lists', 'l2') ''' -}; + }; + data[14] = data[13]!; + return data; +} /// data to test "down" migrations /// This is slightly different from the above, @@ -719,6 +732,7 @@ final dataDown1 = { 10: data1[9]!, 11: data1[10]!, 12: data1[12]!, + 13: data1[13]!, }; final finalData1 = data1[databaseVersion]!; diff --git a/docs/sync.md b/docs/sync.md index bc33520c..0d681917 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -39,16 +39,37 @@ The following commands are supported: When uploads request a write checkpoint, SDKs should call `powersync_next_checkpoint_request_id()` inside a transaction to allocate the id to pass to the -request-checkpoint API. - -`powersync_probe_local_target_op(op_id)` is only for compatibility when a new SDK is used with an -older PowerSync service that does not yet support client-created checkpoint requests. In that mode, -call it after the service-side request is made. Pass `NULL` to probe the current internal `$local` -bucket target op without updating it, or pass an integer to update that target op. In both cases it -returns the value from before the call, or `NULL` if no value existed. Updating to a positive, -non-sentinel target op also stores it as `last_requested_checkpoint_request_id` to support migrating -to client-created checkpoint requests. `0` and sentinel values such as max op id are not stored as -requested checkpoint ids. +request-checkpoint API. In checkpoint-request mode, the SDK should first allocate the id, then post +that id to the service, and then call `powersync_probe_local_target_op(id)` with the same id once +the service accepts the request. This sets the local target op to the request op, replacing the +pending-write sentinel with the concrete checkpoint request id that the sync stream can satisfy. +`powersync_next_checkpoint_request_id()` only advances the request counter; it does not update the +local target op used to block applying downloaded rows. + +`powersync_probe_local_target_op(op_id)` reads and optionally updates the internal local target op. +The same function is used for compatibility when a new SDK is used with an older PowerSync service +that does not yet support client-created checkpoint requests; after the service-side write +checkpoint request returns a concrete id, call `powersync_probe_local_target_op(id)` with that id. +Pass `NULL` to probe the current internal `$local` target op from `ps_kv` without updating it, or +pass an integer or integer string to update that target op. In both cases it returns the value from +before the call, or `NULL` if no value existed. Updating to a positive, non-sentinel target op also +stores it as `last_requested_checkpoint_request_id` to support migrating to client-created +checkpoint requests. Passing `0` clears the local target, and sentinel values such as max op id are +not stored as requested checkpoint ids. + +Database migration v14 moves legacy `$local` checkpoint state into `ps_kv`: `$local.last_applied_op` +becomes `last_synced_checkpoint_request_id`, `$local.last_op` becomes the internal +`last_seen_checkpoint_request_id`, a concrete `$local.target_op` advances the request counter, and +`$local.target_op` is stored as `local_target_op`. Downgrading restores a `$local` row only when +`local_target_op` exists, so older SDKs can keep using target-op based blocking without inventing a +synthetic local bucket when there was no local target state. + +If the migrated target op is not concrete, for example max op id while local writes are pending, +`last_requested_checkpoint_request_id` may be undefined. SDKs must detect that before calling +`powersync_next_checkpoint_request_id()`, since that function would allocate `1` or another value +lower than the legacy service-side counter. In that ambiguous state, create one old-style write +checkpoint first, store the returned concrete id with `powersync_probe_local_target_op(id)`, and +then switch to client-created checkpoint requests. `powersync_control` returns a JSON-encoded array of instructions for the client: From 4e4f932b8048ddeb23d5586df81e6b4d586a8492 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 26 Jun 2026 10:59:54 +0200 Subject: [PATCH 3/8] add temporary docs --- docs/historic-write-checkpoints.md | 208 ++++++++++++++++++++++ docs/sync.md | 17 +- docs/write-checkpoint-requests.md | 269 +++++++++++++++++++++++++++++ 3 files changed, 488 insertions(+), 6 deletions(-) create mode 100644 docs/historic-write-checkpoints.md create mode 100644 docs/write-checkpoint-requests.md diff --git a/docs/historic-write-checkpoints.md b/docs/historic-write-checkpoints.md new file mode 100644 index 00000000..dd697108 --- /dev/null +++ b/docs/historic-write-checkpoints.md @@ -0,0 +1,208 @@ +# Write Checkpointing + +The general flow for mutations is as follows. + +A client makes a write to a table/view. Triggers are used to populate the `ps_crud` table with an entry for the operation. Every local write marks the `$local` bucket in `ps_buckets` as having a `target_op` of the maximum i64 value - this effectively blocks incoming synced checkpoints from being applied. + +```sql +INSERT OR REPLACE INTO ps_buckets(name, last_op, target_op) VALUES('$local', 0, {MAX_OP_ID}) +``` + +A connected client SDK monitors the `ps_crud` table - or the sync state machine triggers CRUD uploads when ready. The user's `uploadData` gets CRUD transactions with `getNextCrudTransaction` or some equivalent method. Calling the `complete` method on a CRUD transaction-like object will: + +- Remove the entries from `ps_crud` +- Depending on the write checkpoint method used: + - Optionally apply a custom_write_checkpoint as the target_op - ONLY if the `ps_crud` queue is empty + - Ensure that the `target_op` is at the MAX_OP_ID + +```TypeScript + await tx.execute(`DELETE FROM ${PSInternalTable.CRUD} WHERE id <= ?`, [lastClientId]); + if (writeCheckpoint) { + const check = await tx.execute(`SELECT 1 FROM ${PSInternalTable.CRUD} LIMIT 1`); + if (!check.rows?.length) { + await tx.execute(`UPDATE ${PSInternalTable.BUCKETS} SET target_op = CAST(? as INTEGER) WHERE name='$local'`, [ + writeCheckpoint + ]); + } + } else { + await tx.execute(`UPDATE ${PSInternalTable.BUCKETS} SET target_op = CAST(? as INTEGER) WHERE name='$local'`, [ + this.bucketStorageAdapter.getMaxOpId() + ]); + } +``` + +Once all the uploads have completed, the Sync implementation will attempt to update the local target. + +```typescript +// private async _uploadAllCrud(signal: AbortSignal): Promise { + +// AbstractStreamingSyncImplementation.ts line 418 +// Uploading is completed +const neededUpdate = await this.options.adapter.updateLocalTarget(() => this.getWriteCheckpoint()); + +// ... +// } + +// SqliteBucketStorage.ts line 67 +// async updateLocalTarget(cb: () => Promise): Promise { +const rs1 = await this.db.getAll( + "SELECT target_op FROM ps_buckets WHERE name = '$local' AND target_op = CAST(? as INTEGER)", + [MAX_OP_ID] +); + +// If the target op is not the MAX_OP_ID (it's a concrete checkpoint ID) +// Then: Don't fetch a new write checkpoint from the service, leave it as is. +// This essentially caters for the custom write checkpoint case where a concrete `writeCheckpoint` +// is set after all items have been uploaded. +// In the managed checkpoint flow, the target_op should be the MAX_OP_ID here. +if (!rs1.length) { + // Nothing to update + return false; +} + +// The logic below tries to ensure that no uploads happened in-between async operations, +// like fetching a write-checkpoint from the PowerSync service +const rs = await this.db.getAll<{ seq: number }>("SELECT seq FROM main.sqlite_sequence WHERE name = 'ps_crud'"); +if (!rs.length) { + // Nothing to update + return false; +} + +const seqBefore: number = rs[0]['seq']; + +// This callback usually connects to the PowerSync service write-checkpoint2.json endpoint +const opId = await cb(); + +// Now we apply the target_op, only if no other CRUD items have dirtied the local state meanwhile. +return this.writeTransaction(async (tx) => { + const anyData = await tx.execute('SELECT 1 FROM ps_crud LIMIT 1'); + if (anyData.rows?.length) { + // if isNotEmpty + this.logger.debug(`New data uploaded since write checkpoint ${opId} - need new write checkpoint`); + return false; + } + + const rs = await tx.execute("SELECT seq FROM main.sqlite_sequence WHERE name = 'ps_crud'"); + if (!rs.rows?.length) { + // assert isNotEmpty + throw new Error('SQLite Sequence should not be empty'); + } + + const seqAfter: number = rs.rows?.item(0)['seq']; + if (seqAfter != seqBefore) { + this.logger.debug( + `New data uploaded since write checkpoint ${opId} - need new write checkpoint (sequence updated)` + ); + + // New crud data may have been uploaded since we got the checkpoint. Abort. + return false; + } + + this.logger.debug(`Updating target write checkpoint to ${opId}`); + await tx.execute("UPDATE ps_buckets SET target_op = CAST(? as INTEGER) WHERE name='$local'", [opId]); + return true; +}); +// } +``` + +Concurrently, the streaming sync implementation is reading checkpoints from the PowerSync service `/sync/stream/` endpoint. The PowerSync service reports which checkpoints are associated with a corresponding write checkpoint. + +The client does not publish guarded changes as soon as it sees that value. After a full checkpoint has completed and its checksums have been validated, `sync_local` stores the checkpoint's `write_checkpoint` as `$local.last_op`. Incoming changes can then be applied locally only when `$local.target_op <= $local.last_op` and the `ps_crud` queue is empty. Partial priority 0 syncs are the exception: they may publish while uploads are outstanding. + +```Rust +// sync_local.rs + +fn can_apply_sync_changes(&self) -> Result { + // Don't publish downloaded data until the upload queue is empty (except for downloaded data + // in priority 0, which is published earlier). + + let needs_check = match &self.partial { + Some(p) => !p.priority.may_publish_with_outstanding_uploads(), + None => true, + }; + + if needs_check { + // language=SQLite + let statement = self.db.prepare_v2( + "SELECT 1 FROM ps_buckets WHERE target_op > last_op AND name = '$local'", + )?; + + if statement.step()? == ResultCode::ROW { + return Ok(false); + } + + let statement = self.db.prepare_v2("SELECT 1 FROM ps_crud LIMIT 1")?; + if statement.step()? != ResultCode::DONE { + return Ok(false); + } + } + + Ok(true) + } + +// storage_adapter.rs + +pub fn sync_local( + // ... +) { + +// ... + if let (None, Some(write_checkpoint)) = (&priority, &checkpoint.write_checkpoint) { + update_bucket.bind_int64(1, *write_checkpoint)?; + update_bucket.bind_text(2, "$local", sqlite::Destructor::STATIC)?; + update_bucket.exec()?; + } + +// ... +} +``` + +## The $local bucket + +`$local` is a special row in `ps_buckets` used to track whether downloaded changes are safe to +publish while local writes are being uploaded. It is stored in the same table as real sync buckets, +but it is not sent to the sync service as a bucket request. + +- `target_op`: The write checkpoint that must be reached before guarded upstream changes may be + published locally. Local writes set this to `MAX_OP_ID`; custom write checkpoints or managed + write-checkpoint requests replace it with a concrete checkpoint id once the relevant upload has + completed. + + ```sql + -- Local writes block guarded publishes until a concrete write checkpoint is known. + INSERT OR REPLACE INTO ps_buckets(name, last_op, target_op) + VALUES('$local', 0, {MAX_OP_ID}); + + -- After upload completion, custom or managed checkpointing stores the target checkpoint. + UPDATE ps_buckets SET target_op = CAST(? AS INTEGER) WHERE name = '$local'; + ``` + +- `last_op`: The latest write checkpoint observed from the sync service. This is updated from + `checkpoint.write_checkpoint` when a full checkpoint is validated. + + ```rust + let update_bucket = self.db.prepare_v2("UPDATE ps_buckets SET last_op = ? WHERE name = ?")?; + + if let (None, Some(write_checkpoint)) = (&priority, &checkpoint.write_checkpoint) { + update_bucket.bind_int64(1, *write_checkpoint)?; + update_bucket.bind_text(2, "$local", sqlite::Destructor::STATIC)?; + update_bucket.exec()?; + } + ``` + +- `last_applied_op`: The latest write checkpoint whose guarded changes have actually been published + locally. This advances to `last_op` after a full `sync_local` apply succeeds. + + ```sql + UPDATE ps_buckets + SET last_applied_op = last_op + WHERE last_applied_op != last_op; + ``` + +The apply gate checks `$local.target_op > $local.last_op` before publishing full checkpoints and +non-priority-0 partial checkpoints. It also checks that `ps_crud` is empty. This means downloaded +changes remain buffered until the client has both uploaded local CRUD and seen the corresponding +write checkpoint in the sync stream. + +Clearing the database removes `$local`: a hard clear deletes all rows from `ps_buckets`, while a +soft clear deletes only the `$local` row and keeps reusable remote bucket state. diff --git a/docs/sync.md b/docs/sync.md index 0d681917..84a70ade 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -64,12 +64,17 @@ becomes `last_synced_checkpoint_request_id`, `$local.last_op` becomes the intern `local_target_op` exists, so older SDKs can keep using target-op based blocking without inventing a synthetic local bucket when there was no local target state. -If the migrated target op is not concrete, for example max op id while local writes are pending, -`last_requested_checkpoint_request_id` may be undefined. SDKs must detect that before calling -`powersync_next_checkpoint_request_id()`, since that function would allocate `1` or another value -lower than the legacy service-side counter. In that ambiguous state, create one old-style write -checkpoint first, store the returned concrete id with `powersync_probe_local_target_op(id)`, and -then switch to client-created checkpoint requests. +If `local_target_op` is absent after migration, there is no local write gate waiting for a +checkpoint. In that case, SDKs can start client-created checkpoint requests normally, even when +`last_requested_checkpoint_request_id` is undefined and the first allocated id is `1`. + +The ambiguous case is a migrated `local_target_op` of max op id with no +`last_requested_checkpoint_request_id`: local writes are pending, but there is no concrete request +id to wait for yet. The max-op sentinel may also cover earlier pending uploads that were already +associated with legacy service-created write checkpoints, so restarting the client-created counter +from `1` could create a target lower than those existing associations. In that state, create one +old-style write checkpoint first, store the returned concrete id with +`powersync_probe_local_target_op(id)`, and then switch to client-created checkpoint requests. `powersync_control` returns a JSON-encoded array of instructions for the client: diff --git a/docs/write-checkpoint-requests.md b/docs/write-checkpoint-requests.md new file mode 100644 index 00000000..739c06c1 --- /dev/null +++ b/docs/write-checkpoint-requests.md @@ -0,0 +1,269 @@ +# Write Checkpoint State in `ps_kv` + +The new write checkpoint logic moves the historic `$local` bucket bookkeeping into `ps_kv`. +`ps_buckets` now tracks real sync buckets, while local upload gating and checkpoint-request +progress are represented as key/value state. + +At a high level: + +- `local_target_op` replaces `$local.target_op` as the local write apply gate. +- `last_seen_checkpoint_request_id` replaces `$local.last_op`. +- `last_synced_checkpoint_request_id` replaces `$local.last_applied_op`. +- `last_requested_checkpoint_request_id` tracks the latest concrete checkpoint request id known to + the client. + +SDKs should not write these keys directly. They update the local target through +`powersync_probe_local_target_op()`, which is the shared helper for both legacy write checkpoints +and new client-created checkpoint requests. The newer `powersync_next_checkpoint_request_id()` +function only allocates a checkpoint request id; after the service accepts that request, the SDK +uses `powersync_probe_local_target_op(id)` to make the accepted id the local target. + +For the historic `$local` bucket flow, see `historic-write-checkpoints.md`. + +## Local writes + +A client write to a synced table/view records an entry in `ps_crud`. For simple CRUD triggers, the +same transaction also records the affected row in `ps_updated_rows` and sets `local_target_op` to +the maximum i64 value. This is the `ps_kv` equivalent of the old `$local.target_op` sentinel: it +means "there are local writes, but we do not yet know the concrete checkpoint id that will +acknowledge them". + +The sentinel is stored in `ps_kv`, not in `ps_buckets`: + +```sql +INSERT OR REPLACE INTO ps_kv(key, value) +VALUES('local_target_op', MAX_OP_ID); +``` + +## Completing uploaded CRUD + +SDK upload code removes uploaded items from `ps_crud`. If the connector supplies a custom write +checkpoint and the queue is empty, that concrete checkpoint becomes the local target immediately. +Otherwise the target is reset to `MAX_OP_ID`, allowing the sync client to create a standard +checkpoint request after the queue drains. + +```text +transaction { + deleteUploadedCrud(upTo: lastUploadedId) + + if let customCheckpoint, crudQueueIsEmpty { + powersync_probe_local_target_op(customCheckpoint) + } else { + powersync_probe_local_target_op(MAX_OP_ID) + } +} +``` + +## Updating the local target + +Once uploads are complete, the sync client updates the local target through +`powersync_probe_local_target_op()`. It only does this when the current target is still +`MAX_OP_ID`, which avoids overwriting a custom checkpoint that was already stored by +`complete(writeCheckpoint:)`. + +The SDK implementation: + +1. Probes the current target with `powersync_probe_local_target_op(NULL)`. +2. Reads `sqlite_sequence.seq` for `ps_crud`. +3. Gets a concrete checkpoint id from either the new or legacy service API. +4. Re-enters a write transaction. +5. Verifies that `ps_crud` is still empty and that its sequence did not change. +6. Stores the concrete target with `powersync_probe_local_target_op(opId)`. + +```text +if powersync_probe_local_target_op(NULL) == MAX_OP_ID { + let seqBefore = psCrudSequence() + let checkpointId = await createOrFetchCheckpointId() + + transaction { + guard ps_crud.isEmpty && psCrudSequence() == seqBefore else { + return + } + + powersync_probe_local_target_op(checkpointId) + } +} +``` + +In checkpoint-request mode, `getWriteCheckpoint()` calls `requestCheckpoint()`. That allocates an +id locally, sends it to `/sync/checkpoint-request`, and returns the same id once the service accepts +the request. Only then does the upload path store that id as `local_target_op` with +`powersync_probe_local_target_op(id)`. + +```text +let requestId = transaction { + powersync_next_checkpoint_request_id() +} + +POST /sync/checkpoint-request { + client_id, + checkpoint_request_id: requestId +} + +return requestId +``` + +The legacy fallback still calls `/write-checkpoint2.json`; the returned write checkpoint is stored +through the same `powersync_probe_local_target_op(opId)` helper. This keeps SDK target updates +consistent across both protocols. + +## Helper functions + +These SQL functions are the SDK-facing API for the new `ps_kv` checkpoint state. + +`powersync_next_checkpoint_request_id()` must be called inside a transaction. It increments and +returns `last_requested_checkpoint_request_id` in `ps_kv`. + +```sql +INSERT INTO ps_kv(key, value) +VALUES('last_requested_checkpoint_request_id', 1) +ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1 +RETURNING value; +``` + +This function only allocates an id. It does not update `local_target_op`. + +Note on sequences: SQLite does not have standalone sequences. The sequence-like alternatives are +either an `AUTOINCREMENT` table backed by SQLite's internal `sqlite_sequence`, or a dedicated +single-row counter table like the existing `ps_tx` transaction counter. The checkpoint request +counter currently lives in `ps_kv` because it is also migrated and seeded from legacy/custom +concrete targets via `powersync_probe_local_target_op()`. If we want stricter structure later, a +dedicated checkpoint-request counter table would be the closest match to a sequence. + +`powersync_probe_local_target_op(op_id)` reads and optionally updates the local target: + +- `NULL` returns the current `local_target_op` without changing it. +- `0` clears `local_target_op`. +- A positive value stores `local_target_op`. +- A positive value other than `i64::MAX` also stores `last_requested_checkpoint_request_id`. +- Negative values and non-integer inputs are rejected. + +The function returns the previous target value, or `NULL` if there was no target. + +```text +previous = ps_kv['local_target_op'] + +if target_op == NULL: + return previous +if target_op == 0: + delete ps_kv['local_target_op'] +else: + ps_kv['local_target_op'] = target_op + if target_op != MAX_OP_ID: + ps_kv['last_requested_checkpoint_request_id'] = target_op + +return previous +``` + +## Applying downloaded checkpoints + +The sync stream reports the checkpoint request id in `checkpoint.write_checkpoint`. After a full +checkpoint validates, core persists it as `last_seen_checkpoint_request_id`. + +```text +on full checkpoint with write_checkpoint: + ps_kv['last_seen_checkpoint_request_id'] = checkpoint.write_checkpoint +``` + +Before publishing downloaded rows, `sync_local` checks the local gate. Full checkpoints and +non-priority-0 partial checkpoints can only apply when: + +- `local_target_op` is absent, or it is less than or equal to `last_seen_checkpoint_request_id`. +- `ps_crud` is empty. + +Priority 0 partial syncs are the exception: they may publish while uploads are outstanding. + +```sql +SELECT 1 +FROM ps_kv AS target +LEFT JOIN ps_kv AS seen ON seen.key = 'last_seen_checkpoint_request_id' +WHERE target.key = 'local_target_op' + AND CAST(target.value AS INTEGER) > COALESCE(CAST(seen.value AS INTEGER), 0); +``` + +If a full checkpoint validated but cannot apply because local CRUD is pending, the state machine +keeps it as `validated_but_not_applied`. When the SDK later sends `completed_upload`, core retries +that checkpoint unless its `write_checkpoint` is older than the current `local_target_op`. + +```text +on completed_upload: + if pending_checkpoint.write_checkpoint >= local_target_op: + retry applying pending_checkpoint +``` + +After a full checkpoint applies, core stores the applied checkpoint request id as +`last_synced_checkpoint_request_id` and emits it in sync status. + +```text +after full checkpoint apply: + ps_kv['last_synced_checkpoint_request_id'] = checkpoint.write_checkpoint +``` + +## Explicit checkpoint requests + +Swift exposes `PowerSyncDatabaseProtocol.requestCheckpoint()` for callers that want to wait until +the local database has caught up to the service. This creates a checkpoint request id through the +connected sync client and returns a `CheckpointRequest`. + +This explicit API does not update `local_target_op`: it is a wait marker, not a local upload gate. +The returned object waits until sync status reports `last_synced_checkpoint_request_id >= requestId`. + +```text +isSynced = status.lastSyncedCheckpointRequestId >= requestId + +waitForSync() { + for status in syncStatusUpdates { + return when status.lastSyncedCheckpointRequestId >= requestId + throw if status reports a sync error + } +} +``` + +The public database method requires an active or connecting sync client, because a disconnected +request could not be delivered to the service or observed in the sync stream. + +## `ps_kv` checkpoint state + +- `local_target_op`: The current apply gate. It is either `MAX_OP_ID` while local writes are + pending, a concrete checkpoint request id after upload completion, or absent when there is no + local write gate. +- `last_requested_checkpoint_request_id`: The last client-created checkpoint request id allocated + by `powersync_next_checkpoint_request_id()`. `powersync_probe_local_target_op()` also writes this + key for positive, non-sentinel targets so migrated or legacy-created concrete targets can seed the + client request counter. +- `last_seen_checkpoint_request_id`: The latest full checkpoint `write_checkpoint` observed and + validated from the sync stream. +- `last_synced_checkpoint_request_id`: The latest full checkpoint `write_checkpoint` that has been + applied locally. SDKs expose this in sync status and use it to resolve `CheckpointRequest` waits. + +## Migration from `$local` + +Migration v14 moves the old `$local` bucket state into `ps_kv`: + +- `$local.last_applied_op` becomes `last_synced_checkpoint_request_id`. +- `$local.last_op` becomes `last_seen_checkpoint_request_id`. +- A concrete `$local.target_op` becomes `last_requested_checkpoint_request_id`. +- Any positive `$local.target_op`, including `MAX_OP_ID`, becomes `local_target_op`. + +An absent `local_target_op` is safe: there is no local write gate waiting for a checkpoint, so an +SDK can start client-created checkpoint requests from `1`. The sync stream will only report that +request id after the service has accepted and reached it. + +The ambiguous case is a migrated `local_target_op` of `MAX_OP_ID` with no +`last_requested_checkpoint_request_id`. That means there is a pending local write gate but no +concrete request id to wait for yet. The `MAX_OP_ID` sentinel only says that local writes dirtied +the gate; it does not prove that no earlier uploads were already associated with legacy +service-created write checkpoints. Those existing checkpoint ids may be higher than a restarted +client counter such as `1`, and using a lower target could let an older seen checkpoint satisfy the +gate too early. In that state, the SDK should create one legacy write checkpoint first, store the +concrete id with `powersync_probe_local_target_op(id)`, and then switch to client-created +checkpoint requests. + +The down migration rebuilds a `$local` row only when `local_target_op` exists, using: + +- `last_seen_checkpoint_request_id` as `$local.last_op` +- `last_synced_checkpoint_request_id` as `$local.last_applied_op` +- `local_target_op` as `$local.target_op` + +This keeps older SDKs able to use the historic target-op gate after a downgrade without inventing a +synthetic `$local` bucket when there was no local target state. From 093bd8acc24a464af37ed0cf7d4721d67b111658 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 26 Jun 2026 16:38:15 +0200 Subject: [PATCH 4/8] cleanup wording --- crates/core/src/migrations.rs | 31 ++++++++--------- crates/core/src/sync/interface.rs | 5 +++ crates/core/src/sync/storage_adapter.rs | 17 ++++----- crates/core/src/sync/streaming_sync.rs | 46 ++++++++++++++----------- crates/core/src/sync/sync_status.rs | 16 ++++----- dart/test/goldens/simple_iteration.json | 8 ++--- dart/test/goldens/starting_stream.json | 2 +- dart/test/migration_test.dart | 8 ++--- dart/test/sync_test.dart | 12 +++---- dart/test/utils/migration_fixtures.dart | 2 +- dart/test/utils/test_utils.dart | 2 -- docs/sync.md | 4 +-- docs/write-checkpoint-requests.md | 18 +++++----- 13 files changed, 86 insertions(+), 85 deletions(-) diff --git a/crates/core/src/migrations.rs b/crates/core/src/migrations.rs index d3a10138..04996084 100644 --- a/crates/core/src/migrations.rs +++ b/crates/core/src/migrations.rs @@ -464,36 +464,33 @@ DROP TABLE ps_sync_state_old; // Move the legacy `$local` checkpoint bookkeeping into ps_kv. // // In older databases, `$local.last_applied_op` represented the latest legacy write - // checkpoint that was actually applied, so it becomes the last synced checkpoint request. + // checkpoint that was actually applied, so it becomes the last applied checkpoint request. // `$local.last_op` represented the latest legacy write checkpoint seen in the sync stream, // so it becomes the last seen checkpoint request. // // `$local.target_op` can either be a concrete checkpoint request id or a sentinel such as // i64::MAX while local writes are pending. Store it separately as `local_target_op`, but // only treat concrete values as requested checkpoint ids. We intentionally don't seed - // `last_requested_checkpoint_request_id` from `$local.last_applied_op` because that is a - // synced value, not necessarily the current requested target. + // `last_requested_checkpoint_request_id` from `$local.last_applied_op` because that is an + // applied value, not necessarily the current requested target. // - // When the target op is not concrete and there is no existing requested checkpoint id, - // `last_requested_checkpoint_request_id` remains undefined. That migration path is - // ambiguous: a new client-created request would start at 1, or another value lower than - // the service's legacy counter. SDKs should detect the undefined value, create one old - // write checkpoint, persist that returned concrete target through - // `powersync_probe_local_target_op`, and only then start creating checkpoint requests. + // An absent local target can safely start client-created checkpoint requests from 1. The + // ambiguous case is an existing max-op local target without a concrete requested id: + // pending local writes may already be associated with legacy service-created write + // checkpoints, so SDKs should bridge once through the legacy endpoint before starting + // client-created checkpoint requests. let up = "\ INSERT INTO ps_kv(key, value) -SELECT 'last_synced_checkpoint_request_id', last_applied_op +SELECT 'last_applied_checkpoint_request_id', last_applied_op FROM ps_buckets WHERE name = '$local' - AND last_applied_op > 0 - AND last_applied_op != 9223372036854775807; + AND last_applied_op > 0; INSERT INTO ps_kv(key, value) SELECT 'last_seen_checkpoint_request_id', last_op FROM ps_buckets WHERE name = '$local' - AND last_op > 0 - AND last_op != 9223372036854775807; + AND last_op > 0; INSERT INTO ps_kv(key, value) SELECT 'last_requested_checkpoint_request_id', target_op @@ -514,7 +511,7 @@ SELECT 'local_target_op', target_op // can keep using their target-op based blocking behavior. In that model, `$local.last_op` // tracked the latest seen legacy write checkpoint and was compared with `$local.target_op` // to decide whether downloaded changes could be applied. The `$local.last_applied_op` - // value represented the synced checkpoint that had actually been applied locally. + // value represented the checkpoint that had actually been applied locally. // `$local.pending_delete = 1` marked this as a synthetic local-only bucket instead of a // normal service bucket. Restore each old progress column from its matching ps_kv key. // The 0 defaults cover a local target that exists before any checkpoint has been seen or @@ -523,11 +520,11 @@ SELECT 'local_target_op', target_op // track. const DOWN_STATEMENTS: &[&str] = &[ "INSERT INTO ps_buckets(name, pending_delete, last_op, last_applied_op, target_op) -SELECT '$local', 1, seen, synced, target +SELECT '$local', 1, seen, applied, target FROM ( SELECT IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = 'last_seen_checkpoint_request_id'), 0) AS seen, - IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = 'last_synced_checkpoint_request_id'), 0) AS synced, + IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = 'last_applied_checkpoint_request_id'), 0) AS applied, (SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = 'local_target_op') AS target ) WHERE EXISTS ( diff --git a/crates/core/src/sync/interface.rs b/crates/core/src/sync/interface.rs index 967cfd66..3ad63933 100644 --- a/crates/core/src/sync/interface.rs +++ b/crates/core/src/sync/interface.rs @@ -388,6 +388,11 @@ fn powersync_probe_local_target_op_impl( }; let db = ctx.db_handle(); + + if new_target_op.is_some() { + verify_in_transaction(db)?; + } + let db_state = unsafe { DatabaseState::from_context(&ctx) }; let adapter = db_state.storage_adapter(db)?; adapter.probe_local_target_op(new_target_op) diff --git a/crates/core/src/sync/storage_adapter.rs b/crates/core/src/sync/storage_adapter.rs index 5c5eeae6..d62dce23 100644 --- a/crates/core/src/sync/storage_adapter.rs +++ b/crates/core/src/sync/storage_adapter.rs @@ -28,7 +28,7 @@ use super::{ const LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY: &str = "last_requested_checkpoint_request_id"; const LAST_SEEN_CHECKPOINT_REQUEST_ID_KEY: &str = "last_seen_checkpoint_request_id"; -const LAST_SYNCED_CHECKPOINT_REQUEST_ID_KEY: &str = "last_synced_checkpoint_request_id"; +const LAST_APPLIED_CHECKPOINT_REQUEST_ID_KEY: &str = "last_applied_checkpoint_request_id"; // Tracks the local target used to block applying downloaded rows while local writes are // outstanding. When present, this is normally either the max-op sentinel for pending local writes or @@ -121,8 +121,8 @@ impl StorageAdapter { items }; - let last_synced_checkpoint_request_id = - self.read_i64_kv(LAST_SYNCED_CHECKPOINT_REQUEST_ID_KEY)?; + let last_applied_checkpoint_request_id = + self.read_i64_kv(LAST_APPLIED_CHECKPOINT_REQUEST_ID_KEY)?; let mut streams = Vec::new(); self.iterate_local_subscriptions(|sub| { @@ -135,7 +135,7 @@ impl StorageAdapter { priority_status: priority_items, downloading: None, streams, - last_synced_checkpoint_request_id, + last_applied_checkpoint_request_id, }) } @@ -545,8 +545,6 @@ WHERE bucket = ?1", return Ok(previous_target_op); }; - // Set the new target op - if target_op < 0 { return Err(PowerSyncError::argument_error( "target op must be a non-negative integer", @@ -579,16 +577,15 @@ WHERE bucket = ?1", } /// Persists the checkpoint request id that was applied locally. - pub fn persist_last_synced_checkpoint_request_id( + pub fn persist_last_applied_checkpoint_request_id( &self, request_id: i64, ) -> Result<(), PowerSyncError> { - self.write_i64_kv(LAST_SYNCED_CHECKPOINT_REQUEST_ID_KEY, request_id) + self.write_i64_kv(LAST_APPLIED_CHECKPOINT_REQUEST_ID_KEY, request_id) } /// Increments, persists and returns the next client-created checkpoint request id. pub fn next_checkpoint_request_id(&self) -> Result { - // Wonder, should this be a sequence instead let statement = self.db.prepare_v2( "INSERT INTO ps_kv(key, value) VALUES(?1, 1) @@ -626,7 +623,7 @@ RETURNING value", let stmt = self.db.prepare_v2( "INSERT INTO ps_kv(key, value) VALUES(?1, ?2) -ON CONFLICT(key) DO UPDATE SET value = excluded.value ", +ON CONFLICT(key) DO UPDATE SET value = excluded.value", )?; stmt.bind_text(1, key, sqlite::Destructor::STATIC)?; stmt.bind_int64(2, value)?; diff --git a/crates/core/src/sync/streaming_sync.rs b/crates/core/src/sync/streaming_sync.rs index c39129dd..8ea26b80 100644 --- a/crates/core/src/sync/streaming_sync.rs +++ b/crates/core/src/sync/streaming_sync.rs @@ -365,7 +365,7 @@ impl StreamingSyncIteration { event.instructions.push(Instruction::FlushFileSystem {}); SyncStateMachineTransition::SyncLocalChangesApplied { - synced_checkpoint_request_id: target.write_checkpoint, + applied_checkpoint_request_id: target.write_checkpoint, partial: None, timestamp, } @@ -402,7 +402,7 @@ impl StreamingSyncIteration { } SyncLocalResult::ChangesApplied { timestamp } => { SyncStateMachineTransition::SyncLocalChangesApplied { - synced_checkpoint_request_id: target.checkpoint.write_checkpoint, + applied_checkpoint_request_id: target.checkpoint.write_checkpoint, partial: Some(priority), timestamp, } @@ -460,7 +460,7 @@ impl StreamingSyncIteration { target: &mut SyncTarget, event: &mut ActiveEvent, transition: SyncStateMachineTransition, - ) -> Option { + ) -> Result, PowerSyncError> { match transition { SyncStateMachineTransition::StartTrackingCheckpoint { progress, @@ -494,14 +494,14 @@ impl StreamingSyncIteration { diagnostics.handle_data_line(line, &*status, &mut event.instructions); } } - SyncStateMachineTransition::CloseIteration(close) => return Some(close), + SyncStateMachineTransition::CloseIteration(close) => return Ok(Some(close)), SyncStateMachineTransition::SyncLocalFailedDueToPendingCrud { validated_but_not_applied, } => { self.validated_but_not_applied = Some(validated_but_not_applied); } SyncStateMachineTransition::SyncLocalChangesApplied { - synced_checkpoint_request_id, + applied_checkpoint_request_id, partial, timestamp, } => { @@ -513,13 +513,17 @@ impl StreamingSyncIteration { &mut event.instructions, ); } else { - self.handle_checkpoint_applied(event, timestamp, synced_checkpoint_request_id); + self.handle_checkpoint_applied( + event, + timestamp, + applied_checkpoint_request_id, + )?; } } SyncStateMachineTransition::Empty => {} }; - None + Ok(None) } /// Handles a single sync line. @@ -533,7 +537,7 @@ impl StreamingSyncIteration { line: &SyncLineWithSource, ) -> Result, PowerSyncError> { let transition = self.prepare_handling_sync_line(target, event, line)?; - Ok(self.apply_transition(target, event, transition)) + self.apply_transition(target, event, transition) } /// Runs a full sync iteration, returning nothing when it completes regularly or an error when @@ -654,7 +658,7 @@ impl StreamingSyncIteration { line: "Applied pending checkpoint after completed upload".into(), }); - self.handle_checkpoint_applied(event, timestamp, checkpoint.write_checkpoint); + self.handle_checkpoint_applied(event, timestamp, checkpoint.write_checkpoint)?; } _ => { event.instructions.push(Instruction::LogLine { @@ -878,14 +882,6 @@ impl StreamingSyncIteration { } } } - - // This is restored into the sync status when initializing a new client. - if priority.is_none() { - if let Some(write_checkpoint) = target.write_checkpoint { - self.adapter - .persist_last_synced_checkpoint_request_id(write_checkpoint)?; - } - } } Ok(result) @@ -950,14 +946,22 @@ impl StreamingSyncIteration { &mut self, event: &mut ActiveEvent, timestamp: TimestampMicros, - synced_checkpoint_request_id: Option, - ) { + applied_checkpoint_request_id: Option, + ) -> Result<(), PowerSyncError> { + if let Some(request_id) = applied_checkpoint_request_id { + // Persisted so it can be restored into sync status when initializing a new client. + self.adapter + .persist_last_applied_checkpoint_request_id(request_id)?; + } + event.instructions.push(Instruction::DidCompleteSync {}); self.status.update( - |status| status.applied_checkpoint(timestamp, synced_checkpoint_request_id), + |status| status.applied_checkpoint(timestamp, applied_checkpoint_request_id), &mut event.instructions, ); + + Ok(()) } } @@ -1133,7 +1137,7 @@ enum SyncStateMachineTransition<'a> { validated_but_not_applied: OwnedCheckpoint, }, SyncLocalChangesApplied { - synced_checkpoint_request_id: Option, + applied_checkpoint_request_id: Option, partial: Option, timestamp: TimestampMicros, }, diff --git a/crates/core/src/sync/sync_status.rs b/crates/core/src/sync/sync_status.rs index 123fb47e..7dba73f0 100644 --- a/crates/core/src/sync/sync_status.rs +++ b/crates/core/src/sync/sync_status.rs @@ -53,8 +53,8 @@ pub struct DownloadSyncStatus { /// received), information about how far the download has progressed. pub downloading: Option, pub streams: Vec, - /// The last fully applied checkpoint request id acknowledged by the sync stream. - pub last_synced_checkpoint_request_id: Option, + /// The latest checkpoint request id whose full checkpoint has been applied locally. + pub last_applied_checkpoint_request_id: Option, } impl DownloadSyncStatus { @@ -122,12 +122,12 @@ impl DownloadSyncStatus { pub fn applied_checkpoint( &mut self, now: TimestampMicros, - synced_checkpoint_request_id: Option, + applied_checkpoint_request_id: Option, ) { self.downloading = None; self.priority_status.clear(); - if let Some(request_id) = synced_checkpoint_request_id { - self.last_synced_checkpoint_request_id = Some(request_id); + if let Some(request_id) = applied_checkpoint_request_id { + self.last_applied_checkpoint_request_id = Some(request_id); } self.priority_status.push(SyncPriorityStatus { @@ -146,7 +146,7 @@ impl Default for DownloadSyncStatus { downloading: None, priority_status: Vec::new(), streams: Vec::new(), - last_synced_checkpoint_request_id: None, + last_applied_checkpoint_request_id: None, } } } @@ -197,8 +197,8 @@ impl Serialize for DownloadSyncStatus { serializer.serialize_field("downloading", &self.downloading)?; serializer.serialize_field("streams", &SerializeStreamsWithProgress(self))?; serializer.serialize_field( - "last_synced_checkpoint_request_id", - &self.last_synced_checkpoint_request_id, + "last_applied_checkpoint_request_id", + &self.last_applied_checkpoint_request_id, )?; serializer.end() diff --git a/dart/test/goldens/simple_iteration.json b/dart/test/goldens/simple_iteration.json index 5bc1bb3f..d3bca3fa 100644 --- a/dart/test/goldens/simple_iteration.json +++ b/dart/test/goldens/simple_iteration.json @@ -11,7 +11,7 @@ "priority_status": [], "downloading": null, "streams": [], - "last_synced_checkpoint_request_id": null + "last_applied_checkpoint_request_id": null } } }, @@ -67,7 +67,7 @@ } }, "streams": [], - "last_synced_checkpoint_request_id": null + "last_applied_checkpoint_request_id": null } } } @@ -118,7 +118,7 @@ } }, "streams": [], - "last_synced_checkpoint_request_id": null + "last_applied_checkpoint_request_id": null } } } @@ -158,7 +158,7 @@ ], "downloading": null, "streams": [], - "last_synced_checkpoint_request_id": null + "last_applied_checkpoint_request_id": null } } } diff --git a/dart/test/goldens/starting_stream.json b/dart/test/goldens/starting_stream.json index 8d55ebee..c5573003 100644 --- a/dart/test/goldens/starting_stream.json +++ b/dart/test/goldens/starting_stream.json @@ -15,7 +15,7 @@ "priority_status": [], "downloading": null, "streams": [], - "last_synced_checkpoint_request_id": null + "last_applied_checkpoint_request_id": null } } }, diff --git a/dart/test/migration_test.dart b/dart/test/migration_test.dart index b8d6b7cf..08ea1536 100644 --- a/dart/test/migration_test.dart +++ b/dart/test/migration_test.dart @@ -109,7 +109,7 @@ VALUES(1, '$local', 5, 6, 7, 0, 0, 1, 0, 0, 0); expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), containsAll([ {'key': 'last_seen_checkpoint_request_id', 'value': 6}, {'key': 'last_requested_checkpoint_request_id', 'value': 7}, - {'key': 'last_synced_checkpoint_request_id', 'value': 5}, + {'key': 'last_applied_checkpoint_request_id', 'value': 5}, {'key': 'local_target_op', 'value': 7}, ])); }); @@ -126,12 +126,12 @@ VALUES(1, '$local', 5, 6, 9223372036854775807, 0, 0, 1, 0, 0, 0); db.executeInTx('select powersync_init()'); - // last_applied_op becomes the synced checkpoint id, but it must not seed the requested + // last_applied_op becomes the applied checkpoint id, but it must not seed the requested // checkpoint counter. The sentinel target is preserved for blocking, but is not concrete // enough to become last_requested_checkpoint_request_id. expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [ {'key': 'last_seen_checkpoint_request_id', 'value': 6}, - {'key': 'last_synced_checkpoint_request_id', 'value': 5}, + {'key': 'last_applied_checkpoint_request_id', 'value': 5}, {'key': 'local_target_op', 'value': 9223372036854775807}, ]); }); @@ -159,7 +159,7 @@ VALUES(1, '$local', 0, 0, 9223372036854775807, 0, 0, 1, 0, 0, 0); INSERT INTO ps_kv(key, value) VALUES ('last_requested_checkpoint_request_id', 7), ('last_seen_checkpoint_request_id', 6), - ('last_synced_checkpoint_request_id', 5), + ('last_applied_checkpoint_request_id', 5), ('local_target_op', 7); '''); diff --git a/dart/test/sync_test.dart b/dart/test/sync_test.dart index b1628b57..d17a10ab 100644 --- a/dart/test/sync_test.dart +++ b/dart/test/sync_test.dart @@ -361,7 +361,7 @@ void _syncTests({ containsPair( 'status', allOf( - containsPair('last_synced_checkpoint_request_id', 1), + containsPair('last_applied_checkpoint_request_id', 1), containsPair( 'priority_status', [ @@ -386,7 +386,7 @@ void _syncTests({ expect(json.decode(row[0]), { 'connected': false, 'connecting': false, - 'last_synced_checkpoint_request_id': 1, + 'last_applied_checkpoint_request_id': 1, 'priority_status': [ {'priority': 2, 'last_synced_at': timestamp(), 'has_synced': true}, { @@ -459,7 +459,7 @@ void _syncTests({ 'UpdateSyncStatus', containsPair( 'status', - containsPair('last_synced_checkpoint_request_id', null), + containsPair('last_applied_checkpoint_request_id', null), ), ), ), @@ -468,7 +468,7 @@ void _syncTests({ final [row] = db.select('select powersync_offline_sync_status();'); expect( json.decode(row[0]), - containsPair('last_synced_checkpoint_request_id', null), + containsPair('last_applied_checkpoint_request_id', null), ); }, ); @@ -489,7 +489,7 @@ void _syncTests({ 'UpdateSyncStatus', containsPair( 'status', - containsPair('last_synced_checkpoint_request_id', 1), + containsPair('last_applied_checkpoint_request_id', 1), ), ), ), @@ -498,7 +498,7 @@ void _syncTests({ final [row] = db.select('select powersync_offline_sync_status();'); expect( json.decode(row[0]), - containsPair('last_synced_checkpoint_request_id', 1), + containsPair('last_applied_checkpoint_request_id', 1), ); expect(db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"), diff --git a/dart/test/utils/migration_fixtures.dart b/dart/test/utils/migration_fixtures.dart index c52ab845..555d4681 100644 --- a/dart/test/utils/migration_fixtures.dart +++ b/dart/test/utils/migration_fixtures.dart @@ -542,7 +542,7 @@ Map _expectedState() { ;INSERT INTO ps_migration(id, down_migrations) VALUES(13, '[{"sql":"UPDATE ps_stream_subscriptions SET expires_at = expires_at / 1000000, last_synced_at = last_synced_at / 1000000"},{"sql":"ALTER TABLE ps_sync_state RENAME TO ps_sync_state_new"},{"sql":"CREATE TABLE ps_sync_state (\n priority INTEGER NOT NULL PRIMARY KEY,\n last_synced_at TEXT NOT NULL\n) STRICT;"},{"sql":"INSERT INTO ps_sync_state (priority, last_synced_at) SELECT priority, datetime(last_synced_at / 1000000, ''unixepoch'') FROM ps_sync_state_new"},{"sql":"DROP TABLE ps_sync_state_new"},{"sql":"DELETE FROM ps_migration WHERE id >= 13"}]')''', }; state[14] = '''${state[13]!.trim()} -;INSERT INTO ps_migration(id, down_migrations) VALUES(14, '[{"sql":"INSERT INTO ps_buckets(name, pending_delete, last_op, last_applied_op, target_op)\\nSELECT ''\$local'', 1, seen, synced, target\\n FROM (\\n SELECT\\n IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''last_seen_checkpoint_request_id''), 0) AS seen,\\n IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''last_synced_checkpoint_request_id''), 0) AS synced,\\n (SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''local_target_op'') AS target\\n )\\n WHERE EXISTS (\\n SELECT 1 FROM ps_kv WHERE key = ''local_target_op''\\n )\\nON CONFLICT(name) DO UPDATE SET\\n pending_delete = excluded.pending_delete,\\n last_op = excluded.last_op,\\n last_applied_op = excluded.last_applied_op,\\n target_op = excluded.target_op"},{"sql":"DELETE FROM ps_migration WHERE id >= 14"}]')'''; +;INSERT INTO ps_migration(id, down_migrations) VALUES(14, '[{"sql":"INSERT INTO ps_buckets(name, pending_delete, last_op, last_applied_op, target_op)\\nSELECT ''\$local'', 1, seen, applied, target\\n FROM (\\n SELECT\\n IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''last_seen_checkpoint_request_id''), 0) AS seen,\\n IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''last_applied_checkpoint_request_id''), 0) AS applied,\\n (SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''local_target_op'') AS target\\n )\\n WHERE EXISTS (\\n SELECT 1 FROM ps_kv WHERE key = ''local_target_op''\\n )\\nON CONFLICT(name) DO UPDATE SET\\n pending_delete = excluded.pending_delete,\\n last_op = excluded.last_op,\\n last_applied_op = excluded.last_applied_op,\\n target_op = excluded.target_op"},{"sql":"DELETE FROM ps_migration WHERE id >= 14"}]')'''; return state; } diff --git a/dart/test/utils/test_utils.dart b/dart/test/utils/test_utils.dart index eb310ac2..8c6bd1fc 100644 --- a/dart/test/utils/test_utils.dart +++ b/dart/test/utils/test_utils.dart @@ -26,13 +26,11 @@ Object stream(String name, bool isDefault, {List errors = const []}) { Object checkpointComplete({ int? priority, String lastOpId = '1', - String? writeCheckpoint, }) { return { priority == null ? 'checkpoint_complete' : 'partial_checkpoint_complete': { 'last_op_id': lastOpId, if (priority != null) 'priority': priority, - if (writeCheckpoint != null) 'write_checkpoint': writeCheckpoint, }, }; } diff --git a/docs/sync.md b/docs/sync.md index 84a70ade..e9595d3e 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -58,7 +58,7 @@ checkpoint requests. Passing `0` clears the local target, and sentinel values su not stored as requested checkpoint ids. Database migration v14 moves legacy `$local` checkpoint state into `ps_kv`: `$local.last_applied_op` -becomes `last_synced_checkpoint_request_id`, `$local.last_op` becomes the internal +becomes `last_applied_checkpoint_request_id`, `$local.last_op` becomes the internal `last_seen_checkpoint_request_id`, a concrete `$local.target_op` advances the request counter, and `$local.target_op` is stored as `local_target_op`. Downgrading restores a `$local` row only when `local_target_op` exists, so older SDKs can keep using target-op based blocking without inventing a @@ -108,7 +108,7 @@ interface UpdateSyncStatus { priority_status: [], downloading: null | DownloadProgress, streams: [], - last_synced_checkpoint_request_id: null | number, + last_applied_checkpoint_request_id: null | number, } // Instructs SDKs to refresh credentials from the backend connector. diff --git a/docs/write-checkpoint-requests.md b/docs/write-checkpoint-requests.md index 739c06c1..a03d9356 100644 --- a/docs/write-checkpoint-requests.md +++ b/docs/write-checkpoint-requests.md @@ -8,7 +8,7 @@ At a high level: - `local_target_op` replaces `$local.target_op` as the local write apply gate. - `last_seen_checkpoint_request_id` replaces `$local.last_op`. -- `last_synced_checkpoint_request_id` replaces `$local.last_applied_op`. +- `last_applied_checkpoint_request_id` replaces `$local.last_applied_op`. - `last_requested_checkpoint_request_id` tracks the latest concrete checkpoint request id known to the client. @@ -192,11 +192,11 @@ on completed_upload: ``` After a full checkpoint applies, core stores the applied checkpoint request id as -`last_synced_checkpoint_request_id` and emits it in sync status. +`last_applied_checkpoint_request_id` and emits it in sync status. ```text after full checkpoint apply: - ps_kv['last_synced_checkpoint_request_id'] = checkpoint.write_checkpoint + ps_kv['last_applied_checkpoint_request_id'] = checkpoint.write_checkpoint ``` ## Explicit checkpoint requests @@ -206,14 +206,14 @@ the local database has caught up to the service. This creates a checkpoint reque connected sync client and returns a `CheckpointRequest`. This explicit API does not update `local_target_op`: it is a wait marker, not a local upload gate. -The returned object waits until sync status reports `last_synced_checkpoint_request_id >= requestId`. +The returned object waits until sync status reports `last_applied_checkpoint_request_id >= requestId`. ```text -isSynced = status.lastSyncedCheckpointRequestId >= requestId +isSynced = status.lastAppliedCheckpointRequestId >= requestId waitForSync() { for status in syncStatusUpdates { - return when status.lastSyncedCheckpointRequestId >= requestId + return when status.lastAppliedCheckpointRequestId >= requestId throw if status reports a sync error } } @@ -233,14 +233,14 @@ request could not be delivered to the service or observed in the sync stream. client request counter. - `last_seen_checkpoint_request_id`: The latest full checkpoint `write_checkpoint` observed and validated from the sync stream. -- `last_synced_checkpoint_request_id`: The latest full checkpoint `write_checkpoint` that has been +- `last_applied_checkpoint_request_id`: The latest full checkpoint `write_checkpoint` that has been applied locally. SDKs expose this in sync status and use it to resolve `CheckpointRequest` waits. ## Migration from `$local` Migration v14 moves the old `$local` bucket state into `ps_kv`: -- `$local.last_applied_op` becomes `last_synced_checkpoint_request_id`. +- `$local.last_applied_op` becomes `last_applied_checkpoint_request_id`. - `$local.last_op` becomes `last_seen_checkpoint_request_id`. - A concrete `$local.target_op` becomes `last_requested_checkpoint_request_id`. - Any positive `$local.target_op`, including `MAX_OP_ID`, becomes `local_target_op`. @@ -262,7 +262,7 @@ checkpoint requests. The down migration rebuilds a `$local` row only when `local_target_op` exists, using: - `last_seen_checkpoint_request_id` as `$local.last_op` -- `last_synced_checkpoint_request_id` as `$local.last_applied_op` +- `last_applied_checkpoint_request_id` as `$local.last_applied_op` - `local_target_op` as `$local.target_op` This keeps older SDKs able to use the historic target-op gate after a downgrade without inventing a From 8da12bb12f0077d28383582615fc48e83740b30e Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 2 Jul 2026 12:48:48 +0200 Subject: [PATCH 5/8] wip: move functions to powersync_control invocations --- crates/core/src/sync/interface.rs | 156 +++++++++------------ crates/core/src/sync/storage_adapter.rs | 36 ++++- crates/core/src/sync/streaming_sync.rs | 69 +++++---- dart/benchmark/apply_lines.dart | 4 +- dart/test/error_test.dart | 1 + dart/test/goldens/simple_iteration.json | 8 +- dart/test/goldens/starting_stream.json | 8 +- dart/test/migration_test.dart | 1 + dart/test/sync_local_performance_test.dart | 1 + dart/test/sync_stream_test.dart | 19 ++- dart/test/sync_test.dart | 102 ++++++++++---- docs/sync.md | 66 +++++---- docs/write-checkpoint-requests.md | 73 ++++++---- 13 files changed, 351 insertions(+), 193 deletions(-) diff --git a/crates/core/src/sync/interface.rs b/crates/core/src/sync/interface.rs index 3ad63933..57e559e6 100644 --- a/crates/core/src/sync/interface.rs +++ b/crates/core/src/sync/interface.rs @@ -4,14 +4,15 @@ use core::ffi::{c_int, c_void}; use super::streaming_sync::SyncClient; use super::sync_status::DownloadSyncStatus; use crate::constants::SUBTYPE_JSON; +use crate::create_sqlite_text_fn; use crate::error::PowerSyncError; use crate::schema::Schema; use crate::state::DatabaseState; use crate::sync::diagnostics::{DiagnosticOptions, DiagnosticsEvent}; use crate::sync::subscriptions::{StreamKey, apply_subscriptions}; -use crate::{create_sqlite_int_fn, create_sqlite_optional_int_fn, create_sqlite_text_fn}; use alloc::borrow::Cow; use alloc::boxed::Box; +use alloc::format; use alloc::rc::Rc; use alloc::{string::String, vec::Vec}; use powersync_sqlite_nostd::bindings::SQLITE_RESULT_SUBTYPE; @@ -76,6 +77,8 @@ pub enum SyncControlRequest<'a> { StartSyncStream(StartSyncStream), /// The client requests to stop the current sync iteration. StopSyncStream, + /// The client requests a new checkpoint request id. + NextCheckpointRequestId, /// The client is forwading a sync event to the core extension. SyncEvent(SyncEvent<'a>), } @@ -89,6 +92,10 @@ pub enum SyncEvent<'a> { /// /// In response, we'll stop the current iteration to begin another one with the new token. DidRefreshToken, + /// Seeds the checkpoint request counter from service state. + SeedCheckpointRequestId { + request_id: Option, + }, /// Notifies the sync client that the current CRUD upload (for which the client SDK is /// responsible) has finished. /// @@ -128,13 +135,24 @@ pub enum Instruction { }, /// Connect to the sync service using the [StreamingSyncRequest] created by the core extension, /// and then forward received lines via [SyncEvent::TextLine] and [SyncEvent::BinaryLine]. - EstablishSyncStream { request: StreamingSyncRequest }, + EstablishSyncStream { + request: StreamingSyncRequest, + /// The latest checkpoint request id known locally before opening this stream. + /// + /// SDKs can use a missing value as a cue to fetch checkpoint request state from the service + /// and report it back with `seed_checkpoint_request_id`. + last_checkpoint_request_id: Option, + }, FetchCredentials { /// Whether the credentials currently used have expired. /// /// If false, this is a pre-fetch. did_expire: bool, }, + /// Return a newly allocated checkpoint request id to the SDK. + CheckpointRequestId { request_id: i64 }, + /// Return the local target op value observed before an optional update. + LocalTargetOp { target_op: Option }, // These are defined like this because deserializers in Kotlin can't support either an // object or a literal value /// Close the websocket / HTTP stream to the sync service. @@ -232,6 +250,24 @@ pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<() } }), "stop" => SyncControlRequest::StopSyncStream, + "next_checkpoint_request_id" => SyncControlRequest::NextCheckpointRequestId, + "local_target_op" => { + let target_op = parse_optional_i64_payload( + *payload, + "local target op", + "local target op must be an integer, integer string, or null", + )?; + let adapter = state.storage_adapter(db)?; + let target_op = adapter.probe_local_target_op(target_op)?; + let formatted = + serde_json::to_string(&alloc::vec![Instruction::LocalTargetOp { + target_op + },]) + .map_err(PowerSyncError::internal)?; + ctx.result_text_transient(&formatted); + ctx.result_subtype(SUBTYPE_JSON); + return Ok(()); + } "line_text" => SyncControlRequest::SyncEvent(SyncEvent::TextLine { data: if payload.value_type() == ColumnType::Text { payload.text() @@ -251,6 +287,15 @@ pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<() }, }), "refreshed_token" => SyncControlRequest::SyncEvent(SyncEvent::DidRefreshToken), + "seed_checkpoint_request_id" => { + SyncControlRequest::SyncEvent(SyncEvent::SeedCheckpointRequestId { + request_id: parse_optional_i64_payload( + *payload, + "checkpoint request id", + "checkpoint request id must be an integer, integer string, or null", + )?, + }) + } "completed_upload" => SyncControlRequest::SyncEvent(SyncEvent::UploadFinished), "update_subscriptions" => { SyncControlRequest::SyncEvent(SyncEvent::DidUpdateSubscriptions { @@ -324,28 +369,6 @@ pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<() Some(DatabaseState::destroy_rc), )?; - db.create_function_v2( - "powersync_probe_local_target_op", - 1, - sqlite::UTF8 | sqlite::DIRECTONLY, - Some(Rc::into_raw(state.clone()) as *mut c_void), - Some(powersync_probe_local_target_op), - None, - None, - Some(DatabaseState::destroy_rc), - )?; - - db.create_function_v2( - "powersync_next_checkpoint_request_id", - 0, - sqlite::UTF8 | sqlite::DIRECTONLY, - Some(Rc::into_raw(state) as *mut c_void), - Some(powersync_next_checkpoint_request_id), - None, - None, - Some(DatabaseState::destroy_rc), - )?; - Ok(()) } @@ -362,74 +385,31 @@ fn powersync_offline_sync_status_impl( Ok(serialized) } -fn powersync_probe_local_target_op_impl( - ctx: *mut sqlite::context, - args: &[*mut sqlite::value], -) -> Result, PowerSyncError> { - if args.len() != 1 { - return Err(PowerSyncError::argument_error( - "powersync_probe_local_target_op takes one argument", - )); - } - - let arg = args[0]; - let new_target_op = - match arg.value_type() { - ColumnType::Null => None, - ColumnType::Integer => Some(arg.int64()), - ColumnType::Text => Some(arg.text().parse::().map_err(|_| { - PowerSyncError::argument_error("target op must be an integer string") - })?), - _ => { - return Err(PowerSyncError::argument_error( - "target op must be an integer, integer string, or null", - )); - } - }; - - let db = ctx.db_handle(); - - if new_target_op.is_some() { - verify_in_transaction(db)?; - } - - let db_state = unsafe { DatabaseState::from_context(&ctx) }; - let adapter = db_state.storage_adapter(db)?; - adapter.probe_local_target_op(new_target_op) -} - -fn powersync_next_checkpoint_request_id_impl( - ctx: *mut sqlite::context, - args: &[*mut sqlite::value], -) -> Result { - if !args.is_empty() { - return Err(PowerSyncError::argument_error( - "powersync_next_checkpoint_request_id does not take arguments", - )); - } - - let db = ctx.db_handle(); - verify_in_transaction(db)?; - - let db_state = unsafe { DatabaseState::from_context(&ctx) }; - let adapter = db_state.storage_adapter(db)?; - adapter.next_checkpoint_request_id() -} - create_sqlite_text_fn!( powersync_offline_sync_status, powersync_offline_sync_status_impl, "powersync_offline_sync_status" ); -create_sqlite_optional_int_fn!( - powersync_probe_local_target_op, - powersync_probe_local_target_op_impl, - "powersync_probe_local_target_op" -); +fn parse_optional_i64_payload( + payload: *mut sqlite::value, + name: &'static str, + type_error: &'static str, +) -> Result, PowerSyncError> { + let value = match payload.value_type() { + ColumnType::Null => return Ok(None), + ColumnType::Integer => payload.int64(), + ColumnType::Text => payload.text().parse::().map_err(|_| { + PowerSyncError::argument_error(format!("{name} must be an integer string")) + })?, + _ => return Err(PowerSyncError::argument_error(type_error)), + }; + + if value < 0 { + return Err(PowerSyncError::argument_error(format!( + "{name} must be a non-negative integer" + ))); + } -create_sqlite_int_fn!( - powersync_next_checkpoint_request_id, - powersync_next_checkpoint_request_id_impl, - "powersync_next_checkpoint_request_id" -); + Ok(Some(value)) +} diff --git a/crates/core/src/sync/storage_adapter.rs b/crates/core/src/sync/storage_adapter.rs index d62dce23..34f0c3b3 100644 --- a/crates/core/src/sync/storage_adapter.rs +++ b/crates/core/src/sync/storage_adapter.rs @@ -540,7 +540,6 @@ WHERE bucket = ?1", ) -> Result, PowerSyncError> { let previous_target_op = self.local_state()?.map(|state| state.target_op); - // Return the previous op if no new target_op has been provided let Some(target_op) = target_op else { return Ok(previous_target_op); }; @@ -558,6 +557,8 @@ WHERE bucket = ?1", self.write_i64_kv(LOCAL_TARGET_OP_KEY, target_op)?; + // Concrete target ops also seed the request counter for clients migrating from legacy + // service-created write checkpoints to client-created checkpoint requests. if target_op != i64::MAX { self.write_i64_kv(LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY, target_op)?; } @@ -605,6 +606,39 @@ RETURNING value", } } + /// Returns whether the local checkpoint request counter has been initialized. + pub fn has_checkpoint_request_id(&self) -> Result { + Ok(self.last_checkpoint_request_id()?.is_some()) + } + + /// Returns the latest checkpoint request id known locally. + pub fn last_checkpoint_request_id(&self) -> Result, PowerSyncError> { + self.read_i64_kv(LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY) + } + + /// Seeds the local checkpoint request counter from service state without moving it backwards. + /// + /// A null service value means the service has no record for this client yet. Store zero in + /// that case so the first local allocation returns one while still marking the state as seeded. + pub fn seed_checkpoint_request_id( + &self, + request_id: Option, + ) -> Result<(), PowerSyncError> { + let stmt = self.db.prepare_v2( + "INSERT INTO ps_kv(key, value) +VALUES(?1, ?2) +ON CONFLICT(key) DO UPDATE SET value = max(CAST(value AS INTEGER), excluded.value)", + )?; + stmt.bind_text( + 1, + LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY, + sqlite::Destructor::STATIC, + )?; + stmt.bind_int64(2, request_id.unwrap_or(0))?; + stmt.exec()?; + Ok(()) + } + fn read_i64_kv(&self, key: &'static str) -> Result, PowerSyncError> { let statement = self .db diff --git a/crates/core/src/sync/streaming_sync.rs b/crates/core/src/sync/streaming_sync.rs index 8ea26b80..116d92e7 100644 --- a/crates/core/src/sync/streaming_sync.rs +++ b/crates/core/src/sync/streaming_sync.rs @@ -95,21 +95,24 @@ impl SyncClient { SyncControlRequest::SyncEvent(sync_event) => { let mut active = ActiveEvent::new(sync_event); - let ClientState::IterationActive(handle) = &mut self.state else { - return Err(PowerSyncError::state_error("No iteration is active")); + let run_result = { + let ClientState::IterationActive(handle) = &mut self.state else { + return Err(PowerSyncError::state_error("No iteration is active")); + }; + + handle.run(&mut active) }; - match handle.run(&mut active) { + match run_result { Err(e) => { self.state = ClientState::Idle; return Err(e); } - Ok(done) => { - if done { - self.state = ClientState::Idle; - } + Ok(true) => { + self.state = ClientState::Idle; } - }; + Ok(false) => {} + } if let Some(recoverable) = active.recoverable_error.take() { Err(recoverable) @@ -117,6 +120,19 @@ impl SyncClient { Ok(active.instructions) } } + SyncControlRequest::NextCheckpointRequestId => { + if !self.has_sync_iteration() { + return Err(PowerSyncError::state_error("No iteration is active")); + } + if !self.adapter.has_checkpoint_request_id()? { + return Err(PowerSyncError::state_error( + "Checkpoint request state has not been seeded", + )); + } + + let request_id = self.adapter.next_checkpoint_request_id()?; + Ok(alloc::vec![Instruction::CheckpointRequestId { request_id }]) + } SyncControlRequest::StopSyncStream => self.state.tear_down(), } } @@ -200,18 +216,18 @@ impl SyncIterationHandle { }; let mut context = Context::from_waker(&waker); - Ok( - if let Poll::Ready(result) = self.future.poll(&mut context) { - let close = result?; + let done = if let Poll::Ready(result) = self.future.poll(&mut context) { + let close = result?; - active - .instructions - .push(Instruction::CloseSyncStream(close)); - true - } else { - false - }, - ) + active + .instructions + .push(Instruction::CloseSyncStream(close)); + true + } else { + false + }; + + Ok(done) } } @@ -557,6 +573,10 @@ impl StreamingSyncIteration { .update(|s| s.disconnect(), &mut event.instructions); break false; } + SyncEvent::SeedCheckpointRequestId { request_id } => { + self.adapter.seed_checkpoint_request_id(request_id)?; + continue; + } SyncEvent::TextLine { data } => SyncLineWithSource::from_text(data)?, SyncEvent::BinaryLine { data } => SyncLineWithSource::from_binary(data)?, SyncEvent::UploadFinished => { @@ -897,11 +917,11 @@ impl StreamingSyncIteration { /// subscriptions, used to associate [BucketSubscriptionReason::DerivedFromExplicitSubscription]. async fn prepare_request(&mut self) -> Result { let event = Self::receive_event().await; - let SyncEvent::Initialize = event.event else { + if !matches!(&event.event, SyncEvent::Initialize) { return Err(PowerSyncError::argument_error( "first event must initialize", )); - }; + } let offline_state = self.adapter.offline_sync_state()?; self.status.update( @@ -933,9 +953,10 @@ impl StreamingSyncIteration { app_metadata: self.options.app_metadata.take(), }; - event - .instructions - .push(Instruction::EstablishSyncStream { request }); + event.instructions.push(Instruction::EstablishSyncStream { + request, + last_checkpoint_request_id: self.adapter.last_checkpoint_request_id()?, + }); Ok(BeforeCheckpoint { local_buckets: local_bucket_names, stream_subscriptions: stream_subscriptions, diff --git a/dart/benchmark/apply_lines.dart b/dart/benchmark/apply_lines.dart index ee0654b5..c06d361a 100644 --- a/dart/benchmark/apply_lines.dart +++ b/dart/benchmark/apply_lines.dart @@ -17,8 +17,10 @@ void main(List args) { final db = openTestDatabase(); db + ..execute('BEGIN') ..execute('select powersync_init()') - ..execute('select powersync_control(?, null)', ['start']); + ..execute('select powersync_control(?, null)', ['start']) + ..execute('COMMIT'); final stopwatch = Stopwatch()..start(); diff --git a/dart/test/error_test.dart b/dart/test/error_test.dart index 74da34a6..305a91f4 100644 --- a/dart/test/error_test.dart +++ b/dart/test/error_test.dart @@ -78,6 +78,7 @@ void main() { final control = db.prepare(stmt); control.execute(['start', null]); + control.execute(['seed_checkpoint_request_id', null]); expect( () => control.execute(['line_text', 'invalid sync line']), throwsA(isSqliteException( diff --git a/dart/test/goldens/simple_iteration.json b/dart/test/goldens/simple_iteration.json index d3bca3fa..c6c3ac21 100644 --- a/dart/test/goldens/simple_iteration.json +++ b/dart/test/goldens/simple_iteration.json @@ -28,11 +28,17 @@ "include_defaults": true, "subscriptions": [] } - } + }, + "last_checkpoint_request_id": null } } ] }, + { + "operation": "seed_checkpoint_request_id", + "data": null, + "output": [] + }, { "operation": "line_text", "data": { diff --git a/dart/test/goldens/starting_stream.json b/dart/test/goldens/starting_stream.json index c5573003..bc9b1436 100644 --- a/dart/test/goldens/starting_stream.json +++ b/dart/test/goldens/starting_stream.json @@ -34,9 +34,15 @@ "include_defaults": true, "subscriptions": [] } - } + }, + "last_checkpoint_request_id": null } } ] + }, + { + "operation": "seed_checkpoint_request_id", + "data": null, + "output": [] } ] diff --git a/dart/test/migration_test.dart b/dart/test/migration_test.dart index 08ea1536..5c3256d5 100644 --- a/dart/test/migration_test.dart +++ b/dart/test/migration_test.dart @@ -301,6 +301,7 @@ INSERT INTO ps_kv(key, value) VALUES db.execute('begin'); control('start'); + control('seed_checkpoint_request_id'); control( 'line_text', json.encode(checkpoint(lastOpId: 3, buckets: [ diff --git a/dart/test/sync_local_performance_test.dart b/dart/test/sync_local_performance_test.dart index 92ba1c2b..81546158 100644 --- a/dart/test/sync_local_performance_test.dart +++ b/dart/test/sync_local_performance_test.dart @@ -100,6 +100,7 @@ COMMIT; // Start a fake sync client to apply the changes we've already written to // ps_oplog control('start'); + control('seed_checkpoint_request_id'); final lastOpid = db.select('select max(op_id) from ps_oplog').single.columnAt(0) as int; final allBuckets = db diff --git a/dart/test/sync_stream_test.dart b/dart/test/sync_stream_test.dart index e5ba8ac1..21c02561 100644 --- a/dart/test/sync_stream_test.dart +++ b/dart/test/sync_stream_test.dart @@ -32,7 +32,7 @@ void main() { ['client_id', 'test-test-test-test']); }); - List control(String operation, Object? data) { + List controlRaw(String operation, Object? data) { db.execute('begin'); ResultSet result; @@ -60,6 +60,23 @@ void main() { } } + bool establishesSyncStream(List instructions) { + return instructions.any((instruction) => + instruction is Map && + instruction.containsKey('EstablishSyncStream')); + } + + List control(String operation, Object? data) { + final result = controlRaw(operation, data); + if (operation == 'start' && establishesSyncStream(result)) { + return [ + ...result, + ...controlRaw('seed_checkpoint_request_id', null), + ]; + } + return result; + } + group('default streams', () { syncTest('are created on-demand', (_) { control('start', null); diff --git a/dart/test/sync_test.dart b/dart/test/sync_test.dart index d17a10ab..34c1d5a4 100644 --- a/dart/test/sync_test.dart +++ b/dart/test/sync_test.dart @@ -63,13 +63,29 @@ void _syncTests({ return jsonDecode(row.columnAt(0)); } + bool establishesSyncStream(List instructions) { + return instructions.any((instruction) => + instruction is Map && + instruction.containsKey('EstablishSyncStream')); + } + List invokeControl(String operation, Object? data) { + List result; if (matcher.enabled) { // Trace through golden matcher - return matcher.invoke(operation, data); + result = matcher.invoke(operation, data); } else { - return invokeControlRaw(operation, data); + result = invokeControlRaw(operation, data); } + + if (operation == 'start' && establishesSyncStream(result)) { + final seedResult = matcher.enabled + ? matcher.invoke('seed_checkpoint_request_id', null) + : invokeControlRaw('seed_checkpoint_request_id', null); + return [...result, ...seedResult]; + } + + return result; } setUp(() async { @@ -141,24 +157,23 @@ void _syncTests({ return rows.isEmpty ? null : rows.single.columnAt(0); } - int nextCheckpointRequestId() { - db.execute('begin'); + Object? streamLastCheckpointRequestId(List instructions) { + final instruction = instructions.whereType().firstWhere( + (instruction) => instruction.containsKey('EstablishSyncStream')); + final establish = instruction['EstablishSyncStream'] as Map; + return establish['last_checkpoint_request_id']; + } - try { - final [row] = db.select('SELECT powersync_next_checkpoint_request_id()'); - db.execute('commit'); - return row.columnAt(0) as int; - } catch (e) { - db.execute('rollback'); - rethrow; - } + int nextCheckpointRequestId() { + final [instruction] = invokeControl('next_checkpoint_request_id', null); + final data = (instruction as Map)['CheckpointRequestId'] as Map; + return data['request_id'] as int; } Object? probeLocalTargetOp([Object? opId]) { - final [row] = db.select('SELECT powersync_probe_local_target_op(?)', [ - opId, - ]); - return row.columnAt(0); + final [instruction] = invokeControl('local_target_op', opId); + final data = (instruction as Map)['LocalTargetOp'] as Map; + return data['target_op']; } ResultSet fetchRows() { @@ -206,12 +221,15 @@ void _syncTests({ }); syncTest('app_metadata is passed to EstablishSyncStream request', (_) { - final startInstructions = invokeControlRaw( - 'start', - json.encode({ - 'app_metadata': {'key1': 'value1', 'key2': 'value2'} - }), - ); + final startInstructions = [ + ...invokeControlRaw( + 'start', + json.encode({ + 'app_metadata': {'key1': 'value1', 'key2': 'value2'} + }), + ), + ...invokeControlRaw('seed_checkpoint_request_id', null), + ]; expect( startInstructions, @@ -401,6 +419,8 @@ void _syncTests({ }); syncTest('allocates requested checkpoint request ids', (_) { + invokeControl('start', null); + expect(nextCheckpointRequestId(), 1); expect(lastRequestedCheckpointRequestId(), 1); @@ -408,16 +428,48 @@ void _syncTests({ expect(lastRequestedCheckpointRequestId(), 2); }); - syncTest('probes and updates local target op with checkpoint request id', (_) { + syncTest('seeds requested checkpoint request ids from service state', (_) { + final startInstructions = invokeControlRaw('start', null); + expect(streamLastCheckpointRequestId(startInstructions), isNull); + invokeControlRaw('seed_checkpoint_request_id', 41); + + expect(nextCheckpointRequestId(), 42); + expect(lastRequestedCheckpointRequestId(), 42); + + final restartInstructions = invokeControlRaw('start', null); + expect( + restartInstructions, + contains(containsPair('EstablishSyncStream', anything)), + ); + expect(streamLastCheckpointRequestId(restartInstructions), 42); + expect(invokeControlRaw('seed_checkpoint_request_id', 100), isEmpty); + + expect(nextCheckpointRequestId(), 101); + expect(lastRequestedCheckpointRequestId(), 101); + }); + + syncTest('requires checkpoint request state before allocating checkpoint ids', (_) { + invokeControlRaw('start', null); + + expect( + () => invokeControlRaw('next_checkpoint_request_id', null), + throwsA(isSqliteException( + 21, + contains('Checkpoint request state has not been seeded'), + )), + ); + expect(probeLocalTargetOp(), isNull); + }); + + syncTest('probes and updates local target op without sync iteration', (_) { expect(probeLocalTargetOp(), isNull); expect(probeLocalTargetOp(1), isNull); expect(lastRequestedCheckpointRequestId(), 1); expect(probeLocalTargetOp(), 1); + expect(probeLocalTargetOp(2), 1); expect(lastRequestedCheckpointRequestId(), 2); expect(probeLocalTargetOp(), 2); - - invokeControl('start', null); }); syncTest('accepts text checkpoint request ids for local target op', (_) { diff --git a/docs/sync.md b/docs/sync.md index e9595d3e..74143cab 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -10,7 +10,7 @@ The function should always be called in a transaction. The following commands are supported: 1. `start`: Payload is a JSON-encoded object. This requests the client to start a sync iteration. - The payload can either be `null` or an JSON object with: + The payload can either be `null` or a JSON object with: - An optional `parameters: Record` entry, specifying parameters to include in the request to the sync service. - A `schema: { tables: Table[], raw_tables: RawTable[] }` entry specifying the schema of the database to @@ -26,36 +26,46 @@ The following commands are supported: - The client will emit an instruction to stop the current stream, clients should restart by sending another `start` command. 6. `completed_upload`: Notify the sync implementation that all local changes have been uploaded. -7. `update_subscriptions`: Notify the sync implementation that subscriptions which are currently active in the app - have changed. Depending on the TTL of caches, this may cause it to request a reconnect. +7. `update_subscriptions`: Payload is a JSON-encoded array of + `{name: string, params: Record}`. Notify the sync implementation that subscriptions + which are currently active in the app have changed. Depending on the TTL of caches, this may + cause it to request a reconnect. 8. `connection`: Notify the sync implementation about the connection being opened (second parameter should be `established`) or the HTTP stream closing (second parameter should be `end`). This is used to set `connected` to true in the sync status without waiting for the first sync line. -9. `subscriptions`: Store a new sync steam subscription in the database or remove it. +9. `subscriptions`: Store a new sync stream subscription in the database or remove it. This command can run outside of a sync iteration and does not affect it. -10. `update_subscriptions`: Second parameter is a JSON-encoded array of `{name: string, params: Record}`. - If a new subscription is created, or when a subscription without a TTL has been removed, the client will ask to - restart the connection. +10. `next_checkpoint_request_id`: No payload. During an active sync iteration after checkpoint + request state exists locally, allocates and returns the next checkpoint request id in a + `CheckpointRequestId` instruction. +11. `local_target_op`: Payload is `null`, an integer, or an integer string. Probes, updates or + clears the local target op and returns the previously-observed value in a `LocalTargetOp` + result. This command can run outside of a sync iteration and does not affect it. +12. `seed_checkpoint_request_id`: Payload is `null`, an integer, or an integer string. After + receiving `EstablishSyncStream`, SDKs can re-request the client's last checkpoint request state + from the service using the provided hint, then seed core with the actual response for + reconciliation. `NULL` means the service has no record for the client yet; core stores `0` only + when no local seed exists. Integer seeds use `max(local, service)` semantics so the local counter + never moves backwards. When uploads request a write checkpoint, SDKs should call -`powersync_next_checkpoint_request_id()` inside a transaction to allocate the id to pass to the -request-checkpoint API. In checkpoint-request mode, the SDK should first allocate the id, then post -that id to the service, and then call `powersync_probe_local_target_op(id)` with the same id once -the service accepts the request. This sets the local target op to the request op, replacing the -pending-write sentinel with the concrete checkpoint request id that the sync stream can satisfy. -`powersync_next_checkpoint_request_id()` only advances the request counter; it does not update the +`powersync_control('next_checkpoint_request_id', NULL)` inside a transaction to allocate the id to +pass to the request-checkpoint API. In checkpoint-request mode, the SDK should first allocate the id, +then post that id to the service, and then call `powersync_control('local_target_op', id)` with the +same id once the service accepts the request. This sets the local target op to the request op, +replacing the pending-write sentinel with the concrete checkpoint request id that the sync stream +can satisfy. `next_checkpoint_request_id` only advances the request counter; it does not update the local target op used to block applying downloaded rows. -`powersync_probe_local_target_op(op_id)` reads and optionally updates the internal local target op. -The same function is used for compatibility when a new SDK is used with an older PowerSync service -that does not yet support client-created checkpoint requests; after the service-side write -checkpoint request returns a concrete id, call `powersync_probe_local_target_op(id)` with that id. -Pass `NULL` to probe the current internal `$local` target op from `ps_kv` without updating it, or -pass an integer or integer string to update that target op. In both cases it returns the value from -before the call, or `NULL` if no value existed. Updating to a positive, non-sentinel target op also -stores it as `last_requested_checkpoint_request_id` to support migrating to client-created -checkpoint requests. Passing `0` clears the local target, and sentinel values such as max op id are -not stored as requested checkpoint ids. +`powersync_control('local_target_op', op_id)` probes and optionally updates the internal local +target op. The same command is used for compatibility when a new SDK is used with an older +PowerSync service that does not yet support client-created checkpoint requests; after the +service-side write checkpoint request returns a concrete id, call +`powersync_control('local_target_op', id)` with that id. Passing `NULL` returns the current target +without changing it, and passing `0` clears the local target. Updating to a positive, non-sentinel +target op also stores it as `last_requested_checkpoint_request_id` to support migrating to +client-created checkpoint requests. Sentinel values such as max op id are not stored as requested +checkpoint ids. Database migration v14 moves legacy `$local` checkpoint state into `ps_kv`: `$local.last_applied_op` becomes `last_applied_checkpoint_request_id`, `$local.last_op` becomes the internal @@ -74,7 +84,7 @@ id to wait for yet. The max-op sentinel may also cover earlier pending uploads t associated with legacy service-created write checkpoints, so restarting the client-created counter from `1` could create a target lower than those existing associations. In that state, create one old-style write checkpoint first, store the returned concrete id with -`powersync_probe_local_target_op(id)`, and then switch to client-created checkpoint requests. +`powersync_control('local_target_op', id)`, and then switch to client-created checkpoint requests. `powersync_control` returns a JSON-encoded array of instructions for the client: @@ -83,6 +93,8 @@ type Instruction = { LogLine: LogLine } | { UpdateSyncStatus: UpdateSyncStatus } | { EstablishSyncStream: EstablishSyncStream } | { FetchCredentials: FetchCredentials } + | { CheckpointRequestId: { request_id: number } } + | { LocalTargetOp: { target_op: null | number } } // Close a connection previously started after EstablishSyncStream | { CloseSyncStream: { hide_disconnect: boolean } } // For the Dart web client, flush the (otherwise non-durable) file system. @@ -97,8 +109,14 @@ interface LogLine { } // Instructs client SDKs to open a connection to the sync service. +// last_checkpoint_request_id is core's local counter value before this stream request. On connect, +// SDKs can use it to re-request this client's last checkpoint request state from the service, then +// call powersync_control('seed_checkpoint_request_id', value) with the actual response for +// reconciliation. `value` may be null when the service has no checkpoint request state for this +// client. interface EstablishSyncStream { request: any // The JSON-encoded StreamingSyncRequest to send to the sync service + last_checkpoint_request_id: null | number } // Instructs SDKS to update the downloading state of their SyncStatus. diff --git a/docs/write-checkpoint-requests.md b/docs/write-checkpoint-requests.md index a03d9356..7edf54c4 100644 --- a/docs/write-checkpoint-requests.md +++ b/docs/write-checkpoint-requests.md @@ -13,10 +13,11 @@ At a high level: the client. SDKs should not write these keys directly. They update the local target through -`powersync_probe_local_target_op()`, which is the shared helper for both legacy write checkpoints -and new client-created checkpoint requests. The newer `powersync_next_checkpoint_request_id()` -function only allocates a checkpoint request id; after the service accepts that request, the SDK -uses `powersync_probe_local_target_op(id)` to make the accepted id the local target. +`powersync_control('local_target_op', value)`, which is the shared helper for both legacy write +checkpoints and new client-created checkpoint requests. The +`powersync_control('next_checkpoint_request_id', NULL)` command only allocates a checkpoint request +id; after the service accepts that request, the SDK uses +`powersync_control('local_target_op', id)` to make the accepted id the local target. For the historic `$local` bucket flow, see `historic-write-checkpoints.md`. @@ -47,9 +48,9 @@ transaction { deleteUploadedCrud(upTo: lastUploadedId) if let customCheckpoint, crudQueueIsEmpty { - powersync_probe_local_target_op(customCheckpoint) + powersync_control('local_target_op', customCheckpoint) } else { - powersync_probe_local_target_op(MAX_OP_ID) + powersync_control('local_target_op', MAX_OP_ID) } } ``` @@ -57,21 +58,25 @@ transaction { ## Updating the local target Once uploads are complete, the sync client updates the local target through -`powersync_probe_local_target_op()`. It only does this when the current target is still +`powersync_control('local_target_op', value)`. It only does this when the current target is still `MAX_OP_ID`, which avoids overwriting a custom checkpoint that was already stored by `complete(writeCheckpoint:)`. The SDK implementation: -1. Probes the current target with `powersync_probe_local_target_op(NULL)`. +1. Probes the current target with `powersync_control('local_target_op', NULL)`. 2. Reads `sqlite_sequence.seq` for `ps_crud`. 3. Gets a concrete checkpoint id from either the new or legacy service API. 4. Re-enters a write transaction. 5. Verifies that `ps_crud` is still empty and that its sequence did not change. -6. Stores the concrete target with `powersync_probe_local_target_op(opId)`. +6. Stores the concrete target with `powersync_control('local_target_op', opId)`. ```text -if powersync_probe_local_target_op(NULL) == MAX_OP_ID { +let previousTarget = transaction { + powersync_control('local_target_op', NULL).LocalTargetOp.target_op +} + +if previousTarget == MAX_OP_ID { let seqBefore = psCrudSequence() let checkpointId = await createOrFetchCheckpointId() @@ -80,7 +85,7 @@ if powersync_probe_local_target_op(NULL) == MAX_OP_ID { return } - powersync_probe_local_target_op(checkpointId) + powersync_control('local_target_op', checkpointId) } } ``` @@ -88,11 +93,11 @@ if powersync_probe_local_target_op(NULL) == MAX_OP_ID { In checkpoint-request mode, `getWriteCheckpoint()` calls `requestCheckpoint()`. That allocates an id locally, sends it to `/sync/checkpoint-request`, and returns the same id once the service accepts the request. Only then does the upload path store that id as `local_target_op` with -`powersync_probe_local_target_op(id)`. +`powersync_control('local_target_op', id)`. ```text let requestId = transaction { - powersync_next_checkpoint_request_id() + powersync_control('next_checkpoint_request_id', NULL).CheckpointRequestId.request_id } POST /sync/checkpoint-request { @@ -104,15 +109,26 @@ return requestId ``` The legacy fallback still calls `/write-checkpoint2.json`; the returned write checkpoint is stored -through the same `powersync_probe_local_target_op(opId)` helper. This keeps SDK target updates +through the same `powersync_control('local_target_op', opId)` helper. This keeps SDK target updates consistent across both protocols. -## Helper functions +## Sync control commands + +These `powersync_control` commands are the SDK-facing API for the new `ps_kv` checkpoint state. -These SQL functions are the SDK-facing API for the new `ps_kv` checkpoint state. +`powersync_control('start', payload)` begins a sync iteration and emits `EstablishSyncStream` with a +`last_checkpoint_request_id` hint. This is core's local `last_requested_checkpoint_request_id` value +before opening the stream, or `NULL` when no local seed exists. On connect, SDKs can use this hint +to re-request the client's last checkpoint request state from the service, then call +`powersync_control('seed_checkpoint_request_id', value)` with the actual response for +reconciliation. `value` may be `NULL` when the service has no record for the client; core stores `0` +only when no local seed exists. Integer seeds use `max(local, service)` semantics so the local +counter never moves backwards. SDKs may also refresh service state when their user/client context +changes. -`powersync_next_checkpoint_request_id()` must be called inside a transaction. It increments and -returns `last_requested_checkpoint_request_id` in `ps_kv`. +`powersync_control('next_checkpoint_request_id', NULL)` must be called inside a transaction during +an active sync iteration after `last_requested_checkpoint_request_id` exists locally. It increments +and returns `last_requested_checkpoint_request_id` in a `CheckpointRequestId` instruction. ```sql INSERT INTO ps_kv(key, value) @@ -121,16 +137,18 @@ ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1 RETURNING value; ``` -This function only allocates an id. It does not update `local_target_op`. +This command only allocates an id. It does not update `local_target_op`. Note on sequences: SQLite does not have standalone sequences. The sequence-like alternatives are either an `AUTOINCREMENT` table backed by SQLite's internal `sqlite_sequence`, or a dedicated single-row counter table like the existing `ps_tx` transaction counter. The checkpoint request counter currently lives in `ps_kv` because it is also migrated and seeded from legacy/custom -concrete targets via `powersync_probe_local_target_op()`. If we want stricter structure later, a -dedicated checkpoint-request counter table would be the closest match to a sequence. +concrete targets via `powersync_control('local_target_op', value)`. If we want stricter structure +later, a dedicated checkpoint-request counter table would be the closest match to a sequence. -`powersync_probe_local_target_op(op_id)` reads and optionally updates the local target: +`powersync_control('local_target_op', op_id)` probes and optionally updates the local target. Like +`subscriptions`, this command is handled directly by `powersync_control` and can run outside an +active sync iteration: - `NULL` returns the current `local_target_op` without changing it. - `0` clears `local_target_op`. @@ -138,7 +156,8 @@ dedicated checkpoint-request counter table would be the closest match to a seque - A positive value other than `i64::MAX` also stores `last_requested_checkpoint_request_id`. - Negative values and non-integer inputs are rejected. -The function returns the previous target value, or `NULL` if there was no target. +The command returns the previous target value in a `LocalTargetOp` result, or `NULL` if there was no +target. ```text previous = ps_kv['local_target_op'] @@ -228,9 +247,9 @@ request could not be delivered to the service or observed in the sync stream. pending, a concrete checkpoint request id after upload completion, or absent when there is no local write gate. - `last_requested_checkpoint_request_id`: The last client-created checkpoint request id allocated - by `powersync_next_checkpoint_request_id()`. `powersync_probe_local_target_op()` also writes this - key for positive, non-sentinel targets so migrated or legacy-created concrete targets can seed the - client request counter. + by `powersync_control('next_checkpoint_request_id', NULL)`. + `powersync_control('local_target_op', value)` also writes this key for positive, non-sentinel + targets so migrated or legacy-created concrete targets can seed the client request counter. - `last_seen_checkpoint_request_id`: The latest full checkpoint `write_checkpoint` observed and validated from the sync stream. - `last_applied_checkpoint_request_id`: The latest full checkpoint `write_checkpoint` that has been @@ -256,7 +275,7 @@ the gate; it does not prove that no earlier uploads were already associated with service-created write checkpoints. Those existing checkpoint ids may be higher than a restarted client counter such as `1`, and using a lower target could let an older seen checkpoint satisfy the gate too early. In that state, the SDK should create one legacy write checkpoint first, store the -concrete id with `powersync_probe_local_target_op(id)`, and then switch to client-created +concrete id with `powersync_control('local_target_op', id)`, and then switch to client-created checkpoint requests. The down migration rebuilds a `$local` row only when `local_target_op` exists, using: From 0e11137723fd11b887226fb98f653a9b2b0f8088 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 2 Jul 2026 13:33:43 +0200 Subject: [PATCH 6/8] drop target_op column in migrations --- crates/core/src/migrations.rs | 43 +++++++++++++++++++++++++ dart/test/migration_test.dart | 13 +++++++- dart/test/utils/fix_035_fixtures.dart | 12 +++---- dart/test/utils/migration_fixtures.dart | 16 +++++++-- docs/schema.md | 20 +++++++----- docs/sync.md | 8 +++-- docs/write-checkpoint-requests.md | 7 +++- 7 files changed, 97 insertions(+), 22 deletions(-) diff --git a/crates/core/src/migrations.rs b/crates/core/src/migrations.rs index 04996084..6d6bbfd4 100644 --- a/crates/core/src/migrations.rs +++ b/crates/core/src/migrations.rs @@ -504,6 +504,8 @@ SELECT 'local_target_op', target_op FROM ps_buckets WHERE name = '$local' AND target_op > 0; + +ALTER TABLE ps_buckets DROP COLUMN target_op; "; local_db.exec_safe(up).into_db_result(local_db)?; @@ -519,6 +521,47 @@ SELECT 'local_target_op', target_op // implementation also didn't have a `$local` bucket unless there was local target state to // track. const DOWN_STATEMENTS: &[&str] = &[ + "ALTER TABLE ps_buckets RENAME TO ps_buckets_14", + "DROP INDEX ps_buckets_name", + "CREATE TABLE ps_buckets( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + last_applied_op INTEGER NOT NULL DEFAULT 0, + last_op INTEGER NOT NULL DEFAULT 0, + target_op INTEGER NOT NULL DEFAULT 0, + add_checksum INTEGER NOT NULL DEFAULT 0, + op_checksum INTEGER NOT NULL DEFAULT 0, + pending_delete INTEGER NOT NULL DEFAULT 0 +) STRICT", + "CREATE UNIQUE INDEX ps_buckets_name ON ps_buckets (name)", + "ALTER TABLE ps_buckets ADD COLUMN count_at_last INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE ps_buckets ADD COLUMN count_since_last INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE ps_buckets ADD COLUMN downloaded_size INTEGER NOT NULL DEFAULT 0", + "INSERT INTO ps_buckets( + id, + name, + last_applied_op, + last_op, + add_checksum, + op_checksum, + pending_delete, + count_at_last, + count_since_last, + downloaded_size +) +SELECT + id, + name, + last_applied_op, + last_op, + add_checksum, + op_checksum, + pending_delete, + count_at_last, + count_since_last, + downloaded_size +FROM ps_buckets_14", + "DROP TABLE ps_buckets_14", "INSERT INTO ps_buckets(name, pending_delete, last_op, last_applied_op, target_op) SELECT '$local', 1, seen, applied, target FROM ( diff --git a/dart/test/migration_test.dart b/dart/test/migration_test.dart index 5c3256d5..771ccd77 100644 --- a/dart/test/migration_test.dart +++ b/dart/test/migration_test.dart @@ -112,6 +112,17 @@ VALUES(1, '$local', 5, 6, 7, 0, 0, 1, 0, 0, 0); {'key': 'last_applied_checkpoint_request_id', 'value': 5}, {'key': 'local_target_op', 'value': 7}, ])); + expect( + db + .select("PRAGMA table_info('ps_buckets')") + .map((row) => row['name']), + isNot(contains('target_op')), + ); + expect( + () => db.execute( + r"UPDATE ps_buckets SET target_op = 8 WHERE name = '$local'"), + throwsA(isA()), + ); }); test('does not migrate last applied op as requested checkpoint id', @@ -130,8 +141,8 @@ VALUES(1, '$local', 5, 6, 9223372036854775807, 0, 0, 1, 0, 0, 0); // checkpoint counter. The sentinel target is preserved for blocking, but is not concrete // enough to become last_requested_checkpoint_request_id. expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [ - {'key': 'last_seen_checkpoint_request_id', 'value': 6}, {'key': 'last_applied_checkpoint_request_id', 'value': 5}, + {'key': 'last_seen_checkpoint_request_id', 'value': 6}, {'key': 'local_target_op', 'value': 9223372036854775807}, ]); }); diff --git a/dart/test/utils/fix_035_fixtures.dart b/dart/test/utils/fix_035_fixtures.dart index fa912e89..0b600a90 100644 --- a/dart/test/utils/fix_035_fixtures.dart +++ b/dart/test/utils/fix_035_fixtures.dart @@ -18,9 +18,9 @@ const dataBroken = ''' /// Data after applying the migration fix, but before sync_local const dataMigrated = ''' -;INSERT INTO ps_buckets(id, name, last_applied_op, last_op, target_op, add_checksum, op_checksum, pending_delete, count_at_last, count_since_last, downloaded_size) VALUES - (1, 'b1', 0, 0, 0, 0, 120, 0, 0, 0, 0), - (2, 'b2', 0, 0, 0, 0, 3, 0, 0, 0, 0) +;INSERT INTO ps_buckets(id, name, last_applied_op, last_op, add_checksum, op_checksum, pending_delete, count_at_last, count_since_last, downloaded_size) VALUES + (1, 'b1', 0, 0, 0, 120, 0, 0, 0, 0), + (2, 'b2', 0, 0, 0, 3, 0, 0, 0, 0) ;INSERT INTO ps_oplog(bucket, op_id, row_type, row_id, key, data, hash) VALUES (1, 1, 'todos', 't1', '', '{}', 100), (1, 2, 'todos', 't2', '', '{}', 20), @@ -39,9 +39,9 @@ const dataMigrated = ''' /// Data after applying the migration fix and sync_local const dataFixed = ''' -;INSERT INTO ps_buckets(id, name, last_applied_op, last_op, target_op, add_checksum, op_checksum, pending_delete, count_at_last, count_since_last, downloaded_size) VALUES - (1, 'b1', 3, 3, 0, 0, 120, 0, 1, 0, 0), - (2, 'b2', 3, 3, 0, 0, 3, 0, 1, 0, 0) +;INSERT INTO ps_buckets(id, name, last_applied_op, last_op, add_checksum, op_checksum, pending_delete, count_at_last, count_since_last, downloaded_size) VALUES + (1, 'b1', 3, 3, 0, 120, 0, 1, 0, 0), + (2, 'b2', 3, 3, 0, 3, 0, 1, 0, 0) ;INSERT INTO ps_oplog(bucket, op_id, row_type, row_id, key, data, hash) VALUES (1, 1, 'todos', 't1', '', '{}', 100), (1, 2, 'todos', 't2', '', '{}', 20), diff --git a/dart/test/utils/migration_fixtures.dart b/dart/test/utils/migration_fixtures.dart index 555d4681..7f6e6ba8 100644 --- a/dart/test/utils/migration_fixtures.dart +++ b/dart/test/utils/migration_fixtures.dart @@ -541,8 +541,8 @@ Map _expectedState() { ;INSERT INTO ps_migration(id, down_migrations) VALUES(12, '[{"sql":"ALTER TABLE ps_buckets DROP COLUMN downloaded_size"},{"sql":"DELETE FROM ps_migration WHERE id >= 12"}]') ;INSERT INTO ps_migration(id, down_migrations) VALUES(13, '[{"sql":"UPDATE ps_stream_subscriptions SET expires_at = expires_at / 1000000, last_synced_at = last_synced_at / 1000000"},{"sql":"ALTER TABLE ps_sync_state RENAME TO ps_sync_state_new"},{"sql":"CREATE TABLE ps_sync_state (\n priority INTEGER NOT NULL PRIMARY KEY,\n last_synced_at TEXT NOT NULL\n) STRICT;"},{"sql":"INSERT INTO ps_sync_state (priority, last_synced_at) SELECT priority, datetime(last_synced_at / 1000000, ''unixepoch'') FROM ps_sync_state_new"},{"sql":"DROP TABLE ps_sync_state_new"},{"sql":"DELETE FROM ps_migration WHERE id >= 13"}]')''', }; - state[14] = '''${state[13]!.trim()} -;INSERT INTO ps_migration(id, down_migrations) VALUES(14, '[{"sql":"INSERT INTO ps_buckets(name, pending_delete, last_op, last_applied_op, target_op)\\nSELECT ''\$local'', 1, seen, applied, target\\n FROM (\\n SELECT\\n IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''last_seen_checkpoint_request_id''), 0) AS seen,\\n IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''last_applied_checkpoint_request_id''), 0) AS applied,\\n (SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''local_target_op'') AS target\\n )\\n WHERE EXISTS (\\n SELECT 1 FROM ps_kv WHERE key = ''local_target_op''\\n )\\nON CONFLICT(name) DO UPDATE SET\\n pending_delete = excluded.pending_delete,\\n last_op = excluded.last_op,\\n last_applied_op = excluded.last_applied_op,\\n target_op = excluded.target_op"},{"sql":"DELETE FROM ps_migration WHERE id >= 14"}]')'''; + state[14] = '''${state[13]!.trim().replaceFirst(' target_op INTEGER NOT NULL DEFAULT 0,\n', '')} +;INSERT INTO ps_migration(id, down_migrations) VALUES(14, '[{"sql":"ALTER TABLE ps_buckets RENAME TO ps_buckets_14"},{"sql":"DROP INDEX ps_buckets_name"},{"sql":"CREATE TABLE ps_buckets(\\n id INTEGER PRIMARY KEY,\\n name TEXT NOT NULL,\\n last_applied_op INTEGER NOT NULL DEFAULT 0,\\n last_op INTEGER NOT NULL DEFAULT 0,\\n target_op INTEGER NOT NULL DEFAULT 0,\\n add_checksum INTEGER NOT NULL DEFAULT 0,\\n op_checksum INTEGER NOT NULL DEFAULT 0,\\n pending_delete INTEGER NOT NULL DEFAULT 0\\n) STRICT"},{"sql":"CREATE UNIQUE INDEX ps_buckets_name ON ps_buckets (name)"},{"sql":"ALTER TABLE ps_buckets ADD COLUMN count_at_last INTEGER NOT NULL DEFAULT 0"},{"sql":"ALTER TABLE ps_buckets ADD COLUMN count_since_last INTEGER NOT NULL DEFAULT 0"},{"sql":"ALTER TABLE ps_buckets ADD COLUMN downloaded_size INTEGER NOT NULL DEFAULT 0"},{"sql":"INSERT INTO ps_buckets(\\n id,\\n name,\\n last_applied_op,\\n last_op,\\n add_checksum,\\n op_checksum,\\n pending_delete,\\n count_at_last,\\n count_since_last,\\n downloaded_size\\n)\\nSELECT\\n id,\\n name,\\n last_applied_op,\\n last_op,\\n add_checksum,\\n op_checksum,\\n pending_delete,\\n count_at_last,\\n count_since_last,\\n downloaded_size\\nFROM ps_buckets_14"},{"sql":"DROP TABLE ps_buckets_14"},{"sql":"INSERT INTO ps_buckets(name, pending_delete, last_op, last_applied_op, target_op)\\nSELECT ''\$local'', 1, seen, applied, target\\n FROM (\\n SELECT\\n IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''last_seen_checkpoint_request_id''), 0) AS seen,\\n IFNULL((SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''last_applied_checkpoint_request_id''), 0) AS applied,\\n (SELECT CAST(value AS INTEGER) FROM ps_kv WHERE key = ''local_target_op'') AS target\\n )\\n WHERE EXISTS (\\n SELECT 1 FROM ps_kv WHERE key = ''local_target_op''\\n )\\nON CONFLICT(name) DO UPDATE SET\\n pending_delete = excluded.pending_delete,\\n last_op = excluded.last_op,\\n last_applied_op = excluded.last_applied_op,\\n target_op = excluded.target_op"},{"sql":"DELETE FROM ps_migration WHERE id >= 14"}]')'''; return state; } @@ -683,7 +683,17 @@ Map _data1() { ('lists', 'l2') ''' }; - data[14] = data[13]!; + data[14] = r''' +;INSERT INTO ps_buckets(id, name, last_applied_op, last_op, add_checksum, op_checksum, pending_delete, count_at_last, count_since_last, downloaded_size) VALUES + (1, 'b1', 0, 0, 0, 120, 0, 0, 0, 0), + (2, 'b2', 0, 0, 1005, 3, 0, 0, 0, 0) +;INSERT INTO ps_oplog(bucket, op_id, row_type, row_id, key, data, hash) VALUES + (1, 1, 'todos', 't1', '', '{}', 100), + (1, 2, 'todos', 't2', '', '{}', 20), + (2, 3, 'lists', 'l1', '', '{}', 3) +;INSERT INTO ps_updated_rows(row_type, row_id) VALUES + ('lists', 'l2') +'''; return data; } diff --git a/docs/schema.md b/docs/schema.md index f7dfbde3..1955bab1 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -15,8 +15,8 @@ A bucket is instantiated for every row returned by a parameter query in a [bucke Clients create entries in `ps_buckets` when receiving a checkpoint message from the sync service, they are also responsible for removing buckets that are no longer relevant to the client. -There is also a special `$local` bucket representing pending -uploads. +Older schema versions also used a special `$local` bucket to represent pending uploads. Current +schema versions keep that local write gate in `ps_kv` instead. We store the following information in `ps_buckets`: @@ -24,12 +24,16 @@ We store the following information in `ps_buckets`: 2. `name`: The name of the bucket as received from the sync service. 3. `last_applied_op`: The last operation id that has been verified and published to views (meaning that it was part of a checkpoint and that we have validated its checksum). -4. `target_op`: Only used for `$local`. TODO: Document further. -5. `add_checksum`: TODO: Document further. -6. `op_checksum`: TODO: Document further. -7. `pending_delete`: TODO: Appears to be unused, document further. -8. `count_at_last`: The amount of operations in the bucket at the last verified checkpoint. -9. `count_since_last`: The amount of operations downloaded since the last verified checkpoint. +4. `add_checksum`: TODO: Document further. +5. `op_checksum`: TODO: Document further. +6. `pending_delete`: TODO: Appears to be unused, document further. +7. `count_at_last`: The amount of operations in the bucket at the last verified checkpoint. +8. `count_since_last`: The amount of operations downloaded since the last verified checkpoint. + +Schema version 14 removes the legacy `target_op` column after migrating `$local.target_op` to +`ps_kv.local_target_op`. This makes older SDKs fail with a hard SQLite error if they try to keep +using the migrated database without downgrading. The down migration restores `target_op` for older +schema versions. ## `ps_crud` diff --git a/docs/sync.md b/docs/sync.md index 74143cab..81fe426d 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -70,9 +70,11 @@ checkpoint ids. Database migration v14 moves legacy `$local` checkpoint state into `ps_kv`: `$local.last_applied_op` becomes `last_applied_checkpoint_request_id`, `$local.last_op` becomes the internal `last_seen_checkpoint_request_id`, a concrete `$local.target_op` advances the request counter, and -`$local.target_op` is stored as `local_target_op`. Downgrading restores a `$local` row only when -`local_target_op` exists, so older SDKs can keep using target-op based blocking without inventing a -synthetic local bucket when there was no local target state. +`$local.target_op` is stored as `local_target_op`. The migration then drops `ps_buckets.target_op` +so older SDKs fail hard if they try to keep using the migrated database directly. Downgrading +restores the column, and restores a `$local` row only when `local_target_op` exists, so older SDKs +can keep using target-op based blocking without inventing a synthetic local bucket when there was no +local target state. If `local_target_op` is absent after migration, there is no local write gate waiting for a checkpoint. In that case, SDKs can start client-created checkpoint requests normally, even when diff --git a/docs/write-checkpoint-requests.md b/docs/write-checkpoint-requests.md index 7edf54c4..02d85cff 100644 --- a/docs/write-checkpoint-requests.md +++ b/docs/write-checkpoint-requests.md @@ -264,6 +264,10 @@ Migration v14 moves the old `$local` bucket state into `ps_kv`: - A concrete `$local.target_op` becomes `last_requested_checkpoint_request_id`. - Any positive `$local.target_op`, including `MAX_OP_ID`, becomes `local_target_op`. +After copying this state, the migration drops `ps_buckets.target_op`. This intentionally makes older +SDKs fail with a hard SQLite error if they try to keep using a migrated database without first +downgrading. + An absent `local_target_op` is safe: there is no local write gate waiting for a checkpoint, so an SDK can start client-created checkpoint requests from `1`. The sync stream will only report that request id after the service has accepted and reached it. @@ -278,7 +282,8 @@ gate too early. In that state, the SDK should create one legacy write checkpoint concrete id with `powersync_control('local_target_op', id)`, and then switch to client-created checkpoint requests. -The down migration rebuilds a `$local` row only when `local_target_op` exists, using: +The down migration restores `ps_buckets.target_op` and rebuilds a `$local` row only when +`local_target_op` exists, using: - `last_seen_checkpoint_request_id` as `$local.last_op` - `last_applied_checkpoint_request_id` as `$local.last_applied_op` From 69bf1d3eb8c217925befd28685ff4078ae63b94f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 2 Jul 2026 14:13:13 +0200 Subject: [PATCH 7/8] use powersync control response instruction for last applied checkpoint request instead of sync status --- crates/core/src/sync/interface.rs | 2 + crates/core/src/sync/storage_adapter.rs | 4 - crates/core/src/sync/streaming_sync.rs | 6 +- crates/core/src/sync/sync_status.rs | 18 +--- dart/test/goldens/simple_iteration.json | 12 +-- dart/test/goldens/starting_stream.json | 3 +- dart/test/sync_test.dart | 126 +++++++++++++----------- docs/sync.md | 8 +- docs/write-checkpoint-requests.md | 17 ++-- 9 files changed, 97 insertions(+), 99 deletions(-) diff --git a/crates/core/src/sync/interface.rs b/crates/core/src/sync/interface.rs index 57e559e6..a673f353 100644 --- a/crates/core/src/sync/interface.rs +++ b/crates/core/src/sync/interface.rs @@ -151,6 +151,8 @@ pub enum Instruction { }, /// Return a newly allocated checkpoint request id to the SDK. CheckpointRequestId { request_id: i64 }, + /// Notify the SDK that a checkpoint request id has been applied locally. + CheckpointRequestApplied { request_id: i64 }, /// Return the local target op value observed before an optional update. LocalTargetOp { target_op: Option }, // These are defined like this because deserializers in Kotlin can't support either an diff --git a/crates/core/src/sync/storage_adapter.rs b/crates/core/src/sync/storage_adapter.rs index 34f0c3b3..d636f95f 100644 --- a/crates/core/src/sync/storage_adapter.rs +++ b/crates/core/src/sync/storage_adapter.rs @@ -121,9 +121,6 @@ impl StorageAdapter { items }; - let last_applied_checkpoint_request_id = - self.read_i64_kv(LAST_APPLIED_CHECKPOINT_REQUEST_ID_KEY)?; - let mut streams = Vec::new(); self.iterate_local_subscriptions(|sub| { streams.push(ActiveStreamSubscription::from_local(&sub)); @@ -135,7 +132,6 @@ impl StorageAdapter { priority_status: priority_items, downloading: None, streams, - last_applied_checkpoint_request_id, }) } diff --git a/crates/core/src/sync/streaming_sync.rs b/crates/core/src/sync/streaming_sync.rs index 116d92e7..a578dcac 100644 --- a/crates/core/src/sync/streaming_sync.rs +++ b/crates/core/src/sync/streaming_sync.rs @@ -970,15 +970,17 @@ impl StreamingSyncIteration { applied_checkpoint_request_id: Option, ) -> Result<(), PowerSyncError> { if let Some(request_id) = applied_checkpoint_request_id { - // Persisted so it can be restored into sync status when initializing a new client. self.adapter .persist_last_applied_checkpoint_request_id(request_id)?; + event + .instructions + .push(Instruction::CheckpointRequestApplied { request_id }); } event.instructions.push(Instruction::DidCompleteSync {}); self.status.update( - |status| status.applied_checkpoint(timestamp, applied_checkpoint_request_id), + |status| status.applied_checkpoint(timestamp), &mut event.instructions, ); diff --git a/crates/core/src/sync/sync_status.rs b/crates/core/src/sync/sync_status.rs index 7dba73f0..6530c460 100644 --- a/crates/core/src/sync/sync_status.rs +++ b/crates/core/src/sync/sync_status.rs @@ -53,8 +53,6 @@ pub struct DownloadSyncStatus { /// received), information about how far the download has progressed. pub downloading: Option, pub streams: Vec, - /// The latest checkpoint request id whose full checkpoint has been applied locally. - pub last_applied_checkpoint_request_id: Option, } impl DownloadSyncStatus { @@ -119,16 +117,9 @@ impl DownloadSyncStatus { self.debug_assert_priority_status_is_sorted(); } - pub fn applied_checkpoint( - &mut self, - now: TimestampMicros, - applied_checkpoint_request_id: Option, - ) { + pub fn applied_checkpoint(&mut self, now: TimestampMicros) { self.downloading = None; self.priority_status.clear(); - if let Some(request_id) = applied_checkpoint_request_id { - self.last_applied_checkpoint_request_id = Some(request_id); - } self.priority_status.push(SyncPriorityStatus { priority: BucketPriority::SENTINEL, @@ -146,7 +137,6 @@ impl Default for DownloadSyncStatus { downloading: None, priority_status: Vec::new(), streams: Vec::new(), - last_applied_checkpoint_request_id: None, } } } @@ -190,16 +180,12 @@ impl Serialize for DownloadSyncStatus { } } - let mut serializer = serializer.serialize_struct("DownloadSyncStatus", 6)?; + let mut serializer = serializer.serialize_struct("DownloadSyncStatus", 5)?; serializer.serialize_field("connected", &self.connected)?; serializer.serialize_field("connecting", &self.connecting)?; serializer.serialize_field("priority_status", &self.priority_status)?; serializer.serialize_field("downloading", &self.downloading)?; serializer.serialize_field("streams", &SerializeStreamsWithProgress(self))?; - serializer.serialize_field( - "last_applied_checkpoint_request_id", - &self.last_applied_checkpoint_request_id, - )?; serializer.end() } diff --git a/dart/test/goldens/simple_iteration.json b/dart/test/goldens/simple_iteration.json index c6c3ac21..e8eb7ff6 100644 --- a/dart/test/goldens/simple_iteration.json +++ b/dart/test/goldens/simple_iteration.json @@ -10,8 +10,7 @@ "connecting": true, "priority_status": [], "downloading": null, - "streams": [], - "last_applied_checkpoint_request_id": null + "streams": [] } } }, @@ -72,8 +71,7 @@ } } }, - "streams": [], - "last_applied_checkpoint_request_id": null + "streams": [] } } } @@ -123,8 +121,7 @@ } } }, - "streams": [], - "last_applied_checkpoint_request_id": null + "streams": [] } } } @@ -163,8 +160,7 @@ } ], "downloading": null, - "streams": [], - "last_applied_checkpoint_request_id": null + "streams": [] } } } diff --git a/dart/test/goldens/starting_stream.json b/dart/test/goldens/starting_stream.json index bc9b1436..b63f2a03 100644 --- a/dart/test/goldens/starting_stream.json +++ b/dart/test/goldens/starting_stream.json @@ -14,8 +14,7 @@ "connecting": true, "priority_status": [], "downloading": null, - "streams": [], - "last_applied_checkpoint_request_id": null + "streams": [] } } }, diff --git a/dart/test/sync_test.dart b/dart/test/sync_test.dart index 34c1d5a4..06046c3f 100644 --- a/dart/test/sync_test.dart +++ b/dart/test/sync_test.dart @@ -65,8 +65,7 @@ void _syncTests({ bool establishesSyncStream(List instructions) { return instructions.any((instruction) => - instruction is Map && - instruction.containsKey('EstablishSyncStream')); + instruction is Map && instruction.containsKey('EstablishSyncStream')); } List invokeControl(String operation, Object? data) { @@ -157,6 +156,12 @@ void _syncTests({ return rows.isEmpty ? null : rows.single.columnAt(0); } + Object? lastAppliedCheckpointRequestId() { + final rows = db.select( + "SELECT value FROM ps_kv WHERE key = 'last_applied_checkpoint_request_id'"); + return rows.isEmpty ? null : rows.single.columnAt(0); + } + Object? streamLastCheckpointRequestId(List instructions) { final instruction = instructions.whereType().firstWhere( (instruction) => instruction.containsKey('EstablishSyncStream')); @@ -363,7 +368,15 @@ void _syncTests({ invokeControl('start', null); pushCheckpoint(buckets: priorityBuckets, writeCheckpoint: '1'); - pushCheckpointComplete(); + expect( + pushCheckpointComplete(), + contains( + containsPair( + 'CheckpointRequestApplied', + {'request_id': 1}, + ), + ), + ); controller.elapse(Duration(minutes: 10)); pushCheckpoint(buckets: priorityBuckets); @@ -378,23 +391,20 @@ void _syncTests({ 'UpdateSyncStatus', containsPair( 'status', - allOf( - containsPair('last_applied_checkpoint_request_id', 1), - containsPair( - 'priority_status', - [ - { - 'priority': 2, - 'last_synced_at': timestamp(), - 'has_synced': true - }, - { - 'priority': 2147483647, - 'last_synced_at': timestamp(plusMinutes: -10), - 'has_synced': true - }, - ], - ), + containsPair( + 'priority_status', + [ + { + 'priority': 2, + 'last_synced_at': timestamp(), + 'has_synced': true + }, + { + 'priority': 2147483647, + 'last_synced_at': timestamp(plusMinutes: -10), + 'has_synced': true + }, + ], ), )), ), @@ -404,7 +414,6 @@ void _syncTests({ expect(json.decode(row[0]), { 'connected': false, 'connecting': false, - 'last_applied_checkpoint_request_id': 1, 'priority_status': [ {'priority': 2, 'last_synced_at': timestamp(), 'has_synced': true}, { @@ -448,7 +457,8 @@ void _syncTests({ expect(lastRequestedCheckpointRequestId(), 101); }); - syncTest('requires checkpoint request state before allocating checkpoint ids', (_) { + syncTest('requires checkpoint request state before allocating checkpoint ids', + (_) { invokeControlRaw('start', null); expect( @@ -478,7 +488,8 @@ void _syncTests({ expect(probeLocalTargetOp(), 1); }); - syncTest('does not store non-request target ops as checkpoint request id', (_) { + syncTest('does not store non-request target ops as checkpoint request id', + (_) { expect(probeLocalTargetOp(0), isNull); expect(lastRequestedCheckpointRequestId(), isNull); expect(probeLocalTargetOp(), isNull); @@ -491,13 +502,13 @@ void _syncTests({ syncTest('does not persist placeholder checkpoint request id', (_) { db.execute("insert into items (id, col) values ('local', 'data');"); - invokeControl('start', null); + invokeControlRaw('start', null); expect(lastRequestedCheckpointRequestId(), isNull); }); syncTest( - 'does not mark checkpoint request id as synced for partial checkpoint', + 'does not emit applied checkpoint request id for partial checkpoint', (_) { invokeControl('start', null); @@ -506,55 +517,51 @@ void _syncTests({ expect( instructions, - contains( - containsPair( - 'UpdateSyncStatus', - containsPair( - 'status', - containsPair('last_applied_checkpoint_request_id', null), - ), - ), - ), + isNot(contains(containsPair('CheckpointRequestApplied', anything))), ); + expect(lastAppliedCheckpointRequestId(), isNull); final [row] = db.select('select powersync_offline_sync_status();'); expect( json.decode(row[0]), - containsPair('last_applied_checkpoint_request_id', null), + isNot(containsPair('last_applied_checkpoint_request_id', anything)), ); }, ); - syncTest('keeps synced checkpoint request id across normal checkpoints', (_) { + syncTest('emits applied checkpoint request id for full checkpoint', (_) { invokeControl('start', null); pushCheckpoint(buckets: priorityBuckets, writeCheckpoint: '1'); - pushCheckpointComplete(); - - pushCheckpoint(buckets: priorityBuckets); - final instructions = pushCheckpointComplete(); + final appliedInstructions = pushCheckpointComplete(); expect( - instructions, + appliedInstructions, contains( containsPair( - 'UpdateSyncStatus', - containsPair( - 'status', - containsPair('last_applied_checkpoint_request_id', 1), - ), + 'CheckpointRequestApplied', + {'request_id': 1}, ), ), ); + expect(lastAppliedCheckpointRequestId(), 1); + + pushCheckpoint(buckets: priorityBuckets); + final instructions = pushCheckpointComplete(); + + expect( + instructions, + isNot(contains(containsPair('CheckpointRequestApplied', anything))), + ); final [row] = db.select('select powersync_offline_sync_status();'); expect( json.decode(row[0]), - containsPair('last_applied_checkpoint_request_id', 1), + isNot(containsPair('last_applied_checkpoint_request_id', anything)), ); - expect(db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"), - isEmpty); + expect( + db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"), isEmpty); }); test('clearing database clears sync status', () { @@ -591,15 +598,18 @@ void _syncTests({ final request = invokeControl('start', null); expect( request, - contains(containsPair( - 'EstablishSyncStream', - { - // Should request state from before clear - 'request': containsPair('buckets', [ - {'name': 'a', 'after': '1'} - ]), - }, - )), + contains( + containsPair( + 'EstablishSyncStream', + containsPair( + // Should request state from before clear + 'request', + containsPair('buckets', [ + {'name': 'a', 'after': '1'} + ]), + ), + ), + ), ); pushCheckpoint(buckets: [bucketDescription('a', count: 1)]); diff --git a/docs/sync.md b/docs/sync.md index 81fe426d..0dbc5362 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -96,6 +96,7 @@ type Instruction = { LogLine: LogLine } | { EstablishSyncStream: EstablishSyncStream } | { FetchCredentials: FetchCredentials } | { CheckpointRequestId: { request_id: number } } + | { CheckpointRequestApplied: { request_id: number } } | { LocalTargetOp: { target_op: null | number } } // Close a connection previously started after EstablishSyncStream | { CloseSyncStream: { hide_disconnect: boolean } } @@ -128,7 +129,12 @@ interface UpdateSyncStatus { priority_status: [], downloading: null | DownloadProgress, streams: [], - last_applied_checkpoint_request_id: null | number, +} + +// Emitted when a full checkpoint with a write_checkpoint has been applied locally. +// SDKs can use this to resolve pending CheckpointRequest waiters. +interface CheckpointRequestApplied { + request_id: number, } // Instructs SDKs to refresh credentials from the backend connector. diff --git a/docs/write-checkpoint-requests.md b/docs/write-checkpoint-requests.md index 02d85cff..8e89fc42 100644 --- a/docs/write-checkpoint-requests.md +++ b/docs/write-checkpoint-requests.md @@ -211,11 +211,12 @@ on completed_upload: ``` After a full checkpoint applies, core stores the applied checkpoint request id as -`last_applied_checkpoint_request_id` and emits it in sync status. +`last_applied_checkpoint_request_id` and emits a `CheckpointRequestApplied` instruction. ```text after full checkpoint apply: ps_kv['last_applied_checkpoint_request_id'] = checkpoint.write_checkpoint + emit CheckpointRequestApplied { request_id: checkpoint.write_checkpoint } ``` ## Explicit checkpoint requests @@ -225,15 +226,14 @@ the local database has caught up to the service. This creates a checkpoint reque connected sync client and returns a `CheckpointRequest`. This explicit API does not update `local_target_op`: it is a wait marker, not a local upload gate. -The returned object waits until sync status reports `last_applied_checkpoint_request_id >= requestId`. +The returned object waits until core emits `CheckpointRequestApplied` for an id greater than or +equal to the requested id. ```text -isSynced = status.lastAppliedCheckpointRequestId >= requestId - waitForSync() { - for status in syncStatusUpdates { - return when status.lastAppliedCheckpointRequestId >= requestId - throw if status reports a sync error + for instruction in syncInstructions { + return when instruction.CheckpointRequestApplied.request_id >= requestId + throw if sync status reports a sync error } } ``` @@ -253,7 +253,8 @@ request could not be delivered to the service or observed in the sync stream. - `last_seen_checkpoint_request_id`: The latest full checkpoint `write_checkpoint` observed and validated from the sync stream. - `last_applied_checkpoint_request_id`: The latest full checkpoint `write_checkpoint` that has been - applied locally. SDKs expose this in sync status and use it to resolve `CheckpointRequest` waits. + applied locally. Core persists this for migration/downgrade state; SDKs should use + `CheckpointRequestApplied` instructions to resolve `CheckpointRequest` waits. ## Migration from `$local` From 12f58aa25e0c53c4ebdc0f2ba9b93c1d379a4a93 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 2 Jul 2026 18:04:58 +0200 Subject: [PATCH 8/8] remove dead macros. Update migrations to fix potential bugs. update doc entries --- crates/core/src/crud_vtab.rs | 9 +- crates/core/src/macros.rs | 46 --------- crates/core/src/migrations.rs | 35 ++++--- crates/core/src/sync/storage_adapter.rs | 22 ++-- dart/test/crud_test.dart | 10 +- dart/test/migration_test.dart | 57 +++++++++-- dart/test/sync_test.dart | 59 +++++++++-- docs/schema.md | 7 +- docs/sync.md | 56 ++++++---- docs/write-checkpoint-requests.md | 131 ++++++++++++++++-------- 10 files changed, 271 insertions(+), 161 deletions(-) diff --git a/crates/core/src/crud_vtab.rs b/crates/core/src/crud_vtab.rs index e14122a9..36936e3e 100644 --- a/crates/core/src/crud_vtab.rs +++ b/crates/core/src/crud_vtab.rs @@ -247,8 +247,15 @@ impl SimpleCrudTransactionMode { fn record_local_write(&mut self, db: *mut sqlite::sqlite3) -> Result<(), ResultCode> { if !self.had_writes { + // Also clear the seen/applied high-water marks: checkpoint request ids observed before + // this write can't acknowledge it, and they may come from an incompatible id namespace + // (legacy write checkpoints migrated by v14, or state from before a counter restart). + // Keeping them around could open the apply gate for a newly allocated target id that + // compares below a stale seen value. The legacy `$local` bookkeeping had the same + // behavior by resetting the entire row on local writes. db.exec_safe(formatcp!( - "INSERT OR REPLACE INTO ps_kv(key, value) VALUES('local_target_op', {MAX_OP_ID})" + "INSERT OR REPLACE INTO ps_kv(key, value) VALUES('local_target_op', {MAX_OP_ID}); +DELETE FROM ps_kv WHERE key IN ('last_seen_checkpoint_request_id', 'last_applied_checkpoint_request_id')" ))?; self.had_writes = true; } diff --git a/crates/core/src/macros.rs b/crates/core/src/macros.rs index 0bc8dc5a..4e60c953 100644 --- a/crates/core/src/macros.rs +++ b/crates/core/src/macros.rs @@ -43,49 +43,3 @@ macro_rules! create_sqlite_optional_text_fn { } }; } - -#[macro_export] -macro_rules! create_sqlite_int_fn { - ($fn_name:ident, $fn_impl_name:ident, $description:literal) => { - extern "C" fn $fn_name( - ctx: *mut sqlite::context, - argc: c_int, - argv: *mut *mut sqlite::value, - ) { - let args = sqlite::args!(argc, argv); - - let result = $fn_impl_name(ctx, args); - - if let Err(err) = result { - PowerSyncError::from(err).apply_to_ctx($description, ctx); - } else if let Ok(r) = result { - ctx.result_int64(r); - } - } - }; -} - -#[macro_export] -macro_rules! create_sqlite_optional_int_fn { - ($fn_name:ident, $fn_impl_name:ident, $description:literal) => { - extern "C" fn $fn_name( - ctx: *mut sqlite::context, - argc: c_int, - argv: *mut *mut sqlite::value, - ) { - let args = sqlite::args!(argc, argv); - - let result = $fn_impl_name(ctx, args); - - if let Err(err) = result { - PowerSyncError::from(err).apply_to_ctx($description, ctx); - } else if let Ok(r) = result { - if let Some(i) = r { - ctx.result_int64(i); - } else { - ctx.result_null(); - } - } - } - }; -} diff --git a/crates/core/src/migrations.rs b/crates/core/src/migrations.rs index 6d6bbfd4..afc1ae5a 100644 --- a/crates/core/src/migrations.rs +++ b/crates/core/src/migrations.rs @@ -468,18 +468,34 @@ DROP TABLE ps_sync_state_old; // `$local.last_op` represented the latest legacy write checkpoint seen in the sync stream, // so it becomes the last seen checkpoint request. // - // `$local.target_op` can either be a concrete checkpoint request id or a sentinel such as - // i64::MAX while local writes are pending. Store it separately as `local_target_op`, but - // only treat concrete values as requested checkpoint ids. We intentionally don't seed - // `last_requested_checkpoint_request_id` from `$local.last_applied_op` because that is an - // applied value, not necessarily the current requested target. + // `$local.target_op` can either be a concrete legacy write checkpoint id or a sentinel such + // as i64::MAX while local writes are pending. Store it separately as `local_target_op`. + // Seeding `last_requested_checkpoint_request_id` from a concrete target would be possible, + // but should be redundant because SDKs reconcile the request counter with service state on + // connect before advancing it through `next_checkpoint_request_id`. // // An absent local target can safely start client-created checkpoint requests from 1. The // ambiguous case is an existing max-op local target without a concrete requested id: // pending local writes may already be associated with legacy service-created write // checkpoints, so SDKs should bridge once through the legacy endpoint before starting // client-created checkpoint requests. + // + // This migration can also run on a database that was previously on version 14 and then + // downgraded: the down migration rebuilds the `$local` row from ps_kv but keeps the ps_kv + // keys around, and an older SDK may have advanced `$local` since. Clear the keys first so + // the `$local` row is the source of truth and the inserts below can't conflict. + // + // After copying, the `$local` row is deleted: version 14 tracks this state exclusively in + // ps_kv, so ps_buckets only contains real sync buckets. The down migration recreates the + // row from ps_kv when needed. let up = "\ +DELETE FROM ps_kv + WHERE key IN ( + 'last_applied_checkpoint_request_id', + 'last_seen_checkpoint_request_id', + 'local_target_op' + ); + INSERT INTO ps_kv(key, value) SELECT 'last_applied_checkpoint_request_id', last_applied_op FROM ps_buckets @@ -492,19 +508,14 @@ SELECT 'last_seen_checkpoint_request_id', last_op WHERE name = '$local' AND last_op > 0; -INSERT INTO ps_kv(key, value) -SELECT 'last_requested_checkpoint_request_id', target_op - FROM ps_buckets - WHERE name = '$local' - AND target_op > 0 - AND target_op != 9223372036854775807; - INSERT INTO ps_kv(key, value) SELECT 'local_target_op', target_op FROM ps_buckets WHERE name = '$local' AND target_op > 0; +DELETE FROM ps_buckets WHERE name = '$local'; + ALTER TABLE ps_buckets DROP COLUMN target_op; "; local_db.exec_safe(up).into_db_result(local_db)?; diff --git a/crates/core/src/sync/storage_adapter.rs b/crates/core/src/sync/storage_adapter.rs index d636f95f..d76f3efd 100644 --- a/crates/core/src/sync/storage_adapter.rs +++ b/crates/core/src/sync/storage_adapter.rs @@ -523,13 +523,15 @@ WHERE bucket = ?1", /// The target op can also be used internally as a sentinel value such as max op id while local /// writes are pending, so it must not always be interpreted as a checkpoint request id. /// - /// When the target op is a positive, non-sentinel checkpoint request id, it also updates - /// `last_requested_checkpoint_request_id` so clients can migrate from the legacy target-op - /// flow to client-created checkpoint requests. `0` clears the local target, and sentinel - /// values such as max op id must not update the last requested id. + /// This only updates the apply gate. It does not allocate, seed or overwrite + /// `last_requested_checkpoint_request_id`, which is managed by `seed_checkpoint_request_id` and + /// `next_checkpoint_request_id`. /// /// Returns the target op value from before this call. When `target_op` is `None`, this only /// reads the current value. + /// + /// Negative values are rejected when parsing the `powersync_control` payload, before this is + /// called. pub fn probe_local_target_op( &self, target_op: Option, @@ -540,12 +542,6 @@ WHERE bucket = ?1", return Ok(previous_target_op); }; - if target_op < 0 { - return Err(PowerSyncError::argument_error( - "target op must be a non-negative integer", - )); - } - if target_op == 0 { self.delete_kv(LOCAL_TARGET_OP_KEY)?; return Ok(previous_target_op); @@ -553,12 +549,6 @@ WHERE bucket = ?1", self.write_i64_kv(LOCAL_TARGET_OP_KEY, target_op)?; - // Concrete target ops also seed the request counter for clients migrating from legacy - // service-created write checkpoints to client-created checkpoint requests. - if target_op != i64::MAX { - self.write_i64_kv(LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY, target_op)?; - } - Ok(previous_target_op) } diff --git a/dart/test/crud_test.dart b/dart/test/crud_test.dart index 0f561a48..8d13c70a 100644 --- a/dart/test/crud_test.dart +++ b/dart/test/crud_test.dart @@ -250,6 +250,14 @@ void main() { }); test('updates local target op and updated rows', () { + // Stale high-water marks (e.g. migrated legacy write checkpoints) must be cleared by a + // local write, so they can't open the apply gate for a smaller new target id. + db.execute(''' +INSERT INTO ps_kv(key, value) VALUES + ('last_seen_checkpoint_request_id', 6), + ('last_applied_checkpoint_request_id', 5); +'''); + db.execute( 'INSERT INTO powersync_crud (op, id, type, data) VALUES (?, ?, ?, ?)', [ @@ -266,7 +274,7 @@ void main() { isEmpty); expect( db.select( - "SELECT key, value FROM ps_kv WHERE key = 'local_target_op'"), + "SELECT key, value FROM ps_kv WHERE key LIKE '%checkpoint_request_id' OR key = 'local_target_op'"), [ { 'key': 'local_target_op', diff --git a/dart/test/migration_test.dart b/dart/test/migration_test.dart index 771ccd77..514cf4da 100644 --- a/dart/test/migration_test.dart +++ b/dart/test/migration_test.dart @@ -106,16 +106,15 @@ VALUES(1, '$local', 5, 6, 7, 0, 0, 1, 0, 0, 0); db.executeInTx('select powersync_init()'); - expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), containsAll([ - {'key': 'last_seen_checkpoint_request_id', 'value': 6}, - {'key': 'last_requested_checkpoint_request_id', 'value': 7}, + expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [ {'key': 'last_applied_checkpoint_request_id', 'value': 5}, + {'key': 'last_seen_checkpoint_request_id', 'value': 6}, {'key': 'local_target_op', 'value': 7}, - ])); + ]); + expect(db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"), + isEmpty); expect( - db - .select("PRAGMA table_info('ps_buckets')") - .map((row) => row['name']), + db.select("PRAGMA table_info('ps_buckets')").map((row) => row['name']), isNot(contains('target_op')), ); expect( @@ -138,8 +137,8 @@ VALUES(1, '$local', 5, 6, 9223372036854775807, 0, 0, 1, 0, 0, 0); db.executeInTx('select powersync_init()'); // last_applied_op becomes the applied checkpoint id, but it must not seed the requested - // checkpoint counter. The sentinel target is preserved for blocking, but is not concrete - // enough to become last_requested_checkpoint_request_id. + // checkpoint counter. The sentinel target is preserved for blocking, but target ops no longer + // seed last_requested_checkpoint_request_id. expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [ {'key': 'last_applied_checkpoint_request_id', 'value': 5}, {'key': 'last_seen_checkpoint_request_id', 'value': 6}, @@ -157,8 +156,8 @@ VALUES(1, '$local', 0, 0, 9223372036854775807, 0, 0, 1, 0, 0, 0); db.executeInTx('select powersync_init()'); - // The max-op sentinel is valid local target state, but it is not a concrete checkpoint - // request id and must not seed last_requested_checkpoint_request_id. + // The max-op sentinel is valid local target state, but target ops no longer seed + // last_requested_checkpoint_request_id. expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [ {'key': 'local_target_op', 'value': 9223372036854775807}, ]); @@ -190,6 +189,42 @@ INSERT INTO ps_kv(key, value) VALUES ); }); + test('re-upgrades after downgrade with checkpoint state', () async { + db.execute(fixtures.finalState); + db.execute(r''' +INSERT INTO ps_kv(key, value) VALUES + ('last_requested_checkpoint_request_id', 7), + ('last_seen_checkpoint_request_id', 6), + ('last_applied_checkpoint_request_id', 5), + ('local_target_op', 7); +'''); + + db.executeInTx('select powersync_test_migration(13)'); + + // Simulate an older SDK advancing the restored $local row while downgraded. + db.execute(r''' +UPDATE ps_buckets + SET last_op = 8, last_applied_op = 8, target_op = 9 + WHERE name = '$local' +'''); + + db.executeInTx('select powersync_init()'); + + // The $local row is the source of truth on re-upgrade; the request counter is unrelated to + // $local and survives the downgrade unchanged. + expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [ + {'key': 'last_applied_checkpoint_request_id', 'value': 8}, + {'key': 'last_requested_checkpoint_request_id', 'value': 7}, + {'key': 'last_seen_checkpoint_request_id', 'value': 8}, + {'key': 'local_target_op', 'value': 9}, + ]); + expect(db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"), + isEmpty); + + final schema = '${getSchema(db)}\n${getMigrations(db)}'; + expect(schema, equals(fixtures.finalState.trim())); + }); + test('does not restore local bucket without local target on downgrade', () async { db.execute(fixtures.finalState); diff --git a/dart/test/sync_test.dart b/dart/test/sync_test.dart index 06046c3f..93a8a4e1 100644 --- a/dart/test/sync_test.dart +++ b/dart/test/sync_test.dart @@ -474,26 +474,52 @@ void _syncTests({ syncTest('probes and updates local target op without sync iteration', (_) { expect(probeLocalTargetOp(), isNull); expect(probeLocalTargetOp(1), isNull); - expect(lastRequestedCheckpointRequestId(), 1); + expect(lastRequestedCheckpointRequestId(), isNull); expect(probeLocalTargetOp(), 1); expect(probeLocalTargetOp(2), 1); - expect(lastRequestedCheckpointRequestId(), 2); + expect(lastRequestedCheckpointRequestId(), isNull); expect(probeLocalTargetOp(), 2); }); syncTest('accepts text checkpoint request ids for local target op', (_) { expect(probeLocalTargetOp('1'), isNull); - expect(lastRequestedCheckpointRequestId(), 1); + expect(lastRequestedCheckpointRequestId(), isNull); expect(probeLocalTargetOp(), 1); }); - syncTest('does not store non-request target ops as checkpoint request id', - (_) { + syncTest('rejects negative local target ops', (_) { + expect( + () => invokeControlRaw('local_target_op', -1), + throwsA(isSqliteException( + 3091, + contains('local target op must be a non-negative integer'), + )), + ); + }); + + syncTest('local target op does not update checkpoint request id', (_) { + invokeControlRaw('start', null); + invokeControlRaw('seed_checkpoint_request_id', 10); + + expect(lastRequestedCheckpointRequestId(), 10); + expect(probeLocalTargetOp(7), isNull); + expect(probeLocalTargetOp(), 7); + expect(lastRequestedCheckpointRequestId(), 10); + }); + + syncTest('does not store target ops as checkpoint request id', (_) { expect(probeLocalTargetOp(0), isNull); expect(lastRequestedCheckpointRequestId(), isNull); expect(probeLocalTargetOp(), isNull); + expect(probeLocalTargetOp(1), isNull); + expect(lastRequestedCheckpointRequestId(), isNull); + expect(probeLocalTargetOp(), 1); + + expect(probeLocalTargetOp(0), 1); + expect(probeLocalTargetOp(), isNull); + expect(probeLocalTargetOp(9223372036854775807), isNull); expect(lastRequestedCheckpointRequestId(), isNull); expect(probeLocalTargetOp(), 9223372036854775807); @@ -564,6 +590,27 @@ void _syncTests({ db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"), isEmpty); }); + syncTest('local writes clear checkpoint request high-water marks', (_) { + invokeControl('start', null); + + pushCheckpoint(buckets: priorityBuckets, writeCheckpoint: '5'); + pushCheckpointComplete(); + expect(lastAppliedCheckpointRequestId(), 5); + + // A local write can only be acknowledged by a checkpoint request id observed after it. Stale + // seen/applied values (which may come from another id namespace, like migrated legacy write + // checkpoints) must not remain to open the apply gate for a smaller new target id. + db.execute("insert into items (id, col) values ('local', 'data');"); + + expect(lastAppliedCheckpointRequestId(), isNull); + expect( + db.select( + "SELECT 1 FROM ps_kv WHERE key = 'last_seen_checkpoint_request_id'"), + isEmpty, + ); + expect(probeLocalTargetOp(), 9223372036854775807); + }); + test('clearing database clears sync status', () { invokeControl('start', null); pushCheckpoint(buckets: priorityBuckets); @@ -1030,7 +1077,7 @@ void _syncTests({ db.execute('DELETE FROM ps_crud'); probeLocalTargetOp(1); expect(invokeControl('completed_upload', null), isEmpty); - expect(lastRequestedCheckpointRequestId(), 1); + expect(lastRequestedCheckpointRequestId(), 0); // Sync afterwards containing data and write checkpoint. pushCheckpoint(buckets: priorityBuckets, writeCheckpoint: '1'); diff --git a/docs/schema.md b/docs/schema.md index 1955bab1..4a6462b9 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -31,9 +31,10 @@ a checkpoint and that we have validated its checksum). 8. `count_since_last`: The amount of operations downloaded since the last verified checkpoint. Schema version 14 removes the legacy `target_op` column after migrating `$local.target_op` to -`ps_kv.local_target_op`. This makes older SDKs fail with a hard SQLite error if they try to keep -using the migrated database without downgrading. The down migration restores `target_op` for older -schema versions. +`ps_kv.local_target_op`, and deletes the `$local` row so `ps_buckets` only contains real sync +buckets. This makes older SDKs fail with a hard SQLite error if they try to keep using the migrated +database without downgrading. The down migration restores `target_op` and recreates the `$local` +row from `ps_kv` for older schema versions. ## `ps_crud` diff --git a/docs/sync.md b/docs/sync.md index 0dbc5362..929e1a35 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -42,11 +42,14 @@ The following commands are supported: clears the local target op and returns the previously-observed value in a `LocalTargetOp` result. This command can run outside of a sync iteration and does not affect it. 12. `seed_checkpoint_request_id`: Payload is `null`, an integer, or an integer string. After - receiving `EstablishSyncStream`, SDKs can re-request the client's last checkpoint request state - from the service using the provided hint, then seed core with the actual response for - reconciliation. `NULL` means the service has no record for the client yet; core stores `0` only - when no local seed exists. Integer seeds use `max(local, service)` semantics so the local counter - never moves backwards. + receiving `EstablishSyncStream`, SDKs should reconcile the provided local hint with the service + checkpoint-request state on every connection attempt. This can bump core when the service is + ahead, or restore the service-side value when the service has cleared stale state but core still + has a local hint. Then seed core with the reconciled value. `NULL` means neither side has a + record for the client yet; core stores `0` only when no local seed exists. Integer seeds use + `max(local, service)` semantics so the local counter never moves backwards while either side + still remembers the value. If both the client and service have lost the value, the counter may + restart. When uploads request a write checkpoint, SDKs should call `powersync_control('next_checkpoint_request_id', NULL)` inside a transaction to allocate the id to @@ -62,31 +65,40 @@ target op. The same command is used for compatibility when a new SDK is used wit PowerSync service that does not yet support client-created checkpoint requests; after the service-side write checkpoint request returns a concrete id, call `powersync_control('local_target_op', id)` with that id. Passing `NULL` returns the current target -without changing it, and passing `0` clears the local target. Updating to a positive, non-sentinel -target op also stores it as `last_requested_checkpoint_request_id` to support migrating to -client-created checkpoint requests. Sentinel values such as max op id are not stored as requested -checkpoint ids. +without changing it, and passing `0` clears the local target. This command only updates the apply +gate; it does not allocate, seed, or overwrite `last_requested_checkpoint_request_id`. Database migration v14 moves legacy `$local` checkpoint state into `ps_kv`: `$local.last_applied_op` becomes `last_applied_checkpoint_request_id`, `$local.last_op` becomes the internal -`last_seen_checkpoint_request_id`, a concrete `$local.target_op` advances the request counter, and -`$local.target_op` is stored as `local_target_op`. The migration then drops `ps_buckets.target_op` -so older SDKs fail hard if they try to keep using the migrated database directly. Downgrading -restores the column, and restores a `$local` row only when `local_target_op` exists, so older SDKs -can keep using target-op based blocking without inventing a synthetic local bucket when there was no -local target state. +`last_seen_checkpoint_request_id`, and any positive `$local.target_op` is stored as +`local_target_op`. A concrete `$local.target_op` could be used to seed +`last_requested_checkpoint_request_id`, but it should be redundant because SDKs reconcile the +request counter with the service on connect. The migration then deletes the `$local` row, leaving +only real sync buckets in `ps_buckets`, and drops `ps_buckets.target_op` so +older SDKs fail hard if they try to keep using the migrated database directly. Downgrading restores +the column, and restores a `$local` row only when `local_target_op` exists, so older SDKs can keep +using target-op based blocking without inventing a synthetic local bucket when there was no local +target state. Because the down migration keeps the `ps_kv` keys around, the up migration clears +them before copying, so re-upgrading a downgraded database takes the `$local` row (including any +progress an older SDK made) as the source of truth instead of failing on the existing keys. + +`last_requested_checkpoint_request_id` is internal allocation state used by +`next_checkpoint_request_id` to allocate increasing ids for client-created checkpoint requests. +`last_seen_checkpoint_request_id` and `last_applied_checkpoint_request_id` are high-water marks +that local writes clear, so only checkpoint request ids observed after a write count towards the +apply gate. SDKs should use `CheckpointRequestApplied` instructions for explicit checkpoint +request waits instead of presenting these values as meaningful sync progress. If `local_target_op` is absent after migration, there is no local write gate waiting for a checkpoint. In that case, SDKs can start client-created checkpoint requests normally, even when `last_requested_checkpoint_request_id` is undefined and the first allocated id is `1`. -The ambiguous case is a migrated `local_target_op` of max op id with no -`last_requested_checkpoint_request_id`: local writes are pending, but there is no concrete request -id to wait for yet. The max-op sentinel may also cover earlier pending uploads that were already -associated with legacy service-created write checkpoints, so restarting the client-created counter -from `1` could create a target lower than those existing associations. In that state, create one -old-style write checkpoint first, store the returned concrete id with -`powersync_control('local_target_op', id)`, and then switch to client-created checkpoint requests. +The ambiguous migration case is a migrated `local_target_op` of max op id: local writes are +pending, but there is no concrete request id to wait for yet. The max-op sentinel may also cover +earlier pending uploads that were already associated with legacy service-created write checkpoints. +In that state, create one old-style write checkpoint first, store the returned concrete id with +`powersync_control('local_target_op', id)`, let that gate resolve, and then switch to +client-created checkpoint requests after the request counter has been reconciled on connect. `powersync_control` returns a JSON-encoded array of instructions for the client: diff --git a/docs/write-checkpoint-requests.md b/docs/write-checkpoint-requests.md index 8e89fc42..bc3d34b2 100644 --- a/docs/write-checkpoint-requests.md +++ b/docs/write-checkpoint-requests.md @@ -9,15 +9,22 @@ At a high level: - `local_target_op` replaces `$local.target_op` as the local write apply gate. - `last_seen_checkpoint_request_id` replaces `$local.last_op`. - `last_applied_checkpoint_request_id` replaces `$local.last_applied_op`. -- `last_requested_checkpoint_request_id` tracks the latest concrete checkpoint request id known to - the client. +- `last_requested_checkpoint_request_id` tracks the latest concrete checkpoint request id allocated + by the client, so `next_checkpoint_request_id` can allocate increasing ids for each checkpoint + request. + +These keys are internal SDK/core state, not user-facing sync progress. +`last_requested_checkpoint_request_id` is functional allocation state, while +`last_seen_checkpoint_request_id` and `last_applied_checkpoint_request_id` are mostly diagnostic +high-water marks. Explicit checkpoint waits should follow `CheckpointRequestApplied` instructions. SDKs should not write these keys directly. They update the local target through `powersync_control('local_target_op', value)`, which is the shared helper for both legacy write checkpoints and new client-created checkpoint requests. The `powersync_control('next_checkpoint_request_id', NULL)` command only allocates a checkpoint request id; after the service accepts that request, the SDK uses -`powersync_control('local_target_op', id)` to make the accepted id the local target. +`powersync_control('local_target_op', id)` to make the accepted id the local target for write +checkpoints. For the historic `$local` bucket flow, see `historic-write-checkpoints.md`. @@ -29,17 +36,29 @@ the maximum i64 value. This is the `ps_kv` equivalent of the old `$local.target_ means "there are local writes, but we do not yet know the concrete checkpoint id that will acknowledge them". -The sentinel is stored in `ps_kv`, not in `ps_buckets`: +The sentinel is stored in `ps_kv`, not in `ps_buckets`. The same statement clears the +`last_seen_checkpoint_request_id` and `last_applied_checkpoint_request_id` high-water marks: ```sql INSERT OR REPLACE INTO ps_kv(key, value) VALUES('local_target_op', MAX_OP_ID); + +DELETE FROM ps_kv +WHERE key IN ('last_seen_checkpoint_request_id', 'last_applied_checkpoint_request_id'); ``` +Clearing the high-water marks mirrors how the legacy flow reset the whole `$local` row on local +writes. A checkpoint request id observed before the write cannot acknowledge it, and a stale seen +value may even come from an incompatible id namespace — a legacy write checkpoint migrated by v14, +or state from before a request counter restart. If such a value stayed around, a newly allocated +target id could compare below it and open the apply gate before the service acknowledged the write. +After a local write, only checkpoint request ids observed from that point on count towards the gate. + ## Completing uploaded CRUD -SDK upload code removes uploaded items from `ps_crud`. If the connector supplies a custom write -checkpoint and the queue is empty, that concrete checkpoint becomes the local target immediately. +SDK upload code removes uploaded items from `ps_crud`. If the connector supplies a legacy custom +write checkpoint and the queue is empty, that concrete checkpoint becomes the local target +immediately. Otherwise the target is reset to `MAX_OP_ID`, allowing the sync client to create a standard checkpoint request after the queue drains. @@ -118,12 +137,24 @@ These `powersync_control` commands are the SDK-facing API for the new `ps_kv` ch `powersync_control('start', payload)` begins a sync iteration and emits `EstablishSyncStream` with a `last_checkpoint_request_id` hint. This is core's local `last_requested_checkpoint_request_id` value -before opening the stream, or `NULL` when no local seed exists. On connect, SDKs can use this hint -to re-request the client's last checkpoint request state from the service, then call -`powersync_control('seed_checkpoint_request_id', value)` with the actual response for -reconciliation. `value` may be `NULL` when the service has no record for the client; core stores `0` -only when no local seed exists. Integer seeds use `max(local, service)` semantics so the local -counter never moves backwards. SDKs may also refresh service state when their user/client context +before opening the stream, or `NULL` when no local seed exists. On every connection attempt, SDKs +should reconcile this hint with the service checkpoint-request state before creating new requests. +The reconciliation is bidirectional: if the service still has a higher value, the SDK uses that +response to bump core locally so following requests are accepted; if the service has cleared stale +state but core still has a local hint, the SDK can use the hint to restore the service-side value. +After reconciliation, call `powersync_control('seed_checkpoint_request_id', value)` with the +reconciled value. + +`last_requested_checkpoint_request_id` is a best-effort counter seed, not durable application state. +If either the client or the service still remembers a higher id, the reconciliation response plus +core's `max(local, service)` seeding keeps the local counter moving forward. If the service has +cleared stale state but the client still has a local seed, the local hint can restore the +service-side value and keep the counter from moving backwards. If the client lost local state but +the service still has a record, the service response restores the seed locally. If both sides have +lost the value, it is acceptable for the counter to restart; this can happen after local state is +cleared and stale service state expires, or when multiple user ids share the same client id. After +reconciliation, `value` may be `NULL` when neither side has a record for the client; core stores `0` +only when no local seed exists. SDKs may also refresh service state when their user/client context changes. `powersync_control('next_checkpoint_request_id', NULL)` must be called inside a transaction during @@ -142,9 +173,9 @@ This command only allocates an id. It does not update `local_target_op`. Note on sequences: SQLite does not have standalone sequences. The sequence-like alternatives are either an `AUTOINCREMENT` table backed by SQLite's internal `sqlite_sequence`, or a dedicated single-row counter table like the existing `ps_tx` transaction counter. The checkpoint request -counter currently lives in `ps_kv` because it is also migrated and seeded from legacy/custom -concrete targets via `powersync_control('local_target_op', value)`. If we want stricter structure -later, a dedicated checkpoint-request counter table would be the closest match to a sequence. +counter currently lives in `ps_kv` so it can persist across requests and be reconciled with service +state on connect. If we want stricter structure later, a dedicated checkpoint-request counter table +would be the closest match to a sequence. `powersync_control('local_target_op', op_id)` probes and optionally updates the local target. Like `subscriptions`, this command is handled directly by `powersync_control` and can run outside an @@ -153,9 +184,11 @@ active sync iteration: - `NULL` returns the current `local_target_op` without changing it. - `0` clears `local_target_op`. - A positive value stores `local_target_op`. -- A positive value other than `i64::MAX` also stores `last_requested_checkpoint_request_id`. - Negative values and non-integer inputs are rejected. +This command only updates the apply gate. It does not allocate, seed, or overwrite +`last_requested_checkpoint_request_id`. + The command returns the previous target value in a `LocalTargetOp` result, or `NULL` if there was no target. @@ -168,8 +201,6 @@ if target_op == 0: delete ps_kv['local_target_op'] else: ps_kv['local_target_op'] = target_op - if target_op != MAX_OP_ID: - ps_kv['last_requested_checkpoint_request_id'] = target_op return previous ``` @@ -221,9 +252,9 @@ after full checkpoint apply: ## Explicit checkpoint requests -Swift exposes `PowerSyncDatabaseProtocol.requestCheckpoint()` for callers that want to wait until -the local database has caught up to the service. This creates a checkpoint request id through the -connected sync client and returns a `CheckpointRequest`. +SDKs can expose a `requestCheckpoint()`-style API for callers that want to wait until the local +database has caught up to the service. The SDK creates a checkpoint request id through the connected +sync client and returns a `CheckpointRequest`-style waiter. This explicit API does not update `local_target_op`: it is a wait marker, not a local upload gate. The returned object waits until core emits `CheckpointRequestApplied` for an id greater than or @@ -247,14 +278,18 @@ request could not be delivered to the service or observed in the sync stream. pending, a concrete checkpoint request id after upload completion, or absent when there is no local write gate. - `last_requested_checkpoint_request_id`: The last client-created checkpoint request id allocated - by `powersync_control('next_checkpoint_request_id', NULL)`. - `powersync_control('local_target_op', value)` also writes this key for positive, non-sentinel - targets so migrated or legacy-created concrete targets can seed the client request counter. + by `powersync_control('next_checkpoint_request_id', NULL)`. This is the counter used to allocate + increasing ids for each client-created checkpoint request, including multiple requests in one + client lifetime. The persisted value is also useful for debugging and for seeding the next + connection attempt. SDKs should reconcile it with the service on every connect, and should + tolerate it restarting when both the client and service have lost the previous value. - `last_seen_checkpoint_request_id`: The latest full checkpoint `write_checkpoint` observed and - validated from the sync stream. + validated from the sync stream since the last local write. Local writes clear this key, so only + checkpoint request ids observed after the write can satisfy the apply gate. - `last_applied_checkpoint_request_id`: The latest full checkpoint `write_checkpoint` that has been - applied locally. Core persists this for migration/downgrade state; SDKs should use - `CheckpointRequestApplied` instructions to resolve `CheckpointRequest` waits. + applied locally since the last local write, which clears this key. Core persists this for + migration/downgrade state and debugging; SDKs should use `CheckpointRequestApplied` instructions + to resolve `CheckpointRequest` waits. ## Migration from `$local` @@ -262,26 +297,36 @@ Migration v14 moves the old `$local` bucket state into `ps_kv`: - `$local.last_applied_op` becomes `last_applied_checkpoint_request_id`. - `$local.last_op` becomes `last_seen_checkpoint_request_id`. -- A concrete `$local.target_op` becomes `last_requested_checkpoint_request_id`. - Any positive `$local.target_op`, including `MAX_OP_ID`, becomes `local_target_op`. -After copying this state, the migration drops `ps_buckets.target_op`. This intentionally makes older -SDKs fail with a hard SQLite error if they try to keep using a migrated database without first -downgrading. +A concrete `$local.target_op` could be used to seed `last_requested_checkpoint_request_id`, but it +should be redundant because SDKs reconcile the request counter with service state on connect before +advancing it through `next_checkpoint_request_id`. + +After copying this state, the migration deletes the `$local` row — version 14 tracks this state +exclusively in `ps_kv`, so `ps_buckets` only contains real sync buckets — and drops +`ps_buckets.target_op`. Dropping the column intentionally makes older SDKs fail with a hard SQLite +error if they try to keep using a migrated database without first downgrading. + +The up migration first deletes any existing `last_applied_checkpoint_request_id`, +`last_seen_checkpoint_request_id` and `local_target_op` keys. Those can be present when a database +was previously on version 14 and then downgraded, because the down migration keeps the ps_kv keys +while rebuilding `$local`. Clearing them makes the `$local` row the source of truth on re-upgrade, +picking up any progress an older SDK made while downgraded. `last_requested_checkpoint_request_id` +is unrelated to `$local` and survives a downgrade/upgrade cycle unchanged. An absent `local_target_op` is safe: there is no local write gate waiting for a checkpoint, so an -SDK can start client-created checkpoint requests from `1`. The sync stream will only report that -request id after the service has accepted and reached it. - -The ambiguous case is a migrated `local_target_op` of `MAX_OP_ID` with no -`last_requested_checkpoint_request_id`. That means there is a pending local write gate but no -concrete request id to wait for yet. The `MAX_OP_ID` sentinel only says that local writes dirtied -the gate; it does not prove that no earlier uploads were already associated with legacy -service-created write checkpoints. Those existing checkpoint ids may be higher than a restarted -client counter such as `1`, and using a lower target could let an older seen checkpoint satisfy the -gate too early. In that state, the SDK should create one legacy write checkpoint first, store the -concrete id with `powersync_control('local_target_op', id)`, and then switch to client-created -checkpoint requests. +SDK can seed the request counter on connect and start client-created checkpoint requests normally. +If neither the client nor service has a previous request id, the first allocated id is `1`. The sync +stream will only report that request id after the service has accepted and reached it. + +The ambiguous case is a migrated `local_target_op` of `MAX_OP_ID`. That means there is a pending +local write gate but no concrete request id to wait for yet. The `MAX_OP_ID` sentinel only says that +local writes dirtied the gate; it does not prove that no earlier uploads were already associated +with legacy service-created write checkpoints. In that state, the SDK should create one legacy write +checkpoint first, store the concrete id with `powersync_control('local_target_op', id)`, let that +gate resolve, and then switch to client-created checkpoint requests after the request counter has +been reconciled on connect. The down migration restores `ps_buckets.target_op` and rebuilds a `$local` row only when `local_target_op` exists, using: