@mediamonks/canopy-cli 0.5.1 → 0.6.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 (39) hide show
  1. package/dist/{db-BW1ZolWn.mjs → db-BpnjQUkF.mjs} +42 -3
  2. package/dist/{deploy-alC2--pZ.mjs → deploy-DdSSL227.mjs} +29 -2
  3. package/dist/dev-DV5ud7AJ.mjs +2 -0
  4. package/dist/{dev-CQQhMOoD.mjs → dev-OxeYndd1.mjs} +24 -9
  5. package/dist/docs/ci.md +1 -3
  6. package/dist/docs/cli.md +3 -4
  7. package/dist/docs/data.md +5 -4
  8. package/dist/docs/deploy.md +1 -3
  9. package/dist/docs/dev.md +13 -11
  10. package/dist/docs/getting-started.md +1 -3
  11. package/dist/docs/infrastructure.md +27 -15
  12. package/dist/docs/migrations.md +166 -0
  13. package/dist/docs/porting-nextjs.md +232 -0
  14. package/dist/docs/sdk-ai.md +103 -0
  15. package/dist/docs/sdk-auth.md +57 -0
  16. package/dist/docs/sdk-database.md +126 -0
  17. package/dist/docs/sdk-files.md +82 -0
  18. package/dist/docs/sdk-services.md +58 -0
  19. package/dist/docs/sdk.md +113 -0
  20. package/dist/docs/static-serving.md +4 -1
  21. package/dist/docs/troubleshooting.md +1 -3
  22. package/dist/{docs-DvSPpSIR.mjs → docs-Cept8t_3.mjs} +23 -10
  23. package/dist/index.mjs +20 -20
  24. package/dist/{local-C5-GWJcx.mjs → local-D9OVEHDg.mjs} +363 -39
  25. package/dist/{login-BO6abf40.mjs → login-Cb8D8a1Z.mjs} +5 -3
  26. package/dist/migrations-B0kJarjr.mjs +50 -0
  27. package/dist/{open-DnlYDqrU.mjs → open-BmgYBSdR.mjs} +2 -2
  28. package/dist/{package-CHoZKzaw.mjs → package-D1wDsLSU.mjs} +1 -1
  29. package/dist/{projects-ijfe7Ffx.mjs → projects-CoGh8m9A.mjs} +17 -9
  30. package/dist/{site-BJjoePQS.mjs → site-DQnysZ3W.mjs} +8 -1
  31. package/dist/skill/canopy/SKILL.md +18 -1
  32. package/dist/{skill-CW0QSbAJ.mjs → skill-192EdixC.mjs} +2 -2
  33. package/dist/templates/worker/AGENTS.md +3 -0
  34. package/dist/{token-BeF9zczg.mjs → token-CWgp_Lqe.mjs} +2 -2
  35. package/dist/{ui-DThlyHgo.mjs → ui-BhzMeKQl.mjs} +1 -1
  36. package/dist/{upgrade-CGz1yzsW.mjs → upgrade-BrHSSg2e.mjs} +2 -2
  37. package/dist/{usage-DQpH7A3X.mjs → usage-CYQRLXwf.mjs} +2 -2
  38. package/package.json +3 -3
  39. package/dist/dev-BJ5xEwqW.mjs +0 -2
@@ -1,5 +1,6 @@
1
- import { a as projectFlagArgs, s as resolveProject } from "./site-BJjoePQS.mjs";
2
- import { i as unwrap, l as withContext, n as reportErrors, o as apiDataRequest, r as requireTty } from "./ui-DThlyHgo.mjs";
1
+ import { c as resolveProject, o as projectFlagArgs } from "./site-DQnysZ3W.mjs";
2
+ import { i as unwrap, l as withContext, n as reportErrors, o as apiDataRequest, r as requireTty } from "./ui-BhzMeKQl.mjs";
3
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-B0kJarjr.mjs";
3
4
  import { defineCommand } from "citty";
4
5
  import { readFileSync, writeFileSync } from "node:fs";
5
6
  import * as p from "@clack/prompts";
@@ -12,6 +13,7 @@ import * as p from "@clack/prompts";
12
13
  * snapshot store a dump server-side (restorable from CLI or dashboard)
13
14
  * snapshots list stored snapshots
14
15
  * restore replace ALL project data from a snapshot or a local .sql file
16
+ * migrate apply pending schema migrations (docs/13-migrations-and-drizzle.md)
15
17
  *
16
18
  * The project comes from canopy.json in the current directory, or
17
19
  * --project <slug>.
