Skip to content

Commit 12f58aa

Browse files
remove dead macros. Update migrations to fix potential bugs. update doc entries
1 parent 69bf1d3 commit 12f58aa

10 files changed

Lines changed: 271 additions & 161 deletions

File tree

crates/core/src/crud_vtab.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,15 @@ impl SimpleCrudTransactionMode {
247247

248248
fn record_local_write(&mut self, db: *mut sqlite::sqlite3) -> Result<(), ResultCode> {
249249
if !self.had_writes {
250+
// Also clear the seen/applied high-water marks: checkpoint request ids observed before
251+
// this write can't acknowledge it, and they may come from an incompatible id namespace
252+
// (legacy write checkpoints migrated by v14, or state from before a counter restart).
253+
// Keeping them around could open the apply gate for a newly allocated target id that
254+
// compares below a stale seen value. The legacy `$local` bookkeeping had the same
255+
// behavior by resetting the entire row on local writes.
250256
db.exec_safe(formatcp!(
251-
"INSERT OR REPLACE INTO ps_kv(key, value) VALUES('local_target_op', {MAX_OP_ID})"
257+
"INSERT OR REPLACE INTO ps_kv(key, value) VALUES('local_target_op', {MAX_OP_ID});
258+
DELETE FROM ps_kv WHERE key IN ('last_seen_checkpoint_request_id', 'last_applied_checkpoint_request_id')"
252259
))?;
253260
self.had_writes = true;
254261
}

crates/core/src/macros.rs

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -43,49 +43,3 @@ macro_rules! create_sqlite_optional_text_fn {
4343
}
4444
};
4545
}
46-
47-
#[macro_export]
48-
macro_rules! create_sqlite_int_fn {
49-
($fn_name:ident, $fn_impl_name:ident, $description:literal) => {
50-
extern "C" fn $fn_name(
51-
ctx: *mut sqlite::context,
52-
argc: c_int,
53-
argv: *mut *mut sqlite::value,
54-
) {
55-
let args = sqlite::args!(argc, argv);
56-
57-
let result = $fn_impl_name(ctx, args);
58-
59-
if let Err(err) = result {
60-
PowerSyncError::from(err).apply_to_ctx($description, ctx);
61-
} else if let Ok(r) = result {
62-
ctx.result_int64(r);
63-
}
64-
}
65-
};
66-
}
67-
68-
#[macro_export]
69-
macro_rules! create_sqlite_optional_int_fn {
70-
($fn_name:ident, $fn_impl_name:ident, $description:literal) => {
71-
extern "C" fn $fn_name(
72-
ctx: *mut sqlite::context,
73-
argc: c_int,
74-
argv: *mut *mut sqlite::value,
75-
) {
76-
let args = sqlite::args!(argc, argv);
77-
78-
let result = $fn_impl_name(ctx, args);
79-
80-
if let Err(err) = result {
81-
PowerSyncError::from(err).apply_to_ctx($description, ctx);
82-
} else if let Ok(r) = result {
83-
if let Some(i) = r {
84-
ctx.result_int64(i);
85-
} else {
86-
ctx.result_null();
87-
}
88-
}
89-
}
90-
};
91-
}

crates/core/src/migrations.rs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -468,18 +468,34 @@ DROP TABLE ps_sync_state_old;
468468
// `$local.last_op` represented the latest legacy write checkpoint seen in the sync stream,
469469
// so it becomes the last seen checkpoint request.
470470
//
471-
// `$local.target_op` can either be a concrete checkpoint request id or a sentinel such as
472-
// i64::MAX while local writes are pending. Store it separately as `local_target_op`, but
473-
// only treat concrete values as requested checkpoint ids. We intentionally don't seed
474-
// `last_requested_checkpoint_request_id` from `$local.last_applied_op` because that is an
475-
// applied value, not necessarily the current requested target.
471+
// `$local.target_op` can either be a concrete legacy write checkpoint id or a sentinel such
472+
// as i64::MAX while local writes are pending. Store it separately as `local_target_op`.
473+
// Seeding `last_requested_checkpoint_request_id` from a concrete target would be possible,
474+
// but should be redundant because SDKs reconcile the request counter with service state on
475+
// connect before advancing it through `next_checkpoint_request_id`.
476476
//
477477
// An absent local target can safely start client-created checkpoint requests from 1. The
478478
// ambiguous case is an existing max-op local target without a concrete requested id:
479479
// pending local writes may already be associated with legacy service-created write
480480
// checkpoints, so SDKs should bridge once through the legacy endpoint before starting
481481
// client-created checkpoint requests.
482+
//
483+
// This migration can also run on a database that was previously on version 14 and then
484+
// downgraded: the down migration rebuilds the `$local` row from ps_kv but keeps the ps_kv
485+
// keys around, and an older SDK may have advanced `$local` since. Clear the keys first so
486+
// the `$local` row is the source of truth and the inserts below can't conflict.
487+
//
488+
// After copying, the `$local` row is deleted: version 14 tracks this state exclusively in
489+
// ps_kv, so ps_buckets only contains real sync buckets. The down migration recreates the
490+
// row from ps_kv when needed.
482491
let up = "\
492+
DELETE FROM ps_kv
493+
WHERE key IN (
494+
'last_applied_checkpoint_request_id',
495+
'last_seen_checkpoint_request_id',
496+
'local_target_op'
497+
);
498+
483499
INSERT INTO ps_kv(key, value)
484500
SELECT 'last_applied_checkpoint_request_id', last_applied_op
485501
FROM ps_buckets
@@ -492,19 +508,14 @@ SELECT 'last_seen_checkpoint_request_id', last_op
492508
WHERE name = '$local'
493509
AND last_op > 0;
494510
495-
INSERT INTO ps_kv(key, value)
496-
SELECT 'last_requested_checkpoint_request_id', target_op
497-
FROM ps_buckets
498-
WHERE name = '$local'
499-
AND target_op > 0
500-
AND target_op != 9223372036854775807;
501-
502511
INSERT INTO ps_kv(key, value)
503512
SELECT 'local_target_op', target_op
504513
FROM ps_buckets
505514
WHERE name = '$local'
506515
AND target_op > 0;
507516
517+
DELETE FROM ps_buckets WHERE name = '$local';
518+
508519
ALTER TABLE ps_buckets DROP COLUMN target_op;
509520
";
510521
local_db.exec_safe(up).into_db_result(local_db)?;

