@syncular/client 0.2.1 → 0.3.1

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 CHANGED
@@ -194,6 +194,14 @@ export declare class SyncClient {
194
194
  get clientId(): string;
195
195
  /** The underlying database — raw SQL is the local query API (B3). */
196
196
  get database(): ClientDatabase;
197
+ /**
198
+ * The raw-SQL read tier. Guarded (query-guard.ts): a single read-only
199
+ * statement only — writes must go through `mutate()` so they hit the
200
+ * outbox (SPEC §7.1). Reserved `_sync_*` columns are stripped from the
201
+ * result, so a `SELECT *` row is safe to feed back into `mutate()`
202
+ * values; alias explicitly (`_sync_version AS v`) to read one. Engine
203
+ * internals read `this.#db` directly and skip this method by design.
204
+ */
197
205
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
198
206
  /**
199
207
  * Subscribe to fine-grained invalidation. The callback fires ONCE per
@@ -309,6 +317,17 @@ export declare class SyncClient {
309
317
  * Returns the generated `clientCommitId`.
310
318
  */
311
319
  mutate(mutations: readonly MutationInput[]): string;
320
+ /**
321
+ * Partial-update convenience over the §6.1 full-row wire: read the
322
+ * current LOCAL row, merge `partial` over it, and record one full-row
323
+ * upsert through `mutate()`. `partial` keys follow the same two-casing
324
+ * rule as mutation values (snake_case or camelCase). The row must be
325
+ * locally present (subscribed/windowed-in); patching an absent row is
326
+ * an error — there is no base to merge into.
327
+ */
328
+ patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
329
+ readonly baseVersion?: number;
330
+ }): string;
312
331
  /**
313
332
  * One combined push+pull round (§1.5, §7.2). The core owns one loop: a
314
333
  * concurrent `sync()` while one is already outstanding is rejected loudly
package/dist/client.js CHANGED
@@ -10,11 +10,13 @@
10
10
  import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
11
11
  import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, upsertLocalRow, } from './apply.js';
12
12
  import { clearPendingUpload, computeBlobId, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, reconcileBlobRefcounts, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
13
+ import { registerDevtools } from './devtools.js';
13
14
  import { ClientSyncError } from './errors.js';
14
15
  import { Invalidation, InvalidationEmitter, } from './invalidation.js';
15
16
  import { singleOwnerLock, } from './leader-lock.js';
16
17
  import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, OutboxEncodeError, } from './outbox.js';
17
- import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalSchema, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, } from './schema.js';
18
+ import { assertReadOnlyQuery } from './query-guard.js';
19
+ import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
18
20
  import { deleteSubscription, getMeta, getSubscription, loadSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
19
21
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
20
22
  /** True iff `unit` is windowed-in for this snapshot (a registry hit, I3). */
@@ -68,6 +70,7 @@ export class SyncClient {
68
70
  #started = false;
69
71
  #lease;
70
72
  #clientId = '';
73
+ #devtoolsUnregister;
71
74
  #schemaFloor;
72
75
  #leaseState;
73
76
  /** §7.4.5: true while a schema-bump reset + first bootstrap is in flight. */
@@ -144,6 +147,20 @@ export class SyncClient {
144
147
  // already at the generated version.
145
148
  this.#detectAndResetSchema();
146
149
  this.#started = true;
150
+ // RFC 0002 §3.2: console introspection — a no-op outside a dev page.
151
+ this.#devtoolsUnregister = registerDevtools({
152
+ kind: 'client',
153
+ ref: this,
154
+ clientId: () => this.#clientId,
155
+ role: () => 'direct',
156
+ outbox: async () => this.pendingCommits().length,
157
+ subscriptions: async () => this.subscriptions(),
158
+ conflicts: async () => this.conflicts.length,
159
+ rejections: async () => this.rejections.length,
160
+ syncNeeded: async () => this.syncNeeded,
161
+ upgrading: async () => this.upgrading,
162
+ onInvalidate: (listener) => this.onInvalidate(listener),
163
+ });
147
164
  }
