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,501 @@
1
+ /**
2
+ * Crash-safe provisioning of content-branch workspaces.
3
+ *
4
+ * [PROV-1] A final branch directory only ever appears by `rename()`, with
5
+ * `.canopy-meta/branch.json` already inside it. A Lambda can be killed at its
6
+ * timeout inside any git step, and anything it was building at the final path
7
+ * would wedge that branch name for every later request. So a workspace is built
8
+ * in a dot-prefixed staging sibling without any lock (the registry,
9
+ * branch-health, the rebase loop and admin all skip dot-prefixed names), then
10
+ * published by one rename under the branch's provisioning lock, held for
11
+ * milliseconds. A kill at any point leaves nothing at the final name or a
12
+ * complete workspace; the worker sweeps the leftovers.
13
+ *
14
+ * The rename alone does not settle a race: it silently replaces an EMPTY
15
+ * directory, and a refused rename may be blocked by a live branch or by
16
+ * residue. Hence the lock, and the inspection of whatever blocks the rename.
17
+ * docs/concurrency.md has the cross-host picture.
18
+ */
19
+ import { createHash, randomBytes } from 'node:crypto';
20
+ import fs from 'node:fs/promises';
21
+ import path from 'node:path';
22
+ import { BRANCH_META_DIR, BRANCH_META_FILE, BranchMetadataCorruptError, readBranchMetadataFile, } from './branch-metadata-file.js';
23
+ import { GitManager } from './git-manager.js';
24
+ import { sanitizeBranchName } from './paths/branch-name.js';
25
+ import { getErrorMessage, isNodeError, isNotFoundError } from './utils/error.js';
26
+ import { isNetworkRemoteUrl } from './utils/git.js';
27
+ import { canopyLogError, canopyLogWarn } from './utils/logger.js';
28
+ import { writeOccJsonFile } from './utils/occ-json-write.js';
29
+ import { acquireProvisioningLockWithin, branchProvisioningLockName, tryAcquireProvisioningLock, } from './utils/provisioning-lock.js';
30
+ /**
31
+ * How long a publish waits for the branch's provisioning lock. Holds on a name being published
32
+ * are brief; a dead holder's marker stays live for 90 s, and waiting that out would spend the
33
+ * request's timeout.
34
+ */
35
+ const PUBLISH_LOCK_WAIT_MS = 8_000;
36
+ /** How long residue must have been untouched before a create request moves it aside. */
37
+ export const CREATE_RESIDUE_MIN_QUIET_MS = 60_000;
38
+ /**
39
+ * Stamped leftovers older than these are swept by the worker. A `.prov-*` may belong to a live
40
+ * builder for as long as a Lambda can run (900 s at most, whatever timeout an adopter sets).
41
+ */
42
+ const STAGING_MAX_AGE_MS = 20 * 60_000;
43
+ const REPAIR_MAX_AGE_MS = 20 * 60_000;
44
+ const DELETING_MAX_AGE_MS = 10 * 60_000;
45
+ const SETTINGS_TRASH_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
46
+ /**
47
+ * Retriable: another process is setting this branch up, or what is at its path is too recently
48
+ * touched (or under an admin repair) to judge. Callers answer 503.
49
+ */
50
+ export class BranchProvisioningBusyError extends Error {
51
+ constructor(dirName, message = `Branch '${dirName}' is still being set up. Try again in a minute.`) {
52
+ super(message);
53
+ this.dirName = dirName;
54
+ this.name = 'BranchProvisioningBusyError';
55
+ }
56
+ }
57
+ /** The branch's path holds a git repository this deployment did not create (prod only). */
58
+ export class BranchDirOccupiedError extends Error {
59
+ constructor(dirName) {
60
+ super(`A directory named '${dirName}' already exists in the branch workspace and holds a git ` +
61
+ `repository this deployment did not create. It was left untouched; an admin can purge ` +
62
+ `it from System Health, or choose another branch name.`);
63
+ this.dirName = dirName;
64
+ this.name = 'BranchDirOccupiedError';
65
+ }
66
+ }
67
+ // --- Names -----------------------------------------------------------------------------------
68
+ const DIR_STAMP_RE = /-(\d{8}T\d{6}Z)$/;
69
+ /** `YYYYMMDDTHHMMSSZ` in UTC, with no colons. */
70
+ export function formatDirStamp(date) {
71
+ return date
72
+ .toISOString()
73
+ .replace(/[-:]/g, '')
74
+ .replace(/\.\d{3}Z$/, 'Z');
75
+ }
76
+ /**
77
+ * The date in a name ending `-{STAMP}`, or null. Leftovers are aged by this alone, never by
78
+ * mtime: `rename()` keeps a directory's mtime, so a months-old residue would look old the
79
+ * moment it was moved.
80
+ */
81
+ export function parseDirStamp(name) {
82
+ const match = DIR_STAMP_RE.exec(name);
83
+ if (!match)
84
+ return null;
85
+ const [, year, month, day, hour, minute, second] = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(match[1]) ?? [];
86
+ const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.000Z`);
87
+ return Number.isNaN(date.getTime()) ? null : date;
88
+ }
89
+ /**
90
+ * A branch directory name cut to a bounded, still-unique form: branch names run to 250 chars.
91
+ * @internal Exported for tests.
92
+ */
93
+ export function shortDirName(dirName) {
94
+ const digest = createHash('sha1').update(dirName).digest('hex').slice(0, 10);
95
+ return `${dirName.slice(0, 40)}-${digest}`;
96
+ }
97
+ function stampedName(prefix, dirName, now) {
98
+ return `${prefix}${shortDirName(dirName)}-${randomBytes(3).toString('hex')}-${formatDirStamp(now)}`;
99
+ }
100
+ const STAGING_PREFIX = '.prov-';
101
+ const REPAIR_PREFIX = '.repair-';
102
+ const TRASH_PREFIX = '.trash-';
103
+ const DELETING_PREFIX = '.deleting-';
104
+ /** Where a branch is built before it is published. */
105
+ const stagingDirName = (dirName, now = new Date()) => stampedName(STAGING_PREFIX, dirName, now);
106
+ /** Where a branch directory goes the moment it is deleted, before its `rm`. */
107
+ export const deletingDirName = (dirName, now = new Date()) => stampedName(DELETING_PREFIX, dirName, now);
108
+ /** The settings workspace's staging and trash siblings, under the workspace root. */
109
+ export const settingsStagingDirName = (now = new Date()) => `${STAGING_PREFIX}settings-${randomBytes(3).toString('hex')}-${formatDirStamp(now)}`;
110
+ export const settingsTrashDirName = (now = new Date()) => `${TRASH_PREFIX}settings-${randomBytes(3).toString('hex')}-${formatDirStamp(now)}`;
111
+ const NO_GIT = { hasGitDir: false, configLock: false, hasIndex: false };
112
+ async function listDir(dirPath) {
113
+ try {
114
+ return await fs.readdir(dirPath);
115
+ }
116
+ catch (err) {
117
+ if (isNodeError(err) && (err.code === 'ENOENT' || err.code === 'ENOTDIR'))
118
+ return null;
119
+ throw err;
120
+ }
121
+ }
122
+ /** Remote URLs in a git config file's text. Read as a file: git does not treat a `.git` that a
123
+ * kill left without `HEAD` as a repository, and a stale `config.lock` blocks only config writes. */
124
+ function gitConfigRemoteUrls(text) {
125
+ const urls = [];
126
+ let inRemote = false;
127
+ for (const raw of text.split('\n')) {
128
+ const line = raw.trim();
129
+ if (line.startsWith('[')) {
130
+ inRemote = /^\[\s*remote\s+"[^"]*"\s*\]/i.test(line);
131
+ continue;
132
+ }
133
+ const match = inRemote ? /^url\s*=\s*(.*)$/i.exec(line) : null;
134
+ if (match)
135
+ urls.push(match[1].trim().replace(/^"(.*)"$/, '$1'));
136
+ }
137
+ return urls;
138
+ }
139
+ function sameRemote(url, expected) {
140
+ if (isNetworkRemoteUrl(url) || isNetworkRemoteUrl(expected))
141
+ return url === expected;
142
+ const local = (value) => path.resolve(value.replace(/^file:\/\//i, ''));
143
+ return local(url) === local(expected);
144
+ }
145
+ /** Classify what is at `dirPath`; see {@link FinalDirState}. */
146
+ export async function classifyFinalDir(dirPath, expectedRemoteUrl) {
147
+ let stat;
148
+ try {
149
+ stat = await fs.lstat(dirPath);
150
+ }
151
+ catch (err) {
152
+ if (isNotFoundError(err))
153
+ return { kind: 'vacant' };
154
+ throw err;
155
+ }
156
+ if (!stat.isDirectory())
157
+ return { kind: 'residue', signature: NO_GIT };
158
+ const metaEntries = (await listDir(path.join(dirPath, BRANCH_META_DIR))) ?? [];
159
+ if (metaEntries.includes(BRANCH_META_FILE)) {
160
+ try {
161
+ return (await readBranchMetadataFile(dirPath)) ? { kind: 'live' } : { kind: 'protected' };
162
+ }
163
+ catch (err) {
164
+ if (err instanceof BranchMetadataCorruptError)
165
+ return { kind: 'corrupt' };
166
+ throw err;
167
+ }
168
+ }
169
+ if (metaEntries.some((name) => name.startsWith(`${BRANCH_META_FILE}.corrupt-`))) {
170
+ return { kind: 'protected' };
171
+ }
172
+ const gitPath = path.join(dirPath, '.git');
173
+ let gitStat;
174
+ try {
175
+ gitStat = await fs.lstat(gitPath);
176
+ }
177
+ catch (err) {
178
+ if (isNotFoundError(err))
179
+ return { kind: 'residue', signature: NO_GIT };
180
+ throw err;
181
+ }
182
+ if (!gitStat.isDirectory())
183
+ return { kind: 'foreign' };
184
+ const gitEntries = (await listDir(gitPath)) ?? [];
185
+ const signature = {
186
+ hasGitDir: true,
187
+ configLock: gitEntries.includes('config.lock'),
188
+ hasIndex: gitEntries.includes('index'),
189
+ };
190
+ let config;
191
+ try {
192
+ config = await fs.readFile(path.join(gitPath, 'config'), 'utf8');
193
+ }
194
+ catch {
195
+ return { kind: 'residue', signature };
196
+ }
197
+ return gitConfigRemoteUrls(config).some((url) => sameRemote(url, expectedRemoteUrl))
198
+ ? { kind: 'residue', signature }
199
+ : { kind: 'foreign' };
200
+ }
201
+ /**
202
+ * Milliseconds since anything was last created or removed in the directory, its `.git` or its
203
+ * `.canopy-meta`. A clone killed inside `git config` touched `.git` when it made `config.lock`.
204
+ */
205
+ export async function quietAgeMs(dirPath, now = Date.now()) {
206
+ const mtimes = await Promise.all(['', '.git', BRANCH_META_DIR].map((sub) => fs.lstat(path.join(dirPath, sub)).then((stat) => stat.mtimeMs, () => 0)));
207
+ return now - Math.max(...mtimes);
208
+ }
209
+ /**
210
+ * Move residue at `baseRoot/dirName` to `.trash-*`. The caller MUST hold the branch's
211
+ * provisioning lock: admin repair-metadata holds it while it legitimately leaves a directory
212
+ * with real edits and no branch.json.
213
+ *
214
+ * Move-then-verify: the name is renamed aside first and only what arrived at the new name is
215
+ * judged — the same inode the old name showed, still residue, quiet for `minQuietMs` — because
216
+ * a look through the old name can come from a stale NFS dentry for a directory another host has
217
+ * since replaced with a live clone. Anything that fails a check, or cannot be checked, is
218
+ * renamed straight back: the sweep would trash a `.repair-*` without branch.json.
219
+ */
220
+ export async function quarantineResidueAt(baseRoot, dirName, options) {
221
+ const finalPath = path.join(baseRoot, dirName);
222
+ let inode;
223
+ try {
224
+ inode = (await fs.lstat(finalPath)).ino;
225
+ }
226
+ catch (err) {
227
+ if (isNotFoundError(err))
228
+ return { kind: 'vacant' };
229
+ throw err;
230
+ }
231
+ const candidateName = stampedName(REPAIR_PREFIX, dirName, new Date());
232
+ const candidate = path.join(baseRoot, candidateName);
233
+ try {
234
+ await fs.rename(finalPath, candidate);
235
+ }
236
+ catch (err) {
237
+ if (isNotFoundError(err))
238
+ return { kind: 'vacant' };
239
+ throw err;
240
+ }
241
+ const moveBack = async (why) => {
242
+ try {
243
+ await fs.rename(candidate, finalPath);
244
+ }
245
+ catch (err) {
246
+ canopyLogError(`[canopy] Could not move '${dirName}' back from ${candidateName} after ${why}: ` +
247
+ `${getErrorMessage(err)}. The worker's sweep restores or trashes it.`);
248
+ }
249
+ };
250
+ const { state, quiet, reason } = await judgeQuarantineCandidate(candidate, inode, options).catch(async (err) => {
251
+ await moveBack(`failing to judge it (${getErrorMessage(err)})`);
252
+ throw err;
253
+ });
254
+ if (reason === undefined && state.kind === 'residue') {
255
+ const trashName = stampedName(TRASH_PREFIX, dirName, new Date());
256
+ await fs.rename(candidate, path.join(baseRoot, trashName));
257
+ const { hasGitDir, configLock, hasIndex } = state.signature;
258
+ canopyLogWarn(`[canopy] Quarantined unfinished branch directory '${dirName}' as ${trashName} ` +
259
+ `(git=${hasGitDir} config.lock=${configLock} index=${hasIndex} quiet=${Math.round(quiet / 1000)}s)`);
260
+ return { kind: 'quarantined', trashName };
261
+ }
262
+ await moveBack(`declining to quarantine it (${reason})`);
263
+ return { kind: 'kept', reason: reason ?? 'replaced' };
264
+ }
265
+ /** Judge what arrived at the quarantine candidate name; `reason` is set when it must be kept. */
266
+ async function judgeQuarantineCandidate(candidate, inode, options) {
267
+ const state = await classifyFinalDir(candidate, options.expectedRemoteUrl);
268
+ const moved = await fs.lstat(candidate).catch(() => null);
269
+ const quiet = await quietAgeMs(candidate, options.now);
270
+ let reason;
271
+ if (!moved || moved.ino !== inode)
272
+ reason = 'replaced';
273
+ else if (state.kind === 'vacant')
274
+ reason = 'replaced';
275
+ else if (state.kind !== 'residue')
276
+ reason = state.kind;
277
+ else if (quiet < options.minQuietMs)
278
+ reason = 'too-young';
279
+ return { state, quiet, reason };
280
+ }
281
+ /** Remove a staging or deleted directory, leaving it to the worker's sweep on failure. */
282
+ export async function removeLeftoverDir(dirPath) {
283
+ try {
284
+ await fs.rm(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
285
+ }
286
+ catch (err) {
287
+ canopyLogWarn(`[canopy] Could not remove ${dirPath}; the worker sweeps it: ${getErrorMessage(err)}`);
288
+ }
289
+ }
290
+ async function hasBranchMetadata(branchRoot) {
291
+ return fs.access(path.join(branchRoot, BRANCH_META_DIR, BRANCH_META_FILE)).then(() => true, () => false);
292
+ }
293
+ /**
294
+ * Build a complete branch workspace, branch.json included, in a staging sibling of the final
295
+ * path. Takes no lock. Returns `exists` without checking out when a competitor published first.
296
+ * A failure removes the staging directory; a kill leaves it for the worker.
297
+ */
298
+ export async function stageBranchWorkspace(options) {
299
+ const { provisionLog: log } = options;
300
+ const stagingPath = path.join(options.baseRoot, stagingDirName(options.dirName));
301
+ try {
302
+ await log.step('clone', () => GitManager.cloneWorkspace(options.remoteUrl, stagingPath, options.baseBranch, {
303
+ ...options.clone,
304
+ noCheckout: true,
305
+ }));
306
+ const git = new GitManager({
307
+ repoPath: stagingPath,
308
+ baseBranch: options.baseBranch,
309
+ remote: options.clone.remoteName,
310
+ });
311
+ const cone = options.sparseCone;
312
+ if (cone)
313
+ await log.step('sparse', () => git.setSparseCone(cone));
314
+ // The checkout is the expensive half; skip it when a competitor has already won.
315
+ if (await hasBranchMetadata(path.join(options.baseRoot, options.dirName))) {
316
+ await removeLeftoverDir(stagingPath);
317
+ return { kind: 'exists' };
318
+ }
319
+ await log.step('checkout', () => git.checkoutFreshClone(options.branchName));
320
+ await log.step('exclude', () => git.ensureGitExclude(options.gitExcludePattern));
321
+ const written = await log.step('metadata', () => writeOccJsonFile(path.join(stagingPath, BRANCH_META_DIR, BRANCH_META_FILE), { ...options.metadata }, { expectedVersion: null, trailingNewline: true }));
322
+ return {
323
+ kind: 'staged',
324
+ stagingPath,
325
+ meta: { ...options.metadata, version: written.version, writeId: written.writeId },
326
+ };
327
+ }
328
+ catch (err) {
329
+ await removeLeftoverDir(stagingPath);
330
+ throw err;
331
+ }
332
+ }
333
+ /**
334
+ * The branch's provisioning lock with a bounded wait; contention is
335
+ * {@link BranchProvisioningBusyError}.
336
+ */
337
+ export async function lockBranchName(baseRoot, dirName) {
338
+ try {
339
+ return await acquireProvisioningLockWithin(baseRoot, branchProvisioningLockName(dirName), PUBLISH_LOCK_WAIT_MS);
340
+ }
341
+ catch (err) {
342
+ if (isNodeError(err) && err.code === 'ELOCKED')
343
+ throw new BranchProvisioningBusyError(dirName);
344
+ throw err;
345
+ }
346
+ }
347
+ /** Listing `.canopy-meta` opens the directory, which makes NFS revalidate it. */
348
+ async function inspectBlocking(finalPath) {
349
+ const entries = (await listDir(path.join(finalPath, BRANCH_META_DIR))) ?? [];
350
+ if (entries.includes(BRANCH_META_FILE))
351
+ return 'live';
352
+ if (entries.some((name) => name.startsWith(`${BRANCH_META_FILE}.corrupt-`)))
353
+ return 'protected';
354
+ return 'residue';
355
+ }
356
+ const RENAME_BLOCKED = new Set(['EEXIST', 'ENOTEMPTY', 'ENOTDIR']);
357
+ /**
358
+ * Publish a staged workspace at its final path, under the branch's provisioning lock.
359
+ *
360
+ * A refused rename is inspected: branch.json there means another request won (`exists`);
361
+ * residue is quarantined and the rename retried once. ENOENT can be an NFS retransmission of a
362
+ * rename that already landed, so it counts as published when the final branch.json carries our
363
+ * `writeId`. The caller removes the staging directory on any outcome but `published`.
364
+ */
365
+ export async function publishStaging(options) {
366
+ const { baseRoot, dirName, stagingPath } = options;
367
+ const finalPath = path.join(baseRoot, dirName);
368
+ const release = await lockBranchName(baseRoot, dirName);
369
+ try {
370
+ for (let attempt = 0; attempt < 2; attempt++) {
371
+ try {
372
+ await fs.rename(stagingPath, finalPath);
373
+ return 'published';
374
+ }
375
+ catch (err) {
376
+ if (!isNodeError(err))
377
+ throw err;
378
+ if (err.code === 'ENOENT') {
379
+ const meta = await readBranchMetadataFile(finalPath).catch(() => null);
380
+ if (meta?.writeId !== undefined && meta.writeId === options.writeId)
381
+ return 'published';
382
+ throw err;
383
+ }
384
+ if (!RENAME_BLOCKED.has(err.code ?? ''))
385
+ throw err;
386
+ }
387
+ const blocking = await (options.inspectBlocked ?? inspectBlocking)(finalPath);
388
+ if (blocking === 'live')
389
+ return 'exists';
390
+ if (blocking === 'protected')
391
+ throw new BranchProvisioningBusyError(dirName);
392
+ const result = await quarantineResidueAt(baseRoot, dirName, {
393
+ minQuietMs: CREATE_RESIDUE_MIN_QUIET_MS,
394
+ expectedRemoteUrl: options.expectedRemoteUrl,
395
+ });
396
+ if (result.kind !== 'kept')
397
+ continue;
398
+ if (result.reason === 'live')
399
+ return 'exists';
400
+ if (result.reason === 'corrupt') {
401
+ await readBranchMetadataFile(finalPath);
402
+ return 'exists';
403
+ }
404
+ if (result.reason === 'foreign')
405
+ throw new BranchDirOccupiedError(dirName);
406
+ throw new BranchProvisioningBusyError(dirName);
407
+ }
408
+ throw new BranchProvisioningBusyError(dirName);
409
+ }
410
+ finally {
411
+ await release().catch((err) => {
412
+ canopyLogWarn(`[canopy] Failed to release the provisioning lock for ${dirName}:`, err);
413
+ });
414
+ }
415
+ }
416
+ async function sweepByAge(root, rules, now) {
417
+ const entries = (await listDir(root)) ?? [];
418
+ const actions = [];
419
+ for (const name of entries) {
420
+ const rule = rules.find((candidate) => name.startsWith(candidate.prefix));
421
+ const stamp = rule ? parseDirStamp(name) : null;
422
+ if (!rule || !stamp || now - stamp.getTime() < rule.maxAgeMs)
423
+ continue;
424
+ try {
425
+ await fs.rm(path.join(root, name), {
426
+ recursive: true,
427
+ force: true,
428
+ maxRetries: 3,
429
+ retryDelay: 100,
430
+ });
431
+ actions.push({ name, action: 'removed' });
432
+ }
433
+ catch (err) {
434
+ actions.push({ name, action: 'failed', detail: getErrorMessage(err) });
435
+ }
436
+ }
437
+ return actions;
438
+ }
439
+ /**
440
+ * A `.repair-*` left by a process killed mid-quarantine goes back to its branch name when it
441
+ * holds branch.json and the name is free; anything else is trashed. The branch name comes from
442
+ * its branch.json, checked against the name's bounded prefix.
443
+ */
444
+ async function recoverRepairDir(baseRoot, name) {
445
+ const repairPath = path.join(baseRoot, name);
446
+ const meta = await readBranchMetadataFile(repairPath).catch(() => null);
447
+ const dirName = meta ? sanitizeBranchName(meta.branch.name) : undefined;
448
+ if (dirName && name.startsWith(`${REPAIR_PREFIX}${shortDirName(dirName)}-`)) {
449
+ let release;
450
+ try {
451
+ release = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(dirName));
452
+ }
453
+ catch (err) {
454
+ if (isNodeError(err) && err.code === 'ELOCKED') {
455
+ return { name, action: 'failed', detail: 'provisioning lock held; retrying next cycle' };
456
+ }
457
+ throw err;
458
+ }
459
+ try {
460
+ const finalPath = path.join(baseRoot, dirName);
461
+ if ((await fs.lstat(finalPath).catch(() => null)) === null) {
462
+ await fs.rename(repairPath, finalPath);
463
+ return { name, action: 'restored', detail: dirName };
464
+ }
465
+ }
466
+ finally {
467
+ await release().catch(() => { });
468
+ }
469
+ }
470
+ const trashName = `${TRASH_PREFIX}${name.slice(REPAIR_PREFIX.length)}`;
471
+ await fs.rename(repairPath, path.join(baseRoot, trashName));
472
+ return { name, action: 'trashed', detail: trashName };
473
+ }
474
+ /**
475
+ * Clear what killed processes left beside the branch directories (`baseRoot`) and the settings
476
+ * workspace (`workspaceRoot`): stale `.prov-*` builds, `.deleting-*` directories whose `rm` never
477
+ * finished, `.repair-*` from an interrupted quarantine, and expired settings trash. Ages come
478
+ * from the stamp in each name.
479
+ */
480
+ export async function sweepProvisioningLeftovers(baseRoot, workspaceRoot, now = Date.now()) {
481
+ const actions = await sweepByAge(baseRoot, [
482
+ { prefix: STAGING_PREFIX, maxAgeMs: STAGING_MAX_AGE_MS },
483
+ { prefix: DELETING_PREFIX, maxAgeMs: DELETING_MAX_AGE_MS },
484
+ ], now);
485
+ for (const name of (await listDir(baseRoot)) ?? []) {
486
+ const stamp = name.startsWith(REPAIR_PREFIX) ? parseDirStamp(name) : null;
487
+ if (!stamp || now - stamp.getTime() < REPAIR_MAX_AGE_MS)
488
+ continue;
489
+ try {
490
+ actions.push(await recoverRepairDir(baseRoot, name));
491
+ }
492
+ catch (err) {
493
+ actions.push({ name, action: 'failed', detail: getErrorMessage(err) });
494
+ }
495
+ }
496
+ actions.push(...(await sweepByAge(workspaceRoot, [
497
+ { prefix: `${STAGING_PREFIX}settings-`, maxAgeMs: STAGING_MAX_AGE_MS },
498
+ { prefix: `${TRASH_PREFIX}settings-`, maxAgeMs: SETTINGS_TRASH_MAX_AGE_MS },
499
+ ], now)));
500
+ return actions;
501
+ }
@@ -2,6 +2,8 @@ import type { RootCollectionConfig } from './config/index.js';
2
2
  import type { FlatSchemaItem } from './config/types.js';
