@syncular/client 0.2.0 → 0.3.0
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/dist/client.d.ts +19 -0
- package/dist/client.js +65 -2
- package/dist/devtools.d.ts +50 -0
- package/dist/devtools.js +60 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/naming.d.ts +10 -0
- package/dist/naming.js +26 -0
- package/dist/query-guard.d.ts +29 -0
- package/dist/query-guard.js +200 -0
- package/dist/schema.d.ts +27 -2
- package/dist/schema.js +87 -8
- package/dist/sql-tag.d.ts +35 -0
- package/dist/sql-tag.js +82 -0
- package/dist/worker-entry.js +7 -0
- package/dist/worker-host.d.ts +23 -17
- package/dist/worker-host.js +30 -8
- package/dist/worker-protocol.d.ts +4 -0
- package/package.json +8 -8
- package/src/client.ts +80 -1
- package/src/devtools.ts +119 -0
- package/src/index.ts +4 -0
- package/src/naming.ts +26 -0
- package/src/query-guard.ts +193 -0
- package/src/schema.ts +104 -10
- package/src/sql-tag.ts +128 -0
- package/src/worker-entry.ts +7 -0
- package/src/worker-host.ts +52 -22
- package/src/worker-protocol.ts +7 -0
package/dist/schema.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ClientSyncError } from './errors.js';
|
|
2
|
+
import { snakeToCamel } from './naming.js';
|
|
2
3
|
const PATTERN_RE = /^([^{}]+):\{([^{}:]+)\}$/;
|
|
3
4
|
export function compileClientSchema(schema) {
|
|
4
5
|
const tables = new Map();
|
|
@@ -41,6 +42,21 @@ export function compileClientSchema(schema) {
|
|
|
41
42
|
scopeColumnByVariable.set(variable, column);
|
|
42
43
|
scopePrefixByVariable.set(variable, prefix);
|
|
43
44
|
}
|
|
45
|
+
// §5: unambiguous camelCase aliases for mutate key normalization.
|
|
46
|
+
const columnIndexByCamel = new Map();
|
|
47
|
+
const ambiguous = new Set();
|
|
48
|
+
table.columns.forEach((column, index) => {
|
|
49
|
+
const alias = snakeToCamel(column.name);
|
|
50
|
+
if (alias === column.name || columnIndex.has(alias))
|
|
51
|
+
return;
|
|
52
|
+
if (columnIndexByCamel.has(alias)) {
|
|
53
|
+
ambiguous.add(alias);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
columnIndexByCamel.set(alias, index);
|
|
57
|
+
});
|
|
58
|
+
for (const alias of ambiguous)
|
|
59
|
+
columnIndexByCamel.delete(alias);
|
|
44
60
|
const indexes = table.indexes ?? [];
|
|
45
61
|
for (const index of indexes) {
|
|
46
62
|
for (const column of index.columns) {
|
|
@@ -55,6 +71,7 @@ export function compileClientSchema(schema) {
|
|
|
55
71
|
primaryKey: table.primaryKey,
|
|
56
72
|
primaryKeyIndex,
|
|
57
73
|
columnIndex,
|
|
74
|
+
columnIndexByCamel,
|
|
58
75
|
scopeColumnByVariable,
|
|
59
76
|
scopePrefixByVariable,
|
|
60
77
|
indexes,
|
|
@@ -75,6 +92,28 @@ export function compileClientSchema(schema) {
|
|
|
75
92
|
export const SYNC_VERSION_COLUMN = '_sync_version';
|
|
76
93
|
/** `_sync_version` for optimistic rows the server has never confirmed. */
|
|
77
94
|
export const OPTIMISTIC_VERSION = -1;
|
|
95
|
+
/**
|
|
96
|
+
* Strip the reserved `_sync_*` columns from app-facing query rows, so a
|
|
97
|
+
* `SELECT *` row round-trips straight into `mutate()` values. Result
|
|
98
|
+
* columns are per-statement, so the first row decides for all rows; an
|
|
99
|
+
* explicit alias (`SELECT _sync_version AS v`) passes through untouched.
|
|
100
|
+
* Engine internals read `_sync_version` via `client.database` and never
|
|
101
|
+
* pass through this filter.
|
|
102
|
+
*/
|
|
103
|
+
export function stripSyncColumns(rows) {
|
|
104
|
+
const first = rows[0];
|
|
105
|
+
if (first === undefined)
|
|
106
|
+
return rows;
|
|
107
|
+
const reserved = Object.keys(first).filter((key) => key.startsWith('_sync_'));
|
|
108
|
+
if (reserved.length === 0)
|
|
109
|
+
return rows;
|
|
110
|
+
return rows.map((row) => {
|
|
111
|
+
const copy = { ...row };
|
|
112
|
+
for (const key of reserved)
|
|
113
|
+
delete copy[key];
|
|
114
|
+
return copy;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
78
117
|
export function quoteIdent(name) {
|
|
79
118
|
return `"${name.replaceAll('"', '""')}"`;
|
|
80
119
|
}
|
|
@@ -221,24 +260,64 @@ export function fromSqlValue(column, value) {
|
|
|
221
260
|
}
|
|
222
261
|
}
|
|
223
262
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
263
|
+
* Normalize an app-facing record's keys to the SQL-truth snake_case column
|
|
264
|
+
* names. Keys are accepted in exactly two casings (§5/§12): snake_case and
|
|
265
|
+
* the generated row types' camelCase — one bijective-map lookup per key,
|
|
266
|
+
* no fuzzy matching. Unknown keys fail loud (with a dedicated hint for the
|
|
267
|
+
* reserved `_sync_*` names); giving one column in both casings is an error.
|
|
226
268
|
*/
|
|
227
|
-
export function
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
269
|
+
export function normalizeRecordKeys(table, record) {
|
|
270
|
+
const normalized = new Map();
|
|
271
|
+
for (const [key, value] of Object.entries(record)) {
|
|
272
|
+
const index = table.columnIndex.get(key) ?? table.columnIndexByCamel.get(key);
|
|
273
|
+
if (index === undefined) {
|
|
274
|
+
if (key.startsWith('_sync_')) {
|
|
275
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: ${JSON.stringify(key)} is an internal sync column and cannot appear in mutation values — did you build this record from a raw SELECT * row? (client.query() strips _sync_* columns; rows read via client.database keep them)`);
|
|
276
|
+
}
|
|
277
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values (snake_case and camelCase keys are accepted)`);
|
|
231
278
|
}
|
|
279
|
+
const sqlName = table.columns[index].name;
|
|
280
|
+
if (normalized.has(sqlName)) {
|
|
281
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values (as both snake_case and camelCase) — pass it once`);
|
|
282
|
+
}
|
|
283
|
+
normalized.set(sqlName, value);
|
|
284
|
+
}
|
|
285
|
+
return normalized;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Accept the local SQL representation of a value alongside the app-facing
|
|
289
|
+
* one, so a row read straight off the mirror (`SELECT *`) feeds back into
|
|
290
|
+
* `mutate()` without per-column fixups: SQLite stores booleans as 0/1 and
|
|
291
|
+
* may surface integers as bigint. Anything else passes through untouched —
|
|
292
|
+
* the codec still fails loud on genuine type garbage at encode time.
|
|
293
|
+
*/
|
|
294
|
+
function coerceSqlRepresentation(column, value) {
|
|
295
|
+
switch (localColumnType(column)) {
|
|
296
|
+
case 'boolean':
|
|
297
|
+
return value === 0 ? false : value === 1 ? true : value;
|
|
298
|
+
case 'integer':
|
|
299
|
+
case 'float':
|
|
300
|
+
return typeof value === 'bigint' ? Number(value) : value;
|
|
301
|
+
default:
|
|
302
|
+
return value;
|
|
232
303
|
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* App-facing record → schema-ordered row values for the codec and the
|
|
307
|
+
* local mirror. Missing keys become NULL; unknown keys fail loud (see
|
|
308
|
+
* {@link normalizeRecordKeys} for the accepted casings).
|
|
309
|
+
*/
|
|
310
|
+
export function recordToRowValues(table, record) {
|
|
311
|
+
const normalized = normalizeRecordKeys(table, record);
|
|
233
312
|
return table.columns.map((column) => {
|
|
234
|
-
const value =
|
|
313
|
+
const value = normalized.get(column.name);
|
|
235
314
|
if (value === undefined || value === null) {
|
|
236
315
|
if (!column.nullable) {
|
|
237
316
|
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1 full-row payloads)`);
|
|
238
317
|
}
|
|
239
318
|
return null;
|
|
240
319
|
}
|
|
241
|
-
return value;
|
|
320
|
+
return coerceSqlRepresentation(column, value);
|
|
242
321
|
});
|
|
243
322
|
}
|
|
244
323
|
function bytesToHex(bytes) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sql` tagged template — the raw tier's composition helper
|
|
3
|
+
* (DESIGN-queries.md I4). Structural injection safety: an interpolated
|
|
4
|
+
* value can only ever become a `?` bind parameter; SQL text can only enter
|
|
5
|
+
* through the literal template, `sql.ident()` (allowlist-gated) or a loud
|
|
6
|
+
* `sql.raw()`. This helper is deliberately dumb plumbing and stays that
|
|
7
|
+
* way — typed/composable queries are the `.syql` codegen tier's job, and
|
|
8
|
+
* this module must never grow features that overlap it.
|
|
9
|
+
*
|
|
10
|
+
* const q = sql`
|
|
11
|
+
* SELECT * FROM todos
|
|
12
|
+
* WHERE list_id = ${listId}
|
|
13
|
+
* ${status ? sql`AND status = ${status}` : sql.empty}
|
|
14
|
+
* AND id IN (${ids})
|
|
15
|
+
* ORDER BY ${sql.ident(orderCol, ['created_at', 'title'])} DESC`;
|
|
16
|
+
* client.query(q.text, q.params);
|
|
17
|
+
*/
|
|
18
|
+
import type { SqlValue } from './database.js';
|
|
19
|
+
/** A composed raw query: SQL text with `?` placeholders + bound params. */
|
|
20
|
+
export interface SqlFragment {
|
|
21
|
+
readonly text: string;
|
|
22
|
+
readonly params: readonly SqlValue[];
|
|
23
|
+
/** Brand so fragments are distinguishable from user values. */
|
|
24
|
+
readonly [SQL_FRAGMENT]: true;
|
|
25
|
+
}
|
|
26
|
+
declare const SQL_FRAGMENT: unique symbol;
|
|
27
|
+
export type SqlInterpolation = SqlValue | SqlFragment | readonly SqlValue[];
|
|
28
|
+
/** Compose a raw query. Values bind; only literals/ident/raw become text. */
|
|
29
|
+
export declare function sql(strings: TemplateStringsArray, ...values: readonly SqlInterpolation[]): SqlFragment;
|
|
30
|
+
export declare namespace sql {
|
|
31
|
+
var empty: SqlFragment;
|
|
32
|
+
var ident: (value: string, allowlist: readonly string[]) => SqlFragment;
|
|
33
|
+
var raw: (text: string) => SqlFragment;
|
|
34
|
+
}
|
|
35
|
+
export {};
|
package/dist/sql-tag.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const SQL_FRAGMENT = Symbol.for('syncular.sqlFragment');
|
|
2
|
+
function fragment(text, params) {
|
|
3
|
+
return { text, params, [SQL_FRAGMENT]: true };
|
|
4
|
+
}
|
|
5
|
+
function isFragment(value) {
|
|
6
|
+
return (typeof value === 'object' &&
|
|
7
|
+
value !== null &&
|
|
8
|
+
value[SQL_FRAGMENT] === true);
|
|
9
|
+
}
|
|
10
|
+
function isSqlValue(value) {
|
|
11
|
+
return (value === null ||
|
|
12
|
+
typeof value === 'string' ||
|
|
13
|
+
typeof value === 'number' ||
|
|
14
|
+
typeof value === 'bigint' ||
|
|
15
|
+
typeof value === 'boolean' ||
|
|
16
|
+
value instanceof Uint8Array);
|
|
17
|
+
}
|
|
18
|
+
/** Compose a raw query. Values bind; only literals/ident/raw become text. */
|
|
19
|
+
export function sql(strings, ...values) {
|
|
20
|
+
let text = '';
|
|
21
|
+
const params = [];
|
|
22
|
+
for (let i = 0; i < strings.length; i++) {
|
|
23
|
+
text += strings[i] ?? '';
|
|
24
|
+
if (i >= values.length)
|
|
25
|
+
continue;
|
|
26
|
+
const value = values[i];
|
|
27
|
+
if (isFragment(value)) {
|
|
28
|
+
text += value.text;
|
|
29
|
+
params.push(...value.params);
|
|
30
|
+
}
|
|
31
|
+
else if (Array.isArray(value)) {
|
|
32
|
+
// An array binds as a comma-joined parameter list — `IN (${ids})`.
|
|
33
|
+
if (value.length === 0) {
|
|
34
|
+
// `IN ()` is a SQLite syntax error; bind a never-matching list.
|
|
35
|
+
text += 'SELECT NULL WHERE 0';
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
for (const [j, item] of value.entries()) {
|
|
39
|
+
if (!isSqlValue(item)) {
|
|
40
|
+
throw new TypeError(`sql\`\` array element ${j} is not a bindable SQL value`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
text += value.map(() => '?').join(', ');
|
|
44
|
+
params.push(...value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (isSqlValue(value)) {
|
|
48
|
+
text += '?';
|
|
49
|
+
params.push(value);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// undefined, objects, functions — always a bug at the call site.
|
|
53
|
+
throw new TypeError(`sql\`\` interpolation ${i} is not a bindable SQL value ` +
|
|
54
|
+
`(got ${value === undefined ? 'undefined' : typeof value}). ` +
|
|
55
|
+
'Bind a value, compose a sql`` fragment, or use ' +
|
|
56
|
+
'sql.ident()/sql.raw() explicitly.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return fragment(text, params);
|
|
60
|
+
}
|
|
61
|
+
/** The empty fragment — the neutral element for conditional composition. */
|
|
62
|
+
sql.empty = fragment('', []);
|
|
63
|
+
/**
|
|
64
|
+
* An identifier (column/table name). The allowlist is MANDATORY —
|
|
65
|
+
* identifiers cannot be bound, so the only safe source is a closed set the
|
|
66
|
+
* caller wrote. The value is also shape-checked and quoted defensively.
|
|
67
|
+
*/
|
|
68
|
+
sql.ident = (value, allowlist) => {
|
|
69
|
+
if (!allowlist.includes(value)) {
|
|
70
|
+
throw new RangeError(`sql.ident: ${JSON.stringify(value)} is not in the allowlist ` +
|
|
71
|
+
`[${allowlist.join(', ')}]`);
|
|
72
|
+
}
|
|
73
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
74
|
+
throw new RangeError(`sql.ident: ${JSON.stringify(value)} is not a plain identifier`);
|
|
75
|
+
}
|
|
76
|
+
return fragment(`"${value}"`, []);
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Verbatim SQL text. The loud escape hatch: never pass request/user/synced
|
|
80
|
+
* data through here — that is the injection you were protected from.
|
|
81
|
+
*/
|
|
82
|
+
sql.raw = (text) => fragment(text, []);
|
package/dist/worker-entry.js
CHANGED
|
@@ -244,6 +244,13 @@ export function startSyncWorker(overrides = {}) {
|
|
|
244
244
|
scheduleAutoSync();
|
|
245
245
|
return id;
|
|
246
246
|
},
|
|
247
|
+
patch: (table, rowId, partial, options) => {
|
|
248
|
+
// Same §8.4 rule as `mutate`: a local write must push without the app
|
|
249
|
+
// orchestrating sync, so schedule a jittered round to drain the outbox.
|
|
250
|
+
const id = requireClient().patch(table, rowId, partial, options);
|
|
251
|
+
scheduleAutoSync();
|
|
252
|
+
return id;
|
|
253
|
+
},
|
|
247
254
|
sync: () => {
|
|
248
255
|
const running = requireClient();
|
|
249
256
|
return serializedSync(() => running.sync());
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -8,17 +8,18 @@
|
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* proxies every call to the leader tab over a
|
|
14
|
-
* `multi-tab.ts`). When the leader tab closes, its
|
|
15
|
-
* followers contest, the winner PROMOTES in place —
|
|
16
|
-
* the persisted OPFS database and re-announces — and
|
|
17
|
-
* flips to `'leader'` with `onRoleChange` firing. The
|
|
18
|
-
* kept across the transition so React bindings hold a
|
|
11
|
+
* Multi-tab is the DEFAULT (RFC 0002 §2.4 — the follower path is
|
|
12
|
+
* conformance-covered): a tab that LOSES the election becomes a FOLLOWER
|
|
13
|
+
* (`role === 'follower'`) that proxies every call to the leader tab over a
|
|
14
|
+
* BroadcastChannel (see `multi-tab.ts`). When the leader tab closes, its
|
|
15
|
+
* lock releases; the followers contest, the winner PROMOTES in place —
|
|
16
|
+
* spawns the worker over the persisted OPFS database and re-announces — and
|
|
17
|
+
* the handle's `role` flips to `'leader'` with `onRoleChange` firing. The
|
|
18
|
+
* same handle object is kept across the transition so React bindings hold a
|
|
19
|
+
* stable reference.
|
|
19
20
|
*
|
|
20
|
-
* With `multiTab
|
|
21
|
-
*
|
|
21
|
+
* With `multiTab: false`, the loser is an `isLeader === false` handle whose
|
|
22
|
+
* calls reject with `client.not_leader` (the single-tab contract).
|
|
22
23
|
*/
|
|
23
24
|
import type { WakeReason } from '@syncular/core';
|
|
24
25
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
@@ -53,10 +54,11 @@ export interface SyncClientHandleConfig {
|
|
|
53
54
|
readonly leaderLock?: LeaderLock;
|
|
54
55
|
readonly lockName?: string;
|
|
55
56
|
/**
|
|
56
|
-
* Multi-tab followers (TODO 3.2).
|
|
57
|
-
* election becomes a FOLLOWER that proxies to the leader over a
|
|
58
|
-
* BroadcastChannel, and contests + promotes when the leader closes.
|
|
59
|
-
* false
|
|
57
|
+
* Multi-tab followers (TODO 3.2). On by default: a tab that loses the
|
|
58
|
+
* leader election becomes a FOLLOWER that proxies to the leader over a
|
|
59
|
+
* BroadcastChannel, and contests + promotes when the leader closes. Set
|
|
60
|
+
* false for the single-tab contract — the loser is a dead
|
|
61
|
+
* `isLeader === false` handle rejecting with `client.not_leader`.
|
|
60
62
|
*/
|
|
61
63
|
readonly multiTab?: boolean;
|
|
62
64
|
/** Cross-tab channel factory (default `BroadcastChannel`); injectable for tests. */
|
|
@@ -134,6 +136,10 @@ export declare class SyncClientHandle {
|
|
|
134
136
|
setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
|
|
135
137
|
windowState(base: WindowBase): Promise<WindowState>;
|
|
136
138
|
mutate(mutations: readonly MutationInput[]): Promise<string>;
|
|
139
|
+
/** Partial-update convenience: read-merge-write one full-row upsert. */
|
|
140
|
+
patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
|
|
141
|
+
readonly baseVersion?: number;
|
|
142
|
+
}): Promise<string>;
|
|
137
143
|
sync(): Promise<SyncSummary>;
|
|
138
144
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
139
145
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
|
@@ -166,9 +172,9 @@ export declare class SyncClientHandle {
|
|
|
166
172
|
/**
|
|
167
173
|
* Acquire leadership, spawn the worker, initialize the core inside it.
|
|
168
174
|
*
|
|
169
|
-
* With `multiTab`
|
|
170
|
-
*
|
|
171
|
-
*
|
|
175
|
+
* With `multiTab` on (the default): a losing tab becomes a FOLLOWER proxying
|
|
176
|
+
* to the leader, and promotes itself if the leader later closes. With
|
|
177
|
+
* `multiTab: false`: a losing tab resolves to a dead not-leader handle.
|
|
172
178
|
*/
|
|
173
179
|
export declare function createSyncClientHandle(config: SyncClientHandleConfig): Promise<SyncClientHandle>;
|
|
174
180
|
export {};
|
package/dist/worker-host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerDevtools } from './devtools.js';
|
|
1
2
|
import { ClientSyncError } from './errors.js';
|
|
2
3
|
import { InvalidationEmitter } from './invalidation.js';
|
|
3
4
|
import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
|
|
@@ -33,6 +34,7 @@ export class SyncClientHandle {
|
|
|
33
34
|
#invalidation;
|
|
34
35
|
#presence;
|
|
35
36
|
#roleListeners;
|
|
37
|
+
#devtoolsUnregister;
|
|
36
38
|
#closed = false;
|
|
37
39
|
/** @internal — use {@link createSyncClientHandle}. */
|
|
38
40
|
constructor(internals) {
|
|
@@ -43,6 +45,20 @@ export class SyncClientHandle {
|
|
|
43
45
|
this.#invalidation = internals.invalidation;
|
|
44
46
|
this.#presence = internals.presence;
|
|
45
47
|
this.#roleListeners = internals.roleListeners ?? new Set();
|
|
48
|
+
// RFC 0002 §3.2: console introspection — a no-op outside a dev page.
|
|
49
|
+
this.#devtoolsUnregister = registerDevtools({
|
|
50
|
+
kind: 'handle',
|
|
51
|
+
ref: this,
|
|
52
|
+
clientId: () => this.#clientId,
|
|
53
|
+
role: () => this.#role,
|
|
54
|
+
outbox: async () => (await this.pendingCommits()).length,
|
|
55
|
+
subscriptions: () => this.subscriptions(),
|
|
56
|
+
conflicts: async () => (await this.conflicts()).length,
|
|
57
|
+
rejections: async () => (await this.rejections()).length,
|
|
58
|
+
syncNeeded: () => this.syncNeeded(),
|
|
59
|
+
upgrading: () => this.upgrading(),
|
|
60
|
+
onInvalidate: (listener) => this.onInvalidate(listener),
|
|
61
|
+
});
|
|
46
62
|
}
|
|
47
63
|
/** @internal — swap this handle from follower to leader (promotion). */
|
|
48
64
|
__becomeLeader(core) {
|
|
@@ -109,7 +125,8 @@ export class SyncClientHandle {
|
|
|
109
125
|
if (this.#role === 'follower') {
|
|
110
126
|
if (this.#follower === undefined) {
|
|
111
127
|
return Promise.reject(new ClientSyncError(NOT_LEADER_CODE, 'this tab is not the leader — another tab owns the syncular ' +
|
|
112
|
-
'core for this origin (
|
|
128
|
+
'core for this origin (this handle opted out of follower ' +
|
|
129
|
+
'proxying with multiTab: false)'));
|
|
113
130
|
}
|
|
114
131
|
return this.#follower.call(method, args);
|
|
115
132
|
}
|
|
@@ -133,6 +150,10 @@ export class SyncClientHandle {
|
|
|
133
150
|
mutate(mutations) {
|
|
134
151
|
return this.#call('mutate', [mutations]);
|
|
135
152
|
}
|
|
153
|
+
/** Partial-update convenience: read-merge-write one full-row upsert. */
|
|
154
|
+
patch(table, rowId, partial, options) {
|
|
155
|
+
return this.#call('patch', [table, rowId, partial, options]);
|
|
156
|
+
}
|
|
136
157
|
sync() {
|
|
137
158
|
return this.#call('sync', []);
|
|
138
159
|
}
|
|
@@ -199,6 +220,7 @@ export class SyncClientHandle {
|
|
|
199
220
|
if (this.#closed)
|
|
200
221
|
return;
|
|
201
222
|
this.#closed = true;
|
|
223
|
+
this.#devtoolsUnregister();
|
|
202
224
|
if (this.#follower !== undefined) {
|
|
203
225
|
this.#follower.close();
|
|
204
226
|
this.#follower = undefined;
|
|
@@ -351,9 +373,9 @@ function fireConfigCallbacks(config, event) {
|
|
|
351
373
|
/**
|
|
352
374
|
* Acquire leadership, spawn the worker, initialize the core inside it.
|
|
353
375
|
*
|
|
354
|
-
* With `multiTab`
|
|
355
|
-
*
|
|
356
|
-
*
|
|
376
|
+
* With `multiTab` on (the default): a losing tab becomes a FOLLOWER proxying
|
|
377
|
+
* to the leader, and promotes itself if the leader later closes. With
|
|
378
|
+
* `multiTab: false`: a losing tab resolves to a dead not-leader handle.
|
|
357
379
|
*/
|
|
358
380
|
export async function createSyncClientHandle(config) {
|
|
359
381
|
const lock = config.leaderLock ?? defaultLeaderLock();
|
|
@@ -381,8 +403,8 @@ export async function createSyncClientHandle(config) {
|
|
|
381
403
|
});
|
|
382
404
|
}
|
|
383
405
|
// ---- Lost the election. ----
|
|
384
|
-
if (config.multiTab
|
|
385
|
-
//
|
|
406
|
+
if (config.multiTab === false) {
|
|
407
|
+
// Opted-out single-tab contract: a dead not-leader handle.
|
|
386
408
|
return new SyncClientHandle({
|
|
387
409
|
role: 'follower',
|
|
388
410
|
clientId: '',
|
|
@@ -407,7 +429,7 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
407
429
|
fireConfigCallbacks(config, event);
|
|
408
430
|
handleRef.handle?.__dispatchEvent(event);
|
|
409
431
|
};
|
|
410
|
-
const makeBridge = config.multiTab
|
|
432
|
+
const makeBridge = config.multiTab !== false
|
|
411
433
|
? (clientId, invoke) => {
|
|
412
434
|
const factory = config.channelFactory ?? broadcastChannelFactory();
|
|
413
435
|
const channel = factory(multiTabChannelName(lockName));
|
|
@@ -484,7 +506,7 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
484
506
|
fireConfigCallbacks(config, event);
|
|
485
507
|
handle.__dispatchEvent(event);
|
|
486
508
|
},
|
|
487
|
-
...(config.multiTab
|
|
509
|
+
...(config.multiTab !== false
|
|
488
510
|
? {
|
|
489
511
|
makeBridge: (clientId, invoke) => {
|
|
490
512
|
const promoteChannel = factory(multiTabChannelName(lockName));
|
|
@@ -82,6 +82,10 @@ export interface WorkerApi {
|
|
|
82
82
|
/** §4.8 completeness oracle (I3): the windowed-in units for a base. */
|
|
83
83
|
windowState(base: WindowBase): WindowState;
|
|
84
84
|
mutate(mutations: readonly MutationInput[]): string;
|
|
85
|
+
/** Partial-update convenience: read-merge-write one full-row upsert. */
|
|
86
|
+
patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
|
|
87
|
+
readonly baseVersion?: number;
|
|
88
|
+
}): string;
|
|
85
89
|
sync(): Promise<SyncSummary>;
|
|
86
90
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
87
91
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"exports": {
|
|
29
29
|
".": {
|
|
30
30
|
"bun": "./src/index.ts",
|
|
31
|
-
"browser": "./
|
|
31
|
+
"browser": "./dist/index.js",
|
|
32
32
|
"import": {
|
|
33
33
|
"types": "./dist/index.d.ts",
|
|
34
34
|
"default": "./dist/index.js"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"./bun": {
|
|
38
38
|
"bun": "./src/bun-database.ts",
|
|
39
|
-
"browser": "./
|
|
39
|
+
"browser": "./dist/bun-database.js",
|
|
40
40
|
"import": {
|
|
41
41
|
"types": "./dist/bun-database.d.ts",
|
|
42
42
|
"default": "./dist/bun-database.js"
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"./node": {
|
|
46
46
|
"bun": "./src/node-database.ts",
|
|
47
|
-
"browser": "./
|
|
47
|
+
"browser": "./dist/node-database.js",
|
|
48
48
|
"import": {
|
|
49
49
|
"types": "./dist/node-database.d.ts",
|
|
50
50
|
"default": "./dist/node-database.js"
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"./wasm": {
|
|
54
54
|
"bun": "./src/wasm-database.ts",
|
|
55
|
-
"browser": "./
|
|
55
|
+
"browser": "./dist/wasm-database.js",
|
|
56
56
|
"import": {
|
|
57
57
|
"types": "./dist/wasm-database.d.ts",
|
|
58
58
|
"default": "./dist/wasm-database.js"
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"./worker": {
|
|
62
62
|
"bun": "./src/worker-entry.ts",
|
|
63
|
-
"browser": "./
|
|
63
|
+
"browser": "./dist/worker-entry.js",
|
|
64
64
|
"import": {
|
|
65
65
|
"types": "./dist/worker-entry.d.ts",
|
|
66
66
|
"default": "./dist/worker-entry.js"
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
84
|
-
"@syncular/core": "0.2.
|
|
84
|
+
"@syncular/core": "0.2.1"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"better-sqlite3": ">=11"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.2.
|
|
95
|
+
"@syncular/server": "0.2.1",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
REALTIME_TAG_ROUND,
|
|
25
25
|
type RequestFrame,
|
|
26
26
|
type ResponseMessage,
|
|
27
|
+
type RowColumn,
|
|
27
28
|
type RowValue,
|
|
28
29
|
type ScopeMap,
|
|
29
30
|
type SegmentRefFrame,
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
serializeBlobRef,
|
|
58
59
|
} from './blob';
|
|
59
60
|
import type { ClientDatabase, SqlRow, SqlValue } from './database';
|
|
61
|
+
import { registerDevtools } from './devtools';
|
|
60
62
|
import type { EncryptionConfig } from './encryption';
|
|
61
63
|
import { ClientSyncError } from './errors';
|
|
62
64
|
import {
|
|
@@ -79,6 +81,7 @@ import {
|
|
|
79
81
|
OutboxEncodeError,
|
|
80
82
|
type OutboxOperation,
|
|
81
83
|
} from './outbox';
|
|
84
|
+
import { assertReadOnlyQuery } from './query-guard';
|
|
82
85
|
import {
|
|
83
86
|
type ClientSchema,
|
|
84
87
|
type CompiledClientSchema,
|
|
@@ -86,13 +89,16 @@ import {
|
|
|
86
89
|
compileClientSchema,
|
|
87
90
|
dropAndRecreateSyncedTables,
|
|
88
91
|
ensureLocalSchema,
|
|
92
|
+
fromSqlValue,
|
|
89
93
|
jsonToRowValue,
|
|
90
94
|
LOCAL_SCHEMA_VERSION_KEY,
|
|
95
|
+
normalizeRecordKeys,
|
|
91
96
|
OPTIMISTIC_VERSION,
|
|
92
97
|
quoteIdent,
|
|
93
98
|
recordToRowValues,
|
|
94
99
|
rowValueToJson,
|
|
95
100
|
SYNC_VERSION_COLUMN,
|
|
101
|
+
stripSyncColumns,
|
|
96
102
|
} from './schema';
|
|
97
103
|
import {
|
|
98
104
|
deleteSubscription,
|
|
@@ -396,6 +402,7 @@ export class SyncClient {
|
|
|
396
402
|
#started = false;
|
|
397
403
|
#lease: LeaderLease | undefined;
|
|
398
404
|
#clientId = '';
|
|
405
|
+
#devtoolsUnregister: (() => void) | undefined;
|
|
399
406
|
#schemaFloor: SchemaFloor | undefined;
|
|
400
407
|
#leaseState: LeaseState | undefined;
|
|
401
408
|
/** §7.4.5: true while a schema-bump reset + first bootstrap is in flight. */
|
|
@@ -475,6 +482,20 @@ export class SyncClient {
|
|
|
475
482
|
// already at the generated version.
|
|
476
483
|
this.#detectAndResetSchema();
|
|
477
484
|
this.#started = true;
|
|
485
|
+
// RFC 0002 §3.2: console introspection — a no-op outside a dev page.
|
|
486
|
+
this.#devtoolsUnregister = registerDevtools({
|
|
487
|
+
kind: 'client',
|
|
488
|
+
ref: this,
|
|
489
|
+
clientId: () => this.#clientId,
|
|
490
|
+
role: () => 'direct',
|
|
491
|
+
outbox: async () => this.pendingCommits().length,
|
|
492
|
+
subscriptions: async () => this.subscriptions(),
|
|
493
|
+
conflicts: async () => this.conflicts.length,
|
|
494
|
+
rejections: async () => this.rejections.length,
|
|
495
|
+
syncNeeded: async () => this.syncNeeded,
|
|
496
|
+
upgrading: async () => this.upgrading,
|
|
497
|
+
onInvalidate: (listener) => this.onInvalidate(listener),
|
|
498
|
+
});
|
|
478
499
|
}
|
|
479
500
|
|
|
480
501
|
/**
|
|
@@ -530,6 +551,8 @@ export class SyncClient {
|
|
|
530
551
|
}
|
|
531
552
|
|
|
532
553
|
async close(): Promise<void> {
|
|
554
|
+
this.#devtoolsUnregister?.();
|
|
555
|
+
this.#devtoolsUnregister = undefined;
|
|
533
556
|
this.#socket?.close();
|
|
534
557
|
this.#socket = undefined;
|
|
535
558
|
this.#abortPendingRound('client closed mid-round');
|
|
@@ -549,8 +572,17 @@ export class SyncClient {
|
|
|
549
572
|
return this.#db;
|
|
550
573
|
}
|
|
551
574
|
|
|
575
|
+
/**
|
|
576
|
+
* The raw-SQL read tier. Guarded (query-guard.ts): a single read-only
|
|
577
|
+
* statement only — writes must go through `mutate()` so they hit the
|
|
578
|
+
* outbox (SPEC §7.1). Reserved `_sync_*` columns are stripped from the
|
|
579
|
+
* result, so a `SELECT *` row is safe to feed back into `mutate()`
|
|
580
|
+
* values; alias explicitly (`_sync_version AS v`) to read one. Engine
|
|
581
|
+
* internals read `this.#db` directly and skip this method by design.
|
|
582
|
+
*/
|
|
552
583
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[] {
|
|
553
|
-
|
|
584
|
+
assertReadOnlyQuery(sql);
|
|
585
|
+
return stripSyncColumns(this.#db.query(sql, params));
|
|
554
586
|
}
|
|
555
587
|
|
|
556
588
|
// -- live-query invalidation (TODO 3.1 / DESIGN-eviction I1–I4) -----------
|
|
@@ -1181,6 +1213,53 @@ export class SyncClient {
|
|
|
1181
1213
|
return clientCommitId;
|
|
1182
1214
|
}
|
|
1183
1215
|
|
|
1216
|
+
/**
|
|
1217
|
+
* Partial-update convenience over the §6.1 full-row wire: read the
|
|
1218
|
+
* current LOCAL row, merge `partial` over it, and record one full-row
|
|
1219
|
+
* upsert through `mutate()`. `partial` keys follow the same two-casing
|
|
1220
|
+
* rule as mutation values (snake_case or camelCase). The row must be
|
|
1221
|
+
* locally present (subscribed/windowed-in); patching an absent row is
|
|
1222
|
+
* an error — there is no base to merge into.
|
|
1223
|
+
*/
|
|
1224
|
+
patch(
|
|
1225
|
+
table: string,
|
|
1226
|
+
rowId: string,
|
|
1227
|
+
partial: Readonly<Record<string, unknown>>,
|
|
1228
|
+
options?: { readonly baseVersion?: number },
|
|
1229
|
+
): string {
|
|
1230
|
+
this.#requireStarted();
|
|
1231
|
+
const compiled = this.#table(table);
|
|
1232
|
+
const pkColumn = compiled.columns[compiled.primaryKeyIndex] as RowColumn;
|
|
1233
|
+
const rows = this.#db.query(
|
|
1234
|
+
`SELECT * FROM ${quoteIdent(compiled.name)} WHERE ${quoteIdent(pkColumn.name)} = ?`,
|
|
1235
|
+
[rowId],
|
|
1236
|
+
);
|
|
1237
|
+
const row = rows[0];
|
|
1238
|
+
if (row === undefined) {
|
|
1239
|
+
throw new ClientSyncError(
|
|
1240
|
+
'sync.invalid_request',
|
|
1241
|
+
`table ${compiled.name}: no local row with primary key ${JSON.stringify(rowId)} to patch`,
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
const record: Record<string, unknown> = {};
|
|
1245
|
+
for (const column of compiled.columns as readonly RowColumn[]) {
|
|
1246
|
+
record[column.name] = fromSqlValue(column, row[column.name] ?? null);
|
|
1247
|
+
}
|
|
1248
|
+
for (const [name, value] of normalizeRecordKeys(compiled, partial)) {
|
|
1249
|
+
record[name] = value;
|
|
1250
|
+
}
|
|
1251
|
+
return this.mutate([
|
|
1252
|
+
{
|
|
1253
|
+
table,
|
|
1254
|
+
op: 'upsert',
|
|
1255
|
+
values: record,
|
|
1256
|
+
...(options?.baseVersion !== undefined
|
|
1257
|
+
? { baseVersion: options.baseVersion }
|
|
1258
|
+
: {}),
|
|
1259
|
+
},
|
|
1260
|
+
]);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1184
1263
|
// -- lease state (§7.3.5) ---------------------------------------------------
|
|
1185
1264
|
|
|
1186
1265
|
/** Merge and persist the lease state (opaque, §7.3.5). */
|