@sqlite-sync/ai 0.8.2 → 0.9.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/index.js CHANGED
@@ -1,401 +1,495 @@
1
- // src/db-access.ts
2
- import {
3
- CrdtEventValidationError,
4
- generateId
5
- } from "@sqlite-sync/core";
6
-
7
- // src/query-guard.ts
1
+ import { CrdtEventValidationError, generateId } from "@sqlite-sync/core";
2
+ import { jsonSchema, tool } from "ai";
3
+ //#region src/policy.ts
4
+ /**
5
+ * Flattens the AI access declared on the schema's table builders into the lookups the doc
6
+ * generator and the enforcement points share, so they cannot disagree. Access is table-level:
7
+ * see {@link AiAccess}.
8
+ */
9
+ function resolveAiPolicy(opts) {
10
+ const tables = opts.syncDbSchema.tablesConfig.map(({ crdtTableName, baseTableName }) => ({
11
+ crdtTableName,
12
+ baseTableName,
13
+ access: opts.syncDbSchema.tables[crdtTableName].aiAccess
14
+ }));
15
+ const byName = /* @__PURE__ */ new Map();
16
+ for (const table of tables) {
17
+ byName.set(table.crdtTableName, table);
18
+ byName.set(table.baseTableName, table);
19
+ }
20
+ return {
21
+ tables,
22
+ hasHiddenTables: tables.some((table) => table.access === "hidden"),
23
+ readableBaseTableNames: tables.filter((table) => table.access !== "hidden").map((table) => table.baseTableName),
24
+ tableAccess(dataset) {
25
+ return byName.get(dataset)?.access ?? "hidden";
26
+ }
27
+ };
28
+ }
29
+ //#endregion
30
+ //#region src/query-guard.ts
8
31
  var QueryGuardError = class extends Error {
9
- rejection;
10
- constructor(rejection) {
11
- super(rejection.message);
12
- this.name = "QueryGuardError";
13
- this.rejection = rejection;
14
- }
32
+ rejection;
33
+ constructor(rejection) {
34
+ super(rejection.message);
35
+ this.name = "QueryGuardError";
36
+ this.rejection = rejection;
37
+ }
15
38
  };