@@ -20,7 +22,7 @@ const formatSize = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)
20
22
  const dbCommand = defineCommand({
21
23
  meta: {
22
24
  name: "db",
23
- description: "Data tools: pull, snapshot, snapshots, restore"
25
+ description: "Data tools: pull, snapshot, snapshots, restore, migrate"
24
26
  },
25
27
  subCommands: {
26
28
  pull: defineCommand({
@@ -141,6 +143,43 @@ const dbCommand = defineCommand({
141
143
  else if (file) await withContext(apiDataRequest("POST", "/db/restore", slug, readFileSync(file, "utf8"), "application/sql"), restoreContext);
142
144
  p.log.success(`Restored ${slug}.`);
143
145
  })
146
+ }),
147
+ migrate: defineCommand({
148
+ meta: {
149
+ name: "migrate",
150
+ description: "Apply pending schema migrations to the project database"
151
+ },
152
+ args: {
153
+ ...projectFlagArgs,
154
+ plan: {
155
+ type: "boolean",
156
+ default: false,
157
+ description: "Dry run: list pending migrations without applying anything"
158
+ }
159
+ },
160
+ run: reportErrors("db migrate", async ({ args }) => {
161
+ const { dir, slug } = resolveProject(args);
162
+ const files = readMigrationFiles(dir);
163
+ if (files === void 0) throw new Error("No migrations directory configured — set \"migrations\" in canopy.json (e.g. {\"migrations\": \"./migrations\"}, pointing at your drizzle-kit output).");
164
+ if (args.plan) {
165
+ const result = await withContext(migrateProject(slug, files, true), { action: `plan migrations for "${slug}"` });
166
+ if (result.pending.length === 0) console.log(`Nothing pending (${result.applied.length} applied previously).`);
167
+ else for (const name of result.pending) console.log(`pending ${name}`);
168
+ for (const line of describeMigrateResult(result)) console.log(line);
169
+ return;
170
+ }
171
+ const spin = p.spinner();
172
+ spin.start(`Migrating ${slug}…`);
173
+ let result;
174
+ try {
175
+ result = await withContext(migrateProject(slug, files), { action: `migrate "${slug}"` });
176
+ } catch (err) {
177
+ spin.error("Migrate failed.");
178
+ throw err;
179
+ }
180
+ spin.stop(result.appliedNow.length === 0 ? `Nothing pending for ${slug}.` : `Applied ${result.appliedNow.length} migration(s) to ${slug}.`);
181
+ for (const line of describeMigrateResult(result)) console.log(` ${line}`);
182
+ })
144
183
  })
145
184
  }
146
185
  });
@@ -1,5 +1,6 @@
1
- import { a as projectFlagArgs, c as tryReadCanopyJson, i as projectDirArgs, n as siteConfig, o as projectUrl, s as resolveProject } from "./site-BJjoePQS.mjs";
2
- import { c as apiRequest, i as unwrap, l as withContext, n as reportErrors, s as apiDownload } from "./ui-DThlyHgo.mjs";
1
+ import { a as projectDirArgs, c as resolveProject, l as tryReadCanopyJson, n as siteConfig, o as projectFlagArgs, s as projectUrl } from "./site-DQnysZ3W.mjs";
2
+ import { c as apiRequest, i as unwrap, l as withContext, n as reportErrors, o as apiDataRequest, s as apiDownload } from "./ui-BhzMeKQl.mjs";
3
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-B0kJarjr.mjs";
3
4
  import { defineCommand } from "citty";
4
5
  import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
5
6
  import { tmpdir } from "node:os";
