@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.
- package/README.md +43 -0
- package/dist/{db-Bkq-_qkd.mjs → db-BW1ZolWn.mjs} +9 -12
- package/dist/{deploy-BO_-gK32.mjs → deploy-alC2--pZ.mjs} +92 -13
- package/dist/dev-BJ5xEwqW.mjs +2 -0
- package/dist/{dev-pPVCUaFp.mjs → dev-CQQhMOoD.mjs} +37 -29
- package/dist/docs/ci.md +6 -2
- package/dist/docs/cli.md +8 -3
- package/dist/docs/data.md +22 -4
- package/dist/docs/deploy.md +10 -6
- package/dist/docs/dev.md +10 -3
- package/dist/docs/infrastructure.md +397 -0
- package/dist/docs/static-serving.md +318 -0
- package/dist/docs/troubleshooting.md +14 -0
- package/dist/{docs-C3aOAx_A.mjs → docs-DvSPpSIR.mjs} +8 -8
- package/dist/index.mjs +53 -19
- package/dist/local-C5-GWJcx.mjs +826 -0
- package/dist/login-Bz-rwlFF.mjs +264 -0
- package/dist/{open-xLiGUz_W.mjs → open-DnlYDqrU.mjs} +2 -2
- package/dist/{package-BHlmIdUm.mjs → package-Bi5FfVSP.mjs} +1 -1
- package/dist/{projects-oyjC4ZGb.mjs → projects-ijfe7Ffx.mjs} +21 -4
- package/dist/site-BJjoePQS.mjs +254 -0
- package/dist/skill/canopy/SKILL.md +1 -1
- package/dist/{skill-BsZKAgGI.mjs → skill-IAAa2EkK.mjs} +2 -2
- package/dist/templates/static/AGENTS.md +1 -1
- package/dist/templates/static/main.js +1 -1
- package/dist/templates/worker/AGENTS.md +11 -9
- package/dist/templates/worker/worker/index.ts +27 -4
- package/dist/{token-BXc2klNm.mjs → token-BeF9zczg.mjs} +2 -3
- package/dist/ui-DThlyHgo.mjs +300 -0
- package/dist/{upgrade-nYLjXxPk.mjs → upgrade-DNxR-zh7.mjs} +3 -3
- package/dist/usage-DQpH7A3X.mjs +79 -0
- package/package.json +5 -4
- package/dist/api-BcX_w6Xf.mjs +0 -58
- package/dist/config-C-z7st1X.mjs +0 -33
- package/dist/dev-R6nzDtA-.mjs +0 -2
- package/dist/local-MLfuFVZb.mjs +0 -563
- package/dist/login-Dif9Y32q.mjs +0 -174
- package/dist/project-CpiPLNby.mjs +0 -54
- package/dist/ui-C8VuPzGp.mjs +0 -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 };
|