canopycms 0.11.1 → 0.12.1

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 (158) hide show
  1. package/CHANGELOG.md +174 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-changes.d.ts +1 -0
  4. package/dist/api/branch-changes.js +1 -1
  5. package/dist/api/branch-entry-changes.d.ts +92 -7
  6. package/dist/api/branch-entry-changes.js +272 -38
  7. package/dist/api/branch-review.js +45 -13
  8. package/dist/api/branch-status.d.ts +12 -0
  9. package/dist/api/branch-status.js +13 -0
  10. package/dist/api/branch-update-from-base.d.ts +23 -0
  11. package/dist/api/branch-update-from-base.js +176 -0
  12. package/dist/api/branch.d.ts +5 -0
  13. package/dist/api/branch.js +1 -0
  14. package/dist/api/client.d.ts +17 -2
  15. package/dist/api/client.js +26 -0
  16. package/dist/api/comments.d.ts +2 -2
  17. package/dist/api/content.d.ts +38 -1
  18. package/dist/api/content.js +41 -5
  19. package/dist/api/merge-conflicts.d.ts +226 -0
  20. package/dist/api/merge-conflicts.js +674 -0
  21. package/dist/api/routes.js +2 -0
  22. package/dist/api/types.d.ts +7 -3
  23. package/dist/base-update.d.ts +26 -0
  24. package/dist/base-update.js +31 -0
  25. package/dist/branch-metadata-file.d.ts +51 -0
  26. package/dist/branch-metadata-file.js +11 -0
  27. package/dist/branch-schema-cache.d.ts +12 -0
  28. package/dist/branch-schema-cache.js +27 -2
  29. package/dist/cli/backfill-item-keys.d.ts +77 -0
  30. package/dist/cli/backfill-item-keys.js +320 -0
  31. package/dist/cli/cli.d.ts +1 -1
  32. package/dist/cli/cli.js +9713 -1493
  33. package/dist/cli/generate-ai-content.js +415 -244
  34. package/dist/cli/init.js +6 -0
  35. package/dist/cli/site-config.d.ts +14 -2
  36. package/dist/cli/site-config.js +20 -4
  37. package/dist/comment-store.d.ts +8 -0
  38. package/dist/comment-store.js +28 -0
  39. package/dist/content-index-generation.d.ts +1 -4
  40. package/dist/content-index-generation.js +1 -4
  41. package/dist/content-store.d.ts +65 -3
  42. package/dist/content-store.js +121 -15
  43. package/dist/editor/BranchManager.d.ts +4 -1
  44. package/dist/editor/BranchManager.js +9 -5
  45. package/dist/editor/Editor.js +549 -53
  46. package/dist/editor/EntryNavigator.d.ts +10 -2
  47. package/dist/editor/EntryNavigator.js +12 -4
  48. package/dist/editor/FormRenderer.d.ts +8 -2
  49. package/dist/editor/FormRenderer.js +5 -6
  50. package/dist/editor/base-update-wait.d.ts +74 -0
  51. package/dist/editor/base-update-wait.js +91 -0
  52. package/dist/editor/branch-status.js +1 -1
  53. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  54. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  55. package/dist/editor/components/BranchPill.d.ts +4 -1
  56. package/dist/editor/components/BranchPill.js +5 -2
  57. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  58. package/dist/editor/components/ConflictsGroup.js +364 -0
  59. package/dist/editor/components/EditorHeader.d.ts +16 -1
  60. package/dist/editor/components/EditorHeader.js +92 -30
  61. package/dist/editor/components/EntryChangesDrawer.d.ts +52 -2
  62. package/dist/editor/components/EntryChangesDrawer.js +164 -162
  63. package/dist/editor/components/EntryHeader.d.ts +3 -1
  64. package/dist/editor/components/EntryHeader.js +4 -6
  65. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  66. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  67. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  68. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  69. package/dist/editor/components/RetryToastMessage.js +1 -1
  70. package/dist/editor/components/change-parts.d.ts +51 -0
  71. package/dist/editor/components/change-parts.js +171 -0
  72. package/dist/editor/conflict-cards.d.ts +90 -0
  73. package/dist/editor/conflict-cards.js +279 -0
  74. package/dist/editor/conflict-choices.d.ts +60 -0
  75. package/dist/editor/conflict-choices.js +108 -0
  76. package/dist/editor/conflict-notice.d.ts +25 -0
  77. package/dist/editor/conflict-notice.js +39 -0
  78. package/dist/editor/copy.d.ts +20 -0
  79. package/dist/editor/copy.js +20 -0
  80. package/dist/editor/entry-diff.d.ts +18 -2
  81. package/dist/editor/entry-diff.js +26 -32
  82. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  83. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  84. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  85. package/dist/editor/hooks/useBranchManager.js +28 -7
  86. package/dist/editor/hooks/useBranchesData.js +3 -0
  87. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  88. package/dist/editor/hooks/useConflictChoices.js +75 -0
  89. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  90. package/dist/editor/hooks/useConflictsData.js +89 -0
  91. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  92. package/dist/editor/hooks/useDraftManager.js +24 -8
  93. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  94. package/dist/editor/hooks/useEntryManager.js +5 -0
  95. package/dist/editor/release-hold.d.ts +12 -0
  96. package/dist/editor/release-hold.js +39 -0
  97. package/dist/editor/server-clock.d.ts +4 -0
  98. package/dist/editor/server-clock.js +15 -0
  99. package/dist/editor/undo-change.d.ts +52 -0
  100. package/dist/editor/undo-change.js +241 -0
  101. package/dist/git-manager.d.ts +5 -0
  102. package/dist/git-manager.js +30 -0
  103. package/dist/item-keys.d.ts +13 -0
  104. package/dist/item-keys.js +36 -14
  105. package/dist/merge/conflict-path.d.ts +40 -0
  106. package/dist/merge/conflict-path.js +128 -0
  107. package/dist/merge/diff3.d.ts +27 -0
  108. package/dist/merge/diff3.js +113 -0
  109. package/dist/merge/entry-merge.d.ts +94 -0
  110. package/dist/merge/entry-merge.js +875 -0
  111. package/dist/merge/list-alignment.d.ts +52 -0
  112. package/dist/merge/list-alignment.js +441 -0
  113. package/dist/merge/text-merge.d.ts +29 -0
  114. package/dist/merge/text-merge.js +59 -0
  115. package/dist/merge/value-equality.d.ts +18 -0
  116. package/dist/merge/value-equality.js +50 -0
  117. package/dist/merge-conflicts.d.ts +232 -0
  118. package/dist/merge-conflicts.js +451 -0
  119. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  120. package/dist/operating-mode/client-safe-strategy.js +6 -0
  121. package/dist/operating-mode/types.d.ts +5 -0
  122. package/dist/services.d.ts +16 -1
  123. package/dist/services.js +82 -22
  124. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  125. package/dist/types.d.ts +28 -2
  126. package/dist/utils/content-serialize.d.ts +18 -1
  127. package/dist/utils/content-serialize.js +12 -0
  128. package/dist/utils/content-write-lock.d.ts +3 -3
  129. package/dist/utils/content-write-lock.js +3 -3
  130. package/dist/utils/markdown-body-splice.d.ts +1 -1
  131. package/dist/utils/markdown-body-splice.js +1 -19
  132. package/dist/utils/markdown-parse.d.ts +9 -0
  133. package/dist/utils/markdown-parse.js +27 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/dist/worker/branch-merge.d.ts +53 -37
  137. package/dist/worker/branch-merge.js +139 -54
  138. package/dist/worker/cms-worker.d.ts +16 -3
  139. package/dist/worker/cms-worker.js +59 -12
  140. package/dist/worker/field-merge.d.ts +65 -0
  141. package/dist/worker/field-merge.js +636 -0
  142. package/dist/worker/git-sync.d.ts +1 -1
  143. package/dist/worker/git-sync.js +7 -6
  144. package/dist/worker/github-commits.d.ts +42 -10
  145. package/dist/worker/github-commits.js +287 -47
  146. package/dist/worker/github-gateway-http.js +17 -9
  147. package/dist/worker/github-gateway-wire.d.ts +21 -12
  148. package/dist/worker/github-gateway-wire.js +5 -1
  149. package/dist/worker/github-gateway.d.ts +10 -1
  150. package/dist/worker/github-gateway.js +18 -6
  151. package/dist/worker/github-transfer.d.ts +5 -3
  152. package/dist/worker/github-transfer.js +8 -5
  153. package/dist/worker/rebase.d.ts +5 -4
  154. package/dist/worker/rebase.js +293 -156
  155. package/dist/worker/task-runner.d.ts +1 -1
  156. package/dist/worker/task-runner.js +14 -2
  157. package/dist/worker/worker-context.d.ts +18 -2
  158. package/package.json +1 -1
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The conflicts the worker's merges found on a branch and nobody has resolved yet:
3
+ * `.canopy-meta/merge-conflicts.json` in the branch clone, never committed, one record per entry.
4
+ * The working tree holds the author's value at each one (merge/entry-merge.ts), so nothing here is
5
+ * needed to read the branch; it is what says which values a person still has to choose.
6
+ *
7
+ * `branch.json` `conflictFiles` is derived from it: the content IDs it names, none for a clone with
8
+ * no such file (`reconcileMergeConflicts`, worker/github-commits.ts).
9
+ *
10
+ * Writers run under the comment store's three layers (comment-store.ts): the in-process mutex, the
11
+ * cross-host lockfile, then version OCC. The worker writes it holding the branch's provisioning and
12
+ * content-write locks.
13
+ */
14
+ import type { DataPathSegment } from './editor/entry-diff.js';
15
+ import type { FieldConflictKind, KeyRemap, SidePaths } from './merge/entry-merge.js';
16
+ import type { ContentId } from './paths/types.js';
17
+ import type { ConflictStatus } from './types.js';
18
+ /** A record field naming where the entry is, which both sides moved to different places. */
19
+ export { LOCATION_CONFLICT_KEY } from './merge/conflict-path.js';
20
+ /** Blob ids of the versions a merge compared; absent where that version had no file. */
21
+ export interface MergeSides {
22
+ ancestor?: string;
23
+ base?: string;
24
+ branch?: string;
25
+ }
26
+ /**
27
+ * - `fields`: values changed differently on both sides, listed in `fields`;
28
+ * - `deleted-on-base`: the base deleted the entry and the branch changed it; the branch's stands;
29
+ * - `deleted-on-branch`: the branch deleted it and the base changed it; it stays deleted;
30
+ * - `unreadable`: a version that can't be merged field by field (it does not parse, is over the
31
+ * size cap, changed its entry type, or is not an entry at all); the branch's file stands.
32
+ */
33
+ export type MergeConflictKind = 'fields' | 'deleted-on-base' | 'deleted-on-branch' | 'unreadable';
34
+ export interface MergeConflictField {
35
+ /** `entry-diff.ts`' `EntryChange.key` for the place, or {@link LOCATION_CONFLICT_KEY}. */
36
+ key: string;
37
+ dataPath: DataPathSegment[];
38
+ /**
39
+ * Where each of `sides`' versions holds the value, as a read of that version names it
40
+ * (merge/entry-merge.ts `FieldConflict.sidePaths`); `ancestor`'s is the first ancestor's.
41
+ */
42
+ sidePaths: SidePaths;
43
+ kind: FieldConflictKind;
44
+ item?: true;
45
+ /** Long text, merged by line: the regions both sides changed come from the three blobs. */
46
+ text?: true;
47
+ /** The merge that last found it. */
48
+ mergeSha: string;
49
+ /**
50
+ * The ancestor of the merge that first found it, which is what the author started from, and the
51
+ * base and branch of the merge that last found it.
52
+ */
53
+ sides: MergeSides;
54
+ }
55
+ export interface MergeConflictEntry {
56
+ /** Where the entry is in the merged tree, or was for one the branch deleted. */
57
+ path: string;
58
+ /** The merge that last changed this record. */
59
+ mergeSha: string;
60
+ kind: MergeConflictKind;
61
+ sides: MergeSides;
62
+ /** For `fields`, never empty. */
63
+ fields?: MergeConflictField[];
64
+ /**
65
+ * The record as it stood before `mergeSha` changed it, kept until the branch holds that merge:
66
+ * a merge recorded and then never landed gives the record back. Absent once it lands, and for a
67
+ * record that merge created.
68
+ */
69
+ previous?: MergeConflictEntry;
70
+ }
71
+ /** Item keys a merge changed, which the branch's comment threads take once it holds the merge. */
72
+ interface PendingRemap {
73
+ mergeSha: string;
74
+ /** The logical path the threads name the entry by. */
75
+ entryPath: string;
76
+ remaps: KeyRemap[];
77
+ }
78
+ /** What the file holds. */
79
+ export interface MergeConflictState {
80
+ entries: Record<string, MergeConflictEntry>;
81
+ remaps?: PendingRemap[];
82
+ /**
83
+ * The merge this worker is landing: written before it reaches `remote.git`, cleared once the
84
+ * clone holds it, or once neither the clone nor `remote.git` does; a clone whose ref can't be read
85
+ * keeps it. The one commit a clone left
86
+ * behind its own published merge may catch up with.
87
+ */
88
+ landing?: Landing;
89
+ /**
90
+ * A record that didn't parse was set aside: which conflicts it held is lost, so Submit and Approve
91
+ * wait ({@link assertNoOpenConflicts}) until an admin releases the branch. A later merge does not:
92
+ * it takes the set-aside merge as merged, so the lost choices would read as the branch's own.
93
+ * {@link MergeConflictStore.update} keeps it through a change that leaves it out.
94
+ */
95
+ held?: Held;
96
+ }
97
+ interface Held {
98
+ /** The name the unparseable file was moved aside to, in `.canopy-meta`. */
99
+ aside: string;
100
+ at: string;
101
+ }
102
+ interface Landing {
103
+ mergeSha: string;
104
+ /** For a merge of GitHub's commits, the paths whose values the branch kept over them. */
105
+ keptOverGitHub?: string[];
106
+ }
107
+ export interface MergeConflictsFile extends MergeConflictState {
108
+ version: number;
109
+ writeId?: string;
110
+ }
111
+ /** What one merge found for one entry, before it has a commit to name. */
112
+ export interface FoundEntryConflict {
113
+ contentId: ContentId;
114
+ path: string;
115
+ kind: MergeConflictKind;
116
+ sides: MergeSides;
117
+ fields?: Array<Omit<MergeConflictField, 'mergeSha' | 'sides'>>;
118
+ }
119
+ /**
120
+ * merge-conflicts.json names another format than this module reads, `found`. It is left as it is,
121
+ * unlike a file that doesn't parse: a worker of that format reads it, and its choices would be lost
122
+ * if it were set aside. The worker's next merge refuses it too, so the branch shows as failing to
123
+ * take its base in until the file is dealt with.
124
+ */
125
+ export declare class MergeConflictsFileFormatError extends Error {
126
+ readonly found: number;
127
+ constructor(found: number);
128
+ }
129
+ /** merge-conflicts.json is there but is not a record this module wrote. */
130
+ export declare class MergeConflictsFileMalformedError extends Error {
131
+ constructor(detail: string);
132
+ }
133
+ /**
134
+ * `existing` with what one merge, `mergeSha`, found. An entry this merge found no conflict in keeps
135
+ * its record: its value is still the author's, which this merge took as the branch's own change.
136
+ * A field found again keeps the ancestor it was first found against, and that ancestor's path, so
137
+ * the person still sees what they started from, and takes this merge's base and branch. An
138
+ * entry-level conflict replaces the entry's record; an `unreadable` record stands against fields
139
+ * found later, taking this merge's base and branch. `paths` moves the kept records of entries this
140
+ * merge moved. Every record this merge changes keeps the one it replaced as `previous`. Pure.
141
+ */
142
+ export declare function withFoundConflicts(existing: Readonly<Record<string, MergeConflictEntry>>, found: readonly FoundEntryConflict[], mergeSha: string, paths?: ReadonlyMap<string, string>): Record<string, MergeConflictEntry>;
143
+ /** The item keys one merge changed in one entry, which the entry's records follow. */
144
+ export interface EntryKeyRemaps {
145
+ contentId?: string;
146
+ remaps: readonly KeyRemap[];
147
+ }
148
+ /**
149
+ * `entries` with the item keys a merge, `mergeSha`, changed renamed in the conflicts on record,
150
+ * as the branch's comment threads are, so a conflict found again under the new key is the same
151
+ * one. A record this renames keeps the one it replaced as `previous`, so a merge that never lands
152
+ * gives the old keys back. Pure.
153
+ */
154
+ export declare function withRemappedItemKeys(entries: Readonly<Record<string, MergeConflictEntry>>, remaps: readonly EntryKeyRemaps[], mergeSha: string): Record<string, MergeConflictEntry>;
155
+ /**
156
+ * `entries` as the branch's history has them: a record whose `mergeSha` fails `holds` gives back the
157
+ * one it replaced, or goes when it replaced none, and a record whose merge the branch holds drops
158
+ * the one it replaced. A field whose own merge fails `holds` goes too, and an entry with no field
159
+ * left. Pure.
160
+ */
161
+ export declare function withoutMergesGone(entries: Readonly<Record<string, MergeConflictEntry>>, holds: (mergeSha: string) => boolean): Record<string, MergeConflictEntry>;
162
+ /** A field conflict a person resolved, named as the record names it. */
163
+ export interface ResolvedFieldConflict {
164
+ mergeSha: string;
165
+ key: string;
166
+ }
167
+ /**
168
+ * `entries` without the fields of `contentId`'s record that `resolved` names, each by its own
169
+ * `mergeSha` and `key`, and without the record once no field is left. A pair the record no longer
170
+ * holds, as after a newer merge found that field again, is left out of `cleared`. A record left with
171
+ * fields keeps its `previous`; a record left with none goes with it, since a merge keeps every field
172
+ * the record it replaced held, so those were resolved too. Pure.
173
+ */
174
+ export declare function withoutResolvedFields(entries: Readonly<Record<string, MergeConflictEntry>>, contentId: string, resolved: readonly ResolvedFieldConflict[]): {
175
+ entries: Record<string, MergeConflictEntry>;
176
+ cleared: ResolvedFieldConflict[];
177
+ };
178
+ /**
179
+ * `entries` without `contentId`'s entry-level record, when `mergeSha` is the merge that last changed
180
+ * it; null when it is not there, or a later merge changed it. Pure.
181
+ */
182
+ export declare function withoutEntryConflict(entries: Readonly<Record<string, MergeConflictEntry>>, contentId: string, mergeSha: string): Record<string, MergeConflictEntry> | null;
183
+ /** How many choices `entries` leave a person: each field conflict, and each entry-level one. */
184
+ export declare function openConflictCount(entries: Readonly<Record<string, MergeConflictEntry>>): number;
185
+ /** What `branch.json` holds for `entries`: the content IDs with a conflict open, sorted. */
186
+ export declare function conflictFilesOf(entries: Readonly<Record<string, MergeConflictEntry>>): {
187
+ conflictStatus: ConflictStatus;
188
+ conflictFiles: ContentId[];
189
+ };
190
+ /**
191
+ * A submit or approval refused while the branch has conflicts open, `count` of them, or with none
192
+ * it can count (`count` null): a record that doesn't parse, which the worker's next cycle sets aside
193
+ * and holds; one held after that (`held`); or one of another format, left until a developer deals
194
+ * with it.
195
+ */
196
+ export declare class OpenMergeConflictsError extends Error {
197
+ readonly count: number | null;
198
+ /** The record was set aside as unparseable, so which conflicts are open is lost. */
199
+ readonly held: boolean;
200
+ constructor(count: number | null,
201
+ /** The record was set aside as unparseable, so which conflicts are open is lost. */
202
+ held?: boolean);
203
+ }
204
+ /**
205
+ * Throws {@link OpenMergeConflictsError} while `branchRoot`'s record holds a conflict, is held after
206
+ * one was set aside, or does not parse. A clone with no record has none open.
207
+ */
208
+ export declare function assertNoOpenConflicts(branchRoot: string): Promise<void>;
209
+ /** A branch clone's merge-conflicts.json. */
210
+ export declare class MergeConflictStore {
211
+ private readonly settleMs?;
212
+ private readonly filePath;
213
+ constructor(branchRoot: string, settleMs?: number | undefined);
214
+ /** Whether the clone has a record at all. */
215
+ exists(): Promise<boolean>;
216
+ /**
217
+ * Copy a file that does not parse as a record aside, and replace it with a new one that is
218
+ * `held`, as which conflicts were open is lost with it. Resolves the copy's path, or null when
219
+ * the file parses by the time the locks are held, as another call may have set it aside.
220
+ * The new record is written beside the file and renamed over it, so a worker killed at any step
221
+ * leaves a record that still refuses the gate: the damaged one, or the held one.
222
+ */
223
+ quarantine(): Promise<string | null>;
224
+ /** The record, or null when the clone has none. Throws on a file that does not parse as one. */
225
+ read(): Promise<MergeConflictsFile | null>;
226
+ /**
227
+ * Replace what the file holds with what `change` makes of it, under the module's locks. `change`
228
+ * gets null when there is no file yet, and returns null to write nothing; a `held` it leaves out
229
+ * stays. Resolves what was written, or null.
230
+ */
231
+ update(change: (current: MergeConflictState | null) => MergeConflictState | null): Promise<MergeConflictState | null>;
232
+ }
@@ -0,0 +1,451 @@
1
+ /**
2
+ * The conflicts the worker's merges found on a branch and nobody has resolved yet:
3
+ * `.canopy-meta/merge-conflicts.json` in the branch clone, never committed, one record per entry.
4
+ * The working tree holds the author's value at each one (merge/entry-merge.ts), so nothing here is
5
+ * needed to read the branch; it is what says which values a person still has to choose.
6
+ *
7
+ * `branch.json` `conflictFiles` is derived from it: the content IDs it names, none for a clone with
8
+ * no such file (`reconcileMergeConflicts`, worker/github-commits.ts).
9
+ *
10
+ * Writers run under the comment store's three layers (comment-store.ts): the in-process mutex, the
11
+ * cross-host lockfile, then version OCC. The worker writes it holding the branch's provisioning and
12
+ * content-write locks.
13
+ */
14
+ import { randomUUID } from 'node:crypto';
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import { z } from 'zod';
18
+ import { withLock } from './utils/async-mutex.js';
19
+ import { isNotFoundError } from './utils/error.js';
20
+ import { withOccFileLock, withOccRetry, writeOccJsonFile } from './utils/occ-json-write.js';
21
+ import { isPlainRecord } from './validation/field-traversal.js';
22
+ const MERGE_CONFLICTS_FILE = 'merge-conflicts.json';
23
+ /** A record field naming where the entry is, which both sides moved to different places. */
24
+ export { LOCATION_CONFLICT_KEY } from './merge/conflict-path.js';
25
+ /** The record's format; a file of another is left as it is ({@link MergeConflictsFileFormatError}). */
26
+ const RECORD_SCHEMA = 2;
27
+ // Strict throughout: within one `RECORD_SCHEMA` the format is fixed, so a member this code doesn't
28
+ // know is a file it didn't write, refused rather than dropped on the next write.
29
+ const sidesSchema = z
30
+ .object({
31
+ ancestor: z.string().optional(),
32
+ base: z.string().optional(),
33
+ branch: z.string().optional(),
34
+ })
35
+ .strict();
36
+ const dataPathSchema = z.array(z.union([
37
+ z.object({ field: z.string() }).strict(),
38
+ z.object({ item: z.string() }).strict(),
39
+ z.object({ blockData: z.literal(true) }).strict(),
40
+ ]));
41
+ const sidePathsSchema = z
42
+ .object({
43
+ ancestor: dataPathSchema.optional(),
44
+ base: dataPathSchema.optional(),
45
+ branch: dataPathSchema.optional(),
46
+ })
47
+ .strict();
48
+ const entrySchema = z.lazy(() => z
49
+ .object({
50
+ path: z.string(),
51
+ mergeSha: z.string(),
52
+ kind: z.enum(['fields', 'deleted-on-base', 'deleted-on-branch', 'unreadable']),
53
+ sides: sidesSchema,
54
+ fields: z
55
+ .array(z
56
+ .object({
57
+ key: z.string(),
58
+ dataPath: dataPathSchema,
59
+ sidePaths: sidePathsSchema,
60
+ kind: z.enum(['changed', 'removed-on-base', 'removed-by-you']),
61
+ item: z.literal(true).optional(),
62
+ text: z.literal(true).optional(),
63
+ mergeSha: z.string(),
64
+ sides: sidesSchema,
65
+ })
66
+ .strict())
67
+ .optional(),
68
+ previous: entrySchema.optional(),
69
+ })
70
+ .strict());
71
+ const fileSchema = z
72
+ .object({
73
+ schema: z.literal(RECORD_SCHEMA),
74
+ version: z.number(),
75
+ writeId: z.string().optional(),
76
+ landing: z
77
+ .object({ mergeSha: z.string(), keptOverGitHub: z.array(z.string()).optional() })
78
+ .strict()
79
+ .optional(),
80
+ held: z.object({ aside: z.string(), at: z.string() }).strict().optional(),
81
+ entries: z.record(z.string(), entrySchema),
82
+ remaps: z
83
+ .array(z
84
+ .object({
85
+ mergeSha: z.string(),
86
+ entryPath: z.string(),
87
+ remaps: z.array(z.object({ within: z.array(z.string()), from: z.string(), to: z.string() }).strict()),
88
+ })
89
+ .strict())
90
+ .optional(),
91
+ })
92
+ .strict();
93
+ /**
94
+ * merge-conflicts.json names another format than this module reads, `found`. It is left as it is,
95
+ * unlike a file that doesn't parse: a worker of that format reads it, and its choices would be lost
96
+ * if it were set aside. The worker's next merge refuses it too, so the branch shows as failing to
97
+ * take its base in until the file is dealt with.
98
+ */
99
+ export class MergeConflictsFileFormatError extends Error {
100
+ constructor(found) {
101
+ super(`merge-conflicts.json is in format ${found}, and this code reads format ${RECORD_SCHEMA}; left as it is`);
102
+ this.found = found;
103
+ this.name = 'MergeConflictsFileFormatError';
104
+ }
105
+ }
106
+ /** merge-conflicts.json is there but is not a record this module wrote. */
107
+ export class MergeConflictsFileMalformedError extends Error {
108
+ constructor(detail) {
109
+ super(`merge-conflicts.json is malformed: ${detail}`);
110
+ this.name = 'MergeConflictsFileMalformedError';
111
+ }
112
+ }
113
+ /**
114
+ * `existing` with what one merge, `mergeSha`, found. An entry this merge found no conflict in keeps
115
+ * its record: its value is still the author's, which this merge took as the branch's own change.
116
+ * A field found again keeps the ancestor it was first found against, and that ancestor's path, so
117
+ * the person still sees what they started from, and takes this merge's base and branch. An
118
+ * entry-level conflict replaces the entry's record; an `unreadable` record stands against fields
119
+ * found later, taking this merge's base and branch. `paths` moves the kept records of entries this
120
+ * merge moved. Every record this merge changes keeps the one it replaced as `previous`. Pure.
121
+ */
122
+ export function withFoundConflicts(existing, found, mergeSha, paths = new Map()) {
123
+ const entries = {};
124
+ const changed = (prior, next) => ({
125
+ ...next,
126
+ mergeSha,
127
+ ...(prior && { previous: prior }),
128
+ });
129
+ for (const [id, entry] of Object.entries(existing)) {
130
+ const at = paths.get(id);
131
+ entries[id] =
132
+ at === undefined || at === entry.path ? entry : changed(entry, { ...entry, path: at });
133
+ }
134
+ for (const { contentId, path: at, kind, sides, fields } of found) {
135
+ const prior = existing[contentId];
136
+ if (kind !== 'fields' || !fields || fields.length === 0) {
137
+ entries[contentId] = changed(prior, { path: at, kind, sides });
138
+ continue;
139
+ }
140
+ if (prior?.kind === 'unreadable') {
141
+ // A version merged as one value nobody has chosen on stands: this merge compared against it
142
+ // as merged, so its changes would be lost unasked. The choice decides the whole entry, the
143
+ // fields this merge found included, and compares this merge's versions with the first
144
+ // ancestor. A deleted entry's record goes once both sides hold the entry again.
145
+ const { ancestor: _later, ...latest } = sides;
146
+ const ancestor = prior.sides.ancestor;
147
+ entries[contentId] = changed(prior, {
148
+ path: at,
149
+ kind: prior.kind,
150
+ sides: { ...latest, ...(ancestor !== undefined && { ancestor }) },
151
+ });
152
+ continue;
153
+ }
154
+ const priorFields = prior?.kind === 'fields' ? (prior.fields ?? []) : [];
155
+ const keys = new Set(fields.map((f) => f.key));
156
+ const kept = priorFields.filter((f) => !keys.has(f.key));
157
+ const { ancestor: _ours, ...latest } = sides;
158
+ const now = fields.map((field) => {
159
+ const earlier = priorFields.find((f) => f.key === field.key);
160
+ const ancestor = earlier ? earlier.sides.ancestor : sides.ancestor;
161
+ // The path travels with its blob: the first ancestor's, or none when that had no value here.
162
+ const { ancestor: _found, ...latestPaths } = field.sidePaths;
163
+ const ancestorPath = earlier ? earlier.sidePaths.ancestor : field.sidePaths.ancestor;
164
+ return {
165
+ ...field,
166
+ sidePaths: {
167
+ ...latestPaths,
168
+ ...(ancestorPath !== undefined && { ancestor: ancestorPath }),
169
+ },
170
+ mergeSha,
171
+ sides: { ...latest, ...(ancestor !== undefined && { ancestor }) },
172
+ };
173
+ });
174
+ entries[contentId] = changed(prior, { path: at, kind, sides, fields: [...kept, ...now] });
175
+ }
176
+ return entries;
177
+ }
178
+ /**
179
+ * `field` with the item keys `remaps` changed renamed in its key and data path. Each remap names
180
+ * an item by the keys it had before this merge, as the comment store's do, so all match against
181
+ * the original path and apply at once. The key holds one token per data path step but `blockData`.
182
+ */
183
+ function withRemappedKeys(field, remaps) {
184
+ const renamed = new Map();
185
+ for (const { within, from, to } of remaps) {
186
+ const enclosing = [];
187
+ field.dataPath.forEach((segment, n) => {
188
+ if (!('item' in segment))
189
+ return;
190
+ const hit = segment.item === from &&
191
+ enclosing.length === within.length &&
192
+ enclosing.every((k, m) => k === within[m]);
193
+ if (hit && !renamed.has(n))
194
+ renamed.set(n, to);
195
+ enclosing.push(segment.item);
196
+ });
197
+ }
198
+ if (renamed.size === 0)
199
+ return field;
200
+ const dataPath = field.dataPath.map((segment, n) => {
201
+ const to = renamed.get(n);
202
+ return to === undefined ? segment : { item: to };
203
+ });
204
+ const tokens = [...field.key.matchAll(/([.?]?)([^.?]+)/g)].map(([, sep, name]) => [sep, name]);
205
+ let token = 0;
206
+ field.dataPath.forEach((segment, n) => {
207
+ if ('blockData' in segment)
208
+ return;
209
+ const to = renamed.get(n);
210
+ if (to !== undefined && tokens[token])
211
+ tokens[token] = [tokens[token][0], to];
212
+ token++;
213
+ });
214
+ const key = tokens.map(([sep, name]) => `${sep}${name}`).join('');
215
+ return { ...field, key, dataPath };
216
+ }
217
+ /**
218
+ * `entries` with the item keys a merge, `mergeSha`, changed renamed in the conflicts on record,
219
+ * as the branch's comment threads are, so a conflict found again under the new key is the same
220
+ * one. A record this renames keeps the one it replaced as `previous`, so a merge that never lands
221
+ * gives the old keys back. Pure.
222
+ */
223
+ export function withRemappedItemKeys(entries, remaps, mergeSha) {
224
+ const out = { ...entries };
225
+ for (const { contentId, remaps: changed } of remaps) {
226
+ const entry = contentId === undefined ? undefined : out[contentId];
227
+ if (!entry?.fields)
228
+ continue;
229
+ const fields = entry.fields.map((field) => withRemappedKeys(field, changed));
230
+ if (fields.every((field, n) => field === entry.fields?.[n]))
231
+ continue;
232
+ out[contentId] = { ...entry, fields, mergeSha, previous: entry };
233
+ }
234
+ return out;
235
+ }
236
+ /**
237
+ * `entries` as the branch's history has them: a record whose `mergeSha` fails `holds` gives back the
238
+ * one it replaced, or goes when it replaced none, and a record whose merge the branch holds drops
239
+ * the one it replaced. A field whose own merge fails `holds` goes too, and an entry with no field
240
+ * left. Pure.
241
+ */
242
+ export function withoutMergesGone(entries, holds) {
243
+ const kept = {};
244
+ for (const [id, newest] of Object.entries(entries)) {
245
+ let entry = newest;
246
+ while (entry && !holds(entry.mergeSha))
247
+ entry = entry.previous;
248
+ if (!entry)
249
+ continue;
250
+ const { previous: _landed, ...landed } = entry;
251
+ if (landed.kind !== 'fields') {
252
+ kept[id] = landed;
253
+ continue;
254
+ }
255
+ const fields = (landed.fields ?? []).filter((f) => holds(f.mergeSha));
256
+ if (fields.length > 0)
257
+ kept[id] = { ...landed, fields };
258
+ }
259
+ return kept;
260
+ }
261
+ /**
262
+ * `entries` without the fields of `contentId`'s record that `resolved` names, each by its own
263
+ * `mergeSha` and `key`, and without the record once no field is left. A pair the record no longer
264
+ * holds, as after a newer merge found that field again, is left out of `cleared`. A record left with
265
+ * fields keeps its `previous`; a record left with none goes with it, since a merge keeps every field
266
+ * the record it replaced held, so those were resolved too. Pure.
267
+ */
268
+ export function withoutResolvedFields(entries, contentId, resolved) {
269
+ const entry = entries[contentId];
270
+ if (entry?.kind !== 'fields')
271
+ return { entries: { ...entries }, cleared: [] };
272
+ const cleared = [];
273
+ const fields = (entry.fields ?? []).filter((field) => {
274
+ const match = resolved.some((r) => r.mergeSha === field.mergeSha && r.key === field.key);
275
+ if (match)
276
+ cleared.push({ mergeSha: field.mergeSha, key: field.key });
277
+ return !match;
278
+ });
279
+ const { [contentId]: _resolved, ...rest } = entries;
280
+ if (cleared.length === 0)
281
+ return { entries: { ...entries }, cleared };
282
+ return {
283
+ entries: fields.length > 0 ? { ...rest, [contentId]: { ...entry, fields } } : rest,
284
+ cleared,
285
+ };
286
+ }
287
+ /**
288
+ * `entries` without `contentId`'s entry-level record, when `mergeSha` is the merge that last changed
289
+ * it; null when it is not there, or a later merge changed it. Pure.
290
+ */
291
+ export function withoutEntryConflict(entries, contentId, mergeSha) {
292
+ const entry = entries[contentId];
293
+ if (!entry || entry.kind === 'fields' || entry.mergeSha !== mergeSha)
294
+ return null;
295
+ const { [contentId]: _resolved, ...rest } = entries;
296
+ return rest;
297
+ }
298
+ /** How many choices `entries` leave a person: each field conflict, and each entry-level one. */
299
+ export function openConflictCount(entries) {
300
+ return Object.values(entries).reduce((n, entry) => n + (entry.kind === 'fields' ? (entry.fields ?? []).length : 1), 0);
301
+ }
302
+ /** What `branch.json` holds for `entries`: the content IDs with a conflict open, sorted. */
303
+ export function conflictFilesOf(entries) {
304
+ const conflictFiles = Object.keys(entries).sort();
305
+ return {
306
+ conflictStatus: conflictFiles.length > 0 ? 'conflicts-detected' : 'clean',
307
+ conflictFiles,
308
+ };
309
+ }
310
+ /**
311
+ * A submit or approval refused while the branch has conflicts open, `count` of them, or with none
312
+ * it can count (`count` null): a record that doesn't parse, which the worker's next cycle sets aside
313
+ * and holds; one held after that (`held`); or one of another format, left until a developer deals
314
+ * with it.
315
+ */
316
+ export class OpenMergeConflictsError extends Error {
317
+ constructor(count,
318
+ /** The record was set aside as unparseable, so which conflicts are open is lost. */
319
+ held = false) {
320
+ super(held
321
+ ? "This branch's record of conflicts was damaged and set aside, so it's on hold until an admin releases it."
322
+ : count === null
323
+ ? "Couldn't check this branch for changes that conflict with newer ones. Try again in a moment."
324
+ : count === 1
325
+ ? 'A change on this branch conflicts with a newer change on the site.'
326
+ : `${count} changes on this branch conflict with newer changes on the site.`);
327
+ this.count = count;
328
+ this.held = held;
329
+ this.name = 'OpenMergeConflictsError';
330
+ }
331
+ }
332
+ /**
333
+ * Throws {@link OpenMergeConflictsError} while `branchRoot`'s record holds a conflict, is held after
334
+ * one was set aside, or does not parse. A clone with no record has none open.
335
+ */
336
+ export async function assertNoOpenConflicts(branchRoot) {
337
+ let record;
338
+ try {
339
+ record = await new MergeConflictStore(branchRoot).read();
340
+ }
341
+ catch (err) {
342
+ if (err instanceof MergeConflictsFileMalformedError ||
343
+ err instanceof MergeConflictsFileFormatError)
344
+ throw new OpenMergeConflictsError(null);
345
+ throw err;
346
+ }
347
+ if (record?.held)
348
+ throw new OpenMergeConflictsError(null, true);
349
+ const count = record ? openConflictCount(record.entries) : 0;
350
+ if (count > 0)
351
+ throw new OpenMergeConflictsError(count);
352
+ }
353
+ /** A branch clone's merge-conflicts.json. */
354
+ export class MergeConflictStore {
355
+ constructor(branchRoot, settleMs) {
356
+ this.settleMs = settleMs;
357
+ // Resolved, so two spellings of one branch root share the in-process lock key.
358
+ this.filePath = path.join(path.resolve(branchRoot), '.canopy-meta', MERGE_CONFLICTS_FILE);
359
+ }
360
+ /** Whether the clone has a record at all. */
361
+ async exists() {
362
+ return fs.stat(this.filePath).then(() => true, () => false);
363
+ }
364
+ /**
365
+ * Copy a file that does not parse as a record aside, and replace it with a new one that is
366
+ * `held`, as which conflicts were open is lost with it. Resolves the copy's path, or null when
367
+ * the file parses by the time the locks are held, as another call may have set it aside.
368
+ * The new record is written beside the file and renamed over it, so a worker killed at any step
369
+ * leaves a record that still refuses the gate: the damaged one, or the held one.
370
+ */
371
+ async quarantine() {
372
+ return withLock(this.filePath, () => withOccFileLock(this.filePath, async () => {
373
+ const parses = await this.read().then(() => true, (err) => {
374
+ if (err instanceof MergeConflictsFileMalformedError)
375
+ return false;
376
+ throw err;
377
+ });
378
+ if (parses)
379
+ return null;
380
+ const stamp = `${Date.now()}-${randomUUID().slice(0, 8)}`;
381
+ const aside = `${this.filePath}.malformed-${stamp}`;
382
+ await fs.copyFile(this.filePath, aside);
383
+ const held = { aside: path.basename(aside), at: new Date().toISOString() };
384
+ const next = `${this.filePath}.held-${stamp}`;
385
+ await writeOccJsonFile(next, { schema: RECORD_SCHEMA, entries: {}, held }, { expectedVersion: null, settleMs: this.settleMs });
386
+ await fs.rename(next, this.filePath);
387
+ return aside;
388
+ }));
389
+ }
390
+ /** The record, or null when the clone has none. Throws on a file that does not parse as one. */
391
+ async read() {
392
+ let text;
393
+ try {
394
+ text = await fs.readFile(this.filePath, 'utf-8');
395
+ }
396
+ catch (err) {
397
+ if (isNotFoundError(err))
398
+ return null;
399
+ throw err;
400
+ }
401
+ let json;
402
+ try {
403
+ json = JSON.parse(text);
404
+ }
405
+ catch {
406
+ throw new MergeConflictsFileMalformedError('it is not JSON');
407
+ }
408
+ const schema = isPlainRecord(json) ? json.schema : undefined;
409
+ if (typeof schema === 'number' && schema !== RECORD_SCHEMA) {
410
+ throw new MergeConflictsFileFormatError(schema);
411
+ }
412
+ const parsed = fileSchema.safeParse(json);
413
+ if (!parsed.success)
414
+ throw new MergeConflictsFileMalformedError(parsed.error.message);
415
+ return parsed.data;
416
+ }
417
+ /**
418
+ * Replace what the file holds with what `change` makes of it, under the module's locks. `change`
419
+ * gets null when there is no file yet, and returns null to write nothing; a `held` it leaves out
420
+ * stays. Resolves what was written, or null.
421
+ */
422
+ async update(change) {
423
+ return withLock(this.filePath, () => withOccFileLock(this.filePath, () => withOccRetry(async () => {
424
+ const current = await this.read();
425
+ const next = change(current && {
426
+ entries: current.entries,
427
+ ...(current.remaps && { remaps: current.remaps }),
428
+ ...(current.landing && { landing: current.landing }),
429
+ ...(current.held && { held: current.held }),
430
+ });
431
+ if (next === null)
432
+ return null;
433
+ // A hold stays unless the change names `held`, as only a release does, setting it undefined.
434
+ const held = Object.prototype.hasOwnProperty.call(next, 'held')
435
+ ? next.held
436
+ : current?.held;
437
+ const payload = {
438
+ schema: RECORD_SCHEMA,
439
+ entries: next.entries,
440
+ ...(next.remaps && { remaps: next.remaps }),
441
+ ...(next.landing && { landing: next.landing }),
442
+ ...(held && { held }),
443
+ };
444
+ await writeOccJsonFile(this.filePath, payload, {
445
+ expectedVersion: current ? current.version : null,
446
+ settleMs: this.settleMs,
447
+ });
448
+ return next;
449
+ })));
450
+ }
451
+ }