@mediamonks/canopy-cli 0.7.21 → 0.7.23

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 (30) hide show
  1. package/dist/dev-BFPlhkzp.mjs +2 -0
  2. package/dist/{dev-CNqx_53I.mjs → dev-XULukupE.mjs} +3 -3
  3. package/dist/{dev-worker-Bij9OUMk.mjs → dev-worker-BDMjExps.mjs} +35 -1
  4. package/dist/docs/cli-login.md +2 -0
  5. package/dist/docs/cli-logout.md +9 -6
  6. package/dist/docs/index.json +27 -7
  7. package/dist/docs/platform-ai-usage.md +36 -10
  8. package/dist/docs/platform-assistant.md +50 -0
  9. package/dist/docs/platform-client-access.md +5 -2
  10. package/dist/docs/platform-feedback.md +3 -0
  11. package/dist/docs/platform-members-and-access.md +29 -6
  12. package/dist/docs/{notifications.md → platform-notifications.md} +6 -2
  13. package/dist/docs/platform-security-headers.md +53 -0
  14. package/dist/docs/platform-slack.md +87 -0
  15. package/dist/docs/sdk-ai.md +102 -11
  16. package/dist/docs/sdk-files.md +6 -0
  17. package/dist/docs/sdk-slack.md +132 -0
  18. package/dist/{docs-fiC6q9Ce.mjs → docs-BDPL0TI3.mjs} +1 -1
  19. package/dist/{feedback-CAwUHwWa.mjs → feedback-B3pnUPLf.mjs} +8 -3
  20. package/dist/index.mjs +10 -10
  21. package/dist/{local-CsSP4Vpt.mjs → local-BjsuI1IB.mjs} +37 -8
  22. package/dist/login-By4y5O5-.mjs +336 -0
  23. package/dist/{package-C_DLlB96.mjs → package-C5rZwlw8.mjs} +1 -1
  24. package/dist/{skill-CmgMU2Iq.mjs → skill-CgToW_Ac.mjs} +1 -1
  25. package/dist/{src-BKKAEkbH.mjs → src-BPKfY9IN.mjs} +53 -1
  26. package/dist/{upgrade-D1j0WV4l.mjs → upgrade-Bl_NJ_m5.mjs} +1 -1
  27. package/dist/{usage-CRpVLLi6.mjs → usage-C1-cvS2-.mjs} +13 -1
  28. package/package.json +3 -3
  29. package/dist/dev-D7v7WK9O.mjs +0 -2
  30. package/dist/login-DRYaJcfT.mjs +0 -285
@@ -4,8 +4,8 @@ description: Metered LLM access through the project's budget-capped gateway key
4
4
  ---
5
5
 
6
6
  `canopy.ai` gives your project LLM access without any API keys in your code.
