canopycms 0.0.55 → 0.0.57

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 (123) hide show
  1. package/dist/api/branch.d.ts +6 -0
  2. package/dist/api/branch.d.ts.map +1 -1
  3. package/dist/api/branch.js +63 -19
  4. package/dist/api/branch.js.map +1 -1
  5. package/dist/api/content.d.ts +2 -2
  6. package/dist/api/entries.d.ts.map +1 -1
  7. package/dist/api/entries.js +10 -2
  8. package/dist/api/entries.js.map +1 -1
  9. package/dist/api/github-sync.d.ts.map +1 -1
  10. package/dist/api/github-sync.js +28 -7
  11. package/dist/api/github-sync.js.map +1 -1
  12. package/dist/auth/caching-auth-plugin.d.ts +8 -1
  13. package/dist/auth/caching-auth-plugin.d.ts.map +1 -1
  14. package/dist/auth/caching-auth-plugin.js +6 -1
  15. package/dist/auth/caching-auth-plugin.js.map +1 -1
  16. package/dist/auth/plugin.d.ts +19 -10
  17. package/dist/auth/plugin.d.ts.map +1 -1
  18. package/dist/auth/plugin.js +12 -10
  19. package/dist/auth/plugin.js.map +1 -1
  20. package/dist/branch-metadata.d.ts +71 -6
  21. package/dist/branch-metadata.d.ts.map +1 -1
  22. package/dist/branch-metadata.js +132 -116
  23. package/dist/branch-metadata.js.map +1 -1
  24. package/dist/branch-registry.d.ts +78 -9
  25. package/dist/branch-registry.d.ts.map +1 -1
  26. package/dist/branch-registry.js +155 -29
  27. package/dist/branch-registry.js.map +1 -1
  28. package/dist/branch-schema-cache.d.ts +126 -11
  29. package/dist/branch-schema-cache.d.ts.map +1 -1
  30. package/dist/branch-schema-cache.js +208 -58
  31. package/dist/branch-schema-cache.js.map +1 -1
  32. package/dist/build/generate-ai-content.d.ts.map +1 -1
  33. package/dist/build/generate-ai-content.js +7 -0
  34. package/dist/build/generate-ai-content.js.map +1 -1
  35. package/dist/cli/generate-ai-content.js +2358 -1556
  36. package/dist/cli/init.d.ts.map +1 -1
  37. package/dist/cli/init.js +16 -8
  38. package/dist/cli/init.js.map +1 -1
  39. package/dist/cli/migrate.d.ts.map +1 -1
  40. package/dist/cli/migrate.js +25 -0
  41. package/dist/cli/migrate.js.map +1 -1
  42. package/dist/cli/sync.js +5 -5
  43. package/dist/cli/sync.js.map +1 -1
  44. package/dist/cli/templates.d.ts.map +1 -1
  45. package/dist/cli/templates.js +6 -3
  46. package/dist/cli/templates.js.map +1 -1
  47. package/dist/comment-store.d.ts +45 -15
  48. package/dist/comment-store.d.ts.map +1 -1
  49. package/dist/comment-store.js +80 -101
  50. package/dist/comment-store.js.map +1 -1
  51. package/dist/config/helpers.d.ts.map +1 -1
  52. package/dist/config/helpers.js +3 -0
  53. package/dist/config/helpers.js.map +1 -1
  54. package/dist/config/schemas/config.d.ts +3 -4
  55. package/dist/config/schemas/config.d.ts.map +1 -1
  56. package/dist/config/schemas/config.js +6 -3
  57. package/dist/config/schemas/config.js.map +1 -1
  58. package/dist/config/types.d.ts +6 -1
  59. package/dist/config/types.d.ts.map +1 -1
  60. package/dist/config-test.d.ts +5 -3
  61. package/dist/config-test.d.ts.map +1 -1
  62. package/dist/config-test.js +9 -1
  63. package/dist/config-test.js.map +1 -1
  64. package/dist/content-index-generation.d.ts +40 -4
  65. package/dist/content-index-generation.d.ts.map +1 -1
  66. package/dist/content-index-generation.js +70 -73
  67. package/dist/content-index-generation.js.map +1 -1
  68. package/dist/content-listing.d.ts +3 -1
  69. package/dist/content-listing.d.ts.map +1 -1
  70. package/dist/content-listing.js +1 -0
  71. package/dist/content-listing.js.map +1 -1
  72. package/dist/content-store.d.ts +63 -0
  73. package/dist/content-store.d.ts.map +1 -1
  74. package/dist/content-store.js +421 -184
  75. package/dist/content-store.js.map +1 -1
  76. package/dist/editor/Editor.d.ts.map +1 -1
  77. package/dist/editor/Editor.js +19 -15
  78. package/dist/editor/Editor.js.map +1 -1
  79. package/dist/editor/hooks/useDraftManager.d.ts +7 -0
  80. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  81. package/dist/editor/hooks/useDraftManager.js +69 -31
  82. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  83. package/dist/editor/hooks/useSchemaManager.d.ts +34 -10
  84. package/dist/editor/hooks/useSchemaManager.d.ts.map +1 -1
  85. package/dist/editor/hooks/useSchemaManager.js +20 -24
  86. package/dist/editor/hooks/useSchemaManager.js.map +1 -1
  87. package/dist/editor/schema-editor/CollectionEditor.d.ts +6 -5
  88. package/dist/editor/schema-editor/CollectionEditor.d.ts.map +1 -1
  89. package/dist/editor/schema-editor/CollectionEditor.js +9 -4
  90. package/dist/editor/schema-editor/CollectionEditor.js.map +1 -1
  91. package/dist/git-manager.d.ts +15 -10
  92. package/dist/git-manager.d.ts.map +1 -1
  93. package/dist/git-manager.js +16 -11
  94. package/dist/git-manager.js.map +1 -1
  95. package/dist/github-service.d.ts +63 -7
  96. package/dist/github-service.d.ts.map +1 -1
  97. package/dist/github-service.js +147 -39
  98. package/dist/github-service.js.map +1 -1
  99. package/dist/resource-generation.d.ts +54 -0
  100. package/dist/resource-generation.d.ts.map +1 -0
  101. package/dist/resource-generation.js +156 -0
  102. package/dist/resource-generation.js.map +1 -0
  103. package/dist/schema/schema-store.d.ts +29 -2
  104. package/dist/schema/schema-store.d.ts.map +1 -1
  105. package/dist/schema/schema-store.js +47 -8
  106. package/dist/schema/schema-store.js.map +1 -1
  107. package/dist/services.js +4 -0
  108. package/dist/services.js.map +1 -1
  109. package/dist/static/index.d.ts +32 -0
  110. package/dist/static/index.d.ts.map +1 -1
  111. package/dist/static/index.js +90 -0
  112. package/dist/static/index.js.map +1 -1
  113. package/dist/sync-core.js +2 -2
  114. package/dist/sync-core.js.map +1 -1
  115. package/dist/utils/occ-json-write.d.ts +100 -0
  116. package/dist/utils/occ-json-write.d.ts.map +1 -0
  117. package/dist/utils/occ-json-write.js +234 -0
  118. package/dist/utils/occ-json-write.js.map +1 -0
  119. package/dist/worker/cms-worker.d.ts +6 -3
  120. package/dist/worker/cms-worker.d.ts.map +1 -1
  121. package/dist/worker/cms-worker.js +42 -41
  122. package/dist/worker/cms-worker.js.map +1 -1
  123. package/package.json +2 -1