3
3
  import type { OperatingMode } from './operating-mode/index.js';
4
4
  import type { EntrySchemaRegistry, SchemaResolutionResult } from './schema/types.js';
5
+ /** File name of the schema cache inside {@link schemaCacheDir}. */
6
+ export declare const SCHEMA_CACHE_FILE = "schema-cache.json";
5
7
  /** resource-generation.ts resource key for the schema cache's marker. */
6
8
  export declare const SCHEMA_GENERATION_RESOURCE = "schema";
7
9
  /** @internal Exported for tests. */
@@ -18,8 +20,8 @@ export interface BranchSchemaCacheEntry {
18
20
  generation: string | null;
19
21
  }
20
22
  /**
21
- * Per-branch schema cache: a file at {branchRoot}/.canopy-meta/schema-cache.json
22
- * with no in-memory layer, so it stays coherent across Lambda invocations.
23
+ * Per-branch schema cache: one file in {@link schemaCacheDir} with no in-memory
24
+ * layer, so it stays coherent across Lambda invocations.
23
25
  *
24
26
  * Freshness follows the generation-marker protocol owned by
25
27
  * resource-generation.ts, and this is one of that protocol's durable-snapshot
@@ -39,7 +41,7 @@ export declare class BranchSchemaCache {
39
41
  /**
40
42
  * Whether to skip the on-disk cache for this branchRoot.
41
43
  *
42
- * Never write `.canopy-meta/` at the project root, whichever entrypoint
44
+ * Never write a schema cache at the project root, whichever entrypoint
43
45
  * produced the cwd branchRoot. branchRoot equals process.cwd() only in the
44
46
  * synthetic contexts static deployments and build phases use; a real branch
45
47
  * root is always nested under the workspace.
@@ -5,10 +5,14 @@ import { flattenSchema } from './config/flatten.js';
5
5
  import { validateReferenceEntryTypes } from './validation/entry-type-reference-validator.js';
6
6
  import { isBuildMode } from './build-mode.js';
7
7
  import { bumpResourceGeneration, readResourceGeneration, isGenerationCurrent, } from './resource-generation.js';
8
+ import { timeRequestPhase } from './utils/request-timing.js';
9
+ import { CANOPY_META_DIR } from './utils/git.js';
8
10
  /** Bump when BranchSchemaCacheEntry shape changes to auto-invalidate stale caches */
