canopycms 0.0.56 → 0.0.58

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 (272) hide show
  1. package/dist/ai/json-to-markdown.js +74 -3
  2. package/dist/ai/json-to-markdown.js.map +1 -1
  3. package/dist/api/__test__/mock-client.d.ts +9 -5
  4. package/dist/api/__test__/mock-client.d.ts.map +1 -1
  5. package/dist/api/__test__/mock-client.js +15 -7
  6. package/dist/api/__test__/mock-client.js.map +1 -1
  7. package/dist/api/assets.d.ts +100 -36
  8. package/dist/api/assets.d.ts.map +1 -1
  9. package/dist/api/assets.js +338 -51
  10. package/dist/api/assets.js.map +1 -1
  11. package/dist/api/branch.d.ts +6 -0
  12. package/dist/api/branch.d.ts.map +1 -1
  13. package/dist/api/branch.js +63 -19
  14. package/dist/api/branch.js.map +1 -1
  15. package/dist/api/client.d.ts +16 -5
  16. package/dist/api/client.d.ts.map +1 -1
  17. package/dist/api/client.js +24 -6
  18. package/dist/api/client.js.map +1 -1
  19. package/dist/api/content.d.ts +2 -2
  20. package/dist/api/entries.d.ts +4 -4
  21. package/dist/api/entries.d.ts.map +1 -1
  22. package/dist/api/entries.js +10 -2
  23. package/dist/api/entries.js.map +1 -1
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/index.d.ts.map +1 -1
  26. package/dist/api/index.js.map +1 -1
  27. package/dist/api/route-builder.d.ts +9 -0
  28. package/dist/api/route-builder.d.ts.map +1 -1
  29. package/dist/api/route-builder.js +1 -0
  30. package/dist/api/route-builder.js.map +1 -1
  31. package/dist/api/schema.d.ts +4 -4
  32. package/dist/api/types.d.ts +11 -1
  33. package/dist/api/types.d.ts.map +1 -1
  34. package/dist/assets/asset-prefixes.d.ts +31 -0
  35. package/dist/assets/asset-prefixes.d.ts.map +1 -0
  36. package/dist/assets/asset-prefixes.js +20 -0
  37. package/dist/assets/asset-prefixes.js.map +1 -0
  38. package/dist/assets/asset-src.d.ts +23 -0
  39. package/dist/assets/asset-src.d.ts.map +1 -0
  40. package/dist/assets/asset-src.js +28 -0
  41. package/dist/assets/asset-src.js.map +1 -0
  42. package/dist/assets/asset-url.d.ts +35 -0
  43. package/dist/assets/asset-url.d.ts.map +1 -0
  44. package/dist/assets/asset-url.js +77 -0
  45. package/dist/assets/asset-url.js.map +1 -0
  46. package/dist/assets/factory.d.ts +18 -0
  47. package/dist/assets/factory.d.ts.map +1 -0
  48. package/dist/assets/factory.js +42 -0
  49. package/dist/assets/factory.js.map +1 -0
  50. package/dist/assets/finalize.d.ts +36 -0
  51. package/dist/assets/finalize.d.ts.map +1 -0
  52. package/dist/assets/finalize.js +72 -0
  53. package/dist/assets/finalize.js.map +1 -0
  54. package/dist/assets/index.d.ts +20 -0
  55. package/dist/assets/index.d.ts.map +1 -0
  56. package/dist/assets/index.js +19 -0
  57. package/dist/assets/index.js.map +1 -0
  58. package/dist/assets/keys.d.ts +56 -0
  59. package/dist/assets/keys.d.ts.map +1 -0
  60. package/dist/assets/keys.js +88 -0
  61. package/dist/assets/keys.js.map +1 -0
  62. package/dist/assets/pipeline.d.ts +50 -0
  63. package/dist/assets/pipeline.d.ts.map +1 -0
  64. package/dist/assets/pipeline.js +202 -0
  65. package/dist/assets/pipeline.js.map +1 -0
  66. package/dist/assets/store-local.d.ts +84 -0
  67. package/dist/assets/store-local.d.ts.map +1 -0
  68. package/dist/assets/store-local.js +254 -0
  69. package/dist/assets/store-local.js.map +1 -0
  70. package/dist/assets/store-s3.d.ts +71 -0
  71. package/dist/assets/store-s3.d.ts.map +1 -0
  72. package/dist/assets/store-s3.js +183 -0
  73. package/dist/assets/store-s3.js.map +1 -0
  74. package/dist/assets/svg-sanitizer.d.ts +37 -0
  75. package/dist/assets/svg-sanitizer.d.ts.map +1 -0
  76. package/dist/assets/svg-sanitizer.js +222 -0
  77. package/dist/assets/svg-sanitizer.js.map +1 -0
  78. package/dist/assets/transform-directives.d.ts +97 -0
  79. package/dist/assets/transform-directives.d.ts.map +1 -0
  80. package/dist/assets/transform-directives.js +267 -0
  81. package/dist/assets/transform-directives.js.map +1 -0
  82. package/dist/assets/transform.d.ts +51 -0
  83. package/dist/assets/transform.d.ts.map +1 -0
  84. package/dist/assets/transform.js +190 -0
  85. package/dist/assets/transform.js.map +1 -0
  86. package/dist/assets/types.d.ts +82 -0
  87. package/dist/assets/types.d.ts.map +1 -0
  88. package/dist/assets/types.js +10 -0
  89. package/dist/assets/types.js.map +1 -0
  90. package/dist/authorization/groups/schema.d.ts +2 -2
  91. package/dist/authorization/permissions/schema.d.ts +2 -2
  92. package/dist/branch-metadata.d.ts +71 -6
  93. package/dist/branch-metadata.d.ts.map +1 -1
  94. package/dist/branch-metadata.js +132 -116
  95. package/dist/branch-metadata.js.map +1 -1
  96. package/dist/branch-registry.d.ts +78 -9
  97. package/dist/branch-registry.d.ts.map +1 -1
  98. package/dist/branch-registry.js +155 -29
  99. package/dist/branch-registry.js.map +1 -1
  100. package/dist/branch-schema-cache.d.ts +126 -11
  101. package/dist/branch-schema-cache.d.ts.map +1 -1
  102. package/dist/branch-schema-cache.js +208 -58
  103. package/dist/branch-schema-cache.js.map +1 -1
  104. package/dist/cli/generate-ai-content.js +1993 -1506
  105. package/dist/cli/init.js +16 -4
  106. package/dist/cli/migrate.d.ts.map +1 -1
  107. package/dist/cli/migrate.js +25 -0
  108. package/dist/cli/migrate.js.map +1 -1
  109. package/dist/cli/sync.js +5 -5
  110. package/dist/cli/sync.js.map +1 -1
  111. package/dist/comment-store.d.ts +45 -15
  112. package/dist/comment-store.d.ts.map +1 -1
  113. package/dist/comment-store.js +80 -101
  114. package/dist/comment-store.js.map +1 -1
  115. package/dist/config/helpers.d.ts.map +1 -1
  116. package/dist/config/helpers.js +2 -1
  117. package/dist/config/helpers.js.map +1 -1
  118. package/dist/config/index.d.ts +2 -2
  119. package/dist/config/index.d.ts.map +1 -1
  120. package/dist/config/index.js +1 -1
  121. package/dist/config/index.js.map +1 -1
  122. package/dist/config/schemas/config.d.ts +10 -0
  123. package/dist/config/schemas/config.d.ts.map +1 -1
  124. package/dist/config/schemas/field.d.ts +38 -3
  125. package/dist/config/schemas/field.d.ts.map +1 -1
  126. package/dist/config/schemas/field.js +18 -0
  127. package/dist/config/schemas/field.js.map +1 -1
  128. package/dist/config/schemas/media.d.ts +8 -0
  129. package/dist/config/schemas/media.d.ts.map +1 -1
  130. package/dist/config/schemas/media.js +9 -0
  131. package/dist/config/schemas/media.js.map +1 -1
  132. package/dist/config/types.d.ts +43 -3
  133. package/dist/config/types.d.ts.map +1 -1
  134. package/dist/config/types.js +4 -1
  135. package/dist/config/types.js.map +1 -1
  136. package/dist/content-index-generation.d.ts +40 -4
  137. package/dist/content-index-generation.d.ts.map +1 -1
  138. package/dist/content-index-generation.js +70 -73
  139. package/dist/content-index-generation.js.map +1 -1
  140. package/dist/content-store.d.ts +63 -0
  141. package/dist/content-store.d.ts.map +1 -1
  142. package/dist/content-store.js +421 -194
  143. package/dist/content-store.js.map +1 -1
  144. package/dist/editor/CanopyEditor.d.ts.map +1 -1
  145. package/dist/editor/CanopyEditor.js +1 -1
  146. package/dist/editor/CanopyEditor.js.map +1 -1
  147. package/dist/editor/Editor.d.ts +2 -0
  148. package/dist/editor/Editor.d.ts.map +1 -1
  149. package/dist/editor/Editor.js +82 -80
  150. package/dist/editor/Editor.js.map +1 -1
  151. package/dist/editor/FormRenderer.d.ts.map +1 -1
  152. package/dist/editor/FormRenderer.js +9 -0
  153. package/dist/editor/FormRenderer.js.map +1 -1
  154. package/dist/editor/components/EditorSidebar.d.ts +6 -1
  155. package/dist/editor/components/EditorSidebar.d.ts.map +1 -1
  156. package/dist/editor/components/EditorSidebar.js +4 -3
  157. package/dist/editor/components/EditorSidebar.js.map +1 -1
  158. package/dist/editor/context/AssetContext.d.ts +25 -0
  159. package/dist/editor/context/AssetContext.d.ts.map +1 -0
  160. package/dist/editor/context/AssetContext.js +26 -0
  161. package/dist/editor/context/AssetContext.js.map +1 -0
  162. package/dist/editor/context/index.d.ts +1 -0
  163. package/dist/editor/context/index.d.ts.map +1 -1
  164. package/dist/editor/context/index.js +1 -0
  165. package/dist/editor/context/index.js.map +1 -1
  166. package/dist/editor/fields/ImageField.d.ts +22 -0
  167. package/dist/editor/fields/ImageField.d.ts.map +1 -0
  168. package/dist/editor/fields/ImageField.js +100 -0
  169. package/dist/editor/fields/ImageField.js.map +1 -0
  170. package/dist/editor/fields/MarkdownField.d.ts.map +1 -1
  171. package/dist/editor/fields/MarkdownField.js +30 -4
  172. package/dist/editor/fields/MarkdownField.js.map +1 -1
  173. package/dist/editor/fields/MdxImageDialog.d.ts +16 -0
  174. package/dist/editor/fields/MdxImageDialog.d.ts.map +1 -0
  175. package/dist/editor/fields/MdxImageDialog.js +73 -0
  176. package/dist/editor/fields/MdxImageDialog.js.map +1 -0
  177. package/dist/editor/media/AssetCard.d.ts +21 -0
  178. package/dist/editor/media/AssetCard.d.ts.map +1 -0
  179. package/dist/editor/media/AssetCard.js +41 -0
  180. package/dist/editor/media/AssetCard.js.map +1 -0
  181. package/dist/editor/media/CropStep.d.ts +26 -0
  182. package/dist/editor/media/CropStep.d.ts.map +1 -0
  183. package/dist/editor/media/CropStep.js +38 -0
  184. package/dist/editor/media/CropStep.js.map +1 -0
  185. package/dist/editor/media/MediaLibrary.d.ts +22 -0
  186. package/dist/editor/media/MediaLibrary.d.ts.map +1 -0
  187. package/dist/editor/media/MediaLibrary.js +17 -0
  188. package/dist/editor/media/MediaLibrary.js.map +1 -0
  189. package/dist/editor/media/MediaLibraryBody.d.ts +17 -0
  190. package/dist/editor/media/MediaLibraryBody.d.ts.map +1 -0
  191. package/dist/editor/media/MediaLibraryBody.js +133 -0
  192. package/dist/editor/media/MediaLibraryBody.js.map +1 -0
  193. package/dist/editor/media/crop-math.d.ts +37 -0
  194. package/dist/editor/media/crop-math.d.ts.map +1 -0
  195. package/dist/editor/media/crop-math.js +62 -0
  196. package/dist/editor/media/crop-math.js.map +1 -0
  197. package/dist/editor/media/upload-asset.d.ts +31 -0
  198. package/dist/editor/media/upload-asset.d.ts.map +1 -0
  199. package/dist/editor/media/upload-asset.js +56 -0
  200. package/dist/editor/media/upload-asset.js.map +1 -0
  201. package/dist/editor/media/upload-constants.d.ts +27 -0
  202. package/dist/editor/media/upload-constants.d.ts.map +1 -0
  203. package/dist/editor/media/upload-constants.js +40 -0
  204. package/dist/editor/media/upload-constants.js.map +1 -0
  205. package/dist/editor/media/useAssetUpload.d.ts +19 -0
  206. package/dist/editor/media/useAssetUpload.d.ts.map +1 -0
  207. package/dist/editor/media/useAssetUpload.js +53 -0
  208. package/dist/editor/media/useAssetUpload.js.map +1 -0
  209. package/dist/editor/media/xhr-upload.d.ts +20 -0
  210. package/dist/editor/media/xhr-upload.d.ts.map +1 -0
  211. package/dist/editor/media/xhr-upload.js +42 -0
  212. package/dist/editor/media/xhr-upload.js.map +1 -0
  213. package/dist/editor/theme.d.ts +2 -1
  214. package/dist/editor/theme.d.ts.map +1 -1
  215. package/dist/editor/theme.js +1 -0
  216. package/dist/editor/theme.js.map +1 -1
  217. package/dist/entry-schema.d.ts +19 -0
  218. package/dist/entry-schema.d.ts.map +1 -1
  219. package/dist/entry-schema.js.map +1 -1
  220. package/dist/git-manager.d.ts +15 -10
  221. package/dist/git-manager.d.ts.map +1 -1
  222. package/dist/git-manager.js +16 -11
  223. package/dist/git-manager.js.map +1 -1
  224. package/dist/http/handler.d.ts +5 -2
  225. package/dist/http/handler.d.ts.map +1 -1
  226. package/dist/http/handler.js +13 -4
  227. package/dist/http/handler.js.map +1 -1
  228. package/dist/http/index.d.ts +2 -2
  229. package/dist/http/index.d.ts.map +1 -1
  230. package/dist/http/index.js +1 -1
  231. package/dist/http/index.js.map +1 -1
  232. package/dist/http/router.d.ts +14 -1
  233. package/dist/http/router.d.ts.map +1 -1
  234. package/dist/http/router.js +4 -1
  235. package/dist/http/router.js.map +1 -1
  236. package/dist/http/types.d.ts +40 -0
  237. package/dist/http/types.d.ts.map +1 -1
  238. package/dist/http/types.js +9 -0
  239. package/dist/http/types.js.map +1 -1
  240. package/dist/index.d.ts +3 -0
  241. package/dist/index.d.ts.map +1 -1
  242. package/dist/index.js +4 -0
  243. package/dist/index.js.map +1 -1
  244. package/dist/resource-generation.d.ts +54 -0
  245. package/dist/resource-generation.d.ts.map +1 -0
  246. package/dist/resource-generation.js +156 -0
  247. package/dist/resource-generation.js.map +1 -0
  248. package/dist/schema/schema-store.d.ts +31 -4
  249. package/dist/schema/schema-store.d.ts.map +1 -1
  250. package/dist/schema/schema-store.js +47 -8
  251. package/dist/schema/schema-store.js.map +1 -1
  252. package/dist/server.d.ts +45 -0
  253. package/dist/server.d.ts.map +1 -1
  254. package/dist/server.js +41 -0
  255. package/dist/server.js.map +1 -1
  256. package/dist/sync-core.js +2 -2
  257. package/dist/sync-core.js.map +1 -1
  258. package/dist/utils/occ-json-write.d.ts +100 -0
  259. package/dist/utils/occ-json-write.d.ts.map +1 -0
  260. package/dist/utils/occ-json-write.js +234 -0
  261. package/dist/utils/occ-json-write.js.map +1 -0
  262. package/dist/validation/entry-validator.d.ts.map +1 -1
  263. package/dist/validation/entry-validator.js +56 -1
  264. package/dist/validation/entry-validator.js.map +1 -1
  265. package/dist/worker/cms-worker.d.ts.map +1 -1
  266. package/dist/worker/cms-worker.js +21 -3
  267. package/dist/worker/cms-worker.js.map +1 -1
  268. package/package.json +13 -1
  269. package/dist/asset-store.d.ts +0 -32
  270. package/dist/asset-store.d.ts.map +0 -1
  271. package/dist/asset-store.js +0 -85
  272. package/dist/asset-store.js.map +0 -1