148
165
  /**
149
166
  * §7.4.1/§7.4.2: compare the generated schema version to the persisted
@@ -194,6 +211,8 @@ export class SyncClient {
194
211
  this.#config.onUpgrading?.(upgrading);
195
212
  }
196
213
  async close() {
214
+ this.#devtoolsUnregister?.();
215
+ this.#devtoolsUnregister = undefined;
197
216
  this.#socket?.close();
198
217
  this.#socket = undefined;
199
218
  this.#abortPendingRound('client closed mid-round');
@@ -209,8 +228,17 @@ export class SyncClient {
209
228
  get database() {
210
229
  return this.#db;
211
230
  }
231
+ /**
232
+ * The raw-SQL read tier. Guarded (query-guard.ts): a single read-only
233
+ * statement only — writes must go through `mutate()` so they hit the
234
+ * outbox (SPEC §7.1). Reserved `_sync_*` columns are stripped from the
235
+ * result, so a `SELECT *` row is safe to feed back into `mutate()`
236
+ * values; alias explicitly (`_sync_version AS v`) to read one. Engine
237
+ * internals read `this.#db` directly and skip this method by design.
238
+ */
212
239
  query(sql, params) {
213
- return this.#db.query(sql, params);
240
+ assertReadOnlyQuery(sql);
241
+ return stripSyncColumns(this.#db.query(sql, params));
214
242
  }
215
243
  // -- live-query invalidation (TODO 3.1 / DESIGN-eviction I1–I4) -----------
216
244
  /**
@@ -751,6 +779,41 @@ export class SyncClient {
751
779
  });
752
780
  return clientCommitId;
753
781
  }
782
+ /**
783
+ * Partial-update convenience over the §6.1 full-row wire: read the
784
+ * current LOCAL row, merge `partial` over it, and record one full-row
785
+ * upsert through `mutate()`. `partial` keys follow the same two-casing
786
+ * rule as mutation values (snake_case or camelCase). The row must be
787
+ * locally present (subscribed/windowed-in); patching an absent row is
788
+ * an error — there is no base to merge into.
789
+ */
790
+ patch(table, rowId, partial, options) {
791
+ this.#requireStarted();
792
+ const compiled = this.#table(table);
793
+ const pkColumn = compiled.columns[compiled.primaryKeyIndex];
794
+ const rows = this.#db.query(`SELECT * FROM ${quoteIdent(compiled.name)} WHERE ${quoteIdent(pkColumn.name)} = ?`, [rowId]);
795
+ const row = rows[0];
796
+ if (row === undefined) {
797
+ throw new ClientSyncError('sync.invalid_request', `table ${compiled.name}: no local row with primary key ${JSON.stringify(rowId)} to patch`);
798
+ }
799
+ const record = {};
800
+ for (const column of compiled.columns) {
801
+ record[column.name] = fromSqlValue(column, row[column.name] ?? null);
802
+ }
803
+ for (const [name, value] of normalizeRecordKeys(compiled, partial)) {
804
+ record[name] = value;
805
+ }
806
+ return this.mutate([
807
+ {
808
+ table,
809
+ op: 'upsert',
810
+ values: record,
811
+ ...(options?.baseVersion !== undefined
812
+ ? { baseVersion: options.baseVersion }
813
+ : {}),
814
+ },
815
+ ]);
816
+ }
754
817
  // -- lease state (§7.3.5) ---------------------------------------------------
755
818
  /** Merge and persist the lease state (opaque, §7.3.5). */
756
819
  #setLeaseState(next) {
@@ -0,0 +1,50 @@
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 { InvalidationListener } from './invalidation.js';
21
+ /** What a registrant supplies — plain lambdas over its own surface. */
22
+ export interface DevtoolsRegistration {
23
+ /** `'direct'` (a `SyncClient`) or the handle's role. */
24
+ readonly kind: 'client' | 'handle';
25
+ /** The client/handle itself, for full-API console access. */
26
+ readonly ref: unknown;
27
+ readonly clientId: () => string;
28
+ readonly role: () => string;
29
+ readonly outbox: () => Promise<number>;
30
+ readonly subscriptions: () => Promise<readonly unknown[]>;
31
+ readonly conflicts: () => Promise<number>;
32
+ readonly rejections: () => Promise<number>;
33
+ readonly syncNeeded: () => Promise<boolean>;
34
+ readonly upgrading: () => Promise<boolean>;
35
+ readonly onInvalidate: (listener: InvalidationListener) => () => void;
36
+ }
37
+ /** One live entry on the registry (a registration plus tracked state). */
38
+ export interface DevtoolsEntry extends DevtoolsRegistration {
39
+ /** The most recent invalidation event, timestamped (epoch ms). */
40
+ lastInvalidation?: {
41
+ readonly atMs: number;
42
+ readonly tables: readonly string[];
43
+ readonly scopeKeys: readonly string[];
44
+ };
45
+ }
46
+ /**
47
+ * Register a client on the page registry. Returns the unregister function
48
+ * (a no-op when the registry is gated off) — call it from `close()`.
49
+ */
50
+ export declare function registerDevtools(registration: DevtoolsRegistration): () => void;
@@ -0,0 +1,60 @@
1
+ const KEY = '__SYNCULAR__';
2
+ /** The page global to install on, or undefined when gated off. */
3
+ function registryHost() {
4
+ const g = globalThis;
5
+ if (g.window === undefined)
6
+ return undefined;
7
+ if (g.process?.env?.NODE_ENV === 'production')
8
+ return undefined;
9
+ return g;
10
+ }
11
+ function registryOn(host) {
12
+ const existing = host[KEY];
13
+ if (existing !== undefined)
14
+ return existing;
15
+ const registry = {
16
+ clients: [],
17
+ snapshot: async () => Promise.all(registry.clients.map(async (entry) => ({
18
+ kind: entry.kind,
19
+ clientId: entry.clientId(),
20
+ role: entry.role(),
21
+ outbox: await entry.outbox().catch(() => 'unavailable'),
22
+ subscriptions: await entry
23
+ .subscriptions()
24
+ .then((subs) => subs.length)
25
+ .catch(() => 'unavailable'),
26
+ conflicts: await entry.conflicts().catch(() => 'unavailable'),
27
+ rejections: await entry.rejections().catch(() => 'unavailable'),
28
+ syncNeeded: await entry.syncNeeded().catch(() => 'unavailable'),
29
+ upgrading: await entry.upgrading().catch(() => 'unavailable'),
30
+ lastInvalidation: entry.lastInvalidation,
31
+ }))),
32
+ };
33
+ host[KEY] = registry;
34
+ return registry;
35
+ }
36
+ /**
37
+ * Register a client on the page registry. Returns the unregister function
38
+ * (a no-op when the registry is gated off) — call it from `close()`.
39
+ */
40
+ export function registerDevtools(registration) {
41
+ const host = registryHost();
42
+ if (host === undefined)
43
+ return () => { };
44
+ const registry = registryOn(host);
45
+ const entry = { ...registration };
46
+ const unlisten = registration.onInvalidate((event) => {
47
+ entry.lastInvalidation = {
48
+ atMs: Date.now(),
49
+ tables: [...event.tables],
50
+ scopeKeys: [...event.scopeKeys],
51
+ };
52
+ });
53
+ registry.clients.push(entry);
54
+ return () => {
55
+ unlisten();
56
+ const index = registry.clients.indexOf(entry);
57
+ if (index !== -1)
58
+ registry.clients.splice(index, 1);
59
+ };
60
+ }
package/dist/index.d.ts CHANGED
@@ -13,14 +13,18 @@ export * from './blob.js';
13
13
  export * from './client.js';
14
14
  export * from './content-type.js';
15
15
  export * from './database.js';
16
+ export * from './devtools.js';
16
17
  export * from './encryption.js';
17
18
  export * from './errors.js';
18
19
  export * from './http.js';
19
20
  export * from './invalidation.js';
20
21
  export * from './leader-lock.js';
21
22
  export * from './multi-tab.js';
23
+ export * from './naming.js';
22
24
  export * from './outbox.js';
25
+ export * from './query-guard.js';
23
26
  export * from './schema.js';
27
+ export * from './sql-tag.js';
24
28
  export * from './state.js';
25
29
  export * from './transport.js';
26
30
  export * from './window.js';
package/dist/index.js CHANGED
@@ -13,14 +13,18 @@ export * from './blob.js';
13
13
  export * from './client.js';
14
14
  export * from './content-type.js';
15
15
  export * from './database.js';
16
+ export * from './devtools.js';
16
17
  export * from './encryption.js';
17
18
  export * from './errors.js';
18
19
  export * from './http.js';
19
20
  export * from './invalidation.js';
20
21
  export * from './leader-lock.js';
21
22
  export * from './multi-tab.js';
23
+ export * from './naming.js';
22
24
  export * from './outbox.js';
25
+ export * from './query-guard.js';
23
26
  export * from './schema.js';
27
+ export * from './sql-tag.js';
24
28
  export * from './state.js';
25
29
  export * from './transport.js';
26
30
  export * from './window.js';
@@ -0,0 +1,10 @@
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
+ /** The pinned §12 snake→camel conversion (see typegen's naming.ts). */
10
+ export declare function snakeToCamel(name: string): string;
package/dist/naming.js 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
+ const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
10
+ /** The pinned §12 snake→camel conversion (see typegen's naming.ts). */
11
+ export function snakeToCamel(name) {
12
+ if (!MAPPABLE_RE.test(name))
13
+ return name;
14
+ const lead = /^_*/.exec(name)?.[0] ?? '';
15
+ const bare = name.slice(lead.length);
16
+ const trail = /_*$/.exec(bare)?.[0] ?? '';
17
+ const middle = bare.slice(0, bare.length - trail.length);
18
+ const segments = middle.split('_').filter((s) => s.length > 0);
19
+ if (segments.length === 0)
20
+ return name;
21
+ const first = segments[0];
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,29 @@
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
+ /** Raised when `client.query()` is handed SQL it will not run. */
22
+ export declare class RawSqlError extends Error {
23
+ readonly name = "RawSqlError";
24
+ }
25
+ /**
26
+ * Assert `sql` is a single read-only statement, or throw `RawSqlError`.
27
+ * Called by `client.query()` before the string reaches the database.
28
+ */
29
+ export declare function assertReadOnlyQuery(sql: string): void;
@@ -0,0 +1,200 @@
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
+ /** Verbs a read-only query may begin with (lowercased). */
22
+ const READ_ONLY_VERBS = new Set([
23
+ 'select',
24
+ 'with',
25
+ 'explain',
26
+ 'pragma',
27
+ 'values',
28
+ ]);
29
+ /** Raised when `client.query()` is handed SQL it will not run. */
30
+ export class RawSqlError extends Error {
31
+ name = 'RawSqlError';
32
+ }
33
+ /**
34
+ * Split `sql` into top-level statements at unquoted `;`, skipping over
35
+ * string literals ('…'), quoted/bracketed identifiers ("…", `…`, […]) and
36
+ * comments (-- …, /* … *​/) so a `;` inside any of them is not a boundary.
37
+ * Returns the non-empty statements (comment/whitespace-only trailers drop).
38
+ */
39
+ function splitStatements(sql) {
40
+ const statements = [];
41
+ let start = 0;
42
+ let i = 0;
43
+ const n = sql.length;
44
+ const pushIfNonEmpty = (end) => {
45
+ const stripped = stripLeading(sql.slice(start, end));
46
+ if (stripped.length > 0)
47
+ statements.push(sql.slice(start, end));
48
+ start = end + 1;
49
+ };
50
+ while (i < n) {
51
+ const c = sql[i];
52
+ if (c === '-' && sql[i + 1] === '-') {
53
+ const nl = sql.indexOf('\n', i + 2);
54
+ i = nl === -1 ? n : nl + 1;
55
+ }
56
+ else if (c === '/' && sql[i + 1] === '*') {
57
+ const close = sql.indexOf('*/', i + 2);
58
+ i = close === -1 ? n : close + 2;
59
+ }
60
+ else if (c === "'" || c === '"' || c === '`') {
61
+ i = skipQuoted(sql, i, c);
62
+ }
63
+ else if (c === '[') {
64
+ const close = sql.indexOf(']', i + 1);
65
+ i = close === -1 ? n : close + 1;
66
+ }
67
+ else if (c === ';') {
68
+ pushIfNonEmpty(i);
69
+ i += 1;
70
+ }
71
+ else {
72
+ i += 1;
73
+ }
74
+ }
75
+ pushIfNonEmpty(n);
76
+ return statements;
77
+ }
78
+ /** Advance past a quoted run opened at `open`; SQL doubles the quote to escape it. */
79
+ function skipQuoted(sql, open, quote) {
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)
85
+ i += 2;
86
+ else
87
+ return i + 1;
88
+ }
89
+ else {
90
+ i += 1;
91
+ }
92
+ }
93
+ return n;
94
+ }
95
+ /** Strip leading whitespace and comments, returning the remainder. */
96
+ function stripLeading(sql) {
97
+ return sql
98
+ .replace(/^\s*(?:--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/|\s)+/, '')
99
+ .trimStart();
100
+ }
101
+ function firstWords(sql) {
102
+ const trimmed = sql.trim().replace(/\s+/g, ' ');
103
+ return trimmed.length > 72 ? `${trimmed.slice(0, 72)}…` : trimmed;
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) {
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];
127
+ if (c === '-' && sql[i + 1] === '-') {
128
+ const nl = sql.indexOf('\n', i + 2);
129
+ i = nl === -1 ? n : nl + 1;
130
+ }
131
+ else if (c === '/' && sql[i + 1] === '*') {
132
+ const close = sql.indexOf('*/', i + 2);
133
+ i = close === -1 ? n : close + 2;
134
+ }
135
+ else if (c === "'" || c === '"' || c === '`') {
136
+ i = skipQuoted(sql, i, c);
137
+ }
138
+ else if (c === '[') {
139
+ const close = sql.indexOf(']', i + 1);
140
+ i = close === -1 ? n : close + 1;
141
+ }
142
+ else if (c === '(') {
143
+ depth += 1;
144
+ i += 1;
145
+ }
146
+ else if (c === ')') {
147
+ depth -= 1;
148
+ i += 1;
149
+ }
150
+ else if (/[A-Za-z_]/.test(c)) {
151
+ let j = i + 1;
152
+ while (j < n && /[A-Za-z0-9_]/.test(sql[j]))
153
+ j += 1;
154
+ const word = sql.slice(i, j).toLowerCase();
155
+ if (depth === 0) {
156
+ if (!sawWith && word === 'with')
157
+ sawWith = true;
158
+ else if (sawWith && MAIN_VERBS.has(word))
159
+ return word;
160
+ }
161
+ i = j;
162
+ }
163
+ else {
164
+ i += 1;
165
+ }
166
+ }
167
+ return undefined;
168
+ }
169
+ /**
170
+ * Assert `sql` is a single read-only statement, or throw `RawSqlError`.
171
+ * Called by `client.query()` before the string reaches the database.
172
+ */
173
+ export function assertReadOnlyQuery(sql) {
174
+ const statements = splitStatements(sql);
175
+ if (statements.length === 0) {
176
+ throw new RawSqlError('client.query() was given an empty statement.');
177
+ }
178
+ if (statements.length > 1) {
179
+ throw new RawSqlError(`client.query() runs a single statement, but ${statements.length} were ` +
180
+ 'given. Split them into separate query() calls. ' +
181
+ `First: ${firstWords(statements[0] ?? '')}`);
182
+ }
183
+ const statement = stripLeading(statements[0] ?? '');
184
+ const verb = statement.match(/^([a-zA-Z]+)/)?.[1]?.toLowerCase();
185
+ const rejectWrite = () => {
186
+ throw new RawSqlError('client.query() is read-only — this statement writes the local ' +
187
+ 'database directly, which bypasses the sync outbox (SPEC §7.1). Use ' +
188
+ '`client.mutate([...])` for inserts/updates/deletes. ' +
189
+ `Rejected: ${firstWords(sql)}`);
190
+ };
191
+ if (verb === undefined || !READ_ONLY_VERBS.has(verb))
192
+ rejectWrite();
193
+ if (verb === 'with') {
194
+ // SQLite allows `WITH … DELETE/INSERT/UPDATE`; only a SELECT/VALUES
195
+ // main statement is a read.
196
+ const main = mainVerbAfterWith(statement);
197
+ if (main !== 'select' && main !== 'values')
198
+ rejectWrite();
199
+ }
200
+ }
package/dist/schema.d.ts CHANGED
@@ -5,7 +5,7 @@
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.js';
8
+ import type { ClientDatabase, SqlRow, SqlValue } from './database.js';
9
9
  /** `'prefix:{variable}'` shorthand (column name = variable) or explicit. */
