@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/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 {
|
|
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
|
-
|
|
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;
|
package/dist/devtools.js
ADDED
|
@@ -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';
|
package/dist/naming.d.ts
ADDED
|
@@ -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 | {
|