9
11
  const SCHEMA_CACHE_VERSION = 3;
10
12
  /** Minimum interval between mtime staleness checks (ms) */
11
13
  const MTIME_CHECK_DEBOUNCE_MS = 1000;
14
+ /** File name of the schema cache inside {@link schemaCacheDir}. */
15
+ export const SCHEMA_CACHE_FILE = 'schema-cache.json';
12
16
  /** resource-generation.ts resource key for the schema cache's marker. */
13
17
  export const SCHEMA_GENERATION_RESOURCE = 'schema';
14
18
  /**
@@ -43,8 +47,22 @@ async function isStaleByMtime(dir, cachedAt) {
43
47
  return false;
44
48
  }
45
49
  /**
46
- * Per-branch schema cache: a file at {branchRoot}/.canopy-meta/schema-cache.json
47
- * with no in-memory layer, so it stays coherent across Lambda invocations.
50
+ * Directory holding a branch's schema cache: `{branchRoot}/.git/canopycms` when
51
+ * the branch root is a full clone (every canopycms branch workspace is), else
52
+ * `{branchRoot}/.canopy-meta`. Under `.git/` because nothing there is ever
53
+ * tracked or reported by `git status`, so the cache can neither dirty the
54
+ * workspace nor enter a commit, even where an adopter has committed
55
+ * `.canopy-meta/`. It also lives and dies with the clone, so a re-clone never
56
+ * reads its predecessor's snapshot.
57
+ */
58
+ async function schemaCacheDir(branchRoot) {
59
+ const gitDir = path.join(branchRoot, '.git');
60
+ const isClone = await fs.stat(gitDir).then((stat) => stat.isDirectory(), () => false);
61
+ return isClone ? path.join(gitDir, 'canopycms') : path.join(branchRoot, CANOPY_META_DIR);
62
+ }
63
+ /**
64
+ * Per-branch schema cache: one file in {@link schemaCacheDir} with no in-memory
65
+ * layer, so it stays coherent across Lambda invocations.
48
66
  *
49
67
  * Freshness follows the generation-marker protocol owned by
50
68
  * resource-generation.ts, and this is one of that protocol's durable-snapshot
@@ -65,7 +83,7 @@ export class BranchSchemaCache {
65
83
  /**
66
84
  * Whether to skip the on-disk cache for this branchRoot.
67
85
  *
68
- * Never write `.canopy-meta/` at the project root, whichever entrypoint
86
+ * Never write a schema cache at the project root, whichever entrypoint
69
87
  * produced the cwd branchRoot. branchRoot equals process.cwd() only in the
70
88
  * synthetic contexts static deployments and build phases use; a real branch
71
89
  * root is always nested under the workspace.
@@ -75,7 +93,7 @@ export class BranchSchemaCache {
75
93
  }
76
94
  /** Get schema for a branch, from the cache when fresh, else resolved fresh. */
