@mediamonks/canopy-cli 0.0.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.
@@ -0,0 +1,563 @@
1
+ import { t as apiRequest } from "./api-BEY4O4ap.mjs";
2
+ import { n as rewriteApiPath } from "./dev-BB7CwqoI.mjs";
3
+ import { createRequire } from "node:module";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { Hono } from "hono";
7
+ //#region ../data-plane/src/blob.ts
8
+ /**
9
+ * /:slug/blob — the SDK's file storage over an injectable BlobStore (a bucket
10
+ * plus the project's isolated key prefix). Same single-implementation deal as
11
+ * db.ts: canopy-api and the CLI's local sandbox mount this exact router.
12
+ *
13
+ * Authorization is the HOST's job (canopy-api enforces session-or-signature
14
+ * ahead of this router; the local sandbox has no auth wall). This router only
15
+ * mints download URLs via the injected signer — verification lives with the
16
+ * host's auth middleware (see signing.ts).
17
+ */
18
+ const MAX_BLOB_BYTES = 25 * 1024 * 1024;
19
+ const MAX_LIST_KEYS = 1e3;
20
+ const URL_TTL_SECONDS = 600;
21
+ /** Keys may nest with `/`, but never escape the prefix or surprise a URL. */
22
+ function safeBlobKey(raw) {
23
+ if (raw.length === 0 || raw.length > 512) return null;
24
+ if (raw.includes("\\") || raw.includes("\0")) return null;
25
+ if (raw.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) return null;
26
+ return raw;
27
+ }
28
+ function blobRouter(config) {
29
+ const blob = new Hono();
30
+ const resolve = async (c) => config.store(c, c.req.param("slug") ?? "");
31
+ blob.put("/:slug/blob/:key", async (c) => {
32
+ const store = await resolve(c);
33
+ if (store instanceof Response) return store;
34
+ const key = safeBlobKey(c.req.param("key"));
35
+ if (!key) return c.json({ error: "invalid_key" }, 400);
36
+ if (Number(c.req.header("content-length") ?? 0) > MAX_BLOB_BYTES) return c.json({
37
+ error: "blob_too_large",
38
+ maxBytes: MAX_BLOB_BYTES
39
+ }, 413);
40
+ const body = await c.req.raw.arrayBuffer();
41
+ if (body.byteLength > MAX_BLOB_BYTES) return c.json({
42
+ error: "blob_too_large",
43
+ maxBytes: MAX_BLOB_BYTES
44
+ }, 413);
45
+ const contentType = c.req.header("content-type") ?? "application/octet-stream";
46
+ await store.bucket.put(store.prefix + key, body, { httpMetadata: { contentType } });
47
+ return c.json({
48
+ key,
49
+ size: body.byteLength,
50
+ contentType,
51
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
52
+ }, 201);
53
+ });
54
+ blob.get("/:slug/blob", async (c) => {
55
+ const store = await resolve(c);
56
+ if (store instanceof Response) return store;
57
+ const prefix = c.req.query("prefix") ?? "";
58
+ if (prefix.length > 512 || prefix.includes("\0")) return c.json({ error: "invalid_prefix" }, 400);
59
+ const base = store.prefix;
60
+ const objects = [];
61
+ let cursor;
62
+ do {
63
+ const page = await store.bucket.list({
64
+ prefix: base + prefix,
65
+ cursor,
66
+ include: ["httpMetadata"]
67
+ });
68
+ for (const o of page.objects) objects.push({
69
+ key: o.key.slice(base.length),
70
+ size: o.size,
71
+ contentType: o.httpMetadata?.contentType ?? "application/octet-stream",
72
+ uploadedAt: o.uploaded.toISOString()
73
+ });
74
+ cursor = page.truncated && objects.length < MAX_LIST_KEYS ? page.cursor : void 0;
75
+ } while (cursor);
76
+ return c.json(objects.slice(0, MAX_LIST_KEYS));
77
+ });
78
+ blob.get("/:slug/blob/:key/url", async (c) => {
79
+ const store = await resolve(c);
80
+ if (store instanceof Response) return store;
81
+ const key = safeBlobKey(c.req.param("key"));
82
+ if (!key) return c.json({ error: "invalid_key" }, 400);
83
+ if (!await store.bucket.head(store.prefix + key)) return c.json({ error: "not_found" }, 404);
84
+ const exp = Math.floor(Date.now() / 1e3) + URL_TTL_SECONDS;
85
+ const sig = await config.signDownload(c, c.req.param("slug"), key, exp);
86
+ return c.json({
87
+ url: `/_api/v1/blob/${encodeURIComponent(key)}?exp=${exp}&sig=${sig}`,
88
+ expiresAt: (/* @__PURE__ */ new Date(exp * 1e3)).toISOString()
89
+ });
90
+ });
91
+ blob.get("/:slug/blob/:key", async (c) => {
92
+ const store = await resolve(c);
93
+ if (store instanceof Response) return store;
94
+ const key = safeBlobKey(c.req.param("key"));
95
+ if (!key) return c.json({ error: "invalid_key" }, 400);
96
+ const object = await store.bucket.get(store.prefix + key);
97
+ if (!object) return c.json({ error: "not_found" }, 404);
98
+ return new Response(object.body, { headers: {
99
+ "content-type": object.httpMetadata?.contentType ?? "application/octet-stream",
100
+ "cache-control": "private, no-store"
101
+ } });
102
+ });
103
+ blob.delete("/:slug/blob/:key", async (c) => {
104
+ const store = await resolve(c);
105
+ if (store instanceof Response) return store;
106
+ const key = safeBlobKey(c.req.param("key"));
107
+ if (!key) return c.json({ error: "invalid_key" }, 400);
108
+ const fullKey = store.prefix + key;
109
+ const existed = await store.bucket.head(fullKey) !== null;
110
+ await store.bucket.delete(fullKey);
111
+ return c.json({ deleted: existed });
112
+ });
113
+ return blob;
114
+ }
115
+ //#endregion
116
+ //#region ../data-plane/src/db.ts
117
+ /**
118
+ * /:slug/db — the SDK's document API + raw-SQL escape hatch, over an
119
+ * injectable DocStore. This is THE data-plane implementation: canopy-api
120
+ * mounts it against project D1s (HTTP query API), and `canopy dev --local`
121
+ * mounts the very same router against a Miniflare D1 — one place to change
122
+ * when the API surface moves.
123
+ *
124
+ * Hosts wrap it with their own auth/scope middleware and resolve the project
125
+ * in `config.store` (returning a Response for not-found/paused/etc.).
126
+ */
127
+ const COLLECTION_RE = /^[a-z][a-z0-9_]{0,63}$/;
128
+ const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
129
+ const META_COLUMNS = {
130
+ _id: "id",
131
+ _createdAt: "created_at",
132
+ _updatedAt: "updated_at"
133
+ };
134
+ const MAX_DOC_BYTES = 256 * 1024;
135
+ const MAX_LIMIT = 1e3;
136
+ const DOC_COLUMNS = "id, data, created_at, updated_at";
137
+ function toDoc(row) {
138
+ return {
139
+ ...JSON.parse(row.data),
140
+ _id: row.id,
141
+ _createdAt: row.created_at,
142
+ _updatedAt: row.updated_at
143
+ };
144
+ }
145
+ function isPlainObject(value) {
146
+ return typeof value === "object" && value !== null && !Array.isArray(value);
147
+ }
148
+ /** User fields only — `_`-prefixed keys are platform-managed. */
149
+ function stripMetaKeys(doc) {
150
+ return Object.fromEntries(Object.entries(doc).filter(([key]) => !key.startsWith("_")));
151
+ }
152
+ function isScalar(value) {
153
+ return value === null || [
154
+ "string",
155
+ "number",
156
+ "boolean"
157
+ ].includes(typeof value);
158
+ }
159
+ var BadQuery = class extends Error {};
160
+ /**
161
+ * WHERE fragments for a field filter. All params are strings: the value rides
162
+ * as JSON text and `json_extract(?, '$')` rehydrates its SQLite type, so 5
163
+ * matches 5 and false matches false. `IS` makes null match JSON null (and
164
+ * absent fields).
165
+ */
166
+ function fieldFilters(where, table = "") {
167
+ const sql = [];
168
+ const params = [];
169
+ for (const [field, value] of Object.entries(where)) {
170
+ if (!isScalar(value)) throw new BadQuery(`where.${field}: only string/number/boolean/null values are supported`);
171
+ const column = META_COLUMNS[field];
172
+ if (column) {
173
+ sql.push(`${table}${column} IS json_extract(?, '$')`);
174
+ params.push(JSON.stringify(value));
175
+ } else if (FIELD_RE.test(field)) {
176
+ sql.push(`json_extract(${table}data, ?) IS json_extract(?, '$')`);
177
+ params.push(`$.${field}`, JSON.stringify(value));
178
+ } else throw new BadQuery(`where.${field}: invalid field name`);
179
+ }
180
+ return {
181
+ sql,
182
+ params
183
+ };
184
+ }
185
+ function intParam(raw, name, fallback, max) {
186
+ if (raw === void 0) return fallback;
187
+ if (!/^\d{1,9}$/.test(raw)) throw new BadQuery(`${name} must be a non-negative integer`);
188
+ return Math.min(Number(raw), max);
189
+ }
190
+ /** Query text → FTS5 MATCH expression: quoted tokens, implicit AND. */
191
+ function ftsMatch(text) {
192
+ return text.split(/\s+/).filter(Boolean).map((token) => `"${token.replaceAll("\"", "\"\"")}"`).join(" ");
193
+ }
194
+ function dbRouter(config) {
195
+ const db = new Hono();
196
+ const resolve = async (c) => config.store(c, c.req.param("slug") ?? "");
197
+ db.post("/:slug/db/sql", async (c) => {
198
+ const store = await resolve(c);
199
+ if (store instanceof Response) return store;
200
+ const body = await c.req.json().catch(() => ({}));
201
+ const params = body.params ?? [];
202
+ if (typeof body.query !== "string" || body.query.trim() === "" || !Array.isArray(params) || !params.every(isScalar)) return c.json({
203
+ error: "invalid_query",
204
+ detail: "expected { query: string, params?: (string|number|boolean|null)[] }"
205
+ }, 400);
206
+ const result = await store.query(body.query, params).catch((err) => err);
207
+ if (result instanceof Error) return c.json({
208
+ error: "sql_error",
209
+ detail: result.message
210
+ }, 400);
211
+ return c.json(result.results);
212
+ });
213
+ db.post("/:slug/db/:collection", async (c) => {
214
+ const store = await resolve(c);
215
+ if (store instanceof Response) return store;
216
+ const collection = c.req.param("collection");
217
+ if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
218
+ const body = await c.req.json().catch(() => void 0);
219
+ if (!isPlainObject(body)) return c.json({
220
+ error: "invalid_document",
221
+ detail: "expected a JSON object"
222
+ }, 400);
223
+ const fields = stripMetaKeys(body);
224
+ const data = JSON.stringify(fields);
225
+ if (data.length > MAX_DOC_BYTES) return c.json({
226
+ error: "document_too_large",
227
+ maxBytes: MAX_DOC_BYTES
228
+ }, 413);
229
+ const id = crypto.randomUUID();
230
+ const now = (/* @__PURE__ */ new Date()).toISOString();
231
+ await store.query("INSERT INTO _canopy_docs (collection, id, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", [
232
+ collection,
233
+ id,
234
+ data,
235
+ now,
236
+ now
237
+ ]);
238
+ return c.json({
239
+ ...fields,
240
+ _id: id,
241
+ _createdAt: now,
242
+ _updatedAt: now
243
+ }, 201);
244
+ });
245
+ db.get("/:slug/db/:collection", async (c) => {
246
+ const store = await resolve(c);
247
+ if (store instanceof Response) return store;
248
+ const collection = c.req.param("collection");
249
+ if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
250
+ const q = c.req.query();
251
+ try {
252
+ const limit = intParam(q.limit, "limit", 100, MAX_LIMIT);
253
+ const offset = intParam(q.offset, "offset", 0, 1e9);
254
+ let where = {};
255
+ if (q.where !== void 0) {
256
+ try {
257
+ where = JSON.parse(q.where);
258
+ } catch {
259
+ throw new BadQuery("where must be a JSON object");
260
+ }
261
+ if (!isPlainObject(where)) throw new BadQuery("where must be a JSON object");
262
+ }
263
+ let sql;
264
+ let params;
265
+ if (q.search !== void 0) {
266
+ const match = ftsMatch(q.search);
267
+ if (!match) return c.json([]);
268
+ const filters = fieldFilters(where, "d.");
269
+ sql = `SELECT d.id, d.data, d.created_at, d.updated_at
270
+ FROM _canopy_docs_fts
271
+ JOIN _canopy_docs d ON d.rowid = _canopy_docs_fts.rowid
272
+ WHERE _canopy_docs_fts MATCH ? AND d.collection = ?
273
+ ${filters.sql.map((f) => `AND ${f}`).join(" ")}
274
+ ORDER BY _canopy_docs_fts.rank
275
+ LIMIT ${limit} OFFSET ${offset}`;
276
+ params = [
277
+ match,
278
+ collection,
279
+ ...filters.params
280
+ ];
281
+ } else {
282
+ const filters = fieldFilters(where);
283
+ params = [collection, ...filters.params];
284
+ let orderExpr = "created_at";
285
+ if (q.orderBy !== void 0) {
286
+ const column = META_COLUMNS[q.orderBy];
287
+ if (column) orderExpr = column;
288
+ else if (FIELD_RE.test(q.orderBy)) {
289
+ orderExpr = "json_extract(data, ?)";
290
+ params.push(`$.${q.orderBy}`);
291
+ } else throw new BadQuery("orderBy: invalid field name");
292
+ }
293
+ if (q.order !== void 0 && q.order !== "asc" && q.order !== "desc") throw new BadQuery("order must be 'asc' or 'desc'");
294
+ sql = `SELECT ${DOC_COLUMNS} FROM _canopy_docs
295
+ WHERE collection = ? ${filters.sql.map((f) => `AND ${f}`).join(" ")}
296
+ ORDER BY ${orderExpr} ${q.order === "desc" ? "DESC" : "ASC"}
297
+ LIMIT ${limit} OFFSET ${offset}`;
298
+ }
299
+ const result = await store.query(sql, params);
300
+ return c.json(result.results.map(toDoc));
301
+ } catch (err) {
302
+ if (err instanceof BadQuery) return c.json({
303
+ error: "invalid_query",
304
+ detail: err.message
305
+ }, 400);
306
+ throw err;
307
+ }
308
+ });
309
+ db.get("/:slug/db/:collection/:id", async (c) => {
310
+ const store = await resolve(c);
311
+ if (store instanceof Response) return store;
312
+ const collection = c.req.param("collection");
313
+ if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
314
+ const row = (await store.query(`SELECT ${DOC_COLUMNS} FROM _canopy_docs WHERE collection = ? AND id = ?`, [collection, c.req.param("id")])).results[0];
315
+ return c.json(row ? toDoc(row) : null);
316
+ });
317
+ db.patch("/:slug/db/:collection/:id", async (c) => {
318
+ const store = await resolve(c);
319
+ if (store instanceof Response) return store;
320
+ const collection = c.req.param("collection");
321
+ if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
322
+ const body = await c.req.json().catch(() => void 0);
323
+ if (!isPlainObject(body)) return c.json({
324
+ error: "invalid_patch",
325
+ detail: "expected a JSON object"
326
+ }, 400);
327
+ const row = (await store.query(`UPDATE _canopy_docs SET data = json_patch(data, ?), updated_at = ?
328
+ WHERE collection = ? AND id = ?
329
+ RETURNING ${DOC_COLUMNS}`, [
330
+ JSON.stringify(stripMetaKeys(body)),
331
+ (/* @__PURE__ */ new Date()).toISOString(),
332
+ collection,
333
+ c.req.param("id")
334
+ ])).results[0];
335
+ if (!row) return c.json({ error: "not_found" }, 404);
336
+ return c.json(toDoc(row));
337
+ });
338
+ db.delete("/:slug/db/:collection/:id", async (c) => {
339
+ const store = await resolve(c);
340
+ if (store instanceof Response) return store;
341
+ const collection = c.req.param("collection");
342
+ if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
343
+ const result = await store.query("DELETE FROM _canopy_docs WHERE collection = ? AND id = ?", [collection, c.req.param("id")]);
344
+ return c.json({ deleted: (result.meta.changes ?? 0) > 0 });
345
+ });
346
+ return db;
347
+ }
348
+ //#endregion
349
+ //#region ../data-plane/src/doc-store.ts
350
+ const DOC_STORE_DDL = [
351
+ `CREATE TABLE IF NOT EXISTS _canopy_docs (
352
+ collection TEXT NOT NULL,
353
+ id TEXT NOT NULL,
354
+ data TEXT NOT NULL,
355
+ created_at TEXT NOT NULL,
356
+ updated_at TEXT NOT NULL,
357
+ PRIMARY KEY (collection, id)
358
+ )`,
359
+ `CREATE INDEX IF NOT EXISTS _canopy_docs_created ON _canopy_docs (collection, created_at)`,
360
+ `CREATE VIRTUAL TABLE IF NOT EXISTS _canopy_docs_fts
361
+ USING fts5(data, content='_canopy_docs', content_rowid='rowid')`,
362
+ `CREATE TRIGGER IF NOT EXISTS _canopy_docs_fts_insert AFTER INSERT ON _canopy_docs BEGIN
363
+ INSERT INTO _canopy_docs_fts (rowid, data) VALUES (new.rowid, new.data);
364
+ END`,
365
+ `CREATE TRIGGER IF NOT EXISTS _canopy_docs_fts_delete AFTER DELETE ON _canopy_docs BEGIN
366
+ INSERT INTO _canopy_docs_fts (_canopy_docs_fts, rowid, data) VALUES ('delete', old.rowid, old.data);
367
+ END`,
368
+ `CREATE TRIGGER IF NOT EXISTS _canopy_docs_fts_update AFTER UPDATE ON _canopy_docs BEGIN
369
+ INSERT INTO _canopy_docs_fts (_canopy_docs_fts, rowid, data) VALUES ('delete', old.rowid, old.data);
370
+ INSERT INTO _canopy_docs_fts (rowid, data) VALUES (new.rowid, new.data);
371
+ END`
372
+ ];
373
+ /** Creates the doc store if missing. Idempotent; hosts decide when to memoize. */
374
+ async function ensureDocStore(store) {
375
+ for (const ddl of DOC_STORE_DDL) await store.query(ddl);
376
+ }
377
+ /** Recreates the doc store's FTS index and re-indexes every document. */
378
+ async function rebuildFtsIndex(store) {
379
+ await ensureDocStore(store);
380
+ await store.query("INSERT INTO _canopy_docs_fts (_canopy_docs_fts) VALUES ('rebuild')");
381
+ }
382
+ //#endregion
383
+ //#region src/local.ts
384
+ /**
385
+ * canopy dev --local — the offline sandbox (docs/02-decisions.md D12,
386
+ * docs/04-roadmap.md Phase 2 item 6): Miniflare-backed D1 + R2 living under
387
+ * the project's .canopy/local/, answering the SDK's data plane with THE SAME
388
+ * routers canopy-api mounts (@mediamonks/canopy-data-plane) — the sandbox can't
389
+ * drift from the platform because they share the implementation.
390
+ *
391
+ * Sync is pull-only: `--pull` hydrates the local database from a fresh prod
392
+ * export; nothing ever pushes back. ai/services aren't data — dev.ts proxies
393
+ * them to the real platform when logged in (metered as usual), and only the
394
+ * offline case lands on the 501 stub here.
395
+ */
396
+ /**
397
+ * Splits a SQL dump into statements: quote- and comment-aware, and a
398
+ * CREATE TRIGGER statement only ends at its `END;` (BEGIN…END bodies carry
399
+ * semicolons of their own).
400
+ */
401
+ function splitSqlStatements(dump) {
402
+ const statements = [];
403
+ let current = "";
404
+ let i = 0;
405
+ while (i < dump.length) {
406
+ const ch = dump[i];
407
+ const pair = dump.slice(i, i + 2);
408
+ if (ch === "'" || ch === "\"" || ch === "`" || ch === "[") {
409
+ const close = ch === "[" ? "]" : ch;
410
+ current += ch;
411
+ i++;
412
+ while (i < dump.length) {
413
+ current += dump[i];
414
+ if (dump[i] === close) {
415
+ if (close !== "]" && dump[i + 1] === close) {
416
+ current += close;
417
+ i += 2;
418
+ continue;
419
+ }
420
+ i++;
421
+ break;
422
+ }
423
+ i++;
424
+ }
425
+ } else if (pair === "--") while (i < dump.length && dump[i] !== "\n") i++;
426
+ else if (pair === "/*") {
427
+ const end = dump.indexOf("*/", i + 2);
428
+ i = end === -1 ? dump.length : end + 2;
429
+ } else if (ch === ";") {
430
+ if (/^\s*CREATE\s+(TEMP(ORARY)?\s+)?TRIGGER/i.test(current) && !/\bEND\s*$/i.test(current)) current += ch;
431
+ else {
432
+ if (current.trim()) statements.push(current.trim());
433
+ current = "";
434
+ }
435
+ i++;
436
+ } else {
437
+ current += ch;
438
+ i++;
439
+ }
440
+ }
441
+ if (current.trim()) statements.push(current.trim());
442
+ return statements;
443
+ }
444
+ /** Locate the built browser SDK, served as /_api/v1/sdk.js in the sandbox. */
445
+ function resolveSdkBundle() {
446
+ const bundle = join(dirname(createRequire(import.meta.url).resolve("@mediamonks/canopy/package.json")), "dist/index.mjs");
447
+ if (!existsSync(bundle)) throw new Error("The local sandbox serves the SDK from @mediamonks/canopy/dist, which is not built — run `pnpm --filter @mediamonks/canopy build` once.");
448
+ return bundle;
449
+ }
450
+ /** Downloads a fresh SQL dump of the project's production D1 (pull-only sync). */
451
+ async function fetchProjectDump(slug) {
452
+ const { url } = await apiRequest("POST", `/projects/${slug}/export`);
453
+ const download = await fetch(url);
454
+ if (!download.ok) throw new Error(`Dump download failed (HTTP ${download.status}).`);
455
+ return download.text();
456
+ }
457
+ async function createSandbox(options) {
458
+ const { Miniflare } = await import("miniflare");
459
+ const mf = new Miniflare({
460
+ modules: true,
461
+ script: "export default { fetch: () => new Response(null, { status: 404 }) };",
462
+ d1Databases: { DB: "canopy-local-db" },
463
+ r2Buckets: { BLOBS: "canopy-local-blobs" },
464
+ ...options.persistDir ? {
465
+ d1Persist: join(options.persistDir, "d1"),
466
+ r2Persist: join(options.persistDir, "blob")
467
+ } : {}
468
+ });
469
+ const d1 = await mf.getD1Database("DB");
470
+ const bucket = await mf.getR2Bucket("BLOBS");
471
+ const store = { query: async (sql, params = []) => {
472
+ return await d1.prepare(sql).bind(...params.map((p) => typeof p === "boolean" ? p ? 1 : 0 : p)).all();
473
+ } };
474
+ await ensureDocStore(store);
475
+ const app = new Hono();
476
+ const ownProject = (c, slug) => slug === options.slug ? null : c.json({ error: "project_not_found" }, 404);
477
+ app.route("/v1/projects", dbRouter({ store: async (c, slug) => ownProject(c, slug) ?? store }));
478
+ app.route("/v1/projects", blobRouter({
479
+ store: async (c, slug) => ownProject(c, slug) ?? {
480
+ bucket,
481
+ prefix: ""
482
+ },
483
+ signDownload: async () => "local"
484
+ }));
485
+ app.get("/v1/sdk.js", () => {
486
+ return new Response(readFileSync(resolveSdkBundle(), "utf8"), { headers: {
487
+ "content-type": "text/javascript",
488
+ "cache-control": "no-store"
489
+ } });
490
+ });
491
+ app.get("/v1/auth/me", (c) => c.json({
492
+ id: "local-dev",
493
+ email: options.email ?? "dev@localhost",
494
+ name: "Local Dev",
495
+ kind: "monk"
496
+ }));
497
+ app.all("/v1/projects/:slug/ai/*", (c) => c.json({
498
+ error: "not_available_in_local_dev",
499
+ detail: "ai needs the platform — log in (`canopy login`) so --local can proxy it."
500
+ }, 501));
501
+ app.all("/v1/projects/:slug/services/*", (c) => c.json({
502
+ error: "not_available_in_local_dev",
503
+ detail: "services need the platform — log in (`canopy login`) so --local can proxy them."
504
+ }, 501));
505
+ app.notFound((c) => c.json({
506
+ error: "not_available_in_local_dev",
507
+ detail: "the local sandbox serves the data plane only (db, blob, auth.user, sdk.js) — control-plane calls need `canopy dev` against the platform."
508
+ }, 501));
509
+ async function handle(request) {
510
+ const url = new URL(request.url);
511
+ if (!url.pathname.startsWith("/_api/")) return Response.json({ error: "not_found" }, { status: 404 });
512
+ const pathname = rewriteApiPath(url.pathname, options.slug);
513
+ return app.fetch(new Request(`http://sandbox${pathname}${url.search}`, request));
514
+ }
515
+ /**
516
+ * Drops every user object so a dump lands on a clean slate (the local
517
+ * counterpart of apps/api/src/routes/data.ts resetProjectDb). Statements
518
+ * run one by one here, so instead of deferring foreign keys the loop
519
+ * retries failed drops until nothing is left — child tables fall first.
520
+ */
521
+ async function resetLocal() {
522
+ for (;;) {
523
+ const objects = (await store.query("SELECT type, name, sql FROM sqlite_master ORDER BY name")).results.filter((row) => !row.name.startsWith("sqlite_") && !row.name.startsWith("_cf_"));
524
+ const virtualTables = objects.filter((row) => row.type === "table" && /^\s*CREATE\s+VIRTUAL\s+TABLE/i.test(row.sql ?? ""));
525
+ const isShadow = (name) => virtualTables.some((v) => name.startsWith(`${v.name}_`));
526
+ const quote = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
527
+ const drops = [];
528
+ for (const row of objects.filter((r) => r.type === "trigger")) drops.push(`DROP TRIGGER ${quote(row.name)}`);
529
+ for (const row of virtualTables) drops.push(`DROP TABLE ${quote(row.name)}`);
530
+ for (const row of objects.filter((r) => r.type === "view")) drops.push(`DROP VIEW ${quote(row.name)}`);
531
+ for (const row of objects.filter((r) => r.type === "table")) if (!virtualTables.includes(row) && !isShadow(row.name)) drops.push(`DROP TABLE ${quote(row.name)}`);
532
+ if (drops.length === 0) return;
533
+ let progressed = false;
534
+ let lastError;
535
+ for (const drop of drops) try {
536
+ await store.query(drop);
537
+ progressed = true;
538
+ } catch (err) {
539
+ lastError = err;
540
+ }
541
+ if (!progressed) throw lastError;
542
+ }
543
+ }
544
+ async function applyDump(sql) {
545
+ await resetLocal();
546
+ for (const statement of splitSqlStatements(sql)) {
547
+ if (/^(BEGIN|COMMIT|ROLLBACK|PRAGMA)\b/i.test(statement)) continue;
548
+ try {
549
+ await store.query(statement);
550
+ } catch (err) {
551
+ throw new Error(`Dump statement failed: ${err instanceof Error ? err.message : String(err)}\n in: ${statement.slice(0, 120)}${statement.length > 120 ? "…" : ""}`);
552
+ }
553
+ }
554
+ await rebuildFtsIndex(store);
555
+ }
556
+ return {
557
+ handle,
558
+ applyDump,
559
+ dispose: () => mf.dispose()
560
+ };
561
+ }
562
+ //#endregion
563
+ export { createSandbox, fetchProjectDump };