@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 +88 -2
- package/dist/availability.d.ts +17 -0
- package/dist/availability.js +48 -0
- package/dist/client.d.ts +33 -0
- package/dist/client.js +124 -22
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invalidation.d.ts +1 -0
- package/dist/multi-tab.d.ts +24 -1
- package/dist/multi-tab.js +113 -4
- package/dist/reactive-store.d.ts +10 -1
- package/dist/reactive-store.js +107 -5
- package/dist/worker-entry.js +28 -2
- package/dist/worker-host.d.ts +40 -3
- package/dist/worker-host.js +126 -7
- package/dist/worker-protocol.d.ts +10 -1
- package/package.json +3 -3
- package/src/availability.ts +70 -0
- package/src/client.ts +176 -23
- package/src/index.ts +1 -0
- package/src/invalidation.ts +1 -0
- package/src/multi-tab.ts +158 -0
- package/src/reactive-store.ts +138 -7
- package/src/worker-entry.ts +32 -2
- package/src/worker-host.ts +174 -6
- package/src/worker-protocol.ts +11 -0
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'
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
848
|
+
this.#requireActive();
|
|
760
849
|
return loadSubscriptions(this.#db);
|
|
761
850
|
}
|
|
762
851
|
subscription(id) {
|
|
763
|
-
this.#
|
|
852
|
+
this.#requireActive();
|
|
764
853
|
return getSubscription(this.#db, id);
|
|
765
854
|
}
|
|
766
855
|
pendingCommits() {
|
|
767
|
-
this.#
|
|
856
|
+
this.#requireActive();
|
|
768
857
|
return listOutbox(this.#db);
|
|
769
858
|
}
|
|
770
859
|
// -- subscriptions ----------------------------------------------------------
|
|
771
860
|
subscribe(input) {
|
|
772
|
-
this.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
1492
|
-
this.#
|
|
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
|
-
|
|
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
package/dist/index.js
CHANGED
package/dist/invalidation.d.ts
CHANGED
|
@@ -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;
|