77
95
  async getSchema(branchRoot, entrySchemaRegistry, contentRootName = 'content') {
78
- return this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName);
96
+ return timeRequestPhase('schema', () => this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName));
79
97
  }
80
98
  /**
81
99
  * Resolve the schema from disk. Protected rather than a direct resolveSchema
@@ -89,8 +107,7 @@ export class BranchSchemaCache {
89
107
  const contentRoot = path.join(branchRoot, contentRootName);
90
108
  const skipDiskCache = this.skipDiskCache(branchRoot);
91
109
  if (!skipDiskCache) {
92
- const cacheDir = path.join(branchRoot, '.canopy-meta');
93
- const cachePath = path.join(cacheDir, 'schema-cache.json');
110
+ const cachePath = path.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
94
111
  let cacheData = null;
95
112
  try {
96
113
  const cacheContent = await fs.readFile(cachePath, 'utf-8');
@@ -149,7 +166,8 @@ export class BranchSchemaCache {
149
166
  const read = skipDiskCache
150
167
  ? null
151
168
  : await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
152
- const result = await this.resolveFresh(contentRoot, entrySchemaRegistry);
169
+ // Nested under `schema`, so a request summary names a cache miss (`…schema>resolve`).
170
+ const result = await timeRequestPhase('resolve', () => this.resolveFresh(contentRoot, entrySchemaRegistry));
153
171
  // Validate schema has content
154
172
  if (!isValidSchema(result.schema)) {
155
173
  throw new Error(`No schema found in ${contentRoot}. Create .collection.json files ` +
@@ -166,11 +184,11 @@ export class BranchSchemaCache {
166
184
  // Use configured contentRoot name as base path for logical paths
167
185
  const flatSchema = flattenSchema(result.schema, contentRootName);
168
186
  if (!skipDiskCache) {
169
- const cacheDir = path.join(branchRoot, '.canopy-meta');
170
- const cachePath = path.join(cacheDir, 'schema-cache.json');
187
+ const cacheDir = await schemaCacheDir(branchRoot);
188
+ const cachePath = path.join(cacheDir, SCHEMA_CACHE_FILE);
171
189
  // Opportunistic cleanup of the retired .stale marker file, which a
172
190
  // mid-flight deploy can leave behind. Not load-bearing.
173
- await fs.unlink(path.join(cacheDir, 'schema-cache.stale')).catch(() => { });
191
+ await fs.unlink(path.join(branchRoot, CANOPY_META_DIR, 'schema-cache.stale')).catch(() => { });
174
192
  if (read && read.ok) {
175
193
  const newCache = {
176
194
  version: SCHEMA_CACHE_VERSION,
@@ -180,7 +198,7 @@ export class BranchSchemaCache {
180
198
  generation: read.token,
181
199
  };
182
200
  // Temp file then rename, with the temp file unlinked on a failed
183
- // rename so a transient error leaves no stray `.tmp` in `.canopy-meta/`.
201
+ // rename so a transient error leaves no stray `.tmp` beside the cache.
184
202
  await fs.mkdir(cacheDir, { recursive: true });
185
203
  const tmpPath = path.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
186
204
  await fs.writeFile(tmpPath, JSON.stringify(newCache, null, 2), 'utf-8');
@@ -0,0 +1,23 @@
1
+ import type { CanopyConfig } from './config/index.js';
2
+ /**
3
+ * The cone a content branch's clone checks out for `contentRoot`, sorted as
4
+ * `git sparse-checkout list` prints it, or null for a full clone when the content root is the
5
+ * repository root.
6
+ */
7
+ export declare function sparseConeFor(contentRoot: string | undefined): string[] | null;
8
+ export declare function sameCone(a: readonly string[] | null, b: readonly string[] | null): boolean;
9
+ export interface RecordedSparseCone {
10
+ cone: string[] | null;
11
+ }
12
+ /** The recorded cone, or undefined when none is recorded or the record does not parse. */
13
+ export declare function readRecordedSparseCone(contentBranchesRoot: string): Promise<RecordedSparseCone | undefined>;
14
+ /**
15
+ * Record `cone` unless it already is, by temp file and rename. Does not create the branches
16
+ * root: a deployment without one has no clones to re-apply a cone to.
17
+ */
18
+ export declare function recordSparseCone(contentBranchesRoot: string, cone: string[] | null): Promise<void>;
19
+ /**
20
+ * Record the cone `config` asks for, once per process start, so a changed content root reaches
21
+ * existing clones without waiting for a new branch. Best-effort: a failure is logged.
22
+ */
23
+ export declare function recordConfiguredSparseCone(config: CanopyConfig): Promise<void>;