@zackbart/connecta 0.28.0 → 0.29.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 (320) hide show
  1. package/CHANGELOG.md +961 -5
  2. package/README.md +62 -39
  3. package/bin/connecta.mjs +110 -139
  4. package/bin/meta-tool-names.d.mts +2 -0
  5. package/bin/meta-tool-names.mjs +9 -0
  6. package/bin/version.mjs +3 -0
  7. package/dist/access-tokens.js +53 -63
  8. package/dist/activity-request.js +8 -0
  9. package/dist/activity.d.ts +76 -42
  10. package/dist/activity.js +110 -42
  11. package/dist/auth/clerk-sdk/client.d.ts +65 -0
  12. package/dist/auth/clerk-sdk/client.js +5 -0
  13. package/dist/auth/clerk-sdk/generated.js +1845 -0
  14. package/dist/auth/clerk-transport.js +19 -0
  15. package/dist/auth/clerk.d.ts +17 -7
  16. package/dist/auth/clerk.js +189 -107
  17. package/dist/auth/cloudflare-access.js +25 -26
  18. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  19. package/dist/auth/downstream-client-metadata.js +133 -0
  20. package/dist/auth/downstream-oauth.js +1632 -1509
  21. package/dist/auth/downstream-revocation.js +106 -0
  22. package/dist/auth/oauth-token-response.js +231 -0
  23. package/dist/auth/oauth-v2-migration.js +116 -0
  24. package/dist/auth/static-oauth.js +526 -0
  25. package/dist/auth-elicitation.js +283 -0
  26. package/dist/branding.d.ts +8 -16
  27. package/dist/branding.js +26 -59
  28. package/dist/byte-read-response.js +39 -0
  29. package/dist/call-admission.js +12 -16
  30. package/dist/catalog-cache.js +739 -0
  31. package/dist/catalog-drift.d.ts +50 -15
  32. package/dist/catalog-drift.js +365 -128
  33. package/dist/catalog-fingerprint.js +2 -4
  34. package/dist/catalog-freshness.js +13 -0
  35. package/dist/catalog-limits.js +2 -2
  36. package/dist/catalog-service.js +368 -224
  37. package/dist/catalog.js +43 -84
  38. package/dist/concurrency.js +3 -4
  39. package/dist/config-defaults.d.ts +39 -0
  40. package/dist/config-defaults.js +46 -0
  41. package/dist/config-schema.d.ts +181 -0
  42. package/dist/config-schema.js +435 -0
  43. package/dist/config-value-sources.js +102 -0
  44. package/dist/config.d.ts +631 -0
  45. package/dist/config.js +503 -0
  46. package/dist/connector-access.d.ts +1 -0
  47. package/dist/connector-access.js +13 -15
  48. package/dist/connector-caller.d.ts +8 -0
  49. package/dist/connector-caller.js +15 -6
  50. package/dist/connector-scope.js +1 -1
  51. package/dist/connectors/api-connector.d.ts +220 -0
  52. package/dist/connectors/api-connector.js +214 -0
  53. package/dist/connectors/api.d.ts +8 -71
  54. package/dist/connectors/api.js +13 -99
  55. package/dist/connectors/guarded-fetch.d.ts +144 -0
  56. package/dist/connectors/guarded-fetch.js +108 -25
  57. package/dist/connectors/negotiation-cache.js +46 -0
  58. package/dist/connectors/option-shapes.js +27 -0
  59. package/dist/connectors/remote-mcp.d.ts +30 -4
  60. package/dist/connectors/remote-mcp.js +1780 -351
  61. package/dist/connectors/resource-uri.js +335 -0
  62. package/dist/credential-contract.d.ts +10 -0
  63. package/dist/credential-rules.js +1 -3
  64. package/dist/credential-url.js +108 -0
  65. package/dist/credentials.d.ts +6 -1
  66. package/dist/credentials.js +27 -18
  67. package/dist/d1.d.ts +130 -0
  68. package/dist/d1.js +290 -0
  69. package/dist/describe-config.d.ts +165 -0
  70. package/dist/describe-config.js +315 -0
  71. package/dist/described.js +106 -0
  72. package/dist/downstream-input-context.js +119 -0
  73. package/dist/downstream-input.js +323 -0
  74. package/dist/errors.d.ts +97 -20
  75. package/dist/errors.js +158 -80
  76. package/dist/execute.js +453 -305
  77. package/dist/executor-admission.d.ts +1 -0
  78. package/dist/executor-admission.js +11 -15
  79. package/dist/executor-contract.d.ts +20 -0
  80. package/dist/executor-contract.js +48 -0
  81. package/dist/executor-result.js +24 -9
  82. package/dist/executors/quickjs-child.js +3 -4
  83. package/dist/executors/quickjs-runtime.js +147 -59
  84. package/dist/executors/quickjs.js +29 -35
  85. package/dist/favicon.js +1 -2
  86. package/dist/fix-prompt.js +1 -3
  87. package/dist/guest-runtime.js +188 -0
  88. package/dist/guest-types.d.ts +176 -0
  89. package/dist/guest-types.js +1 -0
  90. package/dist/html-security.js +24 -0
  91. package/dist/identity.js +1 -3
  92. package/dist/inbound-credential.js +52 -0
  93. package/dist/index.d.ts +25 -257
  94. package/dist/index.js +65 -478
  95. package/dist/invocation-auth.js +69 -0
  96. package/dist/invocation.js +230 -73
  97. package/dist/json-schema.d.ts +1 -1
  98. package/dist/mcp-client-context.js +35 -0
  99. package/dist/mcp-result.js +11 -7
  100. package/dist/meta-output.js +76 -0
  101. package/dist/meta-tool-names.d.ts +2 -0
  102. package/dist/meta-tool-names.js +3 -0
  103. package/dist/meta-tools.js +285 -209
  104. package/dist/module-contracts.d.ts +8 -23
  105. package/dist/node.d.ts +0 -2
  106. package/dist/node.js +2 -2
  107. package/dist/oauth-callback-outcome.js +1 -3
  108. package/dist/oauth-handoff.js +117 -0
  109. package/dist/oauth-partition.js +17 -0
  110. package/dist/oauth-sealing.js +20 -0
  111. package/dist/operator-record.d.ts +137 -0
  112. package/dist/operator-record.js +420 -0
  113. package/dist/operator-ui/assets.js +31 -0
  114. package/dist/operator-ui/contract.d.ts +72 -0
  115. package/dist/operator-ui/contract.js +1 -0
  116. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  117. package/dist/operator-ui/generated.js +5 -4
  118. package/dist/operator-ui/model.d.ts +12 -87
  119. package/dist/operator-ui/model.js +2 -9
  120. package/dist/operator-ui/setup-commands.js +1 -3
  121. package/dist/operator-ui/view.d.ts +323 -0
  122. package/dist/operator-ui/view.js +62 -189
  123. package/dist/page-styles.js +2 -3
  124. package/dist/program-source.js +14 -2
  125. package/dist/{exempt-writes.js → program-writes.js} +55 -18
  126. package/dist/provider.d.ts +142 -0
  127. package/dist/provider.js +187 -0
  128. package/dist/providers/_shared/google/result-size.js +57 -0
  129. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  130. package/dist/providers/_shared/google/service-account.js +500 -0
  131. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  132. package/dist/providers/_shared/google/workspace.js +518 -0
  133. package/dist/providers/basecamp/index.d.ts +43 -0
  134. package/dist/providers/basecamp/index.js +997 -0
  135. package/dist/providers/basecamp/skill.generated.js +9 -0
  136. package/dist/providers/breeze/index.d.ts +34 -0
  137. package/dist/providers/breeze/index.js +1446 -0
  138. package/dist/providers/breeze/skill.generated.js +9 -0
  139. package/dist/providers/ccb/index.d.ts +103 -0
  140. package/dist/providers/ccb/index.js +1725 -0
  141. package/dist/providers/ccb/skill.generated.js +9 -0
  142. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  143. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  144. package/dist/providers/cloudflare/skill.generated.js +10 -0
  145. package/dist/providers/docs/index.d.ts +54 -0
  146. package/dist/providers/docs/index.js +1237 -0
  147. package/dist/providers/docs/skill.generated.js +9 -0
  148. package/dist/providers/drive/index.d.ts +53 -0
  149. package/dist/providers/drive/index.js +1523 -0
  150. package/dist/providers/drive/skill.generated.js +9 -0
  151. package/dist/providers/forms/index.d.ts +54 -0
  152. package/dist/providers/forms/index.js +1217 -0
  153. package/dist/providers/forms/skill.generated.js +11 -0
  154. package/dist/providers/github/auth.d.ts +32 -0
  155. package/dist/providers/github/auth.js +361 -0
  156. package/dist/providers/github/hosted.js +218 -0
  157. package/dist/providers/github/index.d.ts +13 -0
  158. package/dist/providers/github/index.js +140 -0
  159. package/dist/providers/github/key.js +100 -0
  160. package/dist/providers/github/rest.js +321 -0
  161. package/dist/providers/github/scope.d.ts +29 -0
  162. package/dist/providers/github/scope.js +124 -0
  163. package/dist/providers/github/skill.generated.js +8 -0
  164. package/dist/providers/gmail/index.d.ts +51 -0
  165. package/dist/providers/gmail/index.js +1436 -0
  166. package/dist/providers/gmail/skill.generated.js +9 -0
  167. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  168. package/dist/providers/linear/index.js +352 -0
  169. package/dist/providers/linear/skill.generated.js +8 -0
  170. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  171. package/dist/providers/mixpanel/index.js +416 -0
  172. package/dist/providers/mixpanel/skill.generated.js +9 -0
  173. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  174. package/dist/providers/{notion.js → notion/index.js} +226 -239
  175. package/dist/providers/notion/skill.generated.js +10 -0
  176. package/dist/providers/overflow/index.d.ts +60 -0
  177. package/dist/providers/overflow/index.js +1535 -0
  178. package/dist/providers/overflow/skill.generated.js +8 -0
  179. package/dist/providers/planning-center/index.d.ts +59 -0
  180. package/dist/providers/planning-center/index.js +2199 -0
  181. package/dist/providers/planning-center/skill.generated.js +10 -0
  182. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  183. package/dist/providers/revenuecat/index.js +557 -0
  184. package/dist/providers/revenuecat/skill.generated.js +11 -0
  185. package/dist/providers/sheets/index.d.ts +54 -0
  186. package/dist/providers/sheets/index.js +1237 -0
  187. package/dist/providers/sheets/skill.generated.js +10 -0
  188. package/dist/providers/slides/index.d.ts +53 -0
  189. package/dist/providers/slides/index.js +2645 -0
  190. package/dist/providers/slides/skill.generated.js +9 -0
  191. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  192. package/dist/providers/stripe/index.js +266 -0
  193. package/dist/providers/stripe/skill.generated.js +11 -0
  194. package/dist/providers/tithely/index.d.ts +49 -0
  195. package/dist/providers/tithely/index.js +1003 -0
  196. package/dist/providers/tithely/skill.generated.js +8 -0
  197. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  198. package/dist/providers/vercel/index.js +1038 -0
  199. package/dist/providers/vercel/mcp-ownership.js +212 -0
  200. package/dist/providers/vercel/skill.generated.js +11 -0
  201. package/dist/registry.d.ts +108 -171
  202. package/dist/registry.js +513 -925
  203. package/dist/request-state.js +21 -0
  204. package/dist/resource-read.js +100 -0
  205. package/dist/result-shapes.js +17 -49
  206. package/dist/retired-toolkits.js +1 -1
  207. package/dist/routes/access-tokens.js +3 -6
  208. package/dist/routes/activity-context.js +21 -0
  209. package/dist/routes/activity-disclosure.js +40 -0
  210. package/dist/routes/activity.js +59 -21
  211. package/dist/routes/actor-labels.js +3 -50
  212. package/dist/routes/connect.js +221 -0
  213. package/dist/routes/credentials.js +15 -27
  214. package/dist/routes/mcp.js +356 -130
  215. package/dist/routes/oauth-client-metadata.js +15 -0
  216. package/dist/routes/oauth-management.js +45 -107
  217. package/dist/routes/oauth.js +136 -57
  218. package/dist/routes/operator.js +3 -3
  219. package/dist/routes/shared.d.ts +25 -64
  220. package/dist/routes/shared.js +160 -80
  221. package/dist/routes/skills.js +30 -0
  222. package/dist/routes/ui-config.js +225 -0
  223. package/dist/routes/ui-data.js +40 -35
  224. package/dist/routes/ui.js +66 -56
  225. package/dist/runtime/admission.js +4 -2
  226. package/dist/runtime/call-admission.js +3 -1
  227. package/dist/runtime/connector-scope.js +1 -4
  228. package/dist/runtime/run.js +10 -39
  229. package/dist/runtime/services.js +12 -117
  230. package/dist/runtime/shared-read.js +1 -3
  231. package/dist/runtime/storage.js +8 -1
  232. package/dist/sent-secrets.d.ts +52 -0
  233. package/dist/sent-secrets.js +474 -0
  234. package/dist/server.js +28 -71
  235. package/dist/skills.js +406 -174
  236. package/dist/sqlite.d.ts +48 -0
  237. package/dist/sqlite.js +166 -0
  238. package/dist/storage/keys.js +428 -0
  239. package/dist/storage/memory.js +7 -0
  240. package/dist/storage/sql.d.ts +66 -0
  241. package/dist/storage/sql.js +534 -0
  242. package/dist/timeout.js +4 -2
  243. package/dist/tool-name.js +17 -0
  244. package/dist/tool-safety.d.ts +9 -35
  245. package/dist/tool-safety.js +20 -40
  246. package/dist/types.d.ts +320 -54
  247. package/dist/types.js +0 -1
  248. package/dist/ui.d.ts +7 -19
  249. package/dist/ui.js +76 -90
  250. package/dist/url-safety.js +3 -6
  251. package/dist/usage-guide.js +137 -0
  252. package/dist/validate.d.ts +7 -0
  253. package/dist/validate.js +309 -47
  254. package/dist/version.d.ts +1 -7
  255. package/dist/version.js +3 -7
  256. package/dist/worker.d.ts +11 -0
  257. package/dist/worker.js +434 -0
  258. package/documentation/architecture.md +665 -230
  259. package/documentation/auth.md +962 -458
  260. package/documentation/code-mode.md +501 -312
  261. package/documentation/meta-tools.md +449 -200
  262. package/documentation/operator-tests.md +93 -0
  263. package/documentation/provider-migration-0.29.md +60 -0
  264. package/examples/worker/AGENTS.md +34 -12
  265. package/examples/worker/README.md +286 -255
  266. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  267. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  268. package/examples/worker/src/connecta.config.ts +151 -0
  269. package/examples/worker/src/index.ts +14 -197
  270. package/examples/worker/wrangler.jsonc +15 -35
  271. package/package.json +125 -35
  272. package/templates/node/.dockerignore +1 -2
  273. package/templates/node/.env.example +20 -21
  274. package/templates/node/AGENTS.md +21 -17
  275. package/templates/node/Dockerfile +3 -5
  276. package/templates/node/README.md +133 -58
  277. package/templates/node/docker-compose.yml +11 -12
  278. package/templates/node/package.json +4 -2
  279. package/templates/node/src/connecta.config.ts +119 -0
  280. package/templates/node/src/index.ts +9 -154
  281. package/templates/node/src/provision-token.ts +17 -0
  282. package/AGENTS.md +0 -170
  283. package/dist/artifacts/connector.d.ts +0 -58
  284. package/dist/artifacts/connector.js +0 -730
  285. package/dist/artifacts/document.js +0 -86
  286. package/dist/artifacts/guide.js +0 -140
  287. package/dist/artifacts/html-scan.js +0 -329
  288. package/dist/artifacts/json.js +0 -78
  289. package/dist/artifacts/kv-store.d.ts +0 -20
  290. package/dist/artifacts/kv-store.js +0 -140
  291. package/dist/artifacts/markdown.js +0 -478
  292. package/dist/artifacts/module.d.ts +0 -37
  293. package/dist/artifacts/module.js +0 -66
  294. package/dist/artifacts/operations.d.ts +0 -197
  295. package/dist/artifacts/operations.js +0 -1002
  296. package/dist/artifacts/refresh.d.ts +0 -55
  297. package/dist/artifacts/refresh.js +0 -236
  298. package/dist/artifacts/routes.js +0 -206
  299. package/dist/artifacts/types.d.ts +0 -214
  300. package/dist/artifacts/types.js +0 -22
  301. package/dist/artifacts/validate.d.ts +0 -62
  302. package/dist/artifacts/validate.js +0 -651
  303. package/dist/artifacts.d.ts +0 -6
  304. package/dist/artifacts.js +0 -9
  305. package/dist/auth/bearer.d.ts +0 -11
  306. package/dist/auth/bearer.js +0 -50
  307. package/dist/providers/linear.js +0 -217
  308. package/dist/providers/mixpanel.js +0 -247
  309. package/dist/providers/revenuecat.js +0 -317
  310. package/dist/providers/stripe.js +0 -253
  311. package/dist/providers/vercel.js +0 -1253
  312. package/dist/storage/file.d.ts +0 -15
  313. package/dist/storage/file.js +0 -455
  314. package/ethos.md +0 -93
  315. package/examples/worker/src/cloudflare-kv.ts +0 -43
  316. package/examples/worker/src/d1-activity-row.ts +0 -155
  317. package/examples/worker/src/d1-activity.ts +0 -147
  318. package/examples/worker/src/d1-storage.ts +0 -138
  319. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  320. package/templates/node/src/file-activity.ts +0 -182
