@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/src/client.ts
CHANGED
|
@@ -62,9 +62,17 @@ import { registerDevtools } from './devtools';
|
|
|
62
62
|
import type { EncryptionConfig } from './encryption';
|
|
63
63
|
import { ClientSyncError } from './errors';
|
|
64
64
|
import {
|
|
65
|
-
|
|
65
|
+
ChangeAccumulator,
|
|
66
|
+
ChangeEmitter,
|
|
67
|
+
type ClientChangeListener,
|
|
68
|
+
type CommandEffects,
|
|
69
|
+
type CommandResult,
|
|
66
70
|
InvalidationEmitter,
|
|
67
71
|
type InvalidationListener,
|
|
72
|
+
invalidationFromChange,
|
|
73
|
+
type LocalRevision,
|
|
74
|
+
type SyncIntent,
|
|
75
|
+
type SyncStatusSnapshot,
|
|
68
76
|
} from './invalidation';
|
|
69
77
|
import {
|
|
70
78
|
type LeaderLease,
|
|
@@ -101,7 +109,9 @@ import {
|
|
|
101
109
|
stripSyncColumns,
|
|
102
110
|
} from './schema';
|
|
103
111
|
import {
|
|
112
|
+
bumpLocalRevision,
|
|
104
113
|
deleteSubscription,
|
|
114
|
+
getLocalRevision,
|
|
105
115
|
getMeta,
|
|
106
116
|
getSubscription,
|
|
107
117
|
loadSubscriptions,
|
|
@@ -120,6 +130,7 @@ import {
|
|
|
120
130
|
deletePendingEviction,
|
|
121
131
|
deleteWindowUnit,
|
|
122
132
|
deriveSubId,
|
|
133
|
+
getWindowUnitBySubId,
|
|
123
134
|
insertWindowUnit,
|
|
124
135
|
loadPendingEvictions,
|
|
125
136
|
loadWindowUnits,
|
|
@@ -260,7 +271,10 @@ export interface SyncClientConfig {
|
|
|
260
271
|
readonly limits?: SyncClientLimits;
|
|
261
272
|
readonly now?: () => number;
|
|
262
273
|
/** §8: hello `requiresSync` or a wake-up — run a pull soon. */
|
|
263
|
-
readonly onSyncNeeded?: (reason: 'hello' | WakeReason) => void;
|
|
274
|
+
readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
|
|
275
|
+
/** Exact core-owned scheduling intent. Hosts consume this to run an
|
|
276
|
+
* event-driven retry deadline without polling or inferring sync state. */
|
|
277
|
+
readonly onSyncIntent?: (intent: SyncIntent) => void;
|
|
264
278
|
readonly onConflict?: (conflict: ConflictRecord) => void;
|
|
265
279
|
/**
|
|
266
280
|
* §7.4.5: the schema-bump `upgrading` state changed. `true` when a reset
|
|
@@ -319,6 +333,35 @@ export interface WindowState {
|
|
|
319
333
|
readonly pending: readonly string[];
|
|
320
334
|
}
|
|
321
335
|
|
|
336
|
+
/** One generated/raw query's required window units (SPEC §7.5). */
|
|
337
|
+
export interface WindowCoverage {
|
|
338
|
+
readonly base: WindowBase;
|
|
339
|
+
readonly units: readonly string[];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface WindowUnitRef {
|
|
343
|
+
readonly baseKey: string;
|
|
344
|
+
readonly unit: string;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface CoverageSnapshot {
|
|
348
|
+
readonly complete: boolean;
|
|
349
|
+
readonly pending: readonly WindowUnitRef[];
|
|
350
|
+
readonly missing: readonly WindowUnitRef[];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface QueryReadSpec {
|
|
354
|
+
readonly sql: string;
|
|
355
|
+
readonly params?: readonly SqlValue[];
|
|
356
|
+
readonly coverage?: readonly WindowCoverage[];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export interface QuerySnapshot<Row = SqlRow> {
|
|
360
|
+
readonly revision: LocalRevision;
|
|
361
|
+
readonly rows: readonly Row[];
|
|
362
|
+
readonly coverage: CoverageSnapshot;
|
|
363
|
+
}
|
|
364
|
+
|
|
322
365
|
/**
|
|
323
366
|
* True iff `unit` is windowed-in AND its bootstrap completed (§4.8 I3):
|
|
324
367
|
* registered and not pending. A unit with zero server rows still becomes
|
|
@@ -433,15 +476,19 @@ export class SyncClient {
|
|
|
433
476
|
* (the fast-bail the delta path reads).
|
|
434
477
|
*/
|
|
435
478
|
#syncOutstanding = false;
|
|
479
|
+
/** Retry policy belongs to the operation that classified the failure. */
|
|
480
|
+
#retryDelayMs = 250;
|
|
436
481
|
readonly #hasBlobs: boolean;
|
|
437
482
|
/** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
|
|
438
483
|
readonly #presence = new Map<string, Map<string, PresencePeer>>();
|
|
439
|
-
/**
|
|
484
|
+
/** SPEC §7.5: exact core-originated observer transaction batches. */
|
|
485
|
+
readonly #changes = new ChangeEmitter();
|
|
486
|
+
/** Compatibility projection from exact batches; never bridge-inferred. */
|
|
440
487
|
readonly #invalidation = new InvalidationEmitter();
|
|
441
488
|
/** §8.6: subscribable presence-change listeners (twin of onPresence). */
|
|
442
489
|
readonly #presenceListeners = new Set<(scopeKey: string) => void>();
|
|
443
490
|
/** The batch accumulator; non-undefined only inside `#applyBatch`. */
|
|
444
|
-
#batch:
|
|
491
|
+
#batch: ChangeAccumulator | undefined;
|
|
445
492
|
/**
|
|
446
493
|
* Operation-serialization mutex (the core owns one loop). Every
|
|
447
494
|
* transaction-entering ASYNC operation — `sync`, the delta-apply body, and
|
|
@@ -479,8 +526,20 @@ export class SyncClient {
|
|
|
479
526
|
ensureLocalSchema(this.#db, this.#schema);
|
|
480
527
|
if (this.#hasBlobs) ensureBlobSchema(this.#db);
|
|
481
528
|
const persisted = getMeta(this.#db, 'clientId');
|
|
482
|
-
|
|
483
|
-
|
|
529
|
+
if (
|
|
530
|
+
persisted !== undefined &&
|
|
531
|
+
this.#config.clientId !== undefined &&
|
|
532
|
+
persisted !== this.#config.clientId
|
|
533
|
+
) {
|
|
534
|
+
await this.#lease.release();
|
|
535
|
+
this.#lease = undefined;
|
|
536
|
+
throw new ClientSyncError(
|
|
537
|
+
'client.identity_mismatch',
|
|
538
|
+
`this client database belongs to ${JSON.stringify(persisted)}; refusing to rebind it to ${JSON.stringify(this.#config.clientId)}`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
|
|
542
|
+
if (persisted === undefined) {
|
|
484
543
|
setMeta(this.#db, 'clientId', this.#clientId);
|
|
485
544
|
}
|
|
486
545
|
// §7.3.5: restore the persisted lease so leaseState survives restart.
|
|
@@ -494,6 +553,21 @@ export class SyncClient {
|
|
|
494
553
|
// already at the generated version.
|
|
495
554
|
this.#detectAndResetSchema();
|
|
496
555
|
this.#started = true;
|
|
556
|
+
// A persisted active subscription needs one catch-up round on every open:
|
|
557
|
+
// realtime only covers changes after the socket connects, and an
|
|
558
|
+
// idempotent setWindow/subscribe call correctly creates no new command
|
|
559
|
+
// effect. Pending outbox work has the same restart requirement. Surface
|
|
560
|
+
// this as an exact core-owned intent so hosts never need a startup poll or
|
|
561
|
+
// an application-issued sync() call.
|
|
562
|
+
const startupWork =
|
|
563
|
+
this.#schemaFloor === undefined &&
|
|
564
|
+
(listOutbox(this.#db).length > 0 ||
|
|
565
|
+
loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
|
|
566
|
+
if (startupWork) {
|
|
567
|
+
this.#needsPull = true;
|
|
568
|
+
this.#config.onSyncNeeded?.('startup');
|
|
569
|
+
this.#config.onSyncIntent?.({ kind: 'interactive' });
|
|
570
|
+
}
|
|
497
571
|
// RFC 0002 §3.2: console introspection — a no-op outside a dev page.
|
|
498
572
|
this.#devtoolsUnregister = registerDevtools({
|
|
499
573
|
kind: 'client',
|
|
@@ -552,16 +626,36 @@ export class SyncClient {
|
|
|
552
626
|
// The stop state is over: this client now ships a servable schema. The
|
|
553
627
|
// outbox is re-applied optimistically over the (now empty) tables so
|
|
554
628
|
// pending offline writes stay visible across the bump (§7.4.5).
|
|
555
|
-
this.#
|
|
629
|
+
this.#setSchemaFloor(undefined);
|
|
556
630
|
this.#replayOutbox();
|
|
557
631
|
}
|
|
558
632
|
|
|
559
633
|
#setUpgrading(upgrading: boolean): void {
|
|
560
634
|
if (this.#upgrading === upgrading) return;
|
|
561
|
-
this.#
|
|
635
|
+
this.#applyBatch((batch) => {
|
|
636
|
+
this.#upgrading = upgrading;
|
|
637
|
+
batch.status();
|
|
638
|
+
});
|
|
562
639
|
this.#config.onUpgrading?.(upgrading);
|
|
563
640
|
}
|
|
564
641
|
|
|
642
|
+
#setSyncNeeded(syncNeeded: boolean): void {
|
|
643
|
+
if (this.#needsPull === syncNeeded) return;
|
|
644
|
+
this.#applyBatch((batch) => {
|
|
645
|
+
this.#needsPull = syncNeeded;
|
|
646
|
+
batch.status();
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
#setSchemaFloor(schemaFloor: SchemaFloor | undefined): void {
|
|
651
|
+
const current = JSON.stringify(this.#schemaFloor);
|
|
652
|
+
if (current === JSON.stringify(schemaFloor)) return;
|
|
653
|
+
this.#applyBatch((batch) => {
|
|
654
|
+
this.#schemaFloor = schemaFloor;
|
|
655
|
+
batch.status();
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
565
659
|
async close(): Promise<void> {
|
|
566
660
|
this.#devtoolsUnregister?.();
|
|
567
661
|
this.#devtoolsUnregister = undefined;
|
|
@@ -597,6 +691,65 @@ export class SyncClient {
|
|
|
597
691
|
return stripSyncColumns(this.#db.query(sql, params));
|
|
598
692
|
}
|
|
599
693
|
|
|
694
|
+
/** Current durable local observer revision (SPEC §7.5). */
|
|
695
|
+
get localRevision(): LocalRevision {
|
|
696
|
+
this.#requireStarted();
|
|
697
|
+
return getLocalRevision(this.#db);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Read rows, window answerability, and revision from one SQLite snapshot.
|
|
702
|
+
* Reactive integrations use this instead of composing `query()` and
|
|
703
|
+
* `windowState()` across separate worker/IPC calls.
|
|
704
|
+
*/
|
|
705
|
+
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row> {
|
|
706
|
+
this.#requireStarted();
|
|
707
|
+
assertReadOnlyQuery(spec.sql);
|
|
708
|
+
return this.#db.transaction(() => {
|
|
709
|
+
const revision = getLocalRevision(this.#db);
|
|
710
|
+
const rows = stripSyncColumns(
|
|
711
|
+
this.#db.query(spec.sql, spec.params),
|
|
712
|
+
) as unknown as readonly Row[];
|
|
713
|
+
const pending: WindowUnitRef[] = [];
|
|
714
|
+
const missing: WindowUnitRef[] = [];
|
|
715
|
+
for (const requested of spec.coverage ?? []) {
|
|
716
|
+
const baseKey = windowBaseKey(requested.base);
|
|
717
|
+
const live = new Map(
|
|
718
|
+
loadWindowUnits(this.#db, baseKey).map((entry) => [
|
|
719
|
+
entry.unit,
|
|
720
|
+
entry.subId,
|
|
721
|
+
]),
|
|
722
|
+
);
|
|
723
|
+
for (const unit of new Set(requested.units)) {
|
|
724
|
+
const subId = live.get(unit);
|
|
725
|
+
const ref = { baseKey, unit };
|
|
726
|
+
if (subId === undefined) {
|
|
727
|
+
missing.push(ref);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
const sub = getSubscription(this.#db, subId);
|
|
731
|
+
if (
|
|
732
|
+
sub === undefined ||
|
|
733
|
+
sub.status !== 'active' ||
|
|
734
|
+
sub.cursor < 0 ||
|
|
735
|
+
sub.bootstrapState !== undefined
|
|
736
|
+
) {
|
|
737
|
+
pending.push(ref);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
revision,
|
|
743
|
+
rows,
|
|
744
|
+
coverage: {
|
|
745
|
+
complete: pending.length === 0 && missing.length === 0,
|
|
746
|
+
pending,
|
|
747
|
+
missing,
|
|
748
|
+
},
|
|
749
|
+
};
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
600
753
|
// -- live-query invalidation (TODO 3.1 / DESIGN-eviction I1–I4) -----------
|
|
601
754
|
|
|
602
755
|
/**
|
|
@@ -614,23 +767,63 @@ export class SyncClient {
|
|
|
614
767
|
return this.#invalidation.on(listener);
|
|
615
768
|
}
|
|
616
769
|
|
|
770
|
+
/** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
|
|
771
|
+
onChange(listener: ClientChangeListener): () => void {
|
|
772
|
+
return this.#changes.on(listener);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** One call for the complete status domain used by reactive hosts. */
|
|
776
|
+
statusSnapshot(): SyncStatusSnapshot {
|
|
777
|
+
this.#requireStarted();
|
|
778
|
+
return this.#statusSnapshot();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
#statusSnapshot(): SyncStatusSnapshot {
|
|
782
|
+
return {
|
|
783
|
+
outbox: listOutbox(this.#db).length,
|
|
784
|
+
upgrading: this.#upgrading,
|
|
785
|
+
leaseState: this.#leaseState,
|
|
786
|
+
schemaFloor: this.#schemaFloor,
|
|
787
|
+
syncNeeded: this.#needsPull,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
617
791
|
/**
|
|
618
792
|
* Run `fn` as one apply batch: install a fresh accumulator, collect every
|
|
619
793
|
* touched key, then emit exactly one coalesced event if anything changed.
|
|
620
794
|
* Re-entrant calls share the outer batch so a nested apply never
|
|
621
795
|
* double-emits (e.g. purge → blob reconcile → replay inside one round).
|
|
622
796
|
*/
|
|
623
|
-
#applyBatch<T>(fn: (batch:
|
|
797
|
+
#applyBatch<T>(fn: (batch: ChangeAccumulator) => T): T {
|
|
624
798
|
if (this.#batch !== undefined) return fn(this.#batch);
|
|
625
|
-
const batch = new
|
|
626
|
-
|
|
799
|
+
const batch = new ChangeAccumulator();
|
|
800
|
+
let revision: LocalRevision | undefined;
|
|
801
|
+
let status: SyncStatusSnapshot | undefined;
|
|
802
|
+
let result!: T;
|
|
627
803
|
try {
|
|
628
|
-
|
|
629
|
-
|
|
804
|
+
this.#db.transaction(() => {
|
|
805
|
+
this.#batch = batch;
|
|
806
|
+
try {
|
|
807
|
+
result = fn(batch);
|
|
808
|
+
if (batch.touched) {
|
|
809
|
+
revision = bumpLocalRevision(this.#db);
|
|
810
|
+
if (batch.statusChanged) status = this.#statusSnapshot();
|
|
811
|
+
}
|
|
812
|
+
} finally {
|
|
813
|
+
this.#batch = undefined;
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
} catch (error) {
|
|
630
817
|
this.#batch = undefined;
|
|
631
|
-
|
|
632
|
-
|
|
818
|
+
throw error;
|
|
819
|
+
}
|
|
820
|
+
if (revision !== undefined) {
|
|
821
|
+
const event = batch.finish(revision, status);
|
|
822
|
+
this.#changes.emit(event);
|
|
823
|
+
const legacy = invalidationFromChange(event);
|
|
824
|
+
if (legacy !== undefined) this.#invalidation.emit(legacy);
|
|
633
825
|
}
|
|
826
|
+
return result;
|
|
634
827
|
}
|
|
635
828
|
|
|
636
829
|
/**
|
|
@@ -652,22 +845,6 @@ export class SyncClient {
|
|
|
652
845
|
return next;
|
|
653
846
|
}
|
|
654
847
|
|
|
655
|
-
/** Async twin of {@link #applyBatch} for the pull/delta apply round. */
|
|
656
|
-
async #applyBatchAsync<T>(
|
|
657
|
-
fn: (batch: Invalidation) => Promise<T>,
|
|
658
|
-
): Promise<T> {
|
|
659
|
-
if (this.#batch !== undefined) return fn(this.#batch);
|
|
660
|
-
const batch = new Invalidation();
|
|
661
|
-
this.#batch = batch;
|
|
662
|
-
try {
|
|
663
|
-
return await fn(batch);
|
|
664
|
-
} finally {
|
|
665
|
-
this.#batch = undefined;
|
|
666
|
-
const event = batch.finish();
|
|
667
|
-
if (event !== undefined) this.#invalidation.emit(event);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
848
|
// -- blobs (§5.9) ---------------------------------------------------------
|
|
672
849
|
|
|
673
850
|
/**
|
|
@@ -1035,6 +1212,14 @@ export class SyncClient {
|
|
|
1035
1212
|
* re-registers realtime at round end (§8.7). No socket cycle needed.
|
|
1036
1213
|
*/
|
|
1037
1214
|
async setWindow(base: WindowBase, units: readonly string[]): Promise<void> {
|
|
1215
|
+
await this.setWindowCommand(base, units);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** Exact core command result consumed by automatic host loops (§7.5). */
|
|
1219
|
+
async setWindowCommand(
|
|
1220
|
+
base: WindowBase,
|
|
1221
|
+
units: readonly string[],
|
|
1222
|
+
): Promise<CommandResult<void>> {
|
|
1038
1223
|
this.#requireStarted();
|
|
1039
1224
|
const table = this.#table(base.table);
|
|
1040
1225
|
if (!table.scopeColumnByVariable.has(base.variable)) {
|
|
@@ -1046,6 +1231,8 @@ export class SyncClient {
|
|
|
1046
1231
|
// Serialize the whole window edit: it spans an `await deriveSubId` between
|
|
1047
1232
|
// db transactions, so without the chain a delta apply (or a concurrent
|
|
1048
1233
|
// setWindow) could interleave its transactions and corrupt the registry.
|
|
1234
|
+
let changed = false;
|
|
1235
|
+
let widened = false;
|
|
1049
1236
|
await this.#serialize(async () => {
|
|
1050
1237
|
const baseKey = windowBaseKey(base);
|
|
1051
1238
|
const wanted = new Set(units);
|
|
@@ -1056,27 +1243,37 @@ export class SyncClient {
|
|
|
1056
1243
|
for (const unit of wanted) {
|
|
1057
1244
|
if (liveByUnit.has(unit)) continue;
|
|
1058
1245
|
const subId = await deriveSubId(base, unit);
|
|
1059
|
-
this.#
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1246
|
+
this.#applyBatch((batch) => {
|
|
1247
|
+
this.#db.transaction(() => {
|
|
1248
|
+
// Re-entry cancels any deferred eviction for this sub id.
|
|
1249
|
+
deletePendingEviction(this.#db, subId);
|
|
1250
|
+
insertWindowUnit(this.#db, baseKey, unit, subId);
|
|
1251
|
+
saveSubscription(this.#db, {
|
|
1252
|
+
id: subId,
|
|
1253
|
+
table: base.table,
|
|
1254
|
+
scopes: unitScopes(base, unit),
|
|
1255
|
+
...(base.params !== undefined ? { params: base.params } : {}),
|
|
1256
|
+
cursor: -1,
|
|
1257
|
+
status: 'active',
|
|
1258
|
+
});
|
|
1070
1259
|
});
|
|
1260
|
+
batch.window(baseKey, base.table, unit);
|
|
1071
1261
|
});
|
|
1262
|
+
changed = true;
|
|
1263
|
+
widened = true;
|
|
1072
1264
|
}
|
|
1073
1265
|
|
|
1074
1266
|
// Shrink: units live but not wanted → unsubscribe fused with eviction.
|
|
1075
1267
|
for (const { unit, subId } of live) {
|
|
1076
1268
|
if (wanted.has(unit)) continue;
|
|
1077
1269
|
this.#evictUnit(baseKey, base, unit, subId);
|
|
1270
|
+
changed = true;
|
|
1078
1271
|
}
|
|
1079
1272
|
});
|
|
1273
|
+
const effects: CommandEffects = {
|
|
1274
|
+
sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
|
|
1275
|
+
};
|
|
1276
|
+
return { value: undefined, effects };
|
|
1080
1277
|
}
|
|
1081
1278
|
|
|
1082
1279
|
/**
|
|
@@ -1098,6 +1295,7 @@ export class SyncClient {
|
|
|
1098
1295
|
const sub = getSubscription(this.#db, subId);
|
|
1099
1296
|
if (
|
|
1100
1297
|
sub === undefined ||
|
|
1298
|
+
sub.status !== 'active' ||
|
|
1101
1299
|
sub.cursor < 0 ||
|
|
1102
1300
|
sub.bootstrapState !== undefined
|
|
1103
1301
|
) {
|
|
@@ -1140,9 +1338,8 @@ export class SyncClient {
|
|
|
1140
1338
|
deletePendingEviction(this.#db, subId);
|
|
1141
1339
|
}
|
|
1142
1340
|
});
|
|
1143
|
-
// I1: eviction is a bulk delete — a query over the evicted unit re-runs.
|
|
1144
|
-
batch.table(table.name);
|
|
1145
1341
|
batch.scopeMap(table, effective);
|
|
1342
|
+
batch.window(baseKey, table.name, unit);
|
|
1146
1343
|
});
|
|
1147
1344
|
}
|
|
1148
1345
|
|
|
@@ -1167,7 +1364,6 @@ export class SyncClient {
|
|
|
1167
1364
|
deferred = evictScopedRows(this.#db, table, entry.effective, pinned);
|
|
1168
1365
|
if (!deferred) deletePendingEviction(this.#db, entry.subId);
|
|
1169
1366
|
});
|
|
1170
|
-
batch.table(table.name);
|
|
1171
1367
|
batch.scopeMap(table, entry.effective);
|
|
1172
1368
|
});
|
|
1173
1369
|
}
|
|
@@ -1235,11 +1431,20 @@ export class SyncClient {
|
|
|
1235
1431
|
this.#db.transaction(() => {
|
|
1236
1432
|
appendOutboxCommit(this.#db, clientCommitId, operations, this.#now());
|
|
1237
1433
|
this.#applyOperationsLocally(operations, batch);
|
|
1434
|
+
batch.status();
|
|
1238
1435
|
});
|
|
1239
1436
|
});
|
|
1240
1437
|
return clientCommitId;
|
|
1241
1438
|
}
|
|
1242
1439
|
|
|
1440
|
+
/** Host-facing mutation result with explicit network work intent (§7.5). */
|
|
1441
|
+
mutateCommand(mutations: readonly MutationInput[]): CommandResult<string> {
|
|
1442
|
+
return {
|
|
1443
|
+
value: this.mutate(mutations),
|
|
1444
|
+
effects: { sync: { kind: 'interactive' } },
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1243
1448
|
/**
|
|
1244
1449
|
* Partial-update convenience over the §6.1 full-row wire: read the
|
|
1245
1450
|
* current LOCAL row, merge `partial` over it, and record one full-row
|
|
@@ -1287,12 +1492,28 @@ export class SyncClient {
|
|
|
1287
1492
|
]);
|
|
1288
1493
|
}
|
|
1289
1494
|
|
|
1495
|
+
/** Host-facing patch result with explicit network work intent (§7.5). */
|
|
1496
|
+
patchCommand(
|
|
1497
|
+
table: string,
|
|
1498
|
+
rowId: string,
|
|
1499
|
+
partial: Readonly<Record<string, unknown>>,
|
|
1500
|
+
options?: { readonly baseVersion?: number },
|
|
1501
|
+
): CommandResult<string> {
|
|
1502
|
+
return {
|
|
1503
|
+
value: this.patch(table, rowId, partial, options),
|
|
1504
|
+
effects: { sync: { kind: 'interactive' } },
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1290
1508
|
// -- lease state (§7.3.5) ---------------------------------------------------
|
|
1291
1509
|
|
|
1292
1510
|
/** Merge and persist the lease state (opaque, §7.3.5). */
|
|
1293
1511
|
#setLeaseState(next: LeaseState): void {
|
|
1294
|
-
this.#
|
|
1295
|
-
|
|
1512
|
+
this.#applyBatch((batch) => {
|
|
1513
|
+
this.#leaseState = next;
|
|
1514
|
+
setMeta(this.#db, 'leaseState', JSON.stringify(next));
|
|
1515
|
+
batch.status();
|
|
1516
|
+
});
|
|
1296
1517
|
}
|
|
1297
1518
|
|
|
1298
1519
|
/** The request-level lease error codes (§7.3.4): stop-and-surface. */
|
|
@@ -1357,7 +1578,7 @@ export class SyncClient {
|
|
|
1357
1578
|
* purely-optimistic rows are undone, and a rejection record is raised.
|
|
1358
1579
|
*/
|
|
1359
1580
|
#dropIncompatibleCommit(commit: OutboxCommit, message: string): void {
|
|
1360
|
-
this.#
|
|
1581
|
+
this.#applyBatch((batch) => {
|
|
1361
1582
|
deleteOutboxCommit(this.#db, commit.clientCommitId);
|
|
1362
1583
|
for (const operation of commit.operations) {
|
|
1363
1584
|
if (operation.op !== 'upsert') continue;
|
|
@@ -1368,19 +1589,24 @@ export class SyncClient {
|
|
|
1368
1589
|
[operation.rowId],
|
|
1369
1590
|
)[0];
|
|
1370
1591
|
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
1592
|
+
if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
|
|
1593
|
+
batch.table(table.name);
|
|
1594
|
+
}
|
|
1371
1595
|
deleteLocalRow(this.#db, table, operation.rowId);
|
|
1372
1596
|
}
|
|
1373
1597
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1598
|
+
this.#rejections.push({
|
|
1599
|
+
clientCommitId: commit.clientCommitId,
|
|
1600
|
+
opIndex: 0,
|
|
1601
|
+
code: OUTBOX_INCOMPATIBLE_CODE,
|
|
1602
|
+
message,
|
|
1603
|
+
retryable: false,
|
|
1604
|
+
...(commit.operations[0] !== undefined
|
|
1605
|
+
? { operation: commit.operations[0] }
|
|
1606
|
+
: {}),
|
|
1607
|
+
});
|
|
1608
|
+
batch.status();
|
|
1609
|
+
batch.rejections();
|
|
1384
1610
|
});
|
|
1385
1611
|
}
|
|
1386
1612
|
|
|
@@ -1423,7 +1649,7 @@ export class SyncClient {
|
|
|
1423
1649
|
// Cleared before the round, not after: a wake-up (or a delta dropped
|
|
1424
1650
|
// because this pull is mid-flight) that lands during the round must
|
|
1425
1651
|
// survive it — the reference server keeps no replay buffer (§8.2).
|
|
1426
|
-
this.#
|
|
1652
|
+
this.#setSyncNeeded(false);
|
|
1427
1653
|
try {
|
|
1428
1654
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
1429
1655
|
// them, so the server-side existence check (§6.6) passes.
|
|
@@ -1491,10 +1717,11 @@ export class SyncClient {
|
|
|
1491
1717
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
1492
1718
|
// a shrunk window unit — retry any deferred evictions now.
|
|
1493
1719
|
this.#drainPendingEvictions();
|
|
1720
|
+
this.#retryDelayMs = 250;
|
|
1494
1721
|
if (deferred > 0) {
|
|
1495
1722
|
// §6.1 splitBatch remainder: more queued commits than this request
|
|
1496
1723
|
// could carry — keep the sync-needed signal raised for the host.
|
|
1497
|
-
this.#
|
|
1724
|
+
this.#setSyncNeeded(true);
|
|
1498
1725
|
return { ...summary, deferredCommits: deferred };
|
|
1499
1726
|
}
|
|
1500
1727
|
return summary;
|
|
@@ -1511,6 +1738,22 @@ export class SyncClient {
|
|
|
1511
1738
|
errorCode: code,
|
|
1512
1739
|
});
|
|
1513
1740
|
}
|
|
1741
|
+
const explicitlyRetryable = (error as { retryable?: unknown }).retryable;
|
|
1742
|
+
const retryable =
|
|
1743
|
+
explicitlyRetryable === true ||
|
|
1744
|
+
(explicitlyRetryable === undefined && typeof code !== 'string');
|
|
1745
|
+
if (retryable) {
|
|
1746
|
+
const intent: SyncIntent = {
|
|
1747
|
+
kind: 'background',
|
|
1748
|
+
delayMs: this.#retryDelayMs,
|
|
1749
|
+
};
|
|
1750
|
+
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
|
|
1751
|
+
try {
|
|
1752
|
+
this.#config.onSyncIntent?.(intent);
|
|
1753
|
+
} catch {
|
|
1754
|
+
// An observer cannot alter sync correctness.
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1514
1757
|
throw error;
|
|
1515
1758
|
} finally {
|
|
1516
1759
|
this.#syncing = false;
|
|
@@ -1670,14 +1913,14 @@ export class SyncClient {
|
|
|
1670
1913
|
const event = parsed.event;
|
|
1671
1914
|
if (event.event === 'hello') {
|
|
1672
1915
|
if (event.data.requiresSync) {
|
|
1673
|
-
this.#
|
|
1916
|
+
this.#setSyncNeeded(true);
|
|
1674
1917
|
this.#config.onSyncNeeded?.('hello');
|
|
1675
1918
|
}
|
|
1676
1919
|
return;
|
|
1677
1920
|
}
|
|
1678
1921
|
if (event.event === 'sync') {
|
|
1679
1922
|
// §8.3: any wake-up means "run a pull soon", never data.
|
|
1680
|
-
this.#
|
|
1923
|
+
this.#setSyncNeeded(true);
|
|
1681
1924
|
this.#config.onSyncNeeded?.(event.data.reason);
|
|
1682
1925
|
return;
|
|
1683
1926
|
}
|
|
@@ -1735,7 +1978,7 @@ export class SyncClient {
|
|
|
1735
1978
|
// worth it. (An optimization; the op chain below is the correctness
|
|
1736
1979
|
// mechanism — it also excludes a delta from racing a `setWindow` or a
|
|
1737
1980
|
// sync round that started between this check and the apply.)
|
|
1738
|
-
this.#
|
|
1981
|
+
this.#setSyncNeeded(true);
|
|
1739
1982
|
return;
|
|
1740
1983
|
}
|
|
1741
1984
|
// Serialize the apply on the operation chain: a delta must never
|
|
@@ -1749,7 +1992,7 @@ export class SyncClient {
|
|
|
1749
1992
|
await this.#processResponse(message, [], undefined, 'delta');
|
|
1750
1993
|
} catch {
|
|
1751
1994
|
// A delta that cannot be applied is recovered by a pull (§8.3).
|
|
1752
|
-
this.#
|
|
1995
|
+
this.#setSyncNeeded(true);
|
|
1753
1996
|
this.#config.onSyncNeeded?.('catchup-required');
|
|
1754
1997
|
}
|
|
1755
1998
|
});
|
|
@@ -1800,16 +2043,17 @@ export class SyncClient {
|
|
|
1800
2043
|
// version this client sends. The §7.4.2 trigger-2 convergence runs
|
|
1801
2044
|
// when the APP updates (recreating the client with a new generated
|
|
1802
2045
|
// schema), which fires the boot-time §7.4.1 marker check instead.
|
|
1803
|
-
|
|
2046
|
+
const schemaFloor: SchemaFloor = {
|
|
1804
2047
|
requiredSchemaVersion: header.requiredSchemaVersion,
|
|
1805
2048
|
...(header.latestSchemaVersion !== undefined
|
|
1806
2049
|
? { latestSchemaVersion: header.latestSchemaVersion }
|
|
1807
2050
|
: {}),
|
|
1808
2051
|
};
|
|
2052
|
+
this.#setSchemaFloor(schemaFloor);
|
|
1809
2053
|
return {
|
|
1810
2054
|
...summary,
|
|
1811
2055
|
bootstrapping: [],
|
|
1812
|
-
schemaFloor
|
|
2056
|
+
schemaFloor,
|
|
1813
2057
|
};
|
|
1814
2058
|
}
|
|
1815
2059
|
|
|
@@ -1817,55 +2061,151 @@ export class SyncClient {
|
|
|
1817
2061
|
let errorFrame: ClientSyncError | undefined;
|
|
1818
2062
|
let deltaCursor = -1;
|
|
1819
2063
|
|
|
1820
|
-
//
|
|
1821
|
-
//
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
2064
|
+
// Each durable observer transaction emits its own revisioned batch.
|
|
2065
|
+
// Async decrypt/download work happens outside SQLite transactions.
|
|
2066
|
+
try {
|
|
2067
|
+
for (const frame of message.frames.slice(1)) {
|
|
2068
|
+
switch (frame.type) {
|
|
2069
|
+
case 'RESP_HEADER':
|
|
2070
|
+
break;
|
|
2071
|
+
case 'LEASE':
|
|
2072
|
+
// §7.3.5: persist the opaque lease and clear any prior lease
|
|
2073
|
+
// error — a fresh lease means the outage/revocation is over.
|
|
2074
|
+
this.#setLeaseState({
|
|
2075
|
+
leaseId: frame.leaseId,
|
|
2076
|
+
expiresAtMs: frame.expiresAtMs,
|
|
2077
|
+
});
|
|
2078
|
+
break;
|
|
2079
|
+
case 'PUSH_RESULT':
|
|
2080
|
+
this.#applyBatch((batch) =>
|
|
2081
|
+
this.#handlePushResult(frame, commitsById, summary, batch),
|
|
2082
|
+
);
|
|
2083
|
+
break;
|
|
2084
|
+
case 'SUB_START': {
|
|
2085
|
+
const sub = subsById.get(frame.id);
|
|
2086
|
+
const fresh =
|
|
2087
|
+
sub !== undefined &&
|
|
2088
|
+
sub.cursor < 0 &&
|
|
2089
|
+
sub.bootstrapState === undefined &&
|
|
2090
|
+
frame.bootstrap;
|
|
2091
|
+
const skip =
|
|
2092
|
+
sub === undefined ||
|
|
2093
|
+
(mode === 'delta' &&
|
|
2094
|
+
(sub.status !== 'active' || sub.bootstrapState !== undefined));
|
|
2095
|
+
section = { start: frame, sub, fresh, skip, cleared: false };
|
|
2096
|
+
break;
|
|
2097
|
+
}
|
|
2098
|
+
case 'COMMIT':
|
|
2099
|
+
if (section !== undefined && !section.skip) {
|
|
2100
|
+
await this.#applyCommit(frame, summary);
|
|
2101
|
+
}
|
|
2102
|
+
break;
|
|
2103
|
+
case 'SEGMENT_INLINE': {
|
|
2104
|
+
if (
|
|
2105
|
+
section === undefined ||
|
|
2106
|
+
section.skip ||
|
|
2107
|
+
section.sub === undefined
|
|
2108
|
+
) {
|
|
1853
2109
|
break;
|
|
1854
2110
|
}
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
2111
|
+
const segment = decodeRowsSegment(frame.payload);
|
|
2112
|
+
await this.#applySegmentOrFail(
|
|
2113
|
+
section,
|
|
2114
|
+
summary,
|
|
2115
|
+
(table, clearFirst, effective) =>
|
|
2116
|
+
applyRowsSegment(
|
|
2117
|
+
this.#db,
|
|
2118
|
+
this.#schema,
|
|
2119
|
+
table,
|
|
2120
|
+
segment,
|
|
2121
|
+
{
|
|
2122
|
+
clearFirst,
|
|
2123
|
+
effective,
|
|
2124
|
+
transaction: (fn) =>
|
|
2125
|
+
this.#applyBatch((batch) => {
|
|
2126
|
+
if (
|
|
2127
|
+
segment.blocks.some((block) => block.length > 0) ||
|
|
2128
|
+
(clearFirst &&
|
|
2129
|
+
this.#scopedRowsExist(table, effective))
|
|
2130
|
+
) {
|
|
2131
|
+
batch.table(table.name);
|
|
2132
|
+
}
|
|
2133
|
+
return fn();
|
|
2134
|
+
}),
|
|
2135
|
+
},
|
|
2136
|
+
this.#encryption,
|
|
2137
|
+
),
|
|
2138
|
+
section.fresh && !section.cleared,
|
|
2139
|
+
);
|
|
2140
|
+
break;
|
|
2141
|
+
}
|
|
2142
|
+
case 'SEGMENT_REF': {
|
|
2143
|
+
if (
|
|
2144
|
+
section === undefined ||
|
|
2145
|
+
section.skip ||
|
|
2146
|
+
section.sub === undefined
|
|
2147
|
+
) {
|
|
1859
2148
|
break;
|
|
1860
|
-
|
|
2149
|
+
}
|
|
2150
|
+
// §4.2: a descriptor whose mediaType was not advertised is a
|
|
2151
|
+
// broken server — fail loud, never skip or guess.
|
|
2152
|
+
if (
|
|
2153
|
+
frame.mediaType === 'sqlite' &&
|
|
2154
|
+
(this.#acceptMask() & ACCEPT_SQLITE) === 0
|
|
2155
|
+
) {
|
|
2156
|
+
throw new ClientSyncError(
|
|
2157
|
+
'sync.invalid_request',
|
|
2158
|
+
'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
|
|
2159
|
+
);
|
|
2160
|
+
}
|
|
2161
|
+
const bytes = await this.#downloadSegment(frame, section.sub);
|
|
2162
|
+
if (frame.mediaType === 'sqlite') {
|
|
2163
|
+
// §5.3: images are whole-table — a paged descriptor is
|
|
2164
|
+
// invalid, and the image is always its table's first page.
|
|
1861
2165
|
if (
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
section.sub === undefined
|
|
2166
|
+
frame.rowCursor !== undefined ||
|
|
2167
|
+
frame.nextRowCursor !== undefined
|
|
1865
2168
|
) {
|
|
1866
|
-
|
|
2169
|
+
throw new ClientSyncError(
|
|
2170
|
+
'sync.invalid_request',
|
|
2171
|
+
'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)',
|
|
2172
|
+
);
|
|
1867
2173
|
}
|
|
1868
|
-
|
|
2174
|
+
await this.#applySegmentOrFail(
|
|
2175
|
+
section,
|
|
2176
|
+
summary,
|
|
2177
|
+
(table, clearFirst, effective) =>
|
|
2178
|
+
applySqliteSegment(
|
|
2179
|
+
this.#db,
|
|
2180
|
+
this.#schema,
|
|
2181
|
+
table,
|
|
2182
|
+
bytes,
|
|
2183
|
+
{
|
|
2184
|
+
table: frame.table,
|
|
2185
|
+
rowCount: frame.rowCount,
|
|
2186
|
+
asOfCommitSeq: frame.asOfCommitSeq,
|
|
2187
|
+
scopeDigest: frame.scopeDigest,
|
|
2188
|
+
},
|
|
2189
|
+
{
|
|
2190
|
+
clearFirst,
|
|
2191
|
+
effective,
|
|
2192
|
+
transaction: (fn) =>
|
|
2193
|
+
this.#applyBatch((batch) => {
|
|
2194
|
+
if (
|
|
2195
|
+
frame.rowCount > 0 ||
|
|
2196
|
+
(clearFirst &&
|
|
2197
|
+
this.#scopedRowsExist(table, effective))
|
|
2198
|
+
) {
|
|
2199
|
+
batch.table(table.name);
|
|
2200
|
+
}
|
|
2201
|
+
return fn();
|
|
2202
|
+
}),
|
|
2203
|
+
},
|
|
2204
|
+
),
|
|
2205
|
+
section.fresh && !section.cleared,
|
|
2206
|
+
);
|
|
2207
|
+
} else {
|
|
2208
|
+
const segment = decodeRowsSegment(bytes);
|
|
1869
2209
|
await this.#applySegmentOrFail(
|
|
1870
2210
|
section,
|
|
1871
2211
|
summary,
|
|
@@ -1875,131 +2215,71 @@ export class SyncClient {
|
|
|
1875
2215
|
this.#schema,
|
|
1876
2216
|
table,
|
|
1877
2217
|
segment,
|
|
1878
|
-
{
|
|
2218
|
+
{
|
|
2219
|
+
clearFirst,
|
|
2220
|
+
effective,
|
|
2221
|
+
transaction: (fn) =>
|
|
2222
|
+
this.#applyBatch((batch) => {
|
|
2223
|
+
if (
|
|
2224
|
+
segment.blocks.some((block) => block.length > 0) ||
|
|
2225
|
+
(clearFirst &&
|
|
2226
|
+
this.#scopedRowsExist(table, effective))
|
|
2227
|
+
) {
|
|
2228
|
+
batch.table(table.name);
|
|
2229
|
+
}
|
|
2230
|
+
return fn();
|
|
2231
|
+
}),
|
|
2232
|
+
},
|
|
1879
2233
|
this.#encryption,
|
|
1880
2234
|
),
|
|
1881
|
-
section.fresh &&
|
|
2235
|
+
section.fresh &&
|
|
2236
|
+
!section.cleared &&
|
|
2237
|
+
frame.rowCursor === undefined,
|
|
1882
2238
|
);
|
|
1883
|
-
break;
|
|
1884
2239
|
}
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
frame.
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
);
|
|
1903
|
-
}
|
|
1904
|
-
const bytes = await this.#downloadSegment(frame, section.sub);
|
|
1905
|
-
if (frame.mediaType === 'sqlite') {
|
|
1906
|
-
// §5.3: images are whole-table — a paged descriptor is
|
|
1907
|
-
// invalid, and the image is always its table's first page.
|
|
1908
|
-
if (
|
|
1909
|
-
frame.rowCursor !== undefined ||
|
|
1910
|
-
frame.nextRowCursor !== undefined
|
|
1911
|
-
) {
|
|
1912
|
-
throw new ClientSyncError(
|
|
1913
|
-
'sync.invalid_request',
|
|
1914
|
-
'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)',
|
|
1915
|
-
);
|
|
1916
|
-
}
|
|
1917
|
-
await this.#applySegmentOrFail(
|
|
1918
|
-
section,
|
|
1919
|
-
summary,
|
|
1920
|
-
(table, clearFirst, effective) =>
|
|
1921
|
-
applySqliteSegment(
|
|
1922
|
-
this.#db,
|
|
1923
|
-
this.#schema,
|
|
1924
|
-
table,
|
|
1925
|
-
bytes,
|
|
1926
|
-
{
|
|
1927
|
-
table: frame.table,
|
|
1928
|
-
rowCount: frame.rowCount,
|
|
1929
|
-
asOfCommitSeq: frame.asOfCommitSeq,
|
|
1930
|
-
scopeDigest: frame.scopeDigest,
|
|
1931
|
-
},
|
|
1932
|
-
{ clearFirst, effective },
|
|
1933
|
-
),
|
|
1934
|
-
section.fresh && !section.cleared,
|
|
1935
|
-
);
|
|
1936
|
-
} else {
|
|
1937
|
-
const segment = decodeRowsSegment(bytes);
|
|
1938
|
-
await this.#applySegmentOrFail(
|
|
1939
|
-
section,
|
|
1940
|
-
summary,
|
|
1941
|
-
(table, clearFirst, effective) =>
|
|
1942
|
-
applyRowsSegment(
|
|
1943
|
-
this.#db,
|
|
1944
|
-
this.#schema,
|
|
1945
|
-
table,
|
|
1946
|
-
segment,
|
|
1947
|
-
{ clearFirst, effective },
|
|
1948
|
-
this.#encryption,
|
|
1949
|
-
),
|
|
1950
|
-
section.fresh &&
|
|
1951
|
-
!section.cleared &&
|
|
1952
|
-
frame.rowCursor === undefined,
|
|
1953
|
-
);
|
|
1954
|
-
}
|
|
1955
|
-
break;
|
|
1956
|
-
}
|
|
1957
|
-
case 'SUB_END': {
|
|
1958
|
-
if (
|
|
1959
|
-
section !== undefined &&
|
|
1960
|
-
!section.skip &&
|
|
1961
|
-
section.sub !== undefined
|
|
1962
|
-
) {
|
|
1963
|
-
const applied = this.#finishSection(
|
|
1964
|
-
section.sub,
|
|
1965
|
-
section.start,
|
|
1966
|
-
frame.nextCursor,
|
|
1967
|
-
frame.bootstrapState,
|
|
1968
|
-
summary,
|
|
1969
|
-
);
|
|
1970
|
-
if (mode === 'delta' && applied) {
|
|
1971
|
-
deltaCursor = Math.max(deltaCursor, frame.nextCursor);
|
|
1972
|
-
}
|
|
2240
|
+
break;
|
|
2241
|
+
}
|
|
2242
|
+
case 'SUB_END': {
|
|
2243
|
+
if (
|
|
2244
|
+
section !== undefined &&
|
|
2245
|
+
!section.skip &&
|
|
2246
|
+
section.sub !== undefined
|
|
2247
|
+
) {
|
|
2248
|
+
const applied = this.#finishSection(
|
|
2249
|
+
section.sub,
|
|
2250
|
+
section.start,
|
|
2251
|
+
frame.nextCursor,
|
|
2252
|
+
frame.bootstrapState,
|
|
2253
|
+
summary,
|
|
2254
|
+
);
|
|
2255
|
+
if (mode === 'delta' && applied) {
|
|
2256
|
+
deltaCursor = Math.max(deltaCursor, frame.nextCursor);
|
|
1973
2257
|
}
|
|
1974
|
-
section = undefined;
|
|
1975
|
-
break;
|
|
1976
2258
|
}
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
// subscription's SUB_END values are never persisted.
|
|
1980
|
-
errorFrame = new ClientSyncError(
|
|
1981
|
-
frame.code,
|
|
1982
|
-
frame.message,
|
|
1983
|
-
frame.retryable,
|
|
1984
|
-
);
|
|
1985
|
-
section = undefined;
|
|
1986
|
-
break;
|
|
1987
|
-
case 'UNKNOWN':
|
|
1988
|
-
break; // §1.2 rule 2: skipped, never interpreted
|
|
2259
|
+
section = undefined;
|
|
2260
|
+
break;
|
|
1989
2261
|
}
|
|
1990
|
-
|
|
2262
|
+
case 'ERROR':
|
|
2263
|
+
// §1.4 rule 5 / §1.6: the request failed; the open
|
|
2264
|
+
// subscription's SUB_END values are never persisted.
|
|
2265
|
+
errorFrame = new ClientSyncError(
|
|
2266
|
+
frame.code,
|
|
2267
|
+
frame.message,
|
|
2268
|
+
frame.retryable,
|
|
2269
|
+
);
|
|
2270
|
+
section = undefined;
|
|
2271
|
+
break;
|
|
2272
|
+
case 'UNKNOWN':
|
|
2273
|
+
break; // §1.2 rule 2: skipped, never interpreted
|
|
1991
2274
|
}
|
|
1992
|
-
|
|
1993
|
-
// §7.1: local reads see outbox state applied optimistically — replay
|
|
1994
|
-
// the still-pending commits on top of the freshly applied server
|
|
1995
|
-
// state (the simple reconciliation mandated for B3).
|
|
1996
|
-
this.#replayOutbox();
|
|
1997
|
-
// §5.9.7 B1: after every apply/replay, refcounts follow the live rows.
|
|
1998
|
-
// A benign apply retains zero-ref bodies (LRU default); the revocation
|
|
1999
|
-
// purge below deletes orphaned bodies with deleteOrphans (B2).
|
|
2000
|
-
this.#reconcileBlobs(false);
|
|
2275
|
+
if (errorFrame !== undefined) break;
|
|
2001
2276
|
}
|
|
2002
|
-
}
|
|
2277
|
+
} finally {
|
|
2278
|
+
// §7.1: local reads see outbox state applied optimistically — replay
|
|
2279
|
+
// the still-pending commits on top of the freshly applied server state.
|
|
2280
|
+
this.#replayOutbox();
|
|
2281
|
+
this.#reconcileBlobs(false);
|
|
2282
|
+
}
|
|
2003
2283
|
|
|
2004
2284
|
if (errorFrame !== undefined) throw errorFrame;
|
|
2005
2285
|
|
|
@@ -2026,6 +2306,7 @@ export class SyncClient {
|
|
|
2026
2306
|
frame: PushResultFrame,
|
|
2027
2307
|
commitsById: ReadonlyMap<string, OutboxCommit>,
|
|
2028
2308
|
summary: MutableSummary,
|
|
2309
|
+
batch: ChangeAccumulator,
|
|
2029
2310
|
): void {
|
|
2030
2311
|
const commit = commitsById.get(frame.clientCommitId);
|
|
2031
2312
|
if (commit === undefined) return;
|
|
@@ -2033,6 +2314,7 @@ export class SyncClient {
|
|
|
2033
2314
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
2034
2315
|
// "already applied, you may have missed the ack".
|
|
2035
2316
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
2317
|
+
batch.status();
|
|
2036
2318
|
summary.applied.push(frame.clientCommitId);
|
|
2037
2319
|
return;
|
|
2038
2320
|
}
|
|
@@ -2064,6 +2346,7 @@ export class SyncClient {
|
|
|
2064
2346
|
...(operation !== undefined ? { operation } : {}),
|
|
2065
2347
|
};
|
|
2066
2348
|
this.#conflicts.push(conflict);
|
|
2349
|
+
batch.conflicts();
|
|
2067
2350
|
summary.conflicts.push(conflict);
|
|
2068
2351
|
this.#config.onConflict?.(conflict);
|
|
2069
2352
|
} else if (result.status === 'error') {
|
|
@@ -2075,6 +2358,7 @@ export class SyncClient {
|
|
|
2075
2358
|
retryable: result.retryable,
|
|
2076
2359
|
...(operation !== undefined ? { operation } : {}),
|
|
2077
2360
|
});
|
|
2361
|
+
batch.rejections();
|
|
2078
2362
|
}
|
|
2079
2363
|
}
|
|
2080
2364
|
// §7.2: stop optimistic display and decide about dependents — the
|
|
@@ -2092,10 +2376,14 @@ export class SyncClient {
|
|
|
2092
2376
|
[operation.rowId],
|
|
2093
2377
|
)[0];
|
|
2094
2378
|
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
2379
|
+
if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
|
|
2380
|
+
batch.table(table.name);
|
|
2381
|
+
}
|
|
2095
2382
|
deleteLocalRow(this.#db, table, operation.rowId);
|
|
2096
2383
|
}
|
|
2097
2384
|
}
|
|
2098
2385
|
});
|
|
2386
|
+
batch.status();
|
|
2099
2387
|
summary.rejected.push(frame.clientCommitId);
|
|
2100
2388
|
}
|
|
2101
2389
|
|
|
@@ -2118,23 +2406,88 @@ export class SyncClient {
|
|
|
2118
2406
|
frame: CommitFrame,
|
|
2119
2407
|
summary: MutableSummary,
|
|
2120
2408
|
): Promise<void> {
|
|
2121
|
-
await applyCommitFrame(
|
|
2409
|
+
await applyCommitFrame(
|
|
2410
|
+
this.#db,
|
|
2411
|
+
this.#schema,
|
|
2412
|
+
frame,
|
|
2413
|
+
this.#encryption,
|
|
2414
|
+
(fn) =>
|
|
2415
|
+
this.#applyBatch((batch) => {
|
|
2416
|
+
this.#recordCommitChanges(batch, frame);
|
|
2417
|
+
return fn();
|
|
2418
|
+
}),
|
|
2419
|
+
);
|
|
2122
2420
|
summary.commitsApplied += 1;
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
if (batch === undefined) return;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/** Record before + after scope keys while the commit transaction is open. */
|
|
2424
|
+
#recordCommitChanges(batch: ChangeAccumulator, frame: CommitFrame): void {
|
|
2128
2425
|
for (const change of frame.changes) {
|
|
2129
2426
|
const tableName = frame.tables[change.tableIndex];
|
|
2130
2427
|
if (tableName === undefined) continue;
|
|
2131
2428
|
const table = this.#schema.tables.get(tableName);
|
|
2132
2429
|
if (table === undefined) continue;
|
|
2133
|
-
batch.
|
|
2430
|
+
let precise = this.#recordStoredRowScopes(batch, table, change.rowId);
|
|
2431
|
+
for (const variable of Object.keys(change.scopes)) {
|
|
2432
|
+
if (table.scopePrefixByVariable.has(variable)) precise = true;
|
|
2433
|
+
}
|
|
2134
2434
|
batch.changeScopes(table, change.scopes);
|
|
2435
|
+
if (!precise) batch.table(tableName);
|
|
2135
2436
|
}
|
|
2136
2437
|
}
|
|
2137
2438
|
|
|
2439
|
+
/** Add the currently materialized row's scope keys; returns whether known. */
|
|
2440
|
+
#recordStoredRowScopes(
|
|
2441
|
+
batch: ChangeAccumulator,
|
|
2442
|
+
table: CompiledClientTable,
|
|
2443
|
+
rowId: string,
|
|
2444
|
+
): boolean {
|
|
2445
|
+
const mappings = [...table.scopeColumnByVariable].filter(([variable]) =>
|
|
2446
|
+
table.scopePrefixByVariable.has(variable),
|
|
2447
|
+
);
|
|
2448
|
+
if (mappings.length === 0) return false;
|
|
2449
|
+
const row = this.#db.query(
|
|
2450
|
+
`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
|
|
2451
|
+
FROM ${quoteIdent(table.name)}
|
|
2452
|
+
WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
2453
|
+
[rowId],
|
|
2454
|
+
)[0];
|
|
2455
|
+
if (row === undefined) return false;
|
|
2456
|
+
let recorded = false;
|
|
2457
|
+
for (const [variable, column] of mappings) {
|
|
2458
|
+
const value = row[column];
|
|
2459
|
+
const prefix = table.scopePrefixByVariable.get(variable);
|
|
2460
|
+
if (value != null && prefix !== undefined) {
|
|
2461
|
+
batch.scope(table.name, `${prefix}:${String(value)}`);
|
|
2462
|
+
recorded = true;
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
return recorded;
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
/** Whether a fresh-bootstrap clear would remove at least one local row. */
|
|
2469
|
+
#scopedRowsExist(table: CompiledClientTable, effective: ScopeMap): boolean {
|
|
2470
|
+
const entries = Object.entries(effective);
|
|
2471
|
+
if (entries.length === 0) return false;
|
|
2472
|
+
const clauses: string[] = [];
|
|
2473
|
+
const params: string[] = [];
|
|
2474
|
+
for (const [variable, values] of entries) {
|
|
2475
|
+
const column = table.scopeColumnByVariable.get(variable);
|
|
2476
|
+
if (column === undefined || values.length === 0) return false;
|
|
2477
|
+
clauses.push(
|
|
2478
|
+
`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
|
|
2479
|
+
);
|
|
2480
|
+
params.push(...values);
|
|
2481
|
+
}
|
|
2482
|
+
return (
|
|
2483
|
+
this.#db.query(
|
|
2484
|
+
`SELECT 1 FROM ${quoteIdent(table.name)}
|
|
2485
|
+
WHERE ${clauses.join(' AND ')} LIMIT 1`,
|
|
2486
|
+
params,
|
|
2487
|
+
).length > 0
|
|
2488
|
+
);
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2138
2491
|
/**
|
|
2139
2492
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
2140
2493
|
* (no local scope-column mapping) marks the subscription `failed` and
|
|
@@ -2160,27 +2513,28 @@ export class SyncClient {
|
|
|
2160
2513
|
section.start.effectiveScopes,
|
|
2161
2514
|
);
|
|
2162
2515
|
section.cleared = true;
|
|
2163
|
-
// I1/I2: segments carry only a table + scopeDigest, never per-row
|
|
2164
|
-
// scope keys — invalidate the table plus the subscription's effective
|
|
2165
|
-
// scope keys (the coarsest honest key for bulk data).
|
|
2166
|
-
this.#batch?.table(table.name);
|
|
2167
|
-
this.#batch?.scopeMap(table, section.start.effectiveScopes);
|
|
2168
2516
|
} catch (error) {
|
|
2169
2517
|
if (
|
|
2170
2518
|
error instanceof ClientSyncError &&
|
|
2171
2519
|
error.code === 'sync.scope_revoked'
|
|
2172
2520
|
) {
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2521
|
+
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
2522
|
+
this.#applyBatch((batch) => {
|
|
2523
|
+
saveSubscription(this.#db, {
|
|
2524
|
+
id: sub.id,
|
|
2525
|
+
table: sub.table,
|
|
2526
|
+
scopes: sub.scopes,
|
|
2527
|
+
...(sub.params !== undefined ? { params: sub.params } : {}),
|
|
2528
|
+
cursor: sub.cursor,
|
|
2529
|
+
...(sub.effectiveScopes !== undefined
|
|
2530
|
+
? { effectiveScopes: sub.effectiveScopes }
|
|
2531
|
+
: {}),
|
|
2532
|
+
status: 'failed',
|
|
2533
|
+
reasonCode: 'sync.scope_revoked',
|
|
2534
|
+
});
|
|
2535
|
+
if (registered !== undefined) {
|
|
2536
|
+
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
2537
|
+
}
|
|
2184
2538
|
});
|
|
2185
2539
|
summary.failed.push(sub.id);
|
|
2186
2540
|
section.skip = true;
|
|
@@ -2264,39 +2618,49 @@ export class SyncClient {
|
|
|
2264
2618
|
// An absent bootstrapState clears any previous resume token (§4.4:
|
|
2265
2619
|
// absent = bootstrap complete, or not bootstrapping).
|
|
2266
2620
|
const wasPending = sub.cursor < 0 || sub.bootstrapState !== undefined;
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2621
|
+
const completed =
|
|
2622
|
+
wasPending && nextCursor >= 0 && bootstrapState === undefined;
|
|
2623
|
+
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
2624
|
+
this.#applyBatch((batch) => {
|
|
2625
|
+
saveSubscription(this.#db, {
|
|
2626
|
+
id: sub.id,
|
|
2627
|
+
table: sub.table,
|
|
2628
|
+
scopes: sub.scopes,
|
|
2629
|
+
...(sub.params !== undefined ? { params: sub.params } : {}),
|
|
2630
|
+
cursor: nextCursor,
|
|
2631
|
+
...(bootstrapState !== undefined ? { bootstrapState } : {}),
|
|
2632
|
+
effectiveScopes: start.effectiveScopes,
|
|
2633
|
+
status: 'active',
|
|
2634
|
+
});
|
|
2635
|
+
if (completed && registered !== undefined) {
|
|
2636
|
+
// A zero-row bootstrap is a window-domain transition, not a fake
|
|
2637
|
+
// row/table change (SPEC §4.8 / §7.5).
|
|
2638
|
+
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
2639
|
+
}
|
|
2276
2640
|
});
|
|
2277
|
-
if (wasPending && nextCursor >= 0 && bootstrapState === undefined) {
|
|
2278
|
-
// §4.8: the completeness verdict flipped pending → complete. A
|
|
2279
|
-
// zero-row bootstrap applies nothing, so the flip itself must reach
|
|
2280
|
-
// live oracles through the choke point (shares the pull's batch).
|
|
2281
|
-
this.#applyBatch((batch) => batch.table(sub.table));
|
|
2282
|
-
}
|
|
2283
2641
|
return true;
|
|
2284
2642
|
}
|
|
2285
2643
|
|
|
2286
2644
|
if (start.status === 'reset') {
|
|
2287
2645
|
// §4.6: discard cursor + resume token, keep local rows, re-bootstrap
|
|
2288
2646
|
// with cursor = -1 on the next pull. Staleness, not a purge.
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2647
|
+
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
2648
|
+
this.#applyBatch((batch) => {
|
|
2649
|
+
saveSubscription(this.#db, {
|
|
2650
|
+
id: sub.id,
|
|
2651
|
+
table: sub.table,
|
|
2652
|
+
scopes: sub.scopes,
|
|
2653
|
+
...(sub.params !== undefined ? { params: sub.params } : {}),
|
|
2654
|
+
cursor: -1,
|
|
2655
|
+
...(sub.effectiveScopes !== undefined
|
|
2656
|
+
? { effectiveScopes: sub.effectiveScopes }
|
|
2657
|
+
: {}),
|
|
2658
|
+
status: 'active',
|
|
2659
|
+
reasonCode: start.reasonCode,
|
|
2660
|
+
});
|
|
2661
|
+
if (registered !== undefined) {
|
|
2662
|
+
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
2663
|
+
}
|
|
2300
2664
|
});
|
|
2301
2665
|
summary.resets.push(sub.id);
|
|
2302
2666
|
return false;
|
|
@@ -2306,44 +2670,48 @@ export class SyncClient {
|
|
|
2306
2670
|
// (never the requested map), drop doomed outbox commits, stop pulling.
|
|
2307
2671
|
const table = this.#table(sub.table);
|
|
2308
2672
|
const lastEffective = sub.effectiveScopes;
|
|
2673
|
+
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
2309
2674
|
let failed = false;
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2675
|
+
this.#applyBatch((batch) => {
|
|
2676
|
+
if (
|
|
2677
|
+
lastEffective !== undefined &&
|
|
2678
|
+
Object.keys(lastEffective).length > 0
|
|
2679
|
+
) {
|
|
2680
|
+
try {
|
|
2313
2681
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
failed = true;
|
|
2331
|
-
} else {
|
|
2332
|
-
throw error;
|
|
2682
|
+
batch.scopeMap(table, lastEffective);
|
|
2683
|
+
if (
|
|
2684
|
+
dropOutboxCommitsInScope(this.#db, table, lastEffective).length > 0
|
|
2685
|
+
) {
|
|
2686
|
+
batch.status();
|
|
2687
|
+
}
|
|
2688
|
+
this.#reconcileBlobs(true);
|
|
2689
|
+
} catch (error) {
|
|
2690
|
+
if (
|
|
2691
|
+
error instanceof ClientSyncError &&
|
|
2692
|
+
error.code === 'sync.scope_revoked'
|
|
2693
|
+
) {
|
|
2694
|
+
failed = true;
|
|
2695
|
+
} else {
|
|
2696
|
+
throw error;
|
|
2697
|
+
}
|
|
2333
2698
|
}
|
|
2334
2699
|
}
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
:
|
|
2345
|
-
|
|
2346
|
-
|
|
2700
|
+
saveSubscription(this.#db, {
|
|
2701
|
+
id: sub.id,
|
|
2702
|
+
table: sub.table,
|
|
2703
|
+
scopes: sub.scopes,
|
|
2704
|
+
...(sub.params !== undefined ? { params: sub.params } : {}),
|
|
2705
|
+
cursor: nextCursor,
|
|
2706
|
+
...(lastEffective !== undefined
|
|
2707
|
+
? { effectiveScopes: lastEffective }
|
|
2708
|
+
: {}),
|
|
2709
|
+
status: failed ? 'failed' : 'revoked',
|
|
2710
|
+
reasonCode: start.reasonCode,
|
|
2711
|
+
});
|
|
2712
|
+
if (registered !== undefined) {
|
|
2713
|
+
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
2714
|
+
}
|
|
2347
2715
|
});
|
|
2348
2716
|
summary.revoked.push(sub.id);
|
|
2349
2717
|
if (failed) summary.failed.push(sub.id);
|
|
@@ -2354,12 +2722,16 @@ export class SyncClient {
|
|
|
2354
2722
|
|
|
2355
2723
|
#applyOperationsLocally(
|
|
2356
2724
|
operations: readonly OutboxOperation[],
|
|
2357
|
-
batch?:
|
|
2725
|
+
batch?: ChangeAccumulator,
|
|
2358
2726
|
): void {
|
|
2359
2727
|
for (const op of operations) {
|
|
2360
2728
|
const table = this.#table(op.table);
|
|
2361
|
-
|
|
2729
|
+
let precise =
|
|
2730
|
+
batch === undefined
|
|
2731
|
+
? false
|
|
2732
|
+
: this.#recordStoredRowScopes(batch, table, op.rowId);
|
|
2362
2733
|
if (op.op === 'delete') {
|
|
2734
|
+
if (batch !== undefined && !precise) batch.table(op.table);
|
|
2363
2735
|
deleteLocalRow(this.#db, table, op.rowId);
|
|
2364
2736
|
continue;
|
|
2365
2737
|
}
|
|
@@ -2374,9 +2746,11 @@ export class SyncClient {
|
|
|
2374
2746
|
const cell = idx === undefined ? undefined : values[idx];
|
|
2375
2747
|
const prefix = table.scopePrefixByVariable.get(variable);
|
|
2376
2748
|
if (prefix !== undefined && cell != null) {
|
|
2377
|
-
batch.
|
|
2749
|
+
batch.scope(table.name, `${prefix}:${String(cell)}`);
|
|
2750
|
+
precise = true;
|
|
2378
2751
|
}
|
|
2379
2752
|
}
|
|
2753
|
+
if (!precise) batch.table(table.name);
|
|
2380
2754
|
}
|
|
2381
2755
|
const existing = this.#db.query(
|
|
2382
2756
|
`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|