canopycms 0.12.0 → 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 (154) hide show
  1. package/CHANGELOG.md +164 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.js +45 -13
  4. package/dist/api/branch-status.d.ts +12 -0
  5. package/dist/api/branch-status.js +13 -0
  6. package/dist/api/branch-update-from-base.d.ts +23 -0
  7. package/dist/api/branch-update-from-base.js +176 -0
  8. package/dist/api/branch.d.ts +5 -0
  9. package/dist/api/branch.js +1 -0
  10. package/dist/api/client.d.ts +14 -1
  11. package/dist/api/client.js +22 -0
  12. package/dist/api/comments.d.ts +2 -2
  13. package/dist/api/content.d.ts +38 -1
  14. package/dist/api/content.js +41 -5
  15. package/dist/api/merge-conflicts.d.ts +226 -0
  16. package/dist/api/merge-conflicts.js +674 -0
  17. package/dist/api/routes.js +2 -0
  18. package/dist/api/types.d.ts +7 -3
  19. package/dist/base-update.d.ts +26 -0
  20. package/dist/base-update.js +31 -0
  21. package/dist/branch-metadata-file.d.ts +51 -0
  22. package/dist/branch-metadata-file.js +11 -0
  23. package/dist/branch-schema-cache.d.ts +12 -0
  24. package/dist/branch-schema-cache.js +27 -2
  25. package/dist/cli/backfill-item-keys.d.ts +77 -0
  26. package/dist/cli/backfill-item-keys.js +320 -0
  27. package/dist/cli/cli.d.ts +1 -1
  28. package/dist/cli/cli.js +9658 -1499
  29. package/dist/cli/generate-ai-content.js +354 -244
  30. package/dist/cli/init.js +6 -0
  31. package/dist/cli/site-config.d.ts +14 -2
  32. package/dist/cli/site-config.js +20 -4
  33. package/dist/comment-store.d.ts +8 -0
  34. package/dist/comment-store.js +28 -0
  35. package/dist/content-index-generation.d.ts +1 -4
  36. package/dist/content-index-generation.js +1 -4
  37. package/dist/content-store.d.ts +39 -3
  38. package/dist/content-store.js +48 -15
  39. package/dist/editor/BranchManager.d.ts +4 -1
  40. package/dist/editor/BranchManager.js +9 -5
  41. package/dist/editor/Editor.js +429 -52
  42. package/dist/editor/EntryNavigator.d.ts +10 -2
  43. package/dist/editor/EntryNavigator.js +12 -4
  44. package/dist/editor/FormRenderer.d.ts +8 -2
  45. package/dist/editor/FormRenderer.js +5 -6
  46. package/dist/editor/base-update-wait.d.ts +74 -0
  47. package/dist/editor/base-update-wait.js +91 -0
  48. package/dist/editor/branch-status.js +1 -1
  49. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  50. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  51. package/dist/editor/components/BranchPill.d.ts +4 -1
  52. package/dist/editor/components/BranchPill.js +5 -2
  53. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  54. package/dist/editor/components/ConflictsGroup.js +364 -0
  55. package/dist/editor/components/EditorHeader.d.ts +16 -1
  56. package/dist/editor/components/EditorHeader.js +92 -30
  57. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  58. package/dist/editor/components/EntryChangesDrawer.js +33 -167
  59. package/dist/editor/components/EntryHeader.d.ts +3 -1
  60. package/dist/editor/components/EntryHeader.js +2 -2
  61. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  62. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  63. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  64. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  65. package/dist/editor/components/RetryToastMessage.js +1 -1
  66. package/dist/editor/components/change-parts.d.ts +51 -0
  67. package/dist/editor/components/change-parts.js +171 -0
  68. package/dist/editor/conflict-cards.d.ts +90 -0
  69. package/dist/editor/conflict-cards.js +279 -0
  70. package/dist/editor/conflict-choices.d.ts +60 -0
  71. package/dist/editor/conflict-choices.js +108 -0
  72. package/dist/editor/conflict-notice.d.ts +25 -0
  73. package/dist/editor/conflict-notice.js +39 -0
  74. package/dist/editor/copy.d.ts +12 -0
  75. package/dist/editor/copy.js +12 -0
  76. package/dist/editor/entry-diff.d.ts +2 -1
  77. package/dist/editor/entry-diff.js +5 -21
  78. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  79. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  80. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  81. package/dist/editor/hooks/useBranchManager.js +28 -7
  82. package/dist/editor/hooks/useBranchesData.js +3 -0
  83. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  84. package/dist/editor/hooks/useConflictChoices.js +75 -0
  85. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  86. package/dist/editor/hooks/useConflictsData.js +89 -0
  87. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  88. package/dist/editor/hooks/useDraftManager.js +24 -8
  89. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  90. package/dist/editor/hooks/useEntryManager.js +5 -0
  91. package/dist/editor/release-hold.d.ts +12 -0
  92. package/dist/editor/release-hold.js +39 -0
  93. package/dist/editor/server-clock.d.ts +4 -0
  94. package/dist/editor/server-clock.js +15 -0
  95. package/dist/editor/undo-change.d.ts +7 -0
  96. package/dist/editor/undo-change.js +19 -4
  97. package/dist/git-manager.d.ts +5 -0
  98. package/dist/git-manager.js +30 -0
  99. package/dist/item-keys.d.ts +13 -0
  100. package/dist/item-keys.js +36 -14
  101. package/dist/merge/conflict-path.d.ts +40 -0
  102. package/dist/merge/conflict-path.js +128 -0
  103. package/dist/merge/diff3.d.ts +27 -0
  104. package/dist/merge/diff3.js +113 -0
  105. package/dist/merge/entry-merge.d.ts +94 -0
  106. package/dist/merge/entry-merge.js +875 -0
  107. package/dist/merge/list-alignment.d.ts +52 -0
  108. package/dist/merge/list-alignment.js +441 -0
  109. package/dist/merge/text-merge.d.ts +29 -0
  110. package/dist/merge/text-merge.js +59 -0
  111. package/dist/merge/value-equality.d.ts +18 -0
  112. package/dist/merge/value-equality.js +50 -0
  113. package/dist/merge-conflicts.d.ts +232 -0
  114. package/dist/merge-conflicts.js +451 -0
  115. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  116. package/dist/operating-mode/client-safe-strategy.js +6 -0
  117. package/dist/operating-mode/types.d.ts +5 -0
  118. package/dist/services.d.ts +16 -1
  119. package/dist/services.js +82 -22
  120. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  121. package/dist/types.d.ts +28 -2
  122. package/dist/utils/content-serialize.d.ts +18 -1
  123. package/dist/utils/content-serialize.js +12 -0
  124. package/dist/utils/content-write-lock.d.ts +3 -3
  125. package/dist/utils/content-write-lock.js +3 -3
  126. package/dist/utils/markdown-body-splice.d.ts +1 -1
  127. package/dist/utils/markdown-body-splice.js +1 -19
  128. package/dist/utils/markdown-parse.d.ts +9 -0
  129. package/dist/utils/markdown-parse.js +27 -0
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker/branch-merge.d.ts +53 -37
  133. package/dist/worker/branch-merge.js +139 -54
  134. package/dist/worker/cms-worker.d.ts +16 -3
  135. package/dist/worker/cms-worker.js +59 -12
  136. package/dist/worker/field-merge.d.ts +65 -0
  137. package/dist/worker/field-merge.js +636 -0
  138. package/dist/worker/git-sync.d.ts +1 -1
  139. package/dist/worker/git-sync.js +7 -6
  140. package/dist/worker/github-commits.d.ts +42 -10
  141. package/dist/worker/github-commits.js +287 -47
  142. package/dist/worker/github-gateway-http.js +17 -9
  143. package/dist/worker/github-gateway-wire.d.ts +21 -12
  144. package/dist/worker/github-gateway-wire.js +5 -1
  145. package/dist/worker/github-gateway.d.ts +10 -1
  146. package/dist/worker/github-gateway.js +18 -6
  147. package/dist/worker/github-transfer.d.ts +5 -3
  148. package/dist/worker/github-transfer.js +8 -5
  149. package/dist/worker/rebase.d.ts +5 -4
  150. package/dist/worker/rebase.js +293 -156
  151. package/dist/worker/task-runner.d.ts +1 -1
  152. package/dist/worker/task-runner.js +14 -2
  153. package/dist/worker/worker-context.d.ts +18 -2
  154. package/package.json +1 -1
