@sqlite-sync/ai 0.8.2 → 0.9.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/README.md CHANGED
@@ -16,13 +16,13 @@ AI agent tools for [@sqlite-sync](https://github.com/krolebord-dev/sqlite-sync)
16
16
 
17
17
  ```ts
18
18
  import { createAiDbAccess } from "@sqlite-sync/ai";
19
- import { createKyselyExecutor, durableObjectAdapter } from "@sqlite-sync/cloudflare";
19
+ import { durableObjectAdapter } from "@sqlite-sync/cloudflare";
20
20
 
21
21
  // In the DO that owns the synced database:
22
22
  async onStart() {
23
23
  const { syncDb } = await durableObjectAdapter.createCrdtStorage({ syncDbSchema, storage: this.ctx.storage, /* ... */ });
24
24
  this.aiDbAccess = createAiDbAccess({
25
- executor: createKyselyExecutor(this.ctx.storage),
25
+ executor: syncDb.unsafe,
26
26
  storage: syncDb, // optional; enables mutate() on the access object
27
27
  syncDbSchema,
28
28
  context: {
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { SyncDbSchema, CrdtEventType, CrdtStorage } from '@sqlite-sync/core';
2
- import { ToolSet } from 'ai';
3
-
1
+ import { CrdtEventType, CrdtStorage, SyncDbSchema } from "@sqlite-sync/core";
2
+ import { ToolSet } from "ai";
3
+ //#region src/schema-doc.d.ts
4
4
  type SchemaDocContext = {
5
- overview?: string;
5
+ overview?: string;
6
6
  };
7
7
  /**
8
8
  * Generates a markdown schema doc for an AI agent from the declared sync schema's table
@@ -15,56 +15,58 @@ type SchemaDocContext = {
15
15
  * need to describe their own domain.
16
16
  */
17
17
  declare function createSchemaDoc(opts: {
18
- syncDbSchema: SyncDbSchema;
19
- context?: SchemaDocContext;
18
+ syncDbSchema: SyncDbSchema;
19
+ context?: SchemaDocContext;
20
20
  }): string;
21
-
21
+ //#endregion
22
+ //#region src/db-access.d.ts
22
23
  type AiDbExecuteParams = {
23
- sql: string;
24
- parameters: readonly unknown[];
24
+ sql: string;
25
+ parameters: readonly unknown[];
25
26
  };
26
27
  /**
27
- * Minimal executor contract for AI database access. Runtime-specific — inject the one that
28
- * matches where the storage lives; @sqlite-sync/cloudflare's `createKyselyExecutor` satisfies it.
28
+ * Minimal executor contract for AI database access. Runtime-specific — inject the raw SQL
29
+ * capability that matches where the storage lives; a Cloudflare `ServerSyncDb`'s `unsafe`
30
+ * executor satisfies it.
29
31
  */
30
32
  type AiDbExecutor = {
31
- execute<TResult = unknown>(query: AiDbExecuteParams): {
32
- rows: TResult[];
33
- };
34
- transaction(callback: (tx: Pick<AiDbExecutor, "execute">) => void): void;
33
+ execute<TResult = unknown>(query: AiDbExecuteParams): {
34
+ rows: TResult[];
35
+ };
36
+ transaction(callback: (tx: Pick<AiDbExecutor, "execute">) => void): void;
35
37
  };
36
38
  type AiQueryInput = {
37
- sql: string;
38
- parameters?: readonly unknown[];
39
+ sql: string;
40
+ parameters?: readonly unknown[];
39
41
  };
40
42
  type AiQueryResult = {
41
- rows: Record<string, unknown>[];
42
- rowCount: number;
43
- truncated: boolean;
43
+ rows: Record<string, unknown>[];
44
+ rowCount: number;
45
+ truncated: boolean;
44
46
  } | {
45
- error: string;
47
+ error: string;
46
48
  };
47
49
  type AiMutationEvent = {
48
- type: "item-created";
49
- dataset: string;
50
- item_id?: never;
51
- payload?: Record<string, unknown>;
50
+ type: "item-created";
51
+ dataset: string;
52
+ item_id?: never;
53
+ payload?: Record<string, unknown>;
52
54
  } | {
53
- type: Exclude<CrdtEventType, "item-created">;
54
- dataset: string;
55
- item_id: string;
56
- payload?: Record<string, unknown>;
55
+ type: Exclude<CrdtEventType, "item-created">;
56
+ dataset: string;
57
+ item_id: string;
58
+ payload?: Record<string, unknown>;
57
59
  };
58
60
  type AiMutationInput = {
59
- events: AiMutationEvent[];
61
+ events: AiMutationEvent[];
60
62
  };
61
63
  type AiMutationResult = {
62
- applied: true;
63
- eventCount: number;
64
- createdIds: string[];
64
+ applied: true;
65
+ eventCount: number;
66
+ createdIds: string[];
65
67
  } | {
66
- error: string;
67
- errors?: string[];
68
+ error: string;
69
+ errors?: string[];
68
70
  };
69
71
  /**
70
72
  * AI access to a synced database. Lives where the storage lives; its method names
@@ -79,54 +81,55 @@ type AiMutationResult = {
79
81
  * events applied through sqlite-sync's normal own-event path, never direct SQL writes.
80
82
  */
81
83
  type AiDbAccess = {
82
- getSchemaDoc(): string;
83
- query(input: AiQueryInput): AiQueryResult;
84
- mutate?(input: AiMutationInput): AiMutationResult;
84
+ getSchemaDoc(): string;
85
+ query(input: AiQueryInput): AiQueryResult;
86
+ mutate?(input: AiMutationInput): AiMutationResult;
85
87
  };
86
88
  declare function createAiDbAccess(opts: {
87
- executor: AiDbExecutor;
88
- storage?: Pick<CrdtStorage, "applyOwnEvents">;
89
- syncDbSchema: SyncDbSchema;
90
- context?: SchemaDocContext;
91
- limits?: {
92
- maxRows?: number;
93
- maxCellChars?: number;
94
- };
89
+ executor: AiDbExecutor;
90
+ storage?: Pick<CrdtStorage, "applyOwnEvents">;
91
+ syncDbSchema: SyncDbSchema;
92
+ context?: SchemaDocContext;
93
+ limits?: {
94
+ maxRows?: number;
95
+ maxCellChars?: number;
96
+ };
95
97
  }): AiDbAccess;
96
-
98
+ //#endregion
99
+ //#region src/query-guard.d.ts
97
100
  type QueryGuardInput = {
98
- sql: string;
99
- parameters?: readonly unknown[];
101
+ sql: string;
102
+ parameters?: readonly unknown[];
100
103
  };
101
104
  type QueryGuardRejection = {
102
- allowed: false;
103
- code: "invalid-statement" | "multi-statement" | "invalid-sql" | "write-detected";
104
- message: string;
105
+ allowed: false;
106
+ code: "invalid-statement" | "multi-statement" | "invalid-sql" | "write-detected";
107
+ message: string;
105
108
  };
106
109
  type QueryGuardVerdict = {
107
- allowed: true;
110
+ allowed: true;
108
111
  } | QueryGuardRejection;
109
112
  declare class QueryGuardError extends Error {
110
- readonly rejection: QueryGuardRejection;
111
- constructor(rejection: QueryGuardRejection);
113
+ readonly rejection: QueryGuardRejection;
114
+ constructor(rejection: QueryGuardRejection);
112
115
  }
113
116
  type QueryGuard = {
114
- /**
115
- * Statically verifies the query is a read-only single statement. Reads are not restricted
116
- * by table — the whole database file is in scope for the agent, so don't colocate data the
117
- * agent must not see.
118
- */
119
- check(input: QueryGuardInput): QueryGuardVerdict;
120
- /**
121
- * `check` + execute inside a forced-rollback transaction (the unconditional backstop for
122
- * anything the analysis might miss). Throws {@link QueryGuardError} when the check rejects.
123
- */
124
- execute<TResult = unknown>(input: QueryGuardInput): {
125
- rows: TResult[];
126
- };
117
+ /**
118
+ * Statically verifies the query is a read-only single statement. Reads are not restricted
119
+ * by table — the whole database file is in scope for the agent, so don't colocate data the
120
+ * agent must not see.
121
+ */
122
+ check(input: QueryGuardInput): QueryGuardVerdict;
123
+ /**
124
+ * `check` + execute inside a forced-rollback transaction (the unconditional backstop for
125
+ * anything the analysis might miss). Throws {@link QueryGuardError} when the check rejects.
126
+ */
127
+ execute<TResult = unknown>(input: QueryGuardInput): {
128
+ rows: TResult[];
129
+ };
127
130
  };
128
131
  declare function createQueryGuard(opts: {
129
- executor: AiDbExecutor;
132
+ executor: AiDbExecutor;
130
133
  }): QueryGuard;
131
134
  /**
132
135
  * Runs `callback` in a transaction that is always rolled back (via a sentinel throw, relying
@@ -134,26 +137,28 @@ declare function createQueryGuard(opts: {
134
137
  * unconditional write guard backing any gap in the static analysis.
135
138
  */
136
139
  declare function runWithForcedRollback<T>(executor: Pick<AiDbExecutor, "transaction">, callback: (tx: Pick<AiDbExecutor, "execute">) => T): T;
137
-
140
+ //#endregion
141
+ //#region src/tools.d.ts
138
142
  type MaybePromise<T> = T | Promise<T>;
139
143
  /**
140
144
  * What the tools need from the database side. Satisfied directly by `AiDbAccess`, or by a
141
145
  * Durable Object stub whose RPC methods delegate to one (RPC wraps returns in promises).
142
146
  */
143
147
  type DbToolsAccess = {
144
- getSchemaDoc(): MaybePromise<string>;
145
- query(input: AiQueryInput): MaybePromise<AiQueryResult>;
146
- mutate?(input: AiMutationInput): MaybePromise<AiMutationResult>;
148
+ getSchemaDoc(): MaybePromise<string>;
149
+ query(input: AiQueryInput): MaybePromise<AiQueryResult>;
150
+ mutate?(input: AiMutationInput): MaybePromise<AiMutationResult>;
147
151
  };
148
152
  type CreateDbToolsOptions = {
149
- access: () => MaybePromise<DbToolsAccess>;
150
- /** Expose the write-capable `mutateDb` tool. The access object must also implement `mutate`. */
151
- mutations?: boolean;
153
+ access: () => MaybePromise<DbToolsAccess>;
154
+ /** Expose the write-capable `mutateDb` tool. The access object must also implement `mutate`. */
155
+ mutations?: boolean;
152
156
  };
153
157
  /**
154
158
  * AI SDK tools for a synced database. `access` is a factory because acquiring the database
155
159
  * may itself be async per call (e.g. resolving a Durable Object stub from another DO).
156
160
  */
157
161
  declare function createDbTools(opts: CreateDbToolsOptions): ToolSet;
158
-
162
+ //#endregion
159
163
  export { type AiDbAccess, type AiDbExecuteParams, type AiDbExecutor, type AiMutationEvent, type AiMutationInput, type AiMutationResult, type AiQueryInput, type AiQueryResult, type CreateDbToolsOptions, type DbToolsAccess, type QueryGuard, QueryGuardError, type QueryGuardInput, type QueryGuardRejection, type QueryGuardVerdict, type SchemaDocContext, createAiDbAccess, createDbTools, createQueryGuard, createSchemaDoc, runWithForcedRollback };
164
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,401 +1,400 @@
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/query-guard.ts
8
4
  var QueryGuardError = class extends Error {
9
- rejection;
10
- constructor(rejection) {
11
- super(rejection.message);
12
- this.name = "QueryGuardError";
13
- this.rejection = rejection;
14
- }
5
+ rejection;
6
+ constructor(rejection) {
7
+ super(rejection.message);
8
+ this.name = "QueryGuardError";
9
+ this.rejection = rejection;
10
+ }
15
11
  };
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"
12
+ /**
13
+ * Opcodes that prove a statement is not read-only. `OpenWrite` covers row-level writes (every
14
+ * real-table mutation opens its cursor through it), `Clear`/`Destroy` cover whole-table
15
+ * deletes that skip cursors (truncate optimization), the rest cover DDL, pragma-class
16
+ * statements (which a transaction rollback would NOT undo), virtual-table writes, and trigger
17
+ * subprograms. `Insert`/`Delete`/`IdxInsert` are deliberately absent — they also run against
18
+ * ephemeral/sorter cursors in ordinary SELECTs (DISTINCT, ORDER BY) and are only dangerous on
19
+ * a cursor an `OpenWrite` would have created.
20
+ */
21
+ const WRITE_OPCODES = /* @__PURE__ */ new Set([
22
+ "OpenWrite",
23
+ "Clear",
24
+ "Destroy",
25
+ "CreateBtree",
26
+ "SqlExec",
27
+ "ParseSchema",
28
+ "DropTable",
29
+ "DropIndex",
30
+ "DropTrigger",
31
+ "SetCookie",
32
+ "JournalMode",
33
+ "Vacuum",
34
+ "IncrVacuum",
35
+ "Checkpoint",
36
+ "MaxPgcnt",
37
+ "Expire",
38
+ "AutoCommit",
39
+ "VUpdate",
40
+ "VCreate",
41
+ "VDestroy",
42
+ "Program"
38
43
  ]);
39
- var READ_STATEMENT_PREFIXES = ["select", "with", "values"];
44
+ const READ_STATEMENT_PREFIXES = [
45
+ "select",
46
+ "with",
47
+ "values"
48
+ ];
40
49
  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
- };
50
+ function reject(code, message) {
51
+ return {
52
+ allowed: false,
53
+ code,
54
+ message
55
+ };
56
+ }
57
+ function check(input) {
58
+ let body = input.sql.trim();
59
+ while (body.endsWith(";")) body = body.slice(0, -1).trimEnd();
60
+ 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.");
61
+ const lowered = body.toLowerCase();
62
+ 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).");
63
+ let operations;
64
+ try {
65
+ operations = opts.executor.execute({
66
+ sql: `EXPLAIN ${input.sql}`,
67
+ parameters: input.parameters ?? []
68
+ }).rows;
69
+ } catch (error) {
70
+ return reject("invalid-sql", `SQL error: ${error instanceof Error ? error.message : String(error)}`);
71
+ }
72
+ 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.`);
73
+ return { allowed: true };
74
+ }
75
+ return {
76
+ check,
77
+ execute(input) {
78
+ const verdict = check(input);
79
+ if (!verdict.allowed) throw new QueryGuardError(verdict);
80
+ return runWithForcedRollback(opts.executor, (tx) => {
81
+ return tx.execute({
82
+ sql: input.sql,
83
+ parameters: input.parameters ?? []
84
+ });
85
+ });
86
+ }
87
+ };
93
88
  }
