@persql/sdk 0.1.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 +146 -0
- package/dist/chunk-CDNTQOBK.js +1737 -0
- package/dist/chunk-CDNTQOBK.js.map +1 -0
- package/dist/cli.cjs +1827 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.js +103 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +1772 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +926 -0
- package/dist/index.d.ts +926 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1737 @@
|
|
|
1
|
+
// src/local.ts
|
|
2
|
+
var PROPOSAL_TTL_DEFAULT_SEC = 600;
|
|
3
|
+
var PROPOSAL_TTL_MAX_SEC = 3600;
|
|
4
|
+
function newLocalProposalId() {
|
|
5
|
+
const buf = new Uint8Array(8);
|
|
6
|
+
crypto.getRandomValues(buf);
|
|
7
|
+
let hex = "";
|
|
8
|
+
for (const b of buf) hex += b.toString(16).padStart(2, "0");
|
|
9
|
+
return `pmut_local_${hex}`;
|
|
10
|
+
}
|
|
11
|
+
var LocalDriver = class {
|
|
12
|
+
constructor(path) {
|
|
13
|
+
this.path = path;
|
|
14
|
+
}
|
|
15
|
+
db = null;
|
|
16
|
+
proposals = /* @__PURE__ */ new Map();
|
|
17
|
+
async open() {
|
|
18
|
+
if (this.db) return this.db;
|
|
19
|
+
const moduleName = "better-sqlite3";
|
|
20
|
+
let mod;
|
|
21
|
+
try {
|
|
22
|
+
mod = await import(moduleName);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"PerSQL local mode requires the `better-sqlite3` peer dep. Install with `npm i -D better-sqlite3` (or pnpm/yarn equivalent)."
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const m = mod;
|
|
29
|
+
const Ctor = m.default ?? mod;
|
|
30
|
+
this.db = new Ctor(this.path);
|
|
31
|
+
this.db.pragma("foreign_keys = ON");
|
|
32
|
+
return this.db;
|
|
33
|
+
}
|
|
34
|
+
async query(sql, params) {
|
|
35
|
+
const db = await this.open();
|
|
36
|
+
return runOne(db, sql, params);
|
|
37
|
+
}
|
|
38
|
+
async batch(statements, transaction) {
|
|
39
|
+
const db = await this.open();
|
|
40
|
+
const exec = () => statements.map((s) => runOne(db, s.sql, s.params ?? []));
|
|
41
|
+
if (transaction) return db.transaction(exec)();
|
|
42
|
+
return exec();
|
|
43
|
+
}
|
|
44
|
+
async tables() {
|
|
45
|
+
const db = await this.open();
|
|
46
|
+
const rows = runOne(
|
|
47
|
+
db,
|
|
48
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
|
49
|
+
[]
|
|
50
|
+
).rows;
|
|
51
|
+
return rows.map(([name]) => {
|
|
52
|
+
const c = runOne(db, `SELECT COUNT(*) FROM "${name.replace(/"/g, '""')}"`, []).rows[0];
|
|
53
|
+
return { name, rowCount: Number(c?.[0] ?? 0) };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async explain(sql, params) {
|
|
57
|
+
const db = await this.open();
|
|
58
|
+
const r = runOne(db, `EXPLAIN QUERY PLAN ${sql}`, params);
|
|
59
|
+
return r.rows.map((row) => ({
|
|
60
|
+
id: Number(row[0]),
|
|
61
|
+
parent: Number(row[1]),
|
|
62
|
+
detail: String(row[3])
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
async propose(sql, params, ttlSec) {
|
|
66
|
+
const db = await this.open();
|
|
67
|
+
const planRaw = runOne(db, `EXPLAIN QUERY PLAN ${sql}`, params).rows;
|
|
68
|
+
const plan = planRaw;
|
|
69
|
+
const ttl = Math.min(
|
|
70
|
+
Math.max(1, Math.floor(ttlSec ?? PROPOSAL_TTL_DEFAULT_SEC)),
|
|
71
|
+
PROPOSAL_TTL_MAX_SEC
|
|
72
|
+
);
|
|
73
|
+
const expiresAt = Date.now() + ttl * 1e3;
|
|
74
|
+
const executionToken = newLocalProposalId();
|
|
75
|
+
this.proposals.set(executionToken, { sql, params, expiresAt });
|
|
76
|
+
this.gcProposals();
|
|
77
|
+
return {
|
|
78
|
+
sql,
|
|
79
|
+
plan,
|
|
80
|
+
estimatedAffectedRows: null,
|
|
81
|
+
executionToken,
|
|
82
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
83
|
+
action: actionForSql(sql)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async apply(executionToken) {
|
|
87
|
+
const rec = this.proposals.get(executionToken);
|
|
88
|
+
if (!rec) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"PerSQL: executionToken is unknown, expired, or already redeemed"
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
this.proposals.delete(executionToken);
|
|
94
|
+
if (rec.expiresAt < Date.now()) {
|
|
95
|
+
throw new Error("PerSQL: executionToken has expired");
|
|
96
|
+
}
|
|
97
|
+
return this.query(rec.sql, rec.params);
|
|
98
|
+
}
|
|
99
|
+
gcProposals() {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
for (const [k, v] of this.proposals) {
|
|
102
|
+
if (v.expiresAt < now) this.proposals.delete(k);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
close() {
|
|
106
|
+
if (this.db) {
|
|
107
|
+
try {
|
|
108
|
+
this.db.close();
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
this.db = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
function actionForSql(sql) {
|
|
116
|
+
const head = sql.trim().slice(0, 24).toLowerCase();
|
|
117
|
+
if (head.startsWith("create") || head.startsWith("drop") || head.startsWith("alter")) {
|
|
118
|
+
return "admin";
|
|
119
|
+
}
|
|
120
|
+
if (head.startsWith("insert") || head.startsWith("update") || head.startsWith("delete") || head.startsWith("replace")) {
|
|
121
|
+
return "write";
|
|
122
|
+
}
|
|
123
|
+
return "read";
|
|
124
|
+
}
|
|
125
|
+
function runOne(db, sql, params) {
|
|
126
|
+
const stmt = db.prepare(sql);
|
|
127
|
+
if (stmt.reader) {
|
|
128
|
+
const cols = stmt.columns().map((c) => c.name);
|
|
129
|
+
const rows = stmt.raw(true).all(...params);
|
|
130
|
+
return { columns: cols, rows, rowsRead: rows.length, rowsWritten: 0 };
|
|
131
|
+
}
|
|
132
|
+
const info = stmt.run(...params);
|
|
133
|
+
return { columns: [], rows: [], rowsRead: 0, rowsWritten: info.changes ?? 0 };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/index.ts
|
|
137
|
+
var PerSQLError = class extends Error {
|
|
138
|
+
status;
|
|
139
|
+
// Set on /v1/query and /v1/batch failures with a parsed envelope
|
|
140
|
+
// (kind, table, column, hint, etc.) so callers can branch on
|
|
141
|
+
// `error.detail.kind` instead of string-matching the message.
|
|
142
|
+
detail;
|
|
143
|
+
constructor(status, message, detail) {
|
|
144
|
+
super(message);
|
|
145
|
+
this.name = "PerSQLError";
|
|
146
|
+
this.status = status;
|
|
147
|
+
this.detail = detail;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
var RateLimitError = class extends PerSQLError {
|
|
151
|
+
retryAfterSeconds;
|
|
152
|
+
constructor(retryAfterSeconds, message = "Rate limit exceeded") {
|
|
153
|
+
super(429, message);
|
|
154
|
+
this.name = "RateLimitError";
|
|
155
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
var ApprovalRequiredError = class extends PerSQLError {
|
|
159
|
+
approvalToken;
|
|
160
|
+
approvalUrl;
|
|
161
|
+
hits;
|
|
162
|
+
expiresAt;
|
|
163
|
+
constructor(opts) {
|
|
164
|
+
super(403, opts.message);
|
|
165
|
+
this.name = "ApprovalRequiredError";
|
|
166
|
+
this.approvalToken = opts.approvalToken;
|
|
167
|
+
this.approvalUrl = opts.approvalUrl;
|
|
168
|
+
this.hits = opts.hits;
|
|
169
|
+
this.expiresAt = opts.expiresAt;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
var PerSQL = class _PerSQL {
|
|
173
|
+
/** @internal — exposed read-only so PerSQLDatabase can build URLs. */
|
|
174
|
+
token;
|
|
175
|
+
/** @internal — same. */
|
|
176
|
+
baseURL;
|
|
177
|
+
/** @internal — set when running in local mode. */
|
|
178
|
+
local;
|
|
179
|
+
_fetch;
|
|
180
|
+
constructor(opts) {
|
|
181
|
+
if (opts.local) {
|
|
182
|
+
this.local = new LocalDriver(opts.local);
|
|
183
|
+
this.token = "";
|
|
184
|
+
this.baseURL = "";
|
|
185
|
+
this._fetch = opts.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!opts.token) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
'PerSQL: token is required (or pass { local: ":memory:" } for tests)'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
this.local = null;
|
|
194
|
+
this.token = opts.token;
|
|
195
|
+
this.baseURL = (opts.baseURL ?? "https://api.persql.com").replace(/\/$/, "");
|
|
196
|
+
this._fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
197
|
+
}
|
|
198
|
+
/** Close the local SQLite connection (no-op in HTTP mode). */
|
|
199
|
+
close() {
|
|
200
|
+
this.local?.close();
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Redeem a handoff token (`phand_…`) for a regular PerSQL client
|
|
204
|
+
* scoped to the handed-off (database, branch, role). Single use:
|
|
205
|
+
* the handoff token is consumed by the call.
|
|
206
|
+
*
|
|
207
|
+
* Common pattern — agent A pins a branch and hands the token to
|
|
208
|
+
* agent B; agent B does:
|
|
209
|
+
*
|
|
210
|
+
* const persql = await PerSQL.fromHandoff(handoffToken);
|
|
211
|
+
* const db = persql.database(persql.handedOff!.namespaceSlug, persql.handedOff!.databaseSlug);
|
|
212
|
+
*/
|
|
213
|
+
static async fromHandoff(handoffToken, opts = {}) {
|
|
214
|
+
const baseURL = (opts.baseURL ?? "https://api.persql.com").replace(/\/$/, "");
|
|
215
|
+
const fetcher = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
216
|
+
const res = await fetcher(`${baseURL}/v1/handoff/claim`, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "Content-Type": "application/json" },
|
|
219
|
+
body: JSON.stringify({ token: handoffToken, name: opts.name })
|
|
220
|
+
});
|
|
221
|
+
const envelope = await res.json().catch(() => null);
|
|
222
|
+
if (!res.ok || !envelope?.success || !envelope.data) {
|
|
223
|
+
throw new PerSQLError(
|
|
224
|
+
res.status,
|
|
225
|
+
envelope?.error ?? `Handoff claim failed (${res.status})`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const { plaintext, ...handedOff } = envelope.data;
|
|
229
|
+
const client = new _PerSQL({ token: plaintext, baseURL, fetch: opts.fetch });
|
|
230
|
+
return Object.assign(client, { handedOff });
|
|
231
|
+
}
|
|
232
|
+
database(a, b) {
|
|
233
|
+
if (b !== void 0) return new PerSQLDatabase(this, a, b);
|
|
234
|
+
const [ns, slug] = a.split("/");
|
|
235
|
+
if (!ns || !slug) {
|
|
236
|
+
throw new Error('PerSQL: database path must be "<namespace>/<slug>"');
|
|
237
|
+
}
|
|
238
|
+
return new PerSQLDatabase(this, ns, slug);
|
|
239
|
+
}
|
|
240
|
+
/** @internal */
|
|
241
|
+
async request(method, path, body, headers) {
|
|
242
|
+
const res = await this._fetch(`${this.baseURL}${path}`, {
|
|
243
|
+
method,
|
|
244
|
+
headers: {
|
|
245
|
+
Authorization: `Bearer ${this.token}`,
|
|
246
|
+
"Content-Type": "application/json",
|
|
247
|
+
...headers ?? {}
|
|
248
|
+
},
|
|
249
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
250
|
+
});
|
|
251
|
+
if (res.status === 429) {
|
|
252
|
+
const retryAfter = Number(res.headers.get("retry-after") ?? "60");
|
|
253
|
+
throw new RateLimitError(retryAfter);
|
|
254
|
+
}
|
|
255
|
+
let envelope = null;
|
|
256
|
+
try {
|
|
257
|
+
envelope = await res.json();
|
|
258
|
+
} catch {
|
|
259
|
+
}
|
|
260
|
+
if (!res.ok || !envelope?.success) {
|
|
261
|
+
const message = envelope?.error ?? `Request failed (${res.status})`;
|
|
262
|
+
if (res.status === 403 && envelope?.approvalToken && envelope?.approvalUrl && envelope?.expiresAt) {
|
|
263
|
+
throw new ApprovalRequiredError({
|
|
264
|
+
message,
|
|
265
|
+
approvalToken: envelope.approvalToken,
|
|
266
|
+
approvalUrl: envelope.approvalUrl,
|
|
267
|
+
hits: envelope.hits ?? [],
|
|
268
|
+
expiresAt: envelope.expiresAt
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
throw new PerSQLError(res.status, message, envelope?.errorDetail);
|
|
272
|
+
}
|
|
273
|
+
return envelope.data;
|
|
274
|
+
}
|
|
275
|
+
/** @internal — raw upload (e.g. blob PUT) returning the ApiResponse data. */
|
|
276
|
+
async requestRaw(method, path, body, contentType) {
|
|
277
|
+
const res = await this._fetch(`${this.baseURL}${path}`, {
|
|
278
|
+
method,
|
|
279
|
+
headers: {
|
|
280
|
+
Authorization: `Bearer ${this.token}`,
|
|
281
|
+
"Content-Type": contentType ?? "application/octet-stream"
|
|
282
|
+
},
|
|
283
|
+
body,
|
|
284
|
+
// @ts-expect-error — duplex is required by Node fetch for streaming bodies
|
|
285
|
+
duplex: "half"
|
|
286
|
+
});
|
|
287
|
+
if (res.status === 429) {
|
|
288
|
+
const retryAfter = Number(res.headers.get("retry-after") ?? "60");
|
|
289
|
+
throw new RateLimitError(retryAfter);
|
|
290
|
+
}
|
|
291
|
+
let envelope = null;
|
|
292
|
+
try {
|
|
293
|
+
envelope = await res.json();
|
|
294
|
+
} catch {
|
|
295
|
+
}
|
|
296
|
+
if (!res.ok || !envelope?.success) {
|
|
297
|
+
const message = envelope?.error ?? `Request failed (${res.status})`;
|
|
298
|
+
throw new PerSQLError(res.status, message, envelope?.errorDetail);
|
|
299
|
+
}
|
|
300
|
+
return envelope.data;
|
|
301
|
+
}
|
|
302
|
+
/** @internal — raw fetch returning the underlying Response (used by blob GET). */
|
|
303
|
+
fetchRaw(method, path) {
|
|
304
|
+
return this._fetch(`${this.baseURL}${path}`, {
|
|
305
|
+
method,
|
|
306
|
+
headers: { Authorization: `Bearer ${this.token}` }
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
var PerSQLDatabase = class {
|
|
311
|
+
constructor(client, namespace, slug) {
|
|
312
|
+
this.client = client;
|
|
313
|
+
this.namespace = namespace;
|
|
314
|
+
this.slug = slug;
|
|
315
|
+
}
|
|
316
|
+
/** Run a single SQL statement. */
|
|
317
|
+
async query(sql, params = [], options = {}) {
|
|
318
|
+
if (this.client.local) {
|
|
319
|
+
const raw2 = await this.client.local.query(sql, params);
|
|
320
|
+
return { ...raw2, data: rowsToObjects(raw2.columns, raw2.rows) };
|
|
321
|
+
}
|
|
322
|
+
const headers = {};
|
|
323
|
+
if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
|
|
324
|
+
if (options.planKey) headers["Plan-Key"] = options.planKey;
|
|
325
|
+
if (options.planStep) headers["Plan-Step"] = options.planStep;
|
|
326
|
+
const raw = await this.client.request(
|
|
327
|
+
"POST",
|
|
328
|
+
`/v1/db/${this.namespace}/${this.slug}/query`,
|
|
329
|
+
{ sql, params },
|
|
330
|
+
headers
|
|
331
|
+
);
|
|
332
|
+
return {
|
|
333
|
+
...raw,
|
|
334
|
+
data: rowsToObjects(raw.columns, raw.rows)
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/** Run multiple statements in one round-trip, optionally in a transaction. */
|
|
338
|
+
async batch(statements, options = {}) {
|
|
339
|
+
if (this.client.local) {
|
|
340
|
+
const raw2 = await this.client.local.batch(
|
|
341
|
+
statements,
|
|
342
|
+
options.transaction ?? false
|
|
343
|
+
);
|
|
344
|
+
return raw2.map((r) => ({ ...r, data: rowsToObjects(r.columns, r.rows) }));
|
|
345
|
+
}
|
|
346
|
+
const headers = {};
|
|
347
|
+
if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
|
|
348
|
+
if (options.planKey) headers["Plan-Key"] = options.planKey;
|
|
349
|
+
if (options.planStep) headers["Plan-Step"] = options.planStep;
|
|
350
|
+
const raw = await this.client.request(
|
|
351
|
+
"POST",
|
|
352
|
+
`/v1/db/${this.namespace}/${this.slug}/batch`,
|
|
353
|
+
{ statements, transaction: options.transaction ?? false },
|
|
354
|
+
headers
|
|
355
|
+
);
|
|
356
|
+
return raw.map((r) => ({ ...r, data: rowsToObjects(r.columns, r.rows) }));
|
|
357
|
+
}
|
|
358
|
+
/** Convenience for a transactional batch. */
|
|
359
|
+
transaction(statements, options = {}) {
|
|
360
|
+
return this.batch(statements, { ...options, transaction: true });
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Engine telemetry — recent /v1/query and /v1/batch calls issued
|
|
364
|
+
* against this database. Backed by the in-DO `_persql_meta_query_log`
|
|
365
|
+
* table; 7-day retention. Useful for agents that need to replay
|
|
366
|
+
* what they (or another agent) did, audit cost, or stream a live
|
|
367
|
+
* tail via `db.subscribe`.
|
|
368
|
+
*/
|
|
369
|
+
async queryLog(opts = {}) {
|
|
370
|
+
if (this.client.local) {
|
|
371
|
+
throw new Error("PerSQL: queryLog is not available in local mode.");
|
|
372
|
+
}
|
|
373
|
+
const qs = new URLSearchParams();
|
|
374
|
+
if (opts.since) {
|
|
375
|
+
qs.set(
|
|
376
|
+
"since",
|
|
377
|
+
typeof opts.since === "string" ? opts.since : opts.since.toISOString()
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (opts.until) {
|
|
381
|
+
qs.set(
|
|
382
|
+
"until",
|
|
383
|
+
typeof opts.until === "string" ? opts.until : opts.until.toISOString()
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
if (opts.tokenId) qs.set("tokenId", opts.tokenId);
|
|
387
|
+
if (opts.status) qs.set("status", opts.status);
|
|
388
|
+
if (opts.cursor) qs.set("cursor", opts.cursor);
|
|
389
|
+
if (opts.pageSize) qs.set("pageSize", String(opts.pageSize));
|
|
390
|
+
const tail = qs.toString() ? `?${qs.toString()}` : "";
|
|
391
|
+
const res = await this.client.fetchRaw(
|
|
392
|
+
"GET",
|
|
393
|
+
`/v1/db/${this.namespace}/${this.slug}/queries${tail}`
|
|
394
|
+
);
|
|
395
|
+
const body = await res.json();
|
|
396
|
+
if (!res.ok || !body.success) {
|
|
397
|
+
throw new PerSQLError(res.status, body.error ?? `Request failed (${res.status})`);
|
|
398
|
+
}
|
|
399
|
+
return { data: body.data ?? [], nextCursor: body.nextCursor ?? null };
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Returns a structured description of the database — columns, FK
|
|
403
|
+
* graph, and any human-/AI-authored docs persisted via
|
|
404
|
+
* `db.describe(...)`. The shape is designed for direct injection
|
|
405
|
+
* into an LLM prompt: one round-trip and the agent has everything
|
|
406
|
+
* it needs to write correct SQL.
|
|
407
|
+
*/
|
|
408
|
+
async describe() {
|
|
409
|
+
if (this.client.local) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
"PerSQL: describe is not available in local mode (yet)."
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return this.client.request(
|
|
415
|
+
"GET",
|
|
416
|
+
`/v1/db/${this.namespace}/${this.slug}/describe`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Persist semantic descriptions for the database / tables / columns.
|
|
421
|
+
* Pass any subset — only the fields you set are updated. Requires
|
|
422
|
+
* an admin-role token because docs change how every other client
|
|
423
|
+
* (and every agent) interprets the schema.
|
|
424
|
+
*/
|
|
425
|
+
async setDescription(input) {
|
|
426
|
+
if (this.client.local) {
|
|
427
|
+
throw new Error("PerSQL: setDescription is not available in local mode.");
|
|
428
|
+
}
|
|
429
|
+
return this.client.request(
|
|
430
|
+
"PUT",
|
|
431
|
+
`/v1/db/${this.namespace}/${this.slug}/describe`,
|
|
432
|
+
input
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Natural-language ranked search across table names, column names,
|
|
437
|
+
* and any descriptions you've stored via `setDescription`. Use this
|
|
438
|
+
* before writing SQL when you don't know the schema cold — one call
|
|
439
|
+
* narrows a 200-table DB down to the handful that match the intent.
|
|
440
|
+
*/
|
|
441
|
+
async search(query, opts = {}) {
|
|
442
|
+
if (this.client.local) {
|
|
443
|
+
throw new Error("PerSQL: search is not available in local mode.");
|
|
444
|
+
}
|
|
445
|
+
const qs = new URLSearchParams({ q: query });
|
|
446
|
+
if (opts.limit) qs.set("limit", String(opts.limit));
|
|
447
|
+
return this.client.request(
|
|
448
|
+
"GET",
|
|
449
|
+
`/v1/db/${this.namespace}/${this.slug}/search?${qs.toString()}`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Lints the schema for issues that hurt LLM consumption: missing
|
|
454
|
+
* primary keys, ambiguous column names ("data", "value"), unindexed
|
|
455
|
+
* foreign keys, etc. Pure read-only analysis — emits suggestions
|
|
456
|
+
* for the agent to act on, never modifies the schema itself.
|
|
457
|
+
*/
|
|
458
|
+
async doctor() {
|
|
459
|
+
if (this.client.local) {
|
|
460
|
+
throw new Error("PerSQL: doctor is not available in local mode.");
|
|
461
|
+
}
|
|
462
|
+
return this.client.request(
|
|
463
|
+
"GET",
|
|
464
|
+
`/v1/db/${this.namespace}/${this.slug}/doctor`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
/** List user-defined tables in this database. */
|
|
468
|
+
async tables() {
|
|
469
|
+
if (this.client.local) return this.client.local.tables();
|
|
470
|
+
return this.client.request(
|
|
471
|
+
"GET",
|
|
472
|
+
`/v1/db/${this.namespace}/${this.slug}/tables`
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Returns SQLite's `EXPLAIN QUERY PLAN` output for the given SQL.
|
|
477
|
+
* Read-only — does not execute the statement.
|
|
478
|
+
*/
|
|
479
|
+
async explain(sql, params = []) {
|
|
480
|
+
if (this.client.local) return this.client.local.explain(sql, params);
|
|
481
|
+
return this.client.request("POST", `/v1/db/${this.namespace}/${this.slug}/explain`, { sql, params });
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Introspect the database schema. Returns one entry per user table
|
|
485
|
+
* with column definitions, suitable for codegen tools (the
|
|
486
|
+
* `persql-codegen` CLI uses this to emit Drizzle schema files).
|
|
487
|
+
*/
|
|
488
|
+
async schema() {
|
|
489
|
+
const tables = await this.tables();
|
|
490
|
+
const out = { tables: [] };
|
|
491
|
+
for (const t of tables) {
|
|
492
|
+
const r = await this.query(`PRAGMA table_info("${t.name.replace(/"/g, '""')}")`);
|
|
493
|
+
out.tables.push({
|
|
494
|
+
name: t.name,
|
|
495
|
+
columns: r.data.map((c) => ({
|
|
496
|
+
name: c.name,
|
|
497
|
+
type: c.type,
|
|
498
|
+
notNull: !!c.notnull,
|
|
499
|
+
primaryKey: !!c.pk,
|
|
500
|
+
default: c.dflt_value === null ? null : String(c.dflt_value)
|
|
501
|
+
}))
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Per-database semantic search via Cloudflare Vectorize. Use
|
|
508
|
+
* `vectors.upsert` to embed and store rows; `vectors.query` to
|
|
509
|
+
* retrieve the most similar by free text. Embeddings are computed
|
|
510
|
+
* server-side using bge-base-en-v1.5 (768 dim, cosine distance).
|
|
511
|
+
*/
|
|
512
|
+
get vectors() {
|
|
513
|
+
if (this.client.local) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
"PerSQL: vectors require Cloudflare Vectorize and Workers AI \u2014 not available in local mode. Use a server-mode token (psql_live_/psql_test_) to call vectors.upsert/query/delete."
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
return new PerSQLVectors(this.client, this.namespace, this.slug);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Manage preview/PR-style branches of this database. Each branch is
|
|
522
|
+
* its own DO with its own SQLite file; create-or-reset by ref is
|
|
523
|
+
* idempotent (PUT semantics) so CI pipelines can call it on every
|
|
524
|
+
* build. Requires an admin-role bearer token for create / delete /
|
|
525
|
+
* merge; list is open to any role.
|
|
526
|
+
*/
|
|
527
|
+
get branches() {
|
|
528
|
+
if (this.client.local) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
"PerSQL: branches model fork-by-Durable-Object \u2014 not available in local mode. Use a server-mode token to call branches.upsert/claim/merge, or stub branch isolation in tests by spinning up multiple `new PerSQL({ local: ':memory:' })` instances."
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
return new PerSQLBranches(this.client, this.namespace, this.slug);
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Redeem an approval that was minted when a write hit a
|
|
537
|
+
* `require_approval` rule. Throws `ApprovalRequiredError` until a
|
|
538
|
+
* namespace member decides; on approve, runs the original SQL.
|
|
539
|
+
*/
|
|
540
|
+
get approvals() {
|
|
541
|
+
if (this.client.local) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
"PerSQL: approvals require a server-side approval gate \u2014 not available in local mode. Local mode runs every statement immediately; use a server-mode token if you need the require_approval rule."
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
return new PerSQLApprovals(this.client, this.namespace, this.slug);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Pre-flight a write before running it. `propose()` validates the
|
|
550
|
+
* SQL via EXPLAIN, estimates affected rows, and returns a single-use
|
|
551
|
+
* `executionToken` to redeem with `apply()`. Use this when an agent
|
|
552
|
+
* (or its parent / a human) should review a mutation before it runs.
|
|
553
|
+
* Works in both HTTP and local modes — local mode keeps the
|
|
554
|
+
* executionToken in-process.
|
|
555
|
+
*/
|
|
556
|
+
get proposals() {
|
|
557
|
+
return new PerSQLProposals(this.client, this.namespace, this.slug);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Per-database BLOB storage backed by R2. Use this for anything
|
|
561
|
+
* larger than a SQLite cell (images, PDFs, model weights). Each
|
|
562
|
+
* database has its own private namespace; keys may be hierarchical
|
|
563
|
+
* (`avatars/2025/foo.jpg`) but never start with `/`.
|
|
564
|
+
*/
|
|
565
|
+
get blob() {
|
|
566
|
+
if (this.client.local) {
|
|
567
|
+
throw new Error(
|
|
568
|
+
"PerSQL: blob storage is backed by R2 \u2014 not available in local mode. Use a server-mode token, or store blobs in your test fixtures directly."
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
return new PerSQLBlob(this.client, this.namespace, this.slug);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Subscribe to row-changes via WebSocket — the SQL equivalent of
|
|
575
|
+
* Postgres `LISTEN`. The callback fires once per write that
|
|
576
|
+
* matches the table filter. Returns an `unsubscribe` function;
|
|
577
|
+
* call it to close the socket.
|
|
578
|
+
*
|
|
579
|
+
* ```ts
|
|
580
|
+
* const off = db.subscribe({
|
|
581
|
+
* tables: ["orders"],
|
|
582
|
+
* onChange: (e) => console.log(e.kind, e.table),
|
|
583
|
+
* });
|
|
584
|
+
* // …later
|
|
585
|
+
* off();
|
|
586
|
+
* ```
|
|
587
|
+
*
|
|
588
|
+
* Authentication uses the `persql.bearer.<token>` sub-protocol so
|
|
589
|
+
* the token doesn't leak via URL logs. In environments without
|
|
590
|
+
* sub-protocol support (some serverless WebSocket clients), the
|
|
591
|
+
* fallback `?token=` query string is also accepted by the
|
|
592
|
+
* server.
|
|
593
|
+
*/
|
|
594
|
+
subscribe(options) {
|
|
595
|
+
if (this.client.local) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
"PerSQL: subscribe requires the DO's WebSocket fan-out \u2014 not available in local mode. Use a server-mode token to call subscribe()."
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
const baseHttp = this.client.baseURL;
|
|
601
|
+
const wsBase = baseHttp.replace(/^http/, "ws");
|
|
602
|
+
const tablesParam = options.tables && options.tables.length > 0 ? `?tables=${encodeURIComponent(options.tables.join(","))}` : "";
|
|
603
|
+
const url = `${wsBase}/v1/db/${this.namespace}/${this.slug}/subscribe${tablesParam}`;
|
|
604
|
+
const ws = new WebSocket(url, [`persql.bearer.${this.client.token}`]);
|
|
605
|
+
let closed = false;
|
|
606
|
+
ws.addEventListener("message", (ev) => {
|
|
607
|
+
try {
|
|
608
|
+
const msg = JSON.parse(String(ev.data));
|
|
609
|
+
if (msg.type === "change" && msg.table && msg.kind) {
|
|
610
|
+
options.onChange({ table: msg.table, kind: msg.kind });
|
|
611
|
+
} else if (msg.type === "error") {
|
|
612
|
+
options.onError?.(new Error(msg.message ?? "Subscription error"));
|
|
613
|
+
} else if (msg.type === "subscribed") {
|
|
614
|
+
options.onReady?.(msg.tables ?? "*");
|
|
615
|
+
}
|
|
616
|
+
} catch {
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
ws.addEventListener("error", () => {
|
|
620
|
+
options.onError?.(new Error("WebSocket error"));
|
|
621
|
+
});
|
|
622
|
+
ws.addEventListener("close", () => {
|
|
623
|
+
if (!closed) options.onClose?.();
|
|
624
|
+
});
|
|
625
|
+
return () => {
|
|
626
|
+
closed = true;
|
|
627
|
+
try {
|
|
628
|
+
ws.close();
|
|
629
|
+
} catch {
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Returns the callback shape `drizzle-orm/sqlite-proxy` expects.
|
|
635
|
+
* Pair with `drizzle()` from that module to get a typed,
|
|
636
|
+
* fluent ORM client over the PerSQL HTTP API:
|
|
637
|
+
*
|
|
638
|
+
* ```ts
|
|
639
|
+
* import { drizzle } from "drizzle-orm/sqlite-proxy";
|
|
640
|
+
* import { PerSQL } from "@persql/sdk";
|
|
641
|
+
*
|
|
642
|
+
* const persql = new PerSQL({ token });
|
|
643
|
+
* const db = drizzle(persql.database("acme/orders").driver());
|
|
644
|
+
* ```
|
|
645
|
+
*
|
|
646
|
+
* Drizzle's `prepare` interface maps onto our `query` /
|
|
647
|
+
* `batch`; `method` tells us how to shape the rows.
|
|
648
|
+
*/
|
|
649
|
+
driver() {
|
|
650
|
+
const callback = async (sql, params, method) => {
|
|
651
|
+
const result = await this.query(sql, params ?? []);
|
|
652
|
+
if (method === "values" || method === "get") {
|
|
653
|
+
return { rows: result.rows[0] ?? [] };
|
|
654
|
+
}
|
|
655
|
+
return { rows: result.rows };
|
|
656
|
+
};
|
|
657
|
+
return callback;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Returns a tool definition for use with Anthropic / OpenAI / function-calling
|
|
661
|
+
* agents. Pair it with `runTool` to execute the call.
|
|
662
|
+
*/
|
|
663
|
+
asTool(name = "persql_query") {
|
|
664
|
+
return {
|
|
665
|
+
anthropic: {
|
|
666
|
+
name,
|
|
667
|
+
description: `Run a SQL query against the PerSQL database "${this.namespace}/${this.slug}". Returns up to 1000 rows. Use parameter binding (?) to avoid injection.`,
|
|
668
|
+
input_schema: {
|
|
669
|
+
type: "object",
|
|
670
|
+
properties: {
|
|
671
|
+
sql: { type: "string", description: "SQLite SQL statement" },
|
|
672
|
+
params: {
|
|
673
|
+
type: "array",
|
|
674
|
+
items: {},
|
|
675
|
+
description: "Positional parameters for the SQL statement"
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
required: ["sql"]
|
|
679
|
+
}
|
|
680
|
+
},
|
|
681
|
+
openai: {
|
|
682
|
+
type: "function",
|
|
683
|
+
function: {
|
|
684
|
+
name,
|
|
685
|
+
description: `Run a SQL query against the PerSQL database "${this.namespace}/${this.slug}".`,
|
|
686
|
+
parameters: {
|
|
687
|
+
type: "object",
|
|
688
|
+
properties: {
|
|
689
|
+
sql: { type: "string" },
|
|
690
|
+
params: { type: "array", items: {} }
|
|
691
|
+
},
|
|
692
|
+
required: ["sql"]
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Executes a tool-call payload returned from the model. Pair with `asTool`.
|
|
700
|
+
*/
|
|
701
|
+
runTool(input) {
|
|
702
|
+
return this.query(input.sql, input.params ?? []);
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Generate a *bundle* of typed tools — one per table — instead of one
|
|
706
|
+
* generic `query` tool. Agents perform dramatically better with narrow
|
|
707
|
+
* typed surfaces than they do with one fat SQL function. Each table
|
|
708
|
+
* gets:
|
|
709
|
+
* - `select_<table>(where?, limit?, orderBy?)` — typed column filter
|
|
710
|
+
* - `count_<table>(where?)` — quick row count
|
|
711
|
+
* - `describe_<table>()` — columns, row count, foreign keys
|
|
712
|
+
* Plus a fallback `sql_query(sql, params)` for anything the typed
|
|
713
|
+
* tools can't express.
|
|
714
|
+
*
|
|
715
|
+
* The returned `run(name, input)` dispatcher executes whichever tool
|
|
716
|
+
* the model invoked. Format the tools for the LLM via the
|
|
717
|
+
* `anthropic` or `openai` arrays (matches the shapes of `asTool`).
|
|
718
|
+
*
|
|
719
|
+
* ```ts
|
|
720
|
+
* const tools = await db.asTools();
|
|
721
|
+
* const reply = await anthropic.messages.create({
|
|
722
|
+
* model: "claude-opus-4-7",
|
|
723
|
+
* tools: tools.anthropic,
|
|
724
|
+
* messages: [...],
|
|
725
|
+
* });
|
|
726
|
+
* for (const block of reply.content) {
|
|
727
|
+
* if (block.type === "tool_use") {
|
|
728
|
+
* const result = await tools.run(block.name, block.input);
|
|
729
|
+
* }
|
|
730
|
+
* }
|
|
731
|
+
* ```
|
|
732
|
+
*/
|
|
733
|
+
async asTools() {
|
|
734
|
+
const schema = await this.schema();
|
|
735
|
+
const anthropic = [];
|
|
736
|
+
const openai = [];
|
|
737
|
+
for (const t of schema.tables) {
|
|
738
|
+
const safe = sanitizeToolPart(t.name);
|
|
739
|
+
const columnList = t.columns.map((c) => `${c.name} ${c.type}${c.notNull ? " NOT NULL" : ""}${c.primaryKey ? " PRIMARY KEY" : ""}`).join(", ");
|
|
740
|
+
const whereProps = {};
|
|
741
|
+
for (const c of t.columns) {
|
|
742
|
+
whereProps[c.name] = {
|
|
743
|
+
type: ["string", "number", "boolean", "null"],
|
|
744
|
+
description: `Equality filter on ${t.name}.${c.name} (${c.type})`
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
const selectName = `select_${safe}`;
|
|
748
|
+
const selectDesc = `Read rows from ${t.name}. Columns: ${columnList}. Use \`where\` for equality predicates, \`orderBy\` to sort, \`limit\` to bound the result (default 100, max 1000).`;
|
|
749
|
+
const selectInput = {
|
|
750
|
+
type: "object",
|
|
751
|
+
properties: {
|
|
752
|
+
where: {
|
|
753
|
+
type: "object",
|
|
754
|
+
description: "Equality filter \u2014 keys are column names, values are scalars.",
|
|
755
|
+
properties: whereProps,
|
|
756
|
+
additionalProperties: false
|
|
757
|
+
},
|
|
758
|
+
orderBy: {
|
|
759
|
+
type: "string",
|
|
760
|
+
description: "Column name optionally followed by ASC/DESC."
|
|
761
|
+
},
|
|
762
|
+
limit: {
|
|
763
|
+
type: "integer",
|
|
764
|
+
minimum: 1,
|
|
765
|
+
maximum: 1e3,
|
|
766
|
+
default: 100
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
additionalProperties: false
|
|
770
|
+
};
|
|
771
|
+
anthropic.push({ name: selectName, description: selectDesc, input_schema: selectInput });
|
|
772
|
+
openai.push({
|
|
773
|
+
type: "function",
|
|
774
|
+
function: { name: selectName, description: selectDesc, parameters: selectInput }
|
|
775
|
+
});
|
|
776
|
+
const countName = `count_${safe}`;
|
|
777
|
+
const countDesc = `Count rows in ${t.name}, optionally filtered by equality on any column.`;
|
|
778
|
+
const countInput = {
|
|
779
|
+
type: "object",
|
|
780
|
+
properties: {
|
|
781
|
+
where: {
|
|
782
|
+
type: "object",
|
|
783
|
+
properties: whereProps,
|
|
784
|
+
additionalProperties: false
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
additionalProperties: false
|
|
788
|
+
};
|
|
789
|
+
anthropic.push({ name: countName, description: countDesc, input_schema: countInput });
|
|
790
|
+
openai.push({
|
|
791
|
+
type: "function",
|
|
792
|
+
function: { name: countName, description: countDesc, parameters: countInput }
|
|
793
|
+
});
|
|
794
|
+
const descName = `describe_${safe}`;
|
|
795
|
+
const descDesc = `Return columns, row count, and foreign keys for ${t.name}.`;
|
|
796
|
+
const descInput = {
|
|
797
|
+
type: "object",
|
|
798
|
+
properties: {},
|
|
799
|
+
additionalProperties: false
|
|
800
|
+
};
|
|
801
|
+
anthropic.push({ name: descName, description: descDesc, input_schema: descInput });
|
|
802
|
+
openai.push({
|
|
803
|
+
type: "function",
|
|
804
|
+
function: { name: descName, description: descDesc, parameters: descInput }
|
|
805
|
+
});
|
|
806
|
+
const valueProps = {};
|
|
807
|
+
const requiredForInsert = [];
|
|
808
|
+
for (const c of t.columns) {
|
|
809
|
+
valueProps[c.name] = {
|
|
810
|
+
type: ["string", "number", "boolean", "null"],
|
|
811
|
+
description: `${t.name}.${c.name} (${c.type})${c.notNull ? " NOT NULL" : ""}`
|
|
812
|
+
};
|
|
813
|
+
if (c.notNull && !c.primaryKey) {
|
|
814
|
+
requiredForInsert.push(c.name);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const insertName = `insert_${safe}`;
|
|
818
|
+
const insertDesc = `Insert one row into ${t.name}. Provide column values in \`values\`. Required (NOT NULL): ${requiredForInsert.join(", ") || "(none)"}.`;
|
|
819
|
+
const insertInput = {
|
|
820
|
+
type: "object",
|
|
821
|
+
properties: {
|
|
822
|
+
values: {
|
|
823
|
+
type: "object",
|
|
824
|
+
description: "Column \u2192 value map for the new row.",
|
|
825
|
+
properties: valueProps,
|
|
826
|
+
required: requiredForInsert,
|
|
827
|
+
additionalProperties: false
|
|
828
|
+
}
|
|
829
|
+
},
|
|
830
|
+
required: ["values"],
|
|
831
|
+
additionalProperties: false
|
|
832
|
+
};
|
|
833
|
+
anthropic.push({ name: insertName, description: insertDesc, input_schema: insertInput });
|
|
834
|
+
openai.push({
|
|
835
|
+
type: "function",
|
|
836
|
+
function: { name: insertName, description: insertDesc, parameters: insertInput }
|
|
837
|
+
});
|
|
838
|
+
const updateName = `update_${safe}`;
|
|
839
|
+
const updateDesc = `Update rows in ${t.name} matching \`where\` (equality on any column) with the columns provided in \`set\`. Both must be non-empty \u2014 to clear the filter use \`sql_query\`.`;
|
|
840
|
+
const updateInput = {
|
|
841
|
+
type: "object",
|
|
842
|
+
properties: {
|
|
843
|
+
where: {
|
|
844
|
+
type: "object",
|
|
845
|
+
properties: whereProps,
|
|
846
|
+
additionalProperties: false
|
|
847
|
+
},
|
|
848
|
+
set: {
|
|
849
|
+
type: "object",
|
|
850
|
+
properties: valueProps,
|
|
851
|
+
additionalProperties: false
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
required: ["where", "set"],
|
|
855
|
+
additionalProperties: false
|
|
856
|
+
};
|
|
857
|
+
anthropic.push({ name: updateName, description: updateDesc, input_schema: updateInput });
|
|
858
|
+
openai.push({
|
|
859
|
+
type: "function",
|
|
860
|
+
function: { name: updateName, description: updateDesc, parameters: updateInput }
|
|
861
|
+
});
|
|
862
|
+
const deleteName = `delete_${safe}`;
|
|
863
|
+
const deleteDesc = `Delete rows from ${t.name} matching \`where\`. \`where\` must be non-empty \u2014 to truncate use \`sql_query\` with an explicit DELETE.`;
|
|
864
|
+
const deleteInput = {
|
|
865
|
+
type: "object",
|
|
866
|
+
properties: {
|
|
867
|
+
where: {
|
|
868
|
+
type: "object",
|
|
869
|
+
properties: whereProps,
|
|
870
|
+
additionalProperties: false
|
|
871
|
+
}
|
|
872
|
+
},
|
|
873
|
+
required: ["where"],
|
|
874
|
+
additionalProperties: false
|
|
875
|
+
};
|
|
876
|
+
anthropic.push({ name: deleteName, description: deleteDesc, input_schema: deleteInput });
|
|
877
|
+
openai.push({
|
|
878
|
+
type: "function",
|
|
879
|
+
function: { name: deleteName, description: deleteDesc, parameters: deleteInput }
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
const sqlName = "sql_query";
|
|
883
|
+
const sqlDesc = `Run an arbitrary parameterised SQL statement against the PerSQL database "${this.namespace}/${this.slug}". Prefer the table-specific select_/count_/describe_ tools when they fit; fall back to this for joins, aggregates, or DDL.`;
|
|
884
|
+
const sqlInput = {
|
|
885
|
+
type: "object",
|
|
886
|
+
properties: {
|
|
887
|
+
sql: { type: "string" },
|
|
888
|
+
params: { type: "array", items: {} }
|
|
889
|
+
},
|
|
890
|
+
required: ["sql"],
|
|
891
|
+
additionalProperties: false
|
|
892
|
+
};
|
|
893
|
+
anthropic.push({ name: sqlName, description: sqlDesc, input_schema: sqlInput });
|
|
894
|
+
openai.push({
|
|
895
|
+
type: "function",
|
|
896
|
+
function: { name: sqlName, description: sqlDesc, parameters: sqlInput }
|
|
897
|
+
});
|
|
898
|
+
const describeDbName = "describe_database";
|
|
899
|
+
const describeDbDesc = `Return the full schema graph plus stored semantic descriptions for "${this.namespace}/${this.slug}". Call this FIRST on any unfamiliar database \u2014 it includes table descriptions, column docs, and foreign keys in one shot, sized to JSON-stringify into a system prompt.`;
|
|
900
|
+
const describeDbInput = {
|
|
901
|
+
type: "object",
|
|
902
|
+
properties: {},
|
|
903
|
+
additionalProperties: false
|
|
904
|
+
};
|
|
905
|
+
anthropic.push({
|
|
906
|
+
name: describeDbName,
|
|
907
|
+
description: describeDbDesc,
|
|
908
|
+
input_schema: describeDbInput
|
|
909
|
+
});
|
|
910
|
+
openai.push({
|
|
911
|
+
type: "function",
|
|
912
|
+
function: {
|
|
913
|
+
name: describeDbName,
|
|
914
|
+
description: describeDbDesc,
|
|
915
|
+
parameters: describeDbInput
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
const searchSchemaName = "search_schema";
|
|
919
|
+
const searchSchemaDesc = `Natural-language ranked search across table names, column names, and stored descriptions. Use this when you have a goal ("billing addresses", "abandoned carts") but don't know which table holds it.`;
|
|
920
|
+
const searchSchemaInput = {
|
|
921
|
+
type: "object",
|
|
922
|
+
properties: {
|
|
923
|
+
q: {
|
|
924
|
+
type: "string",
|
|
925
|
+
description: "Free-text query \u2014 table/column/description tokens are matched."
|
|
926
|
+
},
|
|
927
|
+
limit: {
|
|
928
|
+
type: "integer",
|
|
929
|
+
minimum: 1,
|
|
930
|
+
maximum: 100,
|
|
931
|
+
default: 25
|
|
932
|
+
}
|
|
933
|
+
},
|
|
934
|
+
required: ["q"],
|
|
935
|
+
additionalProperties: false
|
|
936
|
+
};
|
|
937
|
+
anthropic.push({
|
|
938
|
+
name: searchSchemaName,
|
|
939
|
+
description: searchSchemaDesc,
|
|
940
|
+
input_schema: searchSchemaInput
|
|
941
|
+
});
|
|
942
|
+
openai.push({
|
|
943
|
+
type: "function",
|
|
944
|
+
function: {
|
|
945
|
+
name: searchSchemaName,
|
|
946
|
+
description: searchSchemaDesc,
|
|
947
|
+
parameters: searchSchemaInput
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
const doctorName = "schema_doctor";
|
|
951
|
+
const doctorDesc = `Lint the schema for LLM-hostile patterns \u2014 missing primary keys, ambiguous column names, unindexed foreign keys. Read-only; useful before generating SQL so you know what the schema gets wrong.`;
|
|
952
|
+
const doctorInput = {
|
|
953
|
+
type: "object",
|
|
954
|
+
properties: {},
|
|
955
|
+
additionalProperties: false
|
|
956
|
+
};
|
|
957
|
+
anthropic.push({
|
|
958
|
+
name: doctorName,
|
|
959
|
+
description: doctorDesc,
|
|
960
|
+
input_schema: doctorInput
|
|
961
|
+
});
|
|
962
|
+
openai.push({
|
|
963
|
+
type: "function",
|
|
964
|
+
function: {
|
|
965
|
+
name: doctorName,
|
|
966
|
+
description: doctorDesc,
|
|
967
|
+
parameters: doctorInput
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
const proposeName = "propose_mutation";
|
|
971
|
+
const proposeDesc = `Pre-flight a SQL write \u2014 EXPLAIN-validate, estimate affected rows, and return a single-use executionToken. Nothing is mutated until apply_mutation is called with that token. Use for any UPDATE/DELETE that isn't tightly scoped to a single PK, or any INSERT batch you want to verify.`;
|
|
972
|
+
const proposeInput = {
|
|
973
|
+
type: "object",
|
|
974
|
+
properties: {
|
|
975
|
+
sql: { type: "string", description: "SQLite SQL statement." },
|
|
976
|
+
params: { type: "array", items: {} },
|
|
977
|
+
ttlSec: {
|
|
978
|
+
type: "integer",
|
|
979
|
+
minimum: 30,
|
|
980
|
+
maximum: 3600,
|
|
981
|
+
description: "Token TTL in seconds. Default 600 (10 min)."
|
|
982
|
+
}
|
|
983
|
+
},
|
|
984
|
+
required: ["sql"],
|
|
985
|
+
additionalProperties: false
|
|
986
|
+
};
|
|
987
|
+
anthropic.push({
|
|
988
|
+
name: proposeName,
|
|
989
|
+
description: proposeDesc,
|
|
990
|
+
input_schema: proposeInput
|
|
991
|
+
});
|
|
992
|
+
openai.push({
|
|
993
|
+
type: "function",
|
|
994
|
+
function: {
|
|
995
|
+
name: proposeName,
|
|
996
|
+
description: proposeDesc,
|
|
997
|
+
parameters: proposeInput
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
const applyName = "apply_mutation";
|
|
1001
|
+
const applyDesc = `Redeem an executionToken returned by propose_mutation. Single-use \u2014 calling twice fails. The actual write runs through the same auto-snapshot + query-log + pricing pipeline as a normal SQL call.`;
|
|
1002
|
+
const applyInput = {
|
|
1003
|
+
type: "object",
|
|
1004
|
+
properties: {
|
|
1005
|
+
executionToken: {
|
|
1006
|
+
type: "string",
|
|
1007
|
+
description: "pmut_\u2026 token from propose_mutation."
|
|
1008
|
+
}
|
|
1009
|
+
},
|
|
1010
|
+
required: ["executionToken"],
|
|
1011
|
+
additionalProperties: false
|
|
1012
|
+
};
|
|
1013
|
+
anthropic.push({
|
|
1014
|
+
name: applyName,
|
|
1015
|
+
description: applyDesc,
|
|
1016
|
+
input_schema: applyInput
|
|
1017
|
+
});
|
|
1018
|
+
openai.push({
|
|
1019
|
+
type: "function",
|
|
1020
|
+
function: {
|
|
1021
|
+
name: applyName,
|
|
1022
|
+
description: applyDesc,
|
|
1023
|
+
parameters: applyInput
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
const queryLogName = "recent_queries";
|
|
1027
|
+
const queryLogDesc = `Recent /query and /batch calls against this database \u2014 durations, errors, statement counts. Use to debug a failed write you just made, or to spot N+1 patterns in your own behavior.`;
|
|
1028
|
+
const queryLogInput = {
|
|
1029
|
+
type: "object",
|
|
1030
|
+
properties: {
|
|
1031
|
+
since: {
|
|
1032
|
+
type: "string",
|
|
1033
|
+
description: "ISO timestamp lower bound."
|
|
1034
|
+
},
|
|
1035
|
+
status: {
|
|
1036
|
+
type: "string",
|
|
1037
|
+
enum: ["ok", "error"]
|
|
1038
|
+
},
|
|
1039
|
+
pageSize: {
|
|
1040
|
+
type: "integer",
|
|
1041
|
+
minimum: 1,
|
|
1042
|
+
maximum: 200,
|
|
1043
|
+
default: 50
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
1046
|
+
additionalProperties: false
|
|
1047
|
+
};
|
|
1048
|
+
anthropic.push({
|
|
1049
|
+
name: queryLogName,
|
|
1050
|
+
description: queryLogDesc,
|
|
1051
|
+
input_schema: queryLogInput
|
|
1052
|
+
});
|
|
1053
|
+
openai.push({
|
|
1054
|
+
type: "function",
|
|
1055
|
+
function: {
|
|
1056
|
+
name: queryLogName,
|
|
1057
|
+
description: queryLogDesc,
|
|
1058
|
+
parameters: queryLogInput
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
const dbRef = `"${this.namespace}/${this.slug}"`;
|
|
1062
|
+
const branchesList = "branches_list";
|
|
1063
|
+
const branchesListInput = {
|
|
1064
|
+
type: "object",
|
|
1065
|
+
properties: {},
|
|
1066
|
+
additionalProperties: false
|
|
1067
|
+
};
|
|
1068
|
+
const branchesListDesc = `List preview branches of ${dbRef}. Each branch is its own SQLite database forked from the parent at create time.`;
|
|
1069
|
+
anthropic.push({
|
|
1070
|
+
name: branchesList,
|
|
1071
|
+
description: branchesListDesc,
|
|
1072
|
+
input_schema: branchesListInput
|
|
1073
|
+
});
|
|
1074
|
+
openai.push({
|
|
1075
|
+
type: "function",
|
|
1076
|
+
function: {
|
|
1077
|
+
name: branchesList,
|
|
1078
|
+
description: branchesListDesc,
|
|
1079
|
+
parameters: branchesListInput
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
const branchesCreate = "branches_create";
|
|
1083
|
+
const branchesCreateInput = {
|
|
1084
|
+
type: "object",
|
|
1085
|
+
properties: {
|
|
1086
|
+
ref: {
|
|
1087
|
+
type: "string",
|
|
1088
|
+
description: "Stable identifier for the branch (e.g. 'pr-42' or 'agent-run-7c2f'). PUT semantics \u2014 the same ref re-runs as a reset from the parent's current state."
|
|
1089
|
+
},
|
|
1090
|
+
name: {
|
|
1091
|
+
type: "string",
|
|
1092
|
+
description: "Optional human-readable name."
|
|
1093
|
+
},
|
|
1094
|
+
ttlDays: {
|
|
1095
|
+
type: ["integer", "null"],
|
|
1096
|
+
minimum: 1,
|
|
1097
|
+
maximum: 30,
|
|
1098
|
+
description: "Auto-delete after N days (1..30). Use for ephemeral / scratch experiments."
|
|
1099
|
+
}
|
|
1100
|
+
},
|
|
1101
|
+
required: ["ref"],
|
|
1102
|
+
additionalProperties: false
|
|
1103
|
+
};
|
|
1104
|
+
const branchesCreateDesc = `Create-or-reset a branch of ${dbRef} keyed by ref. Idempotent: repeat the same ref to refresh from the current parent. Requires an admin-role token.`;
|
|
1105
|
+
anthropic.push({
|
|
1106
|
+
name: branchesCreate,
|
|
1107
|
+
description: branchesCreateDesc,
|
|
1108
|
+
input_schema: branchesCreateInput
|
|
1109
|
+
});
|
|
1110
|
+
openai.push({
|
|
1111
|
+
type: "function",
|
|
1112
|
+
function: {
|
|
1113
|
+
name: branchesCreate,
|
|
1114
|
+
description: branchesCreateDesc,
|
|
1115
|
+
parameters: branchesCreateInput
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
const branchesDelete = "branches_delete";
|
|
1119
|
+
const branchesDeleteInput = {
|
|
1120
|
+
type: "object",
|
|
1121
|
+
properties: {
|
|
1122
|
+
ref: { type: "string", description: "The branch ref to delete." }
|
|
1123
|
+
},
|
|
1124
|
+
required: ["ref"],
|
|
1125
|
+
additionalProperties: false
|
|
1126
|
+
};
|
|
1127
|
+
const branchesDeleteDesc = `Delete a branch of ${dbRef}. Removes the branch's database and all of its data. Requires an admin-role token.`;
|
|
1128
|
+
anthropic.push({
|
|
1129
|
+
name: branchesDelete,
|
|
1130
|
+
description: branchesDeleteDesc,
|
|
1131
|
+
input_schema: branchesDeleteInput
|
|
1132
|
+
});
|
|
1133
|
+
openai.push({
|
|
1134
|
+
type: "function",
|
|
1135
|
+
function: {
|
|
1136
|
+
name: branchesDelete,
|
|
1137
|
+
description: branchesDeleteDesc,
|
|
1138
|
+
parameters: branchesDeleteInput
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
const branchesMergeInput = {
|
|
1142
|
+
type: "object",
|
|
1143
|
+
properties: {
|
|
1144
|
+
ref: { type: "string", description: "The branch ref to apply." },
|
|
1145
|
+
mode: {
|
|
1146
|
+
type: "string",
|
|
1147
|
+
enum: ["schema", "promote"],
|
|
1148
|
+
description: "schema (default): apply the DDL delta \u2014 adds, plus replaces for views/triggers/indexes. Refuses table-shape changes. promote: replace the parent's full contents with the branch's (the parent keeps its identity)."
|
|
1149
|
+
},
|
|
1150
|
+
dropRemoved: {
|
|
1151
|
+
type: "boolean",
|
|
1152
|
+
description: "Schema mode only \u2014 also drop objects present in the parent but absent in the branch."
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
required: ["ref"],
|
|
1156
|
+
additionalProperties: false
|
|
1157
|
+
};
|
|
1158
|
+
const branchesPreview = "branches_preview_merge";
|
|
1159
|
+
const branchesPreviewDesc = `Preview merging a branch of ${dbRef} back into its parent \u2014 returns the plan (added/changed/removed objects) without writing. Use this before branches_merge to show the user what will happen.`;
|
|
1160
|
+
anthropic.push({
|
|
1161
|
+
name: branchesPreview,
|
|
1162
|
+
description: branchesPreviewDesc,
|
|
1163
|
+
input_schema: branchesMergeInput
|
|
1164
|
+
});
|
|
1165
|
+
openai.push({
|
|
1166
|
+
type: "function",
|
|
1167
|
+
function: {
|
|
1168
|
+
name: branchesPreview,
|
|
1169
|
+
description: branchesPreviewDesc,
|
|
1170
|
+
parameters: branchesMergeInput
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
const claimBranchName = "claim_branch";
|
|
1174
|
+
const claimBranchDesc = `One-shot lease: create a fresh branch of ${dbRef} AND mint a scoped psql_live_ token in the same call. Use this when delegating work to a sub-agent \u2014 return the token to the sub-agent, walk away, and the branch + token both expire when the lease ends. Requires an admin-role token.`;
|
|
1175
|
+
const claimBranchInput = {
|
|
1176
|
+
type: "object",
|
|
1177
|
+
properties: {
|
|
1178
|
+
purpose: {
|
|
1179
|
+
type: "string",
|
|
1180
|
+
description: "Free-text label baked into the auto-generated branch ref (e.g. 'agent-run-fix-issue-742')."
|
|
1181
|
+
},
|
|
1182
|
+
ttlSec: {
|
|
1183
|
+
type: "integer",
|
|
1184
|
+
minimum: 60,
|
|
1185
|
+
maximum: 60 * 60 * 24 * 30,
|
|
1186
|
+
description: "Lease duration in seconds. Default 3600 (1 hour)."
|
|
1187
|
+
},
|
|
1188
|
+
role: {
|
|
1189
|
+
type: "string",
|
|
1190
|
+
enum: ["read", "write", "admin"],
|
|
1191
|
+
description: "Role for the minted token. Default 'write'."
|
|
1192
|
+
}
|
|
1193
|
+
},
|
|
1194
|
+
additionalProperties: false
|
|
1195
|
+
};
|
|
1196
|
+
anthropic.push({
|
|
1197
|
+
name: claimBranchName,
|
|
1198
|
+
description: claimBranchDesc,
|
|
1199
|
+
input_schema: claimBranchInput
|
|
1200
|
+
});
|
|
1201
|
+
openai.push({
|
|
1202
|
+
type: "function",
|
|
1203
|
+
function: {
|
|
1204
|
+
name: claimBranchName,
|
|
1205
|
+
description: claimBranchDesc,
|
|
1206
|
+
parameters: claimBranchInput
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
const branchesMerge = "branches_merge";
|
|
1210
|
+
const branchesMergeDesc = `Apply a branch back into ${dbRef}. Auto-snapshots the parent first (rollback via /restore with the returned snapshotId). Requires an admin-role token.`;
|
|
1211
|
+
anthropic.push({
|
|
1212
|
+
name: branchesMerge,
|
|
1213
|
+
description: branchesMergeDesc,
|
|
1214
|
+
input_schema: branchesMergeInput
|
|
1215
|
+
});
|
|
1216
|
+
openai.push({
|
|
1217
|
+
type: "function",
|
|
1218
|
+
function: {
|
|
1219
|
+
name: branchesMerge,
|
|
1220
|
+
description: branchesMergeDesc,
|
|
1221
|
+
parameters: branchesMergeInput
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
const tableBySafe = /* @__PURE__ */ new Map();
|
|
1225
|
+
for (const t of schema.tables) tableBySafe.set(sanitizeToolPart(t.name), t);
|
|
1226
|
+
const run = async (name, input = {}) => {
|
|
1227
|
+
if (name === sqlName) {
|
|
1228
|
+
const sql = String(input.sql ?? "");
|
|
1229
|
+
const params2 = Array.isArray(input.params) ? input.params : [];
|
|
1230
|
+
return this.query(sql, params2);
|
|
1231
|
+
}
|
|
1232
|
+
if (name === describeDbName) {
|
|
1233
|
+
return this.describe();
|
|
1234
|
+
}
|
|
1235
|
+
if (name === searchSchemaName) {
|
|
1236
|
+
const q = String(input.q ?? "");
|
|
1237
|
+
if (!q) throw new Error("q is required");
|
|
1238
|
+
const limit2 = typeof input.limit === "number" ? input.limit : void 0;
|
|
1239
|
+
return this.search(q, limit2 !== void 0 ? { limit: limit2 } : {});
|
|
1240
|
+
}
|
|
1241
|
+
if (name === doctorName) {
|
|
1242
|
+
return this.doctor();
|
|
1243
|
+
}
|
|
1244
|
+
if (name === proposeName) {
|
|
1245
|
+
const sql = String(input.sql ?? "");
|
|
1246
|
+
if (!sql) throw new Error("sql is required");
|
|
1247
|
+
const params2 = Array.isArray(input.params) ? input.params : void 0;
|
|
1248
|
+
const ttlSec = typeof input.ttlSec === "number" ? input.ttlSec : void 0;
|
|
1249
|
+
return this.proposals.propose(sql, {
|
|
1250
|
+
params: params2,
|
|
1251
|
+
ttlSec
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
if (name === applyName) {
|
|
1255
|
+
const token = String(input.executionToken ?? "");
|
|
1256
|
+
if (!token) throw new Error("executionToken is required");
|
|
1257
|
+
return this.proposals.apply(token);
|
|
1258
|
+
}
|
|
1259
|
+
if (name === queryLogName) {
|
|
1260
|
+
const opts = {};
|
|
1261
|
+
if (typeof input.since === "string") opts.since = input.since;
|
|
1262
|
+
if (input.status === "ok" || input.status === "error")
|
|
1263
|
+
opts.status = input.status;
|
|
1264
|
+
if (typeof input.pageSize === "number") opts.pageSize = input.pageSize;
|
|
1265
|
+
return this.queryLog(opts);
|
|
1266
|
+
}
|
|
1267
|
+
if (name === claimBranchName) {
|
|
1268
|
+
const opts = {};
|
|
1269
|
+
if (typeof input.purpose === "string") opts.purpose = input.purpose;
|
|
1270
|
+
if (typeof input.ttlSec === "number") opts.ttlSec = input.ttlSec;
|
|
1271
|
+
if (input.role === "read" || input.role === "write" || input.role === "admin")
|
|
1272
|
+
opts.role = input.role;
|
|
1273
|
+
return this.branches.claim(opts);
|
|
1274
|
+
}
|
|
1275
|
+
if (name === branchesList) {
|
|
1276
|
+
return this.branches.list();
|
|
1277
|
+
}
|
|
1278
|
+
if (name === branchesCreate) {
|
|
1279
|
+
const ref = String(input.ref ?? "");
|
|
1280
|
+
if (!ref) throw new Error("ref is required");
|
|
1281
|
+
const ttl = input.ttlDays;
|
|
1282
|
+
return this.branches.upsert(ref, {
|
|
1283
|
+
name: typeof input.name === "string" ? input.name : void 0,
|
|
1284
|
+
ttlDays: typeof ttl === "number" ? ttl : ttl === null ? null : void 0
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
if (name === branchesDelete) {
|
|
1288
|
+
const ref = String(input.ref ?? "");
|
|
1289
|
+
if (!ref) throw new Error("ref is required");
|
|
1290
|
+
return this.branches.delete(ref);
|
|
1291
|
+
}
|
|
1292
|
+
if (name === branchesPreview || name === branchesMerge) {
|
|
1293
|
+
const ref = String(input.ref ?? "");
|
|
1294
|
+
if (!ref) throw new Error("ref is required");
|
|
1295
|
+
const mode = input.mode === "promote" ? "promote" : "schema";
|
|
1296
|
+
return this.branches.merge(ref, {
|
|
1297
|
+
mode,
|
|
1298
|
+
preview: name === branchesPreview,
|
|
1299
|
+
dropRemoved: input.dropRemoved === true
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
const m = name.match(/^(select|count|describe|insert|update|delete)_(.+)$/);
|
|
1303
|
+
if (!m) throw new Error(`Unknown tool: ${name}`);
|
|
1304
|
+
const [, op, tableSafe] = m;
|
|
1305
|
+
const t = tableBySafe.get(tableSafe);
|
|
1306
|
+
if (!t) throw new Error(`Unknown table for tool ${name}`);
|
|
1307
|
+
const ident = quoteIdent(t.name);
|
|
1308
|
+
const colSetForT = new Set(t.columns.map((c) => c.name));
|
|
1309
|
+
if (op === "insert") {
|
|
1310
|
+
const values = input.values ?? {};
|
|
1311
|
+
const cols = Object.keys(values);
|
|
1312
|
+
if (cols.length === 0) throw new Error("values must be non-empty");
|
|
1313
|
+
for (const k of cols) {
|
|
1314
|
+
if (!colSetForT.has(k)) throw new Error(`Unknown column: ${k}`);
|
|
1315
|
+
}
|
|
1316
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
1317
|
+
const colSql = cols.map(quoteIdent).join(", ");
|
|
1318
|
+
return this.query(
|
|
1319
|
+
`INSERT INTO ${ident} (${colSql}) VALUES (${placeholders})`,
|
|
1320
|
+
cols.map((c) => values[c])
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
if (op === "update") {
|
|
1324
|
+
const where2 = input.where ?? {};
|
|
1325
|
+
const setObj = input.set ?? {};
|
|
1326
|
+
const setKeys = Object.keys(setObj);
|
|
1327
|
+
const whereKeys = Object.keys(where2);
|
|
1328
|
+
if (setKeys.length === 0) throw new Error("set must be non-empty");
|
|
1329
|
+
if (whereKeys.length === 0)
|
|
1330
|
+
throw new Error("where must be non-empty \u2014 use sql_query for unfiltered updates");
|
|
1331
|
+
for (const k of [...setKeys, ...whereKeys]) {
|
|
1332
|
+
if (!colSetForT.has(k)) throw new Error(`Unknown column: ${k}`);
|
|
1333
|
+
}
|
|
1334
|
+
const setSql = setKeys.map((k) => `${quoteIdent(k)} = ?`).join(", ");
|
|
1335
|
+
const whereSqlParts = [];
|
|
1336
|
+
const whereParams = [];
|
|
1337
|
+
for (const k of whereKeys) {
|
|
1338
|
+
const v = where2[k];
|
|
1339
|
+
if (v === null) {
|
|
1340
|
+
whereSqlParts.push(`${quoteIdent(k)} IS NULL`);
|
|
1341
|
+
} else {
|
|
1342
|
+
whereSqlParts.push(`${quoteIdent(k)} = ?`);
|
|
1343
|
+
whereParams.push(v);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
return this.query(
|
|
1347
|
+
`UPDATE ${ident} SET ${setSql} WHERE ${whereSqlParts.join(" AND ")}`,
|
|
1348
|
+
[...setKeys.map((k) => setObj[k]), ...whereParams]
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
if (op === "delete") {
|
|
1352
|
+
const where2 = input.where ?? {};
|
|
1353
|
+
const whereKeys = Object.keys(where2);
|
|
1354
|
+
if (whereKeys.length === 0)
|
|
1355
|
+
throw new Error("where must be non-empty \u2014 use sql_query for unfiltered deletes");
|
|
1356
|
+
for (const k of whereKeys) {
|
|
1357
|
+
if (!colSetForT.has(k)) throw new Error(`Unknown column: ${k}`);
|
|
1358
|
+
}
|
|
1359
|
+
const whereSqlParts = [];
|
|
1360
|
+
const whereParams = [];
|
|
1361
|
+
for (const k of whereKeys) {
|
|
1362
|
+
const v = where2[k];
|
|
1363
|
+
if (v === null) {
|
|
1364
|
+
whereSqlParts.push(`${quoteIdent(k)} IS NULL`);
|
|
1365
|
+
} else {
|
|
1366
|
+
whereSqlParts.push(`${quoteIdent(k)} = ?`);
|
|
1367
|
+
whereParams.push(v);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return this.query(
|
|
1371
|
+
`DELETE FROM ${ident} WHERE ${whereSqlParts.join(" AND ")}`,
|
|
1372
|
+
whereParams
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
if (op === "describe") {
|
|
1376
|
+
const [info, fks, count] = await Promise.all([
|
|
1377
|
+
this.query(`PRAGMA table_info(${ident})`),
|
|
1378
|
+
this.query(`PRAGMA foreign_key_list(${ident})`),
|
|
1379
|
+
this.query(`SELECT COUNT(*) AS n FROM ${ident}`)
|
|
1380
|
+
]);
|
|
1381
|
+
return {
|
|
1382
|
+
table: t.name,
|
|
1383
|
+
rowCount: Number(count.rows[0]?.[0] ?? 0),
|
|
1384
|
+
columns: info.data,
|
|
1385
|
+
foreignKeys: fks.data
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
const where = input.where ?? {};
|
|
1389
|
+
const colSet = new Set(t.columns.map((c) => c.name));
|
|
1390
|
+
const clauses = [];
|
|
1391
|
+
const params = [];
|
|
1392
|
+
for (const [k, v] of Object.entries(where)) {
|
|
1393
|
+
if (!colSet.has(k)) throw new Error(`Unknown column: ${k}`);
|
|
1394
|
+
if (v === null) {
|
|
1395
|
+
clauses.push(`${quoteIdent(k)} IS NULL`);
|
|
1396
|
+
} else {
|
|
1397
|
+
clauses.push(`${quoteIdent(k)} = ?`);
|
|
1398
|
+
params.push(v);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
const whereSql = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
1402
|
+
if (op === "count") {
|
|
1403
|
+
const r2 = await this.query(`SELECT COUNT(*) AS n FROM ${ident}${whereSql}`, params);
|
|
1404
|
+
return { count: Number(r2.rows[0]?.[0] ?? 0) };
|
|
1405
|
+
}
|
|
1406
|
+
const limit = Math.min(1e3, Math.max(1, Number(input.limit ?? 100)));
|
|
1407
|
+
let orderSql = "";
|
|
1408
|
+
if (typeof input.orderBy === "string" && input.orderBy.trim()) {
|
|
1409
|
+
const ob = input.orderBy.trim();
|
|
1410
|
+
const obMatch = ob.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*(asc|desc)?$/i);
|
|
1411
|
+
if (!obMatch) throw new Error("orderBy must be `<col>` or `<col> ASC|DESC`");
|
|
1412
|
+
const [, col, dir] = obMatch;
|
|
1413
|
+
if (!colSet.has(col)) throw new Error(`Unknown column: ${col}`);
|
|
1414
|
+
orderSql = ` ORDER BY ${quoteIdent(col)} ${(dir ?? "ASC").toUpperCase()}`;
|
|
1415
|
+
}
|
|
1416
|
+
const r = await this.query(
|
|
1417
|
+
`SELECT * FROM ${ident}${whereSql}${orderSql} LIMIT ${limit}`,
|
|
1418
|
+
params
|
|
1419
|
+
);
|
|
1420
|
+
return r;
|
|
1421
|
+
};
|
|
1422
|
+
const aiSdk = () => {
|
|
1423
|
+
const out = {};
|
|
1424
|
+
for (const t of anthropic) {
|
|
1425
|
+
out[t.name] = {
|
|
1426
|
+
description: t.description,
|
|
1427
|
+
inputSchema: t.input_schema,
|
|
1428
|
+
execute: (input) => run(t.name, input)
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
return out;
|
|
1432
|
+
};
|
|
1433
|
+
const mastra = () => {
|
|
1434
|
+
const out = {};
|
|
1435
|
+
for (const t of anthropic) {
|
|
1436
|
+
out[t.name] = {
|
|
1437
|
+
id: t.name,
|
|
1438
|
+
description: t.description,
|
|
1439
|
+
inputSchema: t.input_schema,
|
|
1440
|
+
execute: ({ context }) => run(t.name, context)
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
return out;
|
|
1444
|
+
};
|
|
1445
|
+
const langchain = () => anthropic.map((t) => ({
|
|
1446
|
+
name: t.name,
|
|
1447
|
+
description: t.description,
|
|
1448
|
+
schema: t.input_schema,
|
|
1449
|
+
invoke: (input) => run(t.name, input)
|
|
1450
|
+
}));
|
|
1451
|
+
const openaiAgents = () => anthropic.map((t) => ({
|
|
1452
|
+
type: "function",
|
|
1453
|
+
name: t.name,
|
|
1454
|
+
description: t.description,
|
|
1455
|
+
parameters: t.input_schema,
|
|
1456
|
+
invoke: (input) => run(t.name, input)
|
|
1457
|
+
}));
|
|
1458
|
+
return { anthropic, openai, run, aiSdk, mastra, langchain, openaiAgents };
|
|
1459
|
+
}
|
|
1460
|
+
};
|
|
1461
|
+
function sanitizeToolPart(s) {
|
|
1462
|
+
return s.replace(/[^A-Za-z0-9_]/g, "_");
|
|
1463
|
+
}
|
|
1464
|
+
function quoteIdent(s) {
|
|
1465
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
1466
|
+
}
|
|
1467
|
+
var PerSQLBlob = class {
|
|
1468
|
+
constructor(client, namespace, slug) {
|
|
1469
|
+
this.client = client;
|
|
1470
|
+
this.namespace = namespace;
|
|
1471
|
+
this.slug = slug;
|
|
1472
|
+
}
|
|
1473
|
+
/** Upload a blob. Body can be ArrayBuffer, Blob, ReadableStream, or string. */
|
|
1474
|
+
async put(key, body, opts = {}) {
|
|
1475
|
+
const path = `/v1/db/${this.namespace}/${this.slug}/blobs/${encodeBlobKey(key)}`;
|
|
1476
|
+
return this.client.requestRaw(
|
|
1477
|
+
"PUT",
|
|
1478
|
+
path,
|
|
1479
|
+
body,
|
|
1480
|
+
opts.contentType
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
/** Fetch a blob. Returns null if missing. */
|
|
1484
|
+
async get(key) {
|
|
1485
|
+
const path = `/v1/db/${this.namespace}/${this.slug}/blobs/${encodeBlobKey(key)}`;
|
|
1486
|
+
const res = await this.client.fetchRaw("GET", path);
|
|
1487
|
+
if (res.status === 404) return null;
|
|
1488
|
+
if (!res.ok) {
|
|
1489
|
+
throw new PerSQLError(res.status, `Blob fetch failed (${res.status})`);
|
|
1490
|
+
}
|
|
1491
|
+
return res;
|
|
1492
|
+
}
|
|
1493
|
+
/** Delete a blob. */
|
|
1494
|
+
async delete(key) {
|
|
1495
|
+
const path = `/v1/db/${this.namespace}/${this.slug}/blobs/${encodeBlobKey(key)}`;
|
|
1496
|
+
await this.client.request("DELETE", path);
|
|
1497
|
+
}
|
|
1498
|
+
/** List blobs by prefix. */
|
|
1499
|
+
async list(opts = {}) {
|
|
1500
|
+
const params = new URLSearchParams();
|
|
1501
|
+
if (opts.prefix) params.set("prefix", opts.prefix);
|
|
1502
|
+
if (opts.cursor) params.set("cursor", opts.cursor);
|
|
1503
|
+
if (opts.limit) params.set("limit", String(opts.limit));
|
|
1504
|
+
const qs = params.toString();
|
|
1505
|
+
const path = `/v1/db/${this.namespace}/${this.slug}/blobs` + (qs ? `?${qs}` : "");
|
|
1506
|
+
return this.client.request("GET", path);
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
function encodeBlobKey(key) {
|
|
1510
|
+
return key.split("/").map(encodeURIComponent).join("/");
|
|
1511
|
+
}
|
|
1512
|
+
var PerSQLVectors = class {
|
|
1513
|
+
constructor(client, namespace, slug) {
|
|
1514
|
+
this.client = client;
|
|
1515
|
+
this.namespace = namespace;
|
|
1516
|
+
this.slug = slug;
|
|
1517
|
+
}
|
|
1518
|
+
/** Embed and upsert up to 100 items in one call. */
|
|
1519
|
+
async upsert(items) {
|
|
1520
|
+
return this.client.request(
|
|
1521
|
+
"POST",
|
|
1522
|
+
`/v1/db/${this.namespace}/${this.slug}/vectors`,
|
|
1523
|
+
{ items }
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
/** Top-K nearest neighbours by free-text query. */
|
|
1527
|
+
async query(text, opts = {}) {
|
|
1528
|
+
return this.client.request(
|
|
1529
|
+
"POST",
|
|
1530
|
+
`/v1/db/${this.namespace}/${this.slug}/vectors/query`,
|
|
1531
|
+
{ query: text, topK: opts.topK, filter: opts.filter }
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
/** Delete vectors by id. Up to 1000 ids per call. */
|
|
1535
|
+
async delete(ids) {
|
|
1536
|
+
return this.client.request(
|
|
1537
|
+
"DELETE",
|
|
1538
|
+
`/v1/db/${this.namespace}/${this.slug}/vectors`,
|
|
1539
|
+
{ ids }
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
var PerSQLApprovals = class {
|
|
1544
|
+
constructor(client, namespace, slug) {
|
|
1545
|
+
this.client = client;
|
|
1546
|
+
this.namespace = namespace;
|
|
1547
|
+
this.slug = slug;
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Redeem an approved approvalToken. The bearer must be the same one
|
|
1551
|
+
* that minted it, and the request must target the same database.
|
|
1552
|
+
* Returns the result of the originally-blocked query or batch.
|
|
1553
|
+
*/
|
|
1554
|
+
async redeem(approvalToken) {
|
|
1555
|
+
const raw = await this.client.request("POST", `/v1/db/${this.namespace}/${this.slug}/redeem_approval`, {
|
|
1556
|
+
approvalToken
|
|
1557
|
+
});
|
|
1558
|
+
if (Array.isArray(raw)) {
|
|
1559
|
+
return raw.map((r) => ({
|
|
1560
|
+
...r,
|
|
1561
|
+
data: rowsToObjects(r.columns, r.rows)
|
|
1562
|
+
}));
|
|
1563
|
+
}
|
|
1564
|
+
return { ...raw, data: rowsToObjects(raw.columns, raw.rows) };
|
|
1565
|
+
}
|
|
1566
|
+
};
|
|
1567
|
+
var PerSQLProposals = class {
|
|
1568
|
+
constructor(client, namespace, slug) {
|
|
1569
|
+
this.client = client;
|
|
1570
|
+
this.namespace = namespace;
|
|
1571
|
+
this.slug = slug;
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Pre-flight an SQL statement. EXPLAIN-validates, estimates affected
|
|
1575
|
+
* rows, and returns a single-use `executionToken` valid for 10 min
|
|
1576
|
+
* (override with `ttlSec`, max 1 hour). Nothing is mutated until
|
|
1577
|
+
* `apply()` is called.
|
|
1578
|
+
*/
|
|
1579
|
+
async propose(sql, opts = {}) {
|
|
1580
|
+
if (this.client.local) {
|
|
1581
|
+
return this.client.local.propose(sql, opts.params ?? [], opts.ttlSec);
|
|
1582
|
+
}
|
|
1583
|
+
return this.client.request(
|
|
1584
|
+
"POST",
|
|
1585
|
+
`/v1/db/${this.namespace}/${this.slug}/propose`,
|
|
1586
|
+
{
|
|
1587
|
+
sql,
|
|
1588
|
+
params: opts.params ?? [],
|
|
1589
|
+
ttlSec: opts.ttlSec
|
|
1590
|
+
}
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Redeem an `executionToken` from `propose()`. Single-use — calling
|
|
1595
|
+
* twice with the same token throws 404. The token is also pinned to
|
|
1596
|
+
* the originating bearer + database; cross-token redemption throws
|
|
1597
|
+
* 403.
|
|
1598
|
+
*/
|
|
1599
|
+
async apply(executionToken) {
|
|
1600
|
+
if (this.client.local) {
|
|
1601
|
+
const raw2 = await this.client.local.apply(executionToken);
|
|
1602
|
+
return { ...raw2, data: rowsToObjects(raw2.columns, raw2.rows) };
|
|
1603
|
+
}
|
|
1604
|
+
const raw = await this.client.request("POST", `/v1/db/${this.namespace}/${this.slug}/apply`, { executionToken });
|
|
1605
|
+
return {
|
|
1606
|
+
...raw,
|
|
1607
|
+
data: rowsToObjects(raw.columns, raw.rows)
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
var PerSQLBranches = class {
|
|
1612
|
+
constructor(client, namespace, slug) {
|
|
1613
|
+
this.client = client;
|
|
1614
|
+
this.namespace = namespace;
|
|
1615
|
+
this.slug = slug;
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* One-shot lease — create or reset a branch with a TTL and mint a
|
|
1619
|
+
* scoped token in the same call. Use at the start of an agent run
|
|
1620
|
+
* instead of chaining create + token mint.
|
|
1621
|
+
*/
|
|
1622
|
+
async claim(opts = {}) {
|
|
1623
|
+
return this.client.request(
|
|
1624
|
+
"POST",
|
|
1625
|
+
`/v1/db/${this.namespace}/${this.slug}/claim_branch`,
|
|
1626
|
+
opts
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
/** List branches of this database. Any-role token. */
|
|
1630
|
+
async list(opts = {}) {
|
|
1631
|
+
const qs = new URLSearchParams();
|
|
1632
|
+
if (opts.cursor) qs.set("cursor", opts.cursor);
|
|
1633
|
+
if (opts.pageSize) qs.set("pageSize", String(opts.pageSize));
|
|
1634
|
+
const tail = qs.toString() ? `?${qs.toString()}` : "";
|
|
1635
|
+
const res = await this.client.fetchRaw(
|
|
1636
|
+
"GET",
|
|
1637
|
+
`/v1/db/${this.namespace}/${this.slug}/branches${tail}`
|
|
1638
|
+
);
|
|
1639
|
+
const body = await res.json();
|
|
1640
|
+
if (!res.ok || !body.success) {
|
|
1641
|
+
throw new PerSQLError(res.status, body.error ?? `Request failed (${res.status})`);
|
|
1642
|
+
}
|
|
1643
|
+
return { data: body.data ?? [], nextCursor: body.nextCursor ?? null };
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Create-or-reset a branch by ref. Idempotent: re-PUTting the same
|
|
1647
|
+
* ref refreshes the branch from the parent's current state.
|
|
1648
|
+
* Requires an admin-role token.
|
|
1649
|
+
*/
|
|
1650
|
+
async upsert(ref, opts = {}) {
|
|
1651
|
+
return this.client.request(
|
|
1652
|
+
"PUT",
|
|
1653
|
+
`/v1/db/${this.namespace}/${this.slug}/branches/${encodeURIComponent(ref)}`,
|
|
1654
|
+
{
|
|
1655
|
+
name: opts.name,
|
|
1656
|
+
region: opts.region,
|
|
1657
|
+
ttlDays: opts.ttlDays ?? null
|
|
1658
|
+
}
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
/** Delete a branch by ref. Requires an admin-role token. */
|
|
1662
|
+
async delete(ref) {
|
|
1663
|
+
return this.client.request(
|
|
1664
|
+
"DELETE",
|
|
1665
|
+
`/v1/db/${this.namespace}/${this.slug}/branches/${encodeURIComponent(ref)}`
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Apply a branch back into its parent. `preview: true` returns the
|
|
1670
|
+
* plan without writing. Requires an admin-role token.
|
|
1671
|
+
*/
|
|
1672
|
+
async merge(ref, opts = {}) {
|
|
1673
|
+
return this.client.request(
|
|
1674
|
+
"POST",
|
|
1675
|
+
`/v1/db/${this.namespace}/${this.slug}/branches/${encodeURIComponent(ref)}/merge`,
|
|
1676
|
+
{
|
|
1677
|
+
mode: opts.mode ?? "schema",
|
|
1678
|
+
preview: opts.preview === true,
|
|
1679
|
+
dropRemoved: opts.dropRemoved === true,
|
|
1680
|
+
snapshot: opts.snapshot !== false
|
|
1681
|
+
}
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
/** Convenience for `merge(ref, { preview: true, ...opts })`. */
|
|
1685
|
+
preview(ref, opts = {}) {
|
|
1686
|
+
return this.merge(ref, { ...opts, preview: true });
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Mint a single-use handoff token for this branch. The receiving
|
|
1690
|
+
* agent calls `PerSQL.fromHandoff(token)` to redeem it for a
|
|
1691
|
+
* regular bearer token scoped to exactly this (database, branch,
|
|
1692
|
+
* role) — the agent that minted the handoff never has to share
|
|
1693
|
+
* its own token. Single-use, ≤24h TTL.
|
|
1694
|
+
*/
|
|
1695
|
+
async pin(ref, opts = {}) {
|
|
1696
|
+
return this.client.request(
|
|
1697
|
+
"POST",
|
|
1698
|
+
`/v1/db/${this.namespace}/${this.slug}/branches/${encodeURIComponent(ref)}/pin`,
|
|
1699
|
+
{ role: opts.role ?? "write", ttlSec: opts.ttlSec }
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Render the branch's schema delta as a committable SQL migration.
|
|
1704
|
+
* Read-only. Returns `{ name, sql, plan, summary }` — `name` is a
|
|
1705
|
+
* timestamped filename (`YYYYMMDDHHMMSS_branch_<ref>.sql`) safe to
|
|
1706
|
+
* write to disk; `sql` is the migration body wrapped in a single
|
|
1707
|
+
* BEGIN/COMMIT.
|
|
1708
|
+
*/
|
|
1709
|
+
async migration(ref, opts = {}) {
|
|
1710
|
+
return this.client.request(
|
|
1711
|
+
"POST",
|
|
1712
|
+
`/v1/db/${this.namespace}/${this.slug}/branches/${encodeURIComponent(ref)}/migration`,
|
|
1713
|
+
{ dropRemoved: opts.dropRemoved === true }
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
function rowsToObjects(columns, rows) {
|
|
1718
|
+
return rows.map((r) => {
|
|
1719
|
+
const obj = {};
|
|
1720
|
+
for (let i = 0; i < columns.length; i++) obj[columns[i]] = r[i];
|
|
1721
|
+
return obj;
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
export {
|
|
1726
|
+
PerSQLError,
|
|
1727
|
+
RateLimitError,
|
|
1728
|
+
ApprovalRequiredError,
|
|
1729
|
+
PerSQL,
|
|
1730
|
+
PerSQLDatabase,
|
|
1731
|
+
PerSQLBlob,
|
|
1732
|
+
PerSQLVectors,
|
|
1733
|
+
PerSQLApprovals,
|
|
1734
|
+
PerSQLProposals,
|
|
1735
|
+
PerSQLBranches
|
|
1736
|
+
};
|
|
1737
|
+
//# sourceMappingURL=chunk-CDNTQOBK.js.map
|