@syncular/client 0.15.13 → 0.15.15

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
@@ -73,7 +73,9 @@ const handle = await createSyncClientHandle({
73
73
  schema, database: { mode: 'persistent', name: 'app' }, endpoints,
74
74
  onRoleChange: (role) => console.log('now', role), // 'follower' → 'leader'
75
75
  });
76
- // handle.role is 'leader' or 'follower'; the API is identical either way.
76
+ // Compatibility: handle.role is 'leader' or 'follower'.
77
+ // Detailed state: handle.leadership / handle.leadershipSnapshot().
78
+ handle.onLeadershipChange((state) => renderConnectionState(state));
77
79
  ```
78
80
 
79
81
  **Topology.** The tab that wins the Web Locks election is the **leader**:
@@ -105,6 +107,15 @@ flushed to the new leader on its announce; past the deadline they fail
105
107
  loudly with `client.follower_timeout` (never a silent hang), and an
106
108
  overflowing queue rejects rather than growing unbounded.
107
109
 
110
+ Leader announcements continue as heartbeats while followers are attached. If
111
+ a tab can acquire neither a response nor a new lock grant before the configured
112
+ `followerCallTimeoutMs`, `handle.leadership` becomes
113
+ `{ state: 'blocked', reason: 'leader-unreachable', code:
114
+ 'client.follower_timeout', retryable: true }`. Calls then reject immediately.
115
+ A later announcement rebinds the same handle; a granted Web Lock promotes it.
116
+ An unreachable `BroadcastChannel` is never treated as evidence that the lock
117
+ owner is stale, so it never authorizes a second worker or database owner.
118
+
108
119
  **Presence semantics — one device, one peer.** All tabs share the leader's
109
120
  single connection, so a device is exactly ONE presence peer collectively:
110
121
  identity is `(actorId, leaderClientId)`. A follower's `setPresence`
@@ -112,8 +123,61 @@ forwards to the leader's single publisher; there is no per-tab presence
112
123
  peer. This is the honest model — the wire only ever sees one connection per
113
124
  device.
114
125
 
126
+ The default is a **shared replica**: same-origin tabs must use the same
127
+ persistent database name, lock name, and derived channel name. For an embed,
128
+ preview, or history entry that is intentionally independent, derive the whole
129
+ ownership tuple from one stable identity:
130
+
131
+ ```ts
132
+ const preview = await createSyncClientHandle({
133
+ worker,
134
+ schema,
135
+ database: { mode: 'persistent', name: 'medical' },
136
+ endpoints,
137
+ replica: { mode: 'isolated', id: 'preview-42' },
138
+ });
139
+ ```
140
+
141
+ This derives a distinct database name and pool directory, Web Lock name, and
142
+ `BroadcastChannel` name together. `isolatedReplicaNames()` exposes the same
143
+ deterministic tuple for diagnostics and host integration. Replica IDs are
144
+ stable code-like values (`A-Z`, `a-z`, `0-9`, dot, underscore, dash).
145
+
146
+ During Vite development, retain the React client resource only while its
147
+ captured generated schema version matches. The
148
+ [schema-aware Vite guide](https://syncular.dev/guide-vite/) uses
149
+ `retainViteSyncClientResource` to close the old worker before constructing a
150
+ schema-bump replacement; hot-reloading query code alone does not migrate the
151
+ worker-owned database.
152
+
115
153
  Set `multiTab: false` to opt out. A losing tab then becomes an
116
- `isLeader === false` handle whose calls reject with `client.not_leader`.
154
+ `isLeader === false` handle whose calls reject with `client.not_leader`. This
155
+ does not solve a coordination-partition mismatch by itself: an independent
156
+ instance must also use an isolated database and lock identity. Changing only
157
+ the channel, lock, or database name is unsafe or ineffective.
158
+
159
+ ## React availability guard
160
+
161
+ The worker handle's schema and leadership snapshots feed the same public React
162
+ boundary as native clients. Guard the application once instead of parsing
163
+ errors or inspecting generated schema modules:
164
+
165
+ ```tsx
166
+ <SyncProvider
167
+ client={clientResource}
168
+ renderBoundary={(state, actions) => (
169
+ <SyncBlockedScreen state={state} onRetry={actions.retry} />
170
+ )}
171
+ >
172
+ <App />
173
+ </SyncProvider>
174
+ ```
175
+
176
+ The state is a discriminated union covering startup, migration,
177
+ `client-upgrade-required`, `server-behind`, `incompatible-schema`, and
178
+ `leader-unreachable`. Recovery changes the same handle/provider back to its
179
+ children; a blocked live query has `phase === 'blocked'`, never an indefinite
180
+ loading state.
117
181
 
118
182
  ## Durable commit outcomes
119
183
 
@@ -168,6 +232,28 @@ subscription that could download the protected rows again. This method does
168
232
  not authenticate a directive, revoke server authority, delete app-owned files,
169
233
  or remove a key from the OS secure store.
170
234
 
235
+ For a race-free bootstrap, construct the client/worker handle with
236
+ `securityPreflight: true`. Before activation, protected reads, writes,
237
+ subscriptions, sync/realtime, blobs, and the automatic host loop fail with
238
+ `client.security_preflight_required`; status, local revision, lifecycle, and
239
+ the exact local purge remain available.
240
+
241
+ ```ts
242
+ const client = await createSyncClientHandle({
243
+ ...config,
244
+ securityPreflight: true,
245
+ });
246
+
247
+ await client.purgeLocalData(directive.plan);
248
+ await client.activateSecurity({ encryption: acceptedKeyring });
249
+ ```
250
+
251
+ Use `beginSecurityPreflight()` before a live key rotation/revocation. It gates
252
+ new calls immediately, disconnects realtime, waits for in-flight core/blob work,
253
+ and releases the old keyring before resolving. In multi-tab mode the gate
254
+ belongs to the single shared leader replica. Direct clients expose the same
255
+ lifecycle with an `EncryptionConfig`; Worker handles use the portable keyring.
256
+
171
257
  Within one local SQLite transaction the engine deletes exactly the matching
172
258
  synced rows, lets generated FTS triggers remove their projections, drops every
173
259
  whole pending commit with a matching operation, restores/replays unrelated
@@ -0,0 +1,17 @@
1
+ import type { SyncStatusSnapshot } from './invalidation.js';
2
+ import type { LeadershipState } from './multi-tab.js';
3
+ export type SyncAvailability = {
4
+ readonly state: 'ready';
5
+ } | {
6
+ readonly state: 'migrating';
7
+ readonly currentSchemaVersion: number;
8
+ } | {
9
+ readonly state: 'blocked';
10
+ readonly reason: 'client-upgrade-required' | 'server-behind' | 'incompatible-schema' | 'leader-unreachable';
11
+ readonly currentSchemaVersion: number;
12
+ readonly requiredSchemaVersion?: number;
13
+ readonly latestServerSchemaVersion?: number;
14
+ readonly retryable: boolean;
15
+ };
16
+ /** Classify schema and browser-ownership state without parsing diagnostics. */
17
+ export declare function classifySyncAvailability(status: SyncStatusSnapshot, leadership?: LeadershipState): SyncAvailability;
@@ -0,0 +1,48 @@
1
+ /** Classify schema and browser-ownership state without parsing diagnostics. */
2
+ export function classifySyncAvailability(status, leadership) {
3
+ const currentSchemaVersion = status.currentSchemaVersion;
4
+ if (leadership?.state === 'blocked') {
5
+ return {
6
+ state: 'blocked',
7
+ reason: 'leader-unreachable',
8
+ currentSchemaVersion,
9
+ retryable: true,
10
+ };
11
+ }
12
+ const required = status.schemaFloor?.requiredSchemaVersion;
13
+ const latest = status.schemaFloor?.latestSchemaVersion;
14
+ if (required !== undefined && required > currentSchemaVersion) {
15
+ return {
16
+ state: 'blocked',
17
+ reason: 'client-upgrade-required',
18
+ currentSchemaVersion,
19
+ requiredSchemaVersion: required,
20
+ ...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
21
+ retryable: false,
22
+ };
23
+ }
24
+ if (latest !== undefined && latest < currentSchemaVersion) {
25
+ return {
26
+ state: 'blocked',
27
+ reason: 'server-behind',
28
+ currentSchemaVersion,
29
+ ...(required !== undefined ? { requiredSchemaVersion: required } : {}),
30
+ latestServerSchemaVersion: latest,
31
+ retryable: false,
32
+ };
33
+ }
34
+ if (status.schemaFloor !== undefined) {
35
+ return {
36
+ state: 'blocked',
37
+ reason: 'incompatible-schema',
38
+ currentSchemaVersion,
39
+ ...(required !== undefined ? { requiredSchemaVersion: required } : {}),
40
+ ...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
41
+ retryable: false,
42
+ };
43
+ }
44
+ if (status.upgrading) {
45
+ return { state: 'migrating', currentSchemaVersion };
46
+ }
47
+ return { state: 'ready' };
48
+ }
package/dist/client.d.ts CHANGED
@@ -147,6 +147,25 @@ export interface SyncClientConfig {
147
147
  * `client.decrypt_failed`, never silent plaintext).
148
148
  */
149
149
  readonly encryption?: EncryptionConfig;
150
+ /**
151
+ * Open the local replica in the fail-closed security preflight state.
152
+ *
153
+ * Preflight opens/migrates the database but suppresses every protected read,
154
+ * mutation, subscription, transport, realtime, presence, and blob operation.
155
+ * Only lifecycle/status inspection and `purgeLocalData` remain available.
156
+ * Install the post-authentication keyring and release the gate with
157
+ * `activateSecurity`. This is mutually exclusive with `encryption`: secure
158
+ * hosts must not materialize key bytes before their preflight has passed.
159
+ */
160
+ readonly securityPreflight?: boolean;
161
+ }
162
+ /** The fail-closed local-replica security lifecycle shared by every host. */
163
+ export type SecurityLifecycle = 'preflight' | 'active';
164
+ /** Stable client-local error while protected operations are preflight-gated. */
165
+ export declare const SECURITY_PREFLIGHT_REQUIRED_CODE = "client.security_preflight_required";
166
+ /** Key material installed atomically when a direct client becomes active. */
167
+ export interface SecurityActivation {
168
+ readonly encryption?: EncryptionConfig;
150
169
  }
151
170
  /** §8.6 a peer's ephemeral presence document on a scope key. */
152
171
  export interface PresencePeer {
@@ -216,6 +235,20 @@ export declare class SyncClient {
216
235
  /** Acquire leadership, create local tables, resolve the clientId. */
217
236
  start(): Promise<void>;
218
237
  close(): Promise<void>;
238
+ /** Current fail-closed local-replica security state. */
239
+ get securityLifecycle(): SecurityLifecycle;
240
+ /**
241
+ * Block new protected operations immediately, then wait for every already
242
+ * serialized database/network operation to settle before releasing key
243
+ * references. Hosts await this barrier before applying a quarantine purge.
244
+ */
245
+ beginSecurityPreflight(): Promise<void>;
246
+ /**
247
+ * Atomically install the post-authentication keyring and release the gate.
248
+ * Persisted subscriptions/outbox work produces one exact startup intent only
249
+ * after activation, never while the local quarantine decision is pending.
250
+ */
251
+ activateSecurity(options?: SecurityActivation): Promise<void>;
219
252
  get clientId(): string;
220
253
  /** The underlying database — raw SQL is the local query API (B3). */
221
254
  get database(): ClientDatabase;
package/dist/client.js CHANGED
@@ -21,6 +21,8 @@ import { assertReadOnlyQuery } from './query-guard.js';
21
21
  import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
22
22
  import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
23
23
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
24
+ /** Stable client-local error while protected operations are preflight-gated. */
25
+ export const SECURITY_PREFLIGHT_REQUIRED_CODE = 'client.security_preflight_required';
24
26
  /**
25
27
  * True iff `unit` is windowed-in AND its bootstrap completed (§4.8 I3):
26
28
  * registered and not pending. A unit with zero server rows still becomes
@@ -72,6 +74,7 @@ export class SyncClient {
72
74
  #schema;
73
75
  /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
74
76
  #encryption;
77
+ #securityLifecycle;
75
78
  #now;
76
79
  #outcomeRetentionMaxEntries;
77
80
  #started = false;
@@ -124,11 +127,21 @@ export class SyncClient {
124
127
  * sections, when the seam is quiescent.
125
128
  */
126
129
  #opChain = Promise.resolve();
130
+ /** Async protected operations outside the SQLite serialization chain
131
+ * (blob I/O and realtime connect). Security preflight waits for this set to
132
+ * drain after synchronously closing the gate. */
133
+ #protectedAsync = new Set();
134
+ #preflightBarrier;
127
135
  constructor(config) {
136
+ if (config.securityPreflight === true && config.encryption !== undefined) {
137
+ throw new ClientSyncError('sync.invalid_request', 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
138
+ }
128
139
  this.#config = config;
129
140
  this.#db = config.database;
130
141
  this.#schema = compileClientSchema(config.schema);
131
142
  this.#encryption = config.encryption;
143
+ this.#securityLifecycle =
144
+ config.securityPreflight === true ? 'preflight' : 'active';
132
145
  this.#now = config.now ?? Date.now;
133
146
  const outcomeRetentionMaxEntries = config.limits?.outcomeRetentionMaxEntries ?? 1_000;
134
147
  if (!Number.isSafeInteger(outcomeRetentionMaxEntries) ||
@@ -192,7 +205,7 @@ export class SyncClient {
192
205
  const startupWork = this.#schemaFloor === undefined &&
193
206
  (listOutbox(this.#db).length > 0 ||
194
207
  subscriptions.some((sub) => sub.status === 'active'));
195
- if (startupWork) {
208
+ if (startupWork && this.#securityLifecycle === 'active') {
196
209
  this.#needsPull = true;
197
210
  this.#config.onSyncNeeded?.('startup');
198
211
  this.#config.onSyncIntent?.({ kind: 'interactive' });
@@ -294,12 +307,66 @@ export class SyncClient {
294
307
  this.#lease = undefined;
295
308
  this.#started = false;
296
309
  }
310
+ /** Current fail-closed local-replica security state. */
311
+ get securityLifecycle() {
312
+ return this.#securityLifecycle;
313
+ }
314
+ /**
315
+ * Block new protected operations immediately, then wait for every already
316
+ * serialized database/network operation to settle before releasing key
317
+ * references. Hosts await this barrier before applying a quarantine purge.
318
+ */
319
+ beginSecurityPreflight() {
320
+ this.#requireStarted();
321
+ if (this.#preflightBarrier !== undefined)
322
+ return this.#preflightBarrier;
323
+ this.#securityLifecycle = 'preflight';
324
+ this.disconnectRealtime();
325
+ const barrier = (async () => {
326
+ await Promise.allSettled([this.#opChain, ...this.#protectedAsync]);
327
+ this.disconnectRealtime();
328
+ this.#encryption = undefined;
329
+ this.#syncOutstanding = false;
330
+ })();
331
+ this.#preflightBarrier = barrier;
332
+ void barrier.then(() => {
333
+ if (this.#preflightBarrier === barrier)
334
+ this.#preflightBarrier = undefined;
335
+ }, () => {
336
+ if (this.#preflightBarrier === barrier)
337
+ this.#preflightBarrier = undefined;
338
+ });
339
+ return barrier;
340
+ }
341
+ /**
342
+ * Atomically install the post-authentication keyring and release the gate.
343
+ * Persisted subscriptions/outbox work produces one exact startup intent only
344
+ * after activation, never while the local quarantine decision is pending.
345
+ */
346
+ async activateSecurity(options = {}) {
347
+ this.#requireStarted();
348
+ if (this.#securityLifecycle === 'active') {
349
+ throw new ClientSyncError('sync.invalid_request', 'activateSecurity requires the client to be in security preflight');
350
+ }
351
+ await (this.#preflightBarrier ?? this.#opChain);
352
+ this.#encryption = options.encryption;
353
+ this.#securityLifecycle = 'active';
354
+ const startupWork = this.#schemaFloor === undefined &&
355
+ (listOutbox(this.#db).length > 0 ||
356
+ loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
357
+ if (startupWork) {
358
+ this.#setSyncNeeded(true);
359
+ this.#config.onSyncNeeded?.('startup');
360
+ this.#config.onSyncIntent?.({ kind: 'interactive' });
361
+ }
362
+ }
297
363
  // -- accessors ------------------------------------------------------------
298
364
  get clientId() {
299
365
  return this.#clientId;
300
366
  }
301
367
  /** The underlying database — raw SQL is the local query API (B3). */
302
368
  get database() {
369
+ this.#requireActive();
303
370
  return this.#db;
304
371
  }
305
372
  /**
@@ -311,6 +378,7 @@ export class SyncClient {
311
378
  * internals read `this.#db` directly and skip this method by design.