@@ -1,1002 +0,0 @@
1
- // Every artifact rule, once: base-version conflicts, exact-match patches,
2
- // rollback, archive, limits, and validation, over any ArtifactStore.
3
- //
4
- // A write reads the head, checks its base against the stream it touches,
5
- // writes bodies (content-addressed) and the versions the new head will
6
- // supersede, then commits by compare-and-set of the head. The logical check
7
- // and the physical swap are different questions on purpose: a stale base is a
8
- // conflict the caller must resolve by re-reading, while a head that moved for
9
- // an unrelated reason — a teammate's edit to a different document, a refresh
10
- // landing data — is re-read and retried here, so a write never reports a
11
- // conflict it does not have. Exactly one of two writes from the same base
12
- // wins, because only one can swap the head its check read.
13
- //
14
- // Nothing here throws for a caller's mistake. Operations answer
15
- // `{ ok: false, code, message }`, and the connector turns that into its typed
16
- // error; a throw means storage failed.
17
- import { buildFrameDocument, FRAME_OVERHEAD_BYTES } from "./document.js";
18
- import { sha256Hex, utf8Bytes } from "./json.js";
19
- import { markdownPage, MarkdownNestingError } from "./markdown.js";
20
- import { ARTIFACT_ID, checkDocument, checkDocumentTotals, checkView, finish, validDocumentName, } from "./validate.js";
21
- /** Physical head moves a write absorbs before giving up as busy. */
22
- const MAX_SWAP_ATTEMPTS = 8;
23
- /** Versions `history` returns. */
24
- const HISTORY_LIMIT = 20;
25
- /** Heads one listing call may examine while searching. */
26
- const LIST_SCAN_LIMIT = 1000;
27
- /** Tombstones keep version numbers, so distinct names need their own bound. */
28
- const DOCUMENT_NAMES_LIMIT = 64;
29
- const fail = (code, message, extra = {}) => ({ ok: false, code, message, ...extra });
30
- const quoteId = (id) => `'${id}'`;
31
- /** Bound on the error list a refusal carries in its message. */
32
- const MAX_REFUSAL_CHARS = 1_800;
33
- /**
34
- * A validation refusal. The message lists every finding — each names a line
35
- * and a fix — so an agent can repair the page from the error alone; it stays
36
- * under the guest error bound, pointing at `validate_artifact` for the rest.
37
- */
38
- export function invalidContent(what, validation) {
39
- const total = validation.errors.length + (validation.errorsOmitted ?? 0);
40
- let message = `${what} failed validation with ${total} error${total === 1 ? "" : "s"}; nothing was saved.`;
41
- let listed = 0;
42
- for (const issue of validation.errors) {
43
- const line = `\n- ${issue.message}`;
44
- if (message.length + line.length > MAX_REFUSAL_CHARS)
45
- break;
46
- message += line;
47
- listed++;
48
- }
49
- if (listed < total) {
50
- message += `\n- …and ${total - listed} more; run artifacts.validate_artifact to see them all.`;
51
- }
52
- return fail("invalid_args", message, { validation });
53
- }
54
- /** Where every stream stands, for a conflict. Bounded: revision, view, and at most 18 documents. */
55
- function currentOf(head) {
56
- const current = {
57
- revision: head.revision,
58
- view: head.view.version,
59
- };
60
- for (const [name, record] of Object.entries(head.documents).slice(0, 18)) {
61
- current[`document:${name}`] = record.version;
62
- }
63
- return current;
64
- }
65
- const liveDocuments = (head) => Object.entries(head.documents).filter(([, record]) => !record.removed);
66
- const dataBytesOf = (head) => liveDocuments(head).reduce((sum, [, record]) => sum + record.bytes, 0);
67
- function lineOf(text, offset) {
68
- let line = 1;
69
- for (let i = text.indexOf("\n"); i >= 0 && i < offset; i = text.indexOf("\n", i + 1))
70
- line++;
71
- return line;
72
- }
73
- export class ArtifactOperations {
74
- #store;
75
- #context;
76
- #now;
77
- constructor(options) {
78
- this.#store = options.store;
79
- this.#context = { limits: options.limits, allowlist: options.allowlist };
80
- this.#now = options.now ?? Date.now;
81
- }
82
- get store() {
83
- return this.#store;
84
- }
85
- get context() {
86
- return this.#context;
87
- }
88
- #at() {
89
- return new Date(this.#now()).toISOString();
90
- }
91
- #checkId(id) {
92
- return typeof id === "string" && ARTIFACT_ID.test(id)
93
- ? undefined
94
- : fail("invalid_args", "id must be a lowercase slug: letters, digits, and single hyphens inside, 1–64 characters, " +
95
- "starting and ending with a letter or digit (e.g. q3-bugs).");
96
- }
97
- #checkTitle(title) {
98
- if (typeof title !== "string" || !title.trim()) {
99
- return fail("invalid_args", "title must be a non-empty string.");
100
- }
101
- const trimmed = title.replace(/\s+/g, " ").trim();
102
- if (trimmed.length > this.#context.limits.titleChars) {
103
- return fail("invalid_args", `title is ${trimmed.length} characters; the limit is ${this.#context.limits.titleChars}.`);
104
- }
105
- return trimmed;
106
- }
107
- #checkVersion(name, value, min = 1) {
108
- return Number.isSafeInteger(value) && value >= min
109
- ? undefined
110
- : fail("invalid_args", `${name} must be a whole number of at least ${min}.`);
111
- }
112
- #checkDocumentName(name) {
113
- if (validDocumentName(name))
114
- return undefined;
115
- return fail("invalid_args", `Document name ${quoteId(String(name))} is not allowed. Use 1–64 letters, digits, or _, ` +
116
- "starting with a letter or _, and avoid reserved object property names.");
117
- }
118
- #notFound(id) {
119
- return fail("not_found", `No artifact ${quoteId(id)}. Find ids with artifacts.list_artifacts.`);
120
- }
121
- #archived(id) {
122
- return fail("invalid_args", `Artifact ${quoteId(id)} is archived. Restore it first with artifacts.restore_artifact.`);
123
- }
124
- async #body(key) {
125
- if (key === undefined)
126
- throw new Error("artifact version has no body");
127
- const body = await this.#store.body(key);
128
- if (body === null)
129
- throw new Error(`artifact body ${key} is missing from storage`);
130
- return body;
131
- }
132
- /** Read one version of one stream: the head's latest, or a stored earlier one. */
133
- async #version(id, stream, latest, version) {
134
- if (latest && latest.version === version)
135
- return latest;
136
- if (!latest || version > latest.version || version < 1)
137
- return undefined;
138
- const [record] = await this.#store.versions(id, stream, { below: version + 1, limit: 1 });
139
- return record?.version === version ? record : undefined;
140
- }
141
- async #history(id, stream, latest, limit = HISTORY_LIMIT) {
142
- const earlier = await this.#store.versions(id, stream, {
143
- below: latest.version,
144
- limit: limit - 1,
145
- });
146
- return [latest, ...earlier];
147
- }
148
- /** Read, plan, write what the plan needs, and swap; retry only physical misses. */
149
- async #commit(id, plan, render, beforeSwap) {
150
- for (let attempt = 0; attempt < MAX_SWAP_ATTEMPTS; attempt++) {
151
- const current = await this.#store.head(id);
152
- if (!current)
153
- return this.#notFound(id);
154
- const drafted = await plan(current.head);
155
- if (!drafted.ok)
156
- return drafted;
157
- const planned = await this.#rendered(id, drafted, render);
158
- if (!planned.ok)
159
- return planned;
160
- await Promise.all([...planned.bodies].map(([key, body]) => this.#store.putBody(key, body)));
161
- await Promise.all(planned.supersede.map(([stream, record]) => this.#store.putVersion(id, stream, record.version, record)));
162
- const refused = beforeSwap?.(planned.next);
163
- if (refused)
164
- return refused;
165
- if (await this.#store.swapHead(id, current.token, planned.next)) {
166
- return { ok: true, head: planned.next, warnings: planned.warnings };
167
- }
168
- }
169
- return fail("unavailable", `Artifact ${quoteId(id)} kept changing while this write was being saved. Retry it.`);
170
- }
171
- /**
172
- * Run the deployment's render check against the page a plan would commit.
173
- * Bodies the plan already holds are passed in; the rest are read. Warnings
174
- * join the plan's; a failure replaces it.
175
- */
176
- async #rendered(id, plan, render) {
177
- if (!render)
178
- return plan;
179
- const known = (key) => key !== undefined && plan.bodies.has(key)
180
- ? plan.bodies.get(key)
181
- : this.#body(key);
182
- const data = {};
183
- for (const [name, record] of liveDocuments(plan.next)) {
184
- data[name] = await known(record.body);
185
- }
186
- const verdict = await render({
187
- id,
188
- head: plan.next,
189
- source: await known(plan.next.view.body),
190
- data,
191
- });
192
- return verdict.ok
193
- ? { ...plan, warnings: [...plan.warnings, ...verdict.warnings] }
194
- : verdict;
195
- }
196
- #touch(head, by, at) {
197
- return { ...head, revision: head.revision + 1, updatedBy: by, updatedAt: at };
198
- }
199
- #viewConflict(id, head, base) {
200
- return fail("conflict", `Artifact ${quoteId(id)} view is at version ${head.view.version}, not ${base}. Re-read it with ` +
201
- `artifacts.get_artifact, reapply the change, and retry with baseVersion ${head.view.version}.`, { current: currentOf(head) });
202
- }
203
- #revisionConflict(id, head, base) {
204
- return fail("conflict", `Artifact ${quoteId(id)} is at revision ${head.revision}, not ${base}. Re-read it with ` +
205
- `artifacts.get_artifact and retry with baseRevision ${head.revision}.`, { current: currentOf(head) });
206
- }
207
- /** Validate a view against the documents the head gives it. */
208
- #validateView(kind, source, title, head) {
209
- const findings = checkView({
210
- kind,
211
- source,
212
- title,
213
- documentNames: liveDocuments(head).map(([name]) => name),
214
- dataBytes: dataBytesOf(head),
215
- }, this.#context);
216
- return finish(findings);
217
- }
218
- /** A document change must fit the frame, including rendered Markdown. */
219
- async #checkRendered(head) {
220
- const total = dataBytesOf(head);
221
- let viewBytes = head.view.bytes;
222
- if (head.kind === "markdown") {
223
- try {
224
- viewBytes = utf8Bytes(markdownPage(await this.#body(head.view.body), head.title));
225
- }
226
- catch (error) {
227
- if (!(error instanceof MarkdownNestingError))
228
- throw error;
229
- return invalidContent("The page", finish({
230
- errors: [{ code: "E_NESTING", severity: "error", message: error.message }],
231
- warnings: [],
232
- }));
233
- }
234
- }
235
- const rendered = viewBytes + total + FRAME_OVERHEAD_BYTES;
236
- if (rendered <= this.#context.limits.renderedBytes)
237
- return undefined;
238
- return invalidContent("The page and its documents", finish({
239
- errors: [{
240
- code: "E_TOO_LARGE",
241
- severity: "error",
242
- message: `The page plus its documents would be ${rendered.toLocaleString("en-US")} bytes; ` +
243
- `the limit is ${this.#context.limits.renderedBytes.toLocaleString("en-US")}. Trim the page or its documents.`,
244
- }],
245
- warnings: [],
246
- }));
247
- }
248
- async create(input) {
249
- const badId = this.#checkId(input.id);
250
- if (badId)
251
- return badId;
252
- const title = this.#checkTitle(input.title);
253
- if (typeof title !== "string")
254
- return title;
255
- if (input.kind !== "html" && input.kind !== "markdown") {
256
- return fail("invalid_args", 'kind must be "html" or "markdown".');
257
- }
258
- if (typeof input.source !== "string") {
259
- return fail("invalid_args", "source must be a string.");
260
- }
261
- const documents = input.documents ?? {};
262
- if (documents === null || typeof documents !== "object" || Array.isArray(documents)) {
263
- return fail("invalid_args", "documents must be an object mapping names to JSON values.");
264
- }
265
- const errors = [];
266
- const stored = [];
267
- for (const [name, value] of Object.entries(documents)) {
268
- const checked = checkDocument(name, value, this.#context.limits);
269
- if (checked.error)
270
- errors.push(checked.error);
271
- else if (checked.text !== undefined)
272
- stored.push([name, checked.text, checked.bytes]);
273
- }
274
- const dataBytes = stored.reduce((sum, [, , bytes]) => sum + bytes, 0);
275
- const totals = checkDocumentTotals(Object.keys(documents).length, dataBytes, this.#context.limits);
276
- if (totals)
277
- errors.push(totals);
278
- const view = checkView({
279
- kind: input.kind,
280
- source: input.source,
281
- title,
282
- documentNames: Object.keys(documents),
283
- dataBytes,
284
- }, this.#context);
285
- const validation = finish({ errors: [...errors, ...view.errors], warnings: view.warnings });
286
- if (!validation.ok)
287
- return invalidContent("The artifact", validation);
288
- const at = this.#at();
289
- const by = input.by;
290
- const bodies = new Map();
291
- const viewKey = await sha256Hex(input.source);
292
- bodies.set(viewKey, input.source);
293
- const records = {};
294
- for (const [name, text, bytes] of stored) {
295
- const key = await sha256Hex(text);
296
- bodies.set(key, text);
297
- records[name] = { version: 1, body: key, bytes, by, at, op: "create" };
298
- }
299
- const head = {
300
- revision: 1,
301
- title,
302
- kind: input.kind,
303
- archived: false,
304
- createdBy: by,
305
- createdAt: at,
306
- updatedBy: by,
307
- updatedAt: at,
308
- view: { version: 1, body: viewKey, bytes: utf8Bytes(input.source), by, at, op: "create" },
309
- documents: records,
310
- };
311
- const rendered = await this.#rendered(input.id, { ok: true, next: head, bodies, supersede: [], warnings: validation.warnings }, input.render);
312
- if (!rendered.ok)
313
- return rendered;
314
- await Promise.all([...bodies].map(([key, body]) => this.#store.putBody(key, body)));
315
- if (!(await this.#store.swapHead(input.id, null, head))) {
316
- return fail("invalid_args", `Artifact id ${quoteId(input.id)} is taken (archived artifacts keep their ids). ` +
317
- "Update it with artifacts.update_artifact, or choose another id.");
318
- }
319
- return { ok: true, head, warnings: rendered.warnings };
320
- }
321
- async update(input) {
322
- const badId = this.#checkId(input.id);
323
- if (badId)
324
- return badId;
325
- const badBase = this.#checkVersion("baseVersion", input.baseVersion);
326
- if (badBase)
327
- return badBase;
328
- if (typeof input.source !== "string")
329
- return fail("invalid_args", "source must be a string.");
330
- let title;
331
- if (input.title !== undefined) {
332
- const checked = this.#checkTitle(input.title);
333
- if (typeof checked !== "string")
334
- return checked;
335
- title = checked;
336
- }
337
- const key = await sha256Hex(input.source);
338
- return this.#commit(input.id, async (head) => {
339
- if (head.archived)
340
- return this.#archived(input.id);
341
- if (head.view.version !== input.baseVersion) {
342
- return this.#viewConflict(input.id, head, input.baseVersion);
343
- }
344
- const validation = this.#validateView(head.kind, input.source, title ?? head.title, head);
345
- if (!validation.ok)
346
- return invalidContent("The page", validation);
347
- return this.#replaceView(head, {
348
- key,
349
- source: input.source,
350
- op: "update",
351
- by: input.by,
352
- warnings: validation.warnings,
353
- ...(title === undefined ? {} : { title }),
354
- });
355
- }, input.render);
356
- }
357
- #replaceView(head, change) {
358
- const at = this.#at();
359
- const view = {
360
- version: head.view.version + 1,
361
- body: change.key,
362
- bytes: utf8Bytes(change.source),
363
- by: change.by,
364
- at,
365
- op: change.op,
366
- ...(change.restoredFrom === undefined ? {} : { restoredFrom: change.restoredFrom }),
367
- };
368
- return {
369
- ok: true,
370
- next: {
371
- ...this.#touch(head, change.by, at),
372
- ...(change.title === undefined ? {} : { title: change.title }),
373
- view,
374
- },
375
- bodies: new Map([[change.key, change.source]]),
376
- supersede: [["view", head.view]],
377
- warnings: change.warnings,
378
- };
379
- }
380
- async patch(input) {
381
- const badId = this.#checkId(input.id);
382
- if (badId)
383
- return badId;
384
- const badBase = this.#checkVersion("baseVersion", input.baseVersion);
385
- if (badBase)
386
- return badBase;
387
- const { limits } = this.#context;
388
- const edits = input.edits;
389
- if (!Array.isArray(edits) || edits.length < 1 || edits.length > limits.patchEdits) {
390
- return fail("invalid_args", `edits must be an array of 1 to ${limits.patchEdits} { find, replace } pairs.`);
391
- }
392
- for (const [index, edit] of edits.entries()) {
393
- if (edit === null ||
394
- typeof edit !== "object" ||
395
- typeof edit.find !== "string" ||
396
- typeof edit.replace !== "string") {
397
- return fail("invalid_args", `edit ${index + 1}: find and replace must both be strings.`);
398
- }
399
- const bytes = utf8Bytes(edit.find);
400
- if (bytes < 1 || bytes > limits.findBytes) {
401
- return fail("invalid_args", `edit ${index + 1}: find must be 1 to ${limits.findBytes} bytes; it is ${bytes}.`);
402
- }
403
- }
404
- let base;
405
- return this.#commit(input.id, async (head) => {
406
- if (head.archived)
407
- return this.#archived(input.id);
408
- if (head.view.version !== input.baseVersion) {
409
- return this.#viewConflict(input.id, head, input.baseVersion);
410
- }
411
- const cached = base;
412
- const source = cached && cached.key === head.view.body
413
- ? cached.source
414
- : await this.#body(head.view.body);
415
- base = { key: head.view.body ?? "", source };
416
- const applied = applyPatch(source, edits);
417
- if (typeof applied !== "string") {
418
- return fail("invalid_args", `The patch was not applied; nothing changed. ${applied.join("; ")}.`);
419
- }
420
- const validation = this.#validateView(head.kind, applied, head.title, head);
421
- if (!validation.ok)
422
- return invalidContent("The patched page", validation);
423
- return this.#replaceView(head, {
424
- key: await sha256Hex(applied),
425
- source: applied,
426
- op: "patch",
427
- by: input.by,
428
- warnings: validation.warnings,
429
- });
430
- }, input.render);
431
- }
432
- async setDocuments(input) {
433
- const badId = this.#checkId(input.id);
434
- if (badId)
435
- return badId;
436
- const { limits } = this.#context;
437
- const documents = input.documents;
438
- if (documents === null || typeof documents !== "object" || Array.isArray(documents)) {
439
- return fail("invalid_args", "documents must map names to { baseVersion, value }.");
440
- }
441
- const entries = Object.entries(documents);
442
- if (entries.length < 1 || entries.length > limits.documents) {
443
- return fail("invalid_args", `documents must name 1 to ${limits.documents} documents.`);
444
- }
445
- const changes = [];
446
- const errors = [];
447
- for (const [name, change] of entries) {
448
- if (change === null || typeof change !== "object") {
449
- return fail("invalid_args", `documents.${name} must be { baseVersion, value }.`);
450
- }
451
- const bad = this.#checkVersion(`documents.${name}.baseVersion`, change.baseVersion, 0);
452
- if (bad)
453
- return bad;
454
- if (!("value" in change)) {
455
- return fail("invalid_args", `documents.${name}.value is required; use null to remove the document.`);
456
- }
457
- if (change.value === null) {
458
- const named = checkDocument(name, 0, limits);
459
- if (named.error)
460
- errors.push(named.error);
461
- changes.push({ name, base: change.baseVersion, bytes: 0 });
462
- continue;
463
- }
464
- const checked = checkDocument(name, change.value, limits);
465
- if (checked.error)
466
- errors.push(checked.error);
467
- else
468
- changes.push({ name, base: change.baseVersion, text: checked.text ?? "", bytes: checked.bytes });
469
- }
470
- if (errors.length) {
471
- return invalidContent("The documents", finish({ errors, warnings: [] }));
472
- }
473
- const keyed = await Promise.all(changes.map(async (change) => ({
474
- ...change,
475
- key: change.text === undefined ? undefined : await sha256Hex(change.text),
476
- })));
477
- const op = input.op ?? "set";
478
- return this.#commit(input.id, async (head) => {
479
- if (head.archived)
480
- return this.#archived(input.id);
481
- if (op === "refresh" && (!input.runId ||
482
- head.refresh?.claim?.runId !== input.runId ||
483
- head.refresh.program.version !== input.programVersion ||
484
- Date.parse(head.refresh.claim.until) <= this.#now()))
485
- return fail("conflict", "The refresh claim expired or its program changed; no data was saved.");
486
- const stale = keyed.filter((change) => (head.documents[change.name]?.version ?? 0) !== change.base);
487
- if (stale.length) {
488
- const described = stale.slice(0, 3).map((change) => {
489
- const now = head.documents[change.name]?.version ?? 0;
490
- return now === 0
491
- ? `'${change.name}' does not exist yet (use baseVersion 0), not version ${change.base}`
492
- : `'${change.name}' is at version ${now}, not ${change.base}`;
493
- });
494
- return fail("conflict", `Artifact ${quoteId(input.id)} document ${described.join("; ")}. Re-read with artifacts.get_document, ` +
495
- "reapply the change, and retry with the current baseVersion.", { current: currentOf(head) });
496
- }
497
- for (const change of keyed) {
498
- const existing = head.documents[change.name];
499
- if (change.key === undefined && (!existing || existing.removed)) {
500
- return fail("invalid_args", `Artifact ${quoteId(input.id)} has no document '${change.name}' to remove.`);
501
- }
502
- }
503
- const at = this.#at();
504
- const next = { ...head.documents };
505
- const supersede = [];
506
- const bodies = new Map();
507
- for (const change of keyed) {
508
- const existing = head.documents[change.name];
509
- if (existing)
510
- supersede.push([`doc:${change.name}`, existing]);
511
- const version = (existing?.version ?? 0) + 1;
512
- next[change.name] =
513
- change.key === undefined
514
- ? { version, bytes: 0, by: input.by, at, op, removed: true, ...(input.runId ? { runId: input.runId } : {}) }
515
- : {
516
- version,
517
- body: change.key,
518
- bytes: change.bytes,
519
- by: input.by,
520
- at,
521
- op,
522
- ...(input.runId ? { runId: input.runId } : {}),
523
- };
524
- if (change.key !== undefined && change.text !== undefined)
525
- bodies.set(change.key, change.text);
526
- }
527
- const nextHead = { ...this.#touch(head, input.by, at), documents: next };
528
- if (Object.keys(next).length > DOCUMENT_NAMES_LIMIT &&
529
- Object.keys(next).length > Object.keys(head.documents).length) {
530
- return fail("invalid_args", `Artifact ${quoteId(input.id)} has used ${DOCUMENT_NAMES_LIMIT} distinct document names. ` +
531
- "Reuse an existing name or create another artifact; removed names keep their version history.");
532
- }
533
- const live = liveDocuments(nextHead);
534
- const total = dataBytesOf(nextHead);
535
- const totals = checkDocumentTotals(live.length, total, limits);
536
- if (totals)
537
- return invalidContent("The documents", finish({ errors: [totals], warnings: [] }));
538
- const tooLarge = await this.#checkRendered(nextHead);
539
- if (tooLarge)
540
- return tooLarge;
541
- return { ok: true, next: nextHead, bodies, supersede, warnings: [] };
542
- }, input.render, op === "refresh" ? (next) => {
543
- const claim = next.refresh?.claim;
544
- return claim !== undefined && claim.runId === input.runId && Date.parse(claim.until) > this.#now()
545
- ? undefined
546
- : fail("conflict", "The refresh claim expired before data commit; no data was saved.");
547
- } : undefined);
548
- }
549
- /** Configure one versioned program. A concurrent document edit is absorbed. */
550
- async setRefresh(input) {
551
- const badId = this.#checkId(input.id);
552
- if (badId)
553
- return badId;
554
- const badName = this.#checkDocumentName(input.document);
555
- if (badName)
556
- return badName;
557
- const badBase = this.#checkVersion("baseVersion", input.baseVersion, 0);
558
- if (badBase)
559
- return badBase;
560
- if (!["manual", "daily", "weekly"].includes(input.schedule)) {
561
- return fail("invalid_args", 'schedule must be "manual", "daily", or "weekly".');
562
- }
563
- if (typeof input.program !== "string" || !input.program.trim() ||
564
- utf8Bytes(input.program) > this.#context.limits.programBytes) {
565
- return fail("invalid_args", `program must be non-empty and at most ${this.#context.limits.programBytes} UTF-8 bytes.`);
566
- }
567
- const key = await sha256Hex(input.program);
568
- for (let attempt = 0; attempt < MAX_SWAP_ATTEMPTS; attempt++) {
569
- const current = await this.#store.head(input.id);
570
- if (!current)
571
- return this.#notFound(input.id);
572
- const head = current.head;
573
- if (head.archived)
574
- return this.#archived(input.id);
575
- const version = head.refresh?.program.version ?? 0;
576
- if (version !== input.baseVersion) {
577
- return fail("conflict", `Refresh program is at version ${version}, not ${input.baseVersion}.`, { current: currentOf(head) });
578
- }
579
- const at = this.#at();
580
- const program = {
581
- version: version + 1, body: key, bytes: utf8Bytes(input.program),
582
- by: input.by, at, op: "update",
583
- };
584
- const next = {
585
- ...this.#touch(head, input.by, at),
586
- refresh: { program, document: input.document, schedule: input.schedule, configuredAt: at,
587
- ...(input.owner ? { owner: input.owner } : {}) },
588
- };
589
- await this.#store.putBody(key, input.program);
590
- if (head.refresh)
591
- await this.#store.putVersion(input.id, "refresh", version, head.refresh.program);
592
- if (await this.#store.swapHead(input.id, current.token, next))
593
- return { ok: true, head: next };
594
- }
595
- return fail("unavailable", "Refresh configuration kept changing; retry it.");
596
- }
597
- async refreshConfig(id) {
598
- const badId = this.#checkId(id);
599
- if (badId)
600
- return badId;
601
- const current = await this.#store.head(id);
602
- if (!current)
603
- return this.#notFound(id);
604
- return {
605
- ok: true,
606
- head: current.head,
607
- program: current.head.refresh?.program.body
608
- ? await this.#body(current.head.refresh.program.body)
609
- : undefined,
610
- };
611
- }
612
- async claimRefresh(id, runId, trigger, dueOnly, claimMs) {
613
- for (let attempt = 0; attempt < MAX_SWAP_ATTEMPTS; attempt++) {
614
- const current = await this.#store.head(id);
615
- if (!current)
616
- return this.#notFound(id);
617
- const head = current.head;
618
- const refresh = head.refresh;
619
- if (!refresh || head.archived)
620
- return { ok: false, skipped: true };
621
- const now = this.#now();
622
- const period = refresh.schedule === "daily" ? 86_400_000
623
- : refresh.schedule === "weekly" ? 604_800_000 : undefined;
624
- if (dueOnly && (period === undefined || now < Date.parse(refresh.last?.at ?? refresh.configuredAt) + period)) {
625
- return { ok: false, skipped: true };
626
- }
627
- if (refresh.claim && Date.parse(refresh.claim.until) > now)
628
- return { ok: false, skipped: true };
629
- const startedAt = new Date(now).toISOString();
630
- const claim = { runId, until: new Date(now + claimMs).toISOString() };
631
- const next = {
632
- ...head, revision: head.revision + 1,
633
- refresh: { ...refresh, claim },
634
- };
635
- if (!(await this.#store.swapHead(id, current.token, next)))
636
- continue;
637
- const run = {
638
- runId, startedAt, status: "running", trigger,
639
- programVersion: refresh.program.version,
640
- };
641
- return { ok: true, head: next, program: refresh.program.body ?? "", run };
642
- }
643
- return fail("unavailable", "Refresh claim kept changing; retry it.");
644
- }
645
- async finishRefresh(id, run) {
646
- for (let attempt = 0; attempt < MAX_SWAP_ATTEMPTS; attempt++) {
647
- const current = await this.#store.head(id);
648
- if (!current?.head.refresh || current.head.refresh.claim?.runId !== run.runId)
649
- break;
650
- const { claim: _claim, ...refresh } = current.head.refresh;
651
- const next = {
652
- ...current.head, revision: current.head.revision + 1,
653
- refresh: { ...refresh, last: {
654
- runId: run.runId, at: run.finishedAt ?? this.#at(),
655
- status: run.status === "running" ? "failed" : run.status,
656
- ...(run.documentVersion !== undefined ? { documentVersion: run.documentVersion } : {}),
657
- } },
658
- };
659
- if (await this.#store.swapHead(id, current.token, next))
660
- break;
661
- }
662
- await this.#store.putRun(id, run);
663
- }
664
- async rollback(input) {
665
- const badId = this.#checkId(input.id);
666
- if (badId)
667
- return badId;
668
- if (input.target !== "view" && input.target !== "document") {
669
- return fail("invalid_args", 'target must be "view" or "document".');
670
- }
671
- const badVersion = this.#checkVersion("version", input.version) ?? this.#checkVersion("baseVersion", input.baseVersion);
672
- if (badVersion)
673
- return badVersion;
674
- if (input.target === "document" && (typeof input.name !== "string" || !input.name)) {
675
- return fail("invalid_args", 'name is required when target is "document".');
676
- }
677
- if (input.target === "document") {
678
- const badName = this.#checkDocumentName(input.name);
679
- if (badName)
680
- return badName;
681
- }
682
- const name = input.name ?? "";
683
- const stream = input.target === "view" ? "view" : `doc:${name}`;
684
- return this.#commit(input.id, async (head) => {
685
- if (head.archived)
686
- return this.#archived(input.id);
687
- const latest = input.target === "view" ? head.view : head.documents[name];
688
- if (!latest) {
689
- return fail("not_found", `Artifact ${quoteId(input.id)} has no document '${name}'.`);
690
- }
691
- if (latest.version !== input.baseVersion) {
692
- return input.target === "view"
693
- ? this.#viewConflict(input.id, head, input.baseVersion)
694
- : fail("conflict", `Artifact ${quoteId(input.id)} document '${name}' is at version ${latest.version}, not ${input.baseVersion}. ` +
695
- `Re-read it with artifacts.get_document and retry with baseVersion ${latest.version}.`, { current: currentOf(head) });
696
- }
697
- if (input.version === latest.version) {
698
- return fail("invalid_args", `Version ${input.version} is already the current one; nothing to roll back.`);
699
- }
700
- const target = await this.#version(input.id, stream, latest, input.version);
701
- if (!target) {
702
- return fail("not_found", `There is no version ${input.version} to roll back to; the latest is ${latest.version}.`);
703
- }
704
- const at = this.#at();
705
- const record = {
706
- version: latest.version + 1,
707
- bytes: target.bytes,
708
- by: input.by,
709
- at,
710
- op: "rollback",
711
- restoredFrom: target.version,
712
- ...(target.body === undefined ? {} : { body: target.body }),
713
- ...(target.removed ? { removed: true } : {}),
714
- };
715
- if (input.target === "view") {
716
- const source = await this.#body(target.body);
717
- const validation = this.#validateView(head.kind, source, head.title, head);
718
- if (!validation.ok)
719
- return invalidContent(`View version ${target.version}`, validation);
720
- return {
721
- ok: true,
722
- next: { ...this.#touch(head, input.by, at), view: record },
723
- bodies: new Map(),
724
- supersede: [["view", latest]],
725
- warnings: validation.warnings,
726
- };
727
- }
728
- if (!target.removed) {
729
- // Limits may have tightened since; the restored value must fit today's.
730
- const checked = checkDocument(name, JSON.parse(await this.#body(target.body)), this.#context.limits);
731
- if (checked.error) {
732
- return invalidContent(`Document '${name}' version ${target.version}`, finish({ errors: [checked.error], warnings: [] }));
733
- }
734
- }
735
- const nextHead = {
736
- ...this.#touch(head, input.by, at),
737
- documents: { ...head.documents, [name]: record },
738
- };
739
- const totals = checkDocumentTotals(liveDocuments(nextHead).length, dataBytesOf(nextHead), this.#context.limits);
740
- if (totals)
741
- return invalidContent("The documents", finish({ errors: [totals], warnings: [] }));
742
- const tooLarge = await this.#checkRendered(nextHead);
743
- if (tooLarge)
744
- return tooLarge;
745
- return { ok: true, next: nextHead, bodies: new Map(), supersede: [[stream, latest]], warnings: [] };
746
- }, input.render);
747
- }
748
- async setArchived(input) {
749
- const badId = this.#checkId(input.id);
750
- if (badId)
751
- return badId;
752
- const bad = this.#checkVersion("baseRevision", input.baseRevision);
753
- if (bad)
754
- return bad;
755
- return this.#commit(input.id, async (head) => {
756
- if (head.revision !== input.baseRevision) {
757
- return this.#revisionConflict(input.id, head, input.baseRevision);
758
- }
759
- if (head.archived === input.archived) {
760
- return fail("invalid_args", `Artifact ${quoteId(input.id)} is ${input.archived ? "already archived" : "not archived"}.`);
761
- }
762
- const at = this.#at();
763
- return {
764
- ok: true,
765
- next: { ...this.#touch(head, input.by, at), archived: input.archived },
766
- bodies: new Map(),
767
- supersede: [],
768
- warnings: [],
769
- };
770
- });
771
- }
772
- async get(id, options = {}) {
773
- const badId = this.#checkId(id);
774
- if (badId)
775
- return badId;
776
- if (options.version !== undefined) {
777
- const bad = this.#checkVersion("version", options.version);
778
- if (bad)
779
- return bad;
780
- }
781
- const current = await this.#store.head(id);
782
- if (!current)
783
- return this.#notFound(id);
784
- const { head } = current;
785
- const view = await this.#version(id, "view", head.view, options.version ?? head.view.version);
786
- if (!view) {
787
- return fail("not_found", `Artifact ${quoteId(id)} has no view version ${options.version}; the latest is ${head.view.version}.`);
788
- }
789
- const history = await this.#history(id, "view", head.view);
790
- return {
791
- ok: true,
792
- head,
793
- view,
794
- history,
795
- ...(options.includeSource === false ? {} : { source: await this.#body(view.body) }),
796
- };
797
- }
798
- async getDocument(id, name, version) {
799
- const badId = this.#checkId(id);
800
- if (badId)
801
- return badId;
802
- if (typeof name !== "string" || !name)
803
- return fail("invalid_args", "name is required.");
804
- const badName = this.#checkDocumentName(name);
805
- if (badName)
806
- return badName;
807
- if (version !== undefined) {
808
- const bad = this.#checkVersion("version", version);
809
- if (bad)
810
- return bad;
811
- }
812
- const current = await this.#store.head(id);
813
- if (!current)
814
- return this.#notFound(id);
815
- const latest = current.head.documents[name];
816
- if (!latest) {
817
- const names = liveDocuments(current.head).map(([candidate]) => candidate);
818
- return fail("not_found", `Artifact ${quoteId(id)} has no document '${name}' (it has: ${names.length ? names.join(", ") : "none"}).`);
819
- }
820
- const record = await this.#version(id, `doc:${name}`, latest, version ?? latest.version);
821
- if (!record) {
822
- return fail("not_found", `Document '${name}' has no version ${version}; the latest is ${latest.version}.`);
823
- }
824
- const value = record.removed ? null : JSON.parse(await this.#body(record.body));
825
- const history = await this.#history(id, `doc:${name}`, latest);
826
- return { ok: true, head: current.head, record, value, history };
827
- }
828
- /** The page a viewer renders: a view version and exactly the documents asked for (all current ones by default). */
829
- async page(id, pin = {}, theme) {
830
- const badId = this.#checkId(id);
831
- if (badId)
832
- return badId;
833
- const current = await this.#store.head(id);
834
- if (!current)
835
- return this.#notFound(id);
836
- const { head } = current;
837
- const view = await this.#version(id, "view", head.view, pin.view ?? head.view.version);
838
- if (!view)
839
- return this.#notFound(id);
840
- const wanted = pin.documents
841
- ? Object.entries(pin.documents)
842
- : liveDocuments(head).map(([name, record]) => [name, record.version]);
843
- // Historical streams were each valid when written, but their selected
844
- // versions may never have coexisted. Bound the selection before loading
845
- // its bodies, then check the actual bodies against today's configuration.
846
- const selected = [];
847
- for (const [name, version] of wanted) {
848
- const badName = this.#checkDocumentName(name);
849
- if (badName)
850
- return badName;
851
- const record = await this.#version(id, `doc:${name}`, head.documents[name], version);
852
- if (!record)
853
- return this.#notFound(id);
854
- if (!record.removed)
855
- selected.push([name, record]);
856
- }
857
- const { limits } = this.#context;
858
- const tooMany = checkDocumentTotals(selected.length, 0, limits);
859
- if (tooMany)
860
- return invalidContent("The snapshot", finish({ errors: [tooMany], warnings: [] }));
861
- const source = await this.#body(view.body);
862
- const sourceBytes = utf8Bytes(source);
863
- if (sourceBytes > limits.sourceBytes) {
864
- return invalidContent("The snapshot", finish({ errors: [{
865
- code: "E_TOO_LARGE", severity: "error",
866
- message: `The view is ${sourceBytes} bytes; the limit is ${limits.sourceBytes}.`,
867
- }], warnings: [] }));
868
- }
869
- const documents = {};
870
- const data = {};
871
- let dataBytes = 0;
872
- for (const [name, record] of selected) {
873
- const value = JSON.parse(await this.#body(record.body));
874
- const checked = checkDocument(name, value, limits);
875
- if (checked.error)
876
- return invalidContent("The snapshot", finish({ errors: [checked.error], warnings: [] }));
877
- dataBytes += checked.bytes;
878
- const totals = checkDocumentTotals(selected.length, dataBytes, limits);
879
- if (totals)
880
- return invalidContent("The snapshot", finish({ errors: [totals], warnings: [] }));
881
- documents[name] = { record, value };
882
- data[name] = checked.text;
883
- }
884
- const tooLarge = () => invalidContent("The snapshot", finish({ errors: [{
885
- code: "E_TOO_LARGE", severity: "error",
886
- message: `The rendered page exceeds the current limit of ${limits.renderedBytes} bytes.`,
887
- }], warnings: [] }));
888
- // For HTML, source plus data is a lower bound before frame construction.
889
- // Markdown can shrink or expand, so its final document decides the bound.
890
- if (dataBytes + (head.kind === "html" ? sourceBytes : 0) > limits.renderedBytes)
891
- return tooLarge();
892
- const rendered = buildFrameDocument({
893
- kind: head.kind, source, ...(theme ? { theme } : {}),
894
- global: {
895
- id, title: head.title, view: { version: view.version },
896
- documents: Object.fromEntries(selected.map(([name, record]) => [name, {
897
- version: record.version, updatedAt: record.at,
898
- }])),
899
- snapshot: pin.view !== undefined || pin.documents !== undefined,
900
- },
901
- data,
902
- });
903
- if (utf8Bytes(rendered) > limits.renderedBytes)
904
- return tooLarge();
905
- return { ok: true, head, view, source, document: rendered, documents };
906
- }
907
- async list(options) {
908
- if (options.cursor !== undefined && !ARTIFACT_ID.test(options.cursor)) {
909
- return fail("invalid_args", "cursor is not one this listing returned.");
910
- }
911
- const needle = options.query?.trim().toLowerCase();
912
- const items = [];
913
- let after = options.cursor;
914
- let scanned = 0;
915
- for (;;) {
916
- const limit = Math.min(100, LIST_SCAN_LIMIT - scanned);
917
- const previous = after;
918
- const page = await this.#store.heads({
919
- ...(after === undefined ? {} : { after }),
920
- limit,
921
- });
922
- for (const item of page.heads) {
923
- scanned++;
924
- after = item.id;
925
- if (item.head.archived && !options.includeArchived)
926
- continue;
927
- if (needle &&
928
- !item.head.title.toLowerCase().includes(needle) &&
929
- !item.id.includes(needle))
930
- continue;
931
- items.push(item);
932
- if (items.length >= options.limit) {
933
- const more = page.next !== undefined || page.heads.at(-1)?.id !== item.id;
934
- return more ? { ok: true, items, nextCursor: item.id } : { ok: true, items };
935
- }
936
- }
937
- if (page.next === undefined)
938
- return { ok: true, items };
939
- if (previous !== undefined && page.next <= previous) {
940
- return fail("unavailable", "Artifact head scan did not advance; retry the listing.");
941
- }
942
- // Listed keys can disappear before the store reads them. Its cursor
943
- // still consumed the whole page, including those missing head records.
944
- after = page.next;
945
- scanned += Math.max(0, limit - page.heads.length);
946
- if (scanned >= LIST_SCAN_LIMIT) {
947
- return { ok: true, items, nextCursor: after };
948
- }
949
- }
950
- }
951
- }
952
- /**
953
- * Apply every edit to `source` at once, each against the original text: every
954
- * `find` must occur exactly once and no two may overlap. Returns the new text,
955
- * or every reason it could not.
956
- */
957
- export function applyPatch(source, edits) {
958
- const problems = [];
959
- const spans = [];
960
- for (const [index, edit] of edits.entries()) {
961
- const hits = [];
962
- for (let at = source.indexOf(edit.find); at >= 0 && hits.length < 4; at = source.indexOf(edit.find, at + 1)) {
963
- hits.push(at);
964
- }
965
- if (hits.length === 0) {
966
- problems.push(`edit ${index + 1}: find matched 0 times; copy it exactly from the current source (artifacts.get_artifact), whitespace included`);
967
- continue;
968
- }
969
- if (hits.length > 1) {
970
- let total = hits.length;
971
- if (total > 3) {
972
- total = 0;
973
- for (let at = source.indexOf(edit.find); at >= 0; at = source.indexOf(edit.find, at + 1))
974
- total++;
975
- }
976
- const lines = hits.slice(0, 3).map((at) => lineOf(source, at));
977
- problems.push(`edit ${index + 1}: find matched ${total} times (lines ${lines.join(", ")}${total > 3 ? ", …" : ""}); ` +
978
- "include surrounding text so it matches once");
979
- continue;
980
- }
981
- const start = hits[0] ?? 0;
982
- spans.push({ start, end: start + edit.find.length, replace: edit.replace, index });
983
- }
984
- spans.sort((a, b) => a.start - b.start);
985
- for (let i = 1; i < spans.length; i++) {
986
- const previous = spans[i - 1];
987
- const current = spans[i];
988
- if (previous && current && current.start < previous.end) {
989
- problems.push(`edits ${Math.min(previous.index, current.index) + 1} and ${Math.max(previous.index, current.index) + 1} overlap ` +
990
- `(line ${lineOf(source, current.start)}); merge them into one edit`);
991
- }
992
- }
993
- if (problems.length)
994
- return problems.slice(0, 20);
995
- let out = "";
996
- let cursor = 0;
997
- for (const span of spans) {
998
- out += source.slice(cursor, span.start) + span.replace;
999
- cursor = span.end;
1000
- }
1001
- return out + source.slice(cursor);
1002
- }