@syncular/client 0.15.44 → 0.15.46

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.
Files changed (59) hide show
  1. package/README.md +37 -5
  2. package/dist/browser-storage-persistence.d.ts +20 -0
  3. package/dist/browser-storage-persistence.js +34 -0
  4. package/dist/bun-database.d.ts +1 -1
  5. package/dist/bun-database.js +1 -1
  6. package/dist/client.d.ts +3 -3
  7. package/dist/client.js +6 -6
  8. package/dist/database.d.ts +1 -1
  9. package/dist/devtools.d.ts +1 -1
  10. package/dist/http.d.ts +6 -2
  11. package/dist/http.js +68 -1
  12. package/dist/index.d.ts +4 -2
  13. package/dist/index.js +4 -2
  14. package/dist/invalidation.d.ts +1 -1
  15. package/dist/leader-lock.d.ts +2 -2
  16. package/dist/multi-tab.js +1 -1
  17. package/dist/naming.d.ts +1 -1
  18. package/dist/naming.js +1 -1
  19. package/dist/node-database.js +1 -1
  20. package/dist/query-guard.d.ts +1 -1
  21. package/dist/query-guard.js +1 -1
  22. package/dist/remote.d.ts +76 -0
  23. package/dist/remote.js +441 -0
  24. package/dist/schema.d.ts +3 -3
  25. package/dist/sql-tag.d.ts +1 -1
  26. package/dist/transport.d.ts +12 -1
  27. package/dist/transport.js +1 -1
  28. package/dist/wasm-database.d.ts +6 -4
  29. package/dist/wasm-database.js +13 -8
  30. package/dist/window.d.ts +1 -1
  31. package/dist/window.js +1 -1
  32. package/dist/worker-entry.js +1 -1
  33. package/dist/worker-host.d.ts +5 -5
  34. package/dist/worker-host.js +2 -2
  35. package/dist/worker-protocol.d.ts +3 -3
  36. package/dist/worker-protocol.js +1 -1
  37. package/package.json +3 -3
  38. package/src/browser-storage-persistence.ts +52 -0
  39. package/src/bun-database.ts +1 -1
  40. package/src/client.ts +6 -6
  41. package/src/database.ts +1 -1
  42. package/src/devtools.ts +1 -1
  43. package/src/http.ts +100 -8
  44. package/src/index.ts +4 -2
  45. package/src/invalidation.ts +1 -1
  46. package/src/leader-lock.ts +2 -2
  47. package/src/multi-tab.ts +1 -1
  48. package/src/naming.ts +1 -1
  49. package/src/node-database.ts +1 -1
  50. package/src/query-guard.ts +1 -1
  51. package/src/remote.ts +724 -0
  52. package/src/schema.ts +3 -3
  53. package/src/sql-tag.ts +1 -1
  54. package/src/transport.ts +20 -1
  55. package/src/wasm-database.ts +13 -8
  56. package/src/window.ts +1 -1
  57. package/src/worker-entry.ts +1 -1
  58. package/src/worker-host.ts +6 -6
  59. package/src/worker-protocol.ts +3 -3