@@ -7,12 +7,40 @@ export interface BranchMetadataFile {
7
7
  branch: BranchMetadata;
8
8
  }
9
9
  export declare class BranchMetadataConflictError extends Error {
10
- constructor();
10
+ constructor(message?: string);
11
11
  }
12
+ /**
13
+ * Manages branch.json — branch status and access ACLs, both security-adjacent
14
+ * state — under `.canopy-meta/` in a branch workspace.
15
+ *
16
+ * save() is protected by three layers, outermost to innermost (identical
17
+ * structure to {@link CommentStore}'s withMutation, see comment-store.ts):
18
+ *
19
+ * 1. {@link withLock} - an in-process FIFO mutex keyed by the resolved file
20
+ * path. Serializes concurrent mutators on the SAME process/host
21
+ * deterministically.
22
+ * 2. {@link withOccFileLock} - a server-enforced, cross-process/cross-host
23
+ * lock (proper-lockfile, mkdir-based). This is the actual fix for lost
24
+ * branch-status/ACL updates across two warm Lambda containers (or a
25
+ * Lambda + the EC2 worker) on EFS: rename-based OCC verification alone
26
+ * relies on a read-back that can be served from the writer's own local
27
+ * NFS dentry/attribute cache, so a foreign writer's rename can stay
28
+ * invisible for that cache's window (commonly 3-60s) and both writers
29
+ * conclude they won. A settle delay does not help — the cache window
30
+ * dwarfs any sleep worth paying — only server-enforced mutual exclusion
31
+ * does. Given branch.json carries status + ACLs, silently losing an
32
+ * update here is a correctness/security issue, not just a UX glitch.
33
+ * 3. {@link withOccRetry} around {@link writeOccJsonFile} - version/writeId
34
+ * based optimistic concurrency control. With layers 1-2 in place this is
35
+ * now a defense-in-depth backstop only, not the primary safety mechanism.
36
+ *
37
+ * See `utils/occ-json-write.ts` for full guarantee documentation of layers 2-3.
38
+ */
12
39
  export declare class BranchMetadataFileManager {
13
40
  private readonly branchRoot;
14
41
  private readonly filePath;
15
42
  private readonly baseRoot;
43
+ private readonly settleMs;
16
44
  private constructor();
17
45
  /**
18
46
  * Load branch metadata without requiring baseRoot.
@@ -23,14 +51,49 @@ export declare class BranchMetadataFileManager {
23
51
  * Get a BranchMetadataFileManager instance configured for registry invalidation.
24
52
  * Use this in API handlers to ensure registry cache is invalidated on updates.
25
53
  */
26
- static get(branchRoot: string, baseRoot: string): BranchMetadataFileManager;
54
+ static get(branchRoot: string, baseRoot: string, options?: {
55
+ settleMs?: number;
56
+ }): BranchMetadataFileManager;
27
57
  private load;
28
58
  /**
29
- * Atomic write using temp-file + rename + post-write verification.
30
- * Follows the same pattern as CommentStore for EFS/NFS safety.
59
+ * Write branch.json via the shared OCC helper, with the schemaVersion
60
+ * default applied here (payload shaping stays branch-metadata's concern).
61
+ *
62
+ * Throws the helper's raw {@link OccWriteConflictError} so the surrounding
63
+ * {@link withOccRetry} in save() recognizes and retries it; translation to
64
+ * the public `BranchMetadataConflictError` contract happens at the save()
65
+ * boundary, after retries are exhausted (translating earlier would make
66
+ * withOccRetry's default predicate miss it, since it only recognizes the
67
+ * raw error type).
68
+ *
69
+ * branch-metadata historically writes WITH a trailing newline, unlike
70
+ * comment-store; `trailingNewline: true` preserves that.
31
71
  */
32
72
  private write;
33
- private withRetry;
73
+ /**
74
+ * Run a save cycle under the full lock + OCC-retry stack described in the
75
+ * class doc comment. A conflict that survives every retry surfaces as the
76
+ * public `BranchMetadataConflictError`.
77
+ *
78
+ * Guards against a phantom-resurrection race with branch deletion: a
79
+ * caller's branchContext can be resolved BEFORE a concurrent
80
+ * deleteBranchHandler removes the branch directory, but this save() call
81
+ * only reaches here (and its own mkdir({recursive:true}) inside write())
82
+ * AFTER the removal. Without this check, that mkdir would silently
83
+ * recreate `.canopy-meta/` (and this save would recreate branch.json from
84
+ * defaults) inside a directory tree that no longer exists anywhere else --
85
+ * a registry entry with no clone behind it. Checking BEFORE the lock stack
86
+ * (rather than after) fails fast without paying for a lock acquisition on
87
+ * a doomed save.
88
+ *
89
+ * Residual window (accepted): a save that passes this check can still
90
+ * race a `rm` that starts moments later and is still mid-flight when this
91
+ * save's write lands, resurrecting the tree. Closing that fully would
92
+ * need a tombstone OUTSIDE the tree being removed -- the lockfile
93
+ * (`withOccFileLock`) this save takes next lives INSIDE `branchRoot`, so
94
+ * it cannot itself provide a wider guarantee than "the directory existed
95
+ * a moment ago."
96
+ */
34
97
  save(incoming: BranchMetadataUpdate): Promise<BranchMetadataFile>;
35
98
  /**
36
99
  * Invalidates the registry cache so next list() call regenerates from branch.json files.
@@ -49,7 +112,9 @@ export interface BranchMetadataUpdate {
49
112
  * Get a BranchMetadataFileManager instance configured for registry invalidation.
50
113
  * Use this in API handlers to ensure registry cache is invalidated on updates.
51
114
  */
52
- export declare const getBranchMetadataFileManager: (branchRoot: string, baseRoot: string) => BranchMetadataFileManager;
115
+ export declare const getBranchMetadataFileManager: (branchRoot: string, baseRoot: string, options?: {
116
+ settleMs?: number;
117
+ }) => BranchMetadataFileManager;
53
118
  /**
54
119
  * Load branch context from metadata file (source of truth).
55
120
  * Returns null if the branch doesn't exist.
@@ -1 +1 @@
1
- {"version":3,"file":"branch-metadata.d.ts","sourceRoot":"","sources":["../src/branch-metadata.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAgB,MAAM,SAAS,CAAA;AAG1E,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAOrD,MAAM,WAAW,kBAAkB;IACjC,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,cAAc,CAAA;CACvB;AAID,qBAAa,2BAA4B,SAAQ,KAAK;;CAKrD;AAED,qBAAa,yBAAyB;IACpC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAQ;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAQ;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAQ;IAEjC,OAAO;IAMP;;;OAGG;WACU,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAa7E;;;OAGG;IACH,MAAM,CAAC,GAAG,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,yBAAyB;YAI7D,IAAI;IAclB;;;OAGG;YACW,KAAK;YA8EL,SAAS;IAiBjB,IAAI,CAAC,QAAQ,EAAE,oBAAoB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IA2CvE;;OAEG;YACW,kBAAkB;CAIjC;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,MAAM,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,WAAW,CAAC,CAAC,CAAA;CAClE;AAED;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACvC,YAAY,MAAM,EAClB,UAAU,MAAM,KACf,yBAEF,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB,GAAU,SAAS;IAC/C,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,aAAa,CAAA;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B,KAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAiB/B,CAAA"}
1
+ {"version":3,"file":"branch-metadata.d.ts","sourceRoot":"","sources":["../src/branch-metadata.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAgB,MAAM,SAAS,CAAA;AAG1E,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAarD,MAAM,WAAW,kBAAkB;IACjC,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,cAAc,CAAA;CACvB;AAID,qBAAa,2BAA4B,SAAQ,KAAK;gBACxC,OAAO,SAAwD;CAI5E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,yBAAyB;IACpC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAQ;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAQ;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAQ;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoB;IAE7C,OAAO;IAOP;;;OAGG;WACU,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAa7E;;;OAGG;IACH,MAAM,CAAC,GAAG,CACR,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAC9B,yBAAyB;YAId,IAAI;IAclB;;;;;;;;;;;;;OAaG;YACW,KAAK;IAenB;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACG,IAAI,CAAC,QAAQ,EAAE,oBAAoB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAoEvE;;OAEG;YACW,kBAAkB;CAIjC;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,MAAM,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,WAAW,CAAC,CAAC,CAAA;CAClE;AAED;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACvC,YAAY,MAAM,EAClB,UAAU,MAAM,EAChB,UAAU;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,KAC9B,yBAEF,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB,GAAU,SAAS;IAC/C,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,aAAa,CAAA;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B,KAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAiB/B,CAAA"}
@@ -1,24 +1,52 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import fs from 'node:fs/promises';
3
2
  import path from 'node:path';
4
3
  import { BranchRegistry } from './branch-registry.js';
5
4
  import { resolveBranchPath } from './paths/index.js';
6
- import { isFileExistsError, isNotFoundError } from './utils/error.js';
5
+ import { isNotFoundError } from './utils/error.js';
7
6
  import { withLock } from './utils/async-mutex.js';
7
+ import { writeOccJsonFile, withOccRetry, withOccFileLock, OccWriteConflictError, } from './utils/occ-json-write.js';
8
8
  const BRANCH_META_DIR = '.canopy-meta';
9
9
  const BRANCH_META_FILE = 'branch.json';
10
10
  const CURRENT_SCHEMA_VERSION = 1;
11
11
  export class BranchMetadataConflictError extends Error {
12
- constructor() {
13
- super('Concurrent modification detected in branch metadata');
12
+ constructor(message = 'Concurrent modification detected in branch metadata') {
13
+ super(message);
14
14
  this.name = 'BranchMetadataConflictError';
15
15
  }
16
16
  }
17
+ /**
18
+ * Manages branch.json — branch status and access ACLs, both security-adjacent
19
+ * state — under `.canopy-meta/` in a branch workspace.
20
+ *
21
+ * save() is protected by three layers, outermost to innermost (identical
22
+ * structure to {@link CommentStore}'s withMutation, see comment-store.ts):
23
+ *
24
+ * 1. {@link withLock} - an in-process FIFO mutex keyed by the resolved file
25
+ * path. Serializes concurrent mutators on the SAME process/host
26
+ * deterministically.
27
+ * 2. {@link withOccFileLock} - a server-enforced, cross-process/cross-host
28
+ * lock (proper-lockfile, mkdir-based). This is the actual fix for lost
29
+ * branch-status/ACL updates across two warm Lambda containers (or a
30
+ * Lambda + the EC2 worker) on EFS: rename-based OCC verification alone
31
+ * relies on a read-back that can be served from the writer's own local
32
+ * NFS dentry/attribute cache, so a foreign writer's rename can stay
33
+ * invisible for that cache's window (commonly 3-60s) and both writers
34
+ * conclude they won. A settle delay does not help — the cache window
35
+ * dwarfs any sleep worth paying — only server-enforced mutual exclusion
36
+ * does. Given branch.json carries status + ACLs, silently losing an
37
+ * update here is a correctness/security issue, not just a UX glitch.
38
+ * 3. {@link withOccRetry} around {@link writeOccJsonFile} - version/writeId
39
+ * based optimistic concurrency control. With layers 1-2 in place this is
40
+ * now a defense-in-depth backstop only, not the primary safety mechanism.
41
+ *
42
+ * See `utils/occ-json-write.ts` for full guarantee documentation of layers 2-3.
43
+ */
17
44
  export class BranchMetadataFileManager {
18
- constructor(branchRoot, baseRoot) {
45
+ constructor(branchRoot, baseRoot, options) {
19
46
  this.branchRoot = path.resolve(branchRoot);
20
47
  this.filePath = path.join(this.branchRoot, BRANCH_META_DIR, BRANCH_META_FILE);
21
48
  this.baseRoot = baseRoot;
49
+ this.settleMs = options?.settleMs;
22
50
  }
23
51
  /**
24
52
  * Load branch metadata without requiring baseRoot.
@@ -41,8 +69,8 @@ export class BranchMetadataFileManager {
41
69
  * Get a BranchMetadataFileManager instance configured for registry invalidation.
42
70
  * Use this in API handlers to ensure registry cache is invalidated on updates.
43
71
  */
44
- static get(branchRoot, baseRoot) {
45
- return new BranchMetadataFileManager(branchRoot, baseRoot);
72
+ static get(branchRoot, baseRoot, options) {
73
+ return new BranchMetadataFileManager(branchRoot, baseRoot, options);
46
74
  }
47
75
  async load() {
48
76
  try {
@@ -59,126 +87,114 @@ export class BranchMetadataFileManager {
59
87
  }
60
88
  }
61
89
  /**
62
- * Atomic write using temp-file + rename + post-write verification.
63
- * Follows the same pattern as CommentStore for EFS/NFS safety.
90
+ * Write branch.json via the shared OCC helper, with the schemaVersion
91
+ * default applied here (payload shaping stays branch-metadata's concern).
92
+ *
93
+ * Throws the helper's raw {@link OccWriteConflictError} so the surrounding
94
+ * {@link withOccRetry} in save() recognizes and retries it; translation to
95
+ * the public `BranchMetadataConflictError` contract happens at the save()
96
+ * boundary, after retries are exhausted (translating earlier would make
97
+ * withOccRetry's default predicate miss it, since it only recognizes the
98
+ * raw error type).
99
+ *
100
+ * branch-metadata historically writes WITH a trailing newline, unlike
101
+ * comment-store; `trailingNewline: true` preserves that.
64
102
  */
65
103
  async write(meta, expectedVersion) {
66
- const newVersion = expectedVersion === null ? 1 : expectedVersion + 1;
67
- const writeId = randomUUID();
68
104
  const payload = {
69
105
  ...meta,
70
106
  schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION,
71
- version: newVersion,
72
- writeId,
73
107
  };
74
- await fs.mkdir(path.dirname(this.filePath), { recursive: true });
75
- const content = JSON.stringify(payload, null, 2) + '\n';
76
- if (expectedVersion === null) {
77
- // New file: use temp-file + rename for atomicity, then link(target) to detect EEXIST races.
78
- // Plain writeFile({flag:'wx'}) is not atomic — a crash mid-write leaves a partial file
79
- // that makes JSON.parse fail on next startup, permanently breaking the branch.
80
- const tempPath = `${this.filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
81
- await fs.writeFile(tempPath, content, 'utf-8');
82
- try {
83
- // link() is atomic and fails with EEXIST if the target already exists,
84
- // giving us the same exclusive-create semantics as wx without the atomicity risk.
85
- await fs.link(tempPath, this.filePath);
86
- }
87
- catch (err) {
88
- await fs.unlink(tempPath).catch(() => { });
89
- if (isFileExistsError(err)) {
90
- throw new BranchMetadataConflictError();
91
- }
92
- throw err;
93
- }
94
- await fs.unlink(tempPath).catch(() => { });
95
- return { version: newVersion, writeId };
96
- }
97
- // Existing file: temp write + atomic rename + verification
98
- const tempPath = `${this.filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
99
- await fs.writeFile(tempPath, content, 'utf-8');
108
+ return writeOccJsonFile(this.filePath, payload, {
109
+ expectedVersion,
110
+ settleMs: this.settleMs,
111
+ trailingNewline: true,
112
+ });
113
+ }
114
+ /**
115
+ * Run a save cycle under the full lock + OCC-retry stack described in the
116
+ * class doc comment. A conflict that survives every retry surfaces as the
117
+ * public `BranchMetadataConflictError`.
118
+ *
119
+ * Guards against a phantom-resurrection race with branch deletion: a
120
+ * caller's branchContext can be resolved BEFORE a concurrent
121
+ * deleteBranchHandler removes the branch directory, but this save() call
122
+ * only reaches here (and its own mkdir({recursive:true}) inside write())
123
+ * AFTER the removal. Without this check, that mkdir would silently
124
+ * recreate `.canopy-meta/` (and this save would recreate branch.json from
125
+ * defaults) inside a directory tree that no longer exists anywhere else --
126
+ * a registry entry with no clone behind it. Checking BEFORE the lock stack
127
+ * (rather than after) fails fast without paying for a lock acquisition on
128
+ * a doomed save.
129
+ *
130
+ * Residual window (accepted): a save that passes this check can still
131
+ * race a `rm` that starts moments later and is still mid-flight when this
132
+ * save's write lands, resurrecting the tree. Closing that fully would
133
+ * need a tombstone OUTSIDE the tree being removed -- the lockfile
134
+ * (`withOccFileLock`) this save takes next lives INSIDE `branchRoot`, so
135
+ * it cannot itself provide a wider guarantee than "the directory existed
136
+ * a moment ago."
137
+ */
138
+ async save(incoming) {
100
139
  try {
101
- // Fast-fail optimization: check version before rename to avoid unnecessary
102
- // rename + verify cycle. Not a correctness guarantee — the post-write
103
- // writeId verification below is what actually detects cross-process races.
104
- let currentVersion = null;
105
- try {
106
- const current = JSON.parse(await fs.readFile(this.filePath, 'utf-8'));
107
- currentVersion = current.version ?? 0;
108
- }
109
- catch {
110
- currentVersion = null;
111
- }
112
- if (currentVersion !== expectedVersion) {
113
- throw new BranchMetadataConflictError();
114
- }
115
- // Atomic rename
116
- await fs.rename(tempPath, this.filePath);
117
- // Post-write verification: confirm our write landed (catches cross-process races)
118
- const afterWrite = JSON.parse(await fs.readFile(this.filePath, 'utf-8'));
119
- if (afterWrite.writeId !== writeId) {
120
- throw new BranchMetadataConflictError();
121
- }
140
+ await fs.stat(this.branchRoot);
122
141
  }
123
142
  catch (err) {
124
- await fs.unlink(tempPath).catch(() => { });
143
+ if (isNotFoundError(err)) {
144
+ throw new BranchMetadataConflictError('Branch no longer exists');
145
+ }
125
146
  throw err;
126
147
  }
127
- return { version: newVersion, writeId };
128
- }
129
- async withRetry(operation, maxAttempts = 5) {
130
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
131
- try {
132
- return await operation();
133
- }
134
- catch (err) {
135
- if (err instanceof BranchMetadataConflictError && attempt < maxAttempts) {
136
- const baseDelay = Math.min(10 * Math.pow(2, attempt - 1), 100);
137
- const jitter = Math.random() * baseDelay;
138
- await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
139
- continue;
140
- }
141
- throw err;
148
+ let saved;
149
+ try {
150
+ saved = await withLock(this.filePath, () => withOccFileLock(this.filePath, () => withOccRetry(async () => {
151
+ const { meta: existing, version } = await this.load();
152
+ const now = new Date().toISOString();
153
+ const defaults = {
154
+ name: 'unknown',
155
+ status: 'editing',
156
+ access: {},
157
+ createdBy: 'unknown',
158
+ createdAt: now,
159
+ updatedAt: now,
160
+ };
161
+ const merged = {
162
+ schemaVersion: CURRENT_SCHEMA_VERSION,
163
+ version: version ?? 0,
164
+ branch: {
165
+ ...defaults,
166
+ ...existing?.branch,
167
+ ...incoming.branch,
168
+ access: {
169
+ ...existing?.branch?.access,
170
+ ...incoming.branch?.access,
171
+ },
172
+ // Immutable after creation
173
+ createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
174
+ createdAt: existing?.branch.createdAt ?? defaults.createdAt,
175
+ // Fork point is recorded once at creation; later saves must not move it
176
+ baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
177
+ },
178
+ };
179
+ const written = await this.write(merged, version);
180
+ merged.version = written.version;
181
+ merged.writeId = written.writeId;
182
+ return merged;
183
+ })));
184
+ }
185
+ catch (err) {
186
+ if (err instanceof OccWriteConflictError) {
187
+ throw new BranchMetadataConflictError();
142
188
  }
189
+ throw err;
143
190
  }
144
- throw new Error('Unreachable');
145
- }
146
- async save(incoming) {
147
- return withLock(this.filePath, () => this.withRetry(async () => {
148
- const { meta: existing, version } = await this.load();
149
- const now = new Date().toISOString();
150
- const defaults = {
151
- name: 'unknown',
152
- status: 'editing',
153
- access: {},
154
- createdBy: 'unknown',
155
- createdAt: now,
156
- updatedAt: now,
157
- };
158
- const merged = {
159
- schemaVersion: CURRENT_SCHEMA_VERSION,
160
- version: version ?? 0,
161
- branch: {
162
- ...defaults,
163
- ...existing?.branch,
164
- ...incoming.branch,
165
- access: {
166
- ...existing?.branch?.access,
167
- ...incoming.branch?.access,
168
- },
169
- // Immutable after creation
170
- createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
171
- createdAt: existing?.branch.createdAt ?? defaults.createdAt,
172
- // Fork point is recorded once at creation; later saves must not move it
173
- baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
174
- },
175
- };
176
- const written = await this.write(merged, version);
177
- merged.version = written.version;
178
- merged.writeId = written.writeId;
179
- await this.invalidateRegistry();
180
- return merged;
181
- }));
191
+ // Registry invalidation AFTER releasing the lockfile: the protocol only
192
+ // requires the bump to land strictly after the branch.json write (it
193
+ // does), and the registry's eager regeneration is O(branch count) fs
194
+ // reads on EFS — holding the server-enforced lock through it would
195
+ // extend every save's critical section for no correctness gain.
196
+ await this.invalidateRegistry();
197
+ return saved;
182
198
  }
183
199
  /**
184
200
  * Invalidates the registry cache so next list() call regenerates from branch.json files.
@@ -192,8 +208,8 @@ export class BranchMetadataFileManager {
192
208
  * Get a BranchMetadataFileManager instance configured for registry invalidation.
193
209
  * Use this in API handlers to ensure registry cache is invalidated on updates.
194
210
  */
195
- export const getBranchMetadataFileManager = (branchRoot, baseRoot) => {
196
- return BranchMetadataFileManager.get(branchRoot, baseRoot);
211
+ export const getBranchMetadataFileManager = (branchRoot, baseRoot, options) => {
212
+ return BranchMetadataFileManager.get(branchRoot, baseRoot, options);
197
213
  };
198
214
  /**
199
215
  * Load branch context from metadata file (source of truth).
@@ -1 +1 @@
1
- {"version":3,"file":"branch-metadata.js","sourceRoot":"","sources":["../src/branch-metadata.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"branch-metadata.js","sourceRoot":"","sources":["../src/branch-metadata.ts"],"names":[],"mappings":"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"}
@@ -2,43 +2,112 @@ import type { BranchContext } from './types';
2
2
  export interface BranchRegistrySnapshot {
3
3
  version: number;
4
4
  branches: BranchContext[];
5
+ /**
6
+ * The resource-generation.ts marker token this snapshot was built against, or
7
+ * null if it was built before any bump ever occurred on this root. Compared
8
+ * against the live marker (via isGenerationCurrent) to decide freshness.
9
+ */
10
+ generation: string | null;
5
11
  }
6
12
  /**
7
13
  * BranchRegistry is a read-only cache for fast branch listing.
8
14
  * Individual branch.json files are the source of truth.
9
15
  *
16
+ * ## Cross-process freshness (resource-generation.ts marker protocol)
17
+ *
18
+ * Several warm Lambda containers plus the EC2 worker can share one branch-clone
19
+ * root over EFS, each with its own in-process cache of `branches.json`. There is
20
+ * no shared memory and no cross-host file watching, so freshness is coordinated
21
+ * via the generic on-disk generation marker in resource-generation.ts: every
22
+ * snapshot embeds the marker token it was built against, and every read
23
+ * cheaply re-checks the live marker before trusting the cached snapshot. See
24
+ * that module's doc comment for the full protocol and residual staleness
25
+ * windows (A/B/C/E).
26
+ *
27
+ * BranchRegistry is exactly the "durable snapshot consumer" case called out
28
+ * there: a regeneration whose scan is served from stale NFS dentry/attribute
29
+ * caches can record a FRESH token over STALE data (window E), and because the
30
+ * result is written to `branches.json`, that staleness becomes durable and
31
+ * shared with every other host that reads the marker - not just one process's
32
+ * memory. Two mitigations close the practical gap:
33
+ *
34
+ * - invalidate() eager-regenerates immediately after its own bump. The
35
+ * mutating host's own scan is necessarily coherent with the mutation it just
36
+ * made (no NFS round trip was needed to observe its own write), so this
37
+ * closes window (E) for the common case without waiting for some other host
38
+ * to lazily pull the change.
39
+ * - get() implements a suspicious-miss backstop: a branch that "should" exist
40
+ * but is absent from the cached snapshot forces one fresh regeneration
41
+ * (throttled), bounding how long a bad snapshot can hide a real branch.
42
+ *
43
+ * regenerate() also skips persisting a snapshot when the marker read itself
44
+ * fails (`{ ok: false }`, e.g. unreadable for a non-ENOENT reason): a snapshot
45
+ * embedding a token we cannot attribute to "matches this scan" would be
46
+ * indistinguishable from a correctly-attributed one to every future reader, so
47
+ * the safer behavior is to serve the fresh scan without writing it durably and
48
+ * let the next read retry the marker.
49
+ *
10
50
  * Design:
11
- * - list() returns cached data if fresh, regenerates from branch.json files if stale
12
- * - invalidate() marks cache as stale (called when branch state changes)
13
- * - Concurrent regeneration is safe (all processes produce identical output)
51
+ * - list() returns the cached snapshot if its embedded generation token still
52
+ * matches the live marker, regenerates from branch.json files otherwise
53
+ * - invalidate() bumps the marker (durable, cross-process) and eager-regenerates
54
+ * - Concurrent regeneration within one process is deduped to a single scan
55
+ * (regenInFlight); across processes, regeneration is still safe since all
56
+ * processes produce identical output from the same branch.json files
14
57
  */
15
58
  export declare class BranchRegistry {
16
59
  private readonly root;
17
60
  private readonly registryPath;
18
61
  private readonly stalePath;
19
62
  private readonly tempPath;
63
+ /** Shared in-flight scan so concurrent list()/get() callers on one instance await a single regeneration. */
64
+ private regenInFlight;
65
+ /** Throttle clock for the get() suspicious-miss backstop. */
66
+ private lastForcedRefreshMs;
20
67
  constructor(root: string);
21
68
  /**
22
- * Returns all branches. Uses cache if fresh, regenerates if stale.
69
+ * Returns all branches. Uses the cached snapshot if its embedded generation
70
+ * token matches the live marker, regenerates otherwise.
23
71
  */
24
72
  list(): Promise<BranchContext[]>;
25
73
  /**
26
74
  * Returns a single branch by name. Uses cache if available.
75
+ *
76
+ * Suspicious-miss backstop: if `name` isn't in the (possibly cached) list,
77
+ * that's a signal the snapshot may be stale in the specific direction where
78
+ * a branch that now exists is missing from it - force one fresh
79
+ * regeneration and re-search before giving up, throttled per instance so a
80
+ * genuinely-absent branch costs at most one extra scan per throttle window.
81
+ * This bounds durable window-E staleness for the "branch exists but
82
+ * snapshot predates it" direction; see the class doc comment.
27
83
  */
28
84
  get(name: string): Promise<BranchContext | undefined>;
85
+ private shouldForceRefresh;
29
86
  /**
30
- * Marks the cache as stale. Next list() call will regenerate.
31
- * Uses atomic rename for safety.
87
+ * Marks the cache as stale for every process sharing this root by bumping
88
+ * the cross-process generation marker, then eager-regenerates on this host
89
+ * (see class doc comment for why). The bump must succeed: a swallowed
90
+ * failure here would leave the registry stale indefinitely with no
91
+ * bounding backstop, unlike a failed eager regen below, which only forgoes
92
+ * closing window (E) early - the bump alone already restored correctness
93
+ * for every future reader. A failed eager regen therefore must not fail the
94
+ * caller's save/delete.
32
95
  */
33
96
  invalidate(): Promise<void>;
34
97
  /**
35
- * Scans branch directories and rebuilds the cache.
36
- * Concurrent calls are safe - all produce identical content.
98
+ * Scans branch directories and rebuilds the cache, deduping concurrent
99
+ * callers on this instance to a single underlying scan. Never loops waiting
100
+ * for the snapshot's embedded token to match the marker - under a bump
101
+ * storm that could livelock; a caller that wants the very latest state
102
+ * after a concurrent bump should call list() again after this resolves.
37
103
  */
38
104
  private regenerate;
105
+ private performRegenerate;
39
106
  /**
40
107
  * Scans the root directory for branch subdirectories with valid branch.json files.
108
+ * Protected (rather than exported as a test hook) so tests can subclass and
109
+ * override to simulate cross-process interleavings.
41
110
  */
42
- private scanBranchDirectories;
111
+ protected scanBranchDirectories(): Promise<BranchContext[]>;
43
112
  }
44
113
  //# sourceMappingURL=branch-registry.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"branch-registry.d.ts","sourceRoot":"","sources":["../src/branch-registry.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAU5C,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,aAAa,EAAE,CAAA;CAC1B;AAED;;;;;;;;GAQG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAQ;IAC7B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAQ;gBAErB,IAAI,EAAE,MAAM;IAOxB;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC;IAkBtC;;OAEG;IACG,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC;IAK3D;;;OAGG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAWjC;;;OAGG;YACW,UAAU;IA2BxB;;OAEG;YACW,qBAAqB;CAiCpC"}
1
+ {"version":3,"file":"branch-registry.d.ts","sourceRoot":"","sources":["../src/branch-registry.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AA4B5C,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,aAAa,EAAE,CAAA;IACzB;;;;OAIG;IACH,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAQ;IAC7B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAQ;IAEjC,4GAA4G;IAC5G,OAAO,CAAC,aAAa,CAAwC;IAE7D,6DAA6D;IAC7D,OAAO,CAAC,mBAAmB,CAAI;gBAEnB,IAAI,EAAE,MAAM;IAOxB;;;OAGG;IACG,IAAI,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC;IA2BtC;;;;;;;;;;OAUG;IACG,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC;IAW3D,OAAO,CAAC,kBAAkB;IAO1B;;;;;;;;;OASG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAkBjC;;;;;;OAMG;YACW,UAAU;YAYV,iBAAiB;IA4C/B;;;;OAIG;cACa,qBAAqB,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC;CAiClE"}