@@ -0,0 +1,674 @@
1
+ /**
2
+ * The conflicts the worker's merges left on a branch (merge-conflicts.ts), for the editor to show
3
+ * and resolve: the list, one entry's conflicts with the three versions they compare, and the
4
+ * choices that resolve an entry-level one. A field conflict is resolved by an ordinary save
5
+ * (`resolvedConflicts`, api/content.ts), through {@link clearFieldConflicts}.
6
+ */
7
+ import path from 'node:path';
8
+ import { z } from 'zod';
9
+ import { entryRefFromRepoPath, entryRefFromRepoPathShape, parseEntryParts, } from '../content-listing.js';
10
+ import { BranchSyncingError, ContentConflictError, ContentStore, ContentStoreError, EntryGoneError, } from '../content-store.js';
11
+ import { keyOfDataPath } from '../merge/conflict-path.js';
12
+ import { MergeConflictStore, MergeConflictsFileMalformedError, MergeConflictsFileFormatError, conflictFilesOf, openConflictCount, withoutEntryConflict, withoutResolvedFields, } from '../merge-conflicts.js';
13
+ import { getBranchMetadataFileManager } from '../branch-metadata.js';
14
+ import { isAdmin } from '../authorization/helpers.js';
15
+ import { findBodyFieldName } from '../utils/body-field.js';
16
+ import { entryAsRead } from '../utils/calendar-day.js';
17
+ import { ContentWriteLockBusyError, withContentWriteLock } from '../utils/content-write-lock.js';
18
+ import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../utils/error.js';
19
+ import { getFormatExtension } from '../utils/format.js';
20
+ import { parseTypedFilename } from '../utils/typed-filename.js';
21
+ import { canopyLogWarn } from '../utils/logger.js';
22
+ import { entryLogicalPath } from '../paths/index.js';
23
+ import { defineEndpoint } from './route-builder.js';
24
+ import { branchNameSchema, contentIdSchema, logicalPathSchema } from './validators.js';
25
+ /** The most of a version read, as "Show changes" caps a side. */
26
+ const MAX_SIDE_BYTES = 1024 * 1024;
27
+ /**
28
+ * The most of all the versions one answer reads, since a field found again by several merges names
29
+ * versions of its own: well under the gateway's 6 MB response cap, as the JSON it parses to is larger.
30
+ */
31
+ const MAX_VERSIONS_BYTES = 3 * 1024 * 1024;
32
+ const FORMATS = ['mdx', 'md', 'json', 'yaml'];
33
+ const RECORD_UNREADABLE_ERROR = "Couldn't read this branch's conflicting changes right now. Try again in a moment.";
34
+ const NO_CONFLICT_ERROR = 'This entry has no conflicting changes.';
35
+ const CONFLICT_CHANGED_ERROR = 'This conflict changed since it was shown. Refresh to see what is left to choose.';
36
+ const blobIdSchema = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/);
37
+ /** A save's `resolvedConflicts`: field conflicts the saved values resolve. */
38
+ export const resolvedConflictsSchema = z
39
+ .array(z.object({ mergeSha: blobIdSchema, key: z.string().min(1).max(4096) }))
40
+ .max(1000);
41
+ /** The record's entries; a record that does not parse answers as unreadable. */
42
+ const readEntries = async (branchRoot) => {
43
+ const record = await readRecord(branchRoot);
44
+ return record === 'unreadable' ? record : record.entries;
45
+ };
46
+ /** The record as the list answers it; one that does not parse answers as unreadable. */
47
+ const readRecord = async (branchRoot) => {
48
+ try {
49
+ const record = await new MergeConflictStore(branchRoot).read();
50
+ return record ?? { entries: {} };
51
+ }
52
+ catch (err) {
53
+ if (err instanceof MergeConflictsFileMalformedError ||
54
+ err instanceof MergeConflictsFileFormatError) {
55
+ return 'unreadable';
56
+ }
57
+ throw err;
58
+ }
59
+ };
60
+ /**
61
+ * The record's path as the editor knows it: an entry, a collection's settings, or another file.
62
+ * A file the schema no longer places is read from its path's shape, as "Show changes" reads one.
63
+ */
64
+ const refOf = (repoPath, flatSchema, contentRoot) => {
65
+ const placed = entryRefFromRepoPath(repoPath, flatSchema, contentRoot);
66
+ if (placed.kind !== 'other')
67
+ return placed;
68
+ return entryRefFromRepoPathShape(repoPath, contentRoot) ?? placed;
69
+ };
70
+ /** The path the editor addresses `ref` by; `at` is the file's repo path. */
71
+ const addressOf = (ref, at, flatSchema, contentRoot) => {
72
+ if (ref.kind === 'entry')
73
+ return ref.logicalPath;
74
+ if (ref.kind === 'collection-meta')
75
+ return ref.collectionPath;
76
+ const settings = refOf(`${path.posix.dirname(at)}/.collection.json`, flatSchema, contentRoot);
77
+ return settings.kind === 'collection-meta' ? settings.collectionPath : undefined;
78
+ };
79
+ /**
80
+ * Every record, located: an entry, or a collection's settings, where its content ID is now, since
81
+ * the branch may have renamed or moved it since the merge recorded its path, and anything else
82
+ * where the record says.
83
+ */
84
+ const locateRecords = async (store, entries, flatSchema, contentRoot) => {
85
+ if (Object.keys(entries).length === 0)
86
+ return [];
87
+ const index = await store.idIndex();
88
+ return Object.entries(entries)
89
+ .sort(([a], [b]) => a.localeCompare(b))
90
+ .map(([contentId, entry]) => {
91
+ const recorded = refOf(entry.path, flatSchema, contentRoot);
92
+ const now = recorded.kind === 'other' ? null : index.findById(contentId);
93
+ const at = recorded.kind === 'entry' && now?.type === 'entry'
94
+ ? now.relativePath
95
+ : recorded.kind === 'collection-meta' && now?.type === 'collection'
96
+ ? `${now.relativePath}/.collection.json`
97
+ : entry.path;
98
+ const ref = at === entry.path ? recorded : refOf(at, flatSchema, contentRoot);
99
+ const address = addressOf(ref, at, flatSchema, contentRoot);
100
+ // A path the schema no longer places, as after a collection is renamed, is named by its shape
101
+ // alone, which no path rule may still grant.
102
+ const placedThen = entryRefFromRepoPath(entry.path, flatSchema, contentRoot);
103
+ const recordedAddress = placedThen.kind === 'other'
104
+ ? undefined
105
+ : addressOf(placedThen, entry.path, flatSchema, contentRoot);
106
+ return {
107
+ contentId,
108
+ entry,
109
+ ref,
110
+ ...(address !== undefined && { address }),
111
+ ...(recordedAddress !== undefined && { recordedAddress }),
112
+ };
113
+ });
114
+ };
115
+ /**
116
+ * Whether `user` may read or edit the record. The path rules judge it where it is and where the
117
+ * merge recorded it, whose versions it shows. A file that is neither entry nor settings is shown
118
+ * to admins only, and collection settings, like any schema change, are edited by admins only.
119
+ */
120
+ const mayAccess = (record, checkAccess, user, level) => {
121
+ const admin = isAdmin(user.groups);
122
+ if (record.ref.kind === 'other' && !admin)
123
+ return false;
124
+ if (level === 'edit' && record.ref.kind === 'collection-meta' && !admin)
125
+ return false;
126
+ if (record.address === undefined)
127
+ return admin;
128
+ return [record.address, record.recordedAddress ?? record.address].every((at) => checkAccess(at, level).allowed);
129
+ };
130
+ /**
131
+ * Change the record's entries by `change`, under the record's locks, then `branch.json`
132
+ * `conflictFiles` to match. `change` returning null writes nothing. Resolves the entries written,
133
+ * or null.
134
+ */
135
+ const changeRecord = async (branchContext, change) => {
136
+ const state = await new MergeConflictStore(branchContext.branchRoot).update((current) => {
137
+ if (current === null)
138
+ return null;
139
+ const entries = change(current.entries);
140
+ return entries && { ...current, entries };
141
+ });
142
+ if (state === null)
143
+ return null;
144
+ await getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot).update((existing) => existing?.branch.name === branchContext.branch.name
145
+ ? { branch: conflictFilesOf(state.entries) }
146
+ : null, { keepUpdatedAt: true });
147
+ return state.entries;
148
+ };
149
+ /**
150
+ * Take the field conflicts `resolved` names off `contentId`'s record, each by its own merge and
151
+ * key, and resolve those it cleared. A pair the record no longer holds is ignored. For a save to
152
+ * call once the entry is written, inside its locks: the record's own locks come after them in
153
+ * the one lock order (docs/concurrency.md).
154
+ */
155
+ export async function clearFieldConflicts(branchContext, contentId, resolved) {
156
+ let cleared = [];
157
+ await changeRecord(branchContext, (entries) => {
158
+ const next = withoutResolvedFields(entries, contentId, resolved);
159
+ cleared = next.cleared;
160
+ return next.cleared.length > 0 ? next.entries : null;
161
+ });
162
+ return cleared;
163
+ }
164
+ /** The entry type's fields and format, while the schema still has both. */
165
+ const entryTypeOf = (flatSchema, ref) => flatSchema
166
+ .find((item) => item.type === 'collection' && item.logicalPath === ref.collectionPath)
167
+ ?.entries?.find((e) => e.name === ref.entryType);
168
+ /** A side's data with an md/mdx body under its body field, as the form holds it. */
169
+ const formValueOf = (side) => side.body === undefined ? side.data : { ...side.data, [side.bodyFieldName ?? 'body']: side.body };
170
+ const listHandler = async (gc, ctx, req) => {
171
+ const { branchContext } = gc;
172
+ const record = await readRecord(branchContext.branchRoot);
173
+ if (record === 'unreadable')
174
+ return { ok: false, status: 503, error: RECORD_UNREADABLE_ERROR };
175
+ const { entries, held } = record;
176
+ const contentRoot = ctx.services.config.contentRoot || 'content';
177
+ const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
178
+ const store = new ContentStore(branchContext.branchRoot, branchContext.flatSchema, {
179
+ contentRootName: contentRoot,
180
+ });
181
+ const conflicts = [];
182
+ let hiddenCount = 0;
183
+ for (const record of await locateRecords(store, entries, branchContext.flatSchema, contentRoot)) {
184
+ const { contentId, entry, ref, address } = record;
185
+ if (!mayAccess(record, checkAccess, req.user, 'read')) {
186
+ hiddenCount++;
187
+ continue;
188
+ }
189
+ conflicts.push({
190
+ contentId,
191
+ kind: entry.kind,
192
+ path: ref.physicalPath,
193
+ ...(address !== undefined && { logicalPath: address }),
194
+ fieldCount: entry.kind === 'fields' ? (entry.fields ?? []).length : 0,
195
+ mergeSha: entry.mergeSha,
196
+ });
197
+ }
198
+ return {
199
+ ok: true,
200
+ status: 200,
201
+ data: {
202
+ conflicts,
203
+ hiddenCount,
204
+ openCount: openConflictCount(entries),
205
+ ...(held && {
206
+ held: { at: held.at, ...(isAdmin(req.user.groups) && { aside: held.aside }) },
207
+ }),
208
+ },
209
+ };
210
+ };
211
+ const listMergeConflicts = defineEndpoint({
212
+ namespace: 'conflicts',
213
+ name: 'list',
214
+ method: 'GET',
215
+ path: '/:branch/conflicts',
216
+ params: z.object({ branch: branchNameSchema }),
217
+ responseType: 'MergeConflictsResponse',
218
+ response: {},
219
+ defaultMockData: { conflicts: [], hiddenCount: 0, openCount: 0 },
220
+ guards: ['branchAccessWithSchema'],
221
+ handler: listHandler,
222
+ });
223
+ const entryParamsSchema = z.object({
224
+ branch: branchNameSchema,
225
+ entryPath: logicalPathSchema,
226
+ /** The entry's content id, which tells an entry from a collection at the same path. */
227
+ id: contentIdSchema.optional(),
228
+ });
229
+ /** `logicalPath` under `contentRoot`, as content reads take it with or without the root. */
230
+ const underContentRoot = (logicalPath, contentRoot) => logicalPath === contentRoot || logicalPath.startsWith(`${contentRoot}/`)
231
+ ? logicalPath
232
+ : `${contentRoot}/${logicalPath}`;
233
+ /** The path the store resolves `requested` to, so a rule judges the entry that is read. */
234
+ const canonicalLogicalPath = (store, requested) => {
235
+ const segments = requested.split('/').filter(Boolean);
236
+ try {
237
+ const { schemaItem, slug } = store.resolvePath(segments);
238
+ return slug ? entryLogicalPath(schemaItem.logicalPath, slug) : schemaItem.logicalPath;
239
+ }
240
+ catch (err) {
241
+ if (err instanceof ContentStoreError)
242
+ return segments.join('/');
243
+ throw err;
244
+ }
245
+ };
246
+ /** The record addressed at `target`, matched by `id` when given; an entry wins over the rest. */
247
+ const findRecord = (records, target, id) => {
248
+ const found = records.filter((r) => r.address === target && (id === undefined || r.contentId === id));
249
+ return found.find(({ ref }) => ref.kind === 'entry') ?? found[0];
250
+ };
251
+ /** Each version `shas` names, read and parsed as `ref`'s entry type. */
252
+ const readVersions = async (git, shas, ref, flatSchema) => {
253
+ const versions = {};
254
+ if (ref.kind !== 'entry') {
255
+ for (const sha of shas)
256
+ versions[sha] = { unavailable: 'not-an-entry' };
257
+ return { versions };
258
+ }
259
+ const entryType = entryTypeOf(flatSchema, ref);
260
+ const fields = entryType?.schema;
261
+ const format = entryType?.format ?? FORMATS.find((f) => ref.physicalPath.endsWith(getFormatExtension(f)));
262
+ let budget = MAX_VERSIONS_BYTES;
263
+ for (const sha of shas) {
264
+ const raw = await git.readBlob(sha, Math.min(MAX_SIDE_BYTES, budget));
265
+ if (typeof raw === 'string')
266
+ budget -= Buffer.byteLength(raw);
267
+ if (raw === undefined)
268
+ versions[sha] = { unavailable: 'missing' };
269
+ else if (raw === 'too-large')
270
+ versions[sha] = { unavailable: 'too-large' };
271
+ else if (!format)
272
+ versions[sha] = { unavailable: 'unreadable' };
273
+ else {
274
+ try {
275
+ const parsed = parseEntryParts(raw, format);
276
+ versions[sha] = {
277
+ format,
278
+ entryType: ref.entryType,
279
+ data: entryAsRead(fields ?? [], parsed.data),
280
+ ...(parsed.body !== undefined && {
281
+ body: parsed.body,
282
+ bodyFieldName: findBodyFieldName(fields ?? []),
283
+ }),
284
+ };
285
+ }
286
+ catch {
287
+ versions[sha] = { unavailable: 'unreadable' };
288
+ }
289
+ }
290
+ }
291
+ return { versions, ...(fields && { fields }) };
292
+ };
293
+ const entryHandler = async (gc, ctx, req, params) => {
294
+ const { branchContext } = gc;
295
+ const { flatSchema, branchRoot } = branchContext;
296
+ const contentRoot = ctx.services.config.contentRoot || 'content';
297
+ const noConflict = { ok: false, status: 404, error: NO_CONFLICT_ERROR };
298
+ const store = new ContentStore(branchRoot, flatSchema, { contentRootName: contentRoot });
299
+ const target = canonicalLogicalPath(store, underContentRoot(params.entryPath, contentRoot));
300
+ // A path this user can't read answers as one with no conflict, so the answer never says more.
301
+ const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchRoot, req.user);
302
+ if (!checkAccess(target, 'read').allowed)
303
+ return noConflict;
304
+ const entries = await readEntries(branchRoot);
305
+ if (entries === 'unreadable')
306
+ return { ok: false, status: 503, error: RECORD_UNREADABLE_ERROR };
307
+ const records = await locateRecords(store, entries, flatSchema, contentRoot);
308
+ const found = findRecord(records, target, params.id);
309
+ if (!found || !mayAccess(found, checkAccess, req.user, 'read'))
310
+ return noConflict;
311
+ const { contentId, entry, ref } = found;
312
+ const shas = new Set();
313
+ const add = (sides) => {
314
+ for (const sha of [sides.ancestor, sides.base, sides.branch])
315
+ if (sha)
316
+ shas.add(sha);
317
+ };
318
+ // Read in the order the cards need them, within MAX_VERSIONS_BYTES: a fields record's cards read
319
+ // only its fields' versions, the newest merge's first; an entry-level record's, its own.
320
+ if (entry.kind === 'fields') {
321
+ for (const field of [...(entry.fields ?? [])].reverse())
322
+ add(field.sides);
323
+ }
324
+ else {
325
+ add(entry.sides);
326
+ }
327
+ const git = ctx.services.createGitManagerFor(branchRoot, {
328
+ baseBranch: branchContext.branch.baseBranch,
329
+ });
330
+ let read;
331
+ try {
332
+ read = await readVersions(git, shas, ref, flatSchema);
333
+ }
334
+ catch (err) {
335
+ canopyLogWarn(`CanopyCMS: could not read the versions of ${branchContext.branch.name}'s conflicts:`, redactCredentials(getErrorMessage(err)));
336
+ return { ok: false, status: 503, error: RECORD_UNREADABLE_ERROR };
337
+ }
338
+ const { versions, fields: schemaFields } = read;
339
+ // A key the merge named under the schema is the drawer's already; one named without it starts
340
+ // at an unknown top field (`?name`) and is named again here, where no list item is on the way.
341
+ const drawerKeyOf = (field) => {
342
+ if (!schemaFields || !field.key.startsWith('?'))
343
+ return field.key;
344
+ const side = [field.sides.branch, field.sides.base, field.sides.ancestor]
345
+ .map((sha) => (sha ? versions[sha] : undefined))
346
+ .find((v) => v !== undefined && !('unavailable' in v));
347
+ return side ? keyOfDataPath(schemaFields, formValueOf(side), field.dataPath) : field.key;
348
+ };
349
+ return {
350
+ ok: true,
351
+ status: 200,
352
+ data: {
353
+ contentId,
354
+ kind: entry.kind,
355
+ path: ref.physicalPath,
356
+ logicalPath: target,
357
+ mergeSha: entry.mergeSha,
358
+ sides: entry.sides,
359
+ fields: (entry.fields ?? []).map((field) => ({
360
+ key: field.key,
361
+ drawerKey: drawerKeyOf(field),
362
+ dataPath: field.dataPath,
363
+ sidePaths: field.sidePaths,
364
+ kind: field.kind,
365
+ ...(field.item && { item: true }),
366
+ ...(field.text && { text: true }),
367
+ mergeSha: field.mergeSha,
368
+ sides: field.sides,
369
+ })),
370
+ versions,
371
+ },
372
+ };
373
+ };
374
+ const readEntryConflict = defineEndpoint({
375
+ namespace: 'conflicts',
376
+ name: 'entry',
377
+ method: 'GET',
378
+ path: '/:branch/conflicts/...entryPath',
379
+ params: entryParamsSchema,
380
+ responseType: 'EntryConflictResponse',
381
+ response: {},
382
+ defaultMockData: {
383
+ contentId: '',
384
+ kind: 'fields',
385
+ path: '',
386
+ logicalPath: '',
387
+ mergeSha: '',
388
+ sides: {},
389
+ fields: [],
390
+ versions: {},
391
+ },
392
+ guards: ['branchAccessWithSchema'],
393
+ handler: entryHandler,
394
+ });
395
+ const resolveParamsSchema = z.object({
396
+ branch: branchNameSchema,
397
+ entryPath: logicalPathSchema,
398
+ });
399
+ const resolveBodySchema = z.object({
400
+ /** The content id the record names the entry by. */
401
+ id: contentIdSchema,
402
+ /** The merge that last changed the record, as the conflict was shown. */
403
+ mergeSha: blobIdSchema,
404
+ /**
405
+ * `keep`: leave the branch as it is, for any kind; `delete`: delete the entry the latest
406
+ * version deleted (`deleted-on-base`); `restore`: put back the entry the branch deleted, as the
407
+ * latest version has it (`deleted-on-branch`); `use-latest`: replace an entry that can't be merged
408
+ * field by field with the latest version (`unreadable`).
409
+ */
410
+ choice: z.enum(['keep', 'delete', 'restore', 'use-latest']),
411
+ });
412
+ /** Which kinds of record each choice resolves. */
413
+ const CHOICE_KINDS = {
414
+ keep: ['fields', 'deleted-on-base', 'deleted-on-branch', 'unreadable'],
415
+ delete: ['deleted-on-base'],
416
+ restore: ['deleted-on-branch'],
417
+ 'use-latest': ['unreadable'],
418
+ };
419
+ /**
420
+ * Whether the latest version of entry `id`, in the tree the record's merge took in (its second
421
+ * parent, worker/branch-merge.ts `buildMergeCommit`), is in the collection of the branch's file at
422
+ * `placed`, with its entry type and extension. False when that tree can't be read.
423
+ */
424
+ const sameKindAtLatest = async (git, entry, id, placed, flatSchema, contentRoot) => {
425
+ const own = parseTypedFilename(path.posix.basename(placed.physicalPath));
426
+ let files;
427
+ try {
428
+ files = await git.listFilesWithSizeAt(`${entry.mergeSha}^2`, contentRoot);
429
+ }
430
+ catch {
431
+ return false;
432
+ }
433
+ const latest = files.find((f) => parseTypedFilename(path.posix.basename(f.path))?.id === id);
434
+ if (!latest || !own)
435
+ return false;
436
+ const latestRef = entryRefFromRepoPath(latest.path, flatSchema, contentRoot);
437
+ return (latestRef.kind === 'entry' &&
438
+ latestRef.collectionPath === placed.collectionPath &&
439
+ latestRef.entryType === own.type &&
440
+ path.posix.extname(latest.path) === path.posix.extname(placed.physicalPath));
441
+ };
442
+ /** Thrown inside a resolve's locks when the record no longer holds what the person chose on. */
443
+ class ConflictChangedError extends Error {
444
+ }
445
+ /** A resolve's tree change refused because the version it needs isn't there. */
446
+ class LatestUnavailableError extends Error {
447
+ }
448
+ const resolveHandler = async (gc, ctx, req, params, body) => {
449
+ const { branchContext } = gc;
450
+ const { flatSchema, branchRoot } = branchContext;
451
+ const contentRoot = ctx.services.config.contentRoot || 'content';
452
+ const store = new ContentStore(branchRoot, flatSchema, { contentRootName: contentRoot });
453
+ const target = canonicalLogicalPath(store, underContentRoot(params.entryPath, contentRoot));
454
+ const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchRoot, req.user);
455
+ if (!checkAccess(target, 'edit').allowed) {
456
+ return { ok: false, status: 403, error: 'Forbidden' };
457
+ }
458
+ const entries = await readEntries(branchRoot);
459
+ if (entries === 'unreadable')
460
+ return { ok: false, status: 503, error: RECORD_UNREADABLE_ERROR };
461
+ const records = await locateRecords(store, entries, flatSchema, contentRoot);
462
+ const found = findRecord(records, target, body.id);
463
+ if (!found)
464
+ return { ok: false, status: 404, error: NO_CONFLICT_ERROR };
465
+ if (!mayAccess(found, checkAccess, req.user, 'edit')) {
466
+ return { ok: false, status: 403, error: 'Forbidden' };
467
+ }
468
+ const { entry, ref } = found;
469
+ const changed = {
470
+ ok: false,
471
+ status: 409,
472
+ code: 'CONFLICT_CHANGED',
473
+ error: CONFLICT_CHANGED_ERROR,
474
+ };
475
+ if (entry.mergeSha !== body.mergeSha)
476
+ return changed;
477
+ if (!CHOICE_KINDS[body.choice].includes(entry.kind)) {
478
+ return {
479
+ ok: false,
480
+ status: 400,
481
+ error: `"${body.choice}" doesn't resolve this kind of conflict.`,
482
+ };
483
+ }
484
+ // The record as shown, checked under the content-write lock before the tree changes: every writer
485
+ // of the record holds that lock, so what is checked here is what `clear` then finds.
486
+ const verify = async () => {
487
+ const now = await readEntries(branchRoot);
488
+ const current = now === 'unreadable' ? undefined : now[body.id];
489
+ if (current?.kind !== entry.kind || current.mergeSha !== body.mergeSha) {
490
+ throw new ConflictChangedError();
491
+ }
492
+ };
493
+ // Clears the record as it stands under its lock, refusing when a merge changed it since.
494
+ const clear = async () => {
495
+ const written = await changeRecord(branchContext, (now) => {
496
+ const current = now[body.id];
497
+ if (current?.kind === 'fields' && current.mergeSha === body.mergeSha) {
498
+ const { [body.id]: _kept, ...rest } = now;
499
+ return rest;
500
+ }
501
+ const next = withoutEntryConflict(now, body.id, body.mergeSha);
502
+ if (next === null)
503
+ throw new ConflictChangedError();
504
+ return next;
505
+ });
506
+ if (written === null)
507
+ throw new ConflictChangedError();
508
+ };
509
+ const placed = ref.kind === 'entry' ? ref : undefined;
510
+ if (body.choice !== 'keep' && !placed) {
511
+ return { ok: false, status: 400, error: `"${body.choice}" only resolves an entry.` };
512
+ }
513
+ const slug = placed && placed.logicalPath.slice(placed.logicalPath.lastIndexOf('/') + 1);
514
+ try {
515
+ if (body.choice === 'keep' || !placed || !slug) {
516
+ await withContentWriteLock(branchRoot, async () => {
517
+ await verify();
518
+ await clear();
519
+ });
520
+ }
521
+ else if (body.choice === 'delete' || (body.choice === 'use-latest' && !entry.sides.base)) {
522
+ // For `use-latest`, the latest version has no such entry, so using it deletes the branch's.
523
+ await store.delete(placed.collectionPath, slug, {
524
+ expectId: body.id,
525
+ beforeDelete: verify,
526
+ afterDelete: clear,
527
+ });
528
+ }
529
+ else {
530
+ const git = ctx.services.createGitManagerFor(branchRoot, {
531
+ baseBranch: branchContext.branch.baseBranch,
532
+ });
533
+ const latest = entry.sides.base
534
+ ? await git.readBlob(entry.sides.base, MAX_SIDE_BYTES)
535
+ : undefined;
536
+ if (latest === 'too-large') {
537
+ return { ok: false, status: 413, error: 'The latest version is too large to use here.' };
538
+ }
539
+ // The latest version's bytes go under the branch's filename, so they must be the same entry
540
+ // type, in the same collection, in the same format: the record keeps one path, the branch's.
541
+ if (body.choice === 'use-latest' &&
542
+ !(await sameKindAtLatest(git, entry, body.id, placed, flatSchema, contentRoot))) {
543
+ return {
544
+ ok: false,
545
+ status: 409,
546
+ error: "The latest version can't replace this entry here: it is another type of entry, or in another collection, or can't be found.",
547
+ };
548
+ }
549
+ const entryType = entryTypeOf(flatSchema, placed);
550
+ if (latest === undefined || !entryType)
551
+ throw new LatestUnavailableError();
552
+ try {
553
+ parseEntryParts(latest, entryType.format);
554
+ }
555
+ catch {
556
+ return {
557
+ ok: false,
558
+ status: 422,
559
+ error: "The latest version of this entry doesn't read, so it can't be used here.",
560
+ };
561
+ }
562
+ if (body.choice === 'restore') {
563
+ await store.restore(placed.collectionPath, slug, { id: body.id, entryTypeName: placed.entryType, raw: latest }, { beforeWrite: verify, afterWrite: clear });
564
+ }
565
+ else {
566
+ await store.replaceRaw(placed.collectionPath, slug, { id: body.id, raw: latest }, { beforeWrite: verify, afterWrite: clear });
567
+ }
568
+ }
569
+ }
570
+ catch (err) {
571
+ if (err instanceof ConflictChangedError)
572
+ return changed;
573
+ if (err instanceof LatestUnavailableError) {
574
+ return {
575
+ ok: false,
576
+ status: 409,
577
+ error: "The latest version of this entry can't be used here: it can't be read, or its entry type is no longer in the schema.",
578
+ };
579
+ }
580
+ if (err instanceof ContentWriteLockBusyError || err instanceof BranchSyncingError) {
581
+ return {
582
+ ok: false,
583
+ status: 409,
584
+ error: err instanceof BranchSyncingError
585
+ ? err.message
586
+ : 'This branch is busy: it is being synced, or a save is in flight. Try again in a moment.',
587
+ code: err.outcome === 'unknown' ? 'WRITE_OUTCOME_UNKNOWN' : 'BRANCH_SYNCING',
588
+ };
589
+ }
590
+ if (err instanceof EntryGoneError) {
591
+ return {
592
+ ok: false,
593
+ status: 409,
594
+ code: 'ENTRY_GONE',
595
+ error: 'This entry has moved or left the branch since the conflict was shown. Refresh to see what is left to choose; for an entry no longer on the branch, keep the branch as it is.',
596
+ };
597
+ }
598
+ if (err instanceof ContentConflictError)
599
+ return { ok: false, status: 409, error: err.message };
600
+ if (err instanceof ContentStoreError) {
601
+ return { ok: false, status: 400, error: sanitizeErrorMessage(err.message) };
602
+ }
603
+ throw err;
604
+ }
605
+ const left = await readEntries(branchRoot);
606
+ return {
607
+ ok: true,
608
+ status: 200,
609
+ data: { contentId: body.id, openCount: left === 'unreadable' ? 0 : openConflictCount(left) },
610
+ };
611
+ };
612
+ const resolveEntryConflict = defineEndpoint({
613
+ namespace: 'conflicts',
614
+ name: 'resolveEntry',
615
+ method: 'POST',
616
+ path: '/:branch/resolve-entry/...entryPath',
617
+ params: resolveParamsSchema,
618
+ body: resolveBodySchema,
619
+ bodyType: 'ResolveEntryConflictBody',
620
+ responseType: 'ResolveEntryConflictResponse',
621
+ response: {},
622
+ defaultMockData: { contentId: '', openCount: 0 },
623
+ guards: ['schema', 'writableBranch'],
624
+ handler: resolveHandler,
625
+ });
626
+ /**
627
+ * An admin's release of a branch held because its conflict record was set aside: which fields
628
+ * clashed is lost, and the values stay as the merges left them. Under the content-write lock, as a
629
+ * merge's record is written.
630
+ */
631
+ const releaseHoldHandler = async (gc) => {
632
+ const { branchRoot } = gc.branchContext;
633
+ try {
634
+ const written = await withContentWriteLock(branchRoot, () => new MergeConflictStore(branchRoot).update((current) => {
635
+ if (!current?.held)
636
+ return null;
637
+ return { ...current, held: undefined };
638
+ }));
639
+ return { ok: true, status: 200, data: { released: written !== null } };
640
+ }
641
+ catch (err) {
642
+ if (err instanceof ContentWriteLockBusyError) {
643
+ return {
644
+ ok: false,
645
+ status: 409,
646
+ code: 'BRANCH_SYNCING',
647
+ error: 'This branch is busy: it is being synced, or a save is in flight. Try again in a moment.',
648
+ };
649
+ }
650
+ if (err instanceof MergeConflictsFileMalformedError ||
651
+ err instanceof MergeConflictsFileFormatError) {
652
+ return { ok: false, status: 503, error: RECORD_UNREADABLE_ERROR };
653
+ }
654
+ throw err;
655
+ }
656
+ };
657
+ const releaseConflictsHold = defineEndpoint({
658
+ namespace: 'conflicts',
659
+ name: 'releaseHold',
660
+ method: 'POST',
661
+ path: '/:branch/conflicts/release-hold',
662
+ params: z.object({ branch: branchNameSchema }),
663
+ responseType: 'ReleaseConflictsHoldResponse',
664
+ response: {},
665
+ defaultMockData: { released: true },
666
+ guards: ['admin', 'branch'],
667
+ handler: releaseHoldHandler,
668
+ });
669
+ export const MERGE_CONFLICT_ROUTES = {
670
+ list: listMergeConflicts,
671
+ releaseHold: releaseConflictsHold,
672
+ entry: readEntryConflict,
673
+ resolveEntry: resolveEntryConflict,
674
+ };
@@ -7,6 +7,7 @@
7
7
  import { BRANCH_ROUTES } from './branch.js';
8
8
  import { BRANCH_CHANGES_ROUTES } from './branch-changes.js';
9
9
  import { BRANCH_ENTRY_CHANGE_ROUTES } from './branch-entry-changes.js';
10
+ import { MERGE_CONFLICT_ROUTES } from './merge-conflicts.js';
10
11
  import { WORKFLOW_ROUTES } from './branch-status.js';
11
12
  import { COMMENT_ROUTES } from './comments.js';
12
13
  import { CONTENT_ROUTES } from './content.js';
@@ -28,6 +29,7 @@ export function buildCanopyRoutes() {
28
29
  ...Object.values(BRANCH_ROUTES),
29
30
  ...Object.values(BRANCH_CHANGES_ROUTES),
30
31
  ...Object.values(BRANCH_ENTRY_CHANGE_ROUTES),
32
+ ...Object.values(MERGE_CONFLICT_ROUTES),
31
33
  ...Object.values(WORKFLOW_ROUTES),
32
34
  ...Object.values(COMMENT_ROUTES),
33
35
  ...Object.values(CONTENT_ROUTES),