@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 CHANGED
@@ -580,3 +580,45 @@ Tests drive the real worker entry in a bun `Worker` with bun:sqlite
580
580
  injected through the bootstrap's database-factory override
581
581
  (`test/worker-rpc.test.ts`); the OPFS path itself is browser-only and is
582
582
  exercised by `apps/demo`.
583
+
584
+ ## Snapshot API migration
585
+
586
+ This source-breaking revision uses methods for application reads across the
587
+ direct client, worker leaders and followers, Tauri, and React Native. Replace
588
+ `client.conflicts`, `client.rejections`, and `client.securityLifecycle` on the
589
+ direct client with method calls. Replace `schemaFloor`, `leaseState`,
590
+ `upgrading`, and `syncNeeded` getters or bridge methods with fields from one
591
+ `statusSnapshot()` call:
592
+
593
+ ```ts
594
+ const status = await client.statusSnapshot();
595
+ if (status.schemaFloor) showUpgradeRequired(status.schemaFloor);
596
+ const conflicts = await client.conflicts();
597
+ const outcome = await client.commitOutcome(commitId);
598
+ ```
599
+
600
+ The direct client returns snapshots synchronously. Worker and native bridges
601
+ return promises; `await` works with both. `querySnapshot` returns rows, coverage,
602
+ and revision from one read. `diagnosticsSnapshot`, `commitOutcome`,
603
+ `commitOutcomes`, and `resolveCommitOutcome` retain their existing arguments.
604
+ The shared `ClientSnapshotMethods` and `PromiseMethods` types describe these
605
+ contracts. Key-bearing security activation stays on each concrete host type.
606
+
607
+ React uses the supplied client directly; `useSyncClient()` preserves its
608
+ identity. Remove imports of `normalizeClient` and the
609
+ `@syncular/client/realtime-supervisor-observation` forwarding utility. Pass the
610
+ client to `SyncProvider` and use `realtimeSupervisorSnapshot(client)` to inspect
611
+ an attached supervisor. Custom React clients must implement the snapshot
612
+ methods and method-form collection reads. See the [React migration](https://syncular.dev/platform-react/)
613
+ for the `onEnqueued` callback rename.
614
+
615
+ ## Outbox read costs
616
+
617
+ Request encoding pins the pending count and highest local sequence before its
618
+ first asynchronous step. It reads keyset pages of 32 raw records, decodes only
619
+ the consumed prefix, and stops at the first whole commit that exceeds the
620
+ remaining operation budget. Mutations appended during encoding enter the next
621
+ request. Status and diagnostics use `COUNT(*)` without parsing pending bodies.
622
+ Optimistic replay still reads the remaining outbox after each response. The
623
+ 100/1,000/10,000-commit workload and measured limits are recorded in
624
+ [the reliability RFC](../../docs/RFC-RELIABILITY-DX.md#9-implementation-evidence-2026-09-05).
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
- get securityLifecycle(): SecurityLifecycle;
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
- get conflicts(): readonly ConflictRecord[];
324
- get rejections(): readonly RejectionRecord[];
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
- (listOutbox(this.#db).length > 0 ||
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
- get securityLifecycle() {
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
- (listOutbox(this.#db).length > 0 ||
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: listOutbox(this.#db).length,
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 ?? listOutbox(this.#db).length,
760
+ outbox: outboxCount ?? countOutbox(this.#db),
761
761
  upgrading: this.#upgrading,
762
762
  leaseState: this.#leaseState,
763
763
  schemaFloor: this.#schemaFloor,
@@ -994,11 +994,11 @@ export class SyncClient {
994
994
  }
995
995
  await transport.upload(blobId, bytes, mediaType);
996
996
  }
997
- get conflicts() {
997
+ conflicts() {
998
998
  this.#requireActive();
999
999
  return this.#conflicts;
1000
1000
  }
1001
- get rejections() {
1001
+ rejections() {
1002
1002
  this.#requireActive();
1003
1003
  return this.#rejections;
1004
1004
  }
@@ -1062,27 +1062,6 @@ export class SyncClient {
1062
1062
  return resolved;
1063
1063
  });
1064
1064
  }
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
1065
  /** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
1087
1066
  * `undefined` if no lease is held. Negative once expired. */
1088
1067
  leaseRemainingMs(now = this.#now()) {
@@ -1093,10 +1072,6 @@ export class SyncClient {
1093
1072
  get stopped() {
1094
1073
  return this.#schemaFloor !== undefined;
1095
1074
  }
1096
- /** §8: a hello/wake-up asked for a pull that has not run yet. */
1097
- get syncNeeded() {
1098
- return this.#needsPull;
1099
- }
1100
1075
  /**
1101
1076
  * §8.6 presence on a scope key: the current peers present there (a map
1102
1077
  * of `actorId clientId` → peer). Empty for a key with no present peers.
@@ -1731,21 +1706,27 @@ export class SyncClient {
1731
1706
  * the encoded push frames index-aligned with the surviving `outbox`.
1732
1707
  */
1733
1708
  async #encodeOutboxForPush() {
1734
- const pending = listOutbox(this.#db);
1709
+ // Pin before the first encryption await: mutations can append while a
1710
+ // round is encoding, and belong to the next request.
1711
+ const bounds = this.#db.query('SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox')[0];
1712
+ const pendingCount = bounds.count;
1713
+ const throughSeq = bounds.last_seq ?? 0;
1735
1714
  const pushFrames = [];
1736
1715
  const outbox = [];
1737
1716
  let deferred = 0;
1738
1717
  let ops = 0;
1739
- for (const commit of pending) {
1718
+ let processed = 0;
1719
+ for (const commit of iterateOutbox(this.#db, throughSeq)) {
1740
1720
  // §6.1 splitBatch: whole commits in commit order, stopping before the
1741
1721
  // per-request operation cap. A first commit that alone exceeds the cap
1742
1722
  // is sent alone — the server rejects it loudly rather than the queue
1743
1723
  // wedging silently. Deferred commits stay queued for the next round.
1744
1724
  if (outbox.length > 0 &&
1745
1725
  ops + commit.operations.length > MAX_OPS_PER_REQUEST) {
1746
- deferred += 1;
1747
- continue;
1726
+ deferred = pendingCount - processed;
1727
+ break;
1748
1728
  }
1729
+ processed += 1;
1749
1730
  try {
1750
1731
  pushFrames.push(
1751
1732
  // §5.11: encrypted columns are encrypted at this encode-at-send
@@ -2003,7 +1984,8 @@ export class SyncClient {
2003
1984
  last.segmentRowsApplied === 0 &&
2004
1985
  last.bootstrapping.length === 0 &&
2005
1986
  last.resets.length === 0 &&
2006
- (last.deferredCommits ?? 0) === 0) {
1987
+ (last.deferredCommits ?? 0) === 0 &&
1988
+ !this.#needsPull) {
2007
1989
  return last;
2008
1990
  }
2009
1991
  }
@@ -2347,7 +2329,7 @@ export class SyncClient {
2347
2329
  });
2348
2330
  break;
2349
2331
  case 'PUSH_RESULT': {
2350
- let outboxCount = responseOutboxCount ?? listOutbox(this.#db).length;
2332
+ let outboxCount = responseOutboxCount ?? countOutbox(this.#db);
2351
2333
  this.#applyBatch((batch) => {
2352
2334
  const drained = this.#handlePushResult(frame, commitsById, summary, batch, rejectionDetailsByCommit.get(frame.clientCommitId), frame === lastFinalPushResult);
2353
2335
  if (drained)
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((row) => ({
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]);
@@ -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
- readonly conflicts: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
38
- readonly rejections: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
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
  }