@@ -1,85 +1,211 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { BranchMetadataFileManager } from './branch-metadata.js';
4
- import { isNotFoundError } from './utils/error.js';
4
+ import { isNotFoundError, getErrorMessage } from './utils/error.js';
5
+ import { createDebugLogger } from './utils/debug.js';
6
+ import { bumpResourceGeneration, readResourceGeneration, isGenerationCurrent, } from './resource-generation.js';
7
+ const log = createDebugLogger({ prefix: 'BranchRegistry' });
5
8
  // Registry files are stored directly in the branches root (not in a subdirectory)
6
9
  const REGISTRY_FILE = 'branches.json';
10
+ // Legacy rename-based invalidation scheme's stale marker file. No longer written -
11
+ // invalidate() now bumps the cross-process generation marker instead - but a
12
+ // process upgraded mid-flight may find one left over from before the deploy, so
13
+ // regenerate() opportunistically deletes it. Keep this name only for that cleanup.
7
14
  const REGISTRY_STALE_FILE = 'branches.stale.json';
8
15
  const REGISTRY_TEMP_FILE = 'branches.tmp.json';
9
- const REGISTRY_VERSION = 1;
16
+ const REGISTRY_VERSION = 2;
17
+ /** resource-generation.ts resource key for the branch registry's marker. */
18
+ const RESOURCE = 'branch-registry';
19
+ /** Throttle for the get() suspicious-miss backstop; mirrors content-store's FORCED_REFRESH_MIN_INTERVAL_MS. */
20
+ const GET_MISS_REFRESH_MIN_INTERVAL_MS = 5000;
10
21
  /**
11
22
  * BranchRegistry is a read-only cache for fast branch listing.
12
23
  * Individual branch.json files are the source of truth.
13
24
  *
25
+ * ## Cross-process freshness (resource-generation.ts marker protocol)
26
+ *
27
+ * Several warm Lambda containers plus the EC2 worker can share one branch-clone
28
+ * root over EFS, each with its own in-process cache of `branches.json`. There is
29
+ * no shared memory and no cross-host file watching, so freshness is coordinated
30
+ * via the generic on-disk generation marker in resource-generation.ts: every
31
+ * snapshot embeds the marker token it was built against, and every read
32
+ * cheaply re-checks the live marker before trusting the cached snapshot. See
33
+ * that module's doc comment for the full protocol and residual staleness
34
+ * windows (A/B/C/E).
35
+ *
36
+ * BranchRegistry is exactly the "durable snapshot consumer" case called out
37
+ * there: a regeneration whose scan is served from stale NFS dentry/attribute
38
+ * caches can record a FRESH token over STALE data (window E), and because the
39
+ * result is written to `branches.json`, that staleness becomes durable and
40
+ * shared with every other host that reads the marker - not just one process's
41
+ * memory. Two mitigations close the practical gap:
42
+ *
43
+ * - invalidate() eager-regenerates immediately after its own bump. The
44
+ * mutating host's own scan is necessarily coherent with the mutation it just
45
+ * made (no NFS round trip was needed to observe its own write), so this
46
+ * closes window (E) for the common case without waiting for some other host
47
+ * to lazily pull the change.
48
+ * - get() implements a suspicious-miss backstop: a branch that "should" exist
49
+ * but is absent from the cached snapshot forces one fresh regeneration
50
+ * (throttled), bounding how long a bad snapshot can hide a real branch.
51
+ *
52
+ * regenerate() also skips persisting a snapshot when the marker read itself
53
+ * fails (`{ ok: false }`, e.g. unreadable for a non-ENOENT reason): a snapshot
54
+ * embedding a token we cannot attribute to "matches this scan" would be
55
+ * indistinguishable from a correctly-attributed one to every future reader, so
56
+ * the safer behavior is to serve the fresh scan without writing it durably and
57
+ * let the next read retry the marker.
58
+ *
14
59
  * Design:
15
- * - list() returns cached data if fresh, regenerates from branch.json files if stale
16
- * - invalidate() marks cache as stale (called when branch state changes)
17
- * - Concurrent regeneration is safe (all processes produce identical output)
60
+ * - list() returns the cached snapshot if its embedded generation token still
61
+ * matches the live marker, regenerates from branch.json files otherwise
62
+ * - invalidate() bumps the marker (durable, cross-process) and eager-regenerates
63
+ * - Concurrent regeneration within one process is deduped to a single scan
64
+ * (regenInFlight); across processes, regeneration is still safe since all
65
+ * processes produce identical output from the same branch.json files
18
66
  */