16
- var WRITE_OPCODES = /* @__PURE__ */ new Set([
17
- "OpenWrite",
18
- "Clear",
19
- "Destroy",
20
- "CreateBtree",
21
- "SqlExec",
22
- "ParseSchema",
23
- "DropTable",
24
- "DropIndex",
25
- "DropTrigger",
26
- "SetCookie",
27
- "JournalMode",
28
- "Vacuum",
29
- "IncrVacuum",
30
- "Checkpoint",
31
- "MaxPgcnt",
32
- "Expire",
33
- "AutoCommit",
34
- "VUpdate",
35
- "VCreate",
36
- "VDestroy",
37
- "Program"
39
+ /**
40
+ * Opcodes that prove a statement is not read-only. `OpenWrite` covers row-level writes (every
41
+ * real-table mutation opens its cursor through it), `Clear`/`Destroy` cover whole-table
42
+ * deletes that skip cursors (truncate optimization), the rest cover DDL, pragma-class
43
+ * statements (which a transaction rollback would NOT undo), virtual-table writes, and trigger
44
+ * subprograms. `Insert`/`Delete`/`IdxInsert` are deliberately absent — they also run against
45
+ * ephemeral/sorter cursors in ordinary SELECTs (DISTINCT, ORDER BY) and are only dangerous on
46
+ * a cursor an `OpenWrite` would have created.
47
+ */
48
+ const WRITE_OPCODES = /* @__PURE__ */ new Set([
49
+ "OpenWrite",
50
+ "Clear",
51
+ "Destroy",
52
+ "CreateBtree",
53
+ "SqlExec",
54
+ "ParseSchema",
55
+ "DropTable",
56
+ "DropIndex",
57
+ "DropTrigger",
58
+ "SetCookie",
59
+ "JournalMode",
60
+ "Vacuum",
61
+ "IncrVacuum",
62
+ "Checkpoint",
63
+ "MaxPgcnt",
64
+ "Expire",
65
+ "AutoCommit",
66
+ "VUpdate",
67
+ "VCreate",
68
+ "VDestroy",
69
+ "Program"
38
70
  ]);
39
- var READ_STATEMENT_PREFIXES = ["select", "with", "values"];
71
+ /**
72
+ * Opcodes that open a b-tree cursor for reading. `p2` is the root page of the table or index
73
+ * and `p3` the database it lives in (0 = main), which is how a statement's real table set is
74
+ * recovered — views are already flattened into their base tables by the time bytecode exists,
75
+ * so aliases, CTEs, subqueries and quoting tricks all resolve here.
76
+ */
77
+ const READ_CURSOR_OPCODES = /* @__PURE__ */ new Set(["OpenRead", "ReopenIdx"]);
78
+ /**
79
+ * Table-valued pragma functions (`pragma_table_list()`, ...) read the schema through a virtual
80
+ * table, so no root page identifies them. They expose names and columns rather than rows, but a
81
+ * hidden table should not be enumerable either, so they are rejected outright when restricted.
82
+ */
83
+ const PRAGMA_FUNCTION_PATTERN = /\bpragma_[a-z_]+\s*\(/i;
84
+ const READ_STATEMENT_PREFIXES = [
85
+ "select",
86
+ "with",
87
+ "values"
88
+ ];
40
89
  function createQueryGuard(opts) {
41
- function reject(code, message) {
42
- return { allowed: false, code, message };
43
- }
44
- function check(input) {
45
- let body = input.sql.trim();
46
- while (body.endsWith(";")) {
47
- body = body.slice(0, -1).trimEnd();
48
- }
49
- if (body.includes(";")) {
50
- return reject(
51
- "multi-statement",
52
- "Only a single SQL statement is allowed per query, and semicolons are only allowed at the very end. If the semicolon is inside a string literal, pass the value as a bound parameter instead."
53
- );
54
- }
55
- const lowered = body.toLowerCase();
56
- if (!READ_STATEMENT_PREFIXES.some((keyword) => lowered.startsWith(keyword))) {
57
- return reject(
58
- "invalid-statement",
59
- "Only read-only queries are allowed: the statement must start directly with SELECT, WITH, or VALUES (no leading comments)."
60
- );
61
- }
62
- let operations;
63
- try {
64
- operations = opts.executor.execute({
65
- sql: `EXPLAIN ${input.sql}`,
66
- parameters: input.parameters ?? []
67
- }).rows;
68
- } catch (error) {
69
- return reject("invalid-sql", `SQL error: ${error instanceof Error ? error.message : String(error)}`);
70
- }
71
- for (const operation of operations) {
72
- if (WRITE_OPCODES.has(operation.opcode)) {
73
- return reject(
74
- "write-detected",
75
- `The statement was rejected because it would modify the database (${operation.opcode}). This tool is strictly read-only; rewrite the query as a pure SELECT.`
76
- );
77
- }
78
- }
79
- return { allowed: true };
80
- }
81
- return {
82
- check,
83
- execute(input) {
84
- const verdict = check(input);
85
- if (!verdict.allowed) {
86
- throw new QueryGuardError(verdict);
87
- }
88
- return runWithForcedRollback(opts.executor, (tx) => {
89
- return tx.execute({ sql: input.sql, parameters: input.parameters ?? [] });
90
- });
91
- }
92
- };
90
+ const readableTables = opts.readableTables ? new Set(opts.readableTables.map((name) => name.toLowerCase())) : null;
91
+ const readableTablesLabel = opts.readableTables?.join(", ") ?? "";
92
+ /**
93
+ * Root page to table name, for every b-tree in the main database. Re-read per query instead of
94
+ * cached: a migration can drop a table and hand its root page to another one, and a stale map
95
+ * would then resolve that page to the wrong (possibly allowed) name.
96
+ */
97
+ function readRootPages() {
98
+ const rows = opts.executor.execute({
99
+ sql: `SELECT "tbl_name", "rootpage" FROM "sqlite_master" WHERE "rootpage" IS NOT NULL AND "rootpage" > 0`,
100
+ parameters: []
101
+ }).rows;
102
+ return new Map(rows.map((row) => [Number(row.rootpage), row.tbl_name]));
103
+ }
104
+ function checkReadableTables(sql, operations) {
105
+ if (!readableTables) return null;
106
+ if (PRAGMA_FUNCTION_PATTERN.test(sql)) return reject("table-denied", `Pragma functions are not available. You can only read these tables: ${readableTablesLabel}.`);
107
+ const cursors = operations.filter((operation) => READ_CURSOR_OPCODES.has(operation.opcode));
108
+ if (cursors.length === 0) return null;
109
+ let rootPages;
110
+ try {
111
+ rootPages = readRootPages();
112
+ } catch {
113
+ return reject("table-denied", "The tables this statement reads could not be verified, so it was rejected.");
114
+ }
115
+ for (const cursor of cursors) {
116
+ const name = Number(cursor.p3) === 0 ? rootPages.get(Number(cursor.p2)) : void 0;
117
+ if (!name || !readableTables.has(name.toLowerCase())) return reject("table-denied", `${name ? `Table "${name}"` : "A table this statement reads"} is not available to you. You can only read these tables: ${readableTablesLabel}.`);
118
+ }
119
+ return null;
120
+ }
121
+ function reject(code, message) {
122
+ return {
123
+ allowed: false,
124
+ code,
125
+ message
126
+ };
127
+ }
128
+ function check(input) {
129
+ let body = input.sql.trim();
130
+ while (body.endsWith(";")) body = body.slice(0, -1).trimEnd();
131
+ if (body.includes(";")) return reject("multi-statement", "Only a single SQL statement is allowed per query, and semicolons are only allowed at the very end. If the semicolon is inside a string literal, pass the value as a bound parameter instead.");
132
+ const lowered = body.toLowerCase();
133
+ if (!READ_STATEMENT_PREFIXES.some((keyword) => lowered.startsWith(keyword))) return reject("invalid-statement", "Only read-only queries are allowed: the statement must start directly with SELECT, WITH, or VALUES (no leading comments).");
134
+ let operations;
135
+ try {
136
+ operations = opts.executor.execute({
137
+ sql: `EXPLAIN ${input.sql}`,
138
+ parameters: input.parameters ?? []
139
+ }).rows;
140
+ } catch (error) {
141
+ return reject("invalid-sql", `SQL error: ${error instanceof Error ? error.message : String(error)}`);
142
+ }
143
+ for (const operation of operations) if (WRITE_OPCODES.has(operation.opcode)) return reject("write-detected", `The statement was rejected because it would modify the database (${operation.opcode}). This tool is strictly read-only; rewrite the query as a pure SELECT.`);
144
+ const denied = checkReadableTables(body, operations);
145
+ if (denied) return denied;
146
+ return { allowed: true };
147
+ }
148
+ return {
149
+ check,
150
+ execute(input) {
151
+ const verdict = check(input);
152
+ if (!verdict.allowed) throw new QueryGuardError(verdict);
153
+ return runWithForcedRollback(opts.executor, (tx) => {
154
+ return tx.execute({
155
+ sql: input.sql,
156
+ parameters: input.parameters ?? []
157
+ });
158
+ });
159
+ }
160
+ };
93
161
  }
162
+ /**
163
+ * Runs `callback` in a transaction that is always rolled back (via a sentinel throw, relying
164
+ * on the {@link AiDbExecutor} contract that a throwing callback rolls back). This is the
165
+ * unconditional write guard backing any gap in the static analysis.
166
+ */
94
167
  function runWithForcedRollback(executor, callback) {
95
- let result;
96
- let completed = false;
97
- const rollbackSentinel = new Error("forced read-only rollback");
98
- try {
99
- executor.transaction((tx) => {
100
- result = callback(tx);
101
- completed = true;
102
- throw rollbackSentinel;
103
- });
104
- } catch (error) {
105
- if (error !== rollbackSentinel) {
106
- throw error;
107
- }
108
- }
109
- if (!completed) {
110
- throw new Error("Transaction completed without running the callback");
111
- }
112
- return result;
168
+ let result;
169
+ let completed = false;
170
+ const rollbackSentinel = /* @__PURE__ */ new Error("forced read-only rollback");
171
+ try {
172
+ executor.transaction((tx) => {
173
+ result = callback(tx);
174
+ completed = true;
175
+ throw rollbackSentinel;
176
+ });
177
+ } catch (error) {
178
+ if (error !== rollbackSentinel) throw error;
179
+ }
180
+ if (!completed) throw new Error("Transaction completed without running the callback");
181
+ return result;
113
182
  }
114
-
115
- // src/schema-doc.ts
116
- var SCHEMA_DOC_PREAMBLE = [
117
- "This is a synced SQLite database \u2014 data replicates automatically between the user's devices.",
118
- "All writes go through a sync event log, which is why the tables listed below are exposed as",
119
- "read-only SQL views; soft-deleted rows are already filtered out, so query them directly",
120
- "without any tombstone filtering. Every table has a unique `id` text primary key."
183
+ //#endregion
184
+ //#region src/schema-doc.ts
185
+ const SCHEMA_DOC_PREAMBLE = [
186
+ "This is a synced SQLite database — data replicates automatically between the user's devices.",
187
+ "All writes go through a sync event log, which is why the tables listed below are exposed as",
188
+ "read-only SQL views; soft-deleted rows are already filtered out, so query them directly",
189
+ "without any tombstone filtering. Every table has a unique `id` text primary key."
121
190
  ].join("\n");
191
+ const RESTRICTED_READS_NOTE = ["Only the tables documented below are readable. Queries that touch any other table are", "rejected, including the internal sync event log, so there is no change history available."].join("\n");
122
192
  function renderColumn(name, meta) {
123
- let line = `- \`${name}\` ${meta.sqlType.toUpperCase()}`;
124
- if (!meta.nullable) {
125
- line += " NOT NULL";
126
- }
127
- if (meta.kind === "boolean") {
128
- line += " (boolean 0/1)";
129
- } else if (meta.kind === "enum") {
130
- line += ` (one of ${(meta.enumValues ?? []).map((value) => `"${value}"`).join(" | ")})`;
131
- }
132
- return meta.description ? `${line} \u2014 ${meta.description}` : line;
193
+ let line = `- \`${name}\` ${meta.sqlType.toUpperCase()}`;
194
+ if (!meta.nullable) line += " NOT NULL";
195
+ if (meta.kind === "boolean") line += " (boolean 0/1)";
196
+ else if (meta.kind === "enum") line += ` (one of ${(meta.enumValues ?? []).map((value) => `"${value}"`).join(" | ")})`;
197
+ return meta.description ? `${line} — ${meta.description}` : line;
133
198
  }
199
+ /**
200
+ * Generates a markdown schema doc for an AI agent from the declared sync schema's table
201
+ * builders — no database access needed. Tables are presented under the view names the agent
202
+ * queries; descriptions come from `.describe()` on the table and column builders.
203
+ * The internal `tombstone` column is omitted.
204
+ *
205
+ * Tables declared `.ai("hidden")` are left out entirely, and read-only ones are labelled so the
206
+ * agent does not attempt a mutation that would be rejected.
207
+ *
208
+ * The doc always includes a built-in preamble explaining sqlite-sync mechanics (read-only
209
+ * views, soft-deletes already filtered) after the consumer's `overview` — consumers only
210
+ * need to describe their own domain.
211
+ */
134
212
  function createSchemaDoc(opts) {
135
- const sections = [];
136
- const overview = opts.context?.overview?.trim();
137
- if (overview) {
138
- sections.push(overview);
139
- }
140
- sections.push(SCHEMA_DOC_PREAMBLE);
141
- for (const [crdtTableName, table] of Object.entries(opts.syncDbSchema.tables)) {
142
- const lines = [`## ${crdtTableName}`];
143
- if (table.description) {
144
- lines.push("", table.description.trim());
145
- }
146
- lines.push("", "Columns:");
147
- for (const [name, meta] of Object.entries(table.columns)) {
148
- if (name === "tombstone") continue;
149
- lines.push(renderColumn(name, meta));
150
- }
151
- sections.push(lines.join("\n"));
152
- }
153
- sections.push(CHANGE_HISTORY_DOC);
154
- return sections.join("\n\n");
213
+ const policy = resolveAiPolicy({ syncDbSchema: opts.syncDbSchema });
214
+ const sections = [];
215
+ const overview = opts.context?.overview?.trim();
216
+ if (overview) sections.push(overview);
217
+ sections.push(SCHEMA_DOC_PREAMBLE);
218
+ if (policy.hasHiddenTables) sections.push(RESTRICTED_READS_NOTE);
219
+ for (const [crdtTableName, table] of Object.entries(opts.syncDbSchema.tables)) {
220
+ const access = policy.tableAccess(crdtTableName);
221
+ if (access === "hidden") continue;
222
+ const lines = [`## ${crdtTableName}`];
223
+ if (table.description) lines.push("", table.description.trim());
224
+ if (access === "read-only") lines.push("", "Read-only: you can query this table but cannot create, update, or delete its rows.");
225
+ lines.push("", "Columns:");
226
+ for (const [name, meta] of Object.entries(table.columns)) {
227
+ if (name === "tombstone") continue;
228
+ lines.push(renderColumn(name, meta));
229
+ }
230
+ sections.push(lines.join("\n"));
231
+ }
232
+ if (!policy.hasHiddenTables) sections.push(CHANGE_HISTORY_DOC);
233
+ return sections.join("\n\n");
155
234
  }
156
- var CHANGE_HISTORY_DOC = [
157
- "## change_history",
158
- "",
159
- "A read-only, append-only log of every change across all tables, one row per sync event.",
160
- "Query it like any other view. Unlike the tables above, soft-delete filtering does NOT apply",
161
- "here \u2014 it includes changes to items that were later deleted, so treat it as an audit log.",
162
- "",
163
- "Columns:",
164
- "- `seq` INTEGER NOT NULL \u2014 monotonic sequence number; **order history by this** (ascending = oldest first)",
165
- "- `dataset` TEXT NOT NULL \u2014 which table the change applies to (matches a table name above)",
166
- "- `item_id` TEXT NOT NULL \u2014 the `id` of the affected row",
167
- '- `change_type` TEXT NOT NULL (one of "item-created" | "item-updated" | "item-deleted")',
168
- '- `status` TEXT NOT NULL (one of "applied" | "pending" | "failed" | "deduped") \u2014 "applied" took effect; "failed" did not; filter to `status = \'applied\'` for the effective history',
169
- '- `origin` TEXT NOT NULL \u2014 where the change came from (e.g. "own", "remote", "local")',
170
- "- `timestamp` TEXT NOT NULL \u2014 opaque hybrid-logical-clock string; do NOT parse it as a date, order by `seq` instead",
171
- "- `changes` TEXT NOT NULL \u2014 JSON of what the change set: the changed columns only for item-updated, the full initial row for item-created, `{}` for item-deleted. Use `json_extract(changes, '$.column')` to read a field."
235
+ const CHANGE_HISTORY_DOC = [
236
+ "## change_history",
237
+ "",
238
+ "A read-only, append-only log of every change across all tables, one row per sync event.",
239
+ "Query it like any other view. Unlike the tables above, soft-delete filtering does NOT apply",
240
+ "here — it includes changes to items that were later deleted, so treat it as an audit log.",
241
+ "",
242
+ "Columns:",
243
+ "- `seq` INTEGER NOT NULL — monotonic sequence number; **order history by this** (ascending = oldest first)",
244
+ "- `dataset` TEXT NOT NULL — which table the change applies to (matches a table name above)",
245
+ "- `item_id` TEXT NOT NULL — the `id` of the affected row",
246
+ "- `change_type` TEXT NOT NULL (one of \"item-created\" | \"item-updated\" | \"item-deleted\")",
247
+ "- `status` TEXT NOT NULL (one of \"applied\" | \"pending\" | \"failed\" | \"deduped\") — \"applied\" took effect; \"failed\" did not; filter to `status = 'applied'` for the effective history",
248
+ "- `origin` TEXT NOT NULL — where the change came from (e.g. \"own\", \"remote\", \"local\")",
249
+ "- `timestamp` TEXT NOT NULL — opaque hybrid-logical-clock string; do NOT parse it as a date, order by `seq` instead",
250
+ "- `changes` TEXT NOT NULL — JSON of what the change set: the changed columns only for item-updated, the full initial row for item-created, `{}` for item-deleted. Use `json_extract(changes, '$.column')` to read a field."
172
251
  ].join("\n");
173
-
174
- // src/db-access.ts
252
+ //#endregion
253
+ //#region src/db-access.ts
175
254
  function toBase64(bytes) {
176
- let binary = "";
177
- for (const byte of bytes) {
178
- binary += String.fromCharCode(byte);
179
- }
180
- return btoa(binary);
255
+ let binary = "";
256
+ for (const byte of bytes) binary += String.fromCharCode(byte);
257
+ return btoa(binary);
181
258
  }
182
259
  function createAiDbAccess(opts) {
183
- const schemaDoc = createSchemaDoc({ syncDbSchema: opts.syncDbSchema, context: opts.context });
184
- const guard = createQueryGuard({ executor: opts.executor });
185
- const maxRows = opts.limits?.maxRows ?? 200;
186
- const maxCellChars = opts.limits?.maxCellChars ?? 2e3;
187
- const access = {
188
- getSchemaDoc() {
189
- return schemaDoc;
190
- },
191
- query(input) {
192
- let resultRows;
193
- try {
194
- resultRows = guard.execute(input).rows;
195
- } catch (error) {
196
- if (error instanceof QueryGuardError) {
197
- return { error: error.message };
198
- }
199
- throw error;
200
- }
201
- let truncated = resultRows.length > maxRows;
202
- function shapeCell(value) {
203
- if (value instanceof Uint8Array) {
204
- if (Math.ceil(value.byteLength / 3) * 4 <= maxCellChars) {
205
- return `<blob base64 ${toBase64(value)}>`;
206
- }
207
- truncated = true;
208
- return `<blob ${value.byteLength} bytes>`;
209
- }
210
- if (typeof value === "string" && value.length > maxCellChars) {
211
- truncated = true;
212
- return `${value.slice(0, maxCellChars)}\u2026`;
213
- }
214
- return value;
215
- }
216
- const rows = resultRows.slice(0, maxRows).map((row) => Object.fromEntries(Object.entries(row).map(([column, value]) => [column, shapeCell(value)])));
217
- return { rows, rowCount: resultRows.length, truncated };
218
- }
219
- };
220
- const storage = opts.storage;
221
- if (storage) {
222
- access.mutate = (input) => {
223
- const errors = [];
224
- const createdIds = [];
225
- const events = [];
226
- for (const [index, event] of input.events.entries()) {
227
- if (event.type === "item-created") {
228
- const looseEvent = event;
229
- const payload = event.payload ?? {};
230
- if (looseEvent.item_id !== void 0) {
231
- errors.push(`[${index}] item-created events must omit item_id; an id is generated automatically`);
232
- }
233
- if ("id" in payload) {
234
- errors.push(`[${index}] item-created payload must omit id; an id is generated automatically`);
235
- }
236
- if (looseEvent.item_id !== void 0 || "id" in payload) {
237
- continue;
238
- }
239
- const id = generateId();
240
- createdIds.push(id);
241
- events.push({
242
- type: "item-created",
243
- dataset: event.dataset,
244
- item_id: id,
245
- payload: JSON.stringify({ ...payload, id })
246
- });
247
- continue;
248
- }
249
- events.push({
250
- type: event.type,
251
- dataset: event.dataset,
252
- item_id: event.item_id,
253
- payload: JSON.stringify(event.payload ?? {})
254
- });
255
- }
256
- if (errors.length > 0) {
257
- return { error: `Invalid mutation events: ${errors.join("; ")}`, errors };
258
- }
259
- try {
260
- storage.applyOwnEvents(events);
261
- } catch (error) {
262
- if (error instanceof CrdtEventValidationError) {
263
- return { error: error.message, errors: error.errors };
264
- }
265
- throw error;
266
- }
267
- return { applied: true, eventCount: events.length, createdIds };
268
- };
269
- }
270
- return access;
260
+ const policy = resolveAiPolicy({ syncDbSchema: opts.syncDbSchema });
261
+ const schemaDoc = createSchemaDoc({
262
+ syncDbSchema: opts.syncDbSchema,
263
+ context: opts.context
264
+ });
265
+ const guard = createQueryGuard({
266
+ executor: opts.executor,
267
+ readableTables: policy.hasHiddenTables ? policy.readableBaseTableNames : void 0
268
+ });
269
+ const maxRows = opts.limits?.maxRows ?? 200;
270
+ const maxCellChars = opts.limits?.maxCellChars ?? 2e3;
271
+ const access = {
272
+ getSchemaDoc() {
273
+ return schemaDoc;
274
+ },
275
+ query(input) {
276
+ let resultRows;
277
+ try {
278
+ resultRows = guard.execute(input).rows;
279
+ } catch (error) {
280
+ if (error instanceof QueryGuardError) return { error: error.message };
281
+ throw error;
282
+ }
283
+ let truncated = resultRows.length > maxRows;
284
+ function shapeCell(value) {
285
+ if (value instanceof Uint8Array) {
286
+ if (Math.ceil(value.byteLength / 3) * 4 <= maxCellChars) return `<blob base64 ${toBase64(value)}>`;
287
+ truncated = true;
288
+ return `<blob ${value.byteLength} bytes>`;
289
+ }
290
+ if (typeof value === "string" && value.length > maxCellChars) {
291
+ truncated = true;
292
+ return `${value.slice(0, maxCellChars)}…`;
293
+ }
294
+ return value;
295
+ }
296
+ return {
297
+ rows: resultRows.slice(0, maxRows).map((row) => Object.fromEntries(Object.entries(row).map(([column, value]) => [column, shapeCell(value)]))),
298
+ rowCount: resultRows.length,
299
+ truncated
300
+ };
301
+ }
302
+ };
303
+ const storage = opts.storage;
304
+ if (storage) access.mutate = (input) => {
305
+ const errors = [];
306
+ const createdIds = [];
307
+ const events = [];
308
+ for (const [index, event] of input.events.entries()) {
309
+ const tableAccess = policy.tableAccess(event.dataset);
310
+ if (tableAccess === "hidden") {
311
+ errors.push(`[${index}] unknown or unavailable dataset "${event.dataset}"`);
312
+ continue;
313
+ }
314
+ if (tableAccess === "read-only") {
315
+ errors.push(`[${index}] dataset "${event.dataset}" is read-only and cannot be modified`);
316
+ continue;
317
+ }
318
+ if (event.type === "item-created") {
319
+ const looseEvent = event;
320
+ const payload = event.payload ?? {};
321
+ if (looseEvent.item_id !== void 0) errors.push(`[${index}] item-created events must omit item_id; an id is generated automatically`);
322
+ if ("id" in payload) errors.push(`[${index}] item-created payload must omit id; an id is generated automatically`);
323
+ if (looseEvent.item_id !== void 0 || "id" in payload) continue;
324
+ const id = generateId();
325
+ createdIds.push(id);
326
+ events.push({
327
+ type: "item-created",
328
+ dataset: event.dataset,
329
+ item_id: id,
330
+ payload: JSON.stringify({
331
+ ...payload,
332
+ id
333
+ })
334
+ });
335
+ continue;
336
+ }
337
+ events.push({
338
+ type: event.type,
339
+ dataset: event.dataset,
340
+ item_id: event.item_id,
341
+ payload: JSON.stringify(event.payload ?? {})
342
+ });
343
+ }
344
+ if (errors.length > 0) return {
345
+ error: `Invalid mutation events: ${errors.join("; ")}`,
346
+ errors
347
+ };
348
+ try {
349
+ storage.applyOwnEvents(events);
350
+ } catch (error) {
351
+ if (error instanceof CrdtEventValidationError) return {
352
+ error: error.message,
353
+ errors: error.errors
354
+ };
355
+ throw error;
356
+ }
357
+ return {
358
+ applied: true,
359
+ eventCount: events.length,
360
+ createdIds
361
+ };
362
+ };
363
+ return access;
271
364
  }
272
-
273
- // src/tools.ts
274
- import { jsonSchema, tool } from "ai";
275
- var emptyInputSchema = jsonSchema({
276
- type: "object",
277
- properties: {},
278
- additionalProperties: false
365
+ //#endregion
366
+ //#region src/tools.ts
367
+ const emptyInputSchema = jsonSchema({
368
+ type: "object",
369
+ properties: {},
370
+ additionalProperties: false
279
371
  });
280
- var queryInputSchema = jsonSchema({
281
- type: "object",
282
- properties: {
283
- sql: {
284
- type: "string",
285
- description: "A single read-only SQLite statement starting with SELECT, WITH, or VALUES. Use ? placeholders for values."
286
- },
287
- parameters: {
288
- type: "array",
289
- items: { type: ["string", "number", "boolean", "null"] },
290
- description: "Values bound to the ? placeholders, in order."
291
- }
292
- },
293
- required: ["sql"],
294
- additionalProperties: false
372
+ const queryInputSchema = jsonSchema({
373
+ type: "object",
374
+ properties: {
375
+ sql: {
376
+ type: "string",
377
+ description: "A single read-only SQLite statement starting with SELECT, WITH, or VALUES. Use ? placeholders for values."
378
+ },
379
+ parameters: {
380
+ type: "array",
381
+ items: { type: [
382
+ "string",
383
+ "number",
384
+ "boolean",
385
+ "null"
386
+ ] },
387
+ description: "Values bound to the ? placeholders, in order."
388
+ }
389
+ },
390
+ required: ["sql"],
391
+ additionalProperties: false
295
392
  });
296
- var mutationInputSchema = jsonSchema({
297
- type: "object",
298
- properties: {
299
- events: {
300
- type: "array",
301
- minItems: 1,
302
- items: {
303
- anyOf: [
304
- {
305
- type: "object",
306
- properties: {
307
- type: {
308
- type: "string",
309
- enum: ["item-created"],
310
- description: "Create a synced row. Omit item_id and payload.id; the tool generates the id."
311
- },
312
- dataset: {
313
- type: "string",
314
- description: "The synced dataset/table name from the schema documentation."
315
- },
316
- payload: {
317
- type: "object",
318
- additionalProperties: true,
319
- not: { required: ["id"] },
320
- description: "Column values for the new row, excluding id. Include all required non-id columns from the schema."
321
- }
322
- },
323
- required: ["type", "dataset", "payload"],
324
- additionalProperties: false
325
- },
326
- {
327
- type: "object",
328
- properties: {
329
- type: {
330
- type: "string",
331
- enum: ["item-updated", "item-deleted"],
332
- description: "Update or delete an existing synced row."
333
- },
334
- dataset: {
335
- type: "string",
336
- description: "The synced dataset/table name from the schema documentation."
337
- },
338
- item_id: {
339
- type: "string",
340
- description: "The stable id of the row being updated or deleted."
341
- },
342
- payload: {
343
- type: "object",
344
- additionalProperties: true,
345
- description: "Changed column values for item-updated. Omit or pass {} for item-deleted."
346
- }
347
- },
348
- required: ["type", "dataset", "item_id"],
349
- additionalProperties: false
350
- }
351
- ]
352
- },
353
- description: "One or more CRDT mutation events to apply atomically."
354
- }
355
- },
356
- required: ["events"],
357
- additionalProperties: false
393
+ const mutationInputSchema = jsonSchema({
394
+ type: "object",
395
+ properties: { events: {
396
+ type: "array",
397
+ minItems: 1,
398
+ items: { anyOf: [{
399
+ type: "object",
400
+ properties: {
401
+ type: {
402
+ type: "string",
403
+ enum: ["item-created"],
404
+ description: "Create a synced row. Omit item_id and payload.id; the tool generates the id."
405
+ },
406
+ dataset: {
407
+ type: "string",
408
+ description: "The synced dataset/table name from the schema documentation."
409
+ },
410
+ payload: {
411
+ type: "object",
412
+ additionalProperties: true,
413
+ not: { required: ["id"] },
414
+ description: "Column values for the new row, excluding id. Include all required non-id columns from the schema."
415
+ }
416
+ },
417
+ required: [
418
+ "type",
419
+ "dataset",
420
+ "payload"
421
+ ],
422
+ additionalProperties: false
423
+ }, {
424
+ type: "object",
425
+ properties: {
426
+ type: {
427
+ type: "string",
428
+ enum: ["item-updated", "item-deleted"],
429
+ description: "Update or delete an existing synced row."
430
+ },
431
+ dataset: {
432
+ type: "string",
433
+ description: "The synced dataset/table name from the schema documentation."
434
+ },
435
+ item_id: {
436
+ type: "string",
437
+ description: "The stable id of the row being updated or deleted."
438
+ },
439
+ payload: {
440
+ type: "object",
441
+ additionalProperties: true,
442
+ description: "Changed column values for item-updated. Omit or pass {} for item-deleted."
443
+ }
444
+ },
445
+ required: [
446
+ "type",
447
+ "dataset",
448
+ "item_id"
449
+ ],
450
+ additionalProperties: false
451
+ }] },
452
+ description: "One or more CRDT mutation events to apply atomically."
453
+ } },
454
+ required: ["events"],
455
+ additionalProperties: false
358
456
  });
457
+ /**
458
+ * AI SDK tools for a synced database. `access` is a factory because acquiring the database
459
+ * may itself be async per call (e.g. resolving a Durable Object stub from another DO).
460
+ */
359
461
  function createDbTools(opts) {
360
- const tools = {
361
- getDbSchema: tool({
362
- description: "Get the schema documentation for the synced SQLite database: tables, columns, types, and data conventions. Call this before reasoning about the data.",
363
- inputSchema: emptyInputSchema,
364
- execute: async () => {
365
- const access = await opts.access();
366
- return await access.getSchemaDoc();
367
- }
368
- }),
369
- queryDb: tool({
370
- description: "Run a read-only SQL query against the synced SQLite database. Only a single SELECT/WITH/VALUES statement is allowed \u2014 anything that writes is rejected. Pass values as ? placeholders via `parameters` instead of inlining them as literals. Results are capped; ask for fewer columns or add LIMIT/WHERE if `truncated` is true.",
371
- inputSchema: queryInputSchema,
372
- execute: async ({ sql, parameters }) => {
373
- const access = await opts.access();
374
- return await access.query({ sql, parameters });
375
- }
376
- })
377
- };
378
- if (opts.mutations) {
379
- tools.mutateDb = tool({
380
- description: "Apply one or more CRDT mutation events to the synced database. Use this for writes instead of SQL. Query the current data first when updating or deleting existing rows. Create events must omit ids: do not provide item_id or payload.id, because the tool generates ids and returns them. Create payloads must include all required non-id columns, update events should include only changed columns, and delete events should use an empty payload.",
381
- inputSchema: mutationInputSchema,
382
- execute: async (input) => {
383
- const access = await opts.access();
384
- if (!access.mutate) {
385
- return { error: "Database mutations are not enabled for this access object." };
386
- }
387
- return await access.mutate(input);
388
- }
389
- });
390
- }
391
- return tools;
462
+ const tools = {
463
+ getDbSchema: tool({
464
+ description: "Get the schema documentation for the synced SQLite database: tables, columns, types, and data conventions. Call this before reasoning about the data.",
465
+ inputSchema: emptyInputSchema,
466
+ execute: async () => {
467
+ return await (await opts.access()).getSchemaDoc();
468
+ }
469
+ }),
470
+ queryDb: tool({
471
+ description: "Run a read-only SQL query against the synced SQLite database. Only a single SELECT/WITH/VALUES statement is allowed — anything that writes is rejected. Pass values as ? placeholders via `parameters` instead of inlining them as literals. Results are capped; ask for fewer columns or add LIMIT/WHERE if `truncated` is true.",
472
+ inputSchema: queryInputSchema,
473
+ execute: async ({ sql, parameters }) => {
474
+ return await (await opts.access()).query({
475
+ sql,
476
+ parameters
477
+ });
478
+ }
479
+ })
480
+ };
481
+ if (opts.mutations) tools.mutateDb = tool({
482
+ description: "Apply one or more CRDT mutation events to the synced database. Use this for writes instead of SQL. Query the current data first when updating or deleting existing rows. Create events must omit ids: do not provide item_id or payload.id, because the tool generates ids and returns them. Create payloads must include all required non-id columns, update events should include only changed columns, and delete events should use an empty payload. Tables the schema documentation marks read-only cannot be written.",
483
+ inputSchema: mutationInputSchema,
484
+ execute: async (input) => {
485
+ const access = await opts.access();
486
+ if (!access.mutate) return { error: "Database mutations are not enabled for this access object." };
487
+ return await access.mutate(input);
488
+ }
489
+ });
490
+ return tools;
392
491
  }
393
- export {
394
- QueryGuardError,
395
- createAiDbAccess,
396
- createDbTools,
397
- createQueryGuard,
398
- createSchemaDoc,
399
- runWithForcedRollback
400
- };
492
+ //#endregion
493
+ export { QueryGuardError, createAiDbAccess, createDbTools, createQueryGuard, createSchemaDoc, resolveAiPolicy, runWithForcedRollback };
494
+
401
495
  //# sourceMappingURL=index.js.map