@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/src/client.ts
CHANGED
|
@@ -513,6 +513,8 @@ function emptySummary(pushed: number): MutableSummary {
|
|
|
513
513
|
};
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
+
const LOG_EPOCH_META_KEY = 'logEpoch';
|
|
517
|
+
|
|
516
518
|
function isFinalPushResult(frame: PushResultFrame): boolean {
|
|
517
519
|
return (
|
|
518
520
|
frame.status !== 'rejected' ||
|
|
@@ -579,6 +581,10 @@ export class SyncClient {
|
|
|
579
581
|
readonly #invalidation = new InvalidationEmitter();
|
|
580
582
|
/** §8.6: subscribable presence-change listeners (twin of onPresence). */
|
|
581
583
|
readonly #presenceListeners = new Set<(scopeKey: string) => void>();
|
|
584
|
+
readonly #syncNeededListeners = new Set<
|
|
585
|
+
(reason: 'startup' | 'hello' | WakeReason) => void
|
|
586
|
+
>();
|
|
587
|
+
readonly #syncIntentListeners = new Set<(intent: SyncIntent) => void>();
|
|
582
588
|
readonly #diagnostics = new ClientDiagnosticsEmitter();
|
|
583
589
|
#diagnosticsDeferralDepth = 0;
|
|
584
590
|
#diagnosticsPending = false;
|
|
@@ -705,8 +711,8 @@ export class SyncClient {
|
|
|
705
711
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
706
712
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
707
713
|
this.#needsPull = true;
|
|
708
|
-
this.#
|
|
709
|
-
this.#
|
|
714
|
+
this.#emitSyncNeeded('startup');
|
|
715
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
710
716
|
}
|
|
711
717
|
// Console introspection is a no-op outside a dev page.
|
|
712
718
|
this.#devtoolsUnregister = registerDevtools({
|
|
@@ -776,6 +782,29 @@ export class SyncClient {
|
|
|
776
782
|
this.#replayOutbox();
|
|
777
783
|
}
|
|
778
784
|
|
|
785
|
+
/** §2.1 reset after the server reports a different log continuity. */
|
|
786
|
+
#runLogEpochReset(logEpoch: string): string[] {
|
|
787
|
+
const subscriptions = loadSubscriptions(this.#db);
|
|
788
|
+
const pending = listOutbox(this.#db);
|
|
789
|
+
this.#setUpgrading(true);
|
|
790
|
+
this.#applyBatch((batch) => {
|
|
791
|
+
this.#db.transaction(() => {
|
|
792
|
+
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
793
|
+
resetSubscriptionsForBump(this.#db);
|
|
794
|
+
setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
|
|
795
|
+
for (const commit of pending) {
|
|
796
|
+
this.#applyOperationsLocally(commit.operations, batch);
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
for (const table of this.#schema.tables.values()) batch.table(table.name);
|
|
800
|
+
});
|
|
801
|
+
this.#localResetEpoch += 1;
|
|
802
|
+
this.#setSyncNeeded(true);
|
|
803
|
+
this.#emitSyncNeeded('startup');
|
|
804
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
805
|
+
return subscriptions.map((subscription) => subscription.id);
|
|
806
|
+
}
|
|
807
|
+
|
|
779
808
|
#setUpgrading(upgrading: boolean): void {
|
|
780
809
|
if (this.#upgrading === upgrading) return;
|
|
781
810
|
this.#applyBatch((batch) => {
|
|
@@ -803,6 +832,7 @@ export class SyncClient {
|
|
|
803
832
|
}
|
|
804
833
|
|
|
805
834
|
async close(): Promise<void> {
|
|
835
|
+
this.#emitSyncIntent({ kind: 'none' });
|
|
806
836
|
this.#devtoolsUnregister?.();
|
|
807
837
|
this.#devtoolsUnregister = undefined;
|
|
808
838
|
this.disconnectRealtime();
|
|
@@ -810,6 +840,38 @@ export class SyncClient {
|
|
|
810
840
|
await this.#lease?.release();
|
|
811
841
|
this.#lease = undefined;
|
|
812
842
|
this.#started = false;
|
|
843
|
+
this.#syncNeededListeners.clear();
|
|
844
|
+
this.#syncIntentListeners.clear();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
#emitSyncNeeded(reason: 'startup' | 'hello' | WakeReason): void {
|
|
848
|
+
try {
|
|
849
|
+
this.#config.onSyncNeeded?.(reason);
|
|
850
|
+
} catch {
|
|
851
|
+
// An observer cannot alter sync correctness.
|
|
852
|
+
}
|
|
853
|
+
for (const listener of this.#syncNeededListeners) {
|
|
854
|
+
try {
|
|
855
|
+
listener(reason);
|
|
856
|
+
} catch {
|
|
857
|
+
// An observer cannot alter sync correctness.
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
#emitSyncIntent(intent: SyncIntent): void {
|
|
863
|
+
try {
|
|
864
|
+
this.#config.onSyncIntent?.(intent);
|
|
865
|
+
} catch {
|
|
866
|
+
// An observer cannot alter sync correctness.
|
|
867
|
+
}
|
|
868
|
+
for (const listener of this.#syncIntentListeners) {
|
|
869
|
+
try {
|
|
870
|
+
listener(intent);
|
|
871
|
+
} catch {
|
|
872
|
+
// An observer cannot alter sync correctness.
|
|
873
|
+
}
|
|
874
|
+
}
|
|
813
875
|
}
|
|
814
876
|
|
|
815
877
|
/** Current fail-closed local-replica security state. */
|
|
@@ -869,8 +931,8 @@ export class SyncClient {
|
|
|
869
931
|
loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
|
|
870
932
|
if (startupWork) {
|
|
871
933
|
this.#setSyncNeeded(true);
|
|
872
|
-
this.#
|
|
873
|
-
this.#
|
|
934
|
+
this.#emitSyncNeeded('startup');
|
|
935
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
874
936
|
}
|
|
875
937
|
this.#emitDiagnostics();
|
|
876
938
|
}
|
|
@@ -982,6 +1044,24 @@ export class SyncClient {
|
|
|
982
1044
|
return this.#changes.on(listener);
|
|
983
1045
|
}
|
|
984
1046
|
|
|
1047
|
+
/** Subscribe to host wake signals raised by startup and realtime. */
|
|
1048
|
+
onSyncNeeded(
|
|
1049
|
+
listener: (reason: 'startup' | 'hello' | WakeReason) => void,
|
|
1050
|
+
): () => void {
|
|
1051
|
+
this.#syncNeededListeners.add(listener);
|
|
1052
|
+
return () => {
|
|
1053
|
+
this.#syncNeededListeners.delete(listener);
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Subscribe to exact core-owned scheduling instructions. */
|
|
1058
|
+
onSyncIntent(listener: (intent: SyncIntent) => void): () => void {
|
|
1059
|
+
this.#syncIntentListeners.add(listener);
|
|
1060
|
+
return () => {
|
|
1061
|
+
this.#syncIntentListeners.delete(listener);
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
985
1065
|
/** Subscribe to complete, privacy-safe diagnostic snapshots. */
|
|
986
1066
|
onDiagnostics(listener: ClientDiagnosticsListener): () => void {
|
|
987
1067
|
return this.#diagnostics.on(listener);
|
|
@@ -1781,12 +1861,17 @@ export class SyncClient {
|
|
|
1781
1861
|
cursor: -1,
|
|
1782
1862
|
status: 'active',
|
|
1783
1863
|
});
|
|
1864
|
+
this.#setSyncNeeded(true);
|
|
1865
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1784
1866
|
this.#emitDiagnostics();
|
|
1785
1867
|
}
|
|
1786
1868
|
|
|
1787
1869
|
unsubscribe(id: string): void {
|
|
1788
1870
|
this.#requireActive();
|
|
1871
|
+
if (getSubscription(this.#db, id) === undefined) return;
|
|
1789
1872
|
deleteSubscription(this.#db, id);
|
|
1873
|
+
this.#setSyncNeeded(true);
|
|
1874
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1790
1875
|
this.#emitDiagnostics();
|
|
1791
1876
|
}
|
|
1792
1877
|
|
|
@@ -1851,7 +1936,9 @@ export class SyncClient {
|
|
|
1851
1936
|
status: 'active',
|
|
1852
1937
|
});
|
|
1853
1938
|
});
|
|
1939
|
+
this.#needsPull = true;
|
|
1854
1940
|
batch.window(baseKey, base.table, unit);
|
|
1941
|
+
batch.status();
|
|
1855
1942
|
});
|
|
1856
1943
|
changed = true;
|
|
1857
1944
|
widened = true;
|
|
@@ -1867,6 +1954,9 @@ export class SyncClient {
|
|
|
1867
1954
|
const effects: CommandEffects = {
|
|
1868
1955
|
sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
|
|
1869
1956
|
};
|
|
1957
|
+
if (effects.sync.kind === 'interactive') {
|
|
1958
|
+
this.#emitSyncIntent(effects.sync);
|
|
1959
|
+
}
|
|
1870
1960
|
return { value: undefined, effects };
|
|
1871
1961
|
}
|
|
1872
1962
|
|
|
@@ -1934,6 +2024,8 @@ export class SyncClient {
|
|
|
1934
2024
|
});
|
|
1935
2025
|
batch.scopeMap(table, effective);
|
|
1936
2026
|
batch.window(baseKey, table.name, unit);
|
|
2027
|
+
this.#needsPull = true;
|
|
2028
|
+
batch.status();
|
|
1937
2029
|
});
|
|
1938
2030
|
}
|
|
1939
2031
|
|
|
@@ -2043,7 +2135,9 @@ export class SyncClient {
|
|
|
2043
2135
|
this.#applyOperationsLocally(operations, batch);
|
|
2044
2136
|
batch.status();
|
|
2045
2137
|
});
|
|
2138
|
+
this.#needsPull = true;
|
|
2046
2139
|
});
|
|
2140
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
2047
2141
|
return clientCommitId;
|
|
2048
2142
|
}
|
|
2049
2143
|
|
|
@@ -2320,8 +2414,8 @@ export class SyncClient {
|
|
|
2320
2414
|
this.#localResetEpoch += 1;
|
|
2321
2415
|
|
|
2322
2416
|
if (!priorUpgrading) this.#config.onUpgrading?.(true);
|
|
2323
|
-
this.#
|
|
2324
|
-
this.#
|
|
2417
|
+
this.#emitSyncNeeded('startup');
|
|
2418
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
2325
2419
|
return {
|
|
2326
2420
|
alreadyApplied: false,
|
|
2327
2421
|
retainedCommits: pending.length,
|
|
@@ -2585,9 +2679,14 @@ export class SyncClient {
|
|
|
2585
2679
|
// survive it — the reference server keeps no replay buffer (§8.2).
|
|
2586
2680
|
this.#setSyncNeeded(false);
|
|
2587
2681
|
try {
|
|
2682
|
+
const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
2588
2683
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
2589
2684
|
// them, so the server-side existence check (§6.6) passes.
|
|
2590
|
-
if (
|
|
2685
|
+
if (
|
|
2686
|
+
logEpoch !== undefined &&
|
|
2687
|
+
this.#hasBlobs &&
|
|
2688
|
+
this.#config.blobs !== undefined
|
|
2689
|
+
) {
|
|
2591
2690
|
await this.flushBlobUploads();
|
|
2592
2691
|
}
|
|
2593
2692
|
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
@@ -2596,7 +2695,9 @@ export class SyncClient {
|
|
|
2596
2695
|
// the queue. `pushFrames` and `outbox` stay index-aligned for result
|
|
2597
2696
|
// mapping.
|
|
2598
2697
|
const { pushFrames, outbox, deferred } =
|
|
2599
|
-
|
|
2698
|
+
logEpoch === undefined
|
|
2699
|
+
? { pushFrames: [], outbox: [], deferred: 0 }
|
|
2700
|
+
: await this.#encodeOutboxForPush();
|
|
2600
2701
|
// Captured together with the subscription state below: the response
|
|
2601
2702
|
// apply persists SUB_END cursors only while this epoch is current.
|
|
2602
2703
|
const resetEpoch = this.#localResetEpoch;
|
|
@@ -2609,6 +2710,7 @@ export class SyncClient {
|
|
|
2609
2710
|
type: 'REQ_HEADER',
|
|
2610
2711
|
clientId: this.#clientId,
|
|
2611
2712
|
schemaVersion: this.#schema.version,
|
|
2713
|
+
...(logEpoch !== undefined ? { logEpoch } : {}),
|
|
2612
2714
|
},
|
|
2613
2715
|
...pushFrames,
|
|
2614
2716
|
{
|
|
@@ -2686,11 +2788,7 @@ export class SyncClient {
|
|
|
2686
2788
|
delayMs: this.#retryDelayMs,
|
|
2687
2789
|
};
|
|
2688
2790
|
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
|
|
2689
|
-
|
|
2690
|
-
this.#config.onSyncIntent?.(intent);
|
|
2691
|
-
} catch {
|
|
2692
|
-
// An observer cannot alter sync correctness.
|
|
2693
|
-
}
|
|
2791
|
+
this.#emitSyncIntent(intent);
|
|
2694
2792
|
}
|
|
2695
2793
|
throw error;
|
|
2696
2794
|
} finally {
|
|
@@ -2916,14 +3014,14 @@ export class SyncClient {
|
|
|
2916
3014
|
if (event.event === 'hello') {
|
|
2917
3015
|
if (event.data.requiresSync) {
|
|
2918
3016
|
this.#setSyncNeeded(true);
|
|
2919
|
-
this.#
|
|
3017
|
+
this.#emitSyncNeeded('hello');
|
|
2920
3018
|
}
|
|
2921
3019
|
return;
|
|
2922
3020
|
}
|
|
2923
3021
|
if (event.event === 'sync') {
|
|
2924
3022
|
// §8.3: any wake-up means "run a pull soon", never data.
|
|
2925
3023
|
this.#setSyncNeeded(true);
|
|
2926
|
-
this.#
|
|
3024
|
+
this.#emitSyncNeeded(event.data.reason);
|
|
2927
3025
|
return;
|
|
2928
3026
|
}
|
|
2929
3027
|
if (event.event === 'presence') {
|
|
@@ -2995,7 +3093,7 @@ export class SyncClient {
|
|
|
2995
3093
|
} catch {
|
|
2996
3094
|
// A delta that cannot be applied is recovered by a pull (§8.3).
|
|
2997
3095
|
this.#setSyncNeeded(true);
|
|
2998
|
-
this.#
|
|
3096
|
+
this.#emitSyncNeeded('catchup-required');
|
|
2999
3097
|
}
|
|
3000
3098
|
});
|
|
3001
3099
|
}
|
|
@@ -3057,6 +3155,16 @@ export class SyncClient {
|
|
|
3057
3155
|
if (header?.type !== 'RESP_HEADER') {
|
|
3058
3156
|
throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
|
|
3059
3157
|
}
|
|
3158
|
+
if (
|
|
3159
|
+
message.wireVersion < 2 ||
|
|
3160
|
+
header.logEpoch === undefined ||
|
|
3161
|
+
header.resetRequired === undefined
|
|
3162
|
+
) {
|
|
3163
|
+
throw new ClientSyncError(
|
|
3164
|
+
'client.invalid_host_response',
|
|
3165
|
+
'the server response does not carry wire version 2 log-epoch state',
|
|
3166
|
+
);
|
|
3167
|
+
}
|
|
3060
3168
|
if (header.requiredSchemaVersion !== undefined) {
|
|
3061
3169
|
// §1.6 schema floor: nothing else was processed — stop syncing and
|
|
3062
3170
|
// surface the upgrade requirement. A live-round floor always stops:
|
|
@@ -3078,6 +3186,26 @@ export class SyncClient {
|
|
|
3078
3186
|
schemaFloor,
|
|
3079
3187
|
};
|
|
3080
3188
|
}
|
|
3189
|
+
const currentLogEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
3190
|
+
if (header.resetRequired) {
|
|
3191
|
+
if (mode !== 'pull' || message.frames.length !== 1) {
|
|
3192
|
+
throw new ClientSyncError(
|
|
3193
|
+
'client.invalid_host_response',
|
|
3194
|
+
'a log-epoch reset response must contain only RESP_HEADER',
|
|
3195
|
+
);
|
|
3196
|
+
}
|
|
3197
|
+
return {
|
|
3198
|
+
...summary,
|
|
3199
|
+
resets: this.#runLogEpochReset(header.logEpoch),
|
|
3200
|
+
bootstrapping: [],
|
|
3201
|
+
};
|
|
3202
|
+
}
|
|
3203
|
+
if (currentLogEpoch === undefined || currentLogEpoch !== header.logEpoch) {
|
|
3204
|
+
throw new ClientSyncError(
|
|
3205
|
+
'client.invalid_host_response',
|
|
3206
|
+
'the server changed logEpoch without requiring a reset',
|
|
3207
|
+
);
|
|
3208
|
+
}
|
|
3081
3209
|
|
|
3082
3210
|
let section: OpenSection | undefined;
|
|
3083
3211
|
let errorFrame: ClientSyncError | undefined;
|
package/src/index.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';
|
|
|
35
35
|
export * from './schema';
|
|
36
36
|
export * from './sql-tag';
|
|
37
37
|
export * from './state';
|
|
38
|
+
export * from './sync-scheduler';
|
|
38
39
|
export * from './transport';
|
|
39
40
|
export * from './window';
|
|
40
41
|
export * from './worker-host';
|
package/src/node-database.ts
CHANGED
|
@@ -1,23 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `ClientDatabase` on
|
|
3
|
-
*
|
|
4
|
-
* exactly (synchronous exec/query/transaction with the shared savepoint
|
|
5
|
-
* helper, and the same §5.3 sqlite-image ATTACH path), so the core behaves
|
|
6
|
-
* identically whether it runs on bun:sqlite (tests), sqlite-wasm (browser)
|
|
7
|
-
* or better-sqlite3 (Node/Electron-main).
|
|
8
|
-
*
|
|
9
|
-
* better-sqlite3 is an OPTIONAL peer dependency, not a hard one: the package
|
|
10
|
-
* installs cleanly without it and this module errors helpfully only when a
|
|
11
|
-
* host actually calls `openNodeDatabase()` without having installed the peer.
|
|
12
|
-
* Not exported from the package root, so browser/bun entries never resolve
|
|
13
|
-
* the native module. Subpath export: `@syncular/client/node`.
|
|
14
|
-
*
|
|
15
|
-
* bun CANNOT dlopen better-sqlite3 (ERR_DLOPEN_FAILED, oven-sh/bun#4290), so
|
|
16
|
-
* this adapter is verified under real Node — see the README "Electron-main /
|
|
17
|
-
* plain-Node" section for the one-command recipe and `test/node-database`.
|
|
2
|
+
* `ClientDatabase` on Node's built-in `node:sqlite`. Semantics mirror the Bun
|
|
3
|
+
* adapter: synchronous queries, nested transactions, and SQLite image attach.
|
|
18
4
|
*/
|
|
5
|
+
import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
|
|
19
6
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
-
import { createRequire } from 'node:module';
|
|
21
7
|
import { tmpdir } from 'node:os';
|
|
22
8
|
import { join } from 'node:path';
|
|
23
9
|
import {
|
|
@@ -28,54 +14,19 @@ import {
|
|
|
28
14
|
type SqlValue,
|
|
29
15
|
} from './database';
|
|
30
16
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
* type it locally (rather than importing `better-sqlite3`'s types) so the
|
|
34
|
-
* package typechecks without the optional peer installed.
|
|
35
|
-
*/
|
|
36
|
-
interface BetterSqliteStatement {
|
|
37
|
-
run(...params: NodeParam[]): unknown;
|
|
38
|
-
all(...params: NodeParam[]): unknown[];
|
|
39
|
-
}
|
|
40
|
-
interface BetterSqliteDatabase {
|
|
41
|
-
readonly inTransaction: boolean;
|
|
42
|
-
prepare(sql: string): BetterSqliteStatement;
|
|
43
|
-
exec(sql: string): unknown;
|
|
44
|
-
close(): void;
|
|
45
|
-
}
|
|
46
|
-
type BetterSqliteConstructor = new (
|
|
47
|
-
path: string,
|
|
48
|
-
options?: { readonly?: boolean; fileMustExist?: boolean },
|
|
49
|
-
) => BetterSqliteDatabase;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* better-sqlite3 accepts string / number / bigint / null / Buffer|Uint8Array
|
|
53
|
-
* bind values, but NOT booleans (it throws "TypeError: can only bind …"). We
|
|
54
|
-
* coerce booleans to 0/1 exactly like the bun adapter so callers see one
|
|
55
|
-
* uniform bind contract across every backend.
|
|
56
|
-
*/
|
|
57
|
-
type NodeParam = string | number | bigint | Uint8Array | null;
|
|
58
|
-
|
|
59
|
-
function coerceParams(params: readonly SqlValue[]): NodeParam[] {
|
|
60
|
-
return params.map((value): NodeParam => {
|
|
17
|
+
function coerceParams(params: readonly SqlValue[]): SQLInputValue[] {
|
|
18
|
+
return params.map((value): SQLInputValue => {
|
|
61
19
|
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
62
20
|
return value;
|
|
63
21
|
});
|
|
64
22
|
}
|
|
65
23
|
|
|
66
|
-
/**
|
|
67
|
-
* better-sqlite3 returns BLOB columns as Node `Buffer`s. A Buffer IS a
|
|
68
|
-
* Uint8Array subclass, but it can be a view onto a shared pool buffer, so we
|
|
69
|
-
* normalize to a standalone Uint8Array — matching what bun:sqlite hands back
|
|
70
|
-
* and keeping the buffer-ownership assumptions elsewhere (worker transfer,
|
|
71
|
-
* structured clone) honest.
|
|
72
|
-
*/
|
|
73
24
|
function normalizeRow(row: Record<string, unknown>): SqlRow {
|
|
74
25
|
const out: SqlRow = {};
|
|
75
26
|
for (const key in row) {
|
|
76
27
|
const value = row[key];
|
|
77
|
-
if (
|
|
78
|
-
out[key] = new Uint8Array(value);
|
|
28
|
+
if (value instanceof Uint8Array) {
|
|
29
|
+
out[key] = new Uint8Array(value);
|
|
79
30
|
} else {
|
|
80
31
|
out[key] = value as SqlValue;
|
|
81
32
|
}
|
|
@@ -83,55 +34,12 @@ function normalizeRow(row: Record<string, unknown>): SqlRow {
|
|
|
83
34
|
return out;
|
|
84
35
|
}
|
|
85
36
|
|
|
86
|
-
/**
|
|
87
|
-
* Load the optional peer AND open the database in one guarded step, so BOTH
|
|
88
|
-
* failure modes are turned into a clear, actionable error rather than a raw
|
|
89
|
-
* one:
|
|
90
|
-
*
|
|
91
|
-
* - `require('better-sqlite3')` throwing MODULE_NOT_FOUND — the peer is not
|
|
92
|
-
* installed (the common browser-only-host case), and
|
|
93
|
-
* - `new Database()` throwing ERR_DLOPEN_FAILED — the module resolves but the
|
|
94
|
-
* native addon cannot load, which is exactly what bun does for
|
|
95
|
-
* better-sqlite3 (oven-sh/bun#4290); the addon only dlopens at construction.
|
|
96
|
-
*/
|
|
97
|
-
function openBetterSqlite(path: string): BetterSqliteDatabase {
|
|
98
|
-
const require = createRequire(import.meta.url);
|
|
99
|
-
try {
|
|
100
|
-
const mod = require('better-sqlite3') as
|
|
101
|
-
| BetterSqliteConstructor
|
|
102
|
-
| { default: BetterSqliteConstructor };
|
|
103
|
-
const Database =
|
|
104
|
-
(mod as { default?: BetterSqliteConstructor }).default ??
|
|
105
|
-
(mod as BetterSqliteConstructor);
|
|
106
|
-
return new Database(path);
|
|
107
|
-
} catch (error) {
|
|
108
|
-
const code = (error as { code?: string })?.code;
|
|
109
|
-
if (code === 'ERR_DLOPEN_FAILED') {
|
|
110
|
-
throw new Error(
|
|
111
|
-
"openNodeDatabase() requires the 'better-sqlite3' native module, but " +
|
|
112
|
-
'it failed to load. This most commonly means you are running under ' +
|
|
113
|
-
'bun, which cannot dlopen better-sqlite3 (oven-sh/bun#4290) — use ' +
|
|
114
|
-
"the bun:sqlite backend ('@syncular/client/bun') under bun, " +
|
|
115
|
-
"and reserve '@syncular/client/node' for Node/Electron-main. " +
|
|
116
|
-
`Underlying error: ${String(error)}`,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
throw new Error(
|
|
120
|
-
'openNodeDatabase() requires the optional peer dependency ' +
|
|
121
|
-
"'better-sqlite3', which is not installed. Add it to your app " +
|
|
122
|
-
'(`npm install better-sqlite3` / `bun add better-sqlite3`) — it is ' +
|
|
123
|
-
'kept optional so @syncular/client installs without a native ' +
|
|
124
|
-
`build for browser-only hosts. Underlying error: ${String(error)}`,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
37
|
export class NodeClientDatabase implements ClientDatabase {
|
|
130
|
-
readonly db:
|
|
38
|
+
readonly db: DatabaseSync;
|
|
131
39
|
#tx = { depth: 0 };
|
|
132
40
|
|
|
133
41
|
constructor(path = ':memory:') {
|
|
134
|
-
this.db =
|
|
42
|
+
this.db = new DatabaseSync(path);
|
|
135
43
|
}
|
|
136
44
|
|
|
137
45
|
exec(sql: string, params: readonly SqlValue[] = []): void {
|
|
@@ -140,19 +48,14 @@ export class NodeClientDatabase implements ClientDatabase {
|
|
|
140
48
|
|
|
141
49
|
query(sql: string, params: readonly SqlValue[] = []): SqlRow[] {
|
|
142
50
|
const rows = this.db.prepare(sql).all(...coerceParams(params));
|
|
143
|
-
return
|
|
51
|
+
return rows.map(normalizeRow);
|
|
144
52
|
}
|
|
145
53
|
|
|
146
54
|
transaction<T>(fn: () => T): T {
|
|
147
55
|
return runTransaction(this.#tx, (sql) => this.db.exec(sql), fn);
|
|
148
56
|
}
|
|
149
57
|
|
|
150
|
-
/**
|
|
151
|
-
* §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
|
|
152
|
-
* buffers, so the image lands in a private temp file for the duration of
|
|
153
|
-
* the ATTACH. Must be called outside any open transaction (SQLite cannot
|
|
154
|
-
* ATTACH inside one).
|
|
155
|
-
*/
|
|
58
|
+
/** §5.3 image import through a private file attached for one callback. */
|
|
156
59
|
withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T {
|
|
157
60
|
assertImageAlias(alias);
|
|
158
61
|
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
package/src/query-guard.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* bypasses the outbox (SPEC §7.1) and silently diverges from the
|
|
11
11
|
* server — writes MUST go through `client.mutate([...])`.
|
|
12
12
|
* 2. ONE STATEMENT. `sqlite-wasm`'s `exec` runs every statement in a
|
|
13
|
-
* multi-statement string (`SELECT 1; DROP TABLE t`), while
|
|
14
|
-
*
|
|
13
|
+
* multi-statement string (`SELECT 1; DROP TABLE t`), while the native
|
|
14
|
+
* SQLite adapters prepare only the first. We unify on the strict
|
|
15
15
|
* behaviour: exactly one statement per `query()`.
|
|
16
16
|
*
|
|
17
17
|
* The guard only fronts the PUBLIC `client.query()` — engine-internal reads
|
package/src/remote.ts
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
encodeRemoteOperationRequest,
|
|
11
11
|
encodeRemoteOperationRealtimeMessage,
|
|
12
12
|
encodeRow,
|
|
13
|
-
PROTOCOL_WIRE_VERSION,
|
|
14
13
|
type PushOperation,
|
|
15
14
|
type PushOperationResult,
|
|
16
15
|
type PushResultDetailsFrame,
|
|
@@ -42,6 +41,8 @@ export interface SyncRemoteClientConfig {
|
|
|
42
41
|
readonly operations?: RemoteOperationTransport;
|
|
43
42
|
readonly operationRealtime?: RemoteOperationRealtimeConnector;
|
|
44
43
|
readonly encryption?: EncryptionConfig;
|
|
44
|
+
/** Acquired partition log epoch for restore-safe ordinary commits (§2.1). */
|
|
45
|
+
readonly logEpoch?: string;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export interface RemoteCommitInput {
|
|
@@ -203,11 +204,15 @@ export class SyncRemoteClient {
|
|
|
203
204
|
}
|
|
204
205
|
>();
|
|
205
206
|
readonly #encryption: EncryptionConfig | undefined;
|
|
207
|
+
readonly #logEpoch: string | undefined;
|
|
206
208
|
|
|
207
209
|
constructor(config: SyncRemoteClientConfig) {
|
|
208
210
|
if (config.clientId.length === 0) {
|
|
209
211
|
throw invalid('SyncRemoteClient clientId must be non-empty');
|
|
210
212
|
}
|
|
213
|
+
if (config.logEpoch !== undefined && config.logEpoch.length === 0) {
|
|
214
|
+
throw invalid('SyncRemoteClient logEpoch must be non-empty');
|
|
215
|
+
}
|
|
211
216
|
this.#schema =
|
|
212
217
|
config.schema === undefined
|
|
213
218
|
? undefined
|
|
@@ -217,6 +222,7 @@ export class SyncRemoteClient {
|
|
|
217
222
|
this.#operations = config.operations;
|
|
218
223
|
this.#operationRealtime = config.operationRealtime;
|
|
219
224
|
this.#encryption = config.encryption;
|
|
225
|
+
this.#logEpoch = config.logEpoch;
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
async prepareCommit(input: RemoteCommitInput): Promise<PreparedRemoteCommit> {
|
|
@@ -277,13 +283,16 @@ export class SyncRemoteClient {
|
|
|
277
283
|
return {
|
|
278
284
|
requestId: input.requestId,
|
|
279
285
|
bytes: encodeMessage({
|
|
280
|
-
wireVersion:
|
|
286
|
+
wireVersion: this.#logEpoch === undefined ? 1 : 2,
|
|
281
287
|
msgKind: 'request',
|
|
282
288
|
frames: [
|
|
283
289
|
{
|
|
284
290
|
type: 'REQ_HEADER',
|
|
285
291
|
clientId: this.#clientId,
|
|
286
292
|
schemaVersion: schema.version,
|
|
293
|
+
...(this.#logEpoch !== undefined
|
|
294
|
+
? { logEpoch: this.#logEpoch }
|
|
295
|
+
: {}),
|
|
287
296
|
},
|
|
288
297
|
{
|
|
289
298
|
type: 'PUSH_COMMIT',
|