@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/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 +6 -6
- 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/src/devtools.ts
ADDED
|
@@ -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
|
-
*
|
|
333
|
-
*
|
|
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
|
|
380
|
+
export function normalizeRecordKeys(
|
|
336
381
|
table: CompiledClientTable,
|
|
337
382
|
record: Readonly<Record<string, unknown>>,
|
|
338
|
-
):
|
|
339
|
-
|
|
340
|
-
|
|
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 =
|
|
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, []);
|
package/src/worker-entry.ts
CHANGED
|
@@ -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());
|