@syncular/client 0.15.48 → 0.16.1
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 +42 -0
- package/dist/client.d.ts +13 -20
- package/dist/client.js +25 -43
- 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/client.ts +59 -47
- 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.16.1",
|
|
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.16.1"
|
|
101
93
|
},
|
|
102
94
|
"devDependencies": {
|
|
103
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.16.1"
|
|
104
96
|
}
|
|
105
97
|
}
|
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,
|
|
@@ -1606,12 +1639,12 @@ export class SyncClient {
|
|
|
1606
1639
|
await transport.upload(blobId, bytes, mediaType);
|
|
1607
1640
|
}
|
|
1608
1641
|
|
|
1609
|
-
|
|
1642
|
+
conflicts(): readonly ConflictRecord[] {
|
|
1610
1643
|
this.#requireActive();
|
|
1611
1644
|
return this.#conflicts;
|
|
1612
1645
|
}
|
|
1613
1646
|
|
|
1614
|
-
|
|
1647
|
+
rejections(): readonly RejectionRecord[] {
|
|
1615
1648
|
this.#requireActive();
|
|
1616
1649
|
return this.#rejections;
|
|
1617
1650
|
}
|
|
@@ -1702,30 +1735,6 @@ export class SyncClient {
|
|
|
1702
1735
|
});
|
|
1703
1736
|
}
|
|
1704
1737
|
|
|
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
1738
|
/** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
|
|
1730
1739
|
* `undefined` if no lease is held. Negative once expired. */
|
|
1731
1740
|
leaseRemainingMs(now: number = this.#now()): number | undefined {
|
|
@@ -1738,11 +1747,6 @@ export class SyncClient {
|
|
|
1738
1747
|
return this.#schemaFloor !== undefined;
|
|
1739
1748
|
}
|
|
1740
1749
|
|
|
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
1750
|
/**
|
|
1747
1751
|
* §8.6 presence on a scope key: the current peers present there (a map
|
|
1748
1752
|
* of `actorId clientId` → peer). Empty for a key with no present peers.
|
|
@@ -2502,12 +2506,19 @@ export class SyncClient {
|
|
|
2502
2506
|
outbox: OutboxCommit[];
|
|
2503
2507
|
deferred: number;
|
|
2504
2508
|
}> {
|
|
2505
|
-
|
|
2509
|
+
// Pin before the first encryption await: mutations can append while a
|
|
2510
|
+
// round is encoding, and belong to the next request.
|
|
2511
|
+
const bounds = this.#db.query(
|
|
2512
|
+
'SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox',
|
|
2513
|
+
)[0]!;
|
|
2514
|
+
const pendingCount = bounds.count as number;
|
|
2515
|
+
const throughSeq = (bounds.last_seq as number | null) ?? 0;
|
|
2506
2516
|
const pushFrames: RequestFrame[] = [];
|
|
2507
2517
|
const outbox: OutboxCommit[] = [];
|
|
2508
2518
|
let deferred = 0;
|
|
2509
2519
|
let ops = 0;
|
|
2510
|
-
|
|
2520
|
+
let processed = 0;
|
|
2521
|
+
for (const commit of iterateOutbox(this.#db, throughSeq)) {
|
|
2511
2522
|
// §6.1 splitBatch: whole commits in commit order, stopping before the
|
|
2512
2523
|
// per-request operation cap. A first commit that alone exceeds the cap
|
|
2513
2524
|
// is sent alone — the server rejects it loudly rather than the queue
|
|
@@ -2516,9 +2527,10 @@ export class SyncClient {
|
|
|
2516
2527
|
outbox.length > 0 &&
|
|
2517
2528
|
ops + commit.operations.length > MAX_OPS_PER_REQUEST
|
|
2518
2529
|
) {
|
|
2519
|
-
deferred
|
|
2520
|
-
|
|
2530
|
+
deferred = pendingCount - processed;
|
|
2531
|
+
break;
|
|
2521
2532
|
}
|
|
2533
|
+
processed += 1;
|
|
2522
2534
|
try {
|
|
2523
2535
|
pushFrames.push(
|
|
2524
2536
|
// §5.11: encrypted columns are encrypted at this encode-at-send
|
|
@@ -2810,7 +2822,8 @@ export class SyncClient {
|
|
|
2810
2822
|
last.segmentRowsApplied === 0 &&
|
|
2811
2823
|
last.bootstrapping.length === 0 &&
|
|
2812
2824
|
last.resets.length === 0 &&
|
|
2813
|
-
(last.deferredCommits ?? 0) === 0
|
|
2825
|
+
(last.deferredCommits ?? 0) === 0 &&
|
|
2826
|
+
!this.#needsPull
|
|
2814
2827
|
) {
|
|
2815
2828
|
return last;
|
|
2816
2829
|
}
|
|
@@ -3229,8 +3242,7 @@ export class SyncClient {
|
|
|
3229
3242
|
});
|
|
3230
3243
|
break;
|
|
3231
3244
|
case 'PUSH_RESULT': {
|
|
3232
|
-
let outboxCount =
|
|
3233
|
-
responseOutboxCount ?? listOutbox(this.#db).length;
|
|
3245
|
+
let outboxCount = responseOutboxCount ?? countOutbox(this.#db);
|
|
3234
3246
|
this.#applyBatch(
|
|
3235
3247
|
(batch) => {
|
|
3236
3248
|
const drained = this.#handlePushResult(
|
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(
|