19
67
  export class BranchRegistry {
20
68
  constructor(root) {
69
+ /** Shared in-flight scan so concurrent list()/get() callers on one instance await a single regeneration. */
70
+ this.regenInFlight = null;
71
+ /** Throttle clock for the get() suspicious-miss backstop. */
72
+ this.lastForcedRefreshMs = 0;
21
73
  this.root = path.resolve(root);
22
74
  this.registryPath = path.join(this.root, REGISTRY_FILE);
23
75
  this.stalePath = path.join(this.root, REGISTRY_STALE_FILE);
24
76
  this.tempPath = path.join(this.root, REGISTRY_TEMP_FILE);
25
77
  }
26
78
  /**
27
- * Returns all branches. Uses cache if fresh, regenerates if stale.
79
+ * Returns all branches. Uses the cached snapshot if its embedded generation
80
+ * token matches the live marker, regenerates otherwise.
28
81
  */
29
82
  async list() {
83
+ let parsed;
30
84
  try {
31
85
  const raw = await fs.readFile(this.registryPath, 'utf8');
32
- const parsed = JSON.parse(raw);
33
- if (!parsed.version || !Array.isArray(parsed.branches)) {
34
- // Invalid cache, regenerate
35
- return await this.regenerate();
36
- }
37
- return parsed.branches;
86
+ parsed = JSON.parse(raw);
38
87
  }
39
88
  catch (err) {
40
89
  if (isNotFoundError(err)) {
41
- // Cache missing or stale, regenerate
42
90
  return await this.regenerate();
43
91
  }
44
92
  throw err;
45
93
  }
94
+ // Strict version check: a truthy-only check would accept a persisted v1
95
+ // snapshot (no `generation` field) left on EFS after a rolling deploy,
96
+ // and `parsed.generation` would then be `undefined` rather than a real
97
+ // token or explicit `null`, breaking the freshness comparison below.
98
+ if (parsed.version !== REGISTRY_VERSION || !Array.isArray(parsed.branches)) {
99
+ return await this.regenerate();
100
+ }
101
+ const read = await readResourceGeneration(this.root, RESOURCE);
102
+ if (isGenerationCurrent(parsed.generation, read)) {
103
+ return parsed.branches;
104
+ }
105
+ return await this.regenerate();
46
106
  }
47
107
  /**
48
108
  * Returns a single branch by name. Uses cache if available.
109
+ *
110
+ * Suspicious-miss backstop: if `name` isn't in the (possibly cached) list,
111
+ * that's a signal the snapshot may be stale in the specific direction where
112
+ * a branch that now exists is missing from it - force one fresh
113
+ * regeneration and re-search before giving up, throttled per instance so a
114
+ * genuinely-absent branch costs at most one extra scan per throttle window.
115
+ * This bounds durable window-E staleness for the "branch exists but
116
+ * snapshot predates it" direction; see the class doc comment.
49
117
  */
50
118
  async get(name) {
51
119
  const branches = await this.list();
52
- return branches.find((b) => b.branch.name === name);
120
+ const found = branches.find((b) => b.branch.name === name);
121
+ if (found)
122
+ return found;
123
+ if (!this.shouldForceRefresh())
124
+ return undefined;
125
+ const refreshed = await this.regenerate();
126
+ return refreshed.find((b) => b.branch.name === name);
127
+ }
128
+ shouldForceRefresh() {
129
+ const now = Date.now();
130
+ if (now - this.lastForcedRefreshMs < GET_MISS_REFRESH_MIN_INTERVAL_MS)
131
+ return false;
132
+ this.lastForcedRefreshMs = now;
133
+ return true;
53
134
  }
54
135
  /**
55
- * Marks the cache as stale. Next list() call will regenerate.
56
- * Uses atomic rename for safety.
136
+ * Marks the cache as stale for every process sharing this root by bumping
137
+ * the cross-process generation marker, then eager-regenerates on this host
138
+ * (see class doc comment for why). The bump must succeed: a swallowed
139
+ * failure here would leave the registry stale indefinitely with no
140
+ * bounding backstop, unlike a failed eager regen below, which only forgoes
141
+ * closing window (E) early - the bump alone already restored correctness
142
+ * for every future reader. A failed eager regen therefore must not fail the
143
+ * caller's save/delete.
57
144
  */
58
145
  async invalidate() {
146
+ await bumpResourceGeneration(this.root, RESOURCE, { mustSucceed: true });
59
147
  try {
60
- await fs.rename(this.registryPath, this.stalePath);
148
+ // A scan already in flight (a concurrent list() on this shared
149
+ // instance) captured the PRE-bump token and possibly pre-mutation
150
+ // state; joining it via regenerate()'s dedup would skip the eager
151
+ // post-bump scan this method exists for. Let it drain — its snapshot
152
+ // self-describes as stale either way — then run a fresh scan.
153
+ if (this.regenInFlight)
154
+ await this.regenInFlight.catch(() => { });
155
+ await this.regenerate();
61
156
  }
62
157
  catch (err) {
63
- // ENOENT means already stale or never existed, which is fine
64
- if (!isNotFoundError(err)) {
65
- throw err;
66
- }
158
+ log.warn('invalidate', 'Eager regeneration after invalidate() failed', {
159
+ error: getErrorMessage(err),
160
+ });
67
161
  }
68
162
  }
69
163
  /**
70
- * Scans branch directories and rebuilds the cache.
71
- * Concurrent calls are safe - all produce identical content.
164
+ * Scans branch directories and rebuilds the cache, deduping concurrent
165
+ * callers on this instance to a single underlying scan. Never loops waiting
166
+ * for the snapshot's embedded token to match the marker - under a bump
167
+ * storm that could livelock; a caller that wants the very latest state
168
+ * after a concurrent bump should call list() again after this resolves.
72
169
  */
73
170
  async regenerate() {
171
+ if (this.regenInFlight)
172
+ return this.regenInFlight;
173
+ const run = this.performRegenerate();
174
+ this.regenInFlight = run;
175
+ try {
176
+ return await run;
177
+ }
178
+ finally {
179
+ this.regenInFlight = null;
180
+ }
181
+ }
182
+ async performRegenerate() {
183
+ // Capture the marker strictly BEFORE scanning: a bump landing mid-scan
184
+ // then differs from the token recorded below, forcing a rebuild on the
185
+ // next probe instead of silently resurrecting stale data.
186
+ const read = await readResourceGeneration(this.root, RESOURCE);
74
187
  const branches = await this.scanBranchDirectories();
75
- // Write to unique temp file first, then atomic rename
76
- // Use random suffix to avoid conflicts between concurrent regenerations
77
- const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
78
- await fs.mkdir(this.root, { recursive: true });
188
+ // Opportunistic cleanup of a stale file left by the old rename-based
189
+ // invalidation scheme (e.g. a process upgraded mid-flight). Not load-bearing.
190
+ await fs.unlink(this.stalePath).catch(() => { });
191
+ if (!read.ok) {
192
+ // The marker couldn't be read for a reason other than "never bumped" -
193
+ // we cannot attribute a token to this scan, and stamping the snapshot
194
+ // with an unattributable token would make it indistinguishable from a
195
+ // correctly-attributed one to every future reader on any host. Serve
196
+ // the fresh scan result without persisting it; the next read retries
197
+ // the marker read and, on success, regenerates and persists normally.
198
+ return branches;
199
+ }
79
200
  const snapshot = {
80
201
  version: REGISTRY_VERSION,
81
202
  branches,
203
+ generation: read.token,
82
204
  };
205
+ // Write to unique temp file first, then atomic rename.
206
+ // Use random suffix to avoid conflicts between concurrent regenerations.
207
+ const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
208
+ await fs.mkdir(this.root, { recursive: true });
83
209
  await fs.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + '\n', 'utf8');
84
210
  try {
85
211
  await fs.rename(uniqueTempPath, this.registryPath);
@@ -89,12 +215,12 @@ export class BranchRegistry {
89
215
  await fs.unlink(uniqueTempPath).catch(() => { });
90
216
  throw err;
91
217
  }
92
- // Clean up stale file (ignore errors)
93
- await fs.unlink(this.stalePath).catch(() => { });
94
218
  return branches;
95
219
  }
96
220
  /**
97
221
  * Scans the root directory for branch subdirectories with valid branch.json files.
222
+ * Protected (rather than exported as a test hook) so tests can subclass and
223
+ * override to simulate cross-process interleavings.
98
224
  */
99
225
  async scanBranchDirectories() {
100
226
  const branches = [];
@@ -1 +1 @@
1
- {"version":3,"file":"branch-registry.js","sourceRoot":"","sources":["../src/branch-registry.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"branch-registry.js","sourceRoot":"","sources":["../src/branch-registry.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,9 @@
1
1
  import type { RootCollectionConfig } from './config';
2
2
  import type { FlatSchemaItem } from './config/types';
3
3
  import type { OperatingMode } from './operating-mode';
4
- import type { EntrySchemaRegistry } from './schema/types';
4
+ import type { EntrySchemaRegistry, SchemaResolutionResult } from './schema/types';
5
+ /** resource-generation.ts resource key for the schema cache's marker. */
6
+ export declare const SCHEMA_GENERATION_RESOURCE = "schema";
5
7
  /**
6
8
  * Schema cache structure stored in {branchRoot}/.canopy-meta/schema-cache.json
7
9
  */
@@ -10,19 +12,84 @@ export interface BranchSchemaCacheEntry {
10
12
  schema: RootCollectionConfig;
11
13
  flatSchema: FlatSchemaItem[];
12
14
  cachedAt: string;
15
+ /**
16
+ * The resource-generation.ts marker token this snapshot was resolved
17
+ * against, or null if it was built before any bump ever occurred on this
18
+ * root. Compared against the live marker (via isGenerationCurrent) to
19
+ * decide freshness. See the class doc comment.
20
+ */
21
+ generation: string | null;
13
22
  }
14
23
  /**
15
- * Manages per-branch schema caching with lazy loading and automatic invalidation.
24
+ * Manages per-branch schema caching with lazy loading and automatic
25
+ * invalidation.
16
26
  *
17
- * Caching Strategy:
18
- * - File-based cache at {branchRoot}/.canopy-meta/schema-cache.json (no in-memory layer
19
- * — intentional: matches prod behavior and keeps cache coherent across Lambda invocations)
20
- * - Invalidation: Writers create .stale marker, causing cache regeneration on next access
27
+ * ## Cross-process freshness (resource-generation.ts marker protocol)
28
+ *
29
+ * Several warm Lambda containers plus the EC2 worker can share one
30
+ * branch-clone root over EFS, each with its own copy of
31
+ * `schema-cache.json`. There is no shared memory and no cross-host file
32
+ * watching, so freshness is coordinated via the generic on-disk generation
33
+ * marker in resource-generation.ts: every cached snapshot embeds the marker
34
+ * token it was resolved against, and every read cheaply re-checks the live
35
+ * marker before trusting the cache. See that module's doc comment for the
36
+ * full protocol and residual staleness windows (A/B/C/E).
37
+ *
38
+ * BranchSchemaCache is the "durable snapshot consumer" case called out there:
39
+ * a resolve whose scan is served from stale NFS dentry/attribute caches can
40
+ * record a FRESH token over STALE data (window E), and because the result is
41
+ * written to `schema-cache.json`, that staleness becomes durable and shared
42
+ * with every other host that reads the marker - not just one process's
43
+ * memory. `loadFromCacheOrResolve` mitigates this the same way
44
+ * BranchRegistry does:
21
45
  *
22
- * Multi-User Support:
23
- * - User A modifies schema via SchemaOps → writes .stale marker
24
- * - User B loads schema later → sees .stale marker → regenerates cache
25
- * - Atomic file operations prevent corruption during concurrent access
46
+ * - The marker is captured strictly BEFORE `resolveFresh()` runs, and the
47
+ * captured token (not one read after resolving) is what gets embedded in
48
+ * the persisted snapshot. A bump landing mid-resolve therefore leaves the
49
+ * embedded token older than the live marker, forcing a re-resolve on the
50
+ * next read instead of silently resurrecting stale schema.
51
+ * - A snapshot is only persisted when the marker read itself succeeded
52
+ * (`{ ok: true }`). If the read fails for a reason other than "never
53
+ * bumped", we cannot attribute a token to this resolve, and stamping the
54
+ * snapshot with an unattributable token would make it indistinguishable
55
+ * from a correctly-attributed one to every future reader. The fresh result
56
+ * is still served to this caller; it just isn't written durably.
57
+ * - invalidate() bumps the marker with `mustSucceed: true`: an explicit
58
+ * invalidation (e.g. after a schema mutation) must not silently fail and
59
+ * leave every reader confidently stale with no bounding backstop.
60
+ * invalidate() itself does NOT eager-regenerate - it isn't given the
61
+ * entrySchemaRegistry/contentRootName that resolveSchema needs. The
62
+ * eager re-resolve (window-E mitigation, mirroring BranchRegistry's
63
+ * eager regen) lives one level up in SchemaOps.invalidateSchemaCache()
64
+ * (schema/schema-store.ts), which has those arguments and calls
65
+ * {@link resolveAndPersist} (NOT getSchema()) right after invalidating -
66
+ * so every SchemaOps mutation re-resolves on the mutating host via a scan
67
+ * that is GUARANTEED to run, not merely likely to run. getSchema() would
68
+ * be the wrong call here: its cache-read fast path can short-circuit
69
+ * through a snapshot a DIFFERENT concurrent host just wrote (see
70
+ * resolveAndPersist()'s doc comment for the exact race), silently
71
+ * skipping the one scan this eager re-resolve exists to guarantee. (The
72
+ * editor's follow-up schema GET is a separate Lambda invocation with no
73
+ * container affinity, so it could not serve this purpose either.)
74
+ * Callers of invalidate() that bypass SchemaOps (api/schema.ts's explicit
75
+ * invalidate endpoint; the bulk-mutation bump in
76
+ * invalidateBranchContentCaches) accept the lazy next-read regen.
77
+ *
78
+ * This is also why prod needs no mtime walk: the dev-only mtime check below
79
+ * exists solely to catch hand edits to .collection.json made outside the CMS
80
+ * (which bypass SchemaOps and therefore never bump the marker). Every
81
+ * mutation path that matters in prod - SchemaOps writes, git working-tree
82
+ * rewrites (checkout/merge/rebase/sync/migrate) - bumps the marker via
83
+ * SchemaOps.invalidateSchemaCache() or the combined
84
+ * invalidateBranchContentCaches() helper in content-index-generation.ts, so
85
+ * the marker alone is a sufficient prod backstop.
86
+ *
87
+ * Caching Strategy:
88
+ * - File-based cache at {branchRoot}/.canopy-meta/schema-cache.json (no
89
+ * in-memory layer - intentional: matches prod behavior and keeps cache
90
+ * coherent across Lambda invocations)
91
+ * - Invalidation: invalidate() bumps the cross-process generation marker;
92
+ * every reader sharing this branchRoot re-resolves at its next access.
26
93
  */
27
94
  export declare class BranchSchemaCache {
28
95
  /** Tracks when we last checked mtimes per contentRoot, to debounce rapid requests */
@@ -51,12 +118,60 @@ export declare class BranchSchemaCache {
51
118
  schema: RootCollectionConfig;
52
119
  flatSchema: FlatSchemaItem[];
53
120
  }>;
121
+ /**
122
+ * Resolve the schema from disk. Wrapped in a protected method (rather than
123
+ * calling the imported resolveSchema directly) so tests can subclass and
124
+ * override with a deferred/blocking implementation to simulate cross-process
125
+ * interleavings — mirrors BranchRegistry's scanBranchDirectories() hook.
126
+ */
127
+ protected resolveFresh(contentRoot: string, entrySchemaRegistry: EntrySchemaRegistry): Promise<SchemaResolutionResult>;
54
128
  /**
55
129
  * Load schema from cache or resolve fresh if cache is missing or stale.
56
130
  */
57
131
  private loadFromCacheOrResolve;
58
132
  /**
59
- * Invalidate cache for a branch (creates .stale marker).
133
+ * Resolve the schema fresh from disk and persist it (subject to the
134
+ * skip-persist rule below), UNCONDITIONALLY -- never through the cache-read
135
+ * fast path in {@link loadFromCacheOrResolve}. Shared by that method's
136
+ * cache-miss fallback and by {@link resolveAndPersist}.
137
+ */
138
+ private resolveFreshAndPersist;
139
+ /**
140
+ * Resolve the schema fresh from disk and persist it, SKIPPING the cache
141
+ * read entirely -- unlike {@link getSchema}, which can short-circuit
142
+ * through a cache HIT. That distinction matters for eager re-resolve after
143
+ * invalidation (see {@link invalidate}'s doc comment and
144
+ * `SchemaOps.invalidateSchemaCache()` in schema/schema-store.ts, the sole
145
+ * intended caller): the whole point of an eager re-resolve is to guarantee
146
+ * ONE scan that is coherent with the mutation this host just made. A plain
147
+ * `getSchema()` call right after `invalidate()` is NOT that guarantee --
148
+ * `getSchema()`'s cache-read fast path (`loadFromCacheOrResolve`) can still
149
+ * return a snapshot written by a DIFFERENT, concurrent host: if that
150
+ * foreign host's own eager re-resolve raced this one and embedded the
151
+ * (now-current) marker token over ITS OWN stale-NFS-cache scan (the
152
+ * window-E case this class's doc comment describes), this host's
153
+ * `getSchema()` would happily accept that foreign snapshot as "current"
154
+ * and skip the one scan this call was specifically trying to guarantee.
155
+ * `resolveAndPersist()` never reads the cache file at all, so it cannot be
156
+ * short-circuited that way.
157
+ */
158
+ resolveAndPersist(branchRoot: string, entrySchemaRegistry: EntrySchemaRegistry, contentRootName?: string): Promise<{
159
+ schema: RootCollectionConfig;
160
+ flatSchema: FlatSchemaItem[];
161
+ }>;
162
+ /**
163
+ * Invalidate cache for a branch by bumping the cross-process generation
164
+ * marker (resource-generation.ts). Every process sharing this branchRoot
165
+ * will re-resolve at its next read.
166
+ *
167
+ * The bump must succeed: a swallowed failure here would leave the schema
168
+ * cache stale indefinitely with no bounding backstop (unlike
169
+ * BranchRegistry, there is no get-miss backstop for a resolved schema).
170
+ *
171
+ * No eager regeneration here (unlike BranchRegistry.invalidate()): this
172
+ * method isn't given the entrySchemaRegistry/contentRootName resolveSchema
173
+ * needs. See the class doc comment for why the mutating request's own
174
+ * follow-up schema read closes the same window-E gap instead.
60
175
  *
61
176
  * @param branchRoot - Root directory of the branch
62
177
  */
@@ -1 +1 @@
1
- {"version":3,"file":"branch-schema-cache.d.ts","sourceRoot":"","sources":["../src/branch-schema-cache.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AACpD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AACpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAA;AAWzD;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,oBAAoB,CAAA;IAC5B,UAAU,EAAE,cAAc,EAAE,CAAA;IAC5B,QAAQ,EAAE,MAAM,CAAA;CACjB;AA8BD;;;;;;;;;;;;GAYG;AACH,qBAAa,iBAAiB;IAC5B,qFAAqF;IACrF,OAAO,CAAC,cAAc,CAA4B;IAElD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;gBAErB,IAAI,GAAE,aAAsB;IAIxC;;;;;;;;OAQG;IACH,OAAO,CAAC,aAAa;IAIrB;;;;;;;OAOG;IACG,SAAS,CACb,UAAU,EAAE,MAAM,EAClB,mBAAmB,EAAE,mBAAmB,EACxC,eAAe,GAAE,MAAkB,GAClC,OAAO,CAAC;QAAE,MAAM,EAAE,oBAAoB,CAAC;QAAC,UAAU,EAAE,cAAc,EAAE,CAAA;KAAE,CAAC;IAI1E;;OAEG;YACW,sBAAsB;IA+FpC;;;;OAIG;IACG,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CASpD"}
1
+ {"version":3,"file":"branch-schema-cache.d.ts","sourceRoot":"","sources":["../src/branch-schema-cache.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AACpD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AACpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,KAAK,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAA;AAiBjF,yEAAyE;AACzE,eAAO,MAAM,0BAA0B,WAAW,CAAA;AAElD;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,oBAAoB,CAAA;IAC5B,UAAU,EAAE,cAAc,EAAE,CAAA;IAC5B,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;;OAKG;IACH,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B;AAmCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AACH,qBAAa,iBAAiB;IAC5B,qFAAqF;IACrF,OAAO,CAAC,cAAc,CAA4B;IAElD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;gBAErB,IAAI,GAAE,aAAsB;IAIxC;;;;;;;;OAQG;IACH,OAAO,CAAC,aAAa;IAIrB;;;;;;;OAOG;IACG,SAAS,CACb,UAAU,EAAE,MAAM,EAClB,mBAAmB,EAAE,mBAAmB,EACxC,eAAe,GAAE,MAAkB,GAClC,OAAO,CAAC;QAAE,MAAM,EAAE,oBAAoB,CAAC;QAAC,UAAU,EAAE,cAAc,EAAE,CAAA;KAAE,CAAC;IAI1E;;;;;OAKG;cACa,YAAY,CAC1B,WAAW,EAAE,MAAM,EACnB,mBAAmB,EAAE,mBAAmB,GACvC,OAAO,CAAC,sBAAsB,CAAC;IAIlC;;OAEG;YACW,sBAAsB;IA+DpC;;;;;OAKG;YACW,sBAAsB;IA0EpC;;;;;;;;;;;;;;;;;;OAkBG;IACG,iBAAiB,CACrB,UAAU,EAAE,MAAM,EAClB,mBAAmB,EAAE,mBAAmB,EACxC,eAAe,GAAE,MAAkB,GAClC,OAAO,CAAC;QAAE,MAAM,EAAE,oBAAoB,CAAC;QAAC,UAAU,EAAE,cAAc,EAAE,CAAA;KAAE,CAAC;IAO1E;;;;;;;;;;;;;;;OAeG;IACG,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAKpD"}