@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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The client-side introspection registry (RFC 0002 §3.2): every live
3
+ * `SyncClient` / `SyncClientHandle` on a page registers itself on
4
+ * `globalThis.__SYNCULAR__`, so a first integration debugs from the console
5
+ * instead of hand-exposing the client:
6
+ *
7
+ * __SYNCULAR__.clients // the live entries
8
+ * await __SYNCULAR__.snapshot() // one plain object per client:
9
+ * // outbox depth, subscriptions,
10
+ * // conflicts, syncNeeded, upgrading,
11
+ * // last invalidation
12
+ * __SYNCULAR__.clients[0].ref // the client itself — full API
13
+ *
14
+ * Gated to development: the registry installs only where a `window` exists
15
+ * (worker cores register through their page-side handle) and NODE_ENV is
16
+ * anything except `'production'` (bundlers statically replace it, so
17
+ * production builds skip installation; environments without `process` are
18
+ * treated as dev). Cost when gated off: one function call per client.
19
+ */
20
+ import type { InvalidationEvent, InvalidationListener } from './invalidation';
21
+
22
+ /** What a registrant supplies — plain lambdas over its own surface. */
23
+ export interface DevtoolsRegistration {
24
+ /** `'direct'` (a `SyncClient`) or the handle's role. */
25
+ readonly kind: 'client' | 'handle';
26
+ /** The client/handle itself, for full-API console access. */
27
+ readonly ref: unknown;
28
+ readonly clientId: () => string;
29
+ readonly role: () => string;
30
+ readonly outbox: () => Promise<number>;
31
+ readonly subscriptions: () => Promise<readonly unknown[]>;
32
+ readonly conflicts: () => Promise<number>;
33
+ readonly rejections: () => Promise<number>;
34
+ readonly syncNeeded: () => Promise<boolean>;
35
+ readonly upgrading: () => Promise<boolean>;
36
+ readonly onInvalidate: (listener: InvalidationListener) => () => void;
37
+ }
38
+
39
+ /** One live entry on the registry (a registration plus tracked state). */
40
+ export interface DevtoolsEntry extends DevtoolsRegistration {
41
+ /** The most recent invalidation event, timestamped (epoch ms). */
42
+ lastInvalidation?: {
43
+ readonly atMs: number;
44
+ readonly tables: readonly string[];
45
+ readonly scopeKeys: readonly string[];
46
+ };
47
+ }
48
+
49
+ interface DevtoolsRegistry {
50
+ readonly clients: DevtoolsEntry[];
51
+ snapshot(): Promise<Record<string, unknown>[]>;
52
+ }
53
+
54
+ const KEY = '__SYNCULAR__';
55
+
56
+ /** The page global to install on, or undefined when gated off. */
57
+ function registryHost(): Record<string, unknown> | undefined {
58
+ const g = globalThis as Record<string, unknown> & {
59
+ window?: unknown;
60
+ process?: { env?: { NODE_ENV?: string } };
61
+ };
62
+ if (g.window === undefined) return undefined;
63
+ if (g.process?.env?.NODE_ENV === 'production') return undefined;
64
+ return g;
65
+ }
66
+
67
+ function registryOn(host: Record<string, unknown>): DevtoolsRegistry {
68
+ const existing = host[KEY];
69
+ if (existing !== undefined) return existing as DevtoolsRegistry;
70
+ const registry: DevtoolsRegistry = {
71
+ clients: [],
72
+ snapshot: async () =>
73
+ Promise.all(
74
+ registry.clients.map(async (entry) => ({
75
+ kind: entry.kind,
76
+ clientId: entry.clientId(),
77
+ role: entry.role(),
78
+ outbox: await entry.outbox().catch(() => 'unavailable'),
79
+ subscriptions: await entry
80
+ .subscriptions()
81
+ .then((subs) => subs.length)
82
+ .catch(() => 'unavailable'),
83
+ conflicts: await entry.conflicts().catch(() => 'unavailable'),
84
+ rejections: await entry.rejections().catch(() => 'unavailable'),
85
+ syncNeeded: await entry.syncNeeded().catch(() => 'unavailable'),
86
+ upgrading: await entry.upgrading().catch(() => 'unavailable'),
87
+ lastInvalidation: entry.lastInvalidation,
88
+ })),
89
+ ),
90
+ };
91
+ host[KEY] = registry;
92
+ return registry;
93
+ }
94
+
95
+ /**
96
+ * Register a client on the page registry. Returns the unregister function
97
+ * (a no-op when the registry is gated off) — call it from `close()`.
98
+ */
99
+ export function registerDevtools(
100
+ registration: DevtoolsRegistration,
101
+ ): () => void {
102
+ const host = registryHost();
103
+ if (host === undefined) return () => {};
104
+ const registry = registryOn(host);
105
+ const entry: DevtoolsEntry = { ...registration };
106
+ const unlisten = registration.onInvalidate((event: InvalidationEvent) => {
107
+ entry.lastInvalidation = {
108
+ atMs: Date.now(),
109
+ tables: [...event.tables],
110
+ scopeKeys: [...event.scopeKeys],
111
+ };
112
+ });
113
+ registry.clients.push(entry);
114
+ return () => {
115
+ unlisten();
116
+ const index = registry.clients.indexOf(entry);
117
+ if (index !== -1) registry.clients.splice(index, 1);
118
+ };
119
+ }
package/src/index.ts CHANGED
@@ -13,14 +13,18 @@ export * from './blob';
13
13
  export * from './client';