89
+ /**
90
+ * Runs `callback` in a transaction that is always rolled back (via a sentinel throw, relying
91
+ * on the {@link AiDbExecutor} contract that a throwing callback rolls back). This is the
92
+ * unconditional write guard backing any gap in the static analysis.
93
+ */
94
94
  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;
95
+ let result;
96
+ let completed = false;
97
+ const rollbackSentinel = /* @__PURE__ */ 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) throw error;
106
+ }
107
+ if (!completed) throw new Error("Transaction completed without running the callback");
108
+ return result;
113
109
  }
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."
110
+ //#endregion
111
+ //#region src/schema-doc.ts
112
+ const SCHEMA_DOC_PREAMBLE = [
113
+ "This is a synced SQLite database — data replicates automatically between the user's devices.",
114
+ "All writes go through a sync event log, which is why the tables listed below are exposed as",
115
+ "read-only SQL views; soft-deleted rows are already filtered out, so query them directly",
116
+ "without any tombstone filtering. Every table has a unique `id` text primary key."
121
117
  ].join("\n");
122
118
  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;
119
+ let line = `- \`${name}\` ${meta.sqlType.toUpperCase()}`;
120
+ if (!meta.nullable) line += " NOT NULL";
121
+ if (meta.kind === "boolean") line += " (boolean 0/1)";
122
+ else if (meta.kind === "enum") line += ` (one of ${(meta.enumValues ?? []).map((value) => `"${value}"`).join(" | ")})`;
123
+ return meta.description ? `${line} — ${meta.description}` : line;
133
124
  }
125
+ /**
126
+ * Generates a markdown schema doc for an AI agent from the declared sync schema's table
127
+ * builders — no database access needed. Tables are presented under the view names the agent
128
+ * queries; descriptions come from `.describe()` on the table and column builders.
129
+ * The internal `tombstone` column is omitted.
130
+ *
131
+ * The doc always includes a built-in preamble explaining sqlite-sync mechanics (read-only
132
+ * views, soft-deletes already filtered) after the consumer's `overview` — consumers only
133
+ * need to describe their own domain.
134
+ */
134
135
  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");