7
- Every request authenticates as the project and is metered against a hard
8
- per-project daily budget — spend, limits, and how prompts are handled by the
7
+ Every request authenticates as the project and is metered against hard
8
+ per-project budgets — spend, limits, and how prompts are handled by the
9
9
  model providers are covered in [AI access & usage](https://docs.canopy.website/platform/ai-usage).
10
10
 
11
11
  ## ai.chat(options)
@@ -32,16 +32,65 @@ res.usage; // { inputTokens: number, outputTokens: number }
32
32
  **`ChatOptions`:**
33
33
 
34
34
  - `messages` — required, non-empty array of `{ role, content }`. `role` is
35
- `"system" | "user" | "assistant"`; `content` is a non-empty string.
35
+ `"system" | "user" | "assistant"`; `content` is a non-empty string, or an
36
+ array of content parts to attach documents and images (below).
36
37
  - `model` — optional `"{provider}/{model}"` id. Omit it and the platform's
37
38
  default model answers. Ask for a model whose provider isn't enabled and the
38
39
  call fails with a message listing what _is_ available; `ai.endpoint()`
39
40
  returns the full curated list.
40
- - `temperature` — optional number between 0 and 2.
41
- - `maxTokens` — optional integer, at least 1.
41
+ - `temperature` — optional number between 0 and 2. Reasoning models (the
42
+ GPT-5 family) only accept their default temperature, so the platform drops
43
+ this setting for them rather than failing the call — it has effect on
44
+ Claude models.
45
+ - `maxTokens` — optional integer, at least 1. On reasoning models the budget
46
+ also covers the model's internal reasoning, so a very small value can
47
+ return an empty answer — leave headroom.
48
+ - `effort` — optional `"low" | "medium" | "high"`: how hard a reasoning model
49
+ should think. Lowered onto each provider's own dial; models without an
50
+ effort dial (Claude Haiku, for one) simply ignore it. Lower effort is
51
+ faster and cheaper, higher is more thorough.
52
+
53
+ **`ChatResponse`:** `{ message, model, usage: { inputTokens, outputTokens } }`.
54
+ `message.role` is `"assistant"` and `message.content` is always a plain
55
+ string, whatever went in.
56
+
57
+ ## Attaching documents and images
58
+
59
+ A message's `content` can be an array of parts instead of a string. The
60
+ easiest way to send a file is to upload it to [blob storage](https://docs.canopy.website/sdk/blob) once
61
+ and reference it by key — the platform fetches it server-side and picks the
62
+ best way to hand it to the model:
42
63
 
43
- **`ChatResponse`:** `{ message: ChatMessage, model: string, usage: { inputTokens, outputTokens } }`.
44
- `message.role` is `"assistant"`.
64
+ ```ts
65
+ await canopy.blob.put("briefs/q3.pdf", file); // once
66
+
67
+ const res = await canopy.ai.chat({
68
+ messages: [
69
+ {
70
+ role: "user",
71
+ content: [
72
+ { type: "text", text: "Summarize this briefing." },
73
+ { type: "blob", key: "briefs/q3.pdf" },
74
+ ],
75
+ },
76
+ ],
77
+ });
78
+ ```
79
+
80
+ The part types:
81
+
82
+ - `{ type: "text", text }` — plain text.
83
+ - `{ type: "blob", key, mediaType? }` — a file already in the project's blob
84
+ storage, by key. `mediaType` overrides the stored content type if needed.
85
+ **Prefer this** for anything you'd otherwise base64 by hand.
86
+ - `{ type: "file", data, mediaType, filename? }` — an inline file;
87
+ `data` is base64 (no `data:` prefix), `mediaType` e.g. `"application/pdf"`.
88
+ - `{ type: "image", data, mediaType }` — an inline image, same encoding.
89
+
90
+ Pick a model that can read what you attach — document and image understanding
91
+ varies per model. The whole request (inline base64 and referenced blobs
92
+ together) must stay under the 8 MB request limit — see
93
+ [AI access & usage](https://docs.canopy.website/platform/ai-usage).
45
94
 
46
95
  ## ai.chatStream(options)
47
96
 
@@ -70,9 +119,10 @@ let an ecosystem client handle streaming for you.
70
119
  Returns the project's OpenAI-compatible endpoint info:
71
120
 
72
121
  ```ts
73
- const { baseUrl, models } = await canopy.ai.endpoint();
122
+ const { baseUrl, models, native } = await canopy.ai.endpoint();
74
123
  // baseUrl — an OpenAI-compatible base URL
75
124
  // models — the curated model list, "{provider}/{model}" ids; models[0] is the default
125
+ // native — provider-native base URLs: { anthropic, openai }
76
126
  ```
77
127
 
78
128
  Point any OpenAI-compatible client — the Vercel AI SDK, an OpenAI SDK, a
@@ -87,15 +137,56 @@ const openai = createOpenAI({ baseURL: baseUrl, apiKey: "unused" });
87
137
  const model = openai(models[0]);
88
138
  ```
89
139
 
140
+ ## Provider-native endpoints
141
+
142
+ The compat endpoint speaks OpenAI's chat-completions dialect to every model —
143
+ portable, but the lowest common denominator. When you want a provider's own
144
+ protocol — Anthropic prompt caching, PDFs with citations, or OpenAI's
145
+ Responses API — use the native base URLs from `endpoint()`. Requests pass
146
+ through to the provider unchanged; auth, the model allowlist, the daily
147
+ budget, and metering all still apply.
148
+
149
+ ```ts
150
+ import Anthropic from "@anthropic-ai/sdk";
151
+
152
+ const { native } = await canopy.ai.endpoint();
153
+ const anthropic = new Anthropic({ baseURL: native.anthropic, apiKey: "unused" });
154
+ const msg = await anthropic.messages.create({
155
+ model: "claude-haiku-4-5", // bare or "anthropic/…" — must be on the curated list
156
+ max_tokens: 1024,
157
+ messages: [{ role: "user", content: "Hello" }],
158
+ });
159
+ ```
160
+
161
+ A base URL is just a prefix — the client library appends the endpoint path
162
+ itself, based on which method you call. Both values point at the platform,
163
+ which forwards each path to the provider:
164
+
165
+ - `native.anthropic` — hand it to `@anthropic-ai/sdk` or `@ai-sdk/anthropic`.
166
+ Calling `anthropic.messages.create(...)` makes the SDK request
167
+ `POST {native.anthropic}/v1/messages` — the Messages API.
168
+ - `native.openai` — hand it to the `openai` package or `@ai-sdk/openai`. It
169
+ already ends in `/v1`, matching OpenAI's own base URL convention; the
170
+ method you call picks the endpoint: `client.responses.create(...)` requests
171
+ `POST {native.openai}/responses` (the Responses API) and
172
+ `client.chat.completions.create(...)` requests
173
+ `POST {native.openai}/chat/completions`. Both are supported.
174
+
175
+ Pick the surface by what you need: `ai.chat()` for plain text (and simple
176
+ attachments), the compat endpoint for portable multi-model apps and ecosystem
177
+ tooling, a native endpoint for provider-specific capabilities. Moving between
178
+ them never changes auth, budgets, or what the Usage tab shows.
179
+
90
180
  ## Errors
91
181
 
92
182
  Failed calls throw `CanopyError` with the HTTP `status` and the response body
93
183
  as the `message`. Two worth handling:
94
184
 
95
185
  - **Budget exhausted** — status `429`, body contains
96
- `"ai_budget_exceeded"`. The project hit its daily AI cap; AI calls resume
97
- the next day (or after a maintainer raises the budget). Everything else in
98
- your app keeps working.
186
+ `"ai_budget_exceeded"` and names which budget tripped: the daily limit
187
+ (calls resume the next day) or the monthly cap (calls resume next month —
188
+ or after a maintainer raises it). Everything else in your app keeps
189
+ working.
99
190
  - **Invalid request** — status `400`, body contains `"invalid_chat"` and a
100
191
  detail explaining which option was rejected.
101
192
 
@@ -62,6 +62,12 @@ lifetime that isn't a whole number of seconds above zero is rejected. Keep the
62
62
  window as short as the job needs — anyone holding the URL can download the
63
63
  file until it expires.
64
64
 
65
+ What a URL does in the browser depends on the file's content type. Images,
66
+ video, audio, fonts, plain text, CSV, JSON and PDF display; SVG displays in
67
+ an `<img>` but is sandboxed when opened directly; anything a browser would
68
+ treat as a page — HTML, XML, scripts, unknown types — downloads instead of
69
+ rendering. See [Browser security](https://docs.canopy.website/platform/security-headers).
70
+
65
71
  ## Listing and deleting
66
72
 
67
73
  ```ts
@@ -0,0 +1,132 @@
1
+ ---
2
+ title: Slack
3
+ description: Handle your bot's events and slash commands in your worker, and post to Slack as the bot — no tokens in project code.
4
+ ---
5
+
6
+ Once your project has a Slack app ([set up on the Services
7
+ tab](https://docs.canopy.website/platform/slack)), the integration is two halves: inbound events arrive
8
+ at a `slack()` export in your worker, and outbound calls go through
9
+ `canopy.slack`. The bot token and signing secret stay with the platform —
10
+ Slack's signatures are verified before anything reaches your code.
11
+
12
+ ## The `slack()` handler
13
+
14
+ Export it next to `fetch` and `scheduled`:
15
+
16
+ ```ts
17
+ export default {
18
+ fetch(request, env) {
19
+ /* … */
20
+ },
21
+
22
+ async slack(event, env) {
23
+ if (event.kind === "command") {
24
+ return { response_type: "in_channel", text: `Running ${event.payload.command}…` };
25
+ }
26
+ // kind === "event": an @-mention, a DM, …
27
+ },
28
+ };
29
+ ```
30
+
31
+ **The event object:**
32
+
33
+ - `kind` — `"event"` (Events API: `app_mention`, `message`, …) or
34
+ `"command"` (a slash command).
35
+ - `payload` — Slack's payload, verbatim. For events that's the full
36
+ callback (the interesting part is `payload.event`); for commands it's the
37
+ command form fields (`command`, `text`, `channel_id`, `user_id`, …).
38
+ - `responseUrl` — commands only: where a late reply can still be sent (see
39
+ below).
40
+ - `user` — who triggered this, as Slack tells it: `{ id, name? }`. Present
41
+ whenever Slack names a person.
42
+ - `teamId`, `appId` — which workspace and app this came from.
43
+ - `retry` — set when Slack is redelivering. Delivery is at-least-once, so
44
+ handlers that create things should tolerate seeing an event twice.
45
+
46
+ ## Who is talking to the bot
47
+
48
+ Two identities, at two trust levels:
49
+
50
+ - **`env.USER`** is set when the bot lives in the Monks workspace and the
51
+ sender has a Canopy account: the same `{ sub, email, name }` your fetch
52
+ routes see for a signed-in visitor, so user-specific logic works the same
53
+ way. The mapping rides the corporate directory — Slack accounts and Canopy
54
+ sign-ins are the same people there.
55
+ - In any other workspace — a client's, say — `env.USER` is `null` and
56
+ `event.user` is all you get: Slack's own word for who typed, nothing
57
+ Canopy has verified. Fine for personalising a reply; don't hang
58
+ authorization on it.
59
+
60
+ Either way, payload text is written by whoever typed it in Slack — treat it
61
+ as untrusted input. And SDK calls from the handler still run as the project
62
+ itself (`canopy.auth.user()` reports kind `worker`), regardless of
63
+ `env.USER`.
64
+
65
+ ## Answering a slash command
66
+
67
+ Slack gives a command three seconds. Return quickly and Canopy relays your
68
+ answer as the command's response:
69
+
70
+ ```ts
71
+ async slack(event, env) {
72
+ if (event.kind === "command") {
73
+ return { response_type: "ephemeral", text: "Done." };
74
+ }
75
+ }
76
+ ```
77
+
78
+ Return a Slack message object, or a plain string (sent as ephemeral text).
79
+ Return nothing and the command completes silently.
80
+
81
+ If your handler needs longer — it queries something slow, calls a model —
82
+ the command is acknowledged for you, and you answer afterwards through the
83
+ `responseUrl`, which stays valid for 30 minutes:
84
+
85
+ ```ts
86
+ if (event.kind === "command") {
87
+ const answer = await somethingSlow();
88
+ await canopy.slack.respond(event.responseUrl!, {
89
+ response_type: "in_channel",
90
+ text: answer,
91
+ });
92
+ }
93
+ ```
94
+
95
+ Events (`kind: "event"`) have no response channel — react by calling out,
96
+ usually `chat.postMessage` back into the conversation the event names.
97
+
98
+ ## slack.api(method, args)
99
+
100
+ Any [Slack Web API method](https://docs.slack.dev/reference/methods), called
101
+ as your project's bot:
102
+
103
+ ```ts
104
+ const result = await canopy.slack.api("chat.postMessage", {
105
+ channel: "C0123456789",
106
+ text: "Deploy finished.",
107
+ });
108
+ if (!result.ok) console.warn("slack said no:", result.error);
109
+ ```
110
+
111
+ The response is Slack's own, `ok: false` included — check it for
112
+ method-level outcomes like `channel_not_found` or `not_in_channel` (the bot
113
+ can only post to channels it's been invited to, and to people's DMs).
114
+ Workspace-management method families (`admin.*`, `tooling.*`, `apps.*`,
115
+ `oauth.*`) are refused before Slack is ever contacted; everything else is
116
+ bounded by the permissions the app was installed with.
117
+
118
+ Backend and `canopy dev` only — pages can't call it, so a visitor can never
119
+ act as your bot.
120
+
121
+ ## slack.respond(responseUrl, message)
122
+
123
+ The late reply to a slash command, described above. `message` is a Slack
124
+ message object or a plain string.
125
+
126
+ ## Errors
127
+
128
+ Refusals arrive as thrown errors with stable codes: `slack_not_configured`
129
+ (no app on this project), `slack_not_installed` (created but not installed
130
+ to a workspace), `slack_disabled` (switched off on the Services tab), and
131
+ `slack_method_not_allowed`. A network-level failure reaching Slack is
132
+ `slack_api_error`.
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-C_DLlB96.mjs";
1
+ import { t as version } from "./package-C5rZwlw8.mjs";
2
2
  import { n as siteConfig } from "./site-BFaWfSVu.mjs";
3
3
  import { i as latestKnownVersion, r as compareSemver } from "./version-check-CcsA1QjI.mjs";
4
4
  import { r as reportErrors } from "./ui--atbZ-TM.mjs";
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-C_DLlB96.mjs";
1
+ import { t as version } from "./package-C5rZwlw8.mjs";
2
2
  import { a as projectDirArgs, c as resolveProject, d as tryResolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-BFaWfSVu.mjs";
3
3
  import { c as apiDownload, l as apiRequest, r as reportErrors, t as formatDateTime, u as apiUpload } from "./ui--atbZ-TM.mjs";
4
4
  import { defineCommand } from "citty";
@@ -89,6 +89,10 @@ function firstLine(body) {
89
89
  const line = body.split("\n")[0] ?? "";
90
90
  return line.length > 72 ? `${line.slice(0, 71)}…` : line;
91
91
  }
92
+ /** List label: the AI-written title when there is one, else the first line. */
93
+ function itemLabel(item) {
94
+ return firstLine(item.title || item.body);
95
+ }
92
96
  function printItems(items) {
93
97
  if (items.length === 0) {
94
98
  console.log("No matching feedback items.");
@@ -97,7 +101,7 @@ function printItems(items) {
97
101
  for (const item of items) {
98
102
  const comments = item.comment_count ? ` 💬${item.comment_count}` : "";
99
103
  const shots = item.attachment_count ? ` 📎${item.attachment_count}` : "";
100
- console.log(`${item.id} ${item.status} ${item.kind} ${firstLine(item.body)}${comments}${shots}`);
104
+ console.log(`${item.id} ${item.status} ${item.kind} ${itemLabel(item)}${comments}${shots}`);
101
105
  }
102
106
  }
103
107
  const listCommand = defineCommand({
@@ -153,6 +157,7 @@ const showCommand = defineCommand({
153
157
  return;
154
158
  }
155
159
  const { item, comments } = detail;
160
+ if (item.title) console.log(item.title);
156
161
  console.log(`${item.kind} · ${item.status} · via ${item.source} · ${formatDateTime(item.created_at)}`);
157
162
  console.log(`reported by ${item.reporter_name || item.reporter_email || "unknown"}` + (item.deployment_id ? ` on ${item.deployment_id}` : "") + (item.channel === "staging" ? " (staging)" : ""));
158
163
  console.log(`\n${item.body}\n`);
@@ -276,7 +281,7 @@ const mineCommand = defineCommand({
276
281
  console.log("You haven't filed any reports.");
277
282
  return;
278
283
  }
279
- for (const item of items) console.log(`${item.id} ${item.status} ${item.kind} [${item.project_slug}] ${firstLine(item.body)}`);
284
+ for (const item of items) console.log(`${item.id} ${item.status} ${item.kind} [${item.project_slug}] ${itemLabel(item)}`);
280
285
  })
281
286
  });
282
287
  const reportCommand = defineCommand({
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as version } from "./package-C_DLlB96.mjs";
2
+ import { t as version } from "./package-C5rZwlw8.mjs";
3
3
  import { r as siteHeaderLine, t as setInvocationProfile } from "./site-BFaWfSVu.mjs";
4
4
  import { a as notifyOfUpdate, o as runRefresh } from "./version-check-CcsA1QjI.mjs";
5
5
  import { defineCommand, runMain } from "citty";
@@ -60,14 +60,14 @@ const main = defineCommand({
60
60
  description: "canopy — create and deploy vibecoded projects"
61
61
  },
62
62
  subCommands: {
63
- login: () => import("./login-DRYaJcfT.mjs").then((m) => m.loginCommand),
64
- whoami: () => import("./login-DRYaJcfT.mjs").then((m) => m.whoamiCommand),
65
- logout: () => import("./login-DRYaJcfT.mjs").then((m) => m.logoutCommand),
63
+ login: () => import("./login-By4y5O5-.mjs").then((m) => m.loginCommand),
64
+ whoami: () => import("./login-By4y5O5-.mjs").then((m) => m.whoamiCommand),
65
+ logout: () => import("./login-By4y5O5-.mjs").then((m) => m.logoutCommand),
66
66
  create: () => import("./projects-1r5387ui.mjs").then((m) => m.createCommand),
67
67
  init: () => import("./projects-1r5387ui.mjs").then((m) => m.initCommand),
68
68
  projects: () => import("./projects-1r5387ui.mjs").then((m) => m.projectsCommand),
69
69
  delete: () => import("./projects-1r5387ui.mjs").then((m) => m.deleteCommand),
70
- dev: () => import("./dev-D7v7WK9O.mjs").then((m) => m.devCommand),
70
+ dev: () => import("./dev-BFPlhkzp.mjs").then((m) => m.devCommand),
71
71
  deploy: () => import("./deploy-DCggsLD7.mjs").then((m) => m.deployCommand),
72
72
  promote: () => import("./deploy-DCggsLD7.mjs").then((m) => m.promoteCommand),
73
73
  staging: () => import("./staging-CdpV2YHB.mjs").then((m) => m.stagingCommand),
@@ -76,13 +76,13 @@ const main = defineCommand({
76
76
  token: () => import("./token-C8Bmzwfl.mjs").then((m) => m.tokenCommand),
77
77
  egress: () => import("./egress-BhbQfjjV.mjs").then((m) => m.egressCommand),
78
78
  schedule: () => import("./schedule-DK_Cv6Xf.mjs").then((m) => m.scheduleCommand),
79
- feedback: () => import("./feedback-CAwUHwWa.mjs").then((m) => m.feedbackCommand),
79
+ feedback: () => import("./feedback-B3pnUPLf.mjs").then((m) => m.feedbackCommand),
80
80
  db: () => import("./db-TaSLpQZ5.mjs").then((m) => m.dbCommand),
81
- usage: () => import("./usage-CRpVLLi6.mjs").then((m) => m.usageCommand),
81
+ usage: () => import("./usage-C1-cvS2-.mjs").then((m) => m.usageCommand),
82
82
  open: () => import("./open-D7f1wNn3.mjs").then((m) => m.openCommand),
83
- docs: () => import("./docs-fiC6q9Ce.mjs").then((m) => m.docsCommand),
84
- skill: () => import("./skill-CmgMU2Iq.mjs").then((m) => m.skillCommand),
85
- upgrade: () => import("./upgrade-D1j0WV4l.mjs").then((m) => m.upgradeCommand),
83
+ docs: () => import("./docs-BDPL0TI3.mjs").then((m) => m.docsCommand),
84
+ skill: () => import("./skill-CgToW_Ac.mjs").then((m) => m.skillCommand),
85
+ upgrade: () => import("./upgrade-Bl_NJ_m5.mjs").then((m) => m.upgradeCommand),
86
86
  logs: () => import("./logs-Beqz--Df.mjs").then((m) => m.logsCommand)
87
87
  }
88
88
  });
@@ -1,6 +1,6 @@
1
1
  import { s as apiDataRequest } from "./ui--atbZ-TM.mjs";
2
- import { n as META_COLUMNS, r as blobRouter, t as dbRouter } from "./src-BKKAEkbH.mjs";
3
- import { n as rewriteApiPath } from "./dev-CNqx_53I.mjs";
2
+ import { n as META_COLUMNS, r as blobRouter, t as dbRouter } from "./src-BPKfY9IN.mjs";
3
+ import { n as rewriteApiPath } from "./dev-XULukupE.mjs";
4
4
  import { createRequire } from "node:module";
5
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join, relative, sep } from "node:path";
@@ -15,19 +15,48 @@ import { Hono } from "hono";
15
15
  * - `fts: false` — search() is an ILIKE substring fallback: different
16
16
  * matches, no ranking (results order by created_at). Surfaced to clients
17
17
  * as `capabilities.fts` rather than papered over.
18
- * - patch() is a shallow jsonb merge (`||`): top-level keys replace rather
19
- * than deep-merge, and a null value stores null instead of removing the
20
- * key.
18
+ * - patch() has the SAME RFC 7386 semantics as SQLite's json_patch (nested
19
+ * objects merge, null removes a key), but Postgres has no built-in for it,
20
+ * so it goes through `public._canopy_merge_patch(jsonb, jsonb)` — a small
21
+ * recursive plpgsql function declared in the DDL below. It lives in
22
+ * `public` (not the per-project schema) so one CREATE OR REPLACE covers
23
+ * every project schema in the database, existing ones included.
21
24
  */
22
25
  const DOC_COLUMNS$1 = "id, data, created_at, updated_at";
23
- const DOC_STORE_DDL_PG = [`CREATE TABLE IF NOT EXISTS _canopy_docs (
26
+ const DOC_STORE_DDL_PG = [
27
+ `CREATE TABLE IF NOT EXISTS _canopy_docs (
24
28
  collection TEXT NOT NULL,
25
29
  id TEXT NOT NULL,
26
30
  data TEXT NOT NULL,
27
31
  created_at TEXT NOT NULL,
28
32
  updated_at TEXT NOT NULL,
29
33
  PRIMARY KEY (collection, id)
30
- )`, `CREATE INDEX IF NOT EXISTS _canopy_docs_created ON _canopy_docs (collection, created_at)`];
34
+ )`,
35
+ `CREATE INDEX IF NOT EXISTS _canopy_docs_created ON _canopy_docs (collection, created_at)`,
36
+ `CREATE OR REPLACE FUNCTION public._canopy_merge_patch(target jsonb, patch jsonb)
37
+ RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $fn$
38
+ BEGIN
39
+ IF jsonb_typeof(patch) IS DISTINCT FROM 'object' THEN
40
+ RETURN patch;
41
+ END IF;
42
+ IF jsonb_typeof(target) IS DISTINCT FROM 'object' THEN
43
+ target := '{}'::jsonb;
44
+ END IF;
45
+ RETURN COALESCE((
46
+ SELECT jsonb_object_agg(m.key, m.value) FROM (
47
+ SELECT t.key, t.value FROM jsonb_each(target) t WHERE NOT (patch ? t.key)
48
+ UNION ALL
49
+ SELECT p.key,
50
+ CASE WHEN jsonb_typeof(p.value) = 'object'
51
+ THEN public._canopy_merge_patch(target -> p.key, p.value)
52
+ ELSE p.value END
53
+ FROM jsonb_each(patch) p
54
+ WHERE jsonb_typeof(p.value) <> 'null'
55
+ ) m
56
+ ), '{}'::jsonb);
57
+ END
58
+ $fn$`
59
+ ];
31
60
  /** `data`'s JSON value at a `$.a.b` path param, as jsonb. */
32
61
  const jsonAt = (column, pathParam) => `(${column}::jsonb #> string_to_array(trim(leading '$.' from ${pathParam}::text), '.'))`;
33
62
  /**
@@ -124,7 +153,7 @@ const postgresDialect = {
124
153
  },
125
154
  patch(collection, id, patchJson, now) {
126
155
  return {
127
- sql: `UPDATE _canopy_docs SET data = (data::jsonb || $1::jsonb)::text, updated_at = $2
156
+ sql: `UPDATE _canopy_docs SET data = public._canopy_merge_patch(data::jsonb, $1::jsonb)::text, updated_at = $2
128
157
  WHERE collection = $3 AND id = $4
129
158
  RETURNING ${DOC_COLUMNS$1}`,
130
159
  params: [