@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.
@@ -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, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
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 { ClientDiagnosticsRequest, ClientDiagnosticsSnapshot } from './diagnostics.js';
23
+ import type { ClientDiagnosticsSnapshot } from './diagnostics.js';
24
24
  import type { EncryptionKeyringConfig } from './encryption.js';
25
- import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
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.15.48",
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.15.48"
92
+ "@syncular/core": "0.16.1"
101
93
  },
102
94
  "devDependencies": {
103
- "@syncular/server": "0.15.48"
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
- (listOutbox(this.#db).length > 0 ||
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
- get securityLifecycle(): SecurityLifecycle {
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
- (listOutbox(this.#db).length > 0 ||
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: listOutbox(this.#db).length,
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 ?? listOutbox(this.#db).length,
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
- get conflicts(): readonly ConflictRecord[] {
1642
+ conflicts(): readonly ConflictRecord[] {
1610
1643
  this.#requireActive();
1611
1644
  return this.#conflicts;
1612
1645
  }
1613
1646
 
1614
- get rejections(): readonly RejectionRecord[] {
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
- const pending = listOutbox(this.#db);
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
- for (const commit of pending) {
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 += 1;
2520
- continue;
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((row) => ({
96
- seq: row.seq as number,
97
- clientCommitId: row.client_commit_id as string,
98
- createdAtMs: row.created_at_ms as number,
99
- operations: JSON.parse(row.operations as string) as OutboxOperation[],
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(