@syncular/client 0.2.1 → 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/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
- * App-facing record schema-ordered row values for the codec and the
225
- * local mirror. Missing keys become NULL; unknown keys fail loud.
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 recordToRowValues(table, record) {
228
- for (const key of Object.keys(record)) {
229
- if (!table.columnIndex.has(key)) {
230
- throw new ClientSyncError('sync.invalid_request', `table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values`);
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 = record[column.name];
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 {};
@@ -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, []);
@@ -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());
@@ -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
- * With `multiTab: true`, a tab that LOSES the election does not resolve to a
12
- * dead not-leader handle: it becomes a FOLLOWER (`role === 'follower'`) that
13
- * proxies every call to the leader tab over a BroadcastChannel (see
14
- * `multi-tab.ts`). When the leader tab closes, its lock releases; the
15
- * followers contest, the winner PROMOTES in place — spawns the worker over
16
- * the persisted OPFS database and re-announces — and the handle's `role`
17
- * flips to `'leader'` with `onRoleChange` firing. The same handle object is
18
- * kept across the transition so React bindings hold a stable reference.
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` off (default), behavior is unchanged: the loser is an
21
- * `isLeader === false` handle whose calls reject with `client.not_leader`.
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). When true, a tab that loses the leader
57
- * election becomes a FOLLOWER that proxies to the leader over a
58
- * BroadcastChannel, and contests + promotes when the leader closes. When
59
- * false (default), the loser is a dead `isLeader === false` handle.
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` off: a losing tab resolves to a dead not-leader handle.
170
- * With `multiTab` on: a losing tab becomes a FOLLOWER proxying to the leader,
171
- * and promotes itself if the leader later closes.
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 {};
@@ -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 (enable multiTab for follower proxying)'));
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` off: a losing tab resolves to a dead not-leader handle.
355
- * With `multiTab` on: a losing tab becomes a FOLLOWER proxying to the leader,
356
- * and promotes itself if the leader later closes.
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 !== true) {
385
- // Legacy single-tab contract: a dead not-leader handle.
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 === true
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 === true
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.2.1",
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": "./src/index.ts",
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": "./src/bun-database.ts",
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": "./src/node-database.ts",
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": "./src/wasm-database.ts",
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": "./src/worker-entry.ts",
63
+ "browser": "./dist/worker-entry.js",
64
64
  "import": {
65
65
  "types": "./dist/worker-entry.d.ts",
66
66
  "default": "./dist/worker-entry.js"
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
- return this.#db.query(sql, params);
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). */