@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 CHANGED
@@ -8,7 +8,7 @@ AI agent tools for [@sqlite-sync](https://github.com/krolebord-dev/sqlite-sync)
8
8
  - `createAiDbAccess` — server-side access object living next to the storage; its methods double as an RPC contract for cross-Durable-Object setups.
9
9
  - `createDbTools` — AI SDK v6 `ToolSet` (`getDbSchema` and `queryDb` tools, plus optional `mutateDb`) backed by an `AiDbAccess` or a stub proxying to one.
10
10
 
11
- `queryDb` is strictly read-only: a single `SELECT`/`WITH`/`VALUES` statement, verified against SQLite's `EXPLAIN` bytecode for write opcodes, and executed inside a transaction that is always rolled back. Reads are **not** restricted by table — the agent can query every table in the database file (including sqlite-sync's internal event log), so don't colocate data the agent must not see. Results are capped (default 200 rows, 2000 chars per cell) and report `truncated` so the agent can narrow its query.
11
+ `queryDb` is strictly read-only: a single `SELECT`/`WITH`/`VALUES` statement, verified against SQLite's `EXPLAIN` bytecode for write opcodes, and executed inside a transaction that is always rolled back. By default reads are **not** restricted by table — the agent can query every table in the database file (including sqlite-sync's internal event log), so don't colocate data the agent must not see unless you hide it (see below). Results are capped (default 200 rows, 2000 chars per cell) and report `truncated` so the agent can narrow its query.
12
12
 
13
13
  `mutateDb` is opt-in. It applies `item-created`, `item-updated`, and `item-deleted` CRDT events through sqlite-sync's own-event path, so writes are validated, persisted to the event log, applied locally, and synced normally. It does not run arbitrary write SQL. For `item-created` events, omit `item_id` and `payload.id`; the tool generates ids, injects them into the CRDT events, and returns them as `createdIds`.
14
14
 