crates/core/src/sync/storage_adapter.rs

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -523,13 +523,15 @@ WHERE bucket = ?1",
523523
/// The target op can also be used internally as a sentinel value such as max op id while local
524524
/// writes are pending, so it must not always be interpreted as a checkpoint request id.
525525
///
526-
/// When the target op is a positive, non-sentinel checkpoint request id, it also updates
527-
/// `last_requested_checkpoint_request_id` so clients can migrate from the legacy target-op
528-
/// flow to client-created checkpoint requests. `0` clears the local target, and sentinel
529-
/// values such as max op id must not update the last requested id.
526+
/// This only updates the apply gate. It does not allocate, seed or overwrite
527+
/// `last_requested_checkpoint_request_id`, which is managed by `seed_checkpoint_request_id` and
528+
/// `next_checkpoint_request_id`.
530529
///
531530
/// Returns the target op value from before this call. When `target_op` is `None`, this only
532531
/// reads the current value.
532+
///
533+
/// Negative values are rejected when parsing the `powersync_control` payload, before this is
534+
/// called.
533535
pub fn probe_local_target_op(
534536
&self,
535537
target_op: Option<i64>,
@@ -540,25 +542,13 @@ WHERE bucket = ?1",
540542
return Ok(previous_target_op);
541543
};
542544

543-
if target_op < 0 {
544-
return Err(PowerSyncError::argument_error(
545-
"target op must be a non-negative integer",
546-
));
547-
}
548-
549545
if target_op == 0 {
550546
self.delete_kv(LOCAL_TARGET_OP_KEY)?;
551547
return Ok(previous_target_op);
552548
}
553549

554550
self.write_i64_kv(LOCAL_TARGET_OP_KEY, target_op)?;
555551

556-
// Concrete target ops also seed the request counter for clients migrating from legacy
557-
// service-created write checkpoints to client-created checkpoint requests.
558-
if target_op != i64::MAX {
559-
self.write_i64_kv(LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY, target_op)?;
560-
}
561-
562552
Ok(previous_target_op)
563553
}
564554

dart/test/crud_test.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,14 @@ void main() {
250250
});
251251