14
14
  export * from './content-type';
15
15
  export * from './database';
16
+ export * from './devtools';
16
17
  export * from './encryption';
17
18
  export * from './errors';
18
19
  export * from './http';
19
20
  export * from './invalidation';
20
21
  export * from './leader-lock';
21
22
  export * from './multi-tab';
23
+ export * from './naming';
22
24
  export * from './outbox';
25
+ export * from './query-guard';
23
26
  export * from './schema';
27
+ export * from './sql-tag';
24
28
  export * from './state';
25
29
  export * from './transport';
26
30
  export * from './window';
package/src/naming.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The pinned snake→camel naming map (DESIGN-queries.md §5, §12) — the
3
+ * client-side copy of the typegen algorithm (kept in lockstep by shared
4
+ * test vectors; the Rust core carries the same function). Used by `mutate`
5
+ * to accept BOTH casings for value keys: the canonical camelCase the
6
+ * generated row types use, and the SQL-truth snake_case. One bijective map
7
+ * lookup per key; anything else errors (no fuzzy matching).
8
+ */
9
+
10
+ const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
11
+
12
+ /** The pinned §12 snake→camel conversion (see typegen's naming.ts). */
13
+ export function snakeToCamel(name: string): string {
14
+ if (!MAPPABLE_RE.test(name)) return name;
15
+ const lead = /^_*/.exec(name)?.[0] ?? '';
16
+ const bare = name.slice(lead.length);
17
+ const trail = /_*$/.exec(bare)?.[0] ?? '';
18
+ const middle = bare.slice(0, bare.length - trail.length);
19
+ const segments = middle.split('_').filter((s) => s.length > 0);
20
+ if (segments.length === 0) return name;
21
+ const first = segments[0] as string;
22
+ const rest = segments
23
+ .slice(1)
24
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1));
25
+ return lead + first + rest.join('') + trail;
26
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The raw-query guard (DESIGN-queries.md I3). `client.query()` / the React
3
+ * `useRawSql` hook are the untrusted raw-SQL tier: an app hands us a SQL
4
+ * string and we run it against the local database. Two rules make that safe
5
+ * to expose, enforced HERE in the core (previously they lived in the
6
+ * now-removed `@syncular/kysely` read-only driver):
7
+ *
8
+ * 1. READ-ONLY. Only `select / with / explain / pragma / values` are
9
+ * allowed. A write (`insert/update/delete/…`) against the local mirror
10
+ * bypasses the outbox (SPEC §7.1) and silently diverges from the
11
+ * server — writes MUST go through `client.mutate([...])`.
12
+ * 2. ONE STATEMENT. `sqlite-wasm`'s `exec` runs every statement in a
13
+ * multi-statement string (`SELECT 1; DROP TABLE t`), while bun:sqlite /
14
+ * better-sqlite3 prepare only the first. We unify on the strict
15
+ * behaviour: exactly one statement per `query()`.
16
+ *
17
+ * The guard only fronts the PUBLIC `client.query()` — engine-internal reads
18
+ * call the `ClientDatabase` directly and are trusted, so they are never
19
+ * routed through here.
20
+ */
21
+
22
+ /** Verbs a read-only query may begin with (lowercased). */
23
+ const READ_ONLY_VERBS = new Set([
24
+ 'select',
25
+ 'with',
26
+ 'explain',
27
+ 'pragma',
28
+ 'values',
29
+ ]);
30
+
31
+ /** Raised when `client.query()` is handed SQL it will not run. */
32
+ export class RawSqlError extends Error {
33
+ override readonly name = 'RawSqlError';
34
+ }
35
+
36
+ /**
37
+ * Split `sql` into top-level statements at unquoted `;`, skipping over
38
+ * string literals ('…'), quoted/bracketed identifiers ("…", `…`, […]) and
39
+ * comments (-- …, /* … *​/) so a `;` inside any of them is not a boundary.
40
+ * Returns the non-empty statements (comment/whitespace-only trailers drop).
41
+ */
42
+ function splitStatements(sql: string): string[] {
43
+ const statements: string[] = [];
44
+ let start = 0;
45
+ let i = 0;
46
+ const n = sql.length;
47
+
48
+ const pushIfNonEmpty = (end: number) => {
49
+ const stripped = stripLeading(sql.slice(start, end));
50
+ if (stripped.length > 0) statements.push(sql.slice(start, end));
51
+ start = end + 1;
52
+ };
53
+
54
+ while (i < n) {
55
+ const c = sql[i];
56
+ if (c === '-' && sql[i + 1] === '-') {
57
+ const nl = sql.indexOf('\n', i + 2);
58
+ i = nl === -1 ? n : nl + 1;
59
+ } else if (c === '/' && sql[i + 1] === '*') {
60
+ const close = sql.indexOf('*/', i + 2);
61
+ i = close === -1 ? n : close + 2;
62
+ } else if (c === "'" || c === '"' || c === '`') {
63
+ i = skipQuoted(sql, i, c as "'" | '"' | '`');
64
+ } else if (c === '[') {
65
+ const close = sql.indexOf(']', i + 1);
66
+ i = close === -1 ? n : close + 1;
67
+ } else if (c === ';') {
68
+ pushIfNonEmpty(i);
69
+ i += 1;
70
+ } else {
71
+ i += 1;
72
+ }
73
+ }
74
+ pushIfNonEmpty(n);
75
+ return statements;
76
+ }
77
+
78
+ /** Advance past a quoted run opened at `open`; SQL doubles the quote to escape it. */
79
+ function skipQuoted(sql: string, open: number, quote: "'" | '"' | '`'): number {
80
+ let i = open + 1;
81
+ const n = sql.length;
82
+ while (i < n) {
83
+ if (sql[i] === quote) {
84
+ if (sql[i + 1] === quote) i += 2;
85
+ else return i + 1;
86
+ } else {
87
+ i += 1;
88
+ }
89
+ }
90
+ return n;
91
+ }
92
+
93
+ /** Strip leading whitespace and comments, returning the remainder. */
94
+ function stripLeading(sql: string): string {
95
+ return sql
96
+ .replace(/^\s*(?:--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/|\s)+/, '')
97
+ .trimStart();
98
+ }
99
+
100
+ function firstWords(sql: string): string {
101
+ const trimmed = sql.trim().replace(/\s+/g, ' ');
102
+ return trimmed.length > 72 ? `${trimmed.slice(0, 72)}…` : trimmed;
103
+ }
104
+
105
+ /**
106
+ * The main verb of a `WITH …` statement: SQLite allows a with-clause before
107
+ * SELECT **and before INSERT/UPDATE/DELETE**, so `WITH t AS (…) DELETE …`
108
+ * must not slip through the verb allowlist. CTE bodies live inside
109
+ * parentheses, and a bare keyword cannot be a CTE name, so the first
110
+ * paren-depth-0 keyword after the clause IS the main verb.
111
+ */
112
+ function mainVerbAfterWith(sql: string): string | undefined {
113
+ const MAIN_VERBS = new Set([
114
+ 'select',
115
+ 'values',
116
+ 'insert',
117
+ 'update',
118
+ 'delete',
119
+ 'replace',
120
+ ]);
121
+ let depth = 0;
122
+ let i = 0;
123
+ const n = sql.length;
124
+ let sawWith = false;
125
+ while (i < n) {
126
+ const c = sql[i] as string;
127
+ if (c === '-' && sql[i + 1] === '-') {
128
+ const nl = sql.indexOf('\n', i + 2);
129
+ i = nl === -1 ? n : nl + 1;
130
+ } else if (c === '/' && sql[i + 1] === '*') {
131
+ const close = sql.indexOf('*/', i + 2);
132
+ i = close === -1 ? n : close + 2;
133
+ } else if (c === "'" || c === '"' || c === '`') {
134
+ i = skipQuoted(sql, i, c as "'" | '"' | '`');
135
+ } else if (c === '[') {
136
+ const close = sql.indexOf(']', i + 1);
137
+ i = close === -1 ? n : close + 1;
138
+ } else if (c === '(') {
139
+ depth += 1;
140
+ i += 1;
141
+ } else if (c === ')') {
142
+ depth -= 1;
143
+ i += 1;
144
+ } else if (/[A-Za-z_]/.test(c)) {
145
+ let j = i + 1;
146
+ while (j < n && /[A-Za-z0-9_]/.test(sql[j] as string)) j += 1;
147
+ const word = sql.slice(i, j).toLowerCase();
148
+ if (depth === 0) {
149
+ if (!sawWith && word === 'with') sawWith = true;
150
+ else if (sawWith && MAIN_VERBS.has(word)) return word;
151
+ }
152
+ i = j;
153
+ } else {
154
+ i += 1;
155
+ }
156
+ }
157
+ return undefined;
158
+ }
159
+
160
+ /**
161
+ * Assert `sql` is a single read-only statement, or throw `RawSqlError`.
162
+ * Called by `client.query()` before the string reaches the database.
163
+ */
164
+ export function assertReadOnlyQuery(sql: string): void {
165
+ const statements = splitStatements(sql);
166
+ if (statements.length === 0) {
167
+ throw new RawSqlError('client.query() was given an empty statement.');
168
+ }
169
+ if (statements.length > 1) {
170
+ throw new RawSqlError(
171
+ `client.query() runs a single statement, but ${statements.length} were ` +
172
+ 'given. Split them into separate query() calls. ' +
173
+ `First: ${firstWords(statements[0] ?? '')}`,
174
+ );
175
+ }
176
+ const statement = stripLeading(statements[0] ?? '');
177
+ const verb = statement.match(/^([a-zA-Z]+)/)?.[1]?.toLowerCase();
178
+ const rejectWrite = (): never => {
179
+ throw new RawSqlError(
180
+ 'client.query() is read-only — this statement writes the local ' +
181
+ 'database directly, which bypasses the sync outbox (SPEC §7.1). Use ' +
182
+ '`client.mutate([...])` for inserts/updates/deletes. ' +
183
+ `Rejected: ${firstWords(sql)}`,
184
+ );
185
+ };
186
+ if (verb === undefined || !READ_ONLY_VERBS.has(verb)) rejectWrite();
187
+ if (verb === 'with') {
188
+ // SQLite allows `WITH … DELETE/INSERT/UPDATE`; only a SELECT/VALUES
189
+ // main statement is a read.
190
+ const main = mainVerbAfterWith(statement);
191
+ if (main !== 'select' && main !== 'values') rejectWrite();
192
+ }
193
+ }
package/src/schema.ts CHANGED
@@ -5,8 +5,9 @@
5
5
  * mapping (scope variable → local column).