136
+ const sections = [];
137
+ const overview = opts.context?.overview?.trim();
138
+ if (overview) sections.push(overview);
139
+ sections.push(SCHEMA_DOC_PREAMBLE);
140
+ for (const [crdtTableName, table] of Object.entries(opts.syncDbSchema.tables)) {
141
+ const lines = [`## ${crdtTableName}`];
142
+ if (table.description) lines.push("", table.description.trim());
143
+ lines.push("", "Columns:");
144
+ for (const [name, meta] of Object.entries(table.columns)) {
145
+ if (name === "tombstone") continue;
146
+ lines.push(renderColumn(name, meta));
147
+ }
148
+ sections.push(lines.join("\n"));
149
+ }
150
+ sections.push(CHANGE_HISTORY_DOC);
151
+ return sections.join("\n\n");
155
152
  }
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."
153
+ const CHANGE_HISTORY_DOC = [
154
+ "## change_history",
155
+ "",
156
+ "A read-only, append-only log of every change across all tables, one row per sync event.",
157
+ "Query it like any other view. Unlike the tables above, soft-delete filtering does NOT apply",
158
+ "here — it includes changes to items that were later deleted, so treat it as an audit log.",
159
+ "",
160
+ "Columns:",
161
+ "- `seq` INTEGER NOT NULL — monotonic sequence number; **order history by this** (ascending = oldest first)",
162
+ "- `dataset` TEXT NOT NULL — which table the change applies to (matches a table name above)",
163
+ "- `item_id` TEXT NOT NULL — the `id` of the affected row",
164
+ "- `change_type` TEXT NOT NULL (one of \"item-created\" | \"item-updated\" | \"item-deleted\")",
165
+ "- `status` TEXT NOT NULL (one of \"applied\" | \"pending\" | \"failed\" | \"deduped\") — \"applied\" took effect; \"failed\" did not; filter to `status = 'applied'` for the effective history",
166
+ "- `origin` TEXT NOT NULL — where the change came from (e.g. \"own\", \"remote\", \"local\")",
167
+ "- `timestamp` TEXT NOT NULL — opaque hybrid-logical-clock string; do NOT parse it as a date, order by `seq` instead",
168
+ "- `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
169
  ].join("\n");
173
-
174
- // src/db-access.ts
170
+ //#endregion
171
+ //#region src/db-access.ts
175
172
  function toBase64(bytes) {
176
- let binary = "";
177
- for (const byte of bytes) {
178
- binary += String.fromCharCode(byte);
179
- }
180
- return btoa(binary);
173
+ let binary = "";
174
+ for (const byte of bytes) binary += String.fromCharCode(byte);
175
+ return btoa(binary);
181
176
  }
182
177
  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;
178
+ const schemaDoc = createSchemaDoc({
179
+ syncDbSchema: opts.syncDbSchema,
180
+ context: opts.context
181
+ });
182
+ const guard = createQueryGuard({ executor: opts.executor });
183
+ const maxRows = opts.limits?.maxRows ?? 200;
184
+ const maxCellChars = opts.limits?.maxCellChars ?? 2e3;
185
+ const access = {
186
+ getSchemaDoc() {
187
+ return schemaDoc;
188
+ },
189
+ query(input) {
190
+ let resultRows;
191
+ try {
192
+ resultRows = guard.execute(input).rows;
193
+ } catch (error) {
194
+ if (error instanceof QueryGuardError) return { error: error.message };
195
+ throw error;
196
+ }
197
+ let truncated = resultRows.length > maxRows;
198
+ function shapeCell(value) {
199
+ if (value instanceof Uint8Array) {
200
+ if (Math.ceil(value.byteLength / 3) * 4 <= maxCellChars) return `<blob base64 ${toBase64(value)}>`;
201
+ truncated = true;
202
+ return `<blob ${value.byteLength} bytes>`;
203
+ }
204
+ if (typeof value === "string" && value.length > maxCellChars) {
205
+ truncated = true;
206
+ return `${value.slice(0, maxCellChars)}…`;
207
+ }
208
+ return value;
209
+ }
210
+ return {
211
+ rows: resultRows.slice(0, maxRows).map((row) => Object.fromEntries(Object.entries(row).map(([column, value]) => [column, shapeCell(value)]))),
212
+ rowCount: resultRows.length,
213
+ truncated
214
+ };
215
+ }
216
+ };
217
+ const storage = opts.storage;
218
+ if (storage) access.mutate = (input) => {
219
+ const errors = [];
220
+ const createdIds = [];
221
+ const events = [];
222
+ for (const [index, event] of input.events.entries()) {
223
+ if (event.type === "item-created") {
224
+ const looseEvent = event;
225
+ const payload = event.payload ?? {};
226
+ if (looseEvent.item_id !== void 0) errors.push(`[${index}] item-created events must omit item_id; an id is generated automatically`);
227
+ if ("id" in payload) errors.push(`[${index}] item-created payload must omit id; an id is generated automatically`);
228
+ if (looseEvent.item_id !== void 0 || "id" in payload) continue;
229
+ const id = generateId();
230
+ createdIds.push(id);
231
+ events.push({
232
+ type: "item-created",
233
+ dataset: event.dataset,
234
+ item_id: id,
235
+ payload: JSON.stringify({
236
+ ...payload,
237
+ id
238
+ })
239
+ });
240
+ continue;
241
+ }
242
+ events.push({
243
+ type: event.type,
244
+ dataset: event.dataset,
245
+ item_id: event.item_id,
246
+ payload: JSON.stringify(event.payload ?? {})
247
+ });
248
+ }
249
+ if (errors.length > 0) return {
250
+ error: `Invalid mutation events: ${errors.join("; ")}`,
251
+ errors
252
+ };
253
+ try {
254
+ storage.applyOwnEvents(events);
255
+ } catch (error) {
256
+ if (error instanceof CrdtEventValidationError) return {
257
+ error: error.message,
258
+ errors: error.errors
259
+ };
260
+ throw error;
261
+ }
262
+ return {
263
+ applied: true,
264
+ eventCount: events.length,
265
+ createdIds
266
+ };
267
+ };
268
+ return access;
271
269
  }