252252
test('updates local target op and updated rows', () {
253+
// Stale high-water marks (e.g. migrated legacy write checkpoints) must be cleared by a
254+
// local write, so they can't open the apply gate for a smaller new target id.
255+
db.execute('''
256+
INSERT INTO ps_kv(key, value) VALUES
257+
('last_seen_checkpoint_request_id', 6),
258+
('last_applied_checkpoint_request_id', 5);
259+
''');
260+
253261
db.execute(
254262
'INSERT INTO powersync_crud (op, id, type, data) VALUES (?, ?, ?, ?)',
255263
[
@@ -266,7 +274,7 @@ void main() {
266274
isEmpty);
267275
expect(
268276
db.select(
269-
"SELECT key, value FROM ps_kv WHERE key = 'local_target_op'"),
277+
"SELECT key, value FROM ps_kv WHERE key LIKE '%checkpoint_request_id' OR key = 'local_target_op'"),
270278
[
271279
{
272280
'key': 'local_target_op',

dart/test/migration_test.dart

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,15 @@ VALUES(1, '$local', 5, 6, 7, 0, 0, 1, 0, 0, 0);
106106

107107
db.executeInTx('select powersync_init()');
108108

109-
expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), containsAll([
110-
{'key': 'last_seen_checkpoint_request_id', 'value': 6},
111-
{'key': 'last_requested_checkpoint_request_id', 'value': 7},
109+
expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [
112110
{'key': 'last_applied_checkpoint_request_id', 'value': 5},
111+
{'key': 'last_seen_checkpoint_request_id', 'value': 6},
113112
{'key': 'local_target_op', 'value': 7},
114-
]));
113+
]);
114+
expect(db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"),
115+
isEmpty);
115116
expect(
116-
db
117-
.select("PRAGMA table_info('ps_buckets')")
118-
.map((row) => row['name']),
117+
db.select("PRAGMA table_info('ps_buckets')").map((row) => row['name']),
119118
isNot(contains('target_op')),
120119
);
121120
expect(
@@ -138,8 +137,8 @@ VALUES(1, '$local', 5, 6, 9223372036854775807, 0, 0, 1, 0, 0, 0);
138137
db.executeInTx('select powersync_init()');
139138

140139
// last_applied_op becomes the applied checkpoint id, but it must not seed the requested
141-
// checkpoint counter. The sentinel target is preserved for blocking, but is not concrete
142-
// enough to become last_requested_checkpoint_request_id.
140+
// checkpoint counter. The sentinel target is preserved for blocking, but target ops no longer
141+
// seed last_requested_checkpoint_request_id.
143142
expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [
144143
{'key': 'last_applied_checkpoint_request_id', 'value': 5},
145144
{'key': 'last_seen_checkpoint_request_id', 'value': 6},
@@ -157,8 +156,8 @@ VALUES(1, '$local', 0, 0, 9223372036854775807, 0, 0, 1, 0, 0, 0);
157156

158157
db.executeInTx('select powersync_init()');
159158

160-
// The max-op sentinel is valid local target state, but it is not a concrete checkpoint
161-
// request id and must not seed last_requested_checkpoint_request_id.
159+
// The max-op sentinel is valid local target state, but target ops no longer seed
160+
// last_requested_checkpoint_request_id.
162161
expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [
163162
{'key': 'local_target_op', 'value': 9223372036854775807},
164163
]);
@@ -190,6 +189,42 @@ INSERT INTO ps_kv(key, value) VALUES
190189
);
191190
});
192191

192+
test('re-upgrades after downgrade with checkpoint state', () async {
193+
db.execute(fixtures.finalState);
194+
db.execute(r'''
195+
INSERT INTO ps_kv(key, value) VALUES
196+
('last_requested_checkpoint_request_id', 7),
197+
('last_seen_checkpoint_request_id', 6),
198+
('last_applied_checkpoint_request_id', 5),
199+
('local_target_op', 7);
200+
''');
201+
202+
db.executeInTx('select powersync_test_migration(13)');
203+
204+
// Simulate an older SDK advancing the restored $local row while downgraded.
205+
db.execute(r'''
206+
UPDATE ps_buckets
207+
SET last_op = 8, last_applied_op = 8, target_op = 9
208+
WHERE name = '$local'
209+
''');
210+
211+
db.executeInTx('select powersync_init()');
212+
213+
// The $local row is the source of truth on re-upgrade; the request counter is unrelated to
214+
// $local and survives the downgrade unchanged.
215+
expect(db.select('SELECT key, value FROM ps_kv ORDER BY key'), [
216+
{'key': 'last_applied_checkpoint_request_id', 'value': 8},
217+
{'key': 'last_requested_checkpoint_request_id', 'value': 7},
218+
{'key': 'last_seen_checkpoint_request_id', 'value': 8},
219+
{'key': 'local_target_op', 'value': 9},
220+
]);
221+
expect(db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"),
222+
isEmpty);
223+
224+
final schema = '${getSchema(db)}\n${getMigrations(db)}';
225+
expect(schema, equals(fixtures.finalState.trim()));
226+
});
227+
193228
test('does not restore local bucket without local target on downgrade',
194229
() async {
195230
db.execute(fixtures.finalState);

dart/test/sync_test.dart

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -474,26 +474,52 @@ void _syncTests<T>({
474474
syncTest('probes and updates local target op without sync iteration', (_) {
475475
expect(probeLocalTargetOp(), isNull);
476476
expect(probeLocalTargetOp(1), isNull);
477-
expect(lastRequestedCheckpointRequestId(), 1);
477+
expect(lastRequestedCheckpointRequestId(), isNull);
478478
expect(probeLocalTargetOp(), 1);
479479

480480
expect(probeLocalTargetOp(2), 1);
481-
expect(lastRequestedCheckpointRequestId(), 2);
481+
expect(lastRequestedCheckpointRequestId(), isNull);
482482
expect(probeLocalTargetOp(), 2);
483483
});
484484

485485
syncTest('accepts text checkpoint request ids for local target op', (_) {
486486
expect(probeLocalTargetOp('1'), isNull);
487-
expect(lastRequestedCheckpointRequestId(), 1);
487+
expect(lastRequestedCheckpointRequestId(), isNull);
488488
expect(probeLocalTargetOp(), 1);
489489
});
490490

491-
syncTest('does not store non-request target ops as checkpoint request id',
492-
(_) {
491+
syncTest('rejects negative local target ops', (_) {
492+
expect(
493+
() => invokeControlRaw('local_target_op', -1),
494+
throwsA(isSqliteException(
495+
3091,
496+
contains('local target op must be a non-negative integer'),
497+
)),
498+
);
499+
});
500+
501+
syncTest('local target op does not update checkpoint request id', (_) {
502+
invokeControlRaw('start', null);
503+
invokeControlRaw('seed_checkpoint_request_id', 10);
504+
505+
expect(lastRequestedCheckpointRequestId(), 10);
506+
expect(probeLocalTargetOp(7), isNull);
507+
expect(probeLocalTargetOp(), 7);
508+
expect(lastRequestedCheckpointRequestId(), 10);
509+
});
510+
511+
syncTest('does not store target ops as checkpoint request id', (_) {
493512
expect(probeLocalTargetOp(0), isNull);
494513
expect(lastRequestedCheckpointRequestId(), isNull);
495514
expect(probeLocalTargetOp(), isNull);
496515

516+
expect(probeLocalTargetOp(1), isNull);
517+
expect(lastRequestedCheckpointRequestId(), isNull);
518+
expect(probeLocalTargetOp(), 1);
519+
520+
expect(probeLocalTargetOp(0), 1);
521+
expect(probeLocalTargetOp(), isNull);
522+
497523
expect(probeLocalTargetOp(9223372036854775807), isNull);
498524
expect(lastRequestedCheckpointRequestId(), isNull);
499525
expect(probeLocalTargetOp(), 9223372036854775807);
@@ -564,6 +590,27 @@ void _syncTests<T>({
564590
db.select(r"SELECT * FROM ps_buckets WHERE name = '$local'"), isEmpty);
565591
});
566592

593+
syncTest('local writes clear checkpoint request high-water marks', (_) {
594+
invokeControl('start', null);
595+
596+
pushCheckpoint(buckets: priorityBuckets, writeCheckpoint: '5');
597+
pushCheckpointComplete();
598+
expect(lastAppliedCheckpointRequestId(), 5);
599+
600+
// A local write can only be acknowledged by a checkpoint request id observed after it. Stale
601+
// seen/applied values (which may come from another id namespace, like migrated legacy write
602+
// checkpoints) must not remain to open the apply gate for a smaller new target id.
603+
db.execute("insert into items (id, col) values ('local', 'data');");
604+
605+
expect(lastAppliedCheckpointRequestId(), isNull);
606+
expect(
607+
db.select(
608+
"SELECT 1 FROM ps_kv WHERE key = 'last_seen_checkpoint_request_id'"),
609+
isEmpty,
610+
);
611+
expect(probeLocalTargetOp(), 9223372036854775807);
612+
});
613+
567614
test('clearing database clears sync status', () {
568615
invokeControl('start', null);
569616
pushCheckpoint(buckets: priorityBuckets);
@@ -1030,7 +1077,7 @@ void _syncTests<T>({
10301077
db.execute('DELETE FROM ps_crud');
10311078
probeLocalTargetOp(1);
10321079
expect(invokeControl('completed_upload', null), isEmpty);
1033-
expect(lastRequestedCheckpointRequestId(), 1);
1080+
expect(lastRequestedCheckpointRequestId(), 0);
10341081

10351082
// Sync afterwards containing data and write checkpoint.
10361083
pushCheckpoint(buckets: priorityBuckets, writeCheckpoint: '1');

docs/schema.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ a checkpoint and that we have validated its checksum).
3131
8. `count_since_last`: The amount of operations downloaded since the last verified checkpoint.
3232

3333
Schema version 14 removes the legacy `target_op` column after migrating `$local.target_op` to
34-
`ps_kv.local_target_op`. This makes older SDKs fail with a hard SQLite error if they try to keep
35-
using the migrated database without downgrading. The down migration restores `target_op` for older
36-
schema versions.
34+
`ps_kv.local_target_op`, and deletes the `$local` row so `ps_buckets` only contains real sync
35+
buckets. This makes older SDKs fail with a hard SQLite error if they try to keep using the migrated
36+
database without downgrading. The down migration restores `target_op` and recreates the `$local`
37+
row from `ps_kv` for older schema versions.
3738

3839
## `ps_crud`
3940

0 commit comments

Comments
 (0)