312
379
  */
313
380
  query(sql, params) {
381
+ this.#requireActive();
314
382
  assertReadOnlyQuery(sql);
315
383
  return stripSyncColumns(this.#db.query(sql, params));
316
384
  }
@@ -325,7 +393,7 @@ export class SyncClient {
325
393
  * `windowState()` across separate worker/IPC calls.
326
394
  */
327
395
  querySnapshot(spec) {
328
- this.#requireStarted();
396
+ this.#requireActive();
329
397
  assertReadOnlyQuery(spec.sql);
330
398
  return this.#db.transaction(() => {
331
399
  const revision = getLocalRevision(this.#db);
@@ -391,6 +459,7 @@ export class SyncClient {
391
459
  }
392
460
  #statusSnapshot() {
393
461
  return {
462
+ currentSchemaVersion: this.#config.schema.version,
394
463
  outbox: listOutbox(this.#db).length,
395
464
  upgrading: this.#upgrading,
396
465
  leaseState: this.#leaseState,
@@ -455,6 +524,13 @@ export class SyncClient {
455
524
  this.#opChain = next.then(() => undefined, () => undefined);
456
525
  return next;
457
526
  }
527
+ #runProtectedAsync(fn) {
528
+ this.#requireActive();
529
+ const task = Promise.resolve().then(fn);
530
+ this.#protectedAsync.add(task);
531
+ void task.then(() => this.#protectedAsync.delete(task), () => this.#protectedAsync.delete(task));
532
+ return task;
533
+ }
458
534
  // -- blobs (§5.9) ---------------------------------------------------------
459
535
  /**
460
536
  * Stage a blob for attachment (§5.9.7): hash the bytes into the content
@@ -463,7 +539,10 @@ export class SyncClient {
463
539
  * a `blob_ref` column of a mutation. The referencing row MUST be written
464
540
  * (via `mutate`) after this call so upload-before-push holds (§5.9.3).
465
541
  */
466
- async uploadBlob(bytes, options) {
542
+ uploadBlob(bytes, options) {
543
+ return this.#runProtectedAsync(() => this.#uploadBlob(bytes, options));
544
+ }
545
+ async #uploadBlob(bytes, options) {
467
546
  if (this.#config.blobs === undefined) {
468
547
  throw new ClientSyncError('sync.invalid_request', 'uploadBlob requires a blob transport (SyncClientConfig.blobs, §5.9)');
469
548
  }
@@ -495,7 +574,10 @@ export class SyncClient {
495
574
  * transport (§5.9.5), verifies the content address, caches, and returns.
496
575
  * Accepts a raw `blob_ref` column string or a bare `blobId`.
497
576
  */
498
- async fetchBlob(blobIdOrRef) {
577
+ fetchBlob(blobIdOrRef) {
578
+ return this.#runProtectedAsync(() => this.#fetchBlob(blobIdOrRef));
579
+ }
580
+ async #fetchBlob(blobIdOrRef) {
499
581
  const blobId = blobIdOrRef.startsWith('sha256:')
500
582
  ? blobIdOrRef
501
583
  : parseBlobRef(blobIdOrRef).blobId;
@@ -549,7 +631,10 @@ export class SyncClient {
549
631
  enforceBlobCacheCap(this.#db, cap);
550
632
  }
551
633
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
552
- async flushBlobUploads() {
634
+ flushBlobUploads() {
635
+ return this.#runProtectedAsync(() => this.#flushBlobUploads());
636
+ }
637
+ async #flushBlobUploads() {
553
638
  const transport = this.#config.blobs;
554
639
  if (transport === undefined || !this.#hasBlobs)
555
640
  return;
@@ -597,19 +682,21 @@ export class SyncClient {
597
682
  await transport.upload(blobId, bytes, mediaType);
598
683
  }
599
684
  get conflicts() {
685
+ this.#requireActive();
600
686
  return this.#conflicts;
601
687
  }
602
688
  get rejections() {
689
+ this.#requireActive();
603
690
  return this.#rejections;
604
691
  }
605
692
  /** One durable final outcome by the originating client commit id. */
606
693
  commitOutcome(clientCommitId) {
607
- this.#requireStarted();
694
+ this.#requireActive();
608
695
  return readCommitOutcome(this.#db, clientCommitId);
609
696
  }
610
697
  /** Newest-first durable outcome journal. */
611
698
  commitOutcomes(query = {}) {
612
- this.#requireStarted();
699
+ this.#requireActive();
613
700
  return listCommitOutcomes(this.#db, query);
614
701
  }
615
702
  /**
@@ -619,7 +706,7 @@ export class SyncClient {
619
706
  * dismissed. The transition is one-way and survives restart.
620
707
  */
621
708
  resolveCommitOutcome(input) {
622
- this.#requireStarted();
709
+ this.#requireActive();
623
710
  const current = readCommitOutcome(this.#db, input.clientCommitId);
624
711
  if (current === undefined) {
625
712
  throw new ClientSyncError('sync.outcome_not_found', `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`);
@@ -703,11 +790,13 @@ export class SyncClient {
703
790
  * Ephemeral — reflects only what the socket has delivered.
704
791
  */
705
792
  presence(scopeKey) {
793
+ this.#requireActive();
706
794
  const peers = this.#presence.get(scopeKey);
707
795
  return peers === undefined ? [] : [...peers.values()];
708
796
  }
709
797
  /** Every scope key this client currently has presence state for. */
710
798
  presenceKeys() {
799
+ this.#requireActive();
711
800
  return [...this.#presence.keys()];
712
801
  }
713
802
  /**
@@ -730,7 +819,7 @@ export class SyncClient {
730
819
  * by the server with `presence.forbidden`.
731
820
  */
732
821
  setPresence(scopeKey, doc) {
733
- this.#requireStarted();
822
+ this.#requireActive();
734
823
  const socket = this.#socket;
735
824
  if (socket === undefined) {
736
825
  throw new ClientSyncError('sync.invalid_request', 'setPresence requires a connected realtime socket (§8.6)');
@@ -756,20 +845,20 @@ export class SyncClient {
756
845
  (urlCapable ? ACCEPT_SIGNED_URLS : 0));
757
846
  }
758
847
  subscriptions() {
759
- this.#requireStarted();
848
+ this.#requireActive();
760
849
  return loadSubscriptions(this.#db);
761
850
  }
762
851
  subscription(id) {
763
- this.#requireStarted();
852
+ this.#requireActive();
764
853
  return getSubscription(this.#db, id);
765
854
  }
766
855
  pendingCommits() {
767
- this.#requireStarted();
856
+ this.#requireActive();
768
857
  return listOutbox(this.#db);
769
858
  }
770
859
  // -- subscriptions ----------------------------------------------------------
771
860
  subscribe(input) {
772
- this.#requireStarted();
861
+ this.#requireActive();
773
862
  if (!this.#schema.tables.has(input.table)) {
774
863
  throw new ClientSyncError('sync.unknown_table', `subscribe: unknown local table ${JSON.stringify(input.table)}`);
775
864
  }
@@ -793,7 +882,7 @@ export class SyncClient {
793
882
  });
794
883
  }
795
884
  unsubscribe(id) {
796
- this.#requireStarted();
885
+ this.#requireActive();
797
886
  deleteSubscription(this.#db, id);
798
887
  }
799
888
  // -- windowed subscriptions (§4.8) ------------------------------------------
@@ -815,7 +904,7 @@ export class SyncClient {
815
904
  }
816
905
  /** Exact core command result consumed by automatic host loops (§7.5). */
817
906
  async setWindowCommand(base, units) {
818
- this.#requireStarted();
907
+ this.#requireActive();
819
908
  const table = this.#table(base.table);
820
909
  if (!table.scopeColumnByVariable.has(base.variable)) {
821
910
  throw new ClientSyncError('sync.invalid_request', `setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`);
@@ -878,7 +967,7 @@ export class SyncClient {
878
967
  * advances past -1 with no resume token held).
879
968
  */
880
969
  windowState(base) {
881
- this.#requireStarted();
970
+ this.#requireActive();
882
971
  const baseKey = windowBaseKey(base);
883
972
  const live = loadWindowUnits(this.#db, baseKey);
884
973
  const pending = [];
@@ -977,7 +1066,7 @@ export class SyncClient {
977
1066
  return this.#recordMutations(mutations);
978
1067
  }
979
1068
  #recordMutations(mutations, changedFieldsByIndex = []) {
980
- this.#requireStarted();
1069
+ this.#requireActive();
981
1070
  const clientCommitId = crypto.randomUUID();
982
1071
  const operations = mutations.map((mutation, index) => {
983
1072
  const table = this.#table(mutation.table);
@@ -1038,7 +1127,7 @@ export class SyncClient {
1038
1127
  * an error — there is no base to merge into.
1039
1128
  */
1040
1129
  patch(table, rowId, partial, options) {
1041
- this.#requireStarted();
1130
+ this.#requireActive();
1042
1131
  const compiled = this.#table(table);
1043
1132
  const pkColumn = compiled.columns[compiled.primaryKeyIndex];
1044
1133
  const rows = this.#db.query(`SELECT * FROM ${quoteIdent(compiled.name)} WHERE ${quoteIdent(pkColumn.name)} = ?`, [rowId]);
@@ -1309,7 +1398,7 @@ export class SyncClient {
1309
1398
  * `setWindow` at an await point.
1310
1399
  */
1311
1400
  sync() {
1312
- this.#requireStarted();
1401
+ this.#requireActive();
1313
1402
  if (this.#syncOutstanding) {
1314
1403
  return Promise.reject(new ClientSyncError('sync.invalid_request', 'sync() is already running — the core owns one loop (coalesce wake-ups)'));
1315
1404
  }
@@ -1488,13 +1577,15 @@ export class SyncClient {
1488
1577
  round.reject(new ClientSyncError('sync.transport_failed', reason, true));
1489
1578
  }
1490
1579
  // -- realtime (§8 client side) ----------------------------------------------
1491
- async connectRealtime() {
1492
- this.#requireStarted();
1580
+ connectRealtime() {
1581
+ return this.#runProtectedAsync(() => this.#connectRealtime());
1582
+ }
1583
+ async #connectRealtime() {
1493
1584
  const connector = this.#config.realtime;
1494
1585
  if (connector === undefined) {
1495
1586
  throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
1496
1587
  }
1497
- this.#socket = await connector({
1588
+ const socket = await connector({
1498
1589
  onText: (text) => this.#handleRealtimeText(text),
1499
1590
  onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
1500
1591
  onClose: () => {
@@ -1503,6 +1594,11 @@ export class SyncClient {
1503
1594
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
1504
1595
  },
1505
1596
  });
1597
+ if (this.#securityLifecycle === 'preflight') {
1598
+ socket.close();
1599
+ throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
1600
+ }
1601
+ this.#socket = socket;
1506
1602
  }
1507
1603
  disconnectRealtime() {
1508
1604
  this.#socket?.close();
@@ -2409,4 +2505,10 @@ export class SyncClient {
2409
2505
  throw new ClientSyncError('sync.invalid_request', 'SyncClient.start() has not completed');
2410
2506
  }
2411
2507
  }
2508
+ #requireActive() {
2509
+ this.#requireStarted();
2510
+ if (this.#securityLifecycle === 'preflight') {
2511
+ throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data');
2512
+ }
2513
+ }
2412
2514
  }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * import no SQLite.
10
10
  */
11
11
  export * from './apply.js';
12
+ export * from './availability.js';
12
13
  export * from './blob.js';
13
14
  export * from './client.js';
14
15
  export * from './content-type.js';
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * import no SQLite.
10
10
  */
11
11
  export * from './apply.js';
12
+ export * from './availability.js';
12
13
  export * from './blob.js';
13
14
  export * from './client.js';
14
15
  export * from './content-type.js';
@@ -20,6 +20,7 @@ export interface WindowChange {
20
20
  readonly units: ReadonlySet<string>;
21
21
  }
22
22
  export interface SyncStatusSnapshot {
23
+ readonly currentSchemaVersion: number;
23
24
  readonly outbox: number;
24
25
  readonly upgrading: boolean;
25
26
  readonly leaseState: LeaseState | undefined;