@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
|
@@ -18,15 +18,14 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { WakeReason } from '@syncular/core';
|
|
20
20
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
21
|
-
import type { ConflictRecord,
|
|
21
|
+
import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
22
22
|
import type { SqlRow, SqlValue } from './database.js';
|
|
23
|
-
import type {
|
|
23
|
+
import type { ClientDiagnosticsSnapshot } from './diagnostics.js';
|
|
24
24
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
25
|
-
import type { ClientChangeBatch, LocalRevision
|
|
25
|
+
import type { ClientChangeBatch, LocalRevision } from './invalidation.js';
|
|
26
26
|
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
|
|
27
27
|
import type { LocalDataRebootstrapInput, LocalDataRebootstrapResult } from './local-rebootstrap.js';
|
|
28
28
|
import type { OutboxCommit } from './outbox.js';
|
|
29
|
-
import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
|
|
30
29
|
import type { ClientSchema } from './schema.js';
|
|
31
30
|
import type { SubscriptionRecord } from './state.js';
|
|
32
31
|
import type { WindowBase } from './window.js';
|
|
@@ -88,7 +87,7 @@ export interface WorkerInitResult {
|
|
|
88
87
|
export interface WorkerSecurityActivation {
|
|
89
88
|
readonly encryption?: EncryptionKeyringConfig;
|
|
90
89
|
}
|
|
91
|
-
export interface WorkerApi {
|
|
90
|
+
export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'> {
|
|
92
91
|
securityLifecycle(): SecurityLifecycle;
|
|
93
92
|
beginSecurityPreflight(): Promise<void>;
|
|
94
93
|
activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
|
|
@@ -112,19 +111,6 @@ export interface WorkerApi {
|
|
|
112
111
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
113
112
|
querySnapshot(spec: QueryReadSpec): QuerySnapshot;
|
|
114
113
|
localRevision(): LocalRevision;
|
|
115
|
-
statusSnapshot(): SyncStatusSnapshot;
|
|
116
|
-
diagnosticsSnapshot(request?: ClientDiagnosticsRequest): ClientDiagnosticsSnapshot;
|
|
117
|
-
conflicts(): readonly ConflictRecord[];
|
|
118
|
-
rejections(): readonly RejectionRecord[];
|
|
119
|
-
commitOutcome(clientCommitId: string): CommitOutcome | undefined;
|
|
120
|
-
commitOutcomes(query?: CommitOutcomeQuery): readonly CommitOutcome[];
|
|
121
|
-
resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
|
|
122
|
-
schemaFloor(): SchemaFloor | undefined;
|
|
123
|
-
/** §7.3.5: the opaque auth-lease state, or undefined. */
|
|
124
|
-
leaseState(): LeaseState | undefined;
|
|
125
|
-
/** §7.4.5: true while a schema-bump reset + first re-bootstrap runs. */
|
|
126
|
-
upgrading(): boolean;
|
|
127
|
-
syncNeeded(): boolean;
|
|
128
114
|
pendingCommits(): OutboxCommit[];
|
|
129
115
|
subscriptions(): SubscriptionRecord[];
|
|
130
116
|
subscription(id: string): SubscriptionRecord | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -34,14 +34,6 @@
|
|
|
34
34
|
"default": "./dist/index.js"
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
|
-
"./realtime-supervisor-observation": {
|
|
38
|
-
"bun": "./src/realtime-supervisor-observation.ts",
|
|
39
|
-
"browser": "./dist/realtime-supervisor-observation.js",
|
|
40
|
-
"import": {
|
|
41
|
-
"types": "./dist/realtime-supervisor-observation.d.ts",
|
|
42
|
-
"default": "./dist/realtime-supervisor-observation.js"
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
37
|
"./bun": {
|
|
46
38
|
"bun": "./src/bun-database.ts",
|
|
47
39
|
"browser": "./dist/bun-database.js",
|
|
@@ -97,9 +89,9 @@
|
|
|
97
89
|
},
|
|
98
90
|
"dependencies": {
|
|
99
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
100
|
-
"@syncular/core": "0.
|
|
92
|
+
"@syncular/core": "0.17.0"
|
|
101
93
|
},
|
|
102
94
|
"devDependencies": {
|
|
103
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.17.0"
|
|
104
96
|
}
|
|
105
97
|
}
|
package/src/bun-database.ts
CHANGED
|
@@ -36,6 +36,16 @@ export class BunClientDatabase implements ClientDatabase {
|
|
|
36
36
|
|
|
37
37
|
constructor(path = ':memory:') {
|
|
38
38
|
this.db = new Database(path);
|
|
39
|
+
// Match native Rust persistence: append durable commits to the WAL
|
|
40
|
+
// instead of creating and syncing a rollback journal per transaction.
|
|
41
|
+
// SQLite retains its in-memory journal for :memory: databases.
|
|
42
|
+
try {
|
|
43
|
+
this.db.run('PRAGMA journal_mode = WAL');
|
|
44
|
+
this.db.run('PRAGMA synchronous = FULL');
|
|
45
|
+
} catch (error) {
|
|
46
|
+
this.db.close();
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
exec(sql: string, params: readonly SqlValue[] = []): void {
|
package/src/client.ts
CHANGED
|
@@ -119,6 +119,8 @@ import {
|
|
|
119
119
|
dropOutboxCommitsInScope,
|
|
120
120
|
encodeOutboxCommit,
|
|
121
121
|
listOutbox,
|
|
122
|
+
iterateOutbox,
|
|
123
|
+
countOutbox,
|
|
122
124
|
listOutboxBeforeImages,
|
|
123
125
|
type OutboxBeforeImage,
|
|
124
126
|
type OutboxCommit,
|
|
@@ -527,6 +529,37 @@ function isFinalPushResult(frame: PushResultFrame): boolean {
|
|
|
527
529
|
);
|
|
528
530
|
}
|
|
529
531
|
|
|
532
|
+
/** Canonical client reads, shared by synchronous cores and promise hosts. */
|
|
533
|
+
export type ClientSnapshotMethods = Pick<
|
|
534
|
+
SyncClient,
|
|
535
|
+
| 'querySnapshot'
|
|
536
|
+
| 'statusSnapshot'
|
|
537
|
+
| 'diagnosticsSnapshot'
|
|
538
|
+
| 'conflicts'
|
|
539
|
+
| 'rejections'
|
|
540
|
+
| 'commitOutcome'
|
|
541
|
+
| 'commitOutcomes'
|
|
542
|
+
| 'resolveCommitOutcome'
|
|
543
|
+
>;
|
|
544
|
+
|
|
545
|
+
/** Project a method contract across an asynchronous host boundary. */
|
|
546
|
+
export type PromiseMethods<Methods> = {
|
|
547
|
+
[Key in keyof Methods]: Methods[Key] extends (
|
|
548
|
+
...args: infer Args
|
|
549
|
+
) => infer Result
|
|
550
|
+
? (...args: Args) => Promise<Awaited<Result>>
|
|
551
|
+
: never;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
/** A reader can execute locally or cross a worker/native boundary. */
|
|
555
|
+
export type ClientSnapshotReader = {
|
|
556
|
+
[Key in keyof ClientSnapshotMethods]: (
|
|
557
|
+
...args: Parameters<ClientSnapshotMethods[Key]>
|
|
558
|
+
) =>
|
|
559
|
+
| ReturnType<ClientSnapshotMethods[Key]>
|
|
560
|
+
| Promise<ReturnType<ClientSnapshotMethods[Key]>>;
|
|
561
|
+
};
|
|
562
|
+
|
|
530
563
|
export class SyncClient {
|
|
531
564
|
readonly #config: SyncClientConfig;
|
|
532
565
|
readonly #db: ClientDatabase;
|
|
@@ -707,7 +740,7 @@ export class SyncClient {
|
|
|
707
740
|
// an application-issued sync() call.
|
|
708
741
|
const startupWork =
|
|
709
742
|
this.#schemaFloor === undefined &&
|
|
710
|
-
(
|
|
743
|
+
(countOutbox(this.#db) > 0 ||
|
|
711
744
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
712
745
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
713
746
|
this.#needsPull = true;
|
|
@@ -722,10 +755,10 @@ export class SyncClient {
|
|
|
722
755
|
role: () => 'direct',
|
|
723
756
|
outbox: async () => this.pendingCommits().length,
|
|
724
757
|
subscriptions: async () => this.subscriptions(),
|
|
725
|
-
conflicts: async () => this.conflicts.length,
|
|
726
|
-
rejections: async () => this.rejections.length,
|
|
727
|
-
syncNeeded: async () => this.syncNeeded,
|
|
728
|
-
upgrading: async () => this.upgrading,
|
|
758
|
+
conflicts: async () => this.conflicts().length,
|
|
759
|
+
rejections: async () => this.rejections().length,
|
|
760
|
+
syncNeeded: async () => this.statusSnapshot().syncNeeded,
|
|
761
|
+
upgrading: async () => this.statusSnapshot().upgrading,
|
|
729
762
|
onInvalidate: (listener) => this.onInvalidate(listener),
|
|
730
763
|
});
|
|
731
764
|
this.#emitDiagnostics();
|
|
@@ -875,7 +908,7 @@ export class SyncClient {
|
|
|
875
908
|
}
|
|
876
909
|
|
|
877
910
|
/** Current fail-closed local-replica security state. */
|
|
878
|
-
|
|
911
|
+
securityLifecycle(): SecurityLifecycle {
|
|
879
912
|
return this.#securityLifecycle;
|
|
880
913
|
}
|
|
881
914
|
|
|
@@ -927,7 +960,7 @@ export class SyncClient {
|
|
|
927
960
|
this.#securityLifecycle = 'active';
|
|
928
961
|
const startupWork =
|
|
929
962
|
this.#schemaFloor === undefined &&
|
|
930
|
-
(
|
|
963
|
+
(countOutbox(this.#db) > 0 ||
|
|
931
964
|
loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
|
|
932
965
|
if (startupWork) {
|
|
933
966
|
this.#setSyncNeeded(true);
|
|
@@ -1194,7 +1227,7 @@ export class SyncClient {
|
|
|
1194
1227
|
replica: {
|
|
1195
1228
|
localRevision: getLocalRevision(this.#db).toString(),
|
|
1196
1229
|
syncNeeded: this.#needsPull,
|
|
1197
|
-
pendingOutbox:
|
|
1230
|
+
pendingOutbox: countOutbox(this.#db),
|
|
1198
1231
|
},
|
|
1199
1232
|
lease: leaseState,
|
|
1200
1233
|
subscriptions: allSubscriptions.slice(
|
|
@@ -1310,7 +1343,7 @@ export class SyncClient {
|
|
|
1310
1343
|
#statusSnapshot(outboxCount?: number): SyncStatusSnapshot {
|
|
1311
1344
|
return {
|
|
1312
1345
|
currentSchemaVersion: this.#config.schema.version,
|
|
1313
|
-
outbox: outboxCount ??
|
|
1346
|
+
outbox: outboxCount ?? countOutbox(this.#db),
|
|
1314
1347
|
upgrading: this.#upgrading,
|
|
1315
1348
|
leaseState: this.#leaseState,
|
|
1316
1349
|
schemaFloor: this.#schemaFloor,
|
|
@@ -1327,6 +1360,7 @@ export class SyncClient {
|
|
|
1327
1360
|
#applyBatch<T>(
|
|
1328
1361
|
fn: (batch: ChangeAccumulator) => T,
|
|
1329
1362
|
statusSnapshotOverride?: () => SyncStatusSnapshot,
|
|
1363
|
+
onRollback?: (error: unknown) => never,
|
|
1330
1364
|
): T {
|
|
1331
1365
|
if (this.#batch !== undefined) return fn(this.#batch);
|
|
1332
1366
|
const batch = new ChangeAccumulator();
|
|
@@ -1350,6 +1384,7 @@ export class SyncClient {
|
|
|
1350
1384
|
});
|
|
1351
1385
|
} catch (error) {
|
|
1352
1386
|
this.#batch = undefined;
|
|
1387
|
+
onRollback?.(error);
|
|
1353
1388
|
throw error;
|
|
1354
1389
|
}
|
|
1355
1390
|
if (revision !== undefined) {
|
|
@@ -1519,6 +1554,9 @@ export class SyncClient {
|
|
|
1519
1554
|
);
|
|
1520
1555
|
}
|
|
1521
1556
|
putCachedBlob(this.#db, blobId, bytes, this.#now());
|
|
1557
|
+
// The referencing row can arrive before its body. Pin the new cache entry
|
|
1558
|
+
// from current visible references before applying the size cap (§5.9.7 B1).
|
|
1559
|
+
this.#reconcileBlobs(false);
|
|
1522
1560
|
this.#enforceBlobCacheCap();
|
|
1523
1561
|
const stored = getCachedBlob(this.#db, blobId);
|
|
1524
1562
|
if (stored === undefined) {
|
|
@@ -1606,12 +1644,12 @@ export class SyncClient {
|
|
|
1606
1644
|
await transport.upload(blobId, bytes, mediaType);
|
|
1607
1645
|
}
|
|
1608
1646
|
|
|
1609
|
-
|
|
1647
|
+
conflicts(): readonly ConflictRecord[] {
|
|
1610
1648
|
this.#requireActive();
|
|
1611
1649
|
return this.#conflicts;
|
|
1612
1650
|
}
|
|
1613
1651
|
|
|
1614
|
-
|
|
1652
|
+
rejections(): readonly RejectionRecord[] {
|
|
1615
1653
|
this.#requireActive();
|
|
1616
1654
|
return this.#rejections;
|
|
1617
1655
|
}
|
|
@@ -1702,30 +1740,6 @@ export class SyncClient {
|
|
|
1702
1740
|
});
|
|
1703
1741
|
}
|
|
1704
1742
|
|
|
1705
|
-
/** Non-undefined once the server declared a schema floor (§1.6). */
|
|
1706
|
-
get schemaFloor(): SchemaFloor | undefined {
|
|
1707
|
-
return this.#schemaFloor;
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
/**
|
|
1711
|
-
* §7.4.5: true while a schema-bump reset + first re-bootstrap is in
|
|
1712
|
-
* flight — the app's "upgrading…" cue. Clears when the first post-reset
|
|
1713
|
-
* bootstrap round reaches idle (every subscription past its fresh
|
|
1714
|
-
* bootstrap).
|
|
1715
|
-
*/
|
|
1716
|
-
get upgrading(): boolean {
|
|
1717
|
-
return this.#upgrading;
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
/**
|
|
1721
|
-
* §7.3.5: the current auth-lease state (opaque). Undefined until a
|
|
1722
|
-
* `LEASE` frame arrives. `errorCode` is set when a round was rejected
|
|
1723
|
-
* with a request-level lease code — syncing on the lease has stopped.
|
|
1724
|
-
*/
|
|
1725
|
-
get leaseState(): LeaseState | undefined {
|
|
1726
|
-
return this.#leaseState;
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
1743
|
/** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
|
|
1730
1744
|
* `undefined` if no lease is held. Negative once expired. */
|
|
1731
1745
|
leaseRemainingMs(now: number = this.#now()): number | undefined {
|
|
@@ -1738,11 +1752,6 @@ export class SyncClient {
|
|
|
1738
1752
|
return this.#schemaFloor !== undefined;
|
|
1739
1753
|
}
|
|
1740
1754
|
|
|
1741
|
-
/** §8: a hello/wake-up asked for a pull that has not run yet. */
|
|
1742
|
-
get syncNeeded(): boolean {
|
|
1743
|
-
return this.#needsPull;
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
1755
|
/**
|
|
1747
1756
|
* §8.6 presence on a scope key: the current peers present there (a map
|
|
1748
1757
|
* of `actorId clientId` → peer). Empty for a key with no present peers.
|
|
@@ -2502,12 +2511,19 @@ export class SyncClient {
|
|
|
2502
2511
|
outbox: OutboxCommit[];
|
|
2503
2512
|
deferred: number;
|
|
2504
2513
|
}> {
|
|
2505
|
-
|
|
2514
|
+
// Pin before the first encryption await: mutations can append while a
|
|
2515
|
+
// round is encoding, and belong to the next request.
|
|
2516
|
+
const bounds = this.#db.query(
|
|
2517
|
+
'SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox',
|
|
2518
|
+
)[0]!;
|
|
2519
|
+
const pendingCount = bounds.count as number;
|
|
2520
|
+
const throughSeq = (bounds.last_seq as number | null) ?? 0;
|
|
2506
2521
|
const pushFrames: RequestFrame[] = [];
|
|
2507
2522
|
const outbox: OutboxCommit[] = [];
|
|
2508
2523
|
let deferred = 0;
|
|
2509
2524
|
let ops = 0;
|
|
2510
|
-
|
|
2525
|
+
let processed = 0;
|
|
2526
|
+
for (const commit of iterateOutbox(this.#db, throughSeq)) {
|
|
2511
2527
|
// §6.1 splitBatch: whole commits in commit order, stopping before the
|
|
2512
2528
|
// per-request operation cap. A first commit that alone exceeds the cap
|
|
2513
2529
|
// is sent alone — the server rejects it loudly rather than the queue
|
|
@@ -2516,9 +2532,10 @@ export class SyncClient {
|
|
|
2516
2532
|
outbox.length > 0 &&
|
|
2517
2533
|
ops + commit.operations.length > MAX_OPS_PER_REQUEST
|
|
2518
2534
|
) {
|
|
2519
|
-
deferred
|
|
2520
|
-
|
|
2535
|
+
deferred = pendingCount - processed;
|
|
2536
|
+
break;
|
|
2521
2537
|
}
|
|
2538
|
+
processed += 1;
|
|
2522
2539
|
try {
|
|
2523
2540
|
pushFrames.push(
|
|
2524
2541
|
// §5.11: encrypted columns are encrypted at this encode-at-send
|
|
@@ -2810,7 +2827,8 @@ export class SyncClient {
|
|
|
2810
2827
|
last.segmentRowsApplied === 0 &&
|
|
2811
2828
|
last.bootstrapping.length === 0 &&
|
|
2812
2829
|
last.resets.length === 0 &&
|
|
2813
|
-
(last.deferredCommits ?? 0) === 0
|
|
2830
|
+
(last.deferredCommits ?? 0) === 0 &&
|
|
2831
|
+
!this.#needsPull
|
|
2814
2832
|
) {
|
|
2815
2833
|
return last;
|
|
2816
2834
|
}
|
|
@@ -2918,8 +2936,14 @@ export class SyncClient {
|
|
|
2918
2936
|
}
|
|
2919
2937
|
let openedSocket: RealtimeSocket | undefined;
|
|
2920
2938
|
const socket = await connector({
|
|
2921
|
-
onText: (text) =>
|
|
2922
|
-
|
|
2939
|
+
onText: (text) => {
|
|
2940
|
+
if (generation !== this.#realtimeGeneration || !this.#started) return;
|
|
2941
|
+
this.#handleRealtimeText(text);
|
|
2942
|
+
},
|
|
2943
|
+
onBinary: (bytes) => {
|
|
2944
|
+
if (generation !== this.#realtimeGeneration || !this.#started) return;
|
|
2945
|
+
this.#routeRealtimeBinary(bytes);
|
|
2946
|
+
},
|
|
2923
2947
|
onClose: () => {
|
|
2924
2948
|
if (openedSocket === undefined || this.#socket !== openedSocket) return;
|
|
2925
2949
|
this.#socket = undefined;
|
|
@@ -3210,13 +3234,14 @@ export class SyncClient {
|
|
|
3210
3234
|
let section: OpenSection | undefined;
|
|
3211
3235
|
let errorFrame: ClientSyncError | undefined;
|
|
3212
3236
|
let deltaCursor = -1;
|
|
3213
|
-
let responseOutboxCount: number | undefined;
|
|
3214
3237
|
|
|
3215
3238
|
// Each durable observer transaction emits its own revisioned batch.
|
|
3216
3239
|
// Async decrypt/download work happens outside SQLite transactions.
|
|
3217
3240
|
this.#beginDiagnosticsDeferral();
|
|
3218
3241
|
try {
|
|
3219
|
-
for (
|
|
3242
|
+
for (let index = 1; index < message.frames.length; index += 1) {
|
|
3243
|
+
const frame = message.frames[index];
|
|
3244
|
+
if (frame === undefined) break;
|
|
3220
3245
|
switch (frame.type) {
|
|
3221
3246
|
case 'RESP_HEADER':
|
|
3222
3247
|
break;
|
|
@@ -3229,23 +3254,61 @@ export class SyncClient {
|
|
|
3229
3254
|
});
|
|
3230
3255
|
break;
|
|
3231
3256
|
case 'PUSH_RESULT': {
|
|
3232
|
-
|
|
3233
|
-
|
|
3257
|
+
const results: PushResultFrame[] = [frame];
|
|
3258
|
+
if (frame.status !== 'rejected') {
|
|
3259
|
+
while (index + 1 < message.frames.length) {
|
|
3260
|
+
const next = message.frames[index + 1];
|
|
3261
|
+
if (next?.type !== 'PUSH_RESULT' || next.status === 'rejected')
|
|
3262
|
+
break;
|
|
3263
|
+
results.push(next);
|
|
3264
|
+
index += 1;
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
const conflictCount = this.#conflicts.length;
|
|
3268
|
+
const rejectionCount = this.#rejections.length;
|
|
3269
|
+
let outboxCount = countOutbox(this.#db);
|
|
3234
3270
|
this.#applyBatch(
|
|
3235
3271
|
(batch) => {
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3272
|
+
let drained = false;
|
|
3273
|
+
for (const result of results) {
|
|
3274
|
+
if (
|
|
3275
|
+
this.#handlePushResult(
|
|
3276
|
+
result,
|
|
3277
|
+
commitsById,
|
|
3278
|
+
summary,
|
|
3279
|
+
batch,
|
|
3280
|
+
rejectionDetailsByCommit.get(result.clientCommitId),
|
|
3281
|
+
)
|
|
3282
|
+
) {
|
|
3283
|
+
outboxCount -= 1;
|
|
3284
|
+
drained = true;
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
if (
|
|
3288
|
+
drained &&
|
|
3289
|
+
results.some((result) => result === lastFinalPushResult)
|
|
3290
|
+
) {
|
|
3291
|
+
pruneCommitOutcomes(
|
|
3292
|
+
this.#db,
|
|
3293
|
+
this.#outcomeRetentionMaxEntries,
|
|
3294
|
+
);
|
|
3295
|
+
}
|
|
3245
3296
|
},
|
|
3246
3297
|
() => this.#statusSnapshot(outboxCount),
|
|
3298
|
+
(cause) => {
|
|
3299
|
+
this.#conflicts.length = conflictCount;
|
|
3300
|
+
this.#rejections.length = rejectionCount;
|
|
3301
|
+
const error = new ClientSyncError(
|
|
3302
|
+
'client.outcome_persistence_failed',
|
|
3303
|
+
'local commit outcome could not be persisted',
|
|
3304
|
+
);
|
|
3305
|
+
error.cause = cause;
|
|
3306
|
+
throw error;
|
|
3307
|
+
},
|
|
3247
3308
|
);
|
|
3248
|
-
|
|
3309
|
+
for (const conflict of this.#conflicts.slice(conflictCount)) {
|
|
3310
|
+
this.#config.onConflict?.(conflict);
|
|
3311
|
+
}
|
|
3249
3312
|
break;
|
|
3250
3313
|
}
|
|
3251
3314
|
case 'PUSH_RESULT_DETAILS':
|
|
@@ -3494,7 +3557,6 @@ export class SyncClient {
|
|
|
3494
3557
|
summary: MutableSummary,
|
|
3495
3558
|
batch: ChangeAccumulator,
|
|
3496
3559
|
rejectionDetails: ReadonlyMap<number, RejectionDetails> | undefined,
|
|
3497
|
-
pruneOutcomes: boolean,
|
|
3498
3560
|
): boolean {
|
|
3499
3561
|
const commit = commitsById.get(frame.clientCommitId);
|
|
3500
3562
|
if (commit === undefined) return false;
|
|
@@ -3517,9 +3579,6 @@ export class SyncClient {
|
|
|
3517
3579
|
})),
|
|
3518
3580
|
});
|
|
3519
3581
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
3520
|
-
if (pruneOutcomes) {
|
|
3521
|
-
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3522
|
-
}
|
|
3523
3582
|
batch.status();
|
|
3524
3583
|
batch.outcomes();
|
|
3525
3584
|
summary.applied.push(frame.clientCommitId);
|
|
@@ -3557,7 +3616,6 @@ export class SyncClient {
|
|
|
3557
3616
|
outcomeResults.push({ status: 'conflict', conflict });
|
|
3558
3617
|
batch.conflicts();
|
|
3559
3618
|
summary.conflicts.push(conflict);
|
|
3560
|
-
this.#config.onConflict?.(conflict);
|
|
3561
3619
|
} else if (result.status === 'error') {
|
|
3562
3620
|
const details = rejectionDetails?.get(result.opIndex);
|
|
3563
3621
|
const rejection: RejectionRecord = {
|
|
@@ -3585,9 +3643,6 @@ export class SyncClient {
|
|
|
3585
3643
|
results: outcomeResults,
|
|
3586
3644
|
operations: commit.operations,
|
|
3587
3645
|
});
|
|
3588
|
-
if (pruneOutcomes) {
|
|
3589
|
-
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3590
|
-
}
|
|
3591
3646
|
batch.outcomes();
|
|
3592
3647
|
// §7.2: remove the rejected optimistic layer. Before-images restore
|
|
3593
3648
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
package/src/node-database.ts
CHANGED
|
@@ -40,6 +40,13 @@ export class NodeClientDatabase implements ClientDatabase {
|
|
|
40
40
|
|
|
41
41
|
constructor(path = ':memory:') {
|
|
42
42
|
this.db = new DatabaseSync(path);
|
|
43
|
+
try {
|
|
44
|
+
this.db.exec('PRAGMA journal_mode = WAL');
|
|
45
|
+
this.db.exec('PRAGMA synchronous = FULL');
|
|
46
|
+
} catch (error) {
|
|
47
|
+
this.db.close();
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
exec(sql: string, params: readonly SqlValue[] = []): void {
|
package/src/outbox.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type PushOperation,
|
|
12
12
|
type ScopeMap,
|
|
13
13
|
} from '@syncular/core';
|
|
14
|
-
import type { ClientDatabase } from './database';
|
|
14
|
+
import type { ClientDatabase, SqlRow } from './database';
|
|
15
15
|
import type { EncryptionConfig } from './encryption';
|
|
16
16
|
import { ClientSyncError } from './errors';
|
|
17
17
|
import {
|
|
@@ -85,19 +85,50 @@ export function appendOutboxCommit(
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** Pending commits in FIFO creation order (§7.1). */
|
|
88
|
+
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
89
89
|
export function listOutbox(db: ClientDatabase): OutboxCommit[] {
|
|
90
90
|
return db
|
|
91
91
|
.query(
|
|
92
92
|
`SELECT seq, client_commit_id, created_at_ms, operations
|
|
93
93
|
FROM _syncular_outbox ORDER BY seq ASC`,
|
|
94
94
|
)
|
|
95
|
-
.map(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
.map(decodeOutboxRow);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function decodeOutboxRow(row: SqlRow): OutboxCommit {
|
|
99
|
+
return {
|
|
100
|
+
seq: row.seq as number,
|
|
101
|
+
clientCommitId: row.client_commit_id as string,
|
|
102
|
+
createdAtMs: row.created_at_ms as number,
|
|
103
|
+
operations: JSON.parse(row.operations as string) as OutboxOperation[],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
|
|
108
|
+
export function* iterateOutbox(
|
|
109
|
+
db: ClientDatabase,
|
|
110
|
+
throughSeq: number,
|
|
111
|
+
): Generator<OutboxCommit> {
|
|
112
|
+
let afterSeq = 0;
|
|
113
|
+
while (afterSeq < throughSeq) {
|
|
114
|
+
const rows = db.query(
|
|
115
|
+
`SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox
|
|
116
|
+
WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`,
|
|
117
|
+
[afterSeq, throughSeq],
|
|
118
|
+
);
|
|
119
|
+
if (rows.length === 0) return;
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
const commit = decodeOutboxRow(row);
|
|
122
|
+
afterSeq = commit.seq;
|
|
123
|
+
yield commit;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Routine status reads never load operation bodies. */
|
|
129
|
+
export function countOutbox(db: ClientDatabase): number {
|
|
130
|
+
return db.query('SELECT COUNT(*) AS count FROM _syncular_outbox')[0]!
|
|
131
|
+
.count as number;
|
|
101
132
|
}
|
|
102
133
|
|
|
103
134
|
export function deleteOutboxCommit(
|