@syncular/client 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +6 -4
- package/dist/client.d.ts +49 -2
- package/dist/client.js +527 -259
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invalidation.d.ts +81 -48
- package/dist/invalidation.js +130 -42
- package/dist/reactive-store.d.ts +74 -0
- package/dist/reactive-store.js +576 -0
- package/dist/schema.js +1 -0
- package/dist/state.d.ts +9 -0
- package/dist/state.js +29 -0
- package/dist/window.d.ts +6 -1
- package/dist/window.js +0 -0
- package/dist/worker-entry.js +78 -52
- package/dist/worker-host.d.ts +8 -4
- package/dist/worker-host.js +26 -6
- package/dist/worker-protocol.d.ts +12 -14
- package/package.json +3 -3
- package/src/apply.ts +18 -5
- package/src/client.ts +685 -311
- package/src/index.ts +1 -0
- package/src/invalidation.ts +216 -62
- package/src/reactive-store.ts +695 -0
- package/src/schema.ts +3 -0
- package/src/state.ts +32 -0
- package/src/window.ts +0 -0
- package/src/worker-entry.ts +83 -54
- package/src/worker-host.ts +44 -8
- package/src/worker-protocol.ts +20 -13
package/dist/client.js
CHANGED
|
@@ -12,13 +12,13 @@ import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow,
|
|
|
12
12
|
import { clearPendingUpload, computeBlobId, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, reconcileBlobRefcounts, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
|
|
13
13
|
import { registerDevtools } from './devtools.js';
|
|
14
14
|
import { ClientSyncError } from './errors.js';
|
|
15
|
-
import {
|
|
15
|
+
import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
16
16
|
import { singleOwnerLock, } from './leader-lock.js';
|
|
17
17
|
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, OutboxEncodeError, } from './outbox.js';
|
|
18
18
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
19
19
|
import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
20
|
-
import { deleteSubscription, getMeta, getSubscription, loadSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
|
|
21
|
-
import { deletePendingEviction, deleteWindowUnit, deriveSubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
|
|
20
|
+
import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
|
|
21
|
+
import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
|
|
22
22
|
/**
|
|
23
23
|
* True iff `unit` is windowed-in AND its bootstrap completed (§4.8 I3):
|
|
24
24
|
* registered and not pending. A unit with zero server rows still becomes
|
|
@@ -93,10 +93,14 @@ export class SyncClient {
|
|
|
93
93
|
* (the fast-bail the delta path reads).
|
|
94
94
|
*/
|
|
95
95
|
#syncOutstanding = false;
|
|
96
|
+
/** Retry policy belongs to the operation that classified the failure. */
|
|
97
|
+
#retryDelayMs = 250;
|
|
96
98
|
#hasBlobs;
|
|
97
99
|
/** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
|
|
98
100
|
#presence = new Map();
|
|
99
|
-
/**
|
|
101
|
+
/** SPEC §7.5: exact core-originated observer transaction batches. */
|
|
102
|
+
#changes = new ChangeEmitter();
|
|
103
|
+
/** Compatibility projection from exact batches; never bridge-inferred. */
|
|
100
104
|
#invalidation = new InvalidationEmitter();
|
|
101
105
|
/** §8.6: subscribable presence-change listeners (twin of onPresence). */
|
|
102
106
|
#presenceListeners = new Set();
|
|
@@ -136,8 +140,15 @@ export class SyncClient {
|
|
|
136
140
|
if (this.#hasBlobs)
|
|
137
141
|
ensureBlobSchema(this.#db);
|
|
138
142
|
const persisted = getMeta(this.#db, 'clientId');
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
if (persisted !== undefined &&
|
|
144
|
+
this.#config.clientId !== undefined &&
|
|
145
|
+
persisted !== this.#config.clientId) {
|
|
146
|
+
await this.#lease.release();
|
|
147
|
+
this.#lease = undefined;
|
|
148
|
+
throw new ClientSyncError('client.identity_mismatch', `this client database belongs to ${JSON.stringify(persisted)}; refusing to rebind it to ${JSON.stringify(this.#config.clientId)}`);
|
|
149
|
+
}
|
|
150
|
+
this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
|
|
151
|
+
if (persisted === undefined) {
|
|
141
152
|
setMeta(this.#db, 'clientId', this.#clientId);
|
|
142
153
|
}
|
|
143
154
|
// §7.3.5: restore the persisted lease so leaseState survives restart.
|
|
@@ -151,6 +162,20 @@ export class SyncClient {
|
|
|
151
162
|
// already at the generated version.
|
|
152
163
|
this.#detectAndResetSchema();
|
|
153
164
|
this.#started = true;
|
|
165
|
+
// A persisted active subscription needs one catch-up round on every open:
|
|
166
|
+
// realtime only covers changes after the socket connects, and an
|
|
167
|
+
// idempotent setWindow/subscribe call correctly creates no new command
|
|
168
|
+
// effect. Pending outbox work has the same restart requirement. Surface
|
|
169
|
+
// this as an exact core-owned intent so hosts never need a startup poll or
|
|
170
|
+
// an application-issued sync() call.
|
|
171
|
+
const startupWork = this.#schemaFloor === undefined &&
|
|
172
|
+
(listOutbox(this.#db).length > 0 ||
|
|
173
|
+
loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
|
|
174
|
+
if (startupWork) {
|
|
175
|
+
this.#needsPull = true;
|
|
176
|
+
this.#config.onSyncNeeded?.('startup');
|
|
177
|
+
this.#config.onSyncIntent?.({ kind: 'interactive' });
|
|
178
|
+
}
|
|
154
179
|
// RFC 0002 §3.2: console introspection — a no-op outside a dev page.
|
|
155
180
|
this.#devtoolsUnregister = registerDevtools({
|
|
156
181
|
kind: 'client',
|
|
@@ -205,15 +230,35 @@ export class SyncClient {
|
|
|
205
230
|
// The stop state is over: this client now ships a servable schema. The
|
|
206
231
|
// outbox is re-applied optimistically over the (now empty) tables so
|
|
207
232
|
// pending offline writes stay visible across the bump (§7.4.5).
|
|
208
|
-
this.#
|
|
233
|
+
this.#setSchemaFloor(undefined);
|
|
209
234
|
this.#replayOutbox();
|
|
210
235
|
}
|
|
211
236
|
#setUpgrading(upgrading) {
|
|
212
237
|
if (this.#upgrading === upgrading)
|
|
213
238
|
return;
|
|
214
|
-
this.#
|
|
239
|
+
this.#applyBatch((batch) => {
|
|
240
|
+
this.#upgrading = upgrading;
|
|
241
|
+
batch.status();
|
|
242
|
+
});
|
|
215
243
|
this.#config.onUpgrading?.(upgrading);
|
|
216
244
|
}
|
|
245
|
+
#setSyncNeeded(syncNeeded) {
|
|
246
|
+
if (this.#needsPull === syncNeeded)
|
|
247
|
+
return;
|
|
248
|
+
this.#applyBatch((batch) => {
|
|
249
|
+
this.#needsPull = syncNeeded;
|
|
250
|
+
batch.status();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
#setSchemaFloor(schemaFloor) {
|
|
254
|
+
const current = JSON.stringify(this.#schemaFloor);
|
|
255
|
+
if (current === JSON.stringify(schemaFloor))
|
|
256
|
+
return;
|
|
257
|
+
this.#applyBatch((batch) => {
|
|
258
|
+
this.#schemaFloor = schemaFloor;
|
|
259
|
+
batch.status();
|
|
260
|
+
});
|
|
261
|
+
}
|
|
217
262
|
async close() {
|
|
218
263
|
this.#devtoolsUnregister?.();
|
|
219
264
|
this.#devtoolsUnregister = undefined;
|
|
@@ -244,6 +289,57 @@ export class SyncClient {
|
|
|
244
289
|
assertReadOnlyQuery(sql);
|
|
245
290
|
return stripSyncColumns(this.#db.query(sql, params));
|
|
246
291
|
}
|
|
292
|
+
/** Current durable local observer revision (SPEC §7.5). */
|
|
293
|
+
get localRevision() {
|
|
294
|
+
this.#requireStarted();
|
|
295
|
+
return getLocalRevision(this.#db);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Read rows, window answerability, and revision from one SQLite snapshot.
|
|
299
|
+
* Reactive integrations use this instead of composing `query()` and
|
|
300
|
+
* `windowState()` across separate worker/IPC calls.
|
|
301
|
+
*/
|
|
302
|
+
querySnapshot(spec) {
|
|
303
|
+
this.#requireStarted();
|
|
304
|
+
assertReadOnlyQuery(spec.sql);
|
|
305
|
+
return this.#db.transaction(() => {
|
|
306
|
+
const revision = getLocalRevision(this.#db);
|
|
307
|
+
const rows = stripSyncColumns(this.#db.query(spec.sql, spec.params));
|
|
308
|
+
const pending = [];
|
|
309
|
+
const missing = [];
|
|
310
|
+
for (const requested of spec.coverage ?? []) {
|
|
311
|
+
const baseKey = windowBaseKey(requested.base);
|
|
312
|
+
const live = new Map(loadWindowUnits(this.#db, baseKey).map((entry) => [
|
|
313
|
+
entry.unit,
|
|
314
|
+
entry.subId,
|
|
315
|
+
]));
|
|
316
|
+
for (const unit of new Set(requested.units)) {
|
|
317
|
+
const subId = live.get(unit);
|
|
318
|
+
const ref = { baseKey, unit };
|
|
319
|
+
if (subId === undefined) {
|
|
320
|
+
missing.push(ref);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const sub = getSubscription(this.#db, subId);
|
|
324
|
+
if (sub === undefined ||
|
|
325
|
+
sub.status !== 'active' ||
|
|
326
|
+
sub.cursor < 0 ||
|
|
327
|
+
sub.bootstrapState !== undefined) {
|
|
328
|
+
pending.push(ref);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
revision,
|
|
334
|
+
rows,
|
|
335
|
+
coverage: {
|
|
336
|
+
complete: pending.length === 0 && missing.length === 0,
|
|
337
|
+
pending,
|
|
338
|
+
missing,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
}
|
|
247
343
|
// -- live-query invalidation (TODO 3.1 / DESIGN-eviction I1–I4) -----------
|
|
248
344
|
/**
|
|
249
345
|
* Subscribe to fine-grained invalidation. The callback fires ONCE per
|
|
@@ -259,6 +355,24 @@ export class SyncClient {
|
|
|
259
355
|
onInvalidate(listener) {
|
|
260
356
|
return this.#invalidation.on(listener);
|
|
261
357
|
}
|
|
358
|
+
/** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
|
|
359
|
+
onChange(listener) {
|
|
360
|
+
return this.#changes.on(listener);
|
|
361
|
+
}
|
|
362
|
+
/** One call for the complete status domain used by reactive hosts. */
|
|
363
|
+
statusSnapshot() {
|
|
364
|
+
this.#requireStarted();
|
|
365
|
+
return this.#statusSnapshot();
|
|
366
|
+
}
|
|
367
|
+
#statusSnapshot() {
|
|
368
|
+
return {
|
|
369
|
+
outbox: listOutbox(this.#db).length,
|
|
370
|
+
upgrading: this.#upgrading,
|
|
371
|
+
leaseState: this.#leaseState,
|
|
372
|
+
schemaFloor: this.#schemaFloor,
|
|
373
|
+
syncNeeded: this.#needsPull,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
262
376
|
/**
|
|
263
377
|
* Run `fn` as one apply batch: install a fresh accumulator, collect every
|
|
264
378
|
* touched key, then emit exactly one coalesced event if anything changed.
|
|
@@ -268,17 +382,38 @@ export class SyncClient {
|
|
|
268
382
|
#applyBatch(fn) {
|
|
269
383
|
if (this.#batch !== undefined)
|
|
270
384
|
return fn(this.#batch);
|
|
271
|
-
const batch = new
|
|
272
|
-
|
|
385
|
+
const batch = new ChangeAccumulator();
|
|
386
|
+
let revision;
|
|
387
|
+
let status;
|
|
388
|
+
let result;
|
|
273
389
|
try {
|
|
274
|
-
|
|
390
|
+
this.#db.transaction(() => {
|
|
391
|
+
this.#batch = batch;
|
|
392
|
+
try {
|
|
393
|
+
result = fn(batch);
|
|
394
|
+
if (batch.touched) {
|
|
395
|
+
revision = bumpLocalRevision(this.#db);
|
|
396
|
+
if (batch.statusChanged)
|
|
397
|
+
status = this.#statusSnapshot();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
this.#batch = undefined;
|
|
402
|
+
}
|
|
403
|
+
});
|
|
275
404
|
}
|
|
276
|
-
|
|
405
|
+
catch (error) {
|
|
277
406
|
this.#batch = undefined;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
if (revision !== undefined) {
|
|
410
|
+
const event = batch.finish(revision, status);
|
|
411
|
+
this.#changes.emit(event);
|
|
412
|
+
const legacy = invalidationFromChange(event);
|
|
413
|
+
if (legacy !== undefined)
|
|
414
|
+
this.#invalidation.emit(legacy);
|
|
281
415
|
}
|
|
416
|
+
return result;
|
|
282
417
|
}
|
|
283
418
|
/**
|
|
284
419
|
* Run `fn` as the next link in the operation-serialization chain
|
|
@@ -295,22 +430,6 @@ export class SyncClient {
|
|
|
295
430
|
this.#opChain = next.then(() => undefined, () => undefined);
|
|
296
431
|
return next;
|
|
297
432
|
}
|
|
298
|
-
/** Async twin of {@link #applyBatch} for the pull/delta apply round. */
|
|
299
|
-
async #applyBatchAsync(fn) {
|
|
300
|
-
if (this.#batch !== undefined)
|
|
301
|
-
return fn(this.#batch);
|
|
302
|
-
const batch = new Invalidation();
|
|
303
|
-
this.#batch = batch;
|
|
304
|
-
try {
|
|
305
|
-
return await fn(batch);
|
|
306
|
-
}
|
|
307
|
-
finally {
|
|
308
|
-
this.#batch = undefined;
|
|
309
|
-
const event = batch.finish();
|
|
310
|
-
if (event !== undefined)
|
|
311
|
-
this.#invalidation.emit(event);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
433
|
// -- blobs (§5.9) ---------------------------------------------------------
|
|
315
434
|
/**
|
|
316
435
|
* Stage a blob for attachment (§5.9.7): hash the bytes into the content
|
|
@@ -607,6 +726,10 @@ export class SyncClient {
|
|
|
607
726
|
* re-registers realtime at round end (§8.7). No socket cycle needed.
|
|
608
727
|
*/
|
|
609
728
|
async setWindow(base, units) {
|
|
729
|
+
await this.setWindowCommand(base, units);
|
|
730
|
+
}
|
|
731
|
+
/** Exact core command result consumed by automatic host loops (§7.5). */
|
|
732
|
+
async setWindowCommand(base, units) {
|
|
610
733
|
this.#requireStarted();
|
|
611
734
|
const table = this.#table(base.table);
|
|
612
735
|
if (!table.scopeColumnByVariable.has(base.variable)) {
|
|
@@ -615,6 +738,8 @@ export class SyncClient {
|
|
|
615
738
|
// Serialize the whole window edit: it spans an `await deriveSubId` between
|
|
616
739
|
// db transactions, so without the chain a delta apply (or a concurrent
|
|
617
740
|
// setWindow) could interleave its transactions and corrupt the registry.
|
|
741
|
+
let changed = false;
|
|
742
|
+
let widened = false;
|
|
618
743
|
await this.#serialize(async () => {
|
|
619
744
|
const baseKey = windowBaseKey(base);
|
|
620
745
|
const wanted = new Set(units);
|
|
@@ -625,27 +750,37 @@ export class SyncClient {
|
|
|
625
750
|
if (liveByUnit.has(unit))
|
|
626
751
|
continue;
|
|
627
752
|
const subId = await deriveSubId(base, unit);
|
|
628
|
-
this.#
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
753
|
+
this.#applyBatch((batch) => {
|
|
754
|
+
this.#db.transaction(() => {
|
|
755
|
+
// Re-entry cancels any deferred eviction for this sub id.
|
|
756
|
+
deletePendingEviction(this.#db, subId);
|
|
757
|
+
insertWindowUnit(this.#db, baseKey, unit, subId);
|
|
758
|
+
saveSubscription(this.#db, {
|
|
759
|
+
id: subId,
|
|
760
|
+
table: base.table,
|
|
761
|
+
scopes: unitScopes(base, unit),
|
|
762
|
+
...(base.params !== undefined ? { params: base.params } : {}),
|
|
763
|
+
cursor: -1,
|
|
764
|
+
status: 'active',
|
|
765
|
+
});
|
|
639
766
|
});
|
|
767
|
+
batch.window(baseKey, base.table, unit);
|
|
640
768
|
});
|
|
769
|
+
changed = true;
|
|
770
|
+
widened = true;
|
|
641
771
|
}
|
|
642
772
|
// Shrink: units live but not wanted → unsubscribe fused with eviction.
|
|
643
773
|
for (const { unit, subId } of live) {
|
|
644
774
|
if (wanted.has(unit))
|
|
645
775
|
continue;
|
|
646
776
|
this.#evictUnit(baseKey, base, unit, subId);
|
|
777
|
+
changed = true;
|
|
647
778
|
}
|
|
648
779
|
});
|
|
780
|
+
const effects = {
|
|
781
|
+
sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
|
|
782
|
+
};
|
|
783
|
+
return { value: undefined, effects };
|
|
649
784
|
}
|
|
650
785
|
/**
|
|
651
786
|
* The completeness oracle (§4.8 I3): which units of a base are windowed-in
|
|
@@ -665,6 +800,7 @@ export class SyncClient {
|
|
|
665
800
|
for (const { unit, subId } of live) {
|
|
666
801
|
const sub = getSubscription(this.#db, subId);
|
|
667
802
|
if (sub === undefined ||
|
|
803
|
+
sub.status !== 'active' ||
|
|
668
804
|
sub.cursor < 0 ||
|
|
669
805
|
sub.bootstrapState !== undefined) {
|
|
670
806
|
pending.push(unit);
|
|
@@ -701,9 +837,8 @@ export class SyncClient {
|
|
|
701
837
|
deletePendingEviction(this.#db, subId);
|
|
702
838
|
}
|
|
703
839
|
});
|
|
704
|
-
// I1: eviction is a bulk delete — a query over the evicted unit re-runs.
|
|
705
|
-
batch.table(table.name);
|
|
706
840
|
batch.scopeMap(table, effective);
|
|
841
|
+
batch.window(baseKey, table.name, unit);
|
|
707
842
|
});
|
|
708
843
|
}
|
|
709
844
|
/**
|
|
@@ -729,7 +864,6 @@ export class SyncClient {
|
|
|
729
864
|
if (!deferred)
|
|
730
865
|
deletePendingEviction(this.#db, entry.subId);
|
|
731
866
|
});
|
|
732
|
-
batch.table(table.name);
|
|
733
867
|
batch.scopeMap(table, entry.effective);
|
|
734
868
|
});
|
|
735
869
|
}
|
|
@@ -792,10 +926,18 @@ export class SyncClient {
|
|
|
792
926
|
this.#db.transaction(() => {
|
|
793
927
|
appendOutboxCommit(this.#db, clientCommitId, operations, this.#now());
|
|
794
928
|
this.#applyOperationsLocally(operations, batch);
|
|
929
|
+
batch.status();
|
|
795
930
|
});
|
|
796
931
|
});
|
|
797
932
|
return clientCommitId;
|
|
798
933
|
}
|
|
934
|
+
/** Host-facing mutation result with explicit network work intent (§7.5). */
|
|
935
|
+
mutateCommand(mutations) {
|
|
936
|
+
return {
|
|
937
|
+
value: this.mutate(mutations),
|
|
938
|
+
effects: { sync: { kind: 'interactive' } },
|
|
939
|
+
};
|
|
940
|
+
}
|
|
799
941
|
/**
|
|
800
942
|
* Partial-update convenience over the §6.1 full-row wire: read the
|
|
801
943
|
* current LOCAL row, merge `partial` over it, and record one full-row
|
|
@@ -831,11 +973,21 @@ export class SyncClient {
|
|
|
831
973
|
},
|
|
832
974
|
]);
|
|
833
975
|
}
|
|
976
|
+
/** Host-facing patch result with explicit network work intent (§7.5). */
|
|
977
|
+
patchCommand(table, rowId, partial, options) {
|
|
978
|
+
return {
|
|
979
|
+
value: this.patch(table, rowId, partial, options),
|
|
980
|
+
effects: { sync: { kind: 'interactive' } },
|
|
981
|
+
};
|
|
982
|
+
}
|
|
834
983
|
// -- lease state (§7.3.5) ---------------------------------------------------
|
|
835
984
|
/** Merge and persist the lease state (opaque, §7.3.5). */
|
|
836
985
|
#setLeaseState(next) {
|
|
837
|
-
this.#
|
|
838
|
-
|
|
986
|
+
this.#applyBatch((batch) => {
|
|
987
|
+
this.#leaseState = next;
|
|
988
|
+
setMeta(this.#db, 'leaseState', JSON.stringify(next));
|
|
989
|
+
batch.status();
|
|
990
|
+
});
|
|
839
991
|
}
|
|
840
992
|
/** The request-level lease error codes (§7.3.4): stop-and-surface. */
|
|
841
993
|
#isLeaseErrorCode(code) {
|
|
@@ -889,7 +1041,7 @@ export class SyncClient {
|
|
|
889
1041
|
* purely-optimistic rows are undone, and a rejection record is raised.
|
|
890
1042
|
*/
|
|
891
1043
|
#dropIncompatibleCommit(commit, message) {
|
|
892
|
-
this.#
|
|
1044
|
+
this.#applyBatch((batch) => {
|
|
893
1045
|
deleteOutboxCommit(this.#db, commit.clientCommitId);
|
|
894
1046
|
for (const operation of commit.operations) {
|
|
895
1047
|
if (operation.op !== 'upsert')
|
|
@@ -899,19 +1051,24 @@ export class SyncClient {
|
|
|
899
1051
|
continue;
|
|
900
1052
|
const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
|
|
901
1053
|
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
1054
|
+
if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
|
|
1055
|
+
batch.table(table.name);
|
|
1056
|
+
}
|
|
902
1057
|
deleteLocalRow(this.#db, table, operation.rowId);
|
|
903
1058
|
}
|
|
904
1059
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1060
|
+
this.#rejections.push({
|
|
1061
|
+
clientCommitId: commit.clientCommitId,
|
|
1062
|
+
opIndex: 0,
|
|
1063
|
+
code: OUTBOX_INCOMPATIBLE_CODE,
|
|
1064
|
+
message,
|
|
1065
|
+
retryable: false,
|
|
1066
|
+
...(commit.operations[0] !== undefined
|
|
1067
|
+
? { operation: commit.operations[0] }
|
|
1068
|
+
: {}),
|
|
1069
|
+
});
|
|
1070
|
+
batch.status();
|
|
1071
|
+
batch.rejections();
|
|
915
1072
|
});
|
|
916
1073
|
}
|
|
917
1074
|
// -- sync -------------------------------------------------------------------
|
|
@@ -946,7 +1103,7 @@ export class SyncClient {
|
|
|
946
1103
|
// Cleared before the round, not after: a wake-up (or a delta dropped
|
|
947
1104
|
// because this pull is mid-flight) that lands during the round must
|
|
948
1105
|
// survive it — the reference server keeps no replay buffer (§8.2).
|
|
949
|
-
this.#
|
|
1106
|
+
this.#setSyncNeeded(false);
|
|
950
1107
|
try {
|
|
951
1108
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
952
1109
|
// them, so the server-side existence check (§6.6) passes.
|
|
@@ -1001,10 +1158,11 @@ export class SyncClient {
|
|
|
1001
1158
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
1002
1159
|
// a shrunk window unit — retry any deferred evictions now.
|
|
1003
1160
|
this.#drainPendingEvictions();
|
|
1161
|
+
this.#retryDelayMs = 250;
|
|
1004
1162
|
if (deferred > 0) {
|
|
1005
1163
|
// §6.1 splitBatch remainder: more queued commits than this request
|
|
1006
1164
|
// could carry — keep the sync-needed signal raised for the host.
|
|
1007
|
-
this.#
|
|
1165
|
+
this.#setSyncNeeded(true);
|
|
1008
1166
|
return { ...summary, deferredCommits: deferred };
|
|
1009
1167
|
}
|
|
1010
1168
|
return summary;
|
|
@@ -1022,6 +1180,22 @@ export class SyncClient {
|
|
|
1022
1180
|
errorCode: code,
|
|
1023
1181
|
});
|
|
1024
1182
|
}
|
|
1183
|
+
const explicitlyRetryable = error.retryable;
|
|
1184
|
+
const retryable = explicitlyRetryable === true ||
|
|
1185
|
+
(explicitlyRetryable === undefined && typeof code !== 'string');
|
|
1186
|
+
if (retryable) {
|
|
1187
|
+
const intent = {
|
|
1188
|
+
kind: 'background',
|
|
1189
|
+
delayMs: this.#retryDelayMs,
|
|
1190
|
+
};
|
|
1191
|
+
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
|
|
1192
|
+
try {
|
|
1193
|
+
this.#config.onSyncIntent?.(intent);
|
|
1194
|
+
}
|
|
1195
|
+
catch {
|
|
1196
|
+
// An observer cannot alter sync correctness.
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1025
1199
|
throw error;
|
|
1026
1200
|
}
|
|
1027
1201
|
finally {
|
|
@@ -1160,14 +1334,14 @@ export class SyncClient {
|
|
|
1160
1334
|
const event = parsed.event;
|
|
1161
1335
|
if (event.event === 'hello') {
|
|
1162
1336
|
if (event.data.requiresSync) {
|
|
1163
|
-
this.#
|
|
1337
|
+
this.#setSyncNeeded(true);
|
|
1164
1338
|
this.#config.onSyncNeeded?.('hello');
|
|
1165
1339
|
}
|
|
1166
1340
|
return;
|
|
1167
1341
|
}
|
|
1168
1342
|
if (event.event === 'sync') {
|
|
1169
1343
|
// §8.3: any wake-up means "run a pull soon", never data.
|
|
1170
|
-
this.#
|
|
1344
|
+
this.#setSyncNeeded(true);
|
|
1171
1345
|
this.#config.onSyncNeeded?.(event.data.reason);
|
|
1172
1346
|
return;
|
|
1173
1347
|
}
|
|
@@ -1221,7 +1395,7 @@ export class SyncClient {
|
|
|
1221
1395
|
// worth it. (An optimization; the op chain below is the correctness
|
|
1222
1396
|
// mechanism — it also excludes a delta from racing a `setWindow` or a
|
|
1223
1397
|
// sync round that started between this check and the apply.)
|
|
1224
|
-
this.#
|
|
1398
|
+
this.#setSyncNeeded(true);
|
|
1225
1399
|
return;
|
|
1226
1400
|
}
|
|
1227
1401
|
// Serialize the apply on the operation chain: a delta must never
|
|
@@ -1237,7 +1411,7 @@ export class SyncClient {
|
|
|
1237
1411
|
}
|
|
1238
1412
|
catch {
|
|
1239
1413
|
// A delta that cannot be applied is recovered by a pull (§8.3).
|
|
1240
|
-
this.#
|
|
1414
|
+
this.#setSyncNeeded(true);
|
|
1241
1415
|
this.#config.onSyncNeeded?.('catchup-required');
|
|
1242
1416
|
}
|
|
1243
1417
|
});
|
|
@@ -1274,140 +1448,166 @@ export class SyncClient {
|
|
|
1274
1448
|
// version this client sends. The §7.4.2 trigger-2 convergence runs
|
|
1275
1449
|
// when the APP updates (recreating the client with a new generated
|
|
1276
1450
|
// schema), which fires the boot-time §7.4.1 marker check instead.
|
|
1277
|
-
|
|
1451
|
+
const schemaFloor = {
|
|
1278
1452
|
requiredSchemaVersion: header.requiredSchemaVersion,
|
|
1279
1453
|
...(header.latestSchemaVersion !== undefined
|
|
1280
1454
|
? { latestSchemaVersion: header.latestSchemaVersion }
|
|
1281
1455
|
: {}),
|
|
1282
1456
|
};
|
|
1457
|
+
this.#setSchemaFloor(schemaFloor);
|
|
1283
1458
|
return {
|
|
1284
1459
|
...summary,
|
|
1285
1460
|
bootstrapping: [],
|
|
1286
|
-
schemaFloor
|
|
1461
|
+
schemaFloor,
|
|
1287
1462
|
};
|
|
1288
1463
|
}
|
|
1289
1464
|
let section;
|
|
1290
1465
|
let errorFrame;
|
|
1291
1466
|
let deltaCursor = -1;
|
|
1292
|
-
//
|
|
1293
|
-
//
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1467
|
+
// Each durable observer transaction emits its own revisioned batch.
|
|
1468
|
+
// Async decrypt/download work happens outside SQLite transactions.
|
|
1469
|
+
try {
|
|
1470
|
+
for (const frame of message.frames.slice(1)) {
|
|
1471
|
+
switch (frame.type) {
|
|
1472
|
+
case 'RESP_HEADER':
|
|
1473
|
+
break;
|
|
1474
|
+
case 'LEASE':
|
|
1475
|
+
// §7.3.5: persist the opaque lease and clear any prior lease
|
|
1476
|
+
// error — a fresh lease means the outage/revocation is over.
|
|
1477
|
+
this.#setLeaseState({
|
|
1478
|
+
leaseId: frame.leaseId,
|
|
1479
|
+
expiresAtMs: frame.expiresAtMs,
|
|
1480
|
+
});
|
|
1481
|
+
break;
|
|
1482
|
+
case 'PUSH_RESULT':
|
|
1483
|
+
this.#applyBatch((batch) => this.#handlePushResult(frame, commitsById, summary, batch));
|
|
1484
|
+
break;
|
|
1485
|
+
case 'SUB_START': {
|
|
1486
|
+
const sub = subsById.get(frame.id);
|
|
1487
|
+
const fresh = sub !== undefined &&
|
|
1488
|
+
sub.cursor < 0 &&
|
|
1489
|
+
sub.bootstrapState === undefined &&
|
|
1490
|
+
frame.bootstrap;
|
|
1491
|
+
const skip = sub === undefined ||
|
|
1492
|
+
(mode === 'delta' &&
|
|
1493
|
+
(sub.status !== 'active' || sub.bootstrapState !== undefined));
|
|
1494
|
+
section = { start: frame, sub, fresh, skip, cleared: false };
|
|
1495
|
+
break;
|
|
1496
|
+
}
|
|
1497
|
+
case 'COMMIT':
|
|
1498
|
+
if (section !== undefined && !section.skip) {
|
|
1499
|
+
await this.#applyCommit(frame, summary);
|
|
1324
1500
|
}
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
case 'SEGMENT_INLINE': {
|
|
1331
|
-
if (section === undefined ||
|
|
1332
|
-
section.skip ||
|
|
1333
|
-
section.sub === undefined) {
|
|
1334
|
-
break;
|
|
1335
|
-
}
|
|
1336
|
-
const segment = decodeRowsSegment(frame.payload);
|
|
1337
|
-
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, { clearFirst, effective }, this.#encryption), section.fresh && !section.cleared);
|
|
1501
|
+
break;
|
|
1502
|
+
case 'SEGMENT_INLINE': {
|
|
1503
|
+
if (section === undefined ||
|
|
1504
|
+
section.skip ||
|
|
1505
|
+
section.sub === undefined) {
|
|
1338
1506
|
break;
|
|
1339
1507
|
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
(this.#acceptMask() & ACCEPT_SQLITE) === 0) {
|
|
1350
|
-
throw new ClientSyncError('sync.invalid_request', 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
|
|
1351
|
-
}
|
|
1352
|
-
const bytes = await this.#downloadSegment(frame, section.sub);
|
|
1353
|
-
if (frame.mediaType === 'sqlite') {
|
|
1354
|
-
// §5.3: images are whole-table — a paged descriptor is
|
|
1355
|
-
// invalid, and the image is always its table's first page.
|
|
1356
|
-
if (frame.rowCursor !== undefined ||
|
|
1357
|
-
frame.nextRowCursor !== undefined) {
|
|
1358
|
-
throw new ClientSyncError('sync.invalid_request', 'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
|
|
1508
|
+
const segment = decodeRowsSegment(frame.payload);
|
|
1509
|
+
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
|
|
1510
|
+
clearFirst,
|
|
1511
|
+
effective,
|
|
1512
|
+
transaction: (fn) => this.#applyBatch((batch) => {
|
|
1513
|
+
if (segment.blocks.some((block) => block.length > 0) ||
|
|
1514
|
+
(clearFirst &&
|
|
1515
|
+
this.#scopedRowsExist(table, effective))) {
|
|
1516
|
+
batch.table(table.name);
|
|
1359
1517
|
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, { clearFirst, effective }, this.#encryption), section.fresh &&
|
|
1370
|
-
!section.cleared &&
|
|
1371
|
-
frame.rowCursor === undefined);
|
|
1372
|
-
}
|
|
1518
|
+
return fn();
|
|
1519
|
+
}),
|
|
1520
|
+
}, this.#encryption), section.fresh && !section.cleared);
|
|
1521
|
+
break;
|
|
1522
|
+
}
|
|
1523
|
+
case 'SEGMENT_REF': {
|
|
1524
|
+
if (section === undefined ||
|
|
1525
|
+
section.skip ||
|
|
1526
|
+
section.sub === undefined) {
|
|
1373
1527
|
break;
|
|
1374
1528
|
}
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1529
|
+
// §4.2: a descriptor whose mediaType was not advertised is a
|
|
1530
|
+
// broken server — fail loud, never skip or guess.
|
|
1531
|
+
if (frame.mediaType === 'sqlite' &&
|
|
1532
|
+
(this.#acceptMask() & ACCEPT_SQLITE) === 0) {
|
|
1533
|
+
throw new ClientSyncError('sync.invalid_request', 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
|
|
1534
|
+
}
|
|
1535
|
+
const bytes = await this.#downloadSegment(frame, section.sub);
|
|
1536
|
+
if (frame.mediaType === 'sqlite') {
|
|
1537
|
+
// §5.3: images are whole-table — a paged descriptor is
|
|
1538
|
+
// invalid, and the image is always its table's first page.
|
|
1539
|
+
if (frame.rowCursor !== undefined ||
|
|
1540
|
+
frame.nextRowCursor !== undefined) {
|
|
1541
|
+
throw new ClientSyncError('sync.invalid_request', 'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
|
|
1383
1542
|
}
|
|
1384
|
-
section
|
|
1385
|
-
|
|
1543
|
+
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applySqliteSegment(this.#db, this.#schema, table, bytes, {
|
|
1544
|
+
table: frame.table,
|
|
1545
|
+
rowCount: frame.rowCount,
|
|
1546
|
+
asOfCommitSeq: frame.asOfCommitSeq,
|
|
1547
|
+
scopeDigest: frame.scopeDigest,
|
|
1548
|
+
}, {
|
|
1549
|
+
clearFirst,
|
|
1550
|
+
effective,
|
|
1551
|
+
transaction: (fn) => this.#applyBatch((batch) => {
|
|
1552
|
+
if (frame.rowCount > 0 ||
|
|
1553
|
+
(clearFirst &&
|
|
1554
|
+
this.#scopedRowsExist(table, effective))) {
|
|
1555
|
+
batch.table(table.name);
|
|
1556
|
+
}
|
|
1557
|
+
return fn();
|
|
1558
|
+
}),
|
|
1559
|
+
}), section.fresh && !section.cleared);
|
|
1386
1560
|
}
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1561
|
+
else {
|
|
1562
|
+
const segment = decodeRowsSegment(bytes);
|
|
1563
|
+
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
|
|
1564
|
+
clearFirst,
|
|
1565
|
+
effective,
|
|
1566
|
+
transaction: (fn) => this.#applyBatch((batch) => {
|
|
1567
|
+
if (segment.blocks.some((block) => block.length > 0) ||
|
|
1568
|
+
(clearFirst &&
|
|
1569
|
+
this.#scopedRowsExist(table, effective))) {
|
|
1570
|
+
batch.table(table.name);
|
|
1571
|
+
}
|
|
1572
|
+
return fn();
|
|
1573
|
+
}),
|
|
1574
|
+
}, this.#encryption), section.fresh &&
|
|
1575
|
+
!section.cleared &&
|
|
1576
|
+
frame.rowCursor === undefined);
|
|
1577
|
+
}
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
case 'SUB_END': {
|
|
1581
|
+
if (section !== undefined &&
|
|
1582
|
+
!section.skip &&
|
|
1583
|
+
section.sub !== undefined) {
|
|
1584
|
+
const applied = this.#finishSection(section.sub, section.start, frame.nextCursor, frame.bootstrapState, summary);
|
|
1585
|
+
if (mode === 'delta' && applied) {
|
|
1586
|
+
deltaCursor = Math.max(deltaCursor, frame.nextCursor);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
section = undefined;
|
|
1590
|
+
break;
|
|
1395
1591
|
}
|
|
1396
|
-
|
|
1592
|
+
case 'ERROR':
|
|
1593
|
+
// §1.4 rule 5 / §1.6: the request failed; the open
|
|
1594
|
+
// subscription's SUB_END values are never persisted.
|
|
1595
|
+
errorFrame = new ClientSyncError(frame.code, frame.message, frame.retryable);
|
|
1596
|
+
section = undefined;
|
|
1397
1597
|
break;
|
|
1598
|
+
case 'UNKNOWN':
|
|
1599
|
+
break; // §1.2 rule 2: skipped, never interpreted
|
|
1398
1600
|
}
|
|
1601
|
+
if (errorFrame !== undefined)
|
|
1602
|
+
break;
|
|
1399
1603
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
// purge below deletes orphaned bodies with deleteOrphans (B2).
|
|
1408
|
-
this.#reconcileBlobs(false);
|
|
1409
|
-
}
|
|
1410
|
-
});
|
|
1604
|
+
}
|
|
1605
|
+
finally {
|
|
1606
|
+
// §7.1: local reads see outbox state applied optimistically — replay
|
|
1607
|
+
// the still-pending commits on top of the freshly applied server state.
|
|
1608
|
+
this.#replayOutbox();
|
|
1609
|
+
this.#reconcileBlobs(false);
|
|
1610
|
+
}
|
|
1411
1611
|
if (errorFrame !== undefined)
|
|
1412
1612
|
throw errorFrame;
|
|
1413
1613
|
if (mode === 'delta') {
|
|
@@ -1427,7 +1627,7 @@ export class SyncClient {
|
|
|
1427
1627
|
}
|
|
1428
1628
|
return { ...summary, bootstrapping };
|
|
1429
1629
|
}
|
|
1430
|
-
#handlePushResult(frame, commitsById, summary) {
|
|
1630
|
+
#handlePushResult(frame, commitsById, summary, batch) {
|
|
1431
1631
|
const commit = commitsById.get(frame.clientCommitId);
|
|
1432
1632
|
if (commit === undefined)
|
|
1433
1633
|
return;
|
|
@@ -1435,6 +1635,7 @@ export class SyncClient {
|
|
|
1435
1635
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
1436
1636
|
// "already applied, you may have missed the ack".
|
|
1437
1637
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
1638
|
+
batch.status();
|
|
1438
1639
|
summary.applied.push(frame.clientCommitId);
|
|
1439
1640
|
return;
|
|
1440
1641
|
}
|
|
@@ -1463,6 +1664,7 @@ export class SyncClient {
|
|
|
1463
1664
|
...(operation !== undefined ? { operation } : {}),
|
|
1464
1665
|
};
|
|
1465
1666
|
this.#conflicts.push(conflict);
|
|
1667
|
+
batch.conflicts();
|
|
1466
1668
|
summary.conflicts.push(conflict);
|
|
1467
1669
|
this.#config.onConflict?.(conflict);
|
|
1468
1670
|
}
|
|
@@ -1475,6 +1677,7 @@ export class SyncClient {
|
|
|
1475
1677
|
retryable: result.retryable,
|
|
1476
1678
|
...(operation !== undefined ? { operation } : {}),
|
|
1477
1679
|
});
|
|
1680
|
+
batch.rejections();
|
|
1478
1681
|
}
|
|
1479
1682
|
}
|
|
1480
1683
|
// §7.2: stop optimistic display and decide about dependents — the
|
|
@@ -1491,10 +1694,14 @@ export class SyncClient {
|
|
|
1491
1694
|
continue;
|
|
1492
1695
|
const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
|
|
1493
1696
|
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
1697
|
+
if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
|
|
1698
|
+
batch.table(table.name);
|
|
1699
|
+
}
|
|
1494
1700
|
deleteLocalRow(this.#db, table, operation.rowId);
|
|
1495
1701
|
}
|
|
1496
1702
|
}
|
|
1497
1703
|
});
|
|
1704
|
+
batch.status();
|
|
1498
1705
|
summary.rejected.push(frame.clientCommitId);
|
|
1499
1706
|
}
|
|
1500
1707
|
#decodeServerRow(tableName, payload) {
|
|
@@ -1511,14 +1718,14 @@ export class SyncClient {
|
|
|
1511
1718
|
return record;
|
|
1512
1719
|
}
|
|
1513
1720
|
async #applyCommit(frame, summary) {
|
|
1514
|
-
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption)
|
|
1721
|
+
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
1722
|
+
this.#recordCommitChanges(batch, frame);
|
|
1723
|
+
return fn();
|
|
1724
|
+
}));
|
|
1515
1725
|
summary.commitsApplied += 1;
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
const batch = this.#batch;
|
|
1520
|
-
if (batch === undefined)
|
|
1521
|
-
return;
|
|
1726
|
+
}
|
|
1727
|
+
/** Record before + after scope keys while the commit transaction is open. */
|
|
1728
|
+
#recordCommitChanges(batch, frame) {
|
|
1522
1729
|
for (const change of frame.changes) {
|
|
1523
1730
|
const tableName = frame.tables[change.tableIndex];
|
|
1524
1731
|
if (tableName === undefined)
|
|
@@ -1526,9 +1733,53 @@ export class SyncClient {
|
|
|
1526
1733
|
const table = this.#schema.tables.get(tableName);
|
|
1527
1734
|
if (table === undefined)
|
|
1528
1735
|
continue;
|
|
1529
|
-
batch.
|
|
1736
|
+
let precise = this.#recordStoredRowScopes(batch, table, change.rowId);
|
|
1737
|
+
for (const variable of Object.keys(change.scopes)) {
|
|
1738
|
+
if (table.scopePrefixByVariable.has(variable))
|
|
1739
|
+
precise = true;
|
|
1740
|
+
}
|
|
1530
1741
|
batch.changeScopes(table, change.scopes);
|
|
1742
|
+
if (!precise)
|
|
1743
|
+
batch.table(tableName);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
/** Add the currently materialized row's scope keys; returns whether known. */
|
|
1747
|
+
#recordStoredRowScopes(batch, table, rowId) {
|
|
1748
|
+
const mappings = [...table.scopeColumnByVariable].filter(([variable]) => table.scopePrefixByVariable.has(variable));
|
|
1749
|
+
if (mappings.length === 0)
|
|
1750
|
+
return false;
|
|
1751
|
+
const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
|
|
1752
|
+
FROM ${quoteIdent(table.name)}
|
|
1753
|
+
WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId])[0];
|
|
1754
|
+
if (row === undefined)
|
|
1755
|
+
return false;
|
|
1756
|
+
let recorded = false;
|
|
1757
|
+
for (const [variable, column] of mappings) {
|
|
1758
|
+
const value = row[column];
|
|
1759
|
+
const prefix = table.scopePrefixByVariable.get(variable);
|
|
1760
|
+
if (value != null && prefix !== undefined) {
|
|
1761
|
+
batch.scope(table.name, `${prefix}:${String(value)}`);
|
|
1762
|
+
recorded = true;
|
|
1763
|
+
}
|
|
1531
1764
|
}
|
|
1765
|
+
return recorded;
|
|
1766
|
+
}
|
|
1767
|
+
/** Whether a fresh-bootstrap clear would remove at least one local row. */
|
|
1768
|
+
#scopedRowsExist(table, effective) {
|
|
1769
|
+
const entries = Object.entries(effective);
|
|
1770
|
+
if (entries.length === 0)
|
|
1771
|
+
return false;
|
|
1772
|
+
const clauses = [];
|
|
1773
|
+
const params = [];
|
|
1774
|
+
for (const [variable, values] of entries) {
|
|
1775
|
+
const column = table.scopeColumnByVariable.get(variable);
|
|
1776
|
+
if (column === undefined || values.length === 0)
|
|
1777
|
+
return false;
|
|
1778
|
+
clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
|
|
1779
|
+
params.push(...values);
|
|
1780
|
+
}
|
|
1781
|
+
return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)}
|
|
1782
|
+
WHERE ${clauses.join(' AND ')} LIMIT 1`, params).length > 0);
|
|
1532
1783
|
}
|
|
1533
1784
|
/**
|
|
1534
1785
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
@@ -1543,26 +1794,27 @@ export class SyncClient {
|
|
|
1543
1794
|
try {
|
|
1544
1795
|
summary.segmentRowsApplied += await apply(table, clearFirst, section.start.effectiveScopes);
|
|
1545
1796
|
section.cleared = true;
|
|
1546
|
-
// I1/I2: segments carry only a table + scopeDigest, never per-row
|
|
1547
|
-
// scope keys — invalidate the table plus the subscription's effective
|
|
1548
|
-
// scope keys (the coarsest honest key for bulk data).
|
|
1549
|
-
this.#batch?.table(table.name);
|
|
1550
|
-
this.#batch?.scopeMap(table, section.start.effectiveScopes);
|
|
1551
1797
|
}
|
|
1552
1798
|
catch (error) {
|
|
1553
1799
|
if (error instanceof ClientSyncError &&
|
|
1554
1800
|
error.code === 'sync.scope_revoked') {
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1801
|
+
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
1802
|
+
this.#applyBatch((batch) => {
|
|
1803
|
+
saveSubscription(this.#db, {
|
|
1804
|
+
id: sub.id,
|
|
1805
|
+
table: sub.table,
|
|
1806
|
+
scopes: sub.scopes,
|
|
1807
|
+
...(sub.params !== undefined ? { params: sub.params } : {}),
|
|
1808
|
+
cursor: sub.cursor,
|
|
1809
|
+
...(sub.effectiveScopes !== undefined
|
|
1810
|
+
? { effectiveScopes: sub.effectiveScopes }
|
|
1811
|
+
: {}),
|
|
1812
|
+
status: 'failed',
|
|
1813
|
+
reasonCode: 'sync.scope_revoked',
|
|
1814
|
+
});
|
|
1815
|
+
if (registered !== undefined) {
|
|
1816
|
+
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
1817
|
+
}
|
|
1566
1818
|
});
|
|
1567
1819
|
summary.failed.push(sub.id);
|
|
1568
1820
|
section.skip = true;
|
|
@@ -1618,38 +1870,47 @@ export class SyncClient {
|
|
|
1618
1870
|
// An absent bootstrapState clears any previous resume token (§4.4:
|
|
1619
1871
|
// absent = bootstrap complete, or not bootstrapping).
|
|
1620
1872
|
const wasPending = sub.cursor < 0 || sub.bootstrapState !== undefined;
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1873
|
+
const completed = wasPending && nextCursor >= 0 && bootstrapState === undefined;
|
|
1874
|
+
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
1875
|
+
this.#applyBatch((batch) => {
|
|
1876
|
+
saveSubscription(this.#db, {
|
|
1877
|
+
id: sub.id,
|
|
1878
|
+
table: sub.table,
|
|
1879
|
+
scopes: sub.scopes,
|
|
1880
|
+
...(sub.params !== undefined ? { params: sub.params } : {}),
|
|
1881
|
+
cursor: nextCursor,
|
|
1882
|
+
...(bootstrapState !== undefined ? { bootstrapState } : {}),
|
|
1883
|
+
effectiveScopes: start.effectiveScopes,
|
|
1884
|
+
status: 'active',
|
|
1885
|
+
});
|
|
1886
|
+
if (completed && registered !== undefined) {
|
|
1887
|
+
// A zero-row bootstrap is a window-domain transition, not a fake
|
|
1888
|
+
// row/table change (SPEC §4.8 / §7.5).
|
|
1889
|
+
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
1890
|
+
}
|
|
1630
1891
|
});
|
|
1631
|
-
if (wasPending && nextCursor >= 0 && bootstrapState === undefined) {
|
|
1632
|
-
// §4.8: the completeness verdict flipped pending → complete. A
|
|
1633
|
-
// zero-row bootstrap applies nothing, so the flip itself must reach
|
|
1634
|
-
// live oracles through the choke point (shares the pull's batch).
|
|
1635
|
-
this.#applyBatch((batch) => batch.table(sub.table));
|
|
1636
|
-
}
|
|
1637
1892
|
return true;
|
|
1638
1893
|
}
|
|
1639
1894
|
if (start.status === 'reset') {
|
|
1640
1895
|
// §4.6: discard cursor + resume token, keep local rows, re-bootstrap
|
|
1641
1896
|
// with cursor = -1 on the next pull. Staleness, not a purge.
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1897
|
+
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
1898
|
+
this.#applyBatch((batch) => {
|
|
1899
|
+
saveSubscription(this.#db, {
|
|
1900
|
+
id: sub.id,
|
|
1901
|
+
table: sub.table,
|
|
1902
|
+
scopes: sub.scopes,
|
|
1903
|
+
...(sub.params !== undefined ? { params: sub.params } : {}),
|
|
1904
|
+
cursor: -1,
|
|
1905
|
+
...(sub.effectiveScopes !== undefined
|
|
1906
|
+
? { effectiveScopes: sub.effectiveScopes }
|
|
1907
|
+
: {}),
|
|
1908
|
+
status: 'active',
|
|
1909
|
+
reasonCode: start.reasonCode,
|
|
1910
|
+
});
|
|
1911
|
+
if (registered !== undefined) {
|
|
1912
|
+
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
1913
|
+
}
|
|
1653
1914
|
});
|
|
1654
1915
|
summary.resets.push(sub.id);
|
|
1655
1916
|
return false;
|
|
@@ -1658,44 +1919,44 @@ export class SyncClient {
|
|
|
1658
1919
|
// (never the requested map), drop doomed outbox commits, stop pulling.
|
|
1659
1920
|
const table = this.#table(sub.table);
|
|
1660
1921
|
const lastEffective = sub.effectiveScopes;
|
|
1922
|
+
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
1661
1923
|
let failed = false;
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1924
|
+
this.#applyBatch((batch) => {
|
|
1925
|
+
if (lastEffective !== undefined &&
|
|
1926
|
+
Object.keys(lastEffective).length > 0) {
|
|
1927
|
+
try {
|
|
1665
1928
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
dropOutboxCommitsInScope(this.#db, table, lastEffective);
|
|
1672
|
-
// §5.9.7 B2: revocation deletes now-unauthorized blob bodies —
|
|
1673
|
-
// reconcile with deleteOrphans (evicted ≠ revoked).
|
|
1674
|
-
this.#reconcileBlobs(true);
|
|
1675
|
-
}
|
|
1676
|
-
catch (error) {
|
|
1677
|
-
if (error instanceof ClientSyncError &&
|
|
1678
|
-
error.code === 'sync.scope_revoked') {
|
|
1679
|
-
// Fail closed: no local mapping — surface a fatal configuration
|
|
1680
|
-
// error and stop syncing the table without clearing anything.
|
|
1681
|
-
failed = true;
|
|
1929
|
+
batch.scopeMap(table, lastEffective);
|
|
1930
|
+
if (dropOutboxCommitsInScope(this.#db, table, lastEffective).length > 0) {
|
|
1931
|
+
batch.status();
|
|
1932
|
+
}
|
|
1933
|
+
this.#reconcileBlobs(true);
|
|
1682
1934
|
}
|
|
1683
|
-
|
|
1684
|
-
|
|
1935
|
+
catch (error) {
|
|
1936
|
+
if (error instanceof ClientSyncError &&
|
|
1937
|
+
error.code === 'sync.scope_revoked') {
|
|
1938
|
+
failed = true;
|
|
1939
|
+
}
|
|
1940
|
+
else {
|
|
1941
|
+
throw error;
|
|
1942
|
+
}
|
|
1685
1943
|
}
|
|
1686
1944
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
:
|
|
1697
|
-
|
|
1698
|
-
|
|
1945
|
+
saveSubscription(this.#db, {
|
|
1946
|
+
id: sub.id,
|
|
1947
|
+
table: sub.table,
|
|
1948
|
+
scopes: sub.scopes,
|
|
1949
|
+
...(sub.params !== undefined ? { params: sub.params } : {}),
|
|
1950
|
+
cursor: nextCursor,
|
|
1951
|
+
...(lastEffective !== undefined
|
|
1952
|
+
? { effectiveScopes: lastEffective }
|
|
1953
|
+
: {}),
|
|
1954
|
+
status: failed ? 'failed' : 'revoked',
|
|
1955
|
+
reasonCode: start.reasonCode,
|
|
1956
|
+
});
|
|
1957
|
+
if (registered !== undefined) {
|
|
1958
|
+
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
1959
|
+
}
|
|
1699
1960
|
});
|
|
1700
1961
|
summary.revoked.push(sub.id);
|
|
1701
1962
|
if (failed)
|
|
@@ -1706,8 +1967,12 @@ export class SyncClient {
|
|
|
1706
1967
|
#applyOperationsLocally(operations, batch) {
|
|
1707
1968
|
for (const op of operations) {
|
|
1708
1969
|
const table = this.#table(op.table);
|
|
1709
|
-
batch
|
|
1970
|
+
let precise = batch === undefined
|
|
1971
|
+
? false
|
|
1972
|
+
: this.#recordStoredRowScopes(batch, table, op.rowId);
|
|
1710
1973
|
if (op.op === 'delete') {
|
|
1974
|
+
if (batch !== undefined && !precise)
|
|
1975
|
+
batch.table(op.table);
|
|
1711
1976
|
deleteLocalRow(this.#db, table, op.rowId);
|
|
1712
1977
|
continue;
|
|
1713
1978
|
}
|
|
@@ -1722,9 +1987,12 @@ export class SyncClient {
|
|
|
1722
1987
|
const cell = idx === undefined ? undefined : values[idx];
|
|
1723
1988
|
const prefix = table.scopePrefixByVariable.get(variable);
|
|
1724
1989
|
if (prefix !== undefined && cell != null) {
|
|
1725
|
-
batch.
|
|
1990
|
+
batch.scope(table.name, `${prefix}:${String(cell)}`);
|
|
1991
|
+
precise = true;
|
|
1726
1992
|
}
|
|
1727
1993
|
}
|
|
1994
|
+
if (!precise)
|
|
1995
|
+
batch.table(table.name);
|
|
1728
1996
|
}
|
|
1729
1997
|
const existing = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [op.rowId])[0];
|
|
1730
1998
|
const version = existing === undefined ? OPTIMISTIC_VERSION : existing.v;
|