@syncular/client 0.15.46 → 0.15.48
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 +43 -43
- package/dist/bun-database.d.ts +5 -0
- package/dist/bun-database.js +5 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +127 -17
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/node-database.d.ts +6 -29
- package/dist/node-database.js +7 -69
- package/dist/query-guard.d.ts +2 -2
- package/dist/query-guard.js +2 -2
- package/dist/remote.d.ts +2 -0
- package/dist/remote.js +10 -2
- package/dist/sqlite-bun.d.ts +2 -0
- package/dist/sqlite-bun.js +4 -0
- package/dist/sqlite-node.d.ts +2 -0
- package/dist/sqlite-node.js +4 -0
- package/dist/sync-scheduler.d.ts +23 -0
- package/dist/sync-scheduler.js +122 -0
- package/dist/window.d.ts +5 -0
- package/dist/window.js +39 -0
- package/dist/worker-entry.js +2 -9
- package/package.json +12 -14
- package/src/bun-database.ts +11 -0
- package/src/client.ts +144 -16
- package/src/index.ts +2 -1
- package/src/node-database.ts +11 -108
- package/src/query-guard.ts +2 -2
- package/src/remote.ts +11 -2
- package/src/sqlite-bun.ts +6 -0
- package/src/sqlite-node.ts +6 -0
- package/src/sync-scheduler.ts +154 -0
- package/src/window.ts +65 -0
- package/src/worker-entry.ts +3 -11
package/README.md
CHANGED
|
@@ -4,8 +4,9 @@ The TypeScript client protocol core (SPEC.md §§3–8, client side) plus its
|
|
|
4
4
|
browser platform bindings.
|
|
5
5
|
|
|
6
6
|
The normal `SyncClient` also runs in a CLI or background service.
|
|
7
|
-
Use `
|
|
8
|
-
replica
|
|
7
|
+
Use `openSqliteDatabase(path)` from `@syncular/client/sqlite` for a persistent
|
|
8
|
+
local replica on Node or Bun. See the
|
|
9
|
+
[server-side sync client guide](https://syncular.dev/guide-server-clients/).
|
|
9
10
|
|
|
10
11
|
`SyncRemoteClient` is the database-less server client. It sends ordinary
|
|
11
12
|
push-only commits through `/sync` and can call registered typed queries,
|
|
@@ -13,6 +14,9 @@ server-authoritative commands, and live query watches through the remote
|
|
|
13
14
|
operation transport. See [remote server operations](https://syncular.dev/guide-remote-operations/).
|
|
14
15
|
Its schema and sync transport are optional for query-only or command-only
|
|
15
16
|
processes.
|
|
17
|
+
Ordinary commits use wire version 1 until the caller supplies an acquired
|
|
18
|
+
partition `logEpoch`. Set `logEpoch` after a restore rotation requires epoch
|
|
19
|
+
validation.
|
|
16
20
|
|
|
17
21
|
## Client-local FTS5 projections
|
|
18
22
|
|
|
@@ -68,6 +72,20 @@ SPEC §8.4); the supported page-level realtime supervisor owns reconnect and
|
|
|
68
72
|
resume policy. The main thread gets `onSyncNeeded` / `onConflict` / `onSynced`
|
|
69
73
|
events for rendering.
|
|
70
74
|
|
|
75
|
+
A direct `SyncClient` used by a long-running service exposes
|
|
76
|
+
`onSyncNeeded()` and `onSyncIntent()`. Install the shared single-flight loop
|
|
77
|
+
instead of maintaining host timers:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const scheduler = installSyncScheduler(client, { onError: reportSyncError });
|
|
81
|
+
// During shutdown:
|
|
82
|
+
scheduler.stop();
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Creation-time window helpers produce immutable UTC month scope values.
|
|
86
|
+
`creationTimeBucket(createdAtMs, 'month')` returns `YYYY-MM`, and
|
|
87
|
+
`last(3, 'month')` returns the current and preceding two buckets oldest first.
|
|
88
|
+
|
|
71
89
|
OPFS is best effort until the browser grants origin persistence. The page owns
|
|
72
90
|
that decision because `StorageManager.persist()` is a Window API and should be
|
|
73
91
|
requested from a user action:
|
|
@@ -470,10 +488,10 @@ directory or IndexedDB store. This is the pinned decision (SPEC §5.9.7 B1):
|
|
|
470
488
|
that pin them — a refcount adjust and a body insert/delete commit atomically,
|
|
471
489
|
so a crash never strands a body against a stale count.
|
|
472
490
|
- **Survives restarts for free.** The client DB already rides OPFS via the
|
|
473
|
-
sahpool VFS in the browser (and a plain file under `rusqlite
|
|
474
|
-
on native
|
|
475
|
-
eviction policy to keep coherent. Close the app, reopen
|
|
476
|
-
the cached body with no network.
|
|
491
|
+
sahpool VFS in the browser (and a plain file under `rusqlite`, `bun:sqlite`,
|
|
492
|
+
or `node:sqlite` on native runtimes), so there is no second persistence
|
|
493
|
+
surface and no second eviction policy to keep coherent. Close the app, reopen
|
|
494
|
+
it: `fetchBlob` serves the cached body with no network.
|
|
477
495
|
- **SQLite handles multi-MB images fine.** A page-cached `BLOB` read is a memory
|
|
478
496
|
copy, well within the image/document envelope this targets.
|
|
479
497
|
|
|
@@ -507,57 +525,38 @@ straight to a media element instead of pulling bytes through the cache — the
|
|
|
507
525
|
image-app default (refcounted `BLOB` cache) and the large-media path (presigned
|
|
508
526
|
URL, no byte cache) coexist per attachment.
|
|
509
527
|
|
|
510
|
-
## Node
|
|
528
|
+
## Node and Bun SQLite backend (`./sqlite`)
|
|
511
529
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
`openNodeDatabase`, a `ClientDatabase` over
|
|
515
|
-
[better-sqlite3](https://github.com/WiseLibs/better-sqlite3):
|
|
530
|
+
CLIs, background workers, Electron main processes, and services can use one
|
|
531
|
+
runtime-selected import:
|
|
516
532
|
|
|
517
533
|
```ts
|
|
518
|
-
import {
|
|
534
|
+
import { openSqliteDatabase } from '@syncular/client/sqlite';
|
|
519
535
|
import { SyncClient } from '@syncular/client';
|
|
520
536
|
|
|
521
|
-
const database =
|
|
537
|
+
const database = openSqliteDatabase('app.db'); // or ':memory:' (default)
|
|
522
538
|
const client = new SyncClient({ database, schema, /* … */ });
|
|
523
539
|
```
|
|
524
540
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
attach path is supported too, so a Node host can accept sqlite-image segments.
|
|
541
|
+
The export selects `bun:sqlite` on Bun and the built-in `node:sqlite` module on
|
|
542
|
+
Node 22.13 or newer. No SQLite package or native addon is required. Both
|
|
543
|
+
adapters support synchronous `exec`, `query`, nested transactions, boolean
|
|
544
|
+
bindings, `null`, `Uint8Array` BLOB values, and §5.3 SQLite-image attachment.
|
|
530
545
|
|
|
531
|
-
|
|
532
|
-
installs cleanly without it (browser-only apps never pay for a native build);
|
|
533
|
-
`openNodeDatabase()` loads it lazily on first call and throws a clear,
|
|
534
|
-
actionable error if the peer is missing. Add it in your app:
|
|
546
|
+
Runtime-specific imports remain available:
|
|
535
547
|
|
|
536
|
-
```
|
|
537
|
-
|
|
548
|
+
```ts
|
|
549
|
+
import { openBunDatabase } from '@syncular/client/bun';
|
|
550
|
+
import { openNodeDatabase } from '@syncular/client/node';
|
|
538
551
|
```
|
|
539
552
|
|
|
540
|
-
|
|
541
|
-
better-sqlite3 (`ERR_DLOPEN_FAILED`,
|
|
542
|
-
[oven-sh/bun#4290](https://github.com/oven-sh/bun/issues/4290)); calling
|
|
543
|
-
`openNodeDatabase()` under bun deliberately raises the same helpful error and
|
|
544
|
-
points you at `./bun` instead. So the bun test suite
|
|
545
|
-
(`test/node-database.test.ts`) proves what it can under bun — type/subpath
|
|
546
|
-
conformance, the missing-peer error, and that the shared behavioral contract
|
|
547
|
-
(`test/node-database/adapter-contract.ts`) passes on the reference bun:sqlite
|
|
548
|
-
backend — while the better-sqlite3 adapter's real behavior is proven under
|
|
549
|
-
**Node** against the actual native module by running that same contract:
|
|
553
|
+
The source and packed-package runtime contracts run under actual Node and Bun:
|
|
550
554
|
|
|
551
555
|
```sh
|
|
552
|
-
|
|
553
|
-
bun run verify:
|
|
556
|
+
bun run verify:runtimes
|
|
557
|
+
bun run build:packages && bun run verify:packages
|
|
554
558
|
```
|
|
555
559
|
|
|
556
|
-
That bundles the verifier with bun (transpile + resolve only — bun never
|
|
557
|
-
executes the native module) and runs the plain-JS bundle under Node, which
|
|
558
|
-
exercises `openNodeDatabase` against real better-sqlite3 and exits non-zero on
|
|
559
|
-
any divergence from the contract.
|
|
560
|
-
|
|
561
560
|
## RPC protocol (6 message types)
|
|
562
561
|
|
|
563
562
|
`init`, `call`, `ready`, `result`, `error`, `event` — every API method
|
|
@@ -573,8 +572,9 @@ they own their buffer.
|
|
|
573
572
|
| `.` | protocol core, transports, handle + RPC protocol (browser-safe, no SQLite) |
|
|
574
573
|
| `./worker` | `startSyncWorker` — worker-side bootstrap (pulls sqlite-wasm) |
|
|
575
574
|
| `./wasm` | sqlite-wasm bindings: `openPersistentWasmDatabase`, `openWasmDatabase` |
|
|
576
|
-
| `./
|
|
577
|
-
| `./
|
|
575
|
+
| `./sqlite` | Runtime-selected SQLite: `openSqliteDatabase` on Node or Bun |
|
|
576
|
+
| `./bun` | Explicit `bun:sqlite` binding: `openBunDatabase` |
|
|
577
|
+
| `./node` | Explicit built-in `node:sqlite` binding: `openNodeDatabase` |
|
|
578
578
|
|
|
579
579
|
Tests drive the real worker entry in a bun `Worker` with bun:sqlite
|
|
580
580
|
injected through the bootstrap's database-factory override
|
package/dist/bun-database.d.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { Database } from 'bun:sqlite';
|
|
7
7
|
import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
|
|
8
|
+
declare module 'bun:sqlite' {
|
|
9
|
+
interface Database {
|
|
10
|
+
clearQueryCache(): void;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
8
13
|
export declare class BunClientDatabase implements ClientDatabase {
|
|
9
14
|
#private;
|
|
10
15
|
readonly db: Database;
|
package/dist/bun-database.js
CHANGED
|
@@ -23,6 +23,11 @@ export class BunClientDatabase {
|
|
|
23
23
|
}
|
|
24
24
|
exec(sql, params = []) {
|
|
25
25
|
this.db.query(sql).run(...coerceParams(params));
|
|
26
|
+
// `Database.query()` caches prepared statements. Clear that cache after
|
|
27
|
+
// schema DDL so a reset does not reprepare every later row upsert.
|
|
28
|
+
if (/^\s*(?:CREATE|DROP|ALTER)\b/i.test(sql)) {
|
|
29
|
+
this.db.clearQueryCache();
|
|
30
|
+
}
|
|
26
31
|
}
|
|
27
32
|
query(sql, params = []) {
|
|
28
33
|
return this.db.query(sql).all(...coerceParams(params));
|
package/dist/client.d.ts
CHANGED
|
@@ -285,6 +285,10 @@ export declare class SyncClient {
|
|
|
285
285
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
286
286
|
/** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
|
|
287
287
|
onChange(listener: ClientChangeListener): () => void;
|
|
288
|
+
/** Subscribe to host wake signals raised by startup and realtime. */
|
|
289
|
+
onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
|
|
290
|
+
/** Subscribe to exact core-owned scheduling instructions. */
|
|
291
|
+
onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
|
|
288
292
|
/** Subscribe to complete, privacy-safe diagnostic snapshots. */
|
|
289
293
|
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
290
294
|
/**
|
package/dist/client.js
CHANGED
|
@@ -71,6 +71,7 @@ function emptySummary(pushed) {
|
|
|
71
71
|
failed: [],
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
|
+
const LOG_EPOCH_META_KEY = 'logEpoch';
|
|
74
75
|
function isFinalPushResult(frame) {
|
|
75
76
|
return (frame.status !== 'rejected' ||
|
|
76
77
|
!frame.results.some((result) => result.status === 'error' &&
|
|
@@ -131,6 +132,8 @@ export class SyncClient {
|
|
|
131
132
|
#invalidation = new InvalidationEmitter();
|
|
132
133
|
/** §8.6: subscribable presence-change listeners (twin of onPresence). */
|
|
133
134
|
#presenceListeners = new Set();
|
|
135
|
+
#syncNeededListeners = new Set();
|
|
136
|
+
#syncIntentListeners = new Set();
|
|
134
137
|
#diagnostics = new ClientDiagnosticsEmitter();
|
|
135
138
|
#diagnosticsDeferralDepth = 0;
|
|
136
139
|
#diagnosticsPending = false;
|
|
@@ -233,8 +236,8 @@ export class SyncClient {
|
|
|
233
236
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
234
237
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
235
238
|
this.#needsPull = true;
|
|
236
|
-
this.#
|
|
237
|
-
this.#
|
|
239
|
+
this.#emitSyncNeeded('startup');
|
|
240
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
238
241
|
}
|
|
239
242
|
// Console introspection is a no-op outside a dev page.
|
|
240
243
|
this.#devtoolsUnregister = registerDevtools({
|
|
@@ -298,6 +301,29 @@ export class SyncClient {
|
|
|
298
301
|
this.#setSchemaFloor(undefined);
|
|
299
302
|
this.#replayOutbox();
|
|
300
303
|
}
|
|
304
|
+
/** §2.1 reset after the server reports a different log continuity. */
|
|
305
|
+
#runLogEpochReset(logEpoch) {
|
|
306
|
+
const subscriptions = loadSubscriptions(this.#db);
|
|
307
|
+
const pending = listOutbox(this.#db);
|
|
308
|
+
this.#setUpgrading(true);
|
|
309
|
+
this.#applyBatch((batch) => {
|
|
310
|
+
this.#db.transaction(() => {
|
|
311
|
+
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
312
|
+
resetSubscriptionsForBump(this.#db);
|
|
313
|
+
setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
|
|
314
|
+
for (const commit of pending) {
|
|
315
|
+
this.#applyOperationsLocally(commit.operations, batch);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
for (const table of this.#schema.tables.values())
|
|
319
|
+
batch.table(table.name);
|
|
320
|
+
});
|
|
321
|
+
this.#localResetEpoch += 1;
|
|
322
|
+
this.#setSyncNeeded(true);
|
|
323
|
+
this.#emitSyncNeeded('startup');
|
|
324
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
325
|
+
return subscriptions.map((subscription) => subscription.id);
|
|
326
|
+
}
|
|
301
327
|
#setUpgrading(upgrading) {
|
|
302
328
|
if (this.#upgrading === upgrading)
|
|
303
329
|
return;
|
|
@@ -325,6 +351,7 @@ export class SyncClient {
|
|
|
325
351
|
});
|
|
326
352
|
}
|
|
327
353
|
async close() {
|
|
354
|
+
this.#emitSyncIntent({ kind: 'none' });
|
|
328
355
|
this.#devtoolsUnregister?.();
|
|
329
356
|
this.#devtoolsUnregister = undefined;
|
|
330
357
|
this.disconnectRealtime();
|
|
@@ -332,6 +359,40 @@ export class SyncClient {
|
|
|
332
359
|
await this.#lease?.release();
|
|
333
360
|
this.#lease = undefined;
|
|
334
361
|
this.#started = false;
|
|
362
|
+
this.#syncNeededListeners.clear();
|
|
363
|
+
this.#syncIntentListeners.clear();
|
|
364
|
+
}
|
|
365
|
+
#emitSyncNeeded(reason) {
|
|
366
|
+
try {
|
|
367
|
+
this.#config.onSyncNeeded?.(reason);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// An observer cannot alter sync correctness.
|
|
371
|
+
}
|
|
372
|
+
for (const listener of this.#syncNeededListeners) {
|
|
373
|
+
try {
|
|
374
|
+
listener(reason);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// An observer cannot alter sync correctness.
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
#emitSyncIntent(intent) {
|
|
382
|
+
try {
|
|
383
|
+
this.#config.onSyncIntent?.(intent);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
// An observer cannot alter sync correctness.
|
|
387
|
+
}
|
|
388
|
+
for (const listener of this.#syncIntentListeners) {
|
|
389
|
+
try {
|
|
390
|
+
listener(intent);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// An observer cannot alter sync correctness.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
335
396
|
}
|
|
336
397
|
/** Current fail-closed local-replica security state. */
|
|
337
398
|
get securityLifecycle() {
|
|
@@ -382,8 +443,8 @@ export class SyncClient {
|
|
|
382
443
|
loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
|
|
383
444
|
if (startupWork) {
|
|
384
445
|
this.#setSyncNeeded(true);
|
|
385
|
-
this.#
|
|
386
|
-
this.#
|
|
446
|
+
this.#emitSyncNeeded('startup');
|
|
447
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
387
448
|
}
|
|
388
449
|
this.#emitDiagnostics();
|
|
389
450
|
}
|
|
@@ -479,6 +540,20 @@ export class SyncClient {
|
|
|
479
540
|
onChange(listener) {
|
|
480
541
|
return this.#changes.on(listener);
|
|
481
542
|
}
|
|
543
|
+
/** Subscribe to host wake signals raised by startup and realtime. */
|
|
544
|
+
onSyncNeeded(listener) {
|
|
545
|
+
this.#syncNeededListeners.add(listener);
|
|
546
|
+
return () => {
|
|
547
|
+
this.#syncNeededListeners.delete(listener);
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/** Subscribe to exact core-owned scheduling instructions. */
|
|
551
|
+
onSyncIntent(listener) {
|
|
552
|
+
this.#syncIntentListeners.add(listener);
|
|
553
|
+
return () => {
|
|
554
|
+
this.#syncIntentListeners.delete(listener);
|
|
555
|
+
};
|
|
556
|
+
}
|
|
482
557
|
/** Subscribe to complete, privacy-safe diagnostic snapshots. */
|
|
483
558
|
onDiagnostics(listener) {
|
|
484
559
|
return this.#diagnostics.on(listener);
|
|
@@ -1119,11 +1194,17 @@ export class SyncClient {
|
|
|
1119
1194
|
cursor: -1,
|
|
1120
1195
|
status: 'active',
|
|
1121
1196
|
});
|
|
1197
|
+
this.#setSyncNeeded(true);
|
|
1198
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1122
1199
|
this.#emitDiagnostics();
|
|
1123
1200
|
}
|
|
1124
1201
|
unsubscribe(id) {
|
|
1125
1202
|
this.#requireActive();
|
|
1203
|
+
if (getSubscription(this.#db, id) === undefined)
|
|
1204
|
+
return;
|
|
1126
1205
|
deleteSubscription(this.#db, id);
|
|
1206
|
+
this.#setSyncNeeded(true);
|
|
1207
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1127
1208
|
this.#emitDiagnostics();
|
|
1128
1209
|
}
|
|
1129
1210
|
// -- windowed subscriptions (§4.8) ------------------------------------------
|
|
@@ -1179,7 +1260,9 @@ export class SyncClient {
|
|
|
1179
1260
|
status: 'active',
|
|
1180
1261
|
});
|
|
1181
1262
|
});
|
|
1263
|
+
this.#needsPull = true;
|
|
1182
1264
|
batch.window(baseKey, base.table, unit);
|
|
1265
|
+
batch.status();
|
|
1183
1266
|
});
|
|
1184
1267
|
changed = true;
|
|
1185
1268
|
widened = true;
|
|
@@ -1195,6 +1278,9 @@ export class SyncClient {
|
|
|
1195
1278
|
const effects = {
|
|
1196
1279
|
sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
|
|
1197
1280
|
};
|
|
1281
|
+
if (effects.sync.kind === 'interactive') {
|
|
1282
|
+
this.#emitSyncIntent(effects.sync);
|
|
1283
|
+
}
|
|
1198
1284
|
return { value: undefined, effects };
|
|
1199
1285
|
}
|
|
1200
1286
|
/**
|
|
@@ -1254,6 +1340,8 @@ export class SyncClient {
|
|
|
1254
1340
|
});
|
|
1255
1341
|
batch.scopeMap(table, effective);
|
|
1256
1342
|
batch.window(baseKey, table.name, unit);
|
|
1343
|
+
this.#needsPull = true;
|
|
1344
|
+
batch.status();
|
|
1257
1345
|
});
|
|
1258
1346
|
}
|
|
1259
1347
|
/**
|
|
@@ -1349,7 +1437,9 @@ export class SyncClient {
|
|
|
1349
1437
|
this.#applyOperationsLocally(operations, batch);
|
|
1350
1438
|
batch.status();
|
|
1351
1439
|
});
|
|
1440
|
+
this.#needsPull = true;
|
|
1352
1441
|
});
|
|
1442
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1353
1443
|
return clientCommitId;
|
|
1354
1444
|
}
|
|
1355
1445
|
/** Host-facing mutation result with explicit network work intent (§7.5). */
|
|
@@ -1577,8 +1667,8 @@ export class SyncClient {
|
|
|
1577
1667
|
this.#localResetEpoch += 1;
|
|
1578
1668
|
if (!priorUpgrading)
|
|
1579
1669
|
this.#config.onUpgrading?.(true);
|
|
1580
|
-
this.#
|
|
1581
|
-
this.#
|
|
1670
|
+
this.#emitSyncNeeded('startup');
|
|
1671
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1582
1672
|
return {
|
|
1583
1673
|
alreadyApplied: false,
|
|
1584
1674
|
retainedCommits: pending.length,
|
|
@@ -1798,9 +1888,12 @@ export class SyncClient {
|
|
|
1798
1888
|
// survive it — the reference server keeps no replay buffer (§8.2).
|
|
1799
1889
|
this.#setSyncNeeded(false);
|
|
1800
1890
|
try {
|
|
1891
|
+
const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
1801
1892
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
1802
1893
|
// them, so the server-side existence check (§6.6) passes.
|
|
1803
|
-
if (
|
|
1894
|
+
if (logEpoch !== undefined &&
|
|
1895
|
+
this.#hasBlobs &&
|
|
1896
|
+
this.#config.blobs !== undefined) {
|
|
1804
1897
|
await this.flushBlobUploads();
|
|
1805
1898
|
}
|
|
1806
1899
|
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
@@ -1808,7 +1901,9 @@ export class SyncClient {
|
|
|
1808
1901
|
// is removed from the push and surfaced as a rejection, never wedging
|
|
1809
1902
|
// the queue. `pushFrames` and `outbox` stay index-aligned for result
|
|
1810
1903
|
// mapping.
|
|
1811
|
-
const { pushFrames, outbox, deferred } =
|
|
1904
|
+
const { pushFrames, outbox, deferred } = logEpoch === undefined
|
|
1905
|
+
? { pushFrames: [], outbox: [], deferred: 0 }
|
|
1906
|
+
: await this.#encodeOutboxForPush();
|
|
1812
1907
|
// Captured together with the subscription state below: the response
|
|
1813
1908
|
// apply persists SUB_END cursors only while this epoch is current.
|
|
1814
1909
|
const resetEpoch = this.#localResetEpoch;
|
|
@@ -1819,6 +1914,7 @@ export class SyncClient {
|
|
|
1819
1914
|
type: 'REQ_HEADER',
|
|
1820
1915
|
clientId: this.#clientId,
|
|
1821
1916
|
schemaVersion: this.#schema.version,
|
|
1917
|
+
...(logEpoch !== undefined ? { logEpoch } : {}),
|
|
1822
1918
|
},
|
|
1823
1919
|
...pushFrames,
|
|
1824
1920
|
{
|
|
@@ -1885,12 +1981,7 @@ export class SyncClient {
|
|
|
1885
1981
|
delayMs: this.#retryDelayMs,
|
|
1886
1982
|
};
|
|
1887
1983
|
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
|
|
1888
|
-
|
|
1889
|
-
this.#config.onSyncIntent?.(intent);
|
|
1890
|
-
}
|
|
1891
|
-
catch {
|
|
1892
|
-
// An observer cannot alter sync correctness.
|
|
1893
|
-
}
|
|
1984
|
+
this.#emitSyncIntent(intent);
|
|
1894
1985
|
}
|
|
1895
1986
|
throw error;
|
|
1896
1987
|
}
|
|
@@ -2078,14 +2169,14 @@ export class SyncClient {
|
|
|
2078
2169
|
if (event.event === 'hello') {
|
|
2079
2170
|
if (event.data.requiresSync) {
|
|
2080
2171
|
this.#setSyncNeeded(true);
|
|
2081
|
-
this.#
|
|
2172
|
+
this.#emitSyncNeeded('hello');
|
|
2082
2173
|
}
|
|
2083
2174
|
return;
|
|
2084
2175
|
}
|
|
2085
2176
|
if (event.event === 'sync') {
|
|
2086
2177
|
// §8.3: any wake-up means "run a pull soon", never data.
|
|
2087
2178
|
this.#setSyncNeeded(true);
|
|
2088
|
-
this.#
|
|
2179
|
+
this.#emitSyncNeeded(event.data.reason);
|
|
2089
2180
|
return;
|
|
2090
2181
|
}
|
|
2091
2182
|
if (event.event === 'presence') {
|
|
@@ -2155,7 +2246,7 @@ export class SyncClient {
|
|
|
2155
2246
|
catch {
|
|
2156
2247
|
// A delta that cannot be applied is recovered by a pull (§8.3).
|
|
2157
2248
|
this.#setSyncNeeded(true);
|
|
2158
|
-
this.#
|
|
2249
|
+
this.#emitSyncNeeded('catchup-required');
|
|
2159
2250
|
}
|
|
2160
2251
|
});
|
|
2161
2252
|
}
|
|
@@ -2195,6 +2286,11 @@ export class SyncClient {
|
|
|
2195
2286
|
if (header?.type !== 'RESP_HEADER') {
|
|
2196
2287
|
throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
|
|
2197
2288
|
}
|
|
2289
|
+
if (message.wireVersion < 2 ||
|
|
2290
|
+
header.logEpoch === undefined ||
|
|
2291
|
+
header.resetRequired === undefined) {
|
|
2292
|
+
throw new ClientSyncError('client.invalid_host_response', 'the server response does not carry wire version 2 log-epoch state');
|
|
2293
|
+
}
|
|
2198
2294
|
if (header.requiredSchemaVersion !== undefined) {
|
|
2199
2295
|
// §1.6 schema floor: nothing else was processed — stop syncing and
|
|
2200
2296
|
// surface the upgrade requirement. A live-round floor always stops:
|
|
@@ -2216,6 +2312,20 @@ export class SyncClient {
|
|
|
2216
2312
|
schemaFloor,
|
|
2217
2313
|
};
|
|
2218
2314
|
}
|
|
2315
|
+
const currentLogEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
2316
|
+
if (header.resetRequired) {
|
|
2317
|
+
if (mode !== 'pull' || message.frames.length !== 1) {
|
|
2318
|
+
throw new ClientSyncError('client.invalid_host_response', 'a log-epoch reset response must contain only RESP_HEADER');
|
|
2319
|
+
}
|
|
2320
|
+
return {
|
|
2321
|
+
...summary,
|
|
2322
|
+
resets: this.#runLogEpochReset(header.logEpoch),
|
|
2323
|
+
bootstrapping: [],
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
if (currentLogEpoch === undefined || currentLogEpoch !== header.logEpoch) {
|
|
2327
|
+
throw new ClientSyncError('client.invalid_host_response', 'the server changed logEpoch without requiring a reset');
|
|
2328
|
+
}
|
|
2219
2329
|
let section;
|
|
2220
2330
|
let errorFrame;
|
|
2221
2331
|
let deltaCursor = -1;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* SPEC.md is normative.
|
|
4
4
|
*
|
|
5
5
|
* Browser-safe root: database backends live behind subpath exports
|
|
6
|
-
* (`./
|
|
6
|
+
* (`./sqlite` for Node or Bun, `./wasm` for sqlite-wasm + OPFS); the
|
|
7
7
|
* worker-side bootstrap lives behind `./worker`. The main-thread handle
|
|
8
8
|
* (`worker-host`) and the RPC protocol types are root exports — they
|
|
9
9
|
* import no SQLite.
|
|
@@ -35,6 +35,7 @@ export * from './realtime-supervisor.js';
|
|
|
35
35
|
export * from './schema.js';
|
|
36
36
|
export * from './sql-tag.js';
|
|
37
37
|
export * from './state.js';
|
|
38
|
+
export * from './sync-scheduler.js';
|
|
38
39
|
export * from './transport.js';
|
|
39
40
|
export * from './window.js';
|
|
40
41
|
export * from './worker-host.js';
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* SPEC.md is normative.
|
|
4
4
|
*
|
|
5
5
|
* Browser-safe root: database backends live behind subpath exports
|
|
6
|
-
* (`./
|
|
6
|
+
* (`./sqlite` for Node or Bun, `./wasm` for sqlite-wasm + OPFS); the
|
|
7
7
|
* worker-side bootstrap lives behind `./worker`. The main-thread handle
|
|
8
8
|
* (`worker-host`) and the RPC protocol types are root exports — they
|
|
9
9
|
* import no SQLite.
|
|
@@ -35,6 +35,7 @@ export * from './realtime-supervisor.js';
|
|
|
35
35
|
export * from './schema.js';
|
|
36
36
|
export * from './sql-tag.js';
|
|
37
37
|
export * from './state.js';
|
|
38
|
+
export * from './sync-scheduler.js';
|
|
38
39
|
export * from './transport.js';
|
|
39
40
|
export * from './window.js';
|
|
40
41
|
export * from './worker-host.js';
|
package/dist/node-database.d.ts
CHANGED
|
@@ -1,41 +1,18 @@
|
|
|
1
|
-
import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* package typechecks without the optional peer installed.
|
|
2
|
+
* `ClientDatabase` on Node's built-in `node:sqlite`. Semantics mirror the Bun
|
|
3
|
+
* adapter: synchronous queries, nested transactions, and SQLite image attach.
|
|
6
4
|
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
all(...params: NodeParam[]): unknown[];
|
|
10
|
-
}
|
|
11
|
-
interface BetterSqliteDatabase {
|
|
12
|
-
readonly inTransaction: boolean;
|
|
13
|
-
prepare(sql: string): BetterSqliteStatement;
|
|
14
|
-
exec(sql: string): unknown;
|
|
15
|
-
close(): void;
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* better-sqlite3 accepts string / number / bigint / null / Buffer|Uint8Array
|
|
19
|
-
* bind values, but NOT booleans (it throws "TypeError: can only bind …"). We
|
|
20
|
-
* coerce booleans to 0/1 exactly like the bun adapter so callers see one
|
|
21
|
-
* uniform bind contract across every backend.
|
|
22
|
-
*/
|
|
23
|
-
type NodeParam = string | number | bigint | Uint8Array | null;
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
|
|
24
7
|
export declare class NodeClientDatabase implements ClientDatabase {
|
|
25
8
|
#private;
|
|
26
|
-
readonly db:
|
|
9
|
+
readonly db: DatabaseSync;
|
|
27
10
|
constructor(path?: string);
|
|
28
11
|
exec(sql: string, params?: readonly SqlValue[]): void;
|
|
29
12
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
30
13
|
transaction<T>(fn: () => T): T;
|
|
31
|
-
/**
|
|
32
|
-
* §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
|
|
33
|
-
* buffers, so the image lands in a private temp file for the duration of
|
|
34
|
-
* the ATTACH. Must be called outside any open transaction (SQLite cannot
|
|
35
|
-
* ATTACH inside one).
|
|
36
|
-
*/
|
|
14
|
+
/** §5.3 image import through a private file attached for one callback. */
|
|
37
15
|
withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T;
|
|
38
16
|
close(): void;
|
|
39
17
|
}
|
|
40
18
|
export declare function openNodeDatabase(path?: string): ClientDatabase;
|
|
41
|
-
export {};
|