@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/dist/node-database.js
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 } 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 { assertImageAlias, runTransaction, } from './database.js';
|
|
@@ -28,19 +14,12 @@ function coerceParams(params) {
|
|
|
28
14
|
return value;
|
|
29
15
|
});
|
|
30
16
|
}
|
|
31
|
-
/**
|
|
32
|
-
* better-sqlite3 returns BLOB columns as Node `Buffer`s. A Buffer IS a
|
|
33
|
-
* Uint8Array subclass, but it can be a view onto a shared pool buffer, so we
|
|
34
|
-
* normalize to a standalone Uint8Array — matching what bun:sqlite hands back
|
|
35
|
-
* and keeping the buffer-ownership assumptions elsewhere (worker transfer,
|
|
36
|
-
* structured clone) honest.
|
|
37
|
-
*/
|
|
38
17
|
function normalizeRow(row) {
|
|
39
18
|
const out = {};
|
|
40
19
|
for (const key in row) {
|
|
41
20
|
const value = row[key];
|
|
42
|
-
if (
|
|
43
|
-
out[key] = new Uint8Array(value);
|
|
21
|
+
if (value instanceof Uint8Array) {
|
|
22
|
+
out[key] = new Uint8Array(value);
|
|
44
23
|
}
|
|
45
24
|
else {
|
|
46
25
|
out[key] = value;
|
|
@@ -48,47 +27,11 @@ function normalizeRow(row) {
|
|
|
48
27
|
}
|
|
49
28
|
return out;
|
|
50
29
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Load the optional peer AND open the database in one guarded step, so BOTH
|
|
53
|
-
* failure modes are turned into a clear, actionable error rather than a raw
|
|
54
|
-
* one:
|
|
55
|
-
*
|
|
56
|
-
* - `require('better-sqlite3')` throwing MODULE_NOT_FOUND — the peer is not
|
|
57
|
-
* installed (the common browser-only-host case), and
|
|
58
|
-
* - `new Database()` throwing ERR_DLOPEN_FAILED — the module resolves but the
|
|
59
|
-
* native addon cannot load, which is exactly what bun does for
|
|
60
|
-
* better-sqlite3 (oven-sh/bun#4290); the addon only dlopens at construction.
|
|
61
|
-
*/
|
|
62
|
-
function openBetterSqlite(path) {
|
|
63
|
-
const require = createRequire(import.meta.url);
|
|
64
|
-
try {
|
|
65
|
-
const mod = require('better-sqlite3');
|
|
66
|
-
const Database = mod.default ??
|
|
67
|
-
mod;
|
|
68
|
-
return new Database(path);
|
|
69
|
-
}
|
|
70
|
-
catch (error) {
|
|
71
|
-
const code = error?.code;
|
|
72
|
-
if (code === 'ERR_DLOPEN_FAILED') {
|
|
73
|
-
throw new Error("openNodeDatabase() requires the 'better-sqlite3' native module, but " +
|
|
74
|
-
'it failed to load. This most commonly means you are running under ' +
|
|
75
|
-
'bun, which cannot dlopen better-sqlite3 (oven-sh/bun#4290) — use ' +
|
|
76
|
-
"the bun:sqlite backend ('@syncular/client/bun') under bun, " +
|
|
77
|
-
"and reserve '@syncular/client/node' for Node/Electron-main. " +
|
|
78
|
-
`Underlying error: ${String(error)}`);
|
|
79
|
-
}
|
|
80
|
-
throw new Error('openNodeDatabase() requires the optional peer dependency ' +
|
|
81
|
-
"'better-sqlite3', which is not installed. Add it to your app " +
|
|
82
|
-
'(`npm install better-sqlite3` / `bun add better-sqlite3`) — it is ' +
|
|
83
|
-
'kept optional so @syncular/client installs without a native ' +
|
|
84
|
-
`build for browser-only hosts. Underlying error: ${String(error)}`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
30
|
export class NodeClientDatabase {
|
|
88
31
|
db;
|
|
89
32
|
#tx = { depth: 0 };
|
|
90
33
|
constructor(path = ':memory:') {
|
|
91
|
-
this.db =
|
|
34
|
+
this.db = new DatabaseSync(path);
|
|
92
35
|
}
|
|
93
36
|
exec(sql, params = []) {
|
|
94
37
|
this.db.prepare(sql).run(...coerceParams(params));
|
|
@@ -100,12 +43,7 @@ export class NodeClientDatabase {
|
|
|
100
43
|
transaction(fn) {
|
|
101
44
|
return runTransaction(this.#tx, (sql) => this.db.exec(sql), fn);
|
|
102
45
|
}
|
|
103
|
-
/**
|
|
104
|
-
* §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
|
|
105
|
-
* buffers, so the image lands in a private temp file for the duration of
|
|
106
|
-
* the ATTACH. Must be called outside any open transaction (SQLite cannot
|
|
107
|
-
* ATTACH inside one).
|
|
108
|
-
*/
|
|
46
|
+
/** §5.3 image import through a private file attached for one callback. */
|
|
109
47
|
withSqliteImage(bytes, alias, fn) {
|
|
110
48
|
assertImageAlias(alias);
|
|
111
49
|
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
package/dist/query-guard.d.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/dist/query-guard.js
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/dist/remote.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface SyncRemoteClientConfig {
|
|
|
18
18
|
readonly operations?: RemoteOperationTransport;
|
|
19
19
|
readonly operationRealtime?: RemoteOperationRealtimeConnector;
|
|
20
20
|
readonly encryption?: EncryptionConfig;
|
|
21
|
+
/** Acquired partition log epoch for restore-safe ordinary commits (§2.1). */
|
|
22
|
+
readonly logEpoch?: string;
|
|
21
23
|
}
|
|
22
24
|
export interface RemoteCommitInput {
|
|
23
25
|
/** Stable caller-owned idempotency identity for this logical commit. */
|
package/dist/remote.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Database-less SSP2 producer (§6.10). It prepares and sends ordinary commits
|
|
3
3
|
* through the existing push path without creating a local replica or outbox.
|
|
4
4
|
*/
|
|
5
|
-
import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeRow,
|
|
5
|
+
import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeRow, } from '@syncular/core';
|
|
6
6
|
import { encryptRowValues } from './encryption.js';
|
|
7
7
|
import { ClientSyncError } from './errors.js';
|
|
8
8
|
import { compileClientSchema, recordToRowValues, } from './schema.js';
|
|
@@ -71,10 +71,14 @@ export class SyncRemoteClient {
|
|
|
71
71
|
#operationSocketGeneration = 0;
|
|
72
72
|
#watches = new Map();
|
|
73
73
|
#encryption;
|
|
74
|
+
#logEpoch;
|
|
74
75
|
constructor(config) {
|
|
75
76
|
if (config.clientId.length === 0) {
|
|
76
77
|
throw invalid('SyncRemoteClient clientId must be non-empty');
|
|
77
78
|
}
|
|
79
|
+
if (config.logEpoch !== undefined && config.logEpoch.length === 0) {
|
|
80
|
+
throw invalid('SyncRemoteClient logEpoch must be non-empty');
|
|
81
|
+
}
|
|
78
82
|
this.#schema =
|
|
79
83
|
config.schema === undefined
|
|
80
84
|
? undefined
|
|
@@ -84,6 +88,7 @@ export class SyncRemoteClient {
|
|
|
84
88
|
this.#operations = config.operations;
|
|
85
89
|
this.#operationRealtime = config.operationRealtime;
|
|
86
90
|
this.#encryption = config.encryption;
|
|
91
|
+
this.#logEpoch = config.logEpoch;
|
|
87
92
|
}
|
|
88
93
|
async prepareCommit(input) {
|
|
89
94
|
const schema = this.#schema;
|
|
@@ -137,13 +142,16 @@ export class SyncRemoteClient {
|
|
|
137
142
|
return {
|
|
138
143
|
requestId: input.requestId,
|
|
139
144
|
bytes: encodeMessage({
|
|
140
|
-
wireVersion:
|
|
145
|
+
wireVersion: this.#logEpoch === undefined ? 1 : 2,
|
|
141
146
|
msgKind: 'request',
|
|
142
147
|
frames: [
|
|
143
148
|
{
|
|
144
149
|
type: 'REQ_HEADER',
|
|
145
150
|
clientId: this.#clientId,
|
|
146
151
|
schemaVersion: schema.version,
|
|
152
|
+
...(this.#logEpoch !== undefined
|
|
153
|
+
? { logEpoch: this.#logEpoch }
|
|
154
|
+
: {}),
|
|
147
155
|
},
|
|
148
156
|
{
|
|
149
157
|
type: 'PUSH_COMMIT',
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { WakeReason } from '@syncular/core';
|
|
2
|
+
import type { SecurityLifecycle } from './client.js';
|
|
3
|
+
import type { SyncIntent } from './invalidation.js';
|
|
4
|
+
export interface SyncSchedulerClient {
|
|
5
|
+
readonly syncNeeded: boolean;
|
|
6
|
+
readonly securityLifecycle: SecurityLifecycle;
|
|
7
|
+
syncUntilIdle(maxRounds?: number): Promise<unknown>;
|
|
8
|
+
onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
|
|
9
|
+
onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
|
|
10
|
+
}
|
|
11
|
+
export interface SyncSchedulerOptions {
|
|
12
|
+
readonly maxRounds?: number;
|
|
13
|
+
readonly onError?: (error: unknown) => void;
|
|
14
|
+
readonly now?: () => number;
|
|
15
|
+
readonly queueMicrotask?: (callback: () => void) => void;
|
|
16
|
+
readonly schedule?: (callback: () => void, delayMs: number) => () => void;
|
|
17
|
+
}
|
|
18
|
+
export interface SyncScheduler {
|
|
19
|
+
readonly stopped: boolean;
|
|
20
|
+
stop(): void;
|
|
21
|
+
}
|
|
22
|
+
/** Install the event-driven single-flight host loop for a direct client. */
|
|
23
|
+
export declare function installSyncScheduler(client: SyncSchedulerClient, options?: SyncSchedulerOptions): SyncScheduler;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** Install the event-driven single-flight host loop for a direct client. */
|
|
2
|
+
export function installSyncScheduler(client, options = {}) {
|
|
3
|
+
const now = options.now ?? Date.now;
|
|
4
|
+
const enqueue = options.queueMicrotask ?? globalThis.queueMicrotask;
|
|
5
|
+
const schedule = options.schedule ??
|
|
6
|
+
((callback, delayMs) => {
|
|
7
|
+
const timer = globalThis.setTimeout(callback, delayMs);
|
|
8
|
+
return () => globalThis.clearTimeout(timer);
|
|
9
|
+
});
|
|
10
|
+
let stopped = false;
|
|
11
|
+
let running = false;
|
|
12
|
+
let immediatePending = false;
|
|
13
|
+
let immediateQueued = false;
|
|
14
|
+
let backgroundReady = false;
|
|
15
|
+
let backgroundDue = Number.POSITIVE_INFINITY;
|
|
16
|
+
let cancelBackground;
|
|
17
|
+
const report = (error) => {
|
|
18
|
+
if (options.onError !== undefined) {
|
|
19
|
+
options.onError(error);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const root = globalThis;
|
|
23
|
+
if (root.reportError !== undefined)
|
|
24
|
+
root.reportError(error);
|
|
25
|
+
else
|
|
26
|
+
console.error(error);
|
|
27
|
+
};
|
|
28
|
+
const clearBackground = () => {
|
|
29
|
+
cancelBackground?.();
|
|
30
|
+
cancelBackground = undefined;
|
|
31
|
+
backgroundReady = false;
|
|
32
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
33
|
+
};
|
|
34
|
+
const queueImmediate = () => {
|
|
35
|
+
immediatePending = true;
|
|
36
|
+
if (stopped || running || immediateQueued)
|
|
37
|
+
return;
|
|
38
|
+
immediateQueued = true;
|
|
39
|
+
enqueue(() => {
|
|
40
|
+
immediateQueued = false;
|
|
41
|
+
if (stopped || !immediatePending)
|
|
42
|
+
return;
|
|
43
|
+
immediatePending = false;
|
|
44
|
+
run();
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const run = () => {
|
|
48
|
+
if (stopped || running)
|
|
49
|
+
return;
|
|
50
|
+
if (client.securityLifecycle === 'preflight') {
|
|
51
|
+
immediatePending = false;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
running = true;
|
|
55
|
+
backgroundReady = false;
|
|
56
|
+
void client
|
|
57
|
+
.syncUntilIdle(options.maxRounds)
|
|
58
|
+
.catch((error) => {
|
|
59
|
+
if (!stopped)
|
|
60
|
+
report(error);
|
|
61
|
+
})
|
|
62
|
+
.finally(() => {
|
|
63
|
+
running = false;
|
|
64
|
+
if (stopped)
|
|
65
|
+
return;
|
|
66
|
+
if (immediatePending || backgroundReady) {
|
|
67
|
+
queueImmediate();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (cancelBackground !== undefined && backgroundDue <= now()) {
|
|
71
|
+
clearBackground();
|
|
72
|
+
queueImmediate();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const consume = (intent) => {
|
|
77
|
+
if (stopped)
|
|
78
|
+
return;
|
|
79
|
+
if (intent.kind === 'none') {
|
|
80
|
+
clearBackground();
|
|
81
|
+
immediatePending = false;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (intent.kind === 'interactive') {
|
|
85
|
+
clearBackground();
|
|
86
|
+
queueImmediate();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (immediatePending || immediateQueued)
|
|
90
|
+
return;
|
|
91
|
+
clearBackground();
|
|
92
|
+
backgroundDue = now() + Math.max(0, intent.delayMs);
|
|
93
|
+
cancelBackground = schedule(() => {
|
|
94
|
+
cancelBackground = undefined;
|
|
95
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
96
|
+
backgroundReady = true;
|
|
97
|
+
if (!running)
|
|
98
|
+
queueImmediate();
|
|
99
|
+
}, Math.max(0, intent.delayMs));
|
|
100
|
+
};
|
|
101
|
+
const unsubscribeNeeded = client.onSyncNeeded(() => {
|
|
102
|
+
consume({ kind: 'interactive' });
|
|
103
|
+
});
|
|
104
|
+
const unsubscribeIntent = client.onSyncIntent(consume);
|
|
105
|
+
if (client.syncNeeded && client.securityLifecycle === 'active') {
|
|
106
|
+
consume({ kind: 'interactive' });
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
get stopped() {
|
|
110
|
+
return stopped;
|
|
111
|
+
},
|
|
112
|
+
stop() {
|
|
113
|
+
if (stopped)
|
|
114
|
+
return;
|
|
115
|
+
stopped = true;
|
|
116
|
+
clearBackground();
|
|
117
|
+
immediatePending = false;
|
|
118
|
+
unsubscribeNeeded();
|
|
119
|
+
unsubscribeIntent();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/window.d.ts
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { type ScopeMap } from '@syncular/core';
|
|
16
16
|
import type { ClientDatabase } from './database.js';
|
|
17
|
+
export type TimeBucketUnit = 'month';
|
|
18
|
+
/** Derive the immutable UTC scope value stored when a row is created. */
|
|
19
|
+
export declare function creationTimeBucket(createdAtMs: number, unit: TimeBucketUnit): string;
|
|
20
|
+
/** Return a rolling UTC month window ordered from oldest to newest. */
|
|
21
|
+
export declare function last(count: number, unit: TimeBucketUnit, nowMs?: number): string[];
|
|
17
22
|
/**
|
|
18
23
|
* A window base: one table, one variable whose values are the window
|
|
19
24
|
* units, and any FIXED scopes every unit shares (other variables pinned
|
package/dist/window.js
CHANGED
|
@@ -13,6 +13,45 @@
|
|
|
13
13
|
* transaction and the invalidation choke point.
|
|
14
14
|
*/
|
|
15
15
|
import { canonicalScopeJson } from '@syncular/core';
|
|
16
|
+
import { ClientSyncError } from './errors.js';
|
|
17
|
+
const MAX_TIME_BUCKET_MS = 253_402_300_799_999;
|
|
18
|
+
function monthBucket(year, month) {
|
|
19
|
+
return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}`;
|
|
20
|
+
}
|
|
21
|
+
/** Derive the immutable UTC scope value stored when a row is created. */
|
|
22
|
+
export function creationTimeBucket(createdAtMs, unit) {
|
|
23
|
+
if (unit !== 'month' ||
|
|
24
|
+
!Number.isSafeInteger(createdAtMs) ||
|
|
25
|
+
createdAtMs < 0 ||
|
|
26
|
+
createdAtMs > MAX_TIME_BUCKET_MS) {
|
|
27
|
+
throw new ClientSyncError('sync.invalid_request', 'creationTimeBucket requires a supported unit and a UTC timestamp from 1970 through 9999');
|
|
28
|
+
}
|
|
29
|
+
const date = new Date(createdAtMs);
|
|
30
|
+
return monthBucket(date.getUTCFullYear(), date.getUTCMonth() + 1);
|
|
31
|
+
}
|
|
32
|
+
/** Return a rolling UTC month window ordered from oldest to newest. */
|
|
33
|
+
export function last(count, unit, nowMs = Date.now()) {
|
|
34
|
+
if (unit !== 'month' ||
|
|
35
|
+
!Number.isSafeInteger(count) ||
|
|
36
|
+
count < 1 ||
|
|
37
|
+
count > 1_200 ||
|
|
38
|
+
!Number.isSafeInteger(nowMs) ||
|
|
39
|
+
nowMs < 0 ||
|
|
40
|
+
nowMs > MAX_TIME_BUCKET_MS) {
|
|
41
|
+
throw new ClientSyncError('sync.invalid_request', 'last requires a supported unit, a count from 1 through 1200, and a UTC timestamp from 1970 through 9999');
|
|
42
|
+
}
|
|
43
|
+
const date = new Date(nowMs);
|
|
44
|
+
const current = date.getUTCFullYear() * 12 + date.getUTCMonth();
|
|
45
|
+
if (current - (count - 1) < 1970 * 12) {
|
|
46
|
+
throw new ClientSyncError('sync.invalid_request', 'last requires every returned UTC month to fall from 1970 through 9999');
|
|
47
|
+
}
|
|
48
|
+
const units = [];
|
|
49
|
+
for (let offset = count - 1; offset >= 0; offset -= 1) {
|
|
50
|
+
const value = current - offset;
|
|
51
|
+
units.push(monthBucket(Math.floor(value / 12), (value % 12) + 1));
|
|
52
|
+
}
|
|
53
|
+
return units;
|
|
54
|
+
}
|
|
16
55
|
/**
|
|
17
56
|
* A stable, server-opaque key for a window base — table + variable +
|
|
18
57
|
* canonical fixed scopes. Two `setWindow` calls with the same base
|
package/dist/worker-entry.js
CHANGED
|
@@ -147,9 +147,6 @@ export function startSyncWorker(overrides = {}) {
|
|
|
147
147
|
autoSyncScheduled = true;
|
|
148
148
|
queueMicrotask(runAutoSync);
|
|
149
149
|
}
|
|
150
|
-
function consumeEffects(effects) {
|
|
151
|
-
consumeSyncIntent(effects.sync);
|
|
152
|
-
}
|
|
153
150
|
function requireClient() {
|
|
154
151
|
if (client === undefined) {
|
|
155
152
|
throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker received a call before init completed');
|
|
@@ -304,20 +301,16 @@ export function startSyncWorker(overrides = {}) {
|
|
|
304
301
|
subscribe: (input) => requireClient().subscribe(input),
|
|
305
302
|
unsubscribe: (id) => requireClient().unsubscribe(id),
|
|
306
303
|
setWindow: async (base, units) => {
|
|
307
|
-
|
|
308
|
-
consumeEffects(result.effects);
|
|
304
|
+
await requireClient().setWindowCommand(base, units);
|
|
309
305
|
},
|
|
310
306
|
windowState: (base) => requireClient().windowState(base),
|
|
311
307
|
mutate: (mutations) => {
|
|
312
|
-
|
|
313
|
-
consumeEffects(result.effects);
|
|
314
|
-
return result.value;
|
|
308
|
+
return requireClient().mutateCommand(mutations).value;
|
|
315
309
|
},
|
|
316
310
|
patch: (table, rowId, partial, options) => {
|
|
317
311
|
// Same §8.4 rule as `mutate`: a local write must push without the app
|
|
318
312
|
// orchestrating sync, so consume the core's immediate intent.
|
|
319
313
|
const result = requireClient().patchCommand(table, rowId, partial, options);
|
|
320
|
-
consumeEffects(result.effects);
|
|
321
314
|
return result.value;
|
|
322
315
|
},
|
|
323
316
|
purgeLocalData: (input) => requireClient().purgeLocalData(input),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.48",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -58,6 +58,14 @@
|
|
|
58
58
|
"default": "./dist/node-database.js"
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
|
+
"./sqlite": {
|
|
62
|
+
"bun": "./src/sqlite-bun.ts",
|
|
63
|
+
"node": {
|
|
64
|
+
"types": "./dist/sqlite-node.d.ts",
|
|
65
|
+
"default": "./dist/sqlite-node.js"
|
|
66
|
+
},
|
|
67
|
+
"types": "./dist/sqlite-node.d.ts"
|
|
68
|
+
},
|
|
61
69
|
"./wasm": {
|
|
62
70
|
"bun": "./src/wasm-database.ts",
|
|
63
71
|
"browser": "./dist/wasm-database.js",
|
|
@@ -85,23 +93,13 @@
|
|
|
85
93
|
"!dist/**/*.test.d.ts"
|
|
86
94
|
],
|
|
87
95
|
"scripts": {
|
|
88
|
-
"verify:node": "bun build ./test/node-database/verify-node.mjs --target=node --
|
|
96
|
+
"verify:node": "bun build ./test/node-database/verify-node.mjs --target=node --outfile=./.verify-node.built.mjs && node ./.verify-node.built.mjs"
|
|
89
97
|
},
|
|
90
98
|
"dependencies": {
|
|
91
99
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.15.
|
|
93
|
-
},
|
|
94
|
-
"peerDependencies": {
|
|
95
|
-
"better-sqlite3": ">=11"
|
|
96
|
-
},
|
|
97
|
-
"peerDependenciesMeta": {
|
|
98
|
-
"better-sqlite3": {
|
|
99
|
-
"optional": true
|
|
100
|
-
}
|
|
100
|
+
"@syncular/core": "0.15.48"
|
|
101
101
|
},
|
|
102
102
|
"devDependencies": {
|
|
103
|
-
"@syncular/server": "0.15.
|
|
104
|
-
"@types/better-sqlite3": "^7.6.13",
|
|
105
|
-
"better-sqlite3": "^12.11.1"
|
|
103
|
+
"@syncular/server": "0.15.48"
|
|
106
104
|
}
|
|
107
105
|
}
|
package/src/bun-database.ts
CHANGED
|
@@ -15,6 +15,12 @@ import {
|
|
|
15
15
|
type SqlValue,
|
|
16
16
|
} from './database';
|
|
17
17
|
|
|
18
|
+
declare module 'bun:sqlite' {
|
|
19
|
+
interface Database {
|
|
20
|
+
clearQueryCache(): void;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
type BunParam = string | number | bigint | Uint8Array | null;
|
|
19
25
|
|
|
20
26
|
function coerceParams(params: readonly SqlValue[]): BunParam[] {
|
|
@@ -34,6 +40,11 @@ export class BunClientDatabase implements ClientDatabase {
|
|
|
34
40
|
|
|
35
41
|
exec(sql: string, params: readonly SqlValue[] = []): void {
|
|
36
42
|
this.db.query(sql).run(...coerceParams(params));
|
|
43
|
+
// `Database.query()` caches prepared statements. Clear that cache after
|
|
44
|
+
// schema DDL so a reset does not reprepare every later row upsert.
|
|
45
|
+
if (/^\s*(?:CREATE|DROP|ALTER)\b/i.test(sql)) {
|
|
46
|
+
this.db.clearQueryCache();
|
|
47
|
+
}
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
query(sql: string, params: readonly SqlValue[] = []): SqlRow[] {
|