@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/README.md +34 -3
- package/dist/index.d.ts +122 -81
- package/dist/index.js +472 -378
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
package/dist/index.js
CHANGED
|
@@ -1,401 +1,495 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
32
|
+
rejection;
|
|
33
|
+
constructor(rejection) {
|
|
34
|
+
super(rejection.message);
|
|
35
|
+
this.name = "QueryGuardError";
|
|
36
|
+
this.rejection = rejection;
|
|
37
|
+
}
|
|
15
38
|
};
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/db-access.ts
|
|
175
254
|
function toBase64(bytes) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|