6
6
  */
7
7
  import type { RowColumn, RowValue } from '@syncular/core';
8
- import type { ClientDatabase, SqlValue } from './database';
8
+ import type { ClientDatabase, SqlRow, SqlValue } from './database';
9
9
  import { ClientSyncError } from './errors';
10
+ import { snakeToCamel } from './naming';
10
11
 
11
12
  /** `'prefix:{variable}'` shorthand (column name = variable) or explicit. */
12
13
  export type ScopePatternSpec = string | { pattern: string; column: string };
@@ -41,6 +42,13 @@ export interface CompiledClientTable {
41
42
  readonly primaryKey: string;
42
43
  readonly primaryKeyIndex: number;
43
44
  readonly columnIndex: ReadonlyMap<string, number>;
45
+ /**
46
+ * §5 mutate key normalization: unambiguous camelCase alias → column
47
+ * index. An alias is dropped when it equals another column's exact name
48
+ * or when two columns map to the same alias (exact names always win; the
49
+ * generator errors on such schemas under camel naming anyway).
50
+ */
51
+ readonly columnIndexByCamel: ReadonlyMap<string, number>;
44
52
  /** Scope variable → local scope column (§3.3 purge mapping). */
45
53
  readonly scopeColumnByVariable: ReadonlyMap<string, string>;
46
54
  /**
@@ -119,6 +127,19 @@ export function compileClientSchema(
119
127
  scopeColumnByVariable.set(variable, column);
120
128
  scopePrefixByVariable.set(variable, prefix);
121
129
  }
130
+ // §5: unambiguous camelCase aliases for mutate key normalization.
131
+ const columnIndexByCamel = new Map<string, number>();
132
+ const ambiguous = new Set<string>();
133
+ table.columns.forEach((column, index) => {
134
+ const alias = snakeToCamel(column.name);
135
+ if (alias === column.name || columnIndex.has(alias)) return;
136
+ if (columnIndexByCamel.has(alias)) {
137
+ ambiguous.add(alias);
138
+ return;
139
+ }
140
+ columnIndexByCamel.set(alias, index);
141
+ });
142
+ for (const alias of ambiguous) columnIndexByCamel.delete(alias);
122
143
  const indexes = table.indexes ?? [];
123
144
  for (const index of indexes) {
124
145
  for (const column of index.columns) {
@@ -135,6 +156,7 @@ export function compileClientSchema(
135
156
  primaryKey: table.primaryKey,
136
157
  primaryKeyIndex,
137
158
  columnIndex,
159
+ columnIndexByCamel,
138
160
  scopeColumnByVariable,
139
161
  scopePrefixByVariable,
140
162
  indexes,
@@ -159,6 +181,26 @@ export const SYNC_VERSION_COLUMN = '_sync_version';
159
181
  /** `_sync_version` for optimistic rows the server has never confirmed. */
160
182
  export const OPTIMISTIC_VERSION = -1;
161
183
 
184
+ /**
185
+ * Strip the reserved `_sync_*` columns from app-facing query rows, so a
186
+ * `SELECT *` row round-trips straight into `mutate()` values. Result
187
+ * columns are per-statement, so the first row decides for all rows; an
188
+ * explicit alias (`SELECT _sync_version AS v`) passes through untouched.
189
+ * Engine internals read `_sync_version` via `client.database` and never
190
+ * pass through this filter.
191
+ */
192
+ export function stripSyncColumns(rows: SqlRow[]): SqlRow[] {
193
+ const first = rows[0];
194
+ if (first === undefined) return rows;
195
+ const reserved = Object.keys(first).filter((key) => key.startsWith('_sync_'));
196
+ if (reserved.length === 0) return rows;
197
+ return rows.map((row) => {
198
+ const copy: SqlRow = { ...row };
199
+ for (const key of reserved) delete copy[key];
200
+ return copy;
201
+ });
202
+ }
203
+
162
204
  export function quoteIdent(name: string): string {
163
205
  return `"${name.replaceAll('"', '""')}"`;
164
206
  }
@@ -329,23 +371,75 @@ export function fromSqlValue(column: RowColumn, value: SqlValue): RowValue {
329
371
  }
330
372
 
331
373
  /**
332
- * App-facing record schema-ordered row values for the codec and the
333
- * local mirror. Missing keys become NULL; unknown keys fail loud.
374
+ * Normalize an app-facing record's keys to the SQL-truth snake_case column
375
+ * names. Keys are accepted in exactly two casings (§5/§12): snake_case and
376
+ * the generated row types' camelCase — one bijective-map lookup per key,
377
+ * no fuzzy matching. Unknown keys fail loud (with a dedicated hint for the
378
+ * reserved `_sync_*` names); giving one column in both casings is an error.
334
379
  */
335
- export function recordToRowValues(
380
+ export function normalizeRecordKeys(
336
381
  table: CompiledClientTable,
337
382
  record: Readonly<Record<string, unknown>>,
338
- ): RowValue[] {
339
- for (const key of Object.keys(record)) {
340
- if (!table.columnIndex.has(key)) {
383
+ ): Map<string, unknown> {
384
+ const normalized = new Map<string, unknown>();
385
+ for (const [key, value] of Object.entries(record)) {
386
+ const index =
387
+ table.columnIndex.get(key) ?? table.columnIndexByCamel.get(key);
388
+ if (index === undefined) {
389
+ if (key.startsWith('_sync_')) {
390
+ throw new ClientSyncError(
391
+ 'sync.invalid_request',
392
+ `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)`,
393
+ );
394
+ }
341
395
  throw new ClientSyncError(
342
396
  'sync.invalid_request',
343
- `table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values`,
397
+ `table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values (snake_case and camelCase keys are accepted)`,
344
398
  );
345
399
  }
400
+ const sqlName = (table.columns[index] as RowColumn).name;
401
+ if (normalized.has(sqlName)) {
402
+ throw new ClientSyncError(
403
+ 'sync.invalid_request',
404
+ `table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values (as both snake_case and camelCase) — pass it once`,
405
+ );
406
+ }
407
+ normalized.set(sqlName, value);
408
+ }
409
+ return normalized;
410
+ }
411
+
412
+ /**
413
+ * Accept the local SQL representation of a value alongside the app-facing
414
+ * one, so a row read straight off the mirror (`SELECT *`) feeds back into
415
+ * `mutate()` without per-column fixups: SQLite stores booleans as 0/1 and
416
+ * may surface integers as bigint. Anything else passes through untouched —
417
+ * the codec still fails loud on genuine type garbage at encode time.
418
+ */
419
+ function coerceSqlRepresentation(column: RowColumn, value: unknown): unknown {
420
+ switch (localColumnType(column)) {
421
+ case 'boolean':
422
+ return value === 0 ? false : value === 1 ? true : value;
423
+ case 'integer':
424
+ case 'float':
425
+ return typeof value === 'bigint' ? Number(value) : value;
426
+ default:
427
+ return value;
346
428
  }
429
+ }
430
+
431
+ /**
432
+ * App-facing record → schema-ordered row values for the codec and the
433
+ * local mirror. Missing keys become NULL; unknown keys fail loud (see
434
+ * {@link normalizeRecordKeys} for the accepted casings).
435
+ */
436
+ export function recordToRowValues(
437
+ table: CompiledClientTable,
438
+ record: Readonly<Record<string, unknown>>,
439
+ ): RowValue[] {
440
+ const normalized = normalizeRecordKeys(table, record);
347
441
  return table.columns.map((column) => {
348
- const value = record[column.name];
442
+ const value = normalized.get(column.name);
349
443
  if (value === undefined || value === null) {
350
444
  if (!column.nullable) {
351
445
  throw new ClientSyncError(
@@ -355,7 +449,7 @@ export function recordToRowValues(
355
449
  }
356
450
  return null;
357
451
  }
358
- return value as RowValue;
452
+ return coerceSqlRepresentation(column, value) as RowValue;
359
453
  });
360
454
  }
361
455
 
package/src/sql-tag.ts ADDED
@@ -0,0 +1,128 @@
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';
19
+
20
+ /** A composed raw query: SQL text with `?` placeholders + bound params. */
21
+ export interface SqlFragment {
22
+ readonly text: string;
23
+ readonly params: readonly SqlValue[];
24
+ /** Brand so fragments are distinguishable from user values. */
25
+ readonly [SQL_FRAGMENT]: true;
26
+ }
27
+
28
+ const SQL_FRAGMENT = Symbol.for('syncular.sqlFragment');
29
+
30
+ function fragment(text: string, params: readonly SqlValue[]): SqlFragment {
31
+ return { text, params, [SQL_FRAGMENT]: true };
32
+ }
33
+
34
+ function isFragment(value: unknown): value is SqlFragment {
35
+ return (
36
+ typeof value === 'object' &&
37
+ value !== null &&
38
+ (value as Record<PropertyKey, unknown>)[SQL_FRAGMENT] === true
39
+ );
40
+ }
41
+
42
+ function isSqlValue(value: unknown): value is SqlValue {
43
+ return (
44
+ value === null ||
45
+ typeof value === 'string' ||
46
+ typeof value === 'number' ||
47
+ typeof value === 'bigint' ||
48
+ typeof value === 'boolean' ||
49
+ value instanceof Uint8Array
50
+ );
51
+ }
52
+
53
+ export type SqlInterpolation = SqlValue | SqlFragment | readonly SqlValue[];
54
+
55
+ /** Compose a raw query. Values bind; only literals/ident/raw become text. */
56
+ export function sql(
57
+ strings: TemplateStringsArray,
58
+ ...values: readonly SqlInterpolation[]
59
+ ): SqlFragment {
60
+ let text = '';
61
+ const params: SqlValue[] = [];
62
+ for (let i = 0; i < strings.length; i++) {
63
+ text += strings[i] ?? '';
64
+ if (i >= values.length) continue;
65
+ const value = values[i];
66
+ if (isFragment(value)) {
67
+ text += value.text;
68
+ params.push(...value.params);
69
+ } else if (Array.isArray(value)) {
70
+ // An array binds as a comma-joined parameter list — `IN (${ids})`.
71
+ if (value.length === 0) {
72
+ // `IN ()` is a SQLite syntax error; bind a never-matching list.
73
+ text += 'SELECT NULL WHERE 0';
74
+ } else {
75
+ for (const [j, item] of value.entries()) {
76
+ if (!isSqlValue(item)) {
77
+ throw new TypeError(
78
+ `sql\`\` array element ${j} is not a bindable SQL value`,
79
+ );
80
+ }
81
+ }
82
+ text += value.map(() => '?').join(', ');
83
+ params.push(...(value as readonly SqlValue[]));
84
+ }
85
+ } else if (isSqlValue(value)) {
86
+ text += '?';
87
+ params.push(value);
88
+ } else {
89
+ // undefined, objects, functions — always a bug at the call site.
90
+ throw new TypeError(
91
+ `sql\`\` interpolation ${i} is not a bindable SQL value ` +
92
+ `(got ${value === undefined ? 'undefined' : typeof value}). ` +
93
+ 'Bind a value, compose a sql`` fragment, or use ' +
94
+ 'sql.ident()/sql.raw() explicitly.',
95
+ );
96
+ }
97
+ }
98
+ return fragment(text, params);
99
+ }
100
+
101
+ /** The empty fragment — the neutral element for conditional composition. */
102
+ sql.empty = fragment('', []);
103
+
104
+ /**
105
+ * An identifier (column/table name). The allowlist is MANDATORY —
106
+ * identifiers cannot be bound, so the only safe source is a closed set the
107
+ * caller wrote. The value is also shape-checked and quoted defensively.
108
+ */
109
+ sql.ident = (value: string, allowlist: readonly string[]): SqlFragment => {
110
+ if (!allowlist.includes(value)) {
111
+ throw new RangeError(
112
+ `sql.ident: ${JSON.stringify(value)} is not in the allowlist ` +
113
+ `[${allowlist.join(', ')}]`,
114
+ );
115
+ }
116
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
117
+ throw new RangeError(
118
+ `sql.ident: ${JSON.stringify(value)} is not a plain identifier`,
119
+ );
120
+ }
121
+ return fragment(`"${value}"`, []);
122
+ };
123
+
124
+ /**
125
+ * Verbatim SQL text. The loud escape hatch: never pass request/user/synced
126
+ * data through here — that is the injection you were protected from.
127
+ */
128
+ sql.raw = (text: string): SqlFragment => fragment(text, []);
@@ -332,6 +332,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
332
332
  scheduleAutoSync();
333
333
  return id;
334
334
  },
335
+ patch: (table, rowId, partial, options) => {
336
+ // Same §8.4 rule as `mutate`: a local write must push without the app
337
+ // orchestrating sync, so schedule a jittered round to drain the outbox.
338
+ const id = requireClient().patch(table, rowId, partial, options);
339
+ scheduleAutoSync();
340
+ return id;
341
+ },
335
342
  sync: () => {
336
343
  const running = requireClient();
337
344
  return serializedSync(() => running.sync());