@mediamonks/canopy-cli 0.4.4 → 0.5.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.
Files changed (40) hide show
  1. package/README.md +43 -0
  2. package/dist/{db-Bkq-_qkd.mjs → db-BW1ZolWn.mjs} +9 -12
  3. package/dist/{deploy-BO_-gK32.mjs → deploy-alC2--pZ.mjs} +92 -13
  4. package/dist/dev-BJ5xEwqW.mjs +2 -0
  5. package/dist/{dev-pPVCUaFp.mjs → dev-CQQhMOoD.mjs} +37 -29
  6. package/dist/docs/ci.md +6 -2
  7. package/dist/docs/cli.md +8 -3
  8. package/dist/docs/data.md +22 -4
  9. package/dist/docs/deploy.md +10 -6
  10. package/dist/docs/dev.md +10 -3
  11. package/dist/docs/infrastructure.md +397 -0
  12. package/dist/docs/static-serving.md +318 -0
  13. package/dist/docs/troubleshooting.md +14 -0
  14. package/dist/{docs-C3aOAx_A.mjs → docs-DvSPpSIR.mjs} +8 -8
  15. package/dist/index.mjs +53 -19
  16. package/dist/local-C5-GWJcx.mjs +826 -0
  17. package/dist/login-Bz-rwlFF.mjs +264 -0
  18. package/dist/{open-xLiGUz_W.mjs → open-DnlYDqrU.mjs} +2 -2
  19. package/dist/{package-BHlmIdUm.mjs → package-Bi5FfVSP.mjs} +1 -1
  20. package/dist/{projects-oyjC4ZGb.mjs → projects-ijfe7Ffx.mjs} +21 -4
  21. package/dist/site-BJjoePQS.mjs +254 -0
  22. package/dist/skill/canopy/SKILL.md +1 -1
  23. package/dist/{skill-BsZKAgGI.mjs → skill-IAAa2EkK.mjs} +2 -2
  24. package/dist/templates/static/AGENTS.md +1 -1
  25. package/dist/templates/static/main.js +1 -1
  26. package/dist/templates/worker/AGENTS.md +11 -9
  27. package/dist/templates/worker/worker/index.ts +27 -4
  28. package/dist/{token-BXc2klNm.mjs → token-BeF9zczg.mjs} +2 -3
  29. package/dist/ui-DThlyHgo.mjs +300 -0
  30. package/dist/{upgrade-nYLjXxPk.mjs → upgrade-DNxR-zh7.mjs} +3 -3
  31. package/dist/usage-DQpH7A3X.mjs +79 -0
  32. package/package.json +5 -4
  33. package/dist/api-BcX_w6Xf.mjs +0 -58
  34. package/dist/config-C-z7st1X.mjs +0 -33
  35. package/dist/dev-R6nzDtA-.mjs +0 -2
  36. package/dist/local-MLfuFVZb.mjs +0 -563
  37. package/dist/login-Dif9Y32q.mjs +0 -174
  38. package/dist/project-CpiPLNby.mjs +0 -54
  39. package/dist/ui-C8VuPzGp.mjs +0 -40
  40. package/dist/{version-check-CoWgQs2h.mjs → version-check-Cuh6HaLX.mjs} +1 -1