272
-
273
- // src/tools.ts
274
- import { jsonSchema, tool } from "ai";
275
- var emptyInputSchema = jsonSchema({
276
- type: "object",
277
- properties: {},
278
- additionalProperties: false
270
+ //#endregion
271
+ //#region src/tools.ts
272
+ const emptyInputSchema = jsonSchema({
273
+ type: "object",
274
+ properties: {},
275
+ additionalProperties: false
279
276
  });
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
277
+ const queryInputSchema = jsonSchema({
278
+ type: "object",
279
+ properties: {
280
+ sql: {
281
+ type: "string",
282
+ description: "A single read-only SQLite statement starting with SELECT, WITH, or VALUES. Use ? placeholders for values."
283
+ },
284
+ parameters: {
285
+ type: "array",
286
+ items: { type: [
287
+ "string",
288
+ "number",
289
+ "boolean",
290
+ "null"
291
+ ] },
292
+ description: "Values bound to the ? placeholders, in order."
293
+ }
294
+ },
295
+ required: ["sql"],
296
+ additionalProperties: false
295
297
  });
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
298
+ const mutationInputSchema = jsonSchema({
299
+ type: "object",
300
+ properties: { events: {
301
+ type: "array",
302
+ minItems: 1,
303
+ items: { anyOf: [{
304
+ type: "object",
305
+ properties: {
306
+ type: {
307
+ type: "string",
308
+ enum: ["item-created"],
309
+ description: "Create a synced row. Omit item_id and payload.id; the tool generates the id."
310
+ },
311
+ dataset: {
312
+ type: "string",
313
+ description: "The synced dataset/table name from the schema documentation."
314
+ },
315
+ payload: {
316
+ type: "object",
317
+ additionalProperties: true,
318
+ not: { required: ["id"] },
319
+ description: "Column values for the new row, excluding id. Include all required non-id columns from the schema."
320
+ }
321
+ },
322
+ required: [
323
+ "type",
324
+ "dataset",
325
+ "payload"
326
+ ],
327
+ additionalProperties: false
328
+ }, {
329
+ type: "object",
330
+ properties: {
331
+ type: {
332
+ type: "string",
333
+ enum: ["item-updated", "item-deleted"],
334
+ description: "Update or delete an existing synced row."
335
+ },
336
+ dataset: {
337
+ type: "string",
338
+ description: "The synced dataset/table name from the schema documentation."
339
+ },
340
+ item_id: {
341
+ type: "string",
342
+ description: "The stable id of the row being updated or deleted."
343
+ },
344
+ payload: {
345
+ type: "object",
346
+ additionalProperties: true,
347
+ description: "Changed column values for item-updated. Omit or pass {} for item-deleted."
348
+ }
349
+ },
350
+ required: [
351
+ "type",
352
+ "dataset",
353
+ "item_id"
354
+ ],
355
+ additionalProperties: false
356
+ }] },
357
+ description: "One or more CRDT mutation events to apply atomically."
358
+ } },
359
+ required: ["events"],
360
+ additionalProperties: false
358
361
  });
362
+ /**
363
+ * AI SDK tools for a synced database. `access` is a factory because acquiring the database
364
+ * may itself be async per call (e.g. resolving a Durable Object stub from another DO).
365
+ */
359
366
  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;
367
+ const tools = {
368
+ getDbSchema: tool({
369
+ description: "Get the schema documentation for the synced SQLite database: tables, columns, types, and data conventions. Call this before reasoning about the data.",
370
+ inputSchema: emptyInputSchema,
371
+ execute: async () => {
372
+ return await (await opts.access()).getSchemaDoc();
373
+ }
374
+ }),
375
+ queryDb: tool({
376
+ 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.",
377
+ inputSchema: queryInputSchema,
378
+ execute: async ({ sql, parameters }) => {
379
+ return await (await opts.access()).query({
380
+ sql,
381
+ parameters
382
+ });
383
+ }
384
+ })
385
+ };
386
+ if (opts.mutations) tools.mutateDb = tool({
387
+ 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.",
388
+ inputSchema: mutationInputSchema,
389
+ execute: async (input) => {
390
+ const access = await opts.access();
391
+ if (!access.mutate) return { error: "Database mutations are not enabled for this access object." };
392
+ return await access.mutate(input);
393
+ }
394
+ });
395
+ return tools;
392
396
  }
393
- export {
394
- QueryGuardError,
395
- createAiDbAccess,
396
- createDbTools,
397
- createQueryGuard,
398
- createSchemaDoc,
399
- runWithForcedRollback
400
- };
397
+ //#endregion
398
+ export { QueryGuardError, createAiDbAccess, createDbTools, createQueryGuard, createSchemaDoc, runWithForcedRollback };
399
+
401
400
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/db-access.ts","../src/query-guard.ts","../src/schema-doc.ts","../src/tools.ts"],"sourcesContent":["import {\n type CrdtEventType,\n CrdtEventValidationError,\n type CrdtStorage,\n generateId,\n type OwnCrdtEvent,\n type SyncDbSchema,\n} from \"@sqlite-sync/core\";\nimport { createQueryGuard, QueryGuardError } from \"./query-guard\";\nimport { createSchemaDoc, type SchemaDocContext } from \"./schema-doc\";\n\nexport type AiDbExecuteParams = {\n sql: string;\n parameters: readonly unknown[];\n};\n\n/**\n * Minimal executor contract for AI database access. Runtime-specific — inject the one that\n * matches where the storage lives; @sqlite-sync/cloudflare's `createKyselyExecutor` satisfies it.\n */\nexport type AiDbExecutor = {\n execute<TResult = unknown>(query: AiDbExecuteParams): { rows: TResult[] };\n transaction(callback: (tx: Pick<AiDbExecutor, \"execute\">) => void): void;\n};\n\nexport type AiQueryInput = {\n sql: string;\n parameters?: readonly unknown[];\n};\n\nexport type AiQueryResult =\n | {\n rows: Record<string, unknown>[];\n rowCount: number;\n truncated: boolean;\n }\n | {\n error: string;\n };\n\nexport type AiMutationEvent =\n | {\n type: \"item-created\";\n dataset: string;\n item_id?: never;\n payload?: Record<string, unknown>;\n }\n | {\n type: Exclude<CrdtEventType, \"item-created\">;\n dataset: string;\n item_id: string;\n payload?: Record<string, unknown>;\n };\n\nexport type AiMutationInput = {\n events: AiMutationEvent[];\n};\n\nexport type AiMutationResult =\n | {\n applied: true;\n eventCount: number;\n createdIds: string[];\n }\n | {\n error: string;\n errors?: string[];\n };\n\n/**\n * AI access to a synced database. Lives where the storage lives; its method names\n * are the RPC contract, so a DO stub proxying to these methods exposes the same surface\n * (promise-wrapped) and satisfies the tool layer's `DbToolsAccess`.\n *\n * `query` enforces read-only (single SELECT/WITH/VALUES statement, no write opcodes, executed\n * in a forced-rollback transaction) but reads are not restricted by table — the whole database\n * file is in scope for the agent, so don't colocate data the agent must not see.\n *\n * `mutate` is only present when `createAiDbAccess` receives a CRDT storage. Mutations are CRDT\n * events applied through sqlite-sync's normal own-event path, never direct SQL writes.\n */\nexport type AiDbAccess = {\n getSchemaDoc(): string;\n query(input: AiQueryInput): AiQueryResult;\n mutate?(input: AiMutationInput): AiMutationResult;\n};\n\nfunction toBase64(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) {\n binary += String.fromCharCode(byte);\n }\n return btoa(binary);\n}\n\nexport function createAiDbAccess(opts: {\n executor: AiDbExecutor;\n storage?: Pick<CrdtStorage, \"applyOwnEvents\">;\n syncDbSchema: SyncDbSchema;\n context?: SchemaDocContext;\n limits?: { maxRows?: number; maxCellChars?: number };\n}): AiDbAccess {\n const schemaDoc = createSchemaDoc({ syncDbSchema: opts.syncDbSchema, context: opts.context });\n const guard = createQueryGuard({ executor: opts.executor });\n const maxRows = opts.limits?.maxRows ?? 200;\n const maxCellChars = opts.limits?.maxCellChars ?? 2000;\n\n const access: AiDbAccess = {\n getSchemaDoc() {\n return schemaDoc;\n },\n query(input) {\n let resultRows: Record<string, unknown>[];\n try {\n resultRows = guard.execute<Record<string, unknown>>(input).rows;\n } catch (error) {\n if (error instanceof QueryGuardError) {\n return { error: error.message };\n }\n throw error;\n }\n\n let truncated = resultRows.length > maxRows;\n\n function shapeCell(value: unknown): unknown {\n if (value instanceof Uint8Array) {\n // Base64 inflates by 4/3, so budget the encoded length against the cell cap.\n if (Math.ceil(value.byteLength / 3) * 4 <= maxCellChars) {\n return `<blob base64 ${toBase64(value)}>`;\n }\n truncated = true;\n return `<blob ${value.byteLength} bytes>`;\n }\n if (typeof value === \"string\" && value.length > maxCellChars) {\n truncated = true;\n return `${value.slice(0, maxCellChars)}…`;\n }\n return value;\n }\n\n const rows = resultRows\n .slice(0, maxRows)\n .map((row) => Object.fromEntries(Object.entries(row).map(([column, value]) => [column, shapeCell(value)])));\n\n return { rows, rowCount: resultRows.length, truncated };\n },\n };\n\n const storage = opts.storage;\n if (storage) {\n access.mutate = (input) => {\n const errors: string[] = [];\n const createdIds: string[] = [];\n const events: OwnCrdtEvent[] = [];\n\n for (const [index, event] of input.events.entries()) {\n if (event.type === \"item-created\") {\n const looseEvent = event as { item_id?: unknown };\n const payload = event.payload ?? {};\n if (looseEvent.item_id !== undefined) {\n errors.push(`[${index}] item-created events must omit item_id; an id is generated automatically`);\n }\n if (\"id\" in payload) {\n errors.push(`[${index}] item-created payload must omit id; an id is generated automatically`);\n }\n if (looseEvent.item_id !== undefined || \"id\" in payload) {\n continue;\n }\n\n const id = generateId();\n createdIds.push(id);\n events.push({\n type: \"item-created\",\n dataset: event.dataset,\n item_id: id,\n payload: JSON.stringify({ ...payload, id }),\n });\n continue;\n }\n\n events.push({\n type: event.type,\n dataset: event.dataset,\n item_id: event.item_id,\n payload: JSON.stringify(event.payload ?? {}),\n });\n }\n\n if (errors.length > 0) {\n return { error: `Invalid mutation events: ${errors.join(\"; \")}`, errors };\n }\n\n try {\n storage.applyOwnEvents(events);\n } catch (error) {\n if (error instanceof CrdtEventValidationError) {\n return { error: error.message, errors: error.errors };\n }\n throw error;\n }\n\n return { applied: true, eventCount: events.length, createdIds };\n };\n }\n\n return access;\n}\n","import type { AiDbExecutor } from \"./db-access\";\n\nexport type QueryGuardInput = {\n sql: string;\n parameters?: readonly unknown[];\n};\n\nexport type QueryGuardRejection = {\n allowed: false;\n code: \"invalid-statement\" | \"multi-statement\" | \"invalid-sql\" | \"write-detected\";\n message: string;\n};\n\nexport type QueryGuardVerdict = { allowed: true } | QueryGuardRejection;\n\nexport class QueryGuardError extends Error {\n readonly rejection: QueryGuardRejection;\n\n constructor(rejection: QueryGuardRejection) {\n super(rejection.message);\n this.name = \"QueryGuardError\";\n this.rejection = rejection;\n }\n}\n\nexport type QueryGuard = {\n /**\n * Statically verifies the query is a read-only single statement. Reads are not restricted\n * by table — the whole database file is in scope for the agent, so don't colocate data the\n * agent must not see.\n */\n check(input: QueryGuardInput): QueryGuardVerdict;\n /**\n * `check` + execute inside a forced-rollback transaction (the unconditional backstop for\n * anything the analysis might miss). Throws {@link QueryGuardError} when the check rejects.\n */\n execute<TResult = unknown>(input: QueryGuardInput): { rows: TResult[] };\n};\n\n/**\n * Opcodes that prove a statement is not read-only. `OpenWrite` covers row-level writes (every\n * real-table mutation opens its cursor through it), `Clear`/`Destroy` cover whole-table\n * deletes that skip cursors (truncate optimization), the rest cover DDL, pragma-class\n * statements (which a transaction rollback would NOT undo), virtual-table writes, and trigger\n * subprograms. `Insert`/`Delete`/`IdxInsert` are deliberately absent — they also run against\n * ephemeral/sorter cursors in ordinary SELECTs (DISTINCT, ORDER BY) and are only dangerous on\n * a cursor an `OpenWrite` would have created.\n */\nconst WRITE_OPCODES = new Set([\n \"OpenWrite\",\n \"Clear\",\n \"Destroy\",\n \"CreateBtree\",\n \"SqlExec\",\n \"ParseSchema\",\n \"DropTable\",\n \"DropIndex\",\n \"DropTrigger\",\n \"SetCookie\",\n \"JournalMode\",\n \"Vacuum\",\n \"IncrVacuum\",\n \"Checkpoint\",\n \"MaxPgcnt\",\n \"Expire\",\n \"AutoCommit\",\n \"VUpdate\",\n \"VCreate\",\n \"VDestroy\",\n \"Program\",\n]);\n\ntype ExplainRow = {\n opcode: string;\n};\n\nconst READ_STATEMENT_PREFIXES = [\"select\", \"with\", \"values\"];\n\nexport function createQueryGuard(opts: { executor: AiDbExecutor }): QueryGuard {\n function reject(code: QueryGuardRejection[\"code\"], message: string): QueryGuardRejection {\n return { allowed: false, code, message };\n }\n\n function check(input: QueryGuardInput): QueryGuardVerdict {\n let body = input.sql.trim();\n while (body.endsWith(\";\")) {\n body = body.slice(0, -1).trimEnd();\n }\n\n if (body.includes(\";\")) {\n return reject(\n \"multi-statement\",\n \"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.\",\n );\n }\n\n const lowered = body.toLowerCase();\n if (!READ_STATEMENT_PREFIXES.some((keyword) => lowered.startsWith(keyword))) {\n return reject(\n \"invalid-statement\",\n \"Only read-only queries are allowed: the statement must start directly with SELECT, WITH, or VALUES (no leading comments).\",\n );\n }\n\n let operations: ExplainRow[];\n try {\n operations = opts.executor.execute<ExplainRow>({\n sql: `EXPLAIN ${input.sql}`,\n parameters: input.parameters ?? [],\n }).rows;\n } catch (error) {\n return reject(\"invalid-sql\", `SQL error: ${error instanceof Error ? error.message : String(error)}`);\n }\n\n for (const operation of operations) {\n if (WRITE_OPCODES.has(operation.opcode)) {\n return reject(\n \"write-detected\",\n `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.`,\n );\n }\n }\n\n return { allowed: true };\n }\n\n return {\n check,\n execute(input) {\n const verdict = check(input);\n if (!verdict.allowed) {\n throw new QueryGuardError(verdict);\n }\n return runWithForcedRollback(opts.executor, (tx) => {\n return tx.execute({ sql: input.sql, parameters: input.parameters ?? [] });\n });\n },\n };\n}\n\n/**\n * Runs `callback` in a transaction that is always rolled back (via a sentinel throw, relying\n * on the {@link AiDbExecutor} contract that a throwing callback rolls back). This is the\n * unconditional write guard backing any gap in the static analysis.\n */\nexport function runWithForcedRollback<T>(\n executor: Pick<AiDbExecutor, \"transaction\">,\n callback: (tx: Pick<AiDbExecutor, \"execute\">) => T,\n): T {\n let result: T | undefined;\n let completed = false;\n const rollbackSentinel = new Error(\"forced read-only rollback\");\n\n try {\n executor.transaction((tx) => {\n result = callback(tx);\n completed = true;\n throw rollbackSentinel;\n });\n } catch (error) {\n if (error !== rollbackSentinel) {\n throw error;\n }\n }\n\n if (!completed) {\n throw new Error(\"Transaction completed without running the callback\");\n }\n return result as T;\n}\n","import type { ColumnMeta, SyncDbSchema } from \"@sqlite-sync/core\";\n\nexport type SchemaDocContext = {\n overview?: string;\n};\n\n// Library mechanics every generated doc should explain, so consumers only have to describe\n// their own domain in `context`. Kept free of tombstone-column details the agent never sees.\nconst SCHEMA_DOC_PREAMBLE = [\n \"This is a synced SQLite database — data replicates automatically between the user's devices.\",\n \"All writes go through a sync event log, which is why the tables listed below are exposed as\",\n \"read-only SQL views; soft-deleted rows are already filtered out, so query them directly\",\n \"without any tombstone filtering. Every table has a unique `id` text primary key.\",\n].join(\"\\n\");\n\nfunction renderColumn(name: string, meta: ColumnMeta): string {\n let line = `- \\`${name}\\` ${meta.sqlType.toUpperCase()}`;\n if (!meta.nullable) {\n line += \" NOT NULL\";\n }\n if (meta.kind === \"boolean\") {\n line += \" (boolean 0/1)\";\n } else if (meta.kind === \"enum\") {\n line += ` (one of ${(meta.enumValues ?? []).map((value) => `\"${value}\"`).join(\" | \")})`;\n }\n return meta.description ? `${line} — ${meta.description}` : line;\n}\n\n/**\n * Generates a markdown schema doc for an AI agent from the declared sync schema's table\n * builders — no database access needed. Tables are presented under the view names the agent\n * queries; descriptions come from `.describe()` on the table and column builders.\n * The internal `tombstone` column is omitted.\n *\n * The doc always includes a built-in preamble explaining sqlite-sync mechanics (read-only\n * views, soft-deletes already filtered) after the consumer's `overview` — consumers only\n * need to describe their own domain.\n */\nexport function createSchemaDoc(opts: { syncDbSchema: SyncDbSchema; context?: SchemaDocContext }): string {\n const sections: string[] = [];\n\n const overview = opts.context?.overview?.trim();\n if (overview) {\n sections.push(overview);\n }\n sections.push(SCHEMA_DOC_PREAMBLE);\n\n for (const [crdtTableName, table] of Object.entries(opts.syncDbSchema.tables)) {\n const lines = [`## ${crdtTableName}`];\n if (table.description) {\n lines.push(\"\", table.description.trim());\n }\n lines.push(\"\", \"Columns:\");\n for (const [name, meta] of Object.entries(table.columns)) {\n if (name === \"tombstone\") continue;\n lines.push(renderColumn(name, meta));\n }\n sections.push(lines.join(\"\\n\"));\n }\n\n sections.push(CHANGE_HISTORY_DOC);\n\n return sections.join(\"\\n\\n\");\n}\n\n// Documents the curated `change_history` view created over the sync event log. Unlike the table\n// views above, it is NOT tombstone-filtered — it intentionally surfaces the full change log,\n// including the contents of since-deleted items.\nconst CHANGE_HISTORY_DOC = [\n \"## change_history\",\n \"\",\n \"A read-only, append-only log of every change across all tables, one row per sync event.\",\n \"Query it like any other view. Unlike the tables above, soft-delete filtering does NOT apply\",\n \"here — it includes changes to items that were later deleted, so treat it as an audit log.\",\n \"\",\n \"Columns:\",\n \"- `seq` INTEGER NOT NULL — monotonic sequence number; **order history by this** (ascending = oldest first)\",\n \"- `dataset` TEXT NOT NULL — which table the change applies to (matches a table name above)\",\n \"- `item_id` TEXT NOT NULL — the `id` of the affected row\",\n '- `change_type` TEXT NOT NULL (one of \"item-created\" | \"item-updated\" | \"item-deleted\")',\n '- `status` TEXT NOT NULL (one of \"applied\" | \"pending\" | \"failed\" | \"deduped\") — \"applied\" took effect; \"failed\" did not; filter to `status = \\'applied\\'` for the effective history',\n '- `origin` TEXT NOT NULL — where the change came from (e.g. \"own\", \"remote\", \"local\")',\n \"- `timestamp` TEXT NOT NULL — opaque hybrid-logical-clock string; do NOT parse it as a date, order by `seq` instead\",\n \"- `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.\",\n].join(\"\\n\");\n","import { jsonSchema, type ToolSet, tool } from \"ai\";\nimport type { AiMutationInput, AiMutationResult, AiQueryInput, AiQueryResult } from \"./db-access\";\n\ntype MaybePromise<T> = T | Promise<T>;\n\n/**\n * What the tools need from the database side. Satisfied directly by `AiDbAccess`, or by a\n * Durable Object stub whose RPC methods delegate to one (RPC wraps returns in promises).\n */\nexport type DbToolsAccess = {\n getSchemaDoc(): MaybePromise<string>;\n query(input: AiQueryInput): MaybePromise<AiQueryResult>;\n mutate?(input: AiMutationInput): MaybePromise<AiMutationResult>;\n};\n\nexport type CreateDbToolsOptions = {\n access: () => MaybePromise<DbToolsAccess>;\n /** Expose the write-capable `mutateDb` tool. The access object must also implement `mutate`. */\n mutations?: boolean;\n};\n\nconst emptyInputSchema = jsonSchema<Record<string, never>>({\n type: \"object\",\n properties: {},\n additionalProperties: false,\n});\n\nconst queryInputSchema = jsonSchema<{ sql: string; parameters?: unknown[] }>({\n type: \"object\",\n properties: {\n sql: {\n type: \"string\",\n description:\n \"A single read-only SQLite statement starting with SELECT, WITH, or VALUES. Use ? placeholders for values.\",\n },\n parameters: {\n type: \"array\",\n items: { type: [\"string\", \"number\", \"boolean\", \"null\"] },\n description: \"Values bound to the ? placeholders, in order.\",\n },\n },\n required: [\"sql\"],\n additionalProperties: false,\n});\n\nconst mutationInputSchema = jsonSchema<AiMutationInput>({\n type: \"object\",\n properties: {\n events: {\n type: \"array\",\n minItems: 1,\n items: {\n anyOf: [\n {\n type: \"object\",\n properties: {\n type: {\n type: \"string\",\n enum: [\"item-created\"],\n description: \"Create a synced row. Omit item_id and payload.id; the tool generates the id.\",\n },\n dataset: {\n type: \"string\",\n description: \"The synced dataset/table name from the schema documentation.\",\n },\n payload: {\n type: \"object\",\n additionalProperties: true,\n not: { required: [\"id\"] },\n description:\n \"Column values for the new row, excluding id. Include all required non-id columns from the schema.\",\n },\n },\n required: [\"type\", \"dataset\", \"payload\"],\n additionalProperties: false,\n },\n {\n type: \"object\",\n properties: {\n type: {\n type: \"string\",\n enum: [\"item-updated\", \"item-deleted\"],\n description: \"Update or delete an existing synced row.\",\n },\n dataset: {\n type: \"string\",\n description: \"The synced dataset/table name from the schema documentation.\",\n },\n item_id: {\n type: \"string\",\n description: \"The stable id of the row being updated or deleted.\",\n },\n payload: {\n type: \"object\",\n additionalProperties: true,\n description: \"Changed column values for item-updated. Omit or pass {} for item-deleted.\",\n },\n },\n required: [\"type\", \"dataset\", \"item_id\"],\n additionalProperties: false,\n },\n ],\n },\n description: \"One or more CRDT mutation events to apply atomically.\",\n },\n },\n required: [\"events\"],\n additionalProperties: false,\n});\n\n/**\n * AI SDK tools for a synced database. `access` is a factory because acquiring the database\n * may itself be async per call (e.g. resolving a Durable Object stub from another DO).\n */\nexport function createDbTools(opts: CreateDbToolsOptions): ToolSet {\n const tools: ToolSet = {\n getDbSchema: tool({\n description:\n \"Get the schema documentation for the synced SQLite database: tables, columns, types, and data conventions. Call this before reasoning about the data.\",\n inputSchema: emptyInputSchema,\n execute: async () => {\n const access = await opts.access();\n return await access.getSchemaDoc();\n },\n }),\n queryDb: tool({\n description:\n \"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.\",\n inputSchema: queryInputSchema,\n execute: async ({ sql, parameters }) => {\n const access = await opts.access();\n return await access.query({ sql, parameters });\n },\n }),\n };\n\n if (opts.mutations) {\n tools.mutateDb = tool({\n description:\n \"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.\",\n inputSchema: mutationInputSchema,\n execute: async (input) => {\n const access = await opts.access();\n if (!access.mutate) {\n return { error: \"Database mutations are not enabled for this access object.\" } satisfies AiMutationResult;\n }\n return await access.mutate(input);\n },\n });\n }\n\n return tools;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/query-guard.ts","../src/schema-doc.ts","../src/db-access.ts","../src/tools.ts"],"sourcesContent":["import type { AiDbExecutor } from \"./db-access\";\n\nexport type QueryGuardInput = {\n sql: string;\n parameters?: readonly unknown[];\n};\n\nexport type QueryGuardRejection = {\n allowed: false;\n code: \"invalid-statement\" | \"multi-statement\" | \"invalid-sql\" | \"write-detected\";\n message: string;\n};\n\nexport type QueryGuardVerdict = { allowed: true } | QueryGuardRejection;\n\nexport class QueryGuardError extends Error {\n readonly rejection: QueryGuardRejection;\n\n constructor(rejection: QueryGuardRejection) {\n super(rejection.message);\n this.name = \"QueryGuardError\";\n this.rejection = rejection;\n }\n}\n\nexport type QueryGuard = {\n /**\n * Statically verifies the query is a read-only single statement. Reads are not restricted\n * by table — the whole database file is in scope for the agent, so don't colocate data the\n * agent must not see.\n */\n check(input: QueryGuardInput): QueryGuardVerdict;\n /**\n * `check` + execute inside a forced-rollback transaction (the unconditional backstop for\n * anything the analysis might miss). Throws {@link QueryGuardError} when the check rejects.\n */\n execute<TResult = unknown>(input: QueryGuardInput): { rows: TResult[] };\n};\n\n/**\n * Opcodes that prove a statement is not read-only. `OpenWrite` covers row-level writes (every\n * real-table mutation opens its cursor through it), `Clear`/`Destroy` cover whole-table\n * deletes that skip cursors (truncate optimization), the rest cover DDL, pragma-class\n * statements (which a transaction rollback would NOT undo), virtual-table writes, and trigger\n * subprograms. `Insert`/`Delete`/`IdxInsert` are deliberately absent — they also run against\n * ephemeral/sorter cursors in ordinary SELECTs (DISTINCT, ORDER BY) and are only dangerous on\n * a cursor an `OpenWrite` would have created.\n */\nconst WRITE_OPCODES = new Set([\n \"OpenWrite\",\n \"Clear\",\n \"Destroy\",\n \"CreateBtree\",\n \"SqlExec\",\n \"ParseSchema\",\n \"DropTable\",\n \"DropIndex\",\n \"DropTrigger\",\n \"SetCookie\",\n \"JournalMode\",\n \"Vacuum\",\n \"IncrVacuum\",\n \"Checkpoint\",\n \"MaxPgcnt\",\n \"Expire\",\n \"AutoCommit\",\n \"VUpdate\",\n \"VCreate\",\n \"VDestroy\",\n \"Program\",\n]);\n\ntype ExplainRow = {\n opcode: string;\n};\n\nconst READ_STATEMENT_PREFIXES = [\"select\", \"with\", \"values\"];\n\nexport function createQueryGuard(opts: { executor: AiDbExecutor }): QueryGuard {\n function reject(code: QueryGuardRejection[\"code\"], message: string): QueryGuardRejection {\n return { allowed: false, code, message };\n }\n\n function check(input: QueryGuardInput): QueryGuardVerdict {\n let body = input.sql.trim();\n while (body.endsWith(\";\")) {\n body = body.slice(0, -1).trimEnd();\n }\n\n if (body.includes(\";\")) {\n return reject(\n \"multi-statement\",\n \"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.\",\n );\n }\n\n const lowered = body.toLowerCase();\n if (!READ_STATEMENT_PREFIXES.some((keyword) => lowered.startsWith(keyword))) {\n return reject(\n \"invalid-statement\",\n \"Only read-only queries are allowed: the statement must start directly with SELECT, WITH, or VALUES (no leading comments).\",\n );\n }\n\n let operations: ExplainRow[];\n try {\n operations = opts.executor.execute<ExplainRow>({\n sql: `EXPLAIN ${input.sql}`,\n parameters: input.parameters ?? [],\n }).rows;\n } catch (error) {\n return reject(\"invalid-sql\", `SQL error: ${error instanceof Error ? error.message : String(error)}`);\n }\n\n for (const operation of operations) {\n if (WRITE_OPCODES.has(operation.opcode)) {\n return reject(\n \"write-detected\",\n `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.`,\n );\n }\n }\n\n return { allowed: true };\n }\n\n return {\n check,\n execute(input) {\n const verdict = check(input);\n if (!verdict.allowed) {\n throw new QueryGuardError(verdict);\n }\n return runWithForcedRollback(opts.executor, (tx) => {\n return tx.execute({ sql: input.sql, parameters: input.parameters ?? [] });\n });\n },\n };\n}\n\n/**\n * Runs `callback` in a transaction that is always rolled back (via a sentinel throw, relying\n * on the {@link AiDbExecutor} contract that a throwing callback rolls back). This is the\n * unconditional write guard backing any gap in the static analysis.\n */\nexport function runWithForcedRollback<T>(\n executor: Pick<AiDbExecutor, \"transaction\">,\n callback: (tx: Pick<AiDbExecutor, \"execute\">) => T,\n): T {\n let result: T | undefined;\n let completed = false;\n const rollbackSentinel = new Error(\"forced read-only rollback\");\n\n try {\n executor.transaction((tx) => {\n result = callback(tx);\n completed = true;\n throw rollbackSentinel;\n });\n } catch (error) {\n if (error !== rollbackSentinel) {\n throw error;\n }\n }\n\n if (!completed) {\n throw new Error(\"Transaction completed without running the callback\");\n }\n return result as T;\n}\n","import type { ColumnMeta, SyncDbSchema } from \"@sqlite-sync/core\";\n\nexport type SchemaDocContext = {\n overview?: string;\n};\n\n// Library mechanics every generated doc should explain, so consumers only have to describe\n// their own domain in `context`. Kept free of tombstone-column details the agent never sees.\nconst SCHEMA_DOC_PREAMBLE = [\n \"This is a synced SQLite database — data replicates automatically between the user's devices.\",\n \"All writes go through a sync event log, which is why the tables listed below are exposed as\",\n \"read-only SQL views; soft-deleted rows are already filtered out, so query them directly\",\n \"without any tombstone filtering. Every table has a unique `id` text primary key.\",\n].join(\"\\n\");\n\nfunction renderColumn(name: string, meta: ColumnMeta): string {\n let line = `- \\`${name}\\` ${meta.sqlType.toUpperCase()}`;\n if (!meta.nullable) {\n line += \" NOT NULL\";\n }\n if (meta.kind === \"boolean\") {\n line += \" (boolean 0/1)\";\n } else if (meta.kind === \"enum\") {\n line += ` (one of ${(meta.enumValues ?? []).map((value) => `\"${value}\"`).join(\" | \")})`;\n }\n return meta.description ? `${line} — ${meta.description}` : line;\n}\n\n/**\n * Generates a markdown schema doc for an AI agent from the declared sync schema's table\n * builders — no database access needed. Tables are presented under the view names the agent\n * queries; descriptions come from `.describe()` on the table and column builders.\n * The internal `tombstone` column is omitted.\n *\n * The doc always includes a built-in preamble explaining sqlite-sync mechanics (read-only\n * views, soft-deletes already filtered) after the consumer's `overview` — consumers only\n * need to describe their own domain.\n */\nexport function createSchemaDoc(opts: { syncDbSchema: SyncDbSchema; context?: SchemaDocContext }): string {\n const sections: string[] = [];\n\n const overview = opts.context?.overview?.trim();\n if (overview) {\n sections.push(overview);\n }\n sections.push(SCHEMA_DOC_PREAMBLE);\n\n for (const [crdtTableName, table] of Object.entries(opts.syncDbSchema.tables)) {\n const lines = [`## ${crdtTableName}`];\n if (table.description) {\n lines.push(\"\", table.description.trim());\n }\n lines.push(\"\", \"Columns:\");\n for (const [name, meta] of Object.entries(table.columns)) {\n if (name === \"tombstone\") continue;\n lines.push(renderColumn(name, meta));\n }\n sections.push(lines.join(\"\\n\"));\n }\n\n sections.push(CHANGE_HISTORY_DOC);\n\n return sections.join(\"\\n\\n\");\n}\n\n// Documents the curated `change_history` view created over the sync event log. Unlike the table\n// views above, it is NOT tombstone-filtered — it intentionally surfaces the full change log,\n// including the contents of since-deleted items.\nconst CHANGE_HISTORY_DOC = [\n \"## change_history\",\n \"\",\n \"A read-only, append-only log of every change across all tables, one row per sync event.\",\n \"Query it like any other view. Unlike the tables above, soft-delete filtering does NOT apply\",\n \"here — it includes changes to items that were later deleted, so treat it as an audit log.\",\n \"\",\n \"Columns:\",\n \"- `seq` INTEGER NOT NULL — monotonic sequence number; **order history by this** (ascending = oldest first)\",\n \"- `dataset` TEXT NOT NULL — which table the change applies to (matches a table name above)\",\n \"- `item_id` TEXT NOT NULL — the `id` of the affected row\",\n '- `change_type` TEXT NOT NULL (one of \"item-created\" | \"item-updated\" | \"item-deleted\")',\n '- `status` TEXT NOT NULL (one of \"applied\" | \"pending\" | \"failed\" | \"deduped\") — \"applied\" took effect; \"failed\" did not; filter to `status = \\'applied\\'` for the effective history',\n '- `origin` TEXT NOT NULL — where the change came from (e.g. \"own\", \"remote\", \"local\")',\n \"- `timestamp` TEXT NOT NULL — opaque hybrid-logical-clock string; do NOT parse it as a date, order by `seq` instead\",\n \"- `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.\",\n].join(\"\\n\");\n","import {\n type CrdtEventType,\n CrdtEventValidationError,\n type CrdtStorage,\n generateId,\n type OwnCrdtEvent,\n type SyncDbSchema,\n} from \"@sqlite-sync/core\";\nimport { createQueryGuard, QueryGuardError } from \"./query-guard\";\nimport { createSchemaDoc, type SchemaDocContext } from \"./schema-doc\";\n\nexport type AiDbExecuteParams = {\n sql: string;\n parameters: readonly unknown[];\n};\n\n/**\n * Minimal executor contract for AI database access. Runtime-specific — inject the raw SQL\n * capability that matches where the storage lives; a Cloudflare `ServerSyncDb`'s `unsafe`\n * executor satisfies it.\n */\nexport type AiDbExecutor = {\n execute<TResult = unknown>(query: AiDbExecuteParams): { rows: TResult[] };\n transaction(callback: (tx: Pick<AiDbExecutor, \"execute\">) => void): void;\n};\n\nexport type AiQueryInput = {\n sql: string;\n parameters?: readonly unknown[];\n};\n\nexport type AiQueryResult =\n | {\n rows: Record<string, unknown>[];\n rowCount: number;\n truncated: boolean;\n }\n | {\n error: string;\n };\n\nexport type AiMutationEvent =\n | {\n type: \"item-created\";\n dataset: string;\n item_id?: never;\n payload?: Record<string, unknown>;\n }\n | {\n type: Exclude<CrdtEventType, \"item-created\">;\n dataset: string;\n item_id: string;\n payload?: Record<string, unknown>;\n };\n\nexport type AiMutationInput = {\n events: AiMutationEvent[];\n};\n\nexport type AiMutationResult =\n | {\n applied: true;\n eventCount: number;\n createdIds: string[];\n }\n | {\n error: string;\n errors?: string[];\n };\n\n/**\n * AI access to a synced database. Lives where the storage lives; its method names\n * are the RPC contract, so a DO stub proxying to these methods exposes the same surface\n * (promise-wrapped) and satisfies the tool layer's `DbToolsAccess`.\n *\n * `query` enforces read-only (single SELECT/WITH/VALUES statement, no write opcodes, executed\n * in a forced-rollback transaction) but reads are not restricted by table — the whole database\n * file is in scope for the agent, so don't colocate data the agent must not see.\n *\n * `mutate` is only present when `createAiDbAccess` receives a CRDT storage. Mutations are CRDT\n * events applied through sqlite-sync's normal own-event path, never direct SQL writes.\n */\nexport type AiDbAccess = {\n getSchemaDoc(): string;\n query(input: AiQueryInput): AiQueryResult;\n mutate?(input: AiMutationInput): AiMutationResult;\n};\n\nfunction toBase64(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) {\n binary += String.fromCharCode(byte);\n }\n return btoa(binary);\n}\n\nexport function createAiDbAccess(opts: {\n executor: AiDbExecutor;\n storage?: Pick<CrdtStorage, \"applyOwnEvents\">;\n syncDbSchema: SyncDbSchema;\n context?: SchemaDocContext;\n limits?: { maxRows?: number; maxCellChars?: number };\n}): AiDbAccess {\n const schemaDoc = createSchemaDoc({ syncDbSchema: opts.syncDbSchema, context: opts.context });\n const guard = createQueryGuard({ executor: opts.executor });\n const maxRows = opts.limits?.maxRows ?? 200;\n const maxCellChars = opts.limits?.maxCellChars ?? 2000;\n\n const access: AiDbAccess = {\n getSchemaDoc() {\n return schemaDoc;\n },\n query(input) {\n let resultRows: Record<string, unknown>[];\n try {\n resultRows = guard.execute<Record<string, unknown>>(input).rows;\n } catch (error) {\n if (error instanceof QueryGuardError) {\n return { error: error.message };\n }\n throw error;\n }\n\n let truncated = resultRows.length > maxRows;\n\n function shapeCell(value: unknown): unknown {\n if (value instanceof Uint8Array) {\n // Base64 inflates by 4/3, so budget the encoded length against the cell cap.\n if (Math.ceil(value.byteLength / 3) * 4 <= maxCellChars) {\n return `<blob base64 ${toBase64(value)}>`;\n }\n truncated = true;\n return `<blob ${value.byteLength} bytes>`;\n }\n if (typeof value === \"string\" && value.length > maxCellChars) {\n truncated = true;\n return `${value.slice(0, maxCellChars)}…`;\n }\n return value;\n }\n\n const rows = resultRows\n .slice(0, maxRows)\n .map((row) => Object.fromEntries(Object.entries(row).map(([column, value]) => [column, shapeCell(value)])));\n\n return { rows, rowCount: resultRows.length, truncated };\n },\n };\n\n const storage = opts.storage;\n if (storage) {\n access.mutate = (input) => {\n const errors: string[] = [];\n const createdIds: string[] = [];\n const events: OwnCrdtEvent[] = [];\n\n for (const [index, event] of input.events.entries()) {\n if (event.type === \"item-created\") {\n const looseEvent = event as { item_id?: unknown };\n const payload = event.payload ?? {};\n if (looseEvent.item_id !== undefined) {\n errors.push(`[${index}] item-created events must omit item_id; an id is generated automatically`);\n }\n if (\"id\" in payload) {\n errors.push(`[${index}] item-created payload must omit id; an id is generated automatically`);\n }\n if (looseEvent.item_id !== undefined || \"id\" in payload) {\n continue;\n }\n\n const id = generateId();\n createdIds.push(id);\n events.push({\n type: \"item-created\",\n dataset: event.dataset,\n item_id: id,\n payload: JSON.stringify({ ...payload, id }),\n });\n continue;\n }\n\n events.push({\n type: event.type,\n dataset: event.dataset,\n item_id: event.item_id,\n payload: JSON.stringify(event.payload ?? {}),\n });\n }\n\n if (errors.length > 0) {\n return { error: `Invalid mutation events: ${errors.join(\"; \")}`, errors };\n }\n\n try {\n storage.applyOwnEvents(events);\n } catch (error) {\n if (error instanceof CrdtEventValidationError) {\n return { error: error.message, errors: error.errors };\n }\n throw error;\n }\n\n return { applied: true, eventCount: events.length, createdIds };\n };\n }\n\n return access;\n}\n","import { jsonSchema, type ToolSet, tool } from \"ai\";\nimport type { AiMutationInput, AiMutationResult, AiQueryInput, AiQueryResult } from \"./db-access\";\n\ntype MaybePromise<T> = T | Promise<T>;\n\n/**\n * What the tools need from the database side. Satisfied directly by `AiDbAccess`, or by a\n * Durable Object stub whose RPC methods delegate to one (RPC wraps returns in promises).\n */\nexport type DbToolsAccess = {\n getSchemaDoc(): MaybePromise<string>;\n query(input: AiQueryInput): MaybePromise<AiQueryResult>;\n mutate?(input: AiMutationInput): MaybePromise<AiMutationResult>;\n};\n\nexport type CreateDbToolsOptions = {\n access: () => MaybePromise<DbToolsAccess>;\n /** Expose the write-capable `mutateDb` tool. The access object must also implement `mutate`. */\n mutations?: boolean;\n};\n\nconst emptyInputSchema = jsonSchema<Record<string, never>>({\n type: \"object\",\n properties: {},\n additionalProperties: false,\n});\n\nconst queryInputSchema = jsonSchema<{ sql: string; parameters?: unknown[] }>({\n type: \"object\",\n properties: {\n sql: {\n type: \"string\",\n description:\n \"A single read-only SQLite statement starting with SELECT, WITH, or VALUES. Use ? placeholders for values.\",\n },\n parameters: {\n type: \"array\",\n items: { type: [\"string\", \"number\", \"boolean\", \"null\"] },\n description: \"Values bound to the ? placeholders, in order.\",\n },\n },\n required: [\"sql\"],\n additionalProperties: false,\n});\n\nconst mutationInputSchema = jsonSchema<AiMutationInput>({\n type: \"object\",\n properties: {\n events: {\n type: \"array\",\n minItems: 1,\n items: {\n anyOf: [\n {\n type: \"object\",\n properties: {\n type: {\n type: \"string\",\n enum: [\"item-created\"],\n description: \"Create a synced row. Omit item_id and payload.id; the tool generates the id.\",\n },\n dataset: {\n type: \"string\",\n description: \"The synced dataset/table name from the schema documentation.\",\n },\n payload: {\n type: \"object\",\n additionalProperties: true,\n not: { required: [\"id\"] },\n description:\n \"Column values for the new row, excluding id. Include all required non-id columns from the schema.\",\n },\n },\n required: [\"type\", \"dataset\", \"payload\"],\n additionalProperties: false,\n },\n {\n type: \"object\",\n properties: {\n type: {\n type: \"string\",\n enum: [\"item-updated\", \"item-deleted\"],\n description: \"Update or delete an existing synced row.\",\n },\n dataset: {\n type: \"string\",\n description: \"The synced dataset/table name from the schema documentation.\",\n },\n item_id: {\n type: \"string\",\n description: \"The stable id of the row being updated or deleted.\",\n },\n payload: {\n type: \"object\",\n additionalProperties: true,\n description: \"Changed column values for item-updated. Omit or pass {} for item-deleted.\",\n },\n },\n required: [\"type\", \"dataset\", \"item_id\"],\n additionalProperties: false,\n },\n ],\n },\n description: \"One or more CRDT mutation events to apply atomically.\",\n },\n },\n required: [\"events\"],\n additionalProperties: false,\n});\n\n/**\n * AI SDK tools for a synced database. `access` is a factory because acquiring the database\n * may itself be async per call (e.g. resolving a Durable Object stub from another DO).\n */\nexport function createDbTools(opts: CreateDbToolsOptions): ToolSet {\n const tools: ToolSet = {\n getDbSchema: tool({\n description:\n \"Get the schema documentation for the synced SQLite database: tables, columns, types, and data conventions. Call this before reasoning about the data.\",\n inputSchema: emptyInputSchema,\n execute: async () => {\n const access = await opts.access();\n return await access.getSchemaDoc();\n },\n }),\n queryDb: tool({\n description:\n \"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.\",\n inputSchema: queryInputSchema,\n execute: async ({ sql, parameters }) => {\n const access = await opts.access();\n return await access.query({ sql, parameters });\n },\n }),\n };\n\n if (opts.mutations) {\n tools.mutateDb = tool({\n description:\n \"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.\",\n inputSchema: mutationInputSchema,\n execute: async (input) => {\n const access = await opts.access();\n if (!access.mutate) {\n return { error: \"Database mutations are not enabled for this access object.\" } satisfies AiMutationResult;\n }\n return await access.mutate(input);\n },\n });\n }\n\n return tools;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqlite-sync/ai",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "AI agent tools for @sqlite-sync databases",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,20 +31,20 @@
31
31
  "dist"
32
32
  ],
33
33
  "peerDependencies": {
34
- "@sqlite-sync/core": "^0.8.2",
34
+ "@sqlite-sync/core": "^0.9.0",
35
35
  "ai": "^6.0.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "ai": "^6.0.201",
39
- "tsup": "^8.3.5",
40
- "typescript": "~6.0.3",
39
+ "tsdown": "0.22.14",
40
+ "typescript": "npm:@typescript/typescript6@6.0.2",
41
41
  "vitest": "^4.1.8",
42
- "@sqlite-sync/core": "0.8.2"
42
+ "@sqlite-sync/core": "0.9.0"
43
43
  },
44
44
  "scripts": {
45
- "build": "tsup",
46
- "dev": "tsup --watch",
45
+ "build": "tsdown",
46
+ "dev": "tsdown --watch",
47
47
  "test": "vitest run",
48
- "typecheck": "tsgo --noEmit"
48
+ "typecheck": "tsc --noEmit"
49
49
  }
50
50
  }