10
10
  export type ScopePatternSpec = string | {
11
11
  pattern: string;
@@ -38,6 +38,13 @@ export interface CompiledClientTable {
38
38
  readonly primaryKey: string;
39
39
  readonly primaryKeyIndex: number;
40
40
  readonly columnIndex: ReadonlyMap<string, number>;
41
+ /**
42
+ * §5 mutate key normalization: unambiguous camelCase alias → column
43
+ * index. An alias is dropped when it equals another column's exact name
44
+ * or when two columns map to the same alias (exact names always win; the
45
+ * generator errors on such schemas under camel naming anyway).
46
+ */
47
+ readonly columnIndexByCamel: ReadonlyMap<string, number>;
41
48
  /** Scope variable → local scope column (§3.3 purge mapping). */
42
49
  readonly scopeColumnByVariable: ReadonlyMap<string, string>;
43
50
  /**
@@ -67,6 +74,15 @@ export declare function compileClientSchema(schema: ClientSchema): CompiledClien
67
74
  export declare const SYNC_VERSION_COLUMN = "_sync_version";
68
75
  /** `_sync_version` for optimistic rows the server has never confirmed. */
69
76
  export declare const OPTIMISTIC_VERSION = -1;
77
+ /**
78
+ * Strip the reserved `_sync_*` columns from app-facing query rows, so a
79
+ * `SELECT *` row round-trips straight into `mutate()` values. Result
80
+ * columns are per-statement, so the first row decides for all rows; an
81
+ * explicit alias (`SELECT _sync_version AS v`) passes through untouched.
82
+ * Engine internals read `_sync_version` via `client.database` and never
83
+ * pass through this filter.
84
+ */
85
+ export declare function stripSyncColumns(rows: SqlRow[]): SqlRow[];
70
86
  export declare function quoteIdent(name: string): string;
71
87
  /**
72
88
  * §5.11: the app-side type of a column for local (plaintext) storage. For an
@@ -94,9 +110,18 @@ export declare function dropAndRecreateSyncedTables(db: ClientDatabase, schema:
94
110
  export declare function toSqlValue(value: RowValue): SqlValue;
95
111
  /** SQL cell → RowValue per the column's declared type. */
96
112
  export declare function fromSqlValue(column: RowColumn, value: SqlValue): RowValue;
113
+ /**
114
+ * Normalize an app-facing record's keys to the SQL-truth snake_case column
115
+ * names. Keys are accepted in exactly two casings (§5/§12): snake_case and
116
+ * the generated row types' camelCase — one bijective-map lookup per key,
117
+ * no fuzzy matching. Unknown keys fail loud (with a dedicated hint for the
118
+ * reserved `_sync_*` names); giving one column in both casings is an error.
119
+ */
120
+ export declare function normalizeRecordKeys(table: CompiledClientTable, record: Readonly<Record<string, unknown>>): Map<string, unknown>;
97
121
  /**
98
122
  * App-facing record → schema-ordered row values for the codec and the
99
- * local mirror. Missing keys become NULL; unknown keys fail loud.
123
+ * local mirror. Missing keys become NULL; unknown keys fail loud (see
124
+ * {@link normalizeRecordKeys} for the accepted casings).
100
125
  */
101
126
  export declare function recordToRowValues(table: CompiledClientTable, record: Readonly<Record<string, unknown>>): RowValue[];
102
127
  export type JsonRowValue = string | number | boolean | null | {