@@ -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: {
@@ -65,3 +65,34 @@ getTools() {
65
65
  The doc skips the internal `tombstone` column and renders enum columns with their allowed values and boolean columns with a `0/1` hint. Table and column descriptions come from `.describe()` on the builders.
66
66
 
67
67
  The generated doc always includes a built-in preamble (after your `overview`) explaining sqlite-sync mechanics — that the database syncs between devices and that the listed tables are read-only views with soft-deleted rows already filtered out. Prefer keeping domain semantics in the schema with `.describe()` and using `context.overview` for app-level notes.
68
+
69
+ ## Limiting what the agent may touch
70
+
71
+ Declare AI access next to the schema. Tables are `"read-write"` unless narrowed:
72
+
73
+ ```ts
74
+ const syncDbSchema = defineSyncSchema({
75
+ tables: {
76
+ todos: t.table({ title: t.text() }),
77
+ // Queryable and documented, never mutated by the agent.
78
+ audit: t.table({ note: t.text() }).ai("read-only"),
79
+ // Not in the doc, and queries that read it are rejected.
80
+ billing: t.table({ card_last4: t.text() }).ai("hidden"),
81
+ },
82
+ migrations,
83
+ });
84
+ ```
85
+
86
+ What each level enforces:
87
+
88
+ - `"read-only"` keeps the table in the schema doc, labelled read-only, and makes `mutateDb` reject events for it.
89
+ - `"hidden"` removes it from the doc and makes `queryDb` reject any statement that reads it. Enforcement works off the root pages a compiled statement opens, so views, aliases, CTEs, subqueries and quoting tricks all resolve to the same check.
90
+
91
+ Access is table-level. There is no per-column setting: SQLite bytecode identifies tables rather than columns, so a column could not be hidden from reads, and blocking writes to a required column would make rows impossible to create. Put data the agent must not touch in its own table.
92
+
93
+ Hiding a table has two side effects worth knowing:
94
+
95
+ - Reads switch to an allow-list of the remaining base tables, so any other table in the same database file (including non-synced tables you created yourself) becomes unreadable too.
96
+ - `change_history` is dropped from the doc and denied. That view reads the raw event log, which holds every dataset's payloads, so it cannot be filtered per table.
97
+
98
+ Names and columns of hidden tables can still be discovered through SQLite's schema introspection in principle; the guard rejects `pragma_*` table-valued functions and reads of `sqlite_schema` while restricted, but treat hiding as row-level protection rather than proof the table does not exist.
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 { AiAccess, 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
@@ -10,61 +10,66 @@ type SchemaDocContext = {
10
10
  * queries; descriptions come from `.describe()` on the table and column builders.
11
11
  * The internal `tombstone` column is omitted.
12
12
  *
13
+ * Tables declared `.ai("hidden")` are left out entirely, and read-only ones are labelled so the
14
+ * agent does not attempt a mutation that would be rejected.
15
+ *
13
16
  * The doc always includes a built-in preamble explaining sqlite-sync mechanics (read-only
14
17
  * views, soft-deletes already filtered) after the consumer's `overview` — consumers only
15
18
  * need to describe their own domain.
16
19
  */
17
20
  declare function createSchemaDoc(opts: {
18
- syncDbSchema: SyncDbSchema;
19
- context?: SchemaDocContext;
21
+ syncDbSchema: SyncDbSchema;
22
+ context?: SchemaDocContext;
20
23
  }): string;
21
-
24
+ //#endregion
25
+ //#region src/db-access.d.ts
22
26
  type AiDbExecuteParams = {
23
- sql: string;
24
- parameters: readonly unknown[];
27
+ sql: string;
28
+ parameters: readonly unknown[];
25
29
  };
26
30
  /**
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.
31
+ * Minimal executor contract for AI database access. Runtime-specific — inject the raw SQL
32
+ * capability that matches where the storage lives; a Cloudflare `ServerSyncDb`'s `unsafe`
33
+ * executor satisfies it.
29
34
  */
30
35
  type AiDbExecutor = {
31
- execute<TResult = unknown>(query: AiDbExecuteParams): {
32
- rows: TResult[];
33
- };
34
- transaction(callback: (tx: Pick<AiDbExecutor, "execute">) => void): void;
36
+ execute<TResult = unknown>(query: AiDbExecuteParams): {
37
+ rows: TResult[];
38
+ };
39
+ transaction(callback: (tx: Pick<AiDbExecutor, "execute">) => void): void;
35
40
  };
36
41
  type AiQueryInput = {
37
- sql: string;
38
- parameters?: readonly unknown[];
42
+ sql: string;
43
+ parameters?: readonly unknown[];
39
44
  };
40
45
  type AiQueryResult = {
41
- rows: Record<string, unknown>[];
42
- rowCount: number;
43
- truncated: boolean;
46
+ rows: Record<string, unknown>[];
47
+ rowCount: number;
48
+ truncated: boolean;
44
49
  } | {
45
- error: string;
50
+ error: string;
46
51
  };
47
52
  type AiMutationEvent = {
48
- type: "item-created";
49
- dataset: string;
50
- item_id?: never;
51
- payload?: Record<string, unknown>;
53
+ type: "item-created";
54
+ dataset: string;
55
+ item_id?: never;
56
+ payload?: Record<string, unknown>;
52
57
  } | {
53
- type: Exclude<CrdtEventType, "item-created">;
54
- dataset: string;
55
- item_id: string;
56
- payload?: Record<string, unknown>;
58
+ type: Exclude<CrdtEventType, "item-created">;
59
+ dataset: string;
60
+ item_id: string;
61
+ payload?: Record<string, unknown>;
57
62
  };
58
63
  type AiMutationInput = {
59
- events: AiMutationEvent[];
64
+ events: AiMutationEvent[];
60
65
  };
61
66
  type AiMutationResult = {
62
- applied: true;
63
- eventCount: number;
64
- createdIds: string[];
67
+ applied: true;
68
+ eventCount: number;
69
+ createdIds: string[];
65
70
  } | {
66
- error: string;
67
- errors?: string[];
71
+ error: string;
72
+ errors?: string[];
68
73
  };
69
74
  /**
70
75
  * AI access to a synced database. Lives where the storage lives; its method names
@@ -72,61 +77,95 @@ type AiMutationResult = {
72
77
  * (promise-wrapped) and satisfies the tool layer's `DbToolsAccess`.
73
78
  *
74
79
  * `query` enforces read-only (single SELECT/WITH/VALUES statement, no write opcodes, executed
75
- * in a forced-rollback transaction) but reads are not restricted by table — the whole database
76
- * file is in scope for the agent, so don't colocate data the agent must not see.
80
+ * in a forced-rollback transaction). Reads are restricted by table only once the schema declares
81
+ * a table `.ai("hidden")`; until then the whole database file is in scope for the agent, so don't
82
+ * colocate data the agent must not see.
77
83
  *
78
84
  * `mutate` is only present when `createAiDbAccess` receives a CRDT storage. Mutations are CRDT
79
- * events applied through sqlite-sync's normal own-event path, never direct SQL writes.
85
+ * events applied through sqlite-sync's normal own-event path, never direct SQL writes, and are
86
+ * rejected for tables the schema declares read-only or hidden.
80
87
  */
81
88
  type AiDbAccess = {
82
- getSchemaDoc(): string;
83
- query(input: AiQueryInput): AiQueryResult;
84
- mutate?(input: AiMutationInput): AiMutationResult;
89
+ getSchemaDoc(): string;
90
+ query(input: AiQueryInput): AiQueryResult;
91
+ mutate?(input: AiMutationInput): AiMutationResult;
85
92
  };
86
93
  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
- };
94
+ executor: AiDbExecutor;
95
+ storage?: Pick<CrdtStorage, "applyOwnEvents">;
96
+ syncDbSchema: SyncDbSchema;
97
+ context?: SchemaDocContext;
98
+ limits?: {
99
+ maxRows?: number;
100
+ maxCellChars?: number;
101
+ };
95
102
  }): AiDbAccess;
96
-
103
+ //#endregion
104
+ //#region src/policy.d.ts
105
+ type ResolvedTablePolicy = {
106
+ crdtTableName: string;
107
+ baseTableName: string;
108
+ access: AiAccess;
109
+ };
110
+ type ResolvedAiPolicy = {
111
+ /** In schema declaration order, hidden tables included. */
112
+ tables: ResolvedTablePolicy[];
113
+ hasHiddenTables: boolean;
114
+ /** Base table names the agent may read. Only meaningful when `hasHiddenTables` is true. */
115
+ readableBaseTableNames: string[];
116
+ /** Resolves either the crdt or the base table name; anything unknown is "hidden". */
117
+ tableAccess(dataset: string): AiAccess;
118
+ };
119
+ /**
120
+ * Flattens the AI access declared on the schema's table builders into the lookups the doc
121
+ * generator and the enforcement points share, so they cannot disagree. Access is table-level:
122
+ * see {@link AiAccess}.
123
+ */
124
+ declare function resolveAiPolicy(opts: {
125
+ syncDbSchema: SyncDbSchema;
126
+ }): ResolvedAiPolicy;
127
+ //#endregion
128
+ //#region src/query-guard.d.ts
97
129
  type QueryGuardInput = {
98
- sql: string;
99
- parameters?: readonly unknown[];
130
+ sql: string;
131
+ parameters?: readonly unknown[];
100
132
  };
101
133
  type QueryGuardRejection = {
102
- allowed: false;
103
- code: "invalid-statement" | "multi-statement" | "invalid-sql" | "write-detected";
104
- message: string;
134
+ allowed: false;
135
+ code: "invalid-statement" | "multi-statement" | "invalid-sql" | "write-detected" | "table-denied";
136
+ message: string;
105
137
  };
106
138
  type QueryGuardVerdict = {
107
- allowed: true;
139
+ allowed: true;
108
140
  } | QueryGuardRejection;
109
141
  declare class QueryGuardError extends Error {
110
- readonly rejection: QueryGuardRejection;
111
- constructor(rejection: QueryGuardRejection);
142
+ readonly rejection: QueryGuardRejection;
143
+ constructor(rejection: QueryGuardRejection);
112
144
  }
113
145
  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
- };
146
+ /**
147
+ * Statically verifies the query is a read-only single statement, and — when the guard was
148
+ * created with `readableTables` — that it only reads those tables. Without `readableTables`
149
+ * reads are unrestricted: the whole database file is in scope for the agent, so don't
150
+ * colocate data the agent must not see.
151
+ */
152
+ check(input: QueryGuardInput): QueryGuardVerdict;
153
+ /**
154
+ * `check` + execute inside a forced-rollback transaction (the unconditional backstop for
155
+ * anything the analysis might miss). Throws {@link QueryGuardError} when the check rejects.
156
+ */
157
+ execute<TResult = unknown>(input: QueryGuardInput): {
158
+ rows: TResult[];
159
+ };
127
160
  };
