@syncular/client 0.15.48 → 0.17.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 +50 -0
- package/dist/bun-database.js +11 -0
- package/dist/client.d.ts +13 -20
- package/dist/client.js +78 -61
- package/dist/node-database.js +8 -0
- package/dist/outbox.d.ts +5 -1
- package/dist/outbox.js +26 -3
- package/dist/reactive-store.d.ts +12 -6
- package/dist/reactive-store.js +219 -45
- package/dist/realtime-supervisor.d.ts +1 -1
- package/dist/realtime-supervisor.js +2 -8
- package/dist/worker-entry.js +6 -10
- package/dist/worker-host.d.ts +4 -7
- package/dist/worker-host.js +3 -15
- package/dist/worker-protocol.d.ts +4 -18
- package/package.json +3 -11
- package/src/bun-database.ts +10 -0
- package/src/client.ts +124 -69
- package/src/node-database.ts +7 -0
- package/src/outbox.ts +39 -8
- package/src/reactive-store.ts +226 -56
- package/src/realtime-supervisor.ts +2 -11
- package/src/worker-entry.ts +7 -10
- package/src/worker-host.ts +5 -22
- package/src/worker-protocol.ts +7 -33
- package/dist/realtime-supervisor-observation.d.ts +0 -8
- package/dist/realtime-supervisor-observation.js +0 -15
- package/src/realtime-supervisor-observation.ts +0 -21
package/README.md
CHANGED
|
@@ -543,6 +543,14 @@ Node 22.13 or newer. No SQLite package or native addon is required. Both
|
|
|
543
543
|
adapters support synchronous `exec`, `query`, nested transactions, boolean
|
|
544
544
|
bindings, `null`, `Uint8Array` BLOB values, and §5.3 SQLite-image attachment.
|
|
545
545
|
|
|
546
|
+
Persistent Bun and Node databases use SQLite WAL journaling with
|
|
547
|
+
`synchronous=FULL`. Each application or sync commit retains its own durable
|
|
548
|
+
transaction. SQLite keeps in-memory databases on its memory journal. Use a
|
|
549
|
+
local filesystem path; WAL uses adjacent `-wal` and `-shm` files. Close every
|
|
550
|
+
connection before copying the database file, or use SQLite's backup facilities
|
|
551
|
+
while it is open. Copying only the main file while writers are active can omit
|
|
552
|
+
committed WAL contents.
|
|
553
|
+
|
|
546
554
|
Runtime-specific imports remain available:
|
|
547
555
|
|
|
548
556
|
```ts
|
|
@@ -580,3 +588,45 @@ Tests drive the real worker entry in a bun `Worker` with bun:sqlite
|
|
|
580
588
|
injected through the bootstrap's database-factory override
|
|
581
589
|
(`test/worker-rpc.test.ts`); the OPFS path itself is browser-only and is
|
|
582
590
|
exercised by `apps/demo`.
|
|
591
|
+
|
|
592
|
+
## Snapshot API migration
|
|
593
|
+
|
|
594
|
+
This source-breaking revision uses methods for application reads across the
|
|
595
|
+
direct client, worker leaders and followers, Tauri, and React Native. Replace
|
|
596
|
+
`client.conflicts`, `client.rejections`, and `client.securityLifecycle` on the
|
|
597
|
+
direct client with method calls. Replace `schemaFloor`, `leaseState`,
|
|
598
|
+
`upgrading`, and `syncNeeded` getters or bridge methods with fields from one
|
|
599
|
+
`statusSnapshot()` call:
|
|
600
|
+
|
|
601
|
+
```ts
|
|
602
|
+
const status = await client.statusSnapshot();
|
|
603
|
+
if (status.schemaFloor) showUpgradeRequired(status.schemaFloor);
|
|
604
|
+
const conflicts = await client.conflicts();
|
|
605
|
+
const outcome = await client.commitOutcome(commitId);
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
The direct client returns snapshots synchronously. Worker and native bridges
|
|
609
|
+
return promises; `await` works with both. `querySnapshot` returns rows, coverage,
|
|
610
|
+
and revision from one read. `diagnosticsSnapshot`, `commitOutcome`,
|
|
611
|
+
`commitOutcomes`, and `resolveCommitOutcome` retain their existing arguments.
|
|
612
|
+
The shared `ClientSnapshotMethods` and `PromiseMethods` types describe these
|
|
613
|
+
contracts. Key-bearing security activation stays on each concrete host type.
|
|
614
|
+
|
|
615
|
+
React uses the supplied client directly; `useSyncClient()` preserves its
|
|
616
|
+
identity. Remove imports of `normalizeClient` and the
|
|
617
|
+
`@syncular/client/realtime-supervisor-observation` forwarding utility. Pass the
|
|
618
|
+
client to `SyncProvider` and use `realtimeSupervisorSnapshot(client)` to inspect
|
|
619
|
+
an attached supervisor. Custom React clients must implement the snapshot
|
|
620
|
+
methods and method-form collection reads. See the [React migration](https://syncular.dev/platform-react/)
|
|
621
|
+
for the `onEnqueued` callback rename.
|
|
622
|
+
|
|
623
|
+
## Outbox read costs
|
|
624
|
+
|
|
625
|
+
Request encoding pins the pending count and highest local sequence before its
|
|
626
|
+
first asynchronous step. It reads keyset pages of 32 raw records, decodes only
|
|
627
|
+
the consumed prefix, and stops at the first whole commit that exceeds the
|
|
628
|
+
remaining operation budget. Mutations appended during encoding enter the next
|
|
629
|
+
request. Status and diagnostics use `COUNT(*)` without parsing pending bodies.
|
|
630
|
+
Optimistic replay still reads the remaining outbox after each response. The
|
|
631
|
+
100/1,000/10,000-commit workload and measured limits are recorded in
|
|
632
|
+
[the reliability RFC](../../docs/RFC-RELIABILITY-DX.md#9-implementation-evidence-2026-09-05).
|
package/dist/bun-database.js
CHANGED
|
@@ -20,6 +20,17 @@ export class BunClientDatabase {
|
|
|
20
20
|
#tx = { depth: 0 };
|
|
21
21
|
constructor(path = ':memory:') {
|
|
22
22
|
this.db = new Database(path);
|
|
23
|
+
// Match native Rust persistence: append durable commits to the WAL
|
|
24
|
+
// instead of creating and syncing a rollback journal per transaction.
|
|
25
|
+
// SQLite retains its in-memory journal for :memory: databases.
|
|
26
|
+
try {
|
|
27
|
+
this.db.run('PRAGMA journal_mode = WAL');
|
|
28
|
+
this.db.run('PRAGMA synchronous = FULL');
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
this.db.close();
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
23
34
|
}
|
|
24
35
|
exec(sql, params = []) {
|
|
25
36
|
this.db.query(sql).run(...coerceParams(params));
|
package/dist/client.d.ts
CHANGED
|
@@ -231,6 +231,16 @@ export interface QuerySnapshot<Row = SqlRow> {
|
|
|
231
231
|
* complete once its bootstrap round finishes — emptiness ≠ pendency.
|
|
232
232
|
*/
|
|
233
233
|
export declare function windowComplete(state: WindowState, unit: string): boolean;
|
|
234
|
+
/** Canonical client reads, shared by synchronous cores and promise hosts. */
|
|
235
|
+
export type ClientSnapshotMethods = Pick<SyncClient, 'querySnapshot' | 'statusSnapshot' | 'diagnosticsSnapshot' | 'conflicts' | 'rejections' | 'commitOutcome' | 'commitOutcomes' | 'resolveCommitOutcome'>;
|
|
236
|
+
/** Project a method contract across an asynchronous host boundary. */
|
|
237
|
+
export type PromiseMethods<Methods> = {
|
|
238
|
+
[Key in keyof Methods]: Methods[Key] extends (...args: infer Args) => infer Result ? (...args: Args) => Promise<Awaited<Result>> : never;
|
|
239
|
+
};
|
|
240
|
+
/** A reader can execute locally or cross a worker/native boundary. */
|
|
241
|
+
export type ClientSnapshotReader = {
|
|
242
|
+
[Key in keyof ClientSnapshotMethods]: (...args: Parameters<ClientSnapshotMethods[Key]>) => ReturnType<ClientSnapshotMethods[Key]> | Promise<ReturnType<ClientSnapshotMethods[Key]>>;
|
|
243
|
+
};
|
|
234
244
|
export declare class SyncClient {
|
|
235
245
|
#private;
|
|
236
246
|
constructor(config: SyncClientConfig);
|
|
@@ -238,7 +248,7 @@ export declare class SyncClient {
|
|
|
238
248
|
start(): Promise<void>;
|
|
239
249
|
close(): Promise<void>;
|
|
240
250
|
/** Current fail-closed local-replica security state. */
|
|
241
|
-
|
|
251
|
+
securityLifecycle(): SecurityLifecycle;
|
|
242
252
|
/**
|
|
243
253
|
* Block new protected operations immediately, then wait for every already
|
|
244
254
|
* serialized database/network operation to settle before releasing key
|
|
@@ -320,8 +330,8 @@ export declare class SyncClient {
|
|
|
320
330
|
fetchBlob(blobIdOrRef: string): Promise<CachedBlob>;
|
|
321
331
|
/** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
|
|
322
332
|
flushBlobUploads(): Promise<void>;
|
|
323
|
-
|
|
324
|
-
|
|
333
|
+
conflicts(): readonly ConflictRecord[];
|
|
334
|
+
rejections(): readonly RejectionRecord[];
|
|
325
335
|
/** One durable final outcome by the originating client commit id. */
|
|
326
336
|
commitOutcome(clientCommitId: string): CommitOutcome | undefined;
|
|
327
337
|
/** Newest-first durable outcome journal. */
|
|
@@ -333,28 +343,11 @@ export declare class SyncClient {
|
|
|
333
343
|
* dismissed. The transition is one-way and survives restart.
|
|
334
344
|
*/
|
|
335
345
|
resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
|
|
336
|
-
/** Non-undefined once the server declared a schema floor (§1.6). */
|
|
337
|
-
get schemaFloor(): SchemaFloor | undefined;
|
|
338
|
-
/**
|
|
339
|
-
* §7.4.5: true while a schema-bump reset + first re-bootstrap is in
|
|
340
|
-
* flight — the app's "upgrading…" cue. Clears when the first post-reset
|
|
341
|
-
* bootstrap round reaches idle (every subscription past its fresh
|
|
342
|
-
* bootstrap).
|
|
343
|
-
*/
|
|
344
|
-
get upgrading(): boolean;
|
|
345
|
-
/**
|
|
346
|
-
* §7.3.5: the current auth-lease state (opaque). Undefined until a
|
|
347
|
-
* `LEASE` frame arrives. `errorCode` is set when a round was rejected
|
|
348
|
-
* with a request-level lease code — syncing on the lease has stopped.
|
|
349
|
-
*/
|
|
350
|
-
get leaseState(): LeaseState | undefined;
|
|
351
346
|
/** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
|
|
352
347
|
* `undefined` if no lease is held. Negative once expired. */
|
|
353
348
|
leaseRemainingMs(now?: number): number | undefined;
|
|
354
349
|
/** True when syncing is stopped pending a client upgrade. */
|
|
355
350
|
get stopped(): boolean;
|
|
356
|
-
/** §8: a hello/wake-up asked for a pull that has not run yet. */
|
|
357
|
-
get syncNeeded(): boolean;
|
|
358
351
|
/**
|
|
359
352
|
* §8.6 presence on a scope key: the current peers present there (a map
|
|
360
353
|
* of `actorId clientId` → peer). Empty for a key with no present peers.
|
package/dist/client.js
CHANGED
|
@@ -18,7 +18,7 @@ import { singleOwnerLock, } from './leader-lock.js';
|
|
|
18
18
|
import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
|
|
19
19
|
import { compileLocalDataRebootstrap, localDataRebootstrapMetaKey, } from './local-rebootstrap.js';
|
|
20
20
|
import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, } from './local-rebootstrap-receipt.js';
|
|
21
|
-
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
|
|
21
|
+
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, iterateOutbox, countOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
|
|
22
22
|
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
23
23
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
24
24
|
import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
@@ -232,7 +232,7 @@ export class SyncClient {
|
|
|
232
232
|
// this as an exact core-owned intent so hosts never need a startup poll or
|
|
233
233
|
// an application-issued sync() call.
|
|
234
234
|
const startupWork = this.#schemaFloor === undefined &&
|
|
235
|
-
(
|
|
235
|
+
(countOutbox(this.#db) > 0 ||
|
|
236
236
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
237
237
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
238
238
|
this.#needsPull = true;
|
|
@@ -247,10 +247,10 @@ export class SyncClient {
|
|
|
247
247
|
role: () => 'direct',
|
|
248
248
|
outbox: async () => this.pendingCommits().length,
|
|
249
249
|
subscriptions: async () => this.subscriptions(),
|
|
250
|
-
conflicts: async () => this.conflicts.length,
|
|
251
|
-
rejections: async () => this.rejections.length,
|
|
252
|
-
syncNeeded: async () => this.syncNeeded,
|
|
253
|
-
upgrading: async () => this.upgrading,
|
|
250
|
+
conflicts: async () => this.conflicts().length,
|
|
251
|
+
rejections: async () => this.rejections().length,
|
|
252
|
+
syncNeeded: async () => this.statusSnapshot().syncNeeded,
|
|
253
|
+
upgrading: async () => this.statusSnapshot().upgrading,
|
|
254
254
|
onInvalidate: (listener) => this.onInvalidate(listener),
|
|
255
255
|
});
|
|
256
256
|
this.#emitDiagnostics();
|
|
@@ -395,7 +395,7 @@ export class SyncClient {
|
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
397
|
/** Current fail-closed local-replica security state. */
|
|
398
|
-
|
|
398
|
+
securityLifecycle() {
|
|
399
399
|
return this.#securityLifecycle;
|
|
400
400
|
}
|
|
401
401
|
/**
|
|
@@ -439,7 +439,7 @@ export class SyncClient {
|
|
|
439
439
|
this.#encryption = options.encryption;
|
|
440
440
|
this.#securityLifecycle = 'active';
|
|
441
441
|
const startupWork = this.#schemaFloor === undefined &&
|
|
442
|
-
(
|
|
442
|
+
(countOutbox(this.#db) > 0 ||
|
|
443
443
|
loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
|
|
444
444
|
if (startupWork) {
|
|
445
445
|
this.#setSyncNeeded(true);
|
|
@@ -666,7 +666,7 @@ export class SyncClient {
|
|
|
666
666
|
replica: {
|
|
667
667
|
localRevision: getLocalRevision(this.#db).toString(),
|
|
668
668
|
syncNeeded: this.#needsPull,
|
|
669
|
-
pendingOutbox:
|
|
669
|
+
pendingOutbox: countOutbox(this.#db),
|
|
670
670
|
},
|
|
671
671
|
lease: leaseState,
|
|
672
672
|
subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
|
|
@@ -757,7 +757,7 @@ export class SyncClient {
|
|
|
757
757
|
#statusSnapshot(outboxCount) {
|
|
758
758
|
return {
|
|
759
759
|
currentSchemaVersion: this.#config.schema.version,
|
|
760
|
-
outbox: outboxCount ??
|
|
760
|
+
outbox: outboxCount ?? countOutbox(this.#db),
|
|
761
761
|
upgrading: this.#upgrading,
|
|
762
762
|
leaseState: this.#leaseState,
|
|
763
763
|
schemaFloor: this.#schemaFloor,
|
|
@@ -770,7 +770,7 @@ export class SyncClient {
|
|
|
770
770
|
* Re-entrant calls share the outer batch so a nested apply never
|
|
771
771
|
* double-emits (e.g. purge → blob reconcile → replay inside one round).
|
|
772
772
|
*/
|
|
773
|
-
#applyBatch(fn, statusSnapshotOverride) {
|
|
773
|
+
#applyBatch(fn, statusSnapshotOverride, onRollback) {
|
|
774
774
|
if (this.#batch !== undefined)
|
|
775
775
|
return fn(this.#batch);
|
|
776
776
|
const batch = new ChangeAccumulator();
|
|
@@ -796,6 +796,7 @@ export class SyncClient {
|
|
|
796
796
|
}
|
|
797
797
|
catch (error) {
|
|
798
798
|
this.#batch = undefined;
|
|
799
|
+
onRollback?.(error);
|
|
799
800
|
throw error;
|
|
800
801
|
}
|
|
801
802
|
if (revision !== undefined) {
|
|
@@ -929,6 +930,9 @@ export class SyncClient {
|
|
|
929
930
|
throw new ClientSyncError('sync.invalid_request', `blob content address mismatch for ${blobId} (§5.9.5)`);
|
|
930
931
|
}
|
|
931
932
|
putCachedBlob(this.#db, blobId, bytes, this.#now());
|
|
933
|
+
// The referencing row can arrive before its body. Pin the new cache entry
|
|
934
|
+
// from current visible references before applying the size cap (§5.9.7 B1).
|
|
935
|
+
this.#reconcileBlobs(false);
|
|
932
936
|
this.#enforceBlobCacheCap();
|
|
933
937
|
const stored = getCachedBlob(this.#db, blobId);
|
|
934
938
|
if (stored === undefined) {
|
|
@@ -994,11 +998,11 @@ export class SyncClient {
|
|
|
994
998
|
}
|
|
995
999
|
await transport.upload(blobId, bytes, mediaType);
|
|
996
1000
|
}
|
|
997
|
-
|
|
1001
|
+
conflicts() {
|
|
998
1002
|
this.#requireActive();
|
|
999
1003
|
return this.#conflicts;
|
|
1000
1004
|
}
|
|
1001
|
-
|
|
1005
|
+
rejections() {
|
|
1002
1006
|
this.#requireActive();
|
|
1003
1007
|
return this.#rejections;
|
|
1004
1008
|
}
|
|
@@ -1062,27 +1066,6 @@ export class SyncClient {
|
|
|
1062
1066
|
return resolved;
|
|
1063
1067
|
});
|
|
1064
1068
|
}
|
|
1065
|
-
/** Non-undefined once the server declared a schema floor (§1.6). */
|
|
1066
|
-
get schemaFloor() {
|
|
1067
|
-
return this.#schemaFloor;
|
|
1068
|
-
}
|
|
1069
|
-
/**
|
|
1070
|
-
* §7.4.5: true while a schema-bump reset + first re-bootstrap is in
|
|
1071
|
-
* flight — the app's "upgrading…" cue. Clears when the first post-reset
|
|
1072
|
-
* bootstrap round reaches idle (every subscription past its fresh
|
|
1073
|
-
* bootstrap).
|
|
1074
|
-
*/
|
|
1075
|
-
get upgrading() {
|
|
1076
|
-
return this.#upgrading;
|
|
1077
|
-
}
|
|
1078
|
-
/**
|
|
1079
|
-
* §7.3.5: the current auth-lease state (opaque). Undefined until a
|
|
1080
|
-
* `LEASE` frame arrives. `errorCode` is set when a round was rejected
|
|
1081
|
-
* with a request-level lease code — syncing on the lease has stopped.
|
|
1082
|
-
*/
|
|
1083
|
-
get leaseState() {
|
|
1084
|
-
return this.#leaseState;
|
|
1085
|
-
}
|
|
1086
1069
|
/** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
|
|
1087
1070
|
* `undefined` if no lease is held. Negative once expired. */
|
|
1088
1071
|
leaseRemainingMs(now = this.#now()) {
|
|
@@ -1093,10 +1076,6 @@ export class SyncClient {
|
|
|
1093
1076
|
get stopped() {
|
|
1094
1077
|
return this.#schemaFloor !== undefined;
|
|
1095
1078
|
}
|
|
1096
|
-
/** §8: a hello/wake-up asked for a pull that has not run yet. */
|
|
1097
|
-
get syncNeeded() {
|
|
1098
|
-
return this.#needsPull;
|
|
1099
|
-
}
|
|
1100
1079
|
/**
|
|
1101
1080
|
* §8.6 presence on a scope key: the current peers present there (a map
|
|
1102
1081
|
* of `actorId clientId` → peer). Empty for a key with no present peers.
|
|
@@ -1731,21 +1710,27 @@ export class SyncClient {
|
|
|
1731
1710
|
* the encoded push frames index-aligned with the surviving `outbox`.
|
|
1732
1711
|
*/
|
|
1733
1712
|
async #encodeOutboxForPush() {
|
|
1734
|
-
|
|
1713
|
+
// Pin before the first encryption await: mutations can append while a
|
|
1714
|
+
// round is encoding, and belong to the next request.
|
|
1715
|
+
const bounds = this.#db.query('SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox')[0];
|
|
1716
|
+
const pendingCount = bounds.count;
|
|
1717
|
+
const throughSeq = bounds.last_seq ?? 0;
|
|
1735
1718
|
const pushFrames = [];
|
|
1736
1719
|
const outbox = [];
|
|
1737
1720
|
let deferred = 0;
|
|
1738
1721
|
let ops = 0;
|
|
1739
|
-
|
|
1722
|
+
let processed = 0;
|
|
1723
|
+
for (const commit of iterateOutbox(this.#db, throughSeq)) {
|
|
1740
1724
|
// §6.1 splitBatch: whole commits in commit order, stopping before the
|
|
1741
1725
|
// per-request operation cap. A first commit that alone exceeds the cap
|
|
1742
1726
|
// is sent alone — the server rejects it loudly rather than the queue
|
|
1743
1727
|
// wedging silently. Deferred commits stay queued for the next round.
|
|
1744
1728
|
if (outbox.length > 0 &&
|
|
1745
1729
|
ops + commit.operations.length > MAX_OPS_PER_REQUEST) {
|
|
1746
|
-
deferred
|
|
1747
|
-
|
|
1730
|
+
deferred = pendingCount - processed;
|
|
1731
|
+
break;
|
|
1748
1732
|
}
|
|
1733
|
+
processed += 1;
|
|
1749
1734
|
try {
|
|
1750
1735
|
pushFrames.push(
|
|
1751
1736
|
// §5.11: encrypted columns are encrypted at this encode-at-send
|
|
@@ -2003,7 +1988,8 @@ export class SyncClient {
|
|
|
2003
1988
|
last.segmentRowsApplied === 0 &&
|
|
2004
1989
|
last.bootstrapping.length === 0 &&
|
|
2005
1990
|
last.resets.length === 0 &&
|
|
2006
|
-
(last.deferredCommits ?? 0) === 0
|
|
1991
|
+
(last.deferredCommits ?? 0) === 0 &&
|
|
1992
|
+
!this.#needsPull) {
|
|
2007
1993
|
return last;
|
|
2008
1994
|
}
|
|
2009
1995
|
}
|
|
@@ -2085,8 +2071,16 @@ export class SyncClient {
|
|
|
2085
2071
|
}
|
|
2086
2072
|
let openedSocket;
|
|
2087
2073
|
const socket = await connector({
|
|
2088
|
-
onText: (text) =>
|
|
2089
|
-
|
|
2074
|
+
onText: (text) => {
|
|
2075
|
+
if (generation !== this.#realtimeGeneration || !this.#started)
|
|
2076
|
+
return;
|
|
2077
|
+
this.#handleRealtimeText(text);
|
|
2078
|
+
},
|
|
2079
|
+
onBinary: (bytes) => {
|
|
2080
|
+
if (generation !== this.#realtimeGeneration || !this.#started)
|
|
2081
|
+
return;
|
|
2082
|
+
this.#routeRealtimeBinary(bytes);
|
|
2083
|
+
},
|
|
2090
2084
|
onClose: () => {
|
|
2091
2085
|
if (openedSocket === undefined || this.#socket !== openedSocket)
|
|
2092
2086
|
return;
|
|
@@ -2329,12 +2323,14 @@ export class SyncClient {
|
|
|
2329
2323
|
let section;
|
|
2330
2324
|
let errorFrame;
|
|
2331
2325
|
let deltaCursor = -1;
|
|
2332
|
-
let responseOutboxCount;
|
|
2333
2326
|
// Each durable observer transaction emits its own revisioned batch.
|
|
2334
2327
|
// Async decrypt/download work happens outside SQLite transactions.
|
|
2335
2328
|
this.#beginDiagnosticsDeferral();
|
|
2336
2329
|
try {
|
|
2337
|
-
for (
|
|
2330
|
+
for (let index = 1; index < message.frames.length; index += 1) {
|
|
2331
|
+
const frame = message.frames[index];
|
|
2332
|
+
if (frame === undefined)
|
|
2333
|
+
break;
|
|
2338
2334
|
switch (frame.type) {
|
|
2339
2335
|
case 'RESP_HEADER':
|
|
2340
2336
|
break;
|
|
@@ -2347,13 +2343,41 @@ export class SyncClient {
|
|
|
2347
2343
|
});
|
|
2348
2344
|
break;
|
|
2349
2345
|
case 'PUSH_RESULT': {
|
|
2350
|
-
|
|
2346
|
+
const results = [frame];
|
|
2347
|
+
if (frame.status !== 'rejected') {
|
|
2348
|
+
while (index + 1 < message.frames.length) {
|
|
2349
|
+
const next = message.frames[index + 1];
|
|
2350
|
+
if (next?.type !== 'PUSH_RESULT' || next.status === 'rejected')
|
|
2351
|
+
break;
|
|
2352
|
+
results.push(next);
|
|
2353
|
+
index += 1;
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
const conflictCount = this.#conflicts.length;
|
|
2357
|
+
const rejectionCount = this.#rejections.length;
|
|
2358
|
+
let outboxCount = countOutbox(this.#db);
|
|
2351
2359
|
this.#applyBatch((batch) => {
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2360
|
+
let drained = false;
|
|
2361
|
+
for (const result of results) {
|
|
2362
|
+
if (this.#handlePushResult(result, commitsById, summary, batch, rejectionDetailsByCommit.get(result.clientCommitId))) {
|
|
2363
|
+
outboxCount -= 1;
|
|
2364
|
+
drained = true;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
if (drained &&
|
|
2368
|
+
results.some((result) => result === lastFinalPushResult)) {
|
|
2369
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2370
|
+
}
|
|
2371
|
+
}, () => this.#statusSnapshot(outboxCount), (cause) => {
|
|
2372
|
+
this.#conflicts.length = conflictCount;
|
|
2373
|
+
this.#rejections.length = rejectionCount;
|
|
2374
|
+
const error = new ClientSyncError('client.outcome_persistence_failed', 'local commit outcome could not be persisted');
|
|
2375
|
+
error.cause = cause;
|
|
2376
|
+
throw error;
|
|
2377
|
+
});
|
|
2378
|
+
for (const conflict of this.#conflicts.slice(conflictCount)) {
|
|
2379
|
+
this.#config.onConflict?.(conflict);
|
|
2380
|
+
}
|
|
2357
2381
|
break;
|
|
2358
2382
|
}
|
|
2359
2383
|
case 'PUSH_RESULT_DETAILS':
|
|
@@ -2519,7 +2543,7 @@ export class SyncClient {
|
|
|
2519
2543
|
}
|
|
2520
2544
|
return { ...summary, bootstrapping };
|
|
2521
2545
|
}
|
|
2522
|
-
#handlePushResult(frame, commitsById, summary, batch, rejectionDetails
|
|
2546
|
+
#handlePushResult(frame, commitsById, summary, batch, rejectionDetails) {
|
|
2523
2547
|
const commit = commitsById.get(frame.clientCommitId);
|
|
2524
2548
|
if (commit === undefined)
|
|
2525
2549
|
return false;
|
|
@@ -2543,9 +2567,6 @@ export class SyncClient {
|
|
|
2543
2567
|
})),
|
|
2544
2568
|
});
|
|
2545
2569
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
2546
|
-
if (pruneOutcomes) {
|
|
2547
|
-
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2548
|
-
}
|
|
2549
2570
|
batch.status();
|
|
2550
2571
|
batch.outcomes();
|
|
2551
2572
|
summary.applied.push(frame.clientCommitId);
|
|
@@ -2580,7 +2601,6 @@ export class SyncClient {
|
|
|
2580
2601
|
outcomeResults.push({ status: 'conflict', conflict });
|
|
2581
2602
|
batch.conflicts();
|
|
2582
2603
|
summary.conflicts.push(conflict);
|
|
2583
|
-
this.#config.onConflict?.(conflict);
|
|
2584
2604
|
}
|
|
2585
2605
|
else if (result.status === 'error') {
|
|
2586
2606
|
const details = rejectionDetails?.get(result.opIndex);
|
|
@@ -2610,9 +2630,6 @@ export class SyncClient {
|
|
|
2610
2630
|
results: outcomeResults,
|
|
2611
2631
|
operations: commit.operations,
|
|
2612
2632
|
});
|
|
2613
|
-
if (pruneOutcomes) {
|
|
2614
|
-
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2615
|
-
}
|
|
2616
2633
|
batch.outcomes();
|
|
2617
2634
|
// §7.2: remove the rejected optimistic layer. Before-images restore
|
|
2618
2635
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
package/dist/node-database.js
CHANGED
|
@@ -32,6 +32,14 @@ export class NodeClientDatabase {
|
|
|
32
32
|
#tx = { depth: 0 };
|
|
33
33
|
constructor(path = ':memory:') {
|
|
34
34
|
this.db = new DatabaseSync(path);
|
|
35
|
+
try {
|
|
36
|
+
this.db.exec('PRAGMA journal_mode = WAL');
|
|
37
|
+
this.db.exec('PRAGMA synchronous = FULL');
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
this.db.close();
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
35
43
|
}
|
|
36
44
|
exec(sql, params = []) {
|
|
37
45
|
this.db.prepare(sql).run(...coerceParams(params));
|
package/dist/outbox.d.ts
CHANGED
|
@@ -38,8 +38,12 @@ export interface OutboxBeforeImage {
|
|
|
38
38
|
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
39
39
|
}
|
|
40
40
|
export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
|
|
41
|
-
/** Pending commits in FIFO creation order (§7.1). */
|
|
41
|
+
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
42
42
|
export declare function listOutbox(db: ClientDatabase): OutboxCommit[];
|
|
43
|
+
/** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
|
|
44
|
+
export declare function iterateOutbox(db: ClientDatabase, throughSeq: number): Generator<OutboxCommit>;
|
|
45
|
+
/** Routine status reads never load operation bodies. */
|
|
46
|
+
export declare function countOutbox(db: ClientDatabase): number;
|
|
43
47
|
export declare function deleteOutboxCommit(db: ClientDatabase, clientCommitId: string): void;
|
|
44
48
|
export declare function listOutboxBeforeImages(db: ClientDatabase, clientCommitId: string): OutboxBeforeImage[];
|
|
45
49
|
export declare function replaceOutboxBeforeImages(db: ClientDatabase, clientCommitId: string, replacements: readonly OutboxBeforeImage[]): void;
|
package/dist/outbox.js
CHANGED
|
@@ -26,17 +26,40 @@ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, before
|
|
|
26
26
|
]);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
/** Pending commits in FIFO creation order (§7.1). */
|
|
29
|
+
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
30
30
|
export function listOutbox(db) {
|
|
31
31
|
return db
|
|
32
32
|
.query(`SELECT seq, client_commit_id, created_at_ms, operations
|
|
33
33
|
FROM _syncular_outbox ORDER BY seq ASC`)
|
|
34
|
-
.map(
|
|
34
|
+
.map(decodeOutboxRow);
|
|
35
|
+
}
|
|
36
|
+
function decodeOutboxRow(row) {
|
|
37
|
+
return {
|
|
35
38
|
seq: row.seq,
|
|
36
39
|
clientCommitId: row.client_commit_id,
|
|
37
40
|
createdAtMs: row.created_at_ms,
|
|
38
41
|
operations: JSON.parse(row.operations),
|
|
39
|
-
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
|
|
45
|
+
export function* iterateOutbox(db, throughSeq) {
|
|
46
|
+
let afterSeq = 0;
|
|
47
|
+
while (afterSeq < throughSeq) {
|
|
48
|
+
const rows = db.query(`SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox
|
|
49
|
+
WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`, [afterSeq, throughSeq]);
|
|
50
|
+
if (rows.length === 0)
|
|
51
|
+
return;
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const commit = decodeOutboxRow(row);
|
|
54
|
+
afterSeq = commit.seq;
|
|
55
|
+
yield commit;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Routine status reads never load operation bodies. */
|
|
60
|
+
export function countOutbox(db) {
|
|
61
|
+
return db.query('SELECT COUNT(*) AS count FROM _syncular_outbox')[0]
|
|
62
|
+
.count;
|
|
40
63
|
}
|
|
41
64
|
export function deleteOutboxCommit(db, clientCommitId) {
|
|
42
65
|
db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id = ?', [clientCommitId]);
|
package/dist/reactive-store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type SyncAvailability } from './availability.js';
|
|
2
|
-
import type { CommitOutcome, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
|
|
2
|
+
import type { CommitOutcome, ClientSnapshotReader, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
|
|
3
3
|
import type { SqlValue } from './database.js';
|
|
4
4
|
import type { ClientChangeListener, SyncStatusSnapshot } from './invalidation.js';
|
|
5
5
|
import type { LeadershipState } from './multi-tab.js';
|
|
@@ -27,16 +27,14 @@ export interface LiveQueryResult<Row> {
|
|
|
27
27
|
readonly isRefreshing: boolean;
|
|
28
28
|
readonly availability: SyncAvailability;
|
|
29
29
|
}
|
|
30
|
-
export interface ReactiveQueryClient {
|
|
30
|
+
export interface ReactiveQueryClient extends Pick<ClientSnapshotReader, 'statusSnapshot' | 'commitOutcomes'> {
|
|
31
31
|
readonly currentSchemaVersion?: number;
|
|
32
32
|
onChange(listener: ClientChangeListener): () => void;
|
|
33
33
|
querySnapshot<Row = Record<string, SqlValue>>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
34
|
-
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
35
34
|
leadershipSnapshot?(): LeadershipState | undefined;
|
|
36
35
|
onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
commitOutcomes(): readonly CommitOutcome[] | Promise<readonly CommitOutcome[]>;
|
|
36
|
+
conflicts(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
37
|
+
rejections(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
40
38
|
setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
|
|
41
39
|
windowState(base: WindowBase): WindowState | Promise<WindowState>;
|
|
42
40
|
}
|
|
@@ -85,6 +83,14 @@ export declare class ReactiveClientStore {
|
|
|
85
83
|
window(base: WindowBase): ExternalStoreEntry<WindowState>;
|
|
86
84
|
setWindowClaim(owner: symbol, base: WindowBase, units: readonly string[]): Promise<void>;
|
|
87
85
|
releaseWindowClaims(owner: symbol): void;
|
|
86
|
+
/** Retained observation counts for diagnostics and resource benchmarks. */
|
|
87
|
+
cacheStats(): {
|
|
88
|
+
queries: number;
|
|
89
|
+
activeQueries: number;
|
|
90
|
+
windows: number;
|
|
91
|
+
activeWindows: number;
|
|
92
|
+
windowClaims: number;
|
|
93
|
+
};
|
|
88
94
|
start(): void;
|
|
89
95
|
dispose(): void;
|
|
90
96
|
}
|