package/dist/remote.js ADDED
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Database-less SSP2 producer (§6.10). It prepares and sends ordinary commits
3
+ * through the existing push path without creating a local replica or outbox.
4
+ */
5
+ import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
6
+ import { encryptRowValues } from './encryption.js';
7
+ import { ClientSyncError } from './errors.js';
8
+ import { compileClientSchema, recordToRowValues, } from './schema.js';
9
+ export function remoteCommand(id) {
10
+ if (id.length === 0)
11
+ throw invalid('remote command id must be non-empty');
12
+ return { id };
13
+ }
14
+ function invalid(message) {
15
+ return new ClientSyncError('sync.invalid_request', message);
16
+ }
17
+ function operationResponse(bytes) {
18
+ let response;
19
+ try {
20
+ response = decodeRemoteOperationResponse(bytes);
21
+ }
22
+ catch {
23
+ throw new ClientSyncError('client.invalid_host_response', 'remote operation response is malformed');
24
+ }
25
+ if (typeof response !== 'object' ||
26
+ response === null ||
27
+ Array.isArray(response)) {
28
+ throw new ClientSyncError('client.invalid_host_response', 'remote operation response is malformed');
29
+ }
30
+ if (response.revision !== 1) {
31
+ throw new ClientSyncError('client.invalid_host_response', 'remote operation response has an unsupported revision');
32
+ }
33
+ if (response.kind === 'error' &&
34
+ (typeof response.code !== 'string' ||
35
+ typeof response.message !== 'string' ||
36
+ typeof response.retryable !== 'boolean')) {
37
+ throw new ClientSyncError('client.invalid_host_response', 'remote operation error response is malformed');
38
+ }
39
+ if (response.kind === 'query' &&
40
+ (typeof response.operationId !== 'string' ||
41
+ !Array.isArray(response.rows) ||
42
+ response.rows.some((row) => typeof row !== 'object' || row === null || Array.isArray(row)) ||
43
+ !Number.isSafeInteger(response.maxCommitSeq) ||
44
+ response.maxCommitSeq < 0)) {
45
+ throw new ClientSyncError('client.invalid_host_response', 'remote query response is malformed');
46
+ }
47
+ if (response.kind === 'command' &&
48
+ (typeof response.operationId !== 'string' ||
49
+ typeof response.requestId !== 'string' ||
50
+ !['applied', 'cached', 'rejected'].includes(response.status) ||
51
+ !Array.isArray(response.results) ||
52
+ (response.commitSeq !== undefined &&
53
+ (!Number.isSafeInteger(response.commitSeq) || response.commitSeq < 1)))) {
54
+ throw new ClientSyncError('client.invalid_host_response', 'remote command response is malformed');
55
+ }
56
+ if (response.kind !== 'error' &&
57
+ response.kind !== 'query' &&
58
+ response.kind !== 'command') {
59
+ throw new ClientSyncError('client.invalid_host_response', 'remote operation response has an unknown kind');
60
+ }
61
+ return response;
62
+ }
63
+ export class SyncRemoteClient {
64
+ #schema;
65
+ #clientId;
66
+ #transport;
67
+ #operations;
68
+ #operationRealtime;
69
+ #operationSocket;
70
+ #operationSocketPromise;
71
+ #operationSocketGeneration = 0;
72
+ #watches = new Map();
73
+ #encryption;
74
+ constructor(config) {
75
+ if (config.clientId.length === 0) {
76
+ throw invalid('SyncRemoteClient clientId must be non-empty');
77
+ }
78
+ this.#schema =
79
+ config.schema === undefined
80
+ ? undefined
81
+ : compileClientSchema(config.schema);
82
+ this.#clientId = config.clientId;
83
+ this.#transport = config.transport;
84
+ this.#operations = config.operations;
85
+ this.#operationRealtime = config.operationRealtime;
86
+ this.#encryption = config.encryption;
87
+ }
88
+ async prepareCommit(input) {
89
+ const schema = this.#schema;
90
+ if (schema === undefined) {
91
+ throw new ClientSyncError('client.remote_schema_unconfigured', 'SyncRemoteClient needs a schema to prepare ordinary commits');
92
+ }
93
+ if (input.requestId.length === 0) {
94
+ throw invalid('remote commit requestId must be non-empty');
95
+ }
96
+ if (input.mutations.length === 0) {
97
+ throw new ClientSyncError('sync.empty_commit', 'a remote commit must carry at least one mutation (§6.1)');
98
+ }
99
+ const operations = [];
100
+ for (const mutation of input.mutations) {
101
+ const table = schema.tables.get(mutation.table);
102
+ if (table === undefined) {
103
+ throw invalid('remote commit targets an unknown table');
104
+ }
105
+ if (mutation.op === 'delete') {
106
+ if (mutation.rowId.length === 0) {
107
+ throw invalid('remote delete rowId must be non-empty');
108
+ }
109
+ operations.push({
110
+ table: mutation.table,
111
+ rowId: mutation.rowId,
112
+ op: 'delete',
113
+ ...(mutation.baseVersion !== undefined
114
+ ? { baseVersion: mutation.baseVersion }
115
+ : {}),
116
+ });
117
+ continue;
118
+ }
119
+ let values = recordToRowValues(table, mutation.values);
120
+ const rowId = values[table.primaryKeyIndex];
121
+ if (typeof rowId !== 'string' || rowId.length === 0) {
122
+ throw invalid('remote upsert requires a non-empty string primary key');
123
+ }
124
+ if (this.#encryption !== undefined && table.hasEncryptedColumns) {
125
+ values = await encryptRowValues(this.#encryption, table, rowId, values);
126
+ }
127
+ operations.push({
128
+ table: mutation.table,
129
+ rowId,
130
+ op: 'upsert',
131
+ ...(mutation.baseVersion !== undefined
132
+ ? { baseVersion: mutation.baseVersion }
133
+ : {}),
134
+ payload: encodeRow(table.columns, values),
135
+ });
136
+ }
137
+ return {
138
+ requestId: input.requestId,
139
+ bytes: encodeMessage({
140
+ wireVersion: PROTOCOL_WIRE_VERSION,
141
+ msgKind: 'request',
142
+ frames: [
143
+ {
144
+ type: 'REQ_HEADER',
145
+ clientId: this.#clientId,
146
+ schemaVersion: schema.version,
147
+ },
148
+ {
149
+ type: 'PUSH_COMMIT',
150
+ clientCommitId: input.requestId,
151
+ operations,
152
+ },
153
+ ],
154
+ }),
155
+ };
156
+ }
157
+ async sendCommit(prepared) {
158
+ if (this.#transport === undefined) {
159
+ throw new ClientSyncError('client.remote_sync_unconfigured', 'SyncRemoteClient has no sync transport for ordinary commits');
160
+ }
161
+ const response = decodeMessage(await this.#transport(prepared.bytes));
162
+ if (response.msgKind !== 'response') {
163
+ throw new ClientSyncError('client.invalid_host_response', 'remote commit transport returned a non-response SSP2 message');
164
+ }
165
+ const error = response.frames.find((frame) => frame.type === 'ERROR');
166
+ if (error?.type === 'ERROR') {
167
+ throw new ClientSyncError(error.code, error.message, error.retryable);
168
+ }
169
+ const result = response.frames.find((frame) => frame.type === 'PUSH_RESULT' &&
170
+ frame.clientCommitId === prepared.requestId);
171
+ if (result === undefined) {
172
+ throw new ClientSyncError('client.invalid_host_response', 'remote commit response carried no matching PUSH_RESULT');
173
+ }
174
+ const details = response.frames.find((frame) => frame.type === 'PUSH_RESULT_DETAILS' &&
175
+ frame.clientCommitId === prepared.requestId);
176
+ const detailsByIndex = new Map(details?.entries.map((entry) => [entry.opIndex, entry.details]) ?? []);
177
+ return {
178
+ requestId: prepared.requestId,
179
+ status: result.status,
180
+ ...(result.commitSeq !== undefined
181
+ ? { commitSeq: result.commitSeq }
182
+ : {}),
183
+ results: result.results.map((operation) => {
184
+ if (operation.status !== 'error')
185
+ return operation;
186
+ const operationDetails = detailsByIndex.get(operation.opIndex);
187
+ return operationDetails === undefined
188
+ ? operation
189
+ : { ...operation, details: operationDetails };
190
+ }),
191
+ };
192
+ }
193
+ async commit(input) {
194
+ return this.sendCommit(await this.prepareCommit(input));
195
+ }
196
+ async query(descriptor, params) {
197
+ if (this.#operations === undefined) {
198
+ throw new ClientSyncError('client.remote_operations_unconfigured', 'SyncRemoteClient has no remote operation transport');
199
+ }
200
+ const response = operationResponse(await this.#operations(encodeRemoteOperationRequest({
201
+ revision: 1,
202
+ kind: 'query',
203
+ clientId: this.#clientId,
204
+ operationId: descriptor.id,
205
+ params: params ?? null,
206
+ })));
207
+ if (response.kind === 'error') {
208
+ throw new ClientSyncError(response.code, response.message, response.retryable);
209
+ }
210
+ if (response.kind !== 'query' || response.operationId !== descriptor.id) {
211
+ throw new ClientSyncError('client.invalid_host_response', 'remote query returned a mismatched response');
212
+ }
213
+ try {
214
+ return {
215
+ rows: response.rows.map((row) => descriptor.mapRow(row)),
216
+ maxCommitSeq: response.maxCommitSeq,
217
+ };
218
+ }
219
+ catch {
220
+ throw new ClientSyncError('client.invalid_host_response', 'remote query row is malformed');
221
+ }
222
+ }
223
+ async command(descriptor, requestId, input) {
224
+ if (this.#operations === undefined) {
225
+ throw new ClientSyncError('client.remote_operations_unconfigured', 'SyncRemoteClient has no remote operation transport');
226
+ }
227
+ if (requestId.length === 0) {
228
+ throw invalid('remote command requestId must be non-empty');
229
+ }
230
+ const response = operationResponse(await this.#operations(encodeRemoteOperationRequest({
231
+ revision: 1,
232
+ kind: 'command',
233
+ clientId: this.#clientId,
234
+ operationId: descriptor.id,
235
+ requestId,
236
+ params: input ?? null,
237
+ })));
238
+ if (response.kind === 'error') {
239
+ throw new ClientSyncError(response.code, response.message, response.retryable);
240
+ }
241
+ if (response.kind !== 'command' ||
242
+ response.operationId !== descriptor.id ||
243
+ response.requestId !== requestId) {
244
+ throw new ClientSyncError('client.invalid_host_response', 'remote command returned a mismatched response');
245
+ }
246
+ return {
247
+ requestId,
248
+ status: response.status,
249
+ ...(response.commitSeq !== undefined
250
+ ? { commitSeq: response.commitSeq }
251
+ : {}),
252
+ results: response.results,
253
+ };
254
+ }
255
+ async watch(descriptor, params, handlers) {
256
+ if (this.#operationRealtime === undefined) {
257
+ throw new ClientSyncError('client.remote_realtime_unconfigured', 'SyncRemoteClient has no remote operation realtime connector');
258
+ }
259
+ const watchId = crypto.randomUUID();
260
+ this.#watches.set(watchId, {
261
+ operationId: descriptor.id,
262
+ mapRow: descriptor.mapRow,
263
+ handlers: handlers,
264
+ });
265
+ let socket;
266
+ try {
267
+ socket = await this.#operationRealtimeSocket();
268
+ }
269
+ catch (error) {
270
+ this.#watches.delete(watchId);
271
+ throw error;
272
+ }
273
+ if (!this.#watches.has(watchId)) {
274
+ throw new ClientSyncError('client.remote_realtime_cancelled', 'remote operation watch was cancelled before registration');
275
+ }
276
+ try {
277
+ socket.send(encodeRemoteOperationRealtimeMessage({
278
+ revision: 1,
279
+ kind: 'watch',
280
+ watchId,
281
+ clientId: this.#clientId,
282
+ operationId: descriptor.id,
283
+ params: params ?? null,
284
+ }));
285
+ }
286
+ catch {
287
+ const error = new ClientSyncError('client.remote_realtime_closed', 'remote operation realtime connection closed while registering a watch', true);
288
+ this.#disconnectOperationRealtime(this.#operationSocketGeneration, error, true);
289
+ throw error;
290
+ }
291
+ return () => {
292
+ if (!this.#watches.delete(watchId))
293
+ return;
294
+ try {
295
+ this.#operationSocket?.send(encodeRemoteOperationRealtimeMessage({
296
+ revision: 1,
297
+ kind: 'unwatch',
298
+ watchId,
299
+ }));
300
+ }
301
+ catch {
302
+ this.#disconnectOperationRealtime(this.#operationSocketGeneration, new ClientSyncError('client.remote_realtime_closed', 'remote operation realtime connection closed while removing a watch', true), true);
303
+ }
304
+ };
305
+ }
306
+ async #operationRealtimeSocket() {
307
+ if (this.#operationSocket !== undefined)
308
+ return this.#operationSocket;
309
+ if (this.#operationSocketPromise !== undefined) {
310
+ return this.#operationSocketPromise;
311
+ }
312
+ const connector = this.#operationRealtime;
313
+ if (connector === undefined) {
314
+ throw new ClientSyncError('client.remote_realtime_unconfigured', 'SyncRemoteClient has no remote operation realtime connector');
315
+ }
316
+ const generation = this.#operationSocketGeneration;
317
+ const pending = Promise.resolve(connector({
318
+ onMessage: (bytes) => {
319
+ if (generation !== this.#operationSocketGeneration)
320
+ return;
321
+ let message;
322
+ try {
323
+ message = decodeRemoteOperationRealtimeMessage(bytes);
324
+ if (typeof message !== 'object' ||
325
+ message === null ||
326
+ message.revision !== 1 ||
327
+ (message.kind !== 'snapshot' && message.kind !== 'watch_error') ||
328
+ typeof message.watchId !== 'string') {
329
+ throw new Error('invalid remote operation realtime message');
330
+ }
331
+ }
332
+ catch {
333
+ this.#disconnectOperationRealtime(generation, new ClientSyncError('client.invalid_host_response', 'remote operation realtime message is malformed'), true);
334
+ return;
335
+ }
336
+ const watch = this.#watches.get(message.watchId);
337
+ if (watch === undefined)
338
+ return;
339
+ if (message.kind === 'watch_error') {
340
+ if (typeof message.code !== 'string' ||
341
+ typeof message.message !== 'string' ||
342
+ typeof message.retryable !== 'boolean') {
343
+ this.#disconnectOperationRealtime(generation, new ClientSyncError('client.invalid_host_response', 'remote operation watch error is malformed'), true);
344
+ return;
345
+ }
346
+ try {
347
+ watch.handlers.onError?.(new ClientSyncError(message.code, message.message, message.retryable));
348
+ }
349
+ catch {
350
+ // An observer cannot alter the connection lifecycle.
351
+ }
352
+ return;
353
+ }
354
+ if (message.operationId !== watch.operationId ||
355
+ !Array.isArray(message.rows) ||
356
+ message.rows.some((row) => typeof row !== 'object' || row === null || Array.isArray(row)) ||
357
+ !Number.isSafeInteger(message.maxCommitSeq) ||
358
+ message.maxCommitSeq < 0) {
359
+ this.#disconnectOperationRealtime(generation, new ClientSyncError('client.invalid_host_response', 'remote operation watch snapshot is malformed'), true);
360
+ return;
361
+ }
362
+ let rows;
363
+ try {
364
+ rows = message.rows.map(watch.mapRow);
365
+ }
366
+ catch {
367
+ try {
368
+ watch.handlers.onError?.(new ClientSyncError('client.invalid_host_response', 'remote operation watch row is malformed'));
369
+ }
370
+ catch {
371
+ // An observer cannot alter the connection lifecycle.
372
+ }
373
+ return;
374
+ }
375
+ try {
376
+ watch.handlers.onSnapshot({
377
+ rows,
378
+ maxCommitSeq: message.maxCommitSeq,
379
+ });
380
+ }
381
+ catch {
382
+ // An observer cannot alter the connection lifecycle.
383
+ }
384
+ },
385
+ onClose: () => {
386
+ this.#disconnectOperationRealtime(generation, new ClientSyncError('client.remote_realtime_closed', 'remote operation realtime connection closed', true), false);
387
+ },
388
+ }))
389
+ .then((socket) => {
390
+ if (generation !== this.#operationSocketGeneration) {
391
+ try {
392
+ socket.close();
393
+ }
394
+ catch {
395
+ // The cancelled socket cannot affect the replacement generation.
396
+ }
397
+ throw new ClientSyncError('client.remote_realtime_cancelled', 'remote operation realtime connection was cancelled');
398
+ }
399
+ this.#operationSocket = socket;
400
+ return socket;
401
+ })
402
+ .finally(() => {
403
+ if (this.#operationSocketPromise === pending) {
404
+ this.#operationSocketPromise = undefined;
405
+ }
406
+ });
407
+ this.#operationSocketPromise = pending;
408
+ return pending;
409
+ }
410
+ #disconnectOperationRealtime(generation, error, closeSocket) {
411
+ if (generation !== this.#operationSocketGeneration)
412
+ return;
413
+ this.#operationSocketGeneration += 1;
414
+ const socket = this.#operationSocket;
415
+ this.#operationSocket = undefined;
416
+ this.#operationSocketPromise = undefined;
417
+ const watches = [...this.#watches.values()];
418
+ this.#watches.clear();
419
+ if (closeSocket) {
420
+ try {
421
+ socket?.close();
422
+ }
423
+ catch {
424
+ // Local state is already disconnected.
425
+ }
426
+ }
427
+ if (error === undefined)
428
+ return;
429
+ for (const watch of watches) {
430
+ try {
431
+ watch.handlers.onError?.(error);
432
+ }
433
+ catch {
434
+ // An observer cannot alter the connection lifecycle.
435
+ }
436
+ }
437
+ }
438
+ close() {
439
+ this.#disconnectOperationRealtime(this.#operationSocketGeneration, undefined, true);
440
+ }
441
+ }
package/dist/schema.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Client schema IR (SPEC.md §2.4, §3.1) — the same shape the server
3
- * compiles, hand-written until codegen (B5) emits it. Drives local table
3
+ * compiles and is emitted by codegen. Drives local table
4
4
  * DDL, the generated row codec, mutation helpers, and the §3.3 purge
5
5
  * mapping (scope variable → local column).
6
6
  */
@@ -17,7 +17,7 @@ export interface ClientIndexSpec {
17
17
  readonly columns: readonly string[];
18
18
  readonly unique: boolean;
19
19
  }
20
- /** One client-local contentful FTS5 projection (RFC 0005). */
20
+ /** One client-local contentful FTS5 projection. */
21
21
  export interface ClientFtsIndexSpec {
22
22
  readonly name: string;
23
23
  readonly columns: readonly string[];
@@ -58,7 +58,7 @@ export interface CompiledClientTable {
58
58
  /**
59
59
  * Scope variable → the pattern's literal prefix (§3.1). A stored-scope
60
60
  * value `v` for this variable has scope key `prefix:v` — the invalidation
61
- * vocabulary (TODO 3.1 / DESIGN-eviction I2) and the delta-routing key.
61
+ * vocabulary and the delta-routing key.
62
62
  */
63
63
  readonly scopePrefixByVariable: ReadonlyMap<string, string>;
64
64
  /** Local secondary indexes to create on the mirror table (declaration
package/dist/sql-tag.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The `sql` tagged template — the raw tier's composition helper
3
- * (DESIGN-queries.md I4). Structural injection safety: an interpolated
3
+ * Structural injection safety: an interpolated
4
4
  * value can only ever become a `?` bind parameter; SQL text can only enter
5
5
  * through the literal template, `sql.ident()` (allowlist-gated) or a loud
6
6
  * `sql.raw()`. This helper is deliberately dumb plumbing and stays that
@@ -1,11 +1,22 @@
1
1
  /**
2
- * Transport seams (REVISE B3): request/response bytes, segment download,
2
+ * Transport seams: request/response bytes, segment download,
3
3
  * and the realtime attach surface matching §8's client side. Tests use
4
4
  * loopback implementations that call the server library directly — the
5
5
  * loopback doctrine; HTTP/WebSocket bindings live in `./http`.
6
6
  */
7
7
  /** One combined push+pull round trip: SSP2 request bytes → response bytes. */
8
8
  export type SyncTransport = (request: Uint8Array) => Promise<Uint8Array>;
9
+ /** One registered authoritative query or command request. */
10
+ export type RemoteOperationTransport = (request: Uint8Array) => Promise<Uint8Array>;
11
+ export interface RemoteOperationRealtimeHandlers {
12
+ onMessage(bytes: Uint8Array): void;
13
+ onClose?(): void;
14
+ }
15
+ export interface RemoteOperationRealtimeSocket {
16
+ send(bytes: Uint8Array): void;
17
+ close(): void;
18
+ }
19
+ export type RemoteOperationRealtimeConnector = (handlers: RemoteOperationRealtimeHandlers) => RemoteOperationRealtimeSocket | Promise<RemoteOperationRealtimeSocket>;
9
20
  export interface SegmentFetchRequest {
10
21
  readonly segmentId: string;
11
22
  readonly table: string;
package/dist/transport.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Transport seams (REVISE B3): request/response bytes, segment download,
2
+ * Transport seams: request/response bytes, segment download,
3
3
  * and the realtime attach surface matching §8's client side. Tests use
4
4
  * loopback implementations that call the server library directly — the
5
5
  * loopback doctrine; HTTP/WebSocket bindings live in `./http`.
@@ -3,7 +3,7 @@ import { type ClientDatabase } from './database.js';
3
3
  * EXPLICIT ephemeral mode: an in-memory sqlite-wasm database. For tests,
4
4
  * demos and SSR only — nothing persists. The persistent mode is
5
5
  * `openPersistentWasmDatabase` inside a worker; there is no fallback from
6
- * one to the other (Direction decision 2).
6
+ * one to the other.
7
7
  */
8
8
  export declare function openWasmDatabase(): Promise<ClientDatabase>;
9
9
  export interface PersistentWasmDatabaseOptions {
@@ -18,13 +18,15 @@ export interface PersistentWasmDatabaseOptions {
18
18
  readonly initialCapacity?: number;
19
19
  }
20
20
  /**
21
- * THE persistent browser mode: a named database on OPFS via the
21
+ * THE reload-persistent browser mode: a named database on OPFS via the
22
22
  * `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
23
23
  * it (it uses `FileSystemSyncAccessHandle`, no `Atomics.wait`, and could
24
24
  * technically run on the main thread), but because the persistent mode IS
25
- * whole-core-in-a-worker (REVISE Direction decision 2, 2026-07-03) and
25
+ * whole-core-in-a-worker and
26
26
  * this factory enforces that decision. No COOP/COEP headers required.
27
27
  *
28
- * Support floor: no OPFS → a loud `ClientSyncError`, never a fallback.
28
+ * Support floor: no OPFS → a loud `ClientSyncError`, never a fallback. This
29
+ * factory cannot request eviction-resistant origin storage because that API
30
+ * belongs to the page's Window context.
29
31
  */
30
32
  export declare function openPersistentWasmDatabase(name: string, options?: PersistentWasmDatabaseOptions): Promise<ClientDatabase>;
@@ -1,8 +1,8 @@
1
1
  /**
2
- * `ClientDatabase` on @sqlite.org/sqlite-wasm (Direction decision 2,
3
- * 2026-07-03). Two modes, no ladder between them:
2
+ * `ClientDatabase` on @sqlite.org/sqlite-wasm. Two modes, no ladder between
3
+ * them:
4
4
  *
5
- * - `openPersistentWasmDatabase(name)` — THE persistent browser mode:
5
+ * - `openPersistentWasmDatabase(name)` — THE reload-persistent browser mode:
6
6
  * OPFS via the `opfs-sahpool` VFS, restricted to Web Worker contexts
7
7
  * because the whole client core runs in a worker by design. SAHPool
8
8
  * needs **no COOP/COEP headers and no SharedArrayBuffer** (it is built
@@ -10,7 +10,10 @@
10
10
  * proxy — the COOP/COEP requirement documented by sqlite-wasm applies
11
11
  * only to `oo1.OpfsDb`, which this binding no longer uses). Browsers
12
12
  * without OPFS are unsupported (support floor ~2023+): the factory
13
- * fails loud. Never IndexedDB, never a silent in-memory fallback.
13
+ * fails loud. Never IndexedDB, never a silent in-memory fallback. The
14
+ * browser's separate origin-eviction policy is exposed from the root package
15
+ * by `checkBrowserStoragePersistence` and
16
+ * `requestBrowserStoragePersistence`.
14
17
  * - `openWasmDatabase()` — EXPLICIT ephemeral: an in-memory database for
15
18
  * tests, demos and SSR. Nothing survives a reload, on purpose.
16
19
  *
@@ -106,7 +109,7 @@ function initSqlite3() {
106
109
  * EXPLICIT ephemeral mode: an in-memory sqlite-wasm database. For tests,
107
110
  * demos and SSR only — nothing persists. The persistent mode is
108
111
  * `openPersistentWasmDatabase` inside a worker; there is no fallback from
109
- * one to the other (Direction decision 2).
112
+ * one to the other.
110
113
  */
111
114
  export async function openWasmDatabase() {
112
115
  const sqlite3 = await initSqlite3();
@@ -133,14 +136,16 @@ function opfsSahPoolError(error, directory) {
133
136
  `or rename the directory. Underlying error: ${detail}`, true);
134
137
  }
135
138
  /**
136
- * THE persistent browser mode: a named database on OPFS via the
139
+ * THE reload-persistent browser mode: a named database on OPFS via the
137
140
  * `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
138
141
  * it (it uses `FileSystemSyncAccessHandle`, no `Atomics.wait`, and could
139
142
  * technically run on the main thread), but because the persistent mode IS
140
- * whole-core-in-a-worker (REVISE Direction decision 2, 2026-07-03) and
143
+ * whole-core-in-a-worker and
141
144
  * this factory enforces that decision. No COOP/COEP headers required.
142
145
  *
143
- * Support floor: no OPFS → a loud `ClientSyncError`, never a fallback.
146
+ * Support floor: no OPFS → a loud `ClientSyncError`, never a fallback. This
147
+ * factory cannot request eviction-resistant origin storage because that API
148
+ * belongs to the page's Window context.
144
149
  */
145
150
  export async function openPersistentWasmDatabase(name, options) {
146
151
  if (!/^[A-Za-z0-9._-]+$/.test(name)) {
package/dist/window.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Windowed subscriptions (SPEC.md §4.8 / DESIGN-eviction.md W1).
2
+ * Windowed subscriptions (SPEC.md §4.8).
3
3
  *
4
4
  * A window is a partial local replica keyed by scope VALUES: the client
5
5
  * holds rows for a chosen set of units (one scope value each) of a window
package/dist/window.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Windowed subscriptions (SPEC.md §4.8 / DESIGN-eviction.md W1).
2
+ * Windowed subscriptions (SPEC.md §4.8).
3
3
  *
4
4
  * A window is a partial local replica keyed by scope VALUES: the client
5
5
  * holds rows for a chosen set of units (one scope value each) of a window
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Worker-side bootstrap (Direction decision 2): constructs the WHOLE
2
+ * Worker-side bootstrap: constructs the WHOLE
3
3
  * client core — SyncClient + transports + realtime + SQLite — inside the
4
4
  * worker and serves the `worker-protocol` RPC over postMessage.
5
5
  *
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Main-thread side of the worker mode (Direction decision 2) and the
3
- * multi-tab topology (TODO 3.2, REVISE B3).
2
+ * Main-thread side of the worker mode and the
3
+ * multi-tab topology.
4
4
  *
5
5
  * `createSyncClientHandle` acquires the Web Locks leader lock and, when it
6
6
  * wins, spawns the worker running the WHOLE core — so exactly one core runs
@@ -8,7 +8,7 @@
8
8
  * holding the lock). The returned {@link SyncClientHandle} is a thin, fully
9
9
  * async proxy over the `worker-protocol` RPC.
10
10
  *
11
- * Multi-tab is the DEFAULT (RFC 0002 §2.4 the follower path is
11
+ * Multi-tab is the DEFAULT. The follower path is
12
12
  * conformance-covered): a tab that LOSES the election becomes a FOLLOWER
13
13
  * (`role === 'follower'`) that proxies every call to the leader tab over a
14
14
  * BroadcastChannel (see `multi-tab.ts`). When the leader tab closes, its
@@ -86,7 +86,7 @@ export interface SyncClientHandleConfig {
86
86
  /** Shared by default; isolated derives the database/lock/channel tuple. */
87
87
  readonly replica?: BrowserReplicaMode;
88
88
  /**
89
- * Multi-tab followers (TODO 3.2). On by default: a tab that loses the
89
+ * Multi-tab followers. On by default: a tab that loses the
90
90
  * leader election becomes a FOLLOWER that proxies to the leader over a
91
91
  * BroadcastChannel, and contests + promotes when the leader closes. Set
92
92
  * false for the single-tab contract — the loser is a dead
@@ -172,7 +172,7 @@ export declare class SyncClientHandle {
172
172
  /** @internal — dispatch a worker/relayed event to handle-local listeners. */
173
173
  __dispatchEvent(event: SyncWorkerEvent): void;
174
174
  /**
175
- * TODO 3.1 / I1: subscribe to fine-grained invalidation the identical
175
+ * Subscribe to fine-grained invalidation. The identical
176
176
  * surface as `SyncClient.onInvalidate`, so React bindings target one
177
177
  * interface across direct, worker-leader, and follower modes. Returns an
178
178
  * unsubscribe function.
@@ -98,7 +98,7 @@ export class SyncClientHandle {
98
98
  this.#diagnostics = internals.diagnostics;
99
99
  this.#roleListeners = internals.roleListeners ?? new Set();
100
100
  this.#leadershipListeners = internals.leadershipListeners ?? new Set();
101
- // RFC 0002 §3.2: console introspection a no-op outside a dev page.
101
+ // Console introspection is a no-op outside a dev page.
102
102
  this.#devtoolsUnregister = registerDevtools({
103
103
  kind: 'handle',
104
104
  ref: this,
@@ -181,7 +181,7 @@ export class SyncClientHandle {
181
181
  }
182
182
  }
183
183
  /**
184
- * TODO 3.1 / I1: subscribe to fine-grained invalidation the identical
184
+ * Subscribe to fine-grained invalidation. The identical
185
185
  * surface as `SyncClient.onInvalidate`, so React bindings target one
186
186
  * interface across direct, worker-leader, and follower modes. Returns an
187
187
  * unsubscribe function.