@@ -0,0 +1,826 @@
1
+ import { o as apiDataRequest } from "./ui-DThlyHgo.mjs";
2
+ import { n as rewriteApiPath } from "./dev-CQQhMOoD.mjs";
3
+ import { createRequire } from "node:module";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, relative, sep } from "node:path";
6
+ import { Hono } from "hono";
7
+ //#region ../data-plane/src/blob.ts
8
+ /**
9
+ * /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);
31
+ blob.put("/blob/:key", async (c) => {
32
+ const resolved = await resolve(c);
33
+ if (resolved instanceof Response) return resolved;
34
+ const { store } = resolved;
35
+ const key = safeBlobKey(c.req.param("key"));
36
+ if (!key) return c.json({ error: "invalid_key" }, 400);
37
+ if (Number(c.req.header("content-length") ?? 0) > MAX_BLOB_BYTES) return c.json({
38
+ error: "blob_too_large",
39
+ maxBytes: MAX_BLOB_BYTES
40
+ }, 413);
41
+ const body = await c.req.raw.arrayBuffer();
42
+ if (body.byteLength > MAX_BLOB_BYTES) return c.json({
43
+ error: "blob_too_large",
44
+ maxBytes: MAX_BLOB_BYTES
45
+ }, 413);
46
+ const contentType = c.req.header("content-type") ?? "application/octet-stream";
47
+ await store.bucket.put(store.prefix + key, body, { httpMetadata: { contentType } });
48
+ return c.json({
49
+ key,
50
+ size: body.byteLength,
51
+ contentType,
52
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
53
+ }, 201);
54
+ });
55
+ blob.get("/blob", async (c) => {
56
+ const resolved = await resolve(c);
57
+ if (resolved instanceof Response) return resolved;
58
+ const { store } = resolved;
59
+ const prefix = c.req.query("prefix") ?? "";
60
+ if (prefix.length > 512 || prefix.includes("\0")) return c.json({ error: "invalid_prefix" }, 400);
61
+ const base = store.prefix;
62
+ const objects = [];
63
+ let cursor;
64
+ do {
65
+ const page = await store.bucket.list({
66
+ prefix: base + prefix,
67
+ cursor,
68
+ include: ["httpMetadata"]
69
+ });
70
+ for (const o of page.objects) objects.push({
71
+ key: o.key.slice(base.length),
72
+ size: o.size,
73
+ contentType: o.httpMetadata?.contentType ?? "application/octet-stream",
74
+ uploadedAt: o.uploaded.toISOString()
75
+ });
76
+ cursor = page.truncated && objects.length < MAX_LIST_KEYS ? page.cursor : void 0;
77
+ } while (cursor);
78
+ return c.json(objects.slice(0, MAX_LIST_KEYS));
79
+ });
80
+ blob.get("/blob/:key/url", async (c) => {
81
+ const resolved = await resolve(c);
82
+ if (resolved instanceof Response) return resolved;
83
+ const { slug, store } = resolved;
84
+ const key = safeBlobKey(c.req.param("key"));
85
+ if (!key) return c.json({ error: "invalid_key" }, 400);
86
+ if (!await store.bucket.head(store.prefix + key)) return c.json({ error: "not_found" }, 404);
87
+ const exp = Math.floor(Date.now() / 1e3) + URL_TTL_SECONDS;
88
+ const sig = await config.signDownload(c, slug, key, exp);
89
+ return c.json({
90
+ url: `/_api/data/v1/blob/${encodeURIComponent(key)}?project=${encodeURIComponent(slug)}&exp=${exp}&sig=${sig}`,
91
+ expiresAt: (/* @__PURE__ */ new Date(exp * 1e3)).toISOString()
92
+ });
93
+ });
94
+ blob.get("/blob/:key", async (c) => {
95
+ const resolved = await resolve(c);
96
+ if (resolved instanceof Response) return resolved;
97
+ const { store } = resolved;
98
+ const key = safeBlobKey(c.req.param("key"));
99
+ if (!key) return c.json({ error: "invalid_key" }, 400);
100
+ const object = await store.bucket.get(store.prefix + key);
101
+ if (!object) return c.json({ error: "not_found" }, 404);
102
+ return new Response(object.body, { headers: {
103
+ "content-type": object.httpMetadata?.contentType ?? "application/octet-stream",
104
+ "cache-control": "private, no-store"
105
+ } });
106
+ });
107
+ blob.delete("/blob/:key", async (c) => {
108
+ const resolved = await resolve(c);
109
+ if (resolved instanceof Response) return resolved;
110
+ const { store } = resolved;
111
+ const key = safeBlobKey(c.req.param("key"));
112
+ if (!key) return c.json({ error: "invalid_key" }, 400);
113
+ const fullKey = store.prefix + key;
114
+ const existed = await store.bucket.head(fullKey) !== null;
115
+ await store.bucket.delete(fullKey);
116
+ return c.json({ deleted: existed });
117
+ });
118
+ return blob;
119
+ }
120
+ //#endregion
121
+ //#region ../data-plane/src/dialect.ts
122
+ /** Platform-managed fields map to real columns instead of JSON extraction. */
123
+ const META_COLUMNS = {
124
+ _id: "id",
125
+ _createdAt: "created_at",
126
+ _updatedAt: "updated_at"
127
+ };
128
+ //#endregion
129
+ //#region ../data-plane/src/db.ts
130
+ /**
131
+ * /db — the SDK's document API + raw-SQL escape hatch, over an injectable
132
+ * DocStore. This is THE data-plane implementation: canopy-api mounts it under
133
+ * /data/v1 against project D1s (or Postgres on GCP), and `canopy dev --local`
134
+ * mounts the very same router against PGlite — one place to change when the
135
+ * API surface moves.
136
+ *
137
+ * Paths carry no project: hosts wrap the router with their own auth/scope
138
+ * middleware, resolve the project themselves (gateway-stamped
139
+ * X-Canopy-Project or token scope — see canopy-api's session.ts), and hand
140
+ * back the store in `config.store` (returning a Response for not-found/paused…).
141
+ */
142
+ const COLLECTION_RE = /^[a-z][a-z0-9_]{0,63}$/;
143
+ const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
144
+ const MAX_DOC_BYTES = 256 * 1024;
145
+ const MAX_LIMIT = 1e3;
146
+ function toDoc(row) {
147
+ return {
148
+ ...JSON.parse(row.data),
149
+ _id: row.id,
150
+ _createdAt: row.created_at,
151
+ _updatedAt: row.updated_at
152
+ };
153
+ }
154
+ function isPlainObject(value) {
155
+ return typeof value === "object" && value !== null && !Array.isArray(value);
156
+ }
157
+ /** User fields only — `_`-prefixed keys are platform-managed. */
158
+ function stripMetaKeys(doc) {
159
+ return Object.fromEntries(Object.entries(doc).filter(([key]) => !key.startsWith("_")));
160
+ }
161
+ function isScalar(value) {
162
+ return value === null || [
163
+ "string",
164
+ "number",
165
+ "boolean"
166
+ ].includes(typeof value);
167
+ }
168
+ var BadQuery = class extends Error {};
169
+ /** Validate a where object: known meta keys / dot-path fields, scalar values. */
170
+ function validateWhere(where) {
171
+ for (const [field, value] of Object.entries(where)) {
172
+ if (!isScalar(value)) throw new BadQuery(`where.${field}: only string/number/boolean/null values are supported`);
173
+ if (!META_COLUMNS[field] && !FIELD_RE.test(field)) throw new BadQuery(`where.${field}: invalid field name`);
174
+ }
175
+ return where;
176
+ }
177
+ function intParam(raw, name, fallback, max) {
178
+ if (raw === void 0) return fallback;
179
+ if (!/^\d{1,9}$/.test(raw)) throw new BadQuery(`${name} must be a non-negative integer`);
180
+ return Math.min(Number(raw), max);
181
+ }
182
+ function dbRouter(config) {
183
+ const db = new Hono();
184
+ const resolve = async (c) => config.store(c);
185
+ db.post("/db/sql", async (c) => {
186
+ const resolved = await resolve(c);
187
+ if (resolved instanceof Response) return resolved;
188
+ const { store } = resolved;
189
+ const body = await c.req.json().catch(() => ({}));
190
+ const params = body.params ?? [];
191
+ if (typeof body.query !== "string" || body.query.trim() === "" || !Array.isArray(params) || !params.every(isScalar)) return c.json({
192
+ error: "invalid_query",
193
+ detail: "expected { query: string, params?: (string|number|boolean|null)[] }"
194
+ }, 400);
195
+ const result = await store.query(body.query, params).catch((err) => err);
196
+ if (result instanceof Error) return c.json({
197
+ error: "sql_error",
198
+ detail: result.message
199
+ }, 400);
200
+ return c.json(result.results);
201
+ });
202
+ db.post("/db/:collection", async (c) => {
203
+ const resolved = await resolve(c);
204
+ if (resolved instanceof Response) return resolved;
205
+ const { store, dialect } = resolved;
206
+ const collection = c.req.param("collection");
207
+ if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
208
+ const body = await c.req.json().catch(() => void 0);
209
+ if (!isPlainObject(body)) return c.json({
210
+ error: "invalid_document",
211
+ detail: "expected a JSON object"
212
+ }, 400);
213
+ const fields = stripMetaKeys(body);
214
+ const data = JSON.stringify(fields);
215
+ if (data.length > MAX_DOC_BYTES) return c.json({
216
+ error: "document_too_large",
217
+ maxBytes: MAX_DOC_BYTES
218
+ }, 413);
219
+ const id = crypto.randomUUID();
220
+ const now = (/* @__PURE__ */ new Date()).toISOString();
221
+ const insert = dialect.insert(collection, id, data, now);
222
+ await store.query(insert.sql, insert.params);
223
+ return c.json({
224
+ ...fields,
225
+ _id: id,
226
+ _createdAt: now,
227
+ _updatedAt: now
228
+ }, 201);
229
+ });
230
+ db.get("/db/:collection", async (c) => {
231
+ const resolved = await resolve(c);
232
+ if (resolved instanceof Response) return resolved;
233
+ const { store, dialect } = resolved;
234
+ const collection = c.req.param("collection");
235
+ if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
236
+ const q = c.req.query();
237
+ try {
238
+ const limit = intParam(q.limit, "limit", 100, MAX_LIMIT);
239
+ const offset = intParam(q.offset, "offset", 0, 1e9);
240
+ let whereRaw = {};
241
+ if (q.where !== void 0) {
242
+ try {
243
+ whereRaw = JSON.parse(q.where);
244
+ } catch {
245
+ throw new BadQuery("where must be a JSON object");
246
+ }
247
+ if (!isPlainObject(whereRaw)) throw new BadQuery("where must be a JSON object");
248
+ }
249
+ const where = validateWhere(whereRaw);
250
+ let query;
251
+ if (q.search !== void 0) {
252
+ query = dialect.search({
253
+ collection,
254
+ text: q.search,
255
+ where,
256
+ limit,
257
+ offset
258
+ });
259
+ if (query === null) return c.json([]);
260
+ } else {
261
+ if (q.orderBy !== void 0 && !META_COLUMNS[q.orderBy] && !FIELD_RE.test(q.orderBy)) throw new BadQuery("orderBy: invalid field name");
262
+ if (q.order !== void 0 && q.order !== "asc" && q.order !== "desc") throw new BadQuery("order must be 'asc' or 'desc'");
263
+ query = dialect.find({
264
+ collection,
265
+ where,
266
+ orderBy: q.orderBy,
267
+ order: q.order === "desc" ? "desc" : "asc",
268
+ limit,
269
+ offset
270
+ });
271
+ }
272
+ const result = await store.query(query.sql, query.params);
273
+ return c.json(result.results.map(toDoc));
274
+ } catch (err) {
275
+ if (err instanceof BadQuery) return c.json({
276
+ error: "invalid_query",
277
+ detail: err.message
278
+ }, 400);
279
+ throw err;
280
+ }
281
+ });
282
+ db.get("/db/:collection/:id", async (c) => {
283
+ const resolved = await resolve(c);
284
+ if (resolved instanceof Response) return resolved;
285
+ const { store, dialect } = resolved;
286
+ const collection = c.req.param("collection");
287
+ if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
288
+ const get = dialect.get(collection, c.req.param("id"));
289
+ const row = (await store.query(get.sql, get.params)).results[0];
290
+ return c.json(row ? toDoc(row) : null);
291
+ });
292
+ db.patch("/db/:collection/:id", async (c) => {
293
+ const resolved = await resolve(c);
294
+ if (resolved instanceof Response) return resolved;
295
+ const { store, dialect } = resolved;
296
+ const collection = c.req.param("collection");
297
+ if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
298
+ const body = await c.req.json().catch(() => void 0);
299
+ if (!isPlainObject(body)) return c.json({
300
+ error: "invalid_patch",
301
+ detail: "expected a JSON object"
302
+ }, 400);
303
+ const patch = dialect.patch(collection, c.req.param("id"), JSON.stringify(stripMetaKeys(body)), (/* @__PURE__ */ new Date()).toISOString());
304
+ const row = (await store.query(patch.sql, patch.params)).results[0];
305
+ if (!row) return c.json({ error: "not_found" }, 404);
306
+ return c.json(toDoc(row));
307
+ });
308
+ db.delete("/db/:collection/:id", async (c) => {
309
+ const resolved = await resolve(c);
310
+ if (resolved instanceof Response) return resolved;
311
+ const { store, dialect } = resolved;
312
+ const collection = c.req.param("collection");
313
+ if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
314
+ const del = dialect.del(collection, c.req.param("id"));
315
+ const result = await store.query(del.sql, del.params);
316
+ return c.json({ deleted: (result.meta.changes ?? 0) > 0 });
317
+ });
318
+ return db;
319
+ }
320
+ //#endregion
321
+ //#region ../data-plane/src/dialect-postgres.ts
322
+ /**
323
+ * Postgres doc-store dialect (Cloud SQL, PGlite). Same table shape as SQLite
324
+ * (`data` as TEXT, string timestamps) so dumps stay portable; JSON operations
325
+ * go through jsonb casts.
326
+ *
327
+ * Known, deliberate differences from the SQLite dialect (docs/08 §5):
328
+ * - `fts: false` — search() is an ILIKE substring fallback: different
329
+ * matches, no ranking (results order by created_at). Surfaced to clients
330
+ * as `capabilities.fts` rather than papered over.
331
+ * - patch() is a shallow jsonb merge (`||`): top-level keys replace rather
332
+ * than deep-merge, and a null value stores null instead of removing the
333
+ * key.
334
+ */
335
+ const DOC_COLUMNS = "id, data, created_at, updated_at";
336
+ const DOC_STORE_DDL_PG = [`CREATE TABLE IF NOT EXISTS _canopy_docs (
337
+ collection TEXT NOT NULL,
338
+ id TEXT NOT NULL,
339
+ data TEXT NOT NULL,
340
+ created_at TEXT NOT NULL,
341
+ updated_at TEXT NOT NULL,
342
+ PRIMARY KEY (collection, id)
343
+ )`, `CREATE INDEX IF NOT EXISTS _canopy_docs_created ON _canopy_docs (collection, created_at)`];
344
+ /** `data`'s JSON value at a `$.a.b` path param, as jsonb. */
345
+ const jsonAt = (column, pathParam) => `(${column}::jsonb #> string_to_array(trim(leading '$.' from ${pathParam}::text), '.'))`;
346
+ /**
347
+ * WHERE fragments for a field filter. Values ride as JSON text and compare as
348
+ * jsonb, so 5 matches 5 and false matches false; IS NOT DISTINCT FROM makes
349
+ * null match JSON null (and absent fields, which extract to SQL NULL).
350
+ */
351
+ function fieldFilters(where, next) {
352
+ const sql = [];
353
+ const params = [];
354
+ for (const [field, value] of Object.entries(where)) {
355
+ const column = META_COLUMNS[field];
356
+ if (column) {
357
+ sql.push(`${column} IS NOT DISTINCT FROM (${next()}::jsonb #>> '{}')`);
358
+ params.push(JSON.stringify(value));
359
+ } else {
360
+ sql.push(`${jsonAt("data", next())} IS NOT DISTINCT FROM (${next()}::jsonb)`);
361
+ params.push(`$.${field}`, JSON.stringify(value));
362
+ }
363
+ }
364
+ return {
365
+ sql,
366
+ params
367
+ };
368
+ }
369
+ /** Fresh 1-based $n placeholder counter per statement. */
370
+ function counter() {
371
+ let n = 0;
372
+ return () => `$${++n}`;
373
+ }
374
+ const postgresDialect = {
375
+ name: "postgres",
376
+ fts: false,
377
+ ddl: DOC_STORE_DDL_PG,
378
+ insert(collection, id, data, now) {
379
+ return {
380
+ sql: "INSERT INTO _canopy_docs (collection, id, data, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)",
381
+ params: [
382
+ collection,
383
+ id,
384
+ data,
385
+ now,
386
+ now
387
+ ]
388
+ };
389
+ },
390
+ get(collection, id) {
391
+ return {
392
+ sql: `SELECT ${DOC_COLUMNS} FROM _canopy_docs WHERE collection = $1 AND id = $2`,
393
+ params: [collection, id]
394
+ };
395
+ },
396
+ find({ collection, where, orderBy, order, limit, offset }) {
397
+ const next = counter();
398
+ const collectionParam = next();
399
+ const filters = fieldFilters(where, next);
400
+ const params = [collection, ...filters.params];
401
+ let orderExpr = "created_at";
402
+ if (orderBy !== void 0) {
403
+ const column = META_COLUMNS[orderBy];
404
+ if (column) orderExpr = column;
405
+ else {
406
+ orderExpr = jsonAt("data", next());
407
+ params.push(`$.${orderBy}`);
408
+ }
409
+ }
410
+ return {
411
+ sql: `SELECT ${DOC_COLUMNS} FROM _canopy_docs
412
+ WHERE collection = ${collectionParam} ${filters.sql.map((f) => `AND ${f}`).join(" ")}
413
+ ORDER BY ${orderExpr} ${order === "desc" ? "DESC" : "ASC"}
414
+ LIMIT ${limit} OFFSET ${offset}`,
415
+ params
416
+ };
417
+ },
418
+ search({ collection, text, where, limit, offset }) {
419
+ const tokens = text.split(/\s+/).filter(Boolean);
420
+ if (tokens.length === 0) return null;
421
+ const next = counter();
422
+ const patternParam = next();
423
+ const collectionParam = next();
424
+ const filters = fieldFilters(where, next);
425
+ return {
426
+ sql: `SELECT ${DOC_COLUMNS} FROM _canopy_docs
427
+ WHERE data ILIKE ${patternParam} AND collection = ${collectionParam}
428
+ ${filters.sql.map((f) => `AND ${f}`).join(" ")}
429
+ ORDER BY created_at
430
+ LIMIT ${limit} OFFSET ${offset}`,
431
+ params: [
432
+ `%${tokens.join("%")}%`,
433
+ collection,
434
+ ...filters.params
435
+ ]
436
+ };
437
+ },
438
+ patch(collection, id, patchJson, now) {
439
+ return {
440
+ sql: `UPDATE _canopy_docs SET data = (data::jsonb || $1::jsonb)::text, updated_at = $2
441
+ WHERE collection = $3 AND id = $4
442
+ RETURNING ${DOC_COLUMNS}`,
443
+ params: [
444
+ patchJson,
445
+ now,
446
+ collection,
447
+ id
448
+ ]
449
+ };
450
+ },
451
+ del(collection, id) {
452
+ return {
453
+ sql: "DELETE FROM _canopy_docs WHERE collection = $1 AND id = $2",
454
+ params: [collection, id]
455
+ };
456
+ },
457
+ dropFtsIndex() {
458
+ return [];
459
+ },
460
+ rebuildFtsIndex() {
461
+ return [...DOC_STORE_DDL_PG];
462
+ }
463
+ };
464
+ //#endregion
465
+ //#region ../data-plane/src/doc-store.ts
466
+ /** Creates the doc store if missing. Idempotent; hosts decide when to memoize. */
467
+ async function ensureDocStore(store, dialect) {
468
+ for (const ddl of dialect.ddl) await store.query(ddl);
469
+ }
470
+ //#endregion
471
+ //#region ../data-plane/src/sql-dump.ts
472
+ /**
473
+ * SQL dump handling shared by canopy-api and the CLI's local sandbox.
474
+ */
475
+ /**
476
+ * Splits a SQL dump into statements: quote- and comment-aware. Dollar-quoted
477
+ * strings (`$tag$…$tag$`) are treated as atomic so function bodies stay whole.
478
+ * Also keeps SQLite CREATE TRIGGER … END bodies intact for older dumps.
479
+ */
480
+ function splitSqlStatements(dump) {
481
+ const statements = [];
482
+ let current = "";
483
+ let i = 0;
484
+ while (i < dump.length) {
485
+ const ch = dump[i];
486
+ const pair = dump.slice(i, i + 2);
487
+ if (ch === "$") {
488
+ const tagMatch = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(dump.slice(i));
489
+ if (tagMatch) {
490
+ const tag = tagMatch[0];
491
+ current += tag;
492
+ i += tag.length;
493
+ const end = dump.indexOf(tag, i);
494
+ if (end === -1) {
495
+ current += dump.slice(i);
496
+ i = dump.length;
497
+ } else {
498
+ current += dump.slice(i, end + tag.length);
499
+ i = end + tag.length;
500
+ }
501
+ continue;
502
+ }
503
+ }
504
+ if (ch === "'" || ch === "\"" || ch === "`" || ch === "[") {
505
+ const close = ch === "[" ? "]" : ch;
506
+ current += ch;
507
+ i++;
508
+ while (i < dump.length) {
509
+ current += dump[i];
510
+ if (dump[i] === close) {
511
+ if (close !== "]" && dump[i + 1] === close) {
512
+ current += close;
513
+ i += 2;
514
+ continue;
515
+ }
516
+ i++;
517
+ break;
518
+ }
519
+ i++;
520
+ }
521
+ } else if (pair === "--") while (i < dump.length && dump[i] !== "\n") i++;
522
+ else if (pair === "/*") {
523
+ const end = dump.indexOf("*/", i + 2);
524
+ i = end === -1 ? dump.length : end + 2;
525
+ } else if (ch === ";") {
526
+ if (/^\s*CREATE\s+(TEMP(ORARY)?\s+)?TRIGGER/i.test(current) && !/\bEND\s*$/i.test(current)) current += ch;
527
+ else {
528
+ if (current.trim()) statements.push(current.trim());
529
+ current = "";
530
+ }
531
+ i++;
532
+ } else {
533
+ current += ch;
534
+ i++;
535
+ }
536
+ }
537
+ if (current.trim()) statements.push(current.trim());
538
+ return statements;
539
+ }
540
+ //#endregion
541
+ //#region src/fs-blob.ts
542
+ /**
543
+ * Filesystem BlobBucket for `canopy dev --local` — same structural interface
544
+ * as R2 / GCS (`@mediamonks/canopy-data-plane` BlobBucket).
545
+ */
546
+ function metaPath(root, key) {
547
+ return join(root, `${key}.canopy-meta.json`);
548
+ }
549
+ function dataPath(root, key) {
550
+ return join(root, key);
551
+ }
552
+ function readMeta(root, key) {
553
+ const path = metaPath(root, key);
554
+ if (!existsSync(path)) return null;
555
+ try {
556
+ return JSON.parse(readFileSync(path, "utf8"));
557
+ } catch {
558
+ return null;
559
+ }
560
+ }
561
+ function listKeys(root, prefix = "") {
562
+ if (!existsSync(root)) return [];
563
+ const out = [];
564
+ const walk = (dir) => {
565
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
566
+ const full = join(dir, entry.name);
567
+ if (entry.isDirectory()) {
568
+ walk(full);
569
+ continue;
570
+ }
571
+ if (entry.name.endsWith(".canopy-meta.json")) continue;
572
+ const key = relative(root, full).split(sep).join("/");
573
+ if (prefix && !key.startsWith(prefix)) continue;
574
+ out.push(key);
575
+ }
576
+ };
577
+ walk(root);
578
+ return out.sort();
579
+ }
580
+ /** In-memory blob store (tests / `persistDir: false`). */
581
+ function createMemoryBlobBucket() {
582
+ const objects = /* @__PURE__ */ new Map();
583
+ return {
584
+ async put(key, body, options) {
585
+ objects.set(key, {
586
+ body,
587
+ meta: {
588
+ contentType: options?.httpMetadata?.contentType,
589
+ uploaded: (/* @__PURE__ */ new Date()).toISOString()
590
+ }
591
+ });
592
+ return {};
593
+ },
594
+ async get(key) {
595
+ const hit = objects.get(key);
596
+ if (!hit) return null;
597
+ const ab = hit.body;
598
+ return {
599
+ key,
600
+ size: ab.byteLength,
601
+ uploaded: new Date(hit.meta.uploaded),
602
+ httpMetadata: { contentType: hit.meta.contentType },
603
+ body: new ReadableStream({ start(controller) {
604
+ controller.enqueue(new Uint8Array(ab));
605
+ controller.close();
606
+ } }),
607
+ arrayBuffer: async () => ab
608
+ };
609
+ },
610
+ async head(key) {
611
+ const hit = objects.get(key);
612
+ if (!hit) return null;
613
+ return {
614
+ key,
615
+ size: hit.body.byteLength,
616
+ uploaded: new Date(hit.meta.uploaded),
617
+ httpMetadata: { contentType: hit.meta.contentType }
618
+ };
619
+ },
620
+ async list(options) {
621
+ const prefix = options?.prefix ?? "";
622
+ return {
623
+ objects: [...objects.keys()].filter((k) => k.startsWith(prefix)).sort().map((key) => {
624
+ const hit = objects.get(key);
625
+ return {
626
+ key,
627
+ size: hit.body.byteLength,
628
+ uploaded: new Date(hit.meta.uploaded),
629
+ httpMetadata: { contentType: hit.meta.contentType }
630
+ };
631
+ }),
632
+ truncated: false
633
+ };
634
+ },
635
+ async delete(key) {
636
+ objects.delete(key);
637
+ }
638
+ };
639
+ }
640
+ /** Filesystem-backed blob store under `root/`. */
641
+ function createFsBlobBucket(root) {
642
+ mkdirSync(root, { recursive: true });
643
+ return {
644
+ async put(key, body, options) {
645
+ const path = dataPath(root, key);
646
+ mkdirSync(dirname(path), { recursive: true });
647
+ writeFileSync(path, Buffer.from(body));
648
+ writeFileSync(metaPath(root, key), `${JSON.stringify({
649
+ contentType: options?.httpMetadata?.contentType,
650
+ uploaded: (/* @__PURE__ */ new Date()).toISOString()
651
+ })}\n`);
652
+ return {};
653
+ },
654
+ async get(key) {
655
+ const path = dataPath(root, key);
656
+ if (!existsSync(path)) return null;
657
+ const buf = readFileSync(path);
658
+ const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
659
+ const meta = readMeta(root, key) ?? { uploaded: statSync(path).mtime.toISOString() };
660
+ return {
661
+ key,
662
+ size: ab.byteLength,
663
+ uploaded: new Date(meta.uploaded),
664
+ httpMetadata: { contentType: meta.contentType },
665
+ body: new ReadableStream({ start(controller) {
666
+ controller.enqueue(new Uint8Array(ab));
667
+ controller.close();
668
+ } }),
669
+ arrayBuffer: async () => ab
670
+ };
671
+ },
672
+ async head(key) {
673
+ const path = dataPath(root, key);
674
+ if (!existsSync(path)) return null;
675
+ const st = statSync(path);
676
+ const meta = readMeta(root, key) ?? { uploaded: st.mtime.toISOString() };
677
+ return {
678
+ key,
679
+ size: st.size,
680
+ uploaded: new Date(meta.uploaded),
681
+ httpMetadata: { contentType: meta.contentType }
682
+ };
683
+ },
684
+ async list(options) {
685
+ const keys = listKeys(root, options?.prefix ?? "");
686
+ const objects = [];
687
+ for (const key of keys) {
688
+ const h = await this.head(key);
689
+ if (h) objects.push(h);
690
+ }
691
+ return {
692
+ objects,
693
+ truncated: false
694
+ };
695
+ },
696
+ async delete(key) {
697
+ const path = dataPath(root, key);
698
+ if (existsSync(path)) rmSync(path);
699
+ const meta = metaPath(root, key);
700
+ if (existsSync(meta)) rmSync(meta);
701
+ }
702
+ };
703
+ }
704
+ //#endregion
705
+ //#region src/local.ts
706
+ /**
707
+ * canopy dev --local — the offline sandbox (docs/02-decisions.md D12,
708
+ * docs/05-gcp-plan.md §10.2 / Phase G4): PGlite + filesystem blobs under the
709
+ * project's .canopy/local/, answering the SDK's data plane with THE SAME
710
+ * routers canopy-api mounts (@mediamonks/canopy-data-plane) — the sandbox can't
711
+ * drift from the platform because they share the implementation.
712
+ *
713
+ * Sync is pull-only: `--pull` hydrates the local database from a fresh prod
714
+ * export; nothing ever pushes back. ai/services aren't data — dev.ts proxies
715
+ * them to the real platform when logged in (metered as usual), and only the
716
+ * offline case lands on the 501 stub here.
717
+ */
718
+ /** Locate the built browser SDK, served as /_api/data/v1/sdk.js in the sandbox. */
719
+ function resolveSdkBundle() {
720
+ const bundle = join(dirname(createRequire(import.meta.url).resolve("@mediamonks/canopy/package.json")), "dist/index.mjs");
721
+ 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.");
722
+ return bundle;
723
+ }
724
+ /** Downloads a fresh SQL dump of the project's production database (pull-only sync). */
725
+ async function fetchProjectDump(slug) {
726
+ const { url } = await apiDataRequest("POST", "/db/export", slug);
727
+ const download = await fetch(url);
728
+ if (!download.ok) throw new Error(`Dump download failed (HTTP ${download.status}).`);
729
+ return download.text();
730
+ }
731
+ async function createSandbox(options) {
732
+ const { PGlite } = await import("@electric-sql/pglite");
733
+ const dataDir = options.persistDir ? join(options.persistDir, "pg") : void 0;
734
+ if (dataDir) mkdirSync(dataDir, { recursive: true });
735
+ const db = dataDir ? new PGlite(dataDir) : new PGlite();
736
+ await db.waitReady;
737
+ const bucket = options.persistDir ? createFsBlobBucket(join(options.persistDir, "blob")) : createMemoryBlobBucket();
738
+ const store = { query: async (sql, params = []) => {
739
+ const result = await db.query(sql, params);
740
+ return {
741
+ results: result.rows,
742
+ meta: { changes: Number(result.affectedRows ?? 0) }
743
+ };
744
+ } };
745
+ await ensureDocStore(store, postgresDialect);
746
+ const app = new Hono();
747
+ const ownProject = (c) => {
748
+ const requested = c.req.header("x-canopy-project") ?? c.req.query("project");
749
+ return requested === void 0 || requested === options.slug ? null : c.json({ error: "project_not_found" }, 404);
750
+ };
751
+ app.route("/data/v1", dbRouter({ store: async (c) => ownProject(c) ?? {
752
+ store,
753
+ dialect: postgresDialect
754
+ } }));
755
+ app.route("/data/v1", blobRouter({
756
+ store: async (c) => ownProject(c) ?? {
757
+ slug: options.slug,
758
+ store: {
759
+ bucket,
760
+ prefix: ""
761
+ }
762
+ },
763
+ signDownload: async () => "local"
764
+ }));
765
+ app.get("/data/v1/sdk.js", () => {
766
+ return new Response(readFileSync(resolveSdkBundle(), "utf8"), { headers: {
767
+ "content-type": "text/javascript",
768
+ "cache-control": "no-store"
769
+ } });
770
+ });
771
+ app.get("/data/v1/auth/me", (c) => c.json({
772
+ id: "local-dev",
773
+ email: options.email ?? "dev@localhost",
774
+ name: "Local Dev",
775
+ kind: "monk"
776
+ }));
777
+ app.all("/data/v1/ai/*", (c) => c.json({
778
+ error: "not_available_in_local_dev",
779
+ detail: "ai needs the platform — log in (`canopy login`) so --local can proxy it."
780
+ }, 501));
781
+ app.all("/data/v1/services/*", (c) => c.json({
782
+ error: "not_available_in_local_dev",
783
+ detail: "services need the platform — log in (`canopy login`) so --local can proxy them."
784
+ }, 501));
785
+ app.notFound((c) => c.json({
786
+ error: "not_available_in_local_dev",
787
+ detail: "the local sandbox serves the data plane only (db, blob, auth.user, sdk.js) — control-plane calls need `canopy dev` against the platform."
788
+ }, 501));
789
+ async function handle(request) {
790
+ const url = new URL(request.url);
791
+ let pathname = url.pathname;
792
+ if (pathname.startsWith("/_api/") || pathname === "/_api") pathname = rewriteApiPath(pathname);
793
+ else if (!pathname.startsWith("/data/v1/") && !pathname.startsWith("/control/v1/")) return Response.json({ error: "not_found" }, { status: 404 });
794
+ return app.fetch(new Request(`http://sandbox${pathname}${url.search}`, request));
795
+ }
796
+ /** Drop every user table so a dump lands on a clean slate. */
797
+ async function resetLocal() {
798
+ const tables = (await db.query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`)).rows;
799
+ for (const { tablename } of tables) await db.exec(`DROP TABLE IF EXISTS "${tablename.replaceAll("\"", "\"\"")}" CASCADE`);
800
+ }
801
+ async function applyDump(sql) {
802
+ await resetLocal();
803
+ for (const statement of splitSqlStatements(sql)) {
804
+ if (/^(BEGIN|COMMIT|ROLLBACK|SET\s+search_path|PRAGMA)\b/i.test(statement)) continue;
805
+ try {
806
+ await db.exec(statement);
807
+ } catch (err) {
808
+ throw new Error(`Dump statement failed: ${err instanceof Error ? err.message : String(err)}\n in: ${statement.slice(0, 120)}${statement.length > 120 ? "…" : ""}`);
809
+ }
810
+ }
811
+ await ensureDocStore(store, postgresDialect);
812
+ }
813
+ return {
814
+ handle,
815
+ applyDump,
816
+ dispose: async () => {
817
+ await db.close();
818
+ if (options.persistDir === false && dataDir && existsSync(dataDir)) rmSync(dataDir, {
819
+ recursive: true,
820
+ force: true
821
+ });
822
+ }
823
+ };
824
+ }
825
+ //#endregion
826
+ export { createSandbox, fetchProjectDump };