128
161
  declare function createQueryGuard(opts: {
129
- executor: AiDbExecutor;
162
+ executor: AiDbExecutor;
163
+ /**
164
+ * Restrict reads to these tables (matched case-insensitively, indexes on them included).
165
+ * Views are resolved through their base tables, so pass base table names. Omit for
166
+ * unrestricted reads.
167
+ */
168
+ readableTables?: readonly string[];
130
169
  }): QueryGuard;
131
170
  /**
132
171
  * Runs `callback` in a transaction that is always rolled back (via a sentinel throw, relying
@@ -134,26 +173,28 @@ declare function createQueryGuard(opts: {
134
173
  * unconditional write guard backing any gap in the static analysis.
135
174
  */
136
175
  declare function runWithForcedRollback<T>(executor: Pick<AiDbExecutor, "transaction">, callback: (tx: Pick<AiDbExecutor, "execute">) => T): T;
137
-
176
+ //#endregion
177
+ //#region src/tools.d.ts
138
178
  type MaybePromise<T> = T | Promise<T>;
139
179
  /**
140
180
  * What the tools need from the database side. Satisfied directly by `AiDbAccess`, or by a
141
181
  * Durable Object stub whose RPC methods delegate to one (RPC wraps returns in promises).
142
182
  */
143
183
  type DbToolsAccess = {
144
- getSchemaDoc(): MaybePromise<string>;
145
- query(input: AiQueryInput): MaybePromise<AiQueryResult>;
146
- mutate?(input: AiMutationInput): MaybePromise<AiMutationResult>;
184
+ getSchemaDoc(): MaybePromise<string>;
185
+ query(input: AiQueryInput): MaybePromise<AiQueryResult>;
186
+ mutate?(input: AiMutationInput): MaybePromise<AiMutationResult>;
147
187
  };
148
188
  type CreateDbToolsOptions = {
149
- access: () => MaybePromise<DbToolsAccess>;
150
- /** Expose the write-capable `mutateDb` tool. The access object must also implement `mutate`. */
151
- mutations?: boolean;
189
+ access: () => MaybePromise<DbToolsAccess>;
190
+ /** Expose the write-capable `mutateDb` tool. The access object must also implement `mutate`. */
191
+ mutations?: boolean;
152
192
  };
153
193
  /**
154
194
  * AI SDK tools for a synced database. `access` is a factory because acquiring the database
155
195
  * may itself be async per call (e.g. resolving a Durable Object stub from another DO).
156
196
  */
157
197
  declare function createDbTools(opts: CreateDbToolsOptions): ToolSet;
158
-
159
- 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 };
198
+ //#endregion
199
+ 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 ResolvedAiPolicy, type ResolvedTablePolicy, type SchemaDocContext, createAiDbAccess, createDbTools, createQueryGuard, createSchemaDoc, resolveAiPolicy, runWithForcedRollback };
200
+ //# sourceMappingURL=index.d.ts.map