@@ -46,6 +47,10 @@ async function bundleWorker(projectDir, entry) {
46
47
  *
47
48
  * Reads canopy.json in the project root:
48
49
  * { "slug": "my-app", "dist": ".", "build": "npm run build" }
50
+ *
51
+ * A "migrations" dir in canopy.json adds a schema step (docs/13): pending
52
+ * migrations apply after the bundle is staged and before the version flips —
53
+ * schema first, then code. A migration failure aborts the deploy.
49
54
  */
50
55
  /** Must match apps/api/src/routes/deploys.ts WORKER_BUNDLE_PATH. */
51
56
  const WORKER_BUNDLE_PATH = "_canopy/worker.mjs";
@@ -97,12 +102,23 @@ const deployCommand = defineCommand({
97
102
  default: true,
98
103
  description: "Run the canopy.json build command first",
99
104
  negativeDescription: "Skip the build, deploy the output directory as-is"
105
+ },
106
+ migrate: {
107
+ type: "boolean",
108
+ default: true,
109
+ description: "Apply pending schema migrations before the version goes live",
110
+ negativeDescription: "Skip migrations, deploy the code only"
100
111
  }
101
112
  },
102
113
  run: reportErrors("deploy", async ({ args }) => {
103
114
  const { dir, slug } = resolveProject(args);
104
115
  const config = tryReadCanopyJson(dir) ?? {};
105
116
  if (config.build && args.build) await runBuild(config.build, dir);
117
+ const migrations = args.migrate ? readMigrationFiles(dir) : void 0;
118
+ if (migrations !== void 0 && config.dialect) try {
119
+ const { dialect } = await apiDataRequest("GET", "/platform", slug);
120
+ if (dialect !== config.dialect) p.log.warn(`canopy.json pins dialect "${config.dialect}" but this site's database speaks ${dialect} — the migrations below were probably written for a different target.`);
121
+ } catch {}
106
122
  const distDir = resolve(dir, config.dist ?? ".");
107
123
  let paths;
108
124
  try {
@@ -140,6 +156,7 @@ const deployCommand = defineCommand({
140
156
  const workerNote = workerBytes ? " + worker" : "";
141
157
  spin.start(`Deploying ${files.length} files${workerNote} (${(total / 1024).toFixed(0)} kB) → ${slug}`);
142
158
  let result;
159
+ let migrateResult;
143
160
  try {
144
161
  const { upload } = await withContext(apiRequest("POST", `/projects/${slug}/deploys`, { worker: workerBytes !== null }, void 0, source), { action: `deploy "${slug}"` });
145
162
  const uploads = [...files, ...workerBytes ? [{
@@ -147,6 +164,15 @@ const deployCommand = defineCommand({
147
164
  data: workerBytes
148
165
  }] : []];
149
166
  await uploadFiles(upload.grant, uploads);
167
+ if (migrations !== void 0 && migrations.length > 0) {
168
+ spin.message(`Applying schema migrations → ${slug}`);
169
+ try {
170
+ migrateResult = await withContext(migrateProject(slug, migrations), { action: `migrate "${slug}"` });
171
+ } catch (err) {
172
+ spin.error("Deploy aborted — migrations failed; the live version is unchanged.");
173
+ throw err;
174
+ }
175
+ }
150
176
  ({deploy: result} = await withContext(apiRequest("POST", `/projects/${slug}/deploys/${upload.version}/complete`, { grant: upload.grant }, void 0, source), { action: `deploy "${slug}"` }));
151
177
  } catch (err) {
152
178
  spin.error("Deploy failed.");
@@ -154,6 +180,7 @@ const deployCommand = defineCommand({
154
180
  }
155
181
  const backend = result.worker?.serviceUrl ? ` · backend ${result.worker.serviceUrl}` : "";
156
182
  spin.stop(`Live: ${projectUrl(slug)} (v${result.version})${backend}`);
183
+ if (migrateResult !== void 0) for (const line of describeMigrateResult(migrateResult)) console.log(` ${line}`);
157
184
  })
158
185
  });
159
186
  const UPLOAD_CONCURRENCY = 8;
@@ -0,0 +1,2 @@
1
+ import { t as devCommand } from "./dev-OxeYndd1.mjs";
2
+ export { devCommand };
@@ -1,5 +1,6 @@
1
- import { i as projectDirArgs, n as siteConfig, s as resolveProject } from "./site-BJjoePQS.mjs";
2
- import { a as ApiError, c as apiRequest, l as withContext, n as reportErrors } from "./ui-DThlyHgo.mjs";
1
+ import { a as projectDirArgs, c as resolveProject, i as projectDialect, l as tryReadCanopyJson, n as siteConfig } from "./site-DQnysZ3W.mjs";
2
+ import { a as ApiError, c as apiRequest, l as withContext, n as reportErrors } from "./ui-BhzMeKQl.mjs";
3
+ import { r as readMigrationFiles } from "./migrations-B0kJarjr.mjs";
3
4
  import { createRequire } from "node:module";
4
5
  import { defineCommand } from "citty";
5
6
  import { join } from "node:path";
@@ -91,14 +92,19 @@ async function devRemote(flags, slug) {
91
92
  } } });
92
93
  console.log(`\n ${slug} — remote-first dev against LIVE project data`);
93
94
  console.log(" (worried about a risky change? `canopy db snapshot` first, or work");
94
- console.log(" on a local copy instead: `canopy dev --local --pull`)\n");
95
+ console.log(" on a local copy instead: `canopy dev --local --pull`)");
96
+ if (tryReadCanopyJson(flags.dir)?.migrations) {
97
+ console.log(" migrations apply on `canopy deploy`, never from dev — use --local");
98
+ console.log(" while the schema is in flux");
99
+ }
100
+ console.log("");
95
101
  server.printUrls();
96
102
  console.log(`\n Dev token expires ${expires_at} — restart canopy dev after that.`);
97
103
  }
98
104
  async function devLocal(flags, slug) {
99
105
  let dump;
100
106
  if (flags.pull) {
101
- const { fetchProjectDump } = await import("./local-C5-GWJcx.mjs");
107
+ const { fetchProjectDump } = await import("./local-D9OVEHDg.mjs");
102
108
  console.log(`Pulling a fresh export of ${slug}…`);
103
109
  dump = await fetchProjectDump(slug);
104
110
  }
@@ -113,16 +119,22 @@ async function devLocal(flags, slug) {
113
119
  const reason = err instanceof ApiError ? err.explain().why : String(err);
114
120
  console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
115
121
  }
116
- const { createSandbox } = await import("./local-C5-GWJcx.mjs");
122
+ const { createSandbox } = await import("./local-D9OVEHDg.mjs");
117
123
  const sandbox = await createSandbox({
118
124
  slug,
119
125
  persistDir: join(flags.dir, ".canopy", "local"),
120
- email: siteConfig().email
126
+ email: siteConfig().email,
127
+ dialect: flags.dialect
121
128
  });
122
129
  if (dump !== void 0) {
123
130
  await sandbox.applyDump(dump);
124
131
  console.log(`Hydrated the sandbox (${(dump.length / 1024).toFixed(1)} kB of SQL).`);
125
132
  }
133
+ const migrationFiles = readMigrationFiles(flags.dir);
134
+ if (migrationFiles !== void 0 && migrationFiles.length > 0) {
135
+ const { applied } = await sandbox.applyMigrations(migrationFiles);
136
+ if (applied.length > 0) console.log(`Applied ${applied.length} pending migration(s): ${applied.join(", ")}`);
137
+ }
126
138
  const handleApi = async (req, res) => {
127
139
  const chunks = [];
128
140
  for await (const chunk of req) chunks.push(chunk);
@@ -170,8 +182,10 @@ async function devLocal(flags, slug) {
170
182
  apiServer.close();
171
183
  sandbox.dispose();
172
184
  });
173
- console.log(`\n ${slug} — LOCAL sandbox (PGlite + filesystem blobs in .canopy/local/)`);
185
+ const engine = flags.dialect === "sqlite" ? "SQLite" : "PGlite";
186
+ console.log(`\n ${slug} — LOCAL sandbox (${engine} + filesystem blobs in .canopy/local/)`);
174
187
  console.log(" data is yours alone: db and blob writes never reach the platform");
188
+ if (flags.dialect === void 0) console.log(" raw sql() speaks postgres here — on a D1 site? lock \"dialect\" in canopy.json");
175
189
  if (dump === void 0) console.log(" (want prod data? restart with --pull to hydrate from a fresh export)");
176
190
  console.log(" blobs are not pulled — the local blob store starts empty");
177
191
  console.log(remote ? " ai/services pass through to the platform (metered as usual)" : " ai/services answer 501 until you log in and restart");
@@ -192,7 +206,7 @@ const devCommand = defineCommand({
192
206
  local: {
193
207
  type: "boolean",
194
208
  default: false,
195
- description: "Local-data sandbox (PGlite + blobs under .canopy/local/) instead of live data"
209
+ description: "Local-data sandbox (database + blobs under .canopy/local/) instead of live data"
196
210
  },
197
211
  pull: {
198
212
  type: "boolean",
@@ -209,7 +223,8 @@ const devCommand = defineCommand({
209
223
  dir,
210
224
  port,
211
225
  local: args.local,
212
- pull: args.pull
226
+ pull: args.pull,
227
+ dialect: projectDialect(dir)
213
228
  };
214
229
  await (flags.local ? devLocal(flags, slug) : devRemote(flags, slug));
215
230
  })
package/dist/docs/ci.md CHANGED
@@ -1,10 +1,8 @@
1
1
  ---
2
2
  title: CI and deploy tokens
3
- summary: Push-to-main deploys using a project-scoped deploy token.
3
+ description: Push-to-main deploys using a project-scoped deploy token.
4
4
  ---
5
5
 
6
- # CI and deploy tokens
7
-
8
6
  Interactive `canopy login` is for humans at a terminal. For automated deploys
9
7
  (GitHub Actions, etc.) use a **deploy token**: a project-scoped credential that
10
8
  can deploy that one project and nothing else.
package/dist/docs/cli.md CHANGED
@@ -1,12 +1,11 @@
1
1
  ---
2
2
  title: CLI command reference
3
- summary: Every canopy command, its flags, and what it does.
3
+ description: Every canopy command, its flags, and what it does.
4
4
  ---
5
5
 
6
- # CLI command reference
7
-
8
6
  Run `canopy <command> --help` for the authoritative flags of any command. This
9
- is the overview.
7
+ is the one-page overview; the docs site's [CLI reference](https://docs.canopy.website/cli) section
8
+ has a full page per command.
10
9
 
11
10
  ## Auth
12
11
 
package/dist/docs/data.md CHANGED
@@ -1,10 +1,8 @@
1
1
  ---
2
2
  title: Working with project data
3
- summary: Pulling, snapshotting, and restoring your project database.
3
+ description: Pulling, snapshotting, and restoring your project database.
4
4
  ---
5
5
 
6
- # Working with project data
7
-
8
6
  Each Canopy project gets its own database and blob storage (D1 and R2 on the
9
7
  Cloudflare target; Postgres and GCS on GCP). Your app reads and writes them
10
8
  through the Canopy SDK at runtime; the CLI gives you tools to manage the data
@@ -65,7 +63,10 @@ risky change.
65
63
 
66
64
  ## Restore — destructive
67
65
 
68
- Restoring **replaces ALL project data**:
66
+ > **Warning:** Restoring **replaces ALL project data**. Take a snapshot first — a snapshot taken seconds earlier
67
+ > is your undo button.
68
+
69
+ Restore from either source:
69
70
 
70
71
  ```bash
71
72
  canopy db restore backup.sql # from a local dump
@@ -1,10 +1,8 @@
1
1
  ---
2
2
  title: Deploying a project
3
- summary: How canopy deploy builds, uploads, and makes a version live.
3
+ description: How canopy deploy builds, uploads, and makes a version live.
4
4
  ---
5
5
 
6
- # Deploying a project
7
-
8
6
  From inside a project directory:
9
7
 
10
8
  ```bash
package/dist/docs/dev.md CHANGED
@@ -1,10 +1,8 @@
1
1
  ---
2
2
  title: Local development
3
- summary: Running a project locally against live or sandboxed data.
3
+ description: Running a project locally against live or sandboxed data.
4
4
  ---
5
5
 
6
- # Local development
7
-
8
6
  From inside a project directory:
9
7
 
10
8
  ```bash
@@ -20,14 +18,14 @@ develop against realistic state.
20
18
  ## Offline sandbox
21
19
 
22
20
  To develop without touching live data — for example on a plane, or before you
23
- trust a destructive change — use a local sandbox backed by PGlite (embedded
24
- Postgres) and a filesystem blob store:
21
+ trust a destructive change — use a local sandbox backed by an embedded database
22
+ and a filesystem blob store:
25
23
 
26
24
  ```bash
27
25
  canopy dev --local
28
26
  ```
29
27
 
30
- This creates local Postgres data and blobs under `.canopy/local/` in your
28
+ This creates a local database and blobs under `.canopy/local/` in your
31
29
  project. The sandbox starts empty.
32
30
 
33
31
  To seed the sandbox from a fresh copy of production data first:
@@ -38,11 +36,15 @@ canopy dev --local --pull
38
36
 
39
37
  `.canopy/local/` is disposable — delete it to reset the sandbox.
40
38
 
41
- One caveat: the sandbox database is Postgres, so raw `canopy.db.sql()` speaks
42
- the postgres dialect there, while your deployed project speaks whatever its
43
- platform target speaks (`canopy.db.dialect()` tells you — sqlite on
44
- Cloudflare). Collections (`canopy.db.collection(...)`) behave the same in both,
45
- so this only matters for hand-written SQL — see `canopy docs data`.
39
+ By default the sandbox runs PGlite (embedded Postgres), so raw
40
+ `canopy.db.sql()` speaks the postgres dialect there, while your deployed
41
+ project speaks whatever its platform target speaks (`canopy.db.dialect()`
42
+ tells you — sqlite on Cloudflare). Collections (`canopy.db.collection(...)`)
43
+ behave the same in both, so this only matters for hand-written SQL — see
44
+ `canopy docs data`. If your project's raw SQL targets a sqlite site, lock the
45
+ dialect in `canopy.json` (`"dialect": "sqlite"`) and the sandbox runs an
46
+ embedded SQLite engine instead, so the same SQL works locally and deployed —
47
+ see [canopy dev](https://docs.canopy.website/cli/dev).
46
48
 
47
49
  ## Which mode should an agent use?
48
50
 
@@ -1,10 +1,8 @@
1
1
  ---
2
2
  title: Getting started
3
- summary: Install the CLI, sign in with OneLogin, and ship your first project.
3
+ description: Install the CLI, sign in with OneLogin, and ship your first project.
4
4
  ---
5
5
 
6
- # Getting started with Canopy
7
-
8
6
  Canopy is Monks' managed platform for vibecoded projects. You build a small web
9
7
  app, and Canopy hosts it, gives it a database, blob storage, and AI — no
10
8
  infrastructure to manage.
@@ -1,4 +1,7 @@
1
- # Infrastructure as code & standing up a Cloudflare account
1
+ ---
2
+ title: Infrastructure as code
3
+ description: Terraform for the account-level Cloudflare pieces Canopy needs.
4
+ ---
2
5
 
3
6
  Canopy's Cloudflare footprint is small on purpose: almost everything
4
7
  per-project is provisioned at runtime by canopy-api (`apps/api/src/cf.ts`,
@@ -19,8 +22,8 @@ Scope of a fresh stand-up:
19
22
 
20
23
  1. the **platform workers** — `canopy-api` and `canopy-gateway` — deployed and
21
24
  healthy (these aren't optional; nothing works without them), and
22
- 2. the **three core projects** — `dashboard`, `www`, `kitchen-sink` — re-created
23
- and deployed _on_ the platform like any other project.
25
+ 2. the **four core projects** — `dashboard`, `www`, `docs`, `kitchen-sink` —
26
+ re-created and deployed _on_ the platform like any other project.
24
27
 
25
28
  Everyone else's projects are **not** carried over — a new account starts with
26
29
  an empty control-plane registry (see [Non-goals](#non-goals)).
@@ -39,8 +42,8 @@ config file per Cloudflare site (same names as CLI profiles). Deployed with
39
42
  is local `wrangler dev` only.
40
43
 
41
44
  **`canopy deploy`** (`canopy.json` in each project): the core projects
42
- (`dashboard`, `www`, `kitchen-sink`) — ordinary platform projects that
43
- dogfood the product, not infrastructure.
45
+ (`dashboard`, `www`, `docs`, `kitchen-sink`) — ordinary platform projects
46
+ that dogfood the product, not infrastructure.
44
47
 
45
48
  **Bootstrap scripts** (`infra/scripts/`): runtime API tokens, worker secrets,
46
49
  AI Gateway BYOK keys + spend rule.
@@ -53,9 +56,9 @@ enable paid Workers products. Plus OneLogin: redirect URIs for the new host
53
56
  ## Non-goals
54
57
 
55
58
  - **No project-data migration.** Per-project D1s, R2 blobs, and deploy history
56
- stay in the old account. The new control-plane D1 is empty; the three core
57
- projects are re-created from scratch (they hold no precious data — `www` and
58
- `dashboard` are stateless, `kitchen-sink` is a demo), and everyone else's
59
+ stay in the old account. The new control-plane D1 is empty; the core
60
+ projects are re-created from scratch (they hold no precious data — `www`,
61
+ `dashboard`, and `docs` are stateless, `kitchen-sink` is a demo), and everyone else's
59
62
  projects are re-created as needed. (If that ever changes, the export/import
60
63
  primitives already exist in `apps/api/src/cf.ts` and `canopy db snapshot|restore`.)
61
64
  - **No Tofu management of Workers/secrets/per-project resources** — see the
@@ -95,10 +98,15 @@ export CLOUDFLARE_ACCOUNT_ID="<account_id>" # required when you belong to >1 a
95
98
  (You can confirm via API once the bootstrap token exists — an empty zone is
96
99
  fine.)
97
100
  4. **R2** must work (creating a probe bucket is enough to confirm). **Workers
98
- Paid** and **Workers for Platforms** are _not_ required for the current CF
99
- platform path (no dispatch namespaces; `capabilities.workers` is false).
100
- Enable them later when you actually need paid Worker limits or Phase-2
101
- per-project Workers. **No Advanced Certificate Manager** — `*.canopy.website`
101
+ Paid + Workers for Platforms** are required for per-project workers
102
+ (Phase 2 item 1, D18): once the subscriptions are active, run
103
+ `infra/scripts/create-dispatch-namespace.sh` to create the
104
+ `canopy-projects` dispatch namespace (the `dispatch_namespaces` bindings in
105
+ `apps/api/wrangler.main.jsonc` and `apps/gateway/wrangler.main.jsonc` fail
106
+ to deploy without it), and make sure `CF_API_TOKEN` carries **Workers
107
+ Scripts Write** (re-mint with `create-api-tokens.sh` if it predates this).
108
+ A site without the binding still runs — workers/schedules/secrets simply
109
+ answer 501. **No Advanced Certificate Manager** — `*.canopy.website`
102
110
  is a single-level wildcard covered by free Universal SSL. (ACM would only
103
111
  be needed if nesting as `*.canopy.<some-other-domain>`.)
104
112
 
@@ -283,8 +291,8 @@ If you already configured these for `canopy.website` before the move, skip.
283
291
 
284
292
  ### 10. Log in, grant yourself admin, then deploy the core projects
285
293
 
286
- The three core projects are ordinary platform projects that dogfood the product.
287
- Two of their slugs (`dashboard`, `www`) are in `RESERVED_SLUGS`
294
+ The four core projects are ordinary platform projects that dogfood the product.
295
+ Three of their slugs (`dashboard`, `www`, `docs`) are in `RESERVED_SLUGS`
288
296
  (`apps/api/src/routes/projects.ts`), so **only an admin can create them** —
289
297
  hence the ordering here.
290
298
 
@@ -326,6 +334,10 @@ export CANOPY_PROJECT_PROTOCOL="https"
326
334
  canopy-dev --profile main create "Canopy" --slug www --no-scaffold
327
335
  canopy-dev --profile main deploy
328
336
 
337
+ cd ../docs
338
+ canopy-dev --profile main create "Canopy Docs" --slug docs --no-scaffold
339
+ canopy-dev --profile main deploy
340
+
329
341
  cd ../../examples/kitchen-sink
330
342
  canopy-dev --profile main create "Kitchen Sink" --slug kitchen-sink --no-scaffold
331
343
  # CF target: capabilities.workers = false — strip worker for this deploy
@@ -371,7 +383,7 @@ same as the old `thanarie.dev` setup). Client-password mode is the only
371
383
  anonymous path. Smoke through a real browser session:
372
384
 
373
385
  1. `https://dashboard.canopy.website` → OneLogin login works; the project list
374
- shows all three under **Core projects**.
386
+ shows all four under **Core projects**.
375
387
  2. `https://www.canopy.website` → marketing site + `/changelog` render (after
376
388
  SSO).
377
389
  3. `https://kitchen-sink.canopy.website` → exercise `db`, `blob`, and `ai.chat`
@@ -0,0 +1,166 @@
1
+ ---
2
+ title: Migrations & Drizzle
3
+ description: Evolve your own tables with versioned SQL migrations — generated by Drizzle or written by hand — applied safely by canopy deploy.
4
+ ---
5
+
6
+ The document API (`canopy.db.collection(…)`) needs no schema and no migrations —
7
+ if it covers your project, skip this page. But developers who define their own
8
+ tables through raw `canopy.db.sql()` eventually need to _change_ those tables,
9
+ and that's what migrations are for: versioned `.sql` files in your repo,
10
+ applied exactly once each, in order, by the platform.
11
+
12
+ [Drizzle](https://orm.drizzle.team) is the recommended way to produce those
13
+ files (a typed schema plus generated SQL), but the mechanism is ORM-agnostic —
14
+ hand-written `.sql` files work identically.
15
+
16
+ ## How it works
17
+
18
+ 1. Your `canopy.json` names a migrations directory:
19
+
20
+ ```json
21
+ {
22
+ "slug": "my-app",
23
+ "dialect": "sqlite",
24
+ "migrations": "./migrations"
25
+ }
26
+ ```
27
+
28
+ 2. The directory holds ordered `.sql` files (`0000_init.sql`, `0001_add_email.sql`, …),
29
+ applied in filename order.
30
+
31
+ 3. The platform journals each applied file in the project database, so a file
32
+ applies **exactly once**, and takes an automatic **pre-migrate snapshot**
33
+ before applying anything — a bad migration is one
34
+ `canopy db restore` away.
35
+
36
+ 4. Three surfaces apply them:
37
+ - **`canopy deploy`** — the main path. Pending migrations apply after the new
38
+ bundle is staged and before it goes live, so schema and code ship
39
+ together and the new code never sees the old schema. A failure aborts the
40
+ deploy with the old version still live.
41
+ - **`canopy dev --local`** — the sandbox applies pending migrations on boot,
42
+ so local dev always matches the repo.
43
+ - **`canopy db migrate [--plan]`** — apply (or dry-run) without deploying.
44
+
45
+ Remote-first `canopy dev` never applies migrations — it points at the
46
+ production database, and schema changes belong to deploys. While a schema
47
+ change is in flux, develop against `--local` (add `--pull` for a copy of real
48
+ data).
49
+
50
+ ## The rules
51
+
52
+ - **Never edit an applied migration.** The platform hashes each file and
53
+ refuses edited history — write a new migration instead. (This is how every
54
+ mainstream migration tool works.)
55
+ - **Prefer additive changes** (new tables, new nullable columns), and do
56
+ removals in a _later_ release once nothing references the old shape. This
57
+ "expand/contract" habit makes every edge case boring.
58
+ - **Don't touch `_canopy_*` tables.** They're the platform's (the document
59
+ store, the migration journal) — the platform rejects migrations that
60
+ reference them.
61
+ - **There are no down-migrations.** Roll forward with a new migration, or
62
+ restore the automatic pre-migrate snapshot.
63
+
64
+ ## Setting up Drizzle
65
+
66
+ Pin your dialect first — a Drizzle schema is written for one SQL flavor. Check
67
+ what your project's site runs on (`await canopy.db.dialect()` or the
68
+ [data guide](https://docs.canopy.website/guides/data)) and set `"dialect"` in `canopy.json`: `"sqlite"`
69
+ on Cloudflare-backed sites, `"postgres"` on GCP-backed ones. That same field
70
+ makes `canopy dev --local` run a matching local engine.
71
+
72
+ Install the tools and describe your schema:
73
+
74
+ ```bash
75
+ npm install drizzle-orm
76
+ npm install -D drizzle-kit
77
+ ```
78
+
79
+ ```ts title="src/schema.ts"
80
+ import { sqliteTable, text } from "drizzle-orm/sqlite-core";
81
+
82
+ export const users = sqliteTable("users", {
83
+ id: text("id").primaryKey(),
84
+ email: text("email").notNull(),
85
+ });
86
+ ```
87
+
88
+ ```ts title="drizzle.config.ts"
89
+ import { defineConfig } from "drizzle-kit";
90
+
91
+ export default defineConfig({
92
+ dialect: "sqlite", // match canopy.json "dialect" ("postgresql" for postgres)
93
+ schema: "./src/schema.ts",
94
+ out: "./migrations",
95
+ });
96
+ ```
97
+
98
+ (On a postgres site, import from `drizzle-orm/pg-core` and set
99
+ `dialect: "postgresql"` — everything else is identical.)
100
+
101
+ Generate a migration whenever the schema changes:
102
+
103
+ ```bash
104
+ npx drizzle-kit generate
105
+ ```
106
+
107
+ This is fully local — it diffs your schema files and writes `.sql` into
108
+ `./migrations`. **No database connection is involved, ever.** Then
109
+ `canopy deploy` (or `canopy db migrate`) applies it.
110
+
111
+ ## Querying with Drizzle
112
+
113
+ Canopy never hands out connection strings — Drizzle talks to your data through
114
+ the handles Canopy already provides.
115
+
116
+ **In a project worker on a Cloudflare-backed site**, `env.DB` is a real D1
117
+ handle:
118
+
119
+ ```ts title="worker/index.ts"
120
+ import { drizzle } from "drizzle-orm/d1";
121
+ import { users } from "../src/schema.ts";
122
+
123
+ export default {
124
+ async fetch(request, env) {
125
+ const db = drizzle(env.DB);
126
+ const all = await db.select().from(users);
127
+ return Response.json(all);
128
+ },
129
+ };
130
+ ```
131
+
132
+ **In the browser (or anywhere the SDK runs)**, wrap `canopy.db.sql()` with
133
+ Drizzle's proxy driver:
134
+
135
+ ```ts title="src/db.ts"
136
+ import { drizzle } from "drizzle-orm/sqlite-proxy";
137
+ import { canopy } from "./canopy.ts";
138
+
139
+ export const db = drizzle(async (sql, params, method) => {
140
+ const rows = await canopy.db.sql<Record<string, unknown>>(sql, params);
141
+ const values = rows.map((row) => Object.values(row));
142
+ return { rows: method === "get" ? (values[0] ?? []) : values };
143
+ });
144
+ ```
145
+
146
+ (On a postgres site use `drizzle-orm/pg-proxy` the same way; in a GCP project
147
+ worker, `env.DB.query(text, params)` fits `pg-proxy` directly.)
148
+
149
+ ## What deliberately doesn't work
150
+
151
+ - **`drizzle-kit push` / `migrate` / `studio` / `introspect`** — these need a
152
+ database connection, which Canopy never exposes. Generating is local;
153
+ applying is Canopy's job. Don't invent a `DATABASE_URL` — there isn't one.
154
+ For browsing data, use the dashboard's data browser or `canopy db pull`.
155
+ - **Drizzle's runtime `migrate()`** — the platform journals migrations in its
156
+ own `_canopy_migrations` table; running Drizzle's applier would double-apply.
157
+ - **Cross-dialect portability.** A project with a Drizzle schema is pinned to
158
+ its dialect. The document API remains the portable path.
159
+
160
+ ## Coexistence
161
+
162
+ Your Drizzle tables live alongside the document store in the same project
163
+ database. Snapshots, restore, `canopy db pull`, and the graduation export all
164
+ operate on the whole database, so they cover your tables automatically — and
165
+ since your schema history is in the repo and the journal travels with the
166
+ data, a graduated project takes its full migration story with it.