canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Writing transform outputs into the store. Server-only.
3
+ *
4
+ * `storeTransform` computes one key and is shared by the authenticated raw route
5
+ * (api/assets.ts), `materializeAssets` (the batch run that writes every key a build references
6
+ * before that build is released) and canopycms-cdk's lazy transform Lambda.
7
+ */
8
+ import { getErrorMessage, isNodeError } from '../utils/error.js';
9
+ import { ASSET_PREFIXES } from './asset-prefixes.js';
10
+ import { loadSharp } from './sharp-loader.js';
11
+ import { applyTransform } from './transform.js';
12
+ import { canonicalizeTransformPath } from './transform-directives.js';
13
+ /** Every transform output is stored under a content-addressed key, so it never changes. */
14
+ export const TRANSFORM_CACHE_CONTROL = 'public, max-age=31536000, immutable';
15
+ /**
16
+ * The object tag on every derivative the lazy transform Lambda writes. Its bucket's `assets/t/`
17
+ * expiry filters on it, so what `materializeAssets` writes, which the Lambda's allowlist may refuse
18
+ * to recompute, is never expired. canopycms-cdk's `AssetSupport` copies these two strings as
19
+ * literals.
20
+ */
21
+ export const LAZY_TRANSFORM_TAG = { key: 'canopy-transform', value: 'lazy' };
22
+ /**
23
+ * Compute the transform `parsed` names from the asset's original and write it under `key`: the
24
+ * canonical key, or that key beneath an output prefix. `parsed` must come from
25
+ * `canonicalizeTransformPath`, so the stored pixels always match their key. A rejection is a fact
26
+ * about the asset or the URL and is never worth retrying; a thrown error belongs to the store or
27
+ * the environment. A 404's `error` names what was missing, so a caller answering a browser
28
+ * replaces it.
29
+ */
30
+ export async function storeTransform(store, parsed, key, options = {}) {
31
+ const meta = await store.getMeta(parsed.hash32);
32
+ if (!meta) {
33
+ return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no meta in the store` };
34
+ }
35
+ if (meta.kind !== 'raster') {
36
+ return { ok: false, status: 400, error: 'Not a raster asset - svg/pdf are served statically' };
37
+ }
38
+ // The slug is decorative in the URL but load-bearing in the stored key, so it must equal the
39
+ // asset's real slug — the parser only enforces `[a-z0-9-]+`, and any other string that passes
40
+ // it aliases the same image into a new cache key.
41
+ if (parsed.slug !== meta.slug) {
42
+ return {
43
+ ok: false,
44
+ status: 404,
45
+ error: `Slug '${parsed.slug}' is not asset ${parsed.hash32}'s slug '${meta.slug}'`,
46
+ };
47
+ }
48
+ // Without an explicit `f=`, the transform keeps the source format, so the URL's `{ext}` must be
49
+ // the source's real ext. The parser cannot check this: it does not know the source format.
50
+ const requestedFormat = parsed.directives.identity ? undefined : parsed.directives.format;
51
+ if (requestedFormat === undefined && parsed.ext !== meta.ext) {
52
+ return { ok: false, status: 400, error: 'Extension does not match the source format' };
53
+ }
54
+ const original = await store.readOriginal(parsed.hash32, meta.ext);
55
+ if (!original) {
56
+ return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no original in the store` };
57
+ }
58
+ const transformed = await applyTransform({ data: original.data, ext: original.ext }, parsed.directives);
59
+ if (!transformed.ok) {
60
+ return { ok: false, status: transformed.status, error: transformed.error };
61
+ }
62
+ const stored = await store.putPublicObject({
63
+ key,
64
+ data: transformed.data,
65
+ contentType: transformed.contentType,
66
+ cacheControl: TRANSFORM_CACHE_CONTROL,
67
+ tags: options.tags,
68
+ });
69
+ return { ok: true, data: transformed.data, contentType: transformed.contentType, stored };
70
+ }
71
+ /** The `schemaVersion` of every `MaterializeReport`; a consumer gating on the JSON checks it. */
72
+ export const MATERIALIZE_REPORT_SCHEMA_VERSION = 1;
73
+ /** sharp cannot load in this process, so nothing missing can be produced here. */
74
+ export class SharpUnavailableError extends Error {
75
+ constructor(cause) {
76
+ super(`sharp failed to load, so the missing transform outputs cannot be produced in this ` +
77
+ `environment: ${getErrorMessage(cause)}. Install sharp for this platform and rerun.`);
78
+ this.name = 'SharpUnavailableError';
79
+ }
80
+ }
81
+ /** `MaterializeOptions.outputPrefix` broke a rule of `assertValidOutputPrefix`; nothing was read or written. */
82
+ export class InvalidOutputPrefixError extends Error {
83
+ constructor(prefix, reason) {
84
+ super(`Invalid output prefix ${JSON.stringify(prefix)}: ${reason}`);
85
+ this.name = 'InvalidOutputPrefixError';
86
+ }
87
+ }
88
+ const OUTPUT_PREFIX_SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
89
+ const CANOPY_PREFIX_SEGMENTS = Object.values(ASSET_PREFIXES).map((prefix) => prefix.split('/'));
90
+ /**
91
+ * Throws `InvalidOutputPrefixError` unless `prefix` is relative, ends in `/`, and is a run of
92
+ * `[A-Za-z0-9._-]` segments, none empty, `.` or `..`, whose first segments are not a canopy
93
+ * prefix. Production trusts whatever is under its own prefixes, so a write beneath one is a write
94
+ * production serves. Compared by segment: `assets-x/` is accepted, `assets/x/` is not.
95
+ */
96
+ export function assertValidOutputPrefix(prefix) {
97
+ const refuse = (reason) => {
98
+ throw new InvalidOutputPrefixError(prefix, reason);
99
+ };
100
+ if (prefix.startsWith('/'))
101
+ refuse('it must be relative, with no leading /');
102
+ if (!prefix.endsWith('/'))
103
+ refuse('it must end in /');
104
+ const segments = prefix.slice(0, -1).split('/');
105
+ for (const segment of segments) {
106
+ if (segment === '')
107
+ refuse('it has an empty segment');
108
+ if (segment === '.' || segment === '..')
109
+ refuse(`it has a ${segment} segment`);
110
+ if (!OUTPUT_PREFIX_SEGMENT_RE.test(segment)) {
111
+ refuse(`segment ${JSON.stringify(segment)} has a character outside [A-Za-z0-9._-]`);
112
+ }
113
+ }
114
+ const canopy = CANOPY_PREFIX_SEGMENTS.find((prefixSegments) => prefixSegments.every((segment, i) => segments[i] === segment));
115
+ if (canopy)
116
+ refuse(`it begins with the canopy prefix ${canopy.join('/')}/`);
117
+ }
118
+ const DEFAULT_CONCURRENCY = 8;
119
+ const DEFAULT_TRANSFORM_CONCURRENCY = 2;
120
+ // eslint-disable-next-line security/detect-non-literal-regexp -- built from a constant
121
+ const STATIC_KEY_RE = new RegExp(`^${ASSET_PREFIXES.public}/[a-f0-9]{32}/[a-z0-9-]+\\.[a-z0-9]{1,10}$`);
122
+ const DEFAULT_LIST_THRESHOLD = 100;
123
+ const DEFAULT_ATTEMPTS = 3;
124
+ const DEFAULT_BASE_DELAY_MS = 500;
125
+ const TRANSIENT_NODE_CODES = new Set([
126
+ 'ECONNRESET',
127
+ 'ECONNREFUSED',
128
+ 'ETIMEDOUT',
129
+ 'EPIPE',
130
+ 'EAI_AGAIN',
131
+ 'EBUSY',
132
+ 'EMFILE',
133
+ ]);
134
+ const TRANSIENT_ERROR_NAMES = new Set([
135
+ 'TimeoutError',
136
+ 'RequestTimeout',
137
+ 'RequestTimeoutException',
138
+ 'SlowDown',
139
+ 'Throttling',
140
+ 'ThrottlingException',
141
+ 'ServiceUnavailable',
142
+ 'InternalError',
143
+ ]);
144
+ /**
145
+ * @internal Exported for tests. Throttling, 5xx and dropped connections are transient. Any other
146
+ * 4xx (AccessDenied, NoSuchBucket) is a configuration fault that a retry only delays.
147
+ */
148
+ export function isTransientStoreError(err) {
149
+ if (!(err instanceof Error))
150
+ return false;
151
+ if (isNodeError(err) && err.code && TRANSIENT_NODE_CODES.has(err.code))
152
+ return true;
153
+ const shaped = err;
154
+ if (shaped.$retryable)
155
+ return true;
156
+ if (TRANSIENT_ERROR_NAMES.has(err.name))
157
+ return true;
158
+ const status = shaped.$metadata?.httpStatusCode;
159
+ return status !== undefined && (status === 429 || status >= 500);
160
+ }
161
+ /** Run `fn` over `items` with at most `limit` in flight. `fn` must not reject. */
162
+ async function forEachBounded(items, limit, fn) {
163
+ let next = 0;
164
+ const worker = async () => {
165
+ while (next < items.length) {
166
+ const item = items[next++];
167
+ await fn(item);
168
+ }
169
+ };
170
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
171
+ }
172
+ /**
173
+ * Make every referenced transform key exist in `store`, and check every static key does. A key
174
+ * already stored is left alone:
175
+ * keys are content-addressed, so an existing object is the right one. sharp is loaded only when
176
+ * something must be transformed, and a sharp that cannot load throws `SharpUnavailableError` rather
177
+ * than failing each key. An invalid `outputPrefix` throws `InvalidOutputPrefixError` first.
178
+ */
179
+ export async function materializeAssets(options) {
180
+ const { store, outputPrefix } = options;
181
+ if (outputPrefix !== undefined)
182
+ assertValidOutputPrefix(outputPrefix);
183
+ const destOf = (key) => (outputPrefix ?? '') + key;
184
+ const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
185
+ const transformConcurrency = Math.max(1, options.transformConcurrency ?? DEFAULT_TRANSFORM_CONCURRENCY);
186
+ const listThreshold = options.listThreshold ?? DEFAULT_LIST_THRESHOLD;
187
+ const attempts = Math.max(1, options.attempts ?? DEFAULT_ATTEMPTS);
188
+ const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
189
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
190
+ const withRetry = async (operation) => {
191
+ for (let attempt = 1;; attempt++) {
192
+ try {
193
+ return await operation();
194
+ }
195
+ catch (err) {
196
+ if (attempt >= attempts || !isTransientStoreError(err))
197
+ throw err;
198
+ await sleep(baseDelayMs * 2 ** (attempt - 1) * (1 + Math.random()));
199
+ }
200
+ }
201
+ };
202
+ const references = new Map();
203
+ const staticKeys = new Set((options.statics ?? []).map((target) => target.key));
204
+ for (const target of [...options.targets, ...(options.statics ?? [])]) {
205
+ const entry = references.get(target.key) ?? { routes: new Set(), files: new Set() };
206
+ target.routes.forEach((route) => entry.routes.add(route));
207
+ target.files.forEach((file) => entry.files.add(file));
208
+ references.set(target.key, entry);
209
+ }
210
+ const outcomes = new Map();
211
+ const fail = (key, failure, error) => outcomes.set(key, { status: 'failed', failure, error });
212
+ // The refs file is an editable file on disk, so every key is re-validated here.
213
+ const transformPrefix = `${ASSET_PREFIXES.transform}/`;
214
+ const valid = [];
215
+ const validStatics = [];
216
+ for (const key of references.keys()) {
217
+ if (staticKeys.has(key)) {
218
+ if (STATIC_KEY_RE.test(key))
219
+ validStatics.push(key);
220
+ else
221
+ fail(key, 'content', 'Not a static asset key (expected assets/{hash32}/{slug}.{ext})');
222
+ continue;
223
+ }
224
+ if (!key.startsWith(transformPrefix)) {
225
+ fail(key, 'content', `Not a transform key (expected a ${transformPrefix} prefix)`);
226
+ continue;
227
+ }
228
+ const segments = key.slice(transformPrefix.length).split('/');
229
+ const canonical = canonicalizeTransformPath(segments, 'any');
230
+ if (!canonical.ok) {
231
+ fail(key, 'content', `Invalid transform key: ${canonical.error}`);
232
+ }
233
+ else if (!canonical.isCanonical) {
234
+ fail(key, 'content', `Not canonical; the stored key would be ${transformPrefix}${canonical.canonicalPath}`);
235
+ }
236
+ else {
237
+ valid.push({ key, directives: segments[0], parsed: canonical });
238
+ }
239
+ }
240
+ const byDirectives = new Map();
241
+ for (const target of valid) {
242
+ const group = byDirectives.get(target.directives) ?? [];
243
+ group.push(target);
244
+ byDirectives.set(target.directives, group);
245
+ }
246
+ const listOnce = async (prefix) => {
247
+ if (!store.listPublicObjectKeys)
248
+ return undefined;
249
+ const listKeys = store.listPublicObjectKeys.bind(store);
250
+ try {
251
+ return new Set(await withRetry(async () => {
252
+ const keys = [];
253
+ for await (const key of listKeys(prefix))
254
+ keys.push(key);
255
+ return keys;
256
+ }));
257
+ }
258
+ catch {
259
+ // A listing that keeps failing costs only speed: its keys are HEADed instead, and each HEAD
260
+ // reports its own failure.
261
+ return undefined;
262
+ }
263
+ };
264
+ const listingsByDirectives = new Map();
265
+ if (store.listPublicObjectKeys) {
266
+ for (const [directives, group] of byDirectives) {
267
+ if (group.length < listThreshold)
268
+ continue;
269
+ const listPrefix = `${transformPrefix}${directives}/`;
270
+ const dest = await listOnce(destOf(listPrefix));
271
+ const needsCanonical = outputPrefix !== undefined && group.some((target) => !dest?.has(destOf(target.key)));
272
+ const canonical = needsCanonical ? await listOnce(listPrefix) : undefined;
273
+ listingsByDirectives.set(directives, { dest, canonical });
274
+ }
275
+ }
276
+ // Every presence check goes through here. Without an output prefix a key's destination is the
277
+ // key itself, so this is one listing lookup or HEAD and never `canonical`.
278
+ const resolvePresence = async (key, listings = {}) => {
279
+ const isStored = async (storeKey, listing) => listing ? listing.has(storeKey) : withRetry(() => store.hasPublicObject(storeKey));
280
+ const dest = destOf(key);
281
+ if (await isStored(dest, listings.dest))
282
+ return 'dest';
283
+ if (dest === key)
284
+ return 'absent';
285
+ return (await isStored(key, listings.canonical)) ? 'canonical' : 'absent';
286
+ };
287
+ const presence = new Map();
288
+ await forEachBounded(valid, concurrency, async (target) => {
289
+ try {
290
+ presence.set(target.key, await resolvePresence(target.key, listingsByDirectives.get(target.directives)));
291
+ }
292
+ catch (err) {
293
+ fail(target.key, 'store', `Existence check failed: ${getErrorMessage(err)}`);
294
+ }
295
+ });
296
+ await forEachBounded(validStatics, concurrency, async (key) => {
297
+ try {
298
+ let found = await resolvePresence(key);
299
+ // A HEAD cannot tell a missing key from a missing bucket, and a static key has no
300
+ // transform's meta read to tell them apart afterwards; a GET names which.
301
+ if (found === 'absent' && (await withRetry(() => store.readPublicObject(key))) !== null) {
302
+ found = destOf(key) === key ? 'dest' : 'canonical';
303
+ }
304
+ presence.set(key, found);
305
+ }
306
+ catch (err) {
307
+ fail(key, 'store', `Existence check failed: ${getErrorMessage(err)}`);
308
+ }
309
+ });
310
+ const toCopy = [];
311
+ for (const [key, found] of presence) {
312
+ if (found === 'dest')
313
+ outcomes.set(key, { status: 'existed' });
314
+ else if (found === 'canonical')
315
+ toCopy.push(key);
316
+ }
317
+ await forEachBounded(toCopy, concurrency, async (key) => {
318
+ try {
319
+ let tries = 0;
320
+ const copied = await withRetry(() => {
321
+ tries++;
322
+ return store.copyPublicObject(key, destOf(key));
323
+ });
324
+ if (copied === 'source-missing') {
325
+ presence.set(key, 'absent');
326
+ }
327
+ else {
328
+ // After a failed try, `already-exists` may be that try's own copy, as for a transform.
329
+ const copiedElsewhere = copied === 'already-exists' && tries === 1;
330
+ outcomes.set(key, { status: copiedElsewhere ? 'existed' : 'copied' });
331
+ }
332
+ }
333
+ catch (err) {
334
+ fail(key, 'store', `Copy failed: ${getErrorMessage(err)}`);
335
+ }
336
+ });
337
+ for (const key of validStatics) {
338
+ if (presence.get(key) === 'absent') {
339
+ fail(key, 'content', 'No stored object; an svg or pdf is written at upload only');
340
+ }
341
+ }
342
+ const missing = valid.filter((target) => presence.get(target.key) === 'absent');
343
+ if (missing.length > 0) {
344
+ try {
345
+ await loadSharp();
346
+ }
347
+ catch (err) {
348
+ throw new SharpUnavailableError(err);
349
+ }
350
+ }
351
+ await forEachBounded(missing, transformConcurrency, async (target) => {
352
+ try {
353
+ let tries = 0;
354
+ const result = await withRetry(() => {
355
+ tries++;
356
+ return storeTransform(store, target.parsed, destOf(target.key));
357
+ });
358
+ if (result.ok) {
359
+ // After a failed attempt, `already-exists` may be that attempt's own write, whose response
360
+ // was lost, so it counts as `created`: a release waiting on its `created` keys then waits
361
+ // on a key it may not have written, never skips one it did.
362
+ const createdElsewhere = result.stored === 'already-exists' && tries === 1;
363
+ outcomes.set(target.key, { status: createdElsewhere ? 'existed' : 'created' });
364
+ }
365
+ else {
366
+ fail(target.key, 'content', `${result.status}: ${result.error}`);
367
+ }
368
+ }
369
+ catch (err) {
370
+ fail(target.key, 'store', getErrorMessage(err));
371
+ }
372
+ });
373
+ const results = [...references.keys()].sort().map((key) => {
374
+ const refs = references.get(key);
375
+ const outcome = outcomes.get(key) ?? {
376
+ status: 'failed',
377
+ failure: 'store',
378
+ error: 'No outcome recorded',
379
+ };
380
+ return {
381
+ key,
382
+ routes: [...(refs?.routes ?? [])].sort(),
383
+ files: [...(refs?.files ?? [])].sort(),
384
+ ...outcome,
385
+ };
386
+ });
387
+ const count = (predicate) => results.filter(predicate).length;
388
+ return {
389
+ schemaVersion: MATERIALIZE_REPORT_SCHEMA_VERSION,
390
+ ...(outputPrefix !== undefined ? { outputPrefix } : {}),
391
+ summary: {
392
+ total: results.length,
393
+ existed: count((r) => r.status === 'existed'),
394
+ created: count((r) => r.status === 'created'),
395
+ copied: count((r) => r.status === 'copied'),
396
+ failed: count((r) => r.status === 'failed'),
397
+ contentFailures: count((r) => r.status === 'failed' && r.failure === 'content'),
398
+ storeFailures: count((r) => r.status === 'failed' && r.failure === 'store'),
399
+ },
400
+ results,
401
+ };
402
+ }
@@ -3,7 +3,7 @@
3
3
  * bucket-prefix layout as S3AssetStore (see keys.ts) so content behaves
4
4
  * identically across adapters.
5
5
  */
6
- import type { AssetMeta, AssetStore, BeginUploadInput, PublicObject, StagedUploadTarget } from './types.js';
6
+ import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, PublicObject, StagedUploadTarget } from './types.js';
7
7
  export interface LocalAssetStoreOptions {
8
8
  /** Root directory. The five bucket prefixes are created as subdirectories under it. */
9
9
  root: string;
@@ -26,11 +26,20 @@ export declare class LocalAssetStore implements AssetStore {
26
26
  */
27
27
  private resolveKey;
28
28
  private headersSidecarPath;
29
- private writeHeadersSidecar;
30
29
  private readHeadersSidecar;
31
30
  private readFileOrNull;
32
31
  private writeFile;
33
32
  private deleteFileIfPresent;
33
+ /** Link `tempPath` in at `filePath`; `false` when something is already there. */
34
+ private linkIfAbsent;
35
+ /**
36
+ * Create-only write of a blob and its headers sidecar. Each is written whole to a temp file, then
37
+ * `link()`ed into place, which fails with EEXIST rather than replacing (see docs/concurrency.md).
38
+ * The sidecar is linked first, so a reader never finds a blob without its headers. A sidecar
39
+ * already there was linked by an earlier writer of this key and is kept, as the blob is: the
40
+ * first writer's headers win, as on S3 (a static's Content-Disposition names its uploaded file).
41
+ */
42
+ private createExclusive;
34
43
  beginUpload(_input: BeginUploadInput): Promise<StagedUploadTarget>;
35
44
  /**
36
45
  * Staging methods accept caller-influenced keys (finalize receives the key
@@ -47,7 +56,8 @@ export declare class LocalAssetStore implements AssetStore {
47
56
  ext: string;
48
57
  data: Uint8Array;
49
58
  contentType: string;
50
- }): Promise<void>;
59
+ }): Promise<CreateOnlyResult>;
60
+ /** Ignores the `ext` hint: a directory read costs nothing here and needs no permission. */
51
61
  readOriginal(hash32: string): Promise<{
52
62
  data: Uint8Array;
53
63
  ext: string;
@@ -59,8 +69,12 @@ export declare class LocalAssetStore implements AssetStore {
59
69
  contentType: string;
60
70
  contentDisposition?: string;
61
71
  cacheControl?: string;
62
- }): Promise<void>;
72
+ /** Ignored: tags exist for bucket lifecycle rules, and a local store has none. */
73
+ tags?: Readonly<Record<string, string>>;
74
+ }): Promise<CreateOnlyResult>;
75
+ copyPublicObject(sourceKey: string, destKey: string): Promise<CreateOnlyResult | 'source-missing'>;
63
76
  readPublicObject(key: string): Promise<PublicObject | null>;
77
+ hasPublicObject(key: string): Promise<boolean>;
64
78
  /**
65
79
  * Atomic-exclusive create: opens with the `wx` flag so two concurrent
66
80
  * writers racing on the same hash32 have exactly one winner ('created')
@@ -69,7 +83,7 @@ export declare class LocalAssetStore implements AssetStore {
69
83
  * always clobbers the destination, which would let the second writer
70
84
  * silently overwrite the first instead of losing cleanly.
71
85
  */
72
- putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<'created' | 'already-exists'>;
86
+ putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
73
87
  getMeta(hash32: string): Promise<AssetMeta | null>;
74
88
  listMeta(input?: {
75
89
  cursor?: string;
@@ -13,6 +13,13 @@ import { ASSET_PREFIXES, metaKey, originalKey, stagingKey } from './keys.js';
13
13
  const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
14
14
  const DEFAULT_LIST_LIMIT = 24;
15
15
  const HEADERS_SIDECAR_SUFFIX = '.headers.json';
16
+ /**
17
+ * Where create-only writes stage their bytes: under the root, so `link()` stays on one filesystem,
18
+ * and outside every prefix, so no read or listing of a prefix ever sees a partial file.
19
+ */
20
+ const TEMP_DIR = '.asset-tmp';
21
+ /** An original's file name after `{hash32}.`; a sidecar or any other name in the directory fails it. */
22
+ const ORIGINAL_EXT_RE = /^[a-z0-9]+$/;
16
23
  export class LocalAssetStore {
17
24
  constructor(options) {
18
25
  this.capabilities = { directUpload: false };
@@ -39,9 +46,6 @@ export class LocalAssetStore {
39
46
  headersSidecarPath(filePath) {
40
47
  return `${filePath}${HEADERS_SIDECAR_SUFFIX}`;
41
48
  }
42
- async writeHeadersSidecar(filePath, headers) {
43
- await atomicWriteFile(this.headersSidecarPath(filePath), JSON.stringify(headers));
44
- }
45
49
  async readHeadersSidecar(filePath) {
46
50
  try {
47
51
  const raw = await fs.readFile(this.headersSidecarPath(filePath), 'utf-8');
@@ -78,6 +82,44 @@ export class LocalAssetStore {
78
82
  // Best-effort sidecar cleanup; a missing sidecar is not an error.
79
83
  await fs.unlink(this.headersSidecarPath(filePath)).catch(() => { });
80
84
  }
85
+ /** Link `tempPath` in at `filePath`; `false` when something is already there. */
86
+ async linkIfAbsent(tempPath, filePath) {
87
+ try {
88
+ await fs.link(tempPath, filePath);
89
+ return true;
90
+ }
91
+ catch (err) {
92
+ if (isFileExistsError(err))
93
+ return false;
94
+ throw err;
95
+ }
96
+ }
97
+ /**
98
+ * Create-only write of a blob and its headers sidecar. Each is written whole to a temp file, then
99
+ * `link()`ed into place, which fails with EEXIST rather than replacing (see docs/concurrency.md).
100
+ * The sidecar is linked first, so a reader never finds a blob without its headers. A sidecar
101
+ * already there was linked by an earlier writer of this key and is kept, as the blob is: the
102
+ * first writer's headers win, as on S3 (a static's Content-Disposition names its uploaded file).
103
+ */
104
+ async createExclusive(key, data, headers) {
105
+ const filePath = this.resolveKey(key);
106
+ const tempDir = this.resolveKey(TEMP_DIR);
107
+ await Promise.all([
108
+ fs.mkdir(tempDir, { recursive: true }),
109
+ fs.mkdir(path.dirname(filePath), { recursive: true }),
110
+ ]);
111
+ const tempBlob = path.join(tempDir, randomUUID());
112
+ const tempHeaders = path.join(tempDir, randomUUID());
113
+ try {
114
+ await fs.writeFile(tempBlob, data);
115
+ await fs.writeFile(tempHeaders, JSON.stringify(headers), 'utf-8');
116
+ await this.linkIfAbsent(tempHeaders, this.headersSidecarPath(filePath));
117
+ return (await this.linkIfAbsent(tempBlob, filePath)) ? 'created' : 'already-exists';
118
+ }
119
+ finally {
120
+ await Promise.all([tempBlob, tempHeaders].map((temp) => fs.unlink(temp).catch(() => { })));
121
+ }
122
+ }
81
123
  async beginUpload(_input) {
82
124
  return {
83
125
  mode: 'proxied',
@@ -109,10 +151,11 @@ export class LocalAssetStore {
109
151
  await this.deleteFileIfPresent(this.resolveKey(key));
110
152
  }
111
153
  async putOriginal(input) {
112
- const filePath = this.resolveKey(originalKey(input.hash32, input.ext));
113
- await this.writeFile(filePath, input.data);
114
- await this.writeHeadersSidecar(filePath, { contentType: input.contentType });
154
+ return this.createExclusive(originalKey(input.hash32, input.ext), input.data, {
155
+ contentType: input.contentType,
156
+ });
115
157
  }
158
+ /** Ignores the `ext` hint: a directory read costs nothing here and needs no permission. */
116
159
  async readOriginal(hash32) {
117
160
  const dir = this.resolveKey(ASSET_PREFIXES.originals);
118
161
  let entries;
@@ -125,7 +168,7 @@ export class LocalAssetStore {
125
168
  throw err;
126
169
  }
127
170
  const prefix = `${hash32}.`;
128
- const match = entries.find((name) => name.startsWith(prefix) && !name.endsWith(HEADERS_SIDECAR_SUFFIX));
171
+ const match = entries.find((name) => name.startsWith(prefix) && ORIGINAL_EXT_RE.test(name.slice(prefix.length)));
129
172
  if (!match)
130
173
  return null;
131
174
  const filePath = path.join(dir, match);
@@ -136,14 +179,20 @@ export class LocalAssetStore {
136
179
  return { data, ext: match.slice(prefix.length), contentType: headers.contentType };
137
180
  }
138
181
  async putPublicObject(input) {
139
- const filePath = this.resolveKey(input.key);
140
- await this.writeFile(filePath, input.data);
141
- await this.writeHeadersSidecar(filePath, {
182
+ return this.createExclusive(input.key, input.data, {
142
183
  contentType: input.contentType,
143
184
  contentDisposition: input.contentDisposition,
144
185
  cacheControl: input.cacheControl,
145
186
  });
146
187
  }
188
+ async copyPublicObject(sourceKey, destKey) {
189
+ const sourcePath = this.resolveKey(sourceKey);
190
+ const data = await this.readFileOrNull(sourcePath);
191
+ if (!data)
192
+ return 'source-missing';
193
+ const { contentType, contentDisposition, cacheControl } = await this.readHeadersSidecar(sourcePath);
194
+ return this.createExclusive(destKey, data, { contentType, contentDisposition, cacheControl });
195
+ }
147
196
  async readPublicObject(key) {
148
197
  const filePath = this.resolveKey(key);
149
198
  const data = await this.readFileOrNull(filePath);
@@ -157,6 +206,16 @@ export class LocalAssetStore {
157
206
  cacheControl: headers.cacheControl,
158
207
  };
159
208
  }
209
+ async hasPublicObject(key) {
210
+ try {
211
+ return (await fs.stat(this.resolveKey(key))).isFile();
212
+ }
213
+ catch (err) {
214
+ if (isNotFoundError(err))
215
+ return false;
216
+ throw err;
217
+ }
218
+ }
160
219
  /**
161
220
  * Atomic-exclusive create: opens with the `wx` flag so two concurrent
162
221
  * writers racing on the same hash32 have exactly one winner ('created')
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * S3-backed AssetStore. Same bucket-prefix layout as LocalAssetStore (see
3
3
  * keys.ts). Assumes an EXISTING content bucket (versioning/SSE/replication
4
- * already configured by the site's CDK stack) — this store only ever reads
5
- * and writes objects under the five asset prefixes.
4
+ * already configured by the site's CDK stack). It reads and writes under the
5
+ * five asset prefixes, plus a caller's output prefix for `copyPublicObject`.
6
6
  */
7
7
  import { type AssetPrefixes } from './keys.js';
8
- import type { AssetMeta, AssetStore, BeginUploadInput, PublicObject, StagedUploadTarget } from './types.js';
8
+ import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, PublicObject, StagedUploadTarget } from './types.js';
9
9
  export interface S3AssetStoreOptions {
10
10
  bucket: string;
11
11
  region: string;
@@ -23,6 +23,8 @@ export interface S3AssetStoreOptions {
23
23
  uploadUrl?: string;
24
24
  /** Override the default bucket-prefix layout (rarely needed). */
25
25
  prefixes?: AssetPrefixes;
26
+ /** @internal Test seam for the conditional-write retry delay. */
27
+ sleep?: (ms: number) => Promise<void>;
26
28
  }
27
29
  export declare class S3AssetStore implements AssetStore {
28
30
  readonly capabilities: {
@@ -34,7 +36,19 @@ export declare class S3AssetStore implements AssetStore {
34
36
  private readonly uploadUrl;
35
37
  private readonly keys;
36
38
  private readonly stagingPrefix;
39
+ private readonly sleep;
37
40
  constructor(options: S3AssetStoreOptions);
41
+ /**
42
+ * Sends one create-only request (`IfNoneMatch: '*'`), so S3 answers a taken key with 412. A
43
+ * `ConditionalRequestConflict` is retried here, the only retry layer for it, so a caller with no
44
+ * retry of its own (the lazy transform Lambda, finalize) ends in one of the two results.
45
+ *
46
+ * With `retriedIsCreated`, a 412 after any error the SDK had already retried counts as
47
+ * `created`: one of those attempts may have committed and lost its response. Public objects use
48
+ * it; meta does not, since a meta `already-exists` sends finalize to read the stored winner.
49
+ */
50
+ private createIfAbsent;
51
+ private putIfAbsent;
38
52
  /**
39
53
  * Staging methods accept caller-influenced keys (finalize receives the key
40
54
  * from the client), so they must never operate outside the staging prefix —
@@ -59,8 +73,8 @@ export declare class S3AssetStore implements AssetStore {
59
73
  ext: string;
60
74
  data: Uint8Array;
61
75
  contentType: string;
62
- }): Promise<void>;
63
- readOriginal(hash32: string): Promise<{
76
+ }): Promise<CreateOnlyResult>;
77
+ readOriginal(hash32: string, ext?: string): Promise<{
64
78
  data: Uint8Array;
65
79
  ext: string;
66
80
  contentType?: string;
@@ -71,9 +85,26 @@ export declare class S3AssetStore implements AssetStore {
71
85
  contentType: string;
72
86
  contentDisposition?: string;
73
87
  cacheControl?: string;
74
- }): Promise<void>;
88
+ tags?: Readonly<Record<string, string>>;
89
+ }): Promise<CreateOnlyResult>;
90
+ /**
91
+ * A server-side CopyObject. S3 URL-decodes `CopySource`, and keys hold `=`, `,` and `:`, so each
92
+ * segment is encoded and the `/` between them is not. `MetadataDirective: 'COPY'` keeps the
93
+ * source's Content-Type, Cache-Control and Content-Disposition; `TaggingDirective: 'REPLACE'`
94
+ * with no `Tagging` leaves the copy untagged and needs no tagging permission, even from a
95
+ * lazy-tagged source; `COPY` would need `s3:GetObjectTagging` and `s3:PutObjectTagging`.
96
+ */
97
+ copyPublicObject(sourceKey: string, destKey: string): Promise<CreateOnlyResult | 'source-missing'>;
75
98
  readPublicObject(key: string): Promise<PublicObject | null>;
76
- putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<'created' | 'already-exists'>;
99
+ /**
100
+ * A HEAD. S3 answers a missing key with 403, not 404, unless the caller may `s3:ListBucket`,
101
+ * so a role without it sees an error here rather than `false`.
102
+ */
103
+ hasPublicObject(key: string): Promise<boolean>;
104
+ listPublicObjectKeys(prefix: string): AsyncIterable<string>;
105
+ /** HEADs first: signing is local and succeeds for a missing key too. */
106
+ presignPublicObjectRead(key: string): Promise<string | null>;
107
+ putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
77
108
  getMeta(hash32: string): Promise<AssetMeta | null>;
78
109
  listMeta(input?: {
79
110
  cursor?: string;