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
@@ -6,6 +6,8 @@ import { defineEndpoint } from './route-builder.js';
6
6
  import { syncConvertToDraft } from './github-sync.js';
7
7
  import { syncPrComments } from './pr-comment-sync.js';
8
8
  import { CommentStore } from '../comment-store.js';
9
+ import { assertNoOpenConflicts, OpenMergeConflictsError } from '../merge-conflicts.js';
10
+ import { ContentWriteLockBusyError, withContentWriteLock } from '../utils/content-write-lock.js';
9
11
  import { getErrorMessage } from '../utils/error.js';
10
12
  import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../authorization/review-role.js';
11
13
  import { getBranchProtection, isAdmin } from '../authorization/index.js';
@@ -131,22 +133,52 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
131
133
  }
132
134
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
133
135
  // As for request changes, the status on disk decides, in the step that writes. So does an
134
- // unfinished submit: what's approved may not be what the pull request holds.
136
+ // unfinished submit: what's approved may not be what the pull request holds. So do conflicts: a
137
+ // merge of GitHub's commits can record some on a branch in review, under the content-write lock
138
+ // held here, so none lands between the check and the write.
135
139
  let found;
136
140
  let unfinished = false;
137
141
  let notQueued = false;
138
- const updated = await meta.saveIf({
139
- branch: {
140
- name: branchContext.branch.name,
141
- status: 'approved',
142
- review: reviewBy(req.user, 'approved'),
143
- },
144
- }, (existing) => {
145
- found = existing?.branch.status;
146
- unfinished = existing?.branch.submitUnfinished === true;
147
- notQueued = existing?.branch.submitNotQueued === true;
148
- return found === 'submitted' && !unfinished;
149
- });
142
+ let updated;
143
+ try {
144
+ updated = await withContentWriteLock(branchContext.branchRoot, async () => {
145
+ await assertNoOpenConflicts(branchContext.branchRoot);
146
+ return meta.saveIf({
147
+ branch: {
148
+ name: branchContext.branch.name,
149
+ status: 'approved',
150
+ review: reviewBy(req.user, 'approved'),
151
+ },
152
+ }, (existing) => {
153
+ found = existing?.branch.status;
154
+ unfinished = existing?.branch.submitUnfinished === true;
155
+ notQueued = existing?.branch.submitNotQueued === true;
156
+ return found === 'submitted' && !unfinished;
157
+ });
158
+ });
159
+ }
160
+ catch (err) {
161
+ if (err instanceof OpenMergeConflictsError) {
162
+ return {
163
+ ok: false,
164
+ status: 409,
165
+ code: 'OPEN_CONFLICTS',
166
+ error: err.count === null
167
+ ? err.message
168
+ : `${err.message} Use "Request changes" so its editor can choose what to keep.`,
169
+ };
170
+ }
171
+ if (err instanceof ContentWriteLockBusyError) {
172
+ return {
173
+ ok: false,
174
+ status: 409,
175
+ error: err.outcome === 'not-run'
176
+ ? 'This branch is being synced, or a save is in flight, so it was not approved. Try again in a moment.'
177
+ : 'This branch was being synced while it was approved, so the approval may not have been recorded. Refresh to check.',
178
+ };
179
+ }
180
+ throw err;
181
+ }
150
182
  if (unfinished && found === 'submitted') {
151
183
  return {
152
184
  ok: false,
@@ -4,6 +4,7 @@ import { type BranchListItem, type BranchListItemResponse } from './branch.js';
4
4
  import type { StampedEntry } from '../stamp-on-publish.js';
5
5
  export type { BranchMergeResponse } from './branch-merge.js';
6
6
  export type { RequestChangesBody } from './branch-review.js';
7
+ export type { UpdateFromBaseResponse } from './branch-update-from-base.js';
7
8
  /**
8
9
  * A submit's answer: the branch, plus the entries the submit stamped (stamp-on-publish.ts) when
9
10
  * there were any, so the editor can carry its open forms and drafts onto their new versions.
@@ -74,6 +75,17 @@ export declare const WORKFLOW_ROUTES: {
74
75
  readonly __brand: "BranchName";
75
76
  };
76
77
  }>, undefined, BranchListItemResponse>;
78
+ readonly updateFromBase: import("./route-builder.js").RouteDefinition<z.ZodObject<{
79
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
80
+ }, "strip", z.ZodTypeAny, {
81
+ branch: string & {
82
+ readonly __brand: "BranchName";
83
+ };
84
+ }, {
85
+ branch: string & {
86
+ readonly __brand: "BranchName";
87
+ };
88
+ }>, undefined, import("./branch-update-from-base.js").UpdateFromBaseResponse>;
77
89
  readonly requestChanges: import("./route-builder.js").RouteDefinition<z.ZodObject<{
78
90
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
79
91
  }, "strip", z.ZodTypeAny, {
@@ -3,6 +3,7 @@ import { branchParamSchema } from './validators.js';
3
3
  import { toBranchListItem } from './branch.js';
4
4
  import { getBranchMetadataFileManager } from '../branch-metadata.js';
5
5
  import { withdrawBranch } from './branch-withdraw.js';
6
+ import { updateFromBase } from './branch-update-from-base.js';
6
7
  import { requestChanges, approveBranch } from './branch-review.js';
7
8
  import { markAsMerged } from './branch-merge.js';
8
9
  import { ARCHIVE_BRANCH_ROUTES } from './archived-branches.js';
@@ -14,6 +15,7 @@ import { isNonFastForwardRejection } from '../utils/git.js';
14
15
  import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
15
16
  import { submissionEditorFromUser } from '../submission-attribution.js';
16
17
  import { NothingToSubmitError } from '../services.js';
18
+ import { OpenMergeConflictsError } from '../merge-conflicts.js';
17
19
  import { baseBranchOf } from '../utils/base-branch.js';
18
20
  import { shortNameOf } from '../branch-param.js';
19
21
  import { resolveEditorUrl } from '../editor-url.js';
@@ -118,6 +120,16 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
118
120
  if (err instanceof NothingToSubmitError) {
119
121
  return { ok: false, status: 400, error: err.message };
120
122
  }
123
+ if (err instanceof OpenMergeConflictsError) {
124
+ return {
125
+ ok: false,
126
+ status: 409,
127
+ code: 'OPEN_CONFLICTS',
128
+ error: err.count === null
129
+ ? err.message
130
+ : `${err.message} Choose what to keep in "Show changes", then submit again.`,
131
+ };
132
+ }
121
133
  const shortName = shortNameOf(ctx.services.config, branchContext.branch.name);
122
134
  // Retriable either way: a resubmit commits nothing new and pushes only
123
135
  // what has not reached the remote.
@@ -275,6 +287,7 @@ export const WORKFLOW_ROUTES = {
275
287
  getStatus: getBranchStatus,
276
288
  submit: submitBranchForMerge,
277
289
  withdraw: withdrawBranch,
290
+ updateFromBase,
278
291
  requestChanges: requestChanges,
279
292
  approve: approveBranch,
280
293
  markMerged: markAsMerged,
@@ -0,0 +1,23 @@
1
+ import type { z } from 'zod';
2
+ import type { ApiResponse } from './types.js';
3
+ import { type BranchListItem } from './branch.js';
4
+ /**
5
+ * A request for the latest changes: when it was made, which the editor waits on until the
6
+ * branch's `lastBaseUpdate.at` reaches it, and the branch as it stands with the request recorded.
7
+ */
8
+ export type UpdateFromBaseResponse = ApiResponse<{
9
+ requestedAt: string;
10
+ branch: BranchListItem;
11
+ }>;
12
+ /** Get the latest changes: bring the base branch into this one at the worker's next chance. */
13
+ export declare const updateFromBase: import("./route-builder.js").RouteDefinition<z.ZodObject<{
14
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
15
+ }, "strip", z.ZodTypeAny, {
16
+ branch: string & {
17
+ readonly __brand: "BranchName";
18
+ };
19
+ }, {
20
+ branch: string & {
21
+ readonly __brand: "BranchName";
22
+ };
23
+ }>, undefined, UpdateFromBaseResponse>;
@@ -0,0 +1,176 @@
1
+ import { branchParamSchema } from './validators.js';
2
+ import { toBranchListItem } from './branch.js';
3
+ import { defineEndpoint } from './route-builder.js';
4
+ import { canPerformWorkflowAction, getBranchProtection } from '../authorization/index.js';
5
+ import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
6
+ import { clientOperatingStrategy } from '../operating-mode/index.js';
7
+ import { enqueueTask, listTasks } from '../task-queue/cms-task-queue.js';
8
+ import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
9
+ import { BASE_UPDATE_GIVE_UP_MS, BASE_UPDATE_REQUEST_INTERVAL_MS, SYNC_BRANCH_ACTION, isBaseUpdatePending, } from '../base-update.js';
10
+ import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
11
+ import { getErrorMessage, redactCredentials } from '../utils/error.js';
12
+ import { baseBranchOf } from '../utils/base-branch.js';
13
+ /**
14
+ * Ask the worker to bring the base branch into this one now, rather than at its next sync
15
+ * (base-update.ts). Saved work is committed first, locally: the sync skips a branch with
16
+ * uncommitted changes, so without the commit the request would do nothing.
17
+ *
18
+ * The request is recorded on branch.json, which is what the worker answers, so a lost task or a
19
+ * worker restart costs only the wait for its next regular cycle. The task only wakes it early.
20
+ * A request joins the last one, answering with its time: while that is unanswered, under
21
+ * BASE_UPDATE_GIVE_UP_MS old and either under BASE_UPDATE_REQUEST_INTERVAL_MS old or still queued;
22
+ * or once it was answered under BASE_UPDATE_REQUEST_INTERVAL_MS ago without failing. Anything
23
+ * else, a Retry after a failure among them, is a new request.
24
+ */
25
+ const updateFromBaseHandler = async (gc, ctx, req, _params) => {
26
+ const { branchContext } = gc;
27
+ const { config } = ctx.services;
28
+ const { branch } = branchContext;
29
+ if (!clientOperatingStrategy(config.mode).syncsBranchesWithBase()) {
30
+ return {
31
+ ok: false,
32
+ status: 409,
33
+ error: `Getting the latest changes needs the CMS worker, which ${config.mode} mode doesn't run.`,
34
+ };
35
+ }
36
+ const { isProtected } = getBranchProtection(config, branch.name, branch.baseBranch);
37
+ const allowed = canPerformWorkflowAction(branchContext, req.user, config.defaultBranchAccess ?? 'deny', { isProtectedBranch: isProtected });
38
+ if (!allowed) {
39
+ return {
40
+ ok: false,
41
+ status: 403,
42
+ error: 'You do not have permission to get the latest changes for this branch.',
43
+ };
44
+ }
45
+ if (isProtected) {
46
+ return {
47
+ ok: false,
48
+ status: 409,
49
+ error: 'This branch is the one changes come from, so it has no latest changes to get.',
50
+ };
51
+ }
52
+ // Only an editing branch takes the base in; the worker leaves the others as they are.
53
+ if (branch.status !== 'editing') {
54
+ return {
55
+ ok: false,
56
+ status: 409,
57
+ error: branch.status === 'submitted' || branch.status === 'approved'
58
+ ? 'This branch is in review. Withdraw it from review to get the latest changes.'
59
+ : "This branch can't be edited, so it can't get the latest changes.",
60
+ };
61
+ }
62
+ const base = branch.baseBranch ?? baseBranchOf(config);
63
+ const { authPlugin } = ctx;
64
+ try {
65
+ await ctx.services.commitSavedWork({
66
+ context: branchContext,
67
+ message: `Save changes before getting the latest from ${base}`,
68
+ lookupEditor: authPlugin ? (id) => authPlugin.getUserMetadata(id) : undefined,
69
+ });
70
+ }
71
+ catch (err) {
72
+ if (err instanceof ContentWriteLockBusyError) {
73
+ return {
74
+ ok: false,
75
+ status: 409,
76
+ error: err.outcome === 'not-run'
77
+ ? 'This branch is being synced, or a save is in progress. Nothing changed; try again in a moment.'
78
+ : "This branch was being synced while your saved work was prepared, so the latest changes weren't requested. Try again.",
79
+ };
80
+ }
81
+ console.error(`CanopyCMS: Could not commit the saved changes of ${branch.name} before getting the latest:`, redactCredentials(getErrorMessage(err)));
82
+ return {
83
+ ok: false,
84
+ status: 500,
85
+ error: "Your saved changes couldn't be prepared, so the latest changes weren't requested.",
86
+ };
87
+ }
88
+ const taskDir = getTaskQueueDir(config);
89
+ const queued = (await listTasks(taskDir, 'pending', Number.MAX_SAFE_INTEGER)).some((t) => t.action === SYNC_BRANCH_ACTION && t.payload.branch === branch.name);
90
+ const now = Date.now();
91
+ let requestedAt = new Date(now).toISOString();
92
+ let joined = false;
93
+ let leftEditing;
94
+ const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
95
+ const saved = await meta.update((existing) => {
96
+ if (existing?.branch.name !== branch.name)
97
+ return null;
98
+ // A submit since the guard read: the worker would leave the request standing until a withdraw.
99
+ if (existing.branch.status !== 'editing') {
100
+ leftEditing = existing.branch.status;
101
+ return null;
102
+ }
103
+ const last = existing.branch.baseUpdateRequestedAt;
104
+ const age = last === undefined ? Infinity : now - Date.parse(last);
105
+ const answer = existing.branch.lastBaseUpdate;
106
+ const pending = isBaseUpdatePending(existing.branch);
107
+ // An unanswered request is joined until the editor stops waiting on it. An answer under the
108
+ // interval old stands for a new click too, except a failure, whose Retry must ask again.
109
+ const joinsPending = pending && age < BASE_UPDATE_GIVE_UP_MS && (queued || age < BASE_UPDATE_REQUEST_INTERVAL_MS);
110
+ const answeredRecently = !pending &&
111
+ answer !== undefined &&
112
+ answer.outcome !== 'failed' &&
113
+ now - Date.parse(answer.at) < BASE_UPDATE_REQUEST_INTERVAL_MS;
114
+ if (last !== undefined && (joinsPending || answeredRecently)) {
115
+ requestedAt = last;
116
+ joined = true;
117
+ return null;
118
+ }
119
+ return { branch: { baseUpdateRequestedAt: requestedAt } };
120
+ }, { keepUpdatedAt: true });
121
+ if (leftEditing) {
122
+ return {
123
+ ok: false,
124
+ status: 409,
125
+ error: leftEditing === 'submitted' || leftEditing === 'approved'
126
+ ? 'This branch is in review. Withdraw it from review to get the latest changes.'
127
+ : "This branch can't be edited, so it can't get the latest changes.",
128
+ };
129
+ }
130
+ const current = saved ?? (await BranchMetadataFileManager.loadOnly(branchContext.branchRoot));
131
+ if (current?.branch.name !== branch.name) {
132
+ return { ok: false, status: 404, error: 'This branch no longer exists.' };
133
+ }
134
+ // A wake-up already queued serves the new request too.
135
+ if (!joined && !queued) {
136
+ try {
137
+ await enqueueTask(taskDir, { action: SYNC_BRANCH_ACTION, payload: { branch: branch.name } });
138
+ }
139
+ catch (err) {
140
+ // The worker's next regular sync answers the recorded request anyway, only later.
141
+ console.warn(`CanopyCMS: Could not queue the request for the latest changes on ${branch.name}:`, getErrorMessage(err));
142
+ }
143
+ }
144
+ return {
145
+ ok: true,
146
+ status: 202,
147
+ data: { requestedAt, branch: toBranchListItem(config, current.branch, req.user) },
148
+ };
149
+ };
150
+ /** Get the latest changes: bring the base branch into this one at the worker's next chance. */
151
+ export const updateFromBase = defineEndpoint({
152
+ namespace: 'workflow',
153
+ name: 'updateFromBase',
154
+ // Commits saved work on the shared filesystem before answering.
155
+ clientTimeout: 'long',
156
+ method: 'POST',
157
+ path: '/:branch/update-from-base',
158
+ params: branchParamSchema,
159
+ responseType: 'UpdateFromBaseResponse',
160
+ response: {},
161
+ defaultMockData: {
162
+ requestedAt: '2024-01-01T00:00:00.000Z',
163
+ branch: {
164
+ name: 'test-branch',
165
+ status: 'editing',
166
+ access: {},
167
+ createdBy: 'user-1',
168
+ createdAt: '2024-01-01',
169
+ updatedAt: '2024-01-01',
170
+ baseUpdateRequestedAt: '2024-01-01T00:00:00.000Z',
171
+ },
172
+ },
173
+ // As Withdraw: the handler's canPerformWorkflowAction decides who may ask.
174
+ guards: ['branch'],
175
+ handler: updateFromBaseHandler,
176
+ });
@@ -82,6 +82,11 @@ export type BranchListResponse = ApiResponse<{
82
82
  defaultBranch?: string;
83
83
  /** Profiles of the listed branches' creators, in no order; unknown ids are absent. */
84
84
  users: UserProfile[];
85
+ /**
86
+ * The server's clock as it answered, in ms since the epoch, so the editor times a wait for the
87
+ * latest changes against the clock that stamped the request rather than the browser's.
88
+ */
89
+ serverTime?: number;
85
90
  }>;
86
91
  /** Response type for branch deletion */
87
92
  export type BranchDeleteResponse = ApiResponse<{
@@ -528,6 +528,7 @@ const branchListResponse = async (ctx, req, branches, defaultBranch) => {
528
528
  branches: branches.map((context) => toBranchListItem(ctx.services.config, context.branch, req.user)),
529
529
  defaultBranch,
530
530
  users,
531
+ serverTime: Date.now(),
531
532
  },
532
533
  };
533
534
  };
@@ -11,7 +11,8 @@ import type { OperatingMode } from '../operating-mode/types.js';
11
11
  import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
12
12
  import type { BranchChangesResponse } from './branch-changes.js';
13
13
  import type { BranchEntryChangeResponse, RestoreEntryBody, RestoreEntryResponse } from './branch-entry-changes.js';
14
- import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse } from './branch-status.js';
14
+ import type { EntryConflictResponse, MergeConflictsResponse, ReleaseConflictsHoldResponse, ResolveEntryConflictBody, ResolveEntryConflictResponse } from './merge-conflicts.js';
15
+ import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse, UpdateFromBaseResponse } from './branch-status.js';
15
16
  import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DeleteArchivedBranchesBody, DeleteArchivedBranchesResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
16
17
  import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
17
18
  import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
@@ -95,9 +96,21 @@ export declare class CanopyApiClient {
95
96
  /** POST /:branch/restore-entry/...entryPath */
96
97
  restore: (params: Record<string, string>, body: RestoreEntryBody, options?: RequestOptions) => Promise<RestoreEntryResponse>;
97
98
  };
99
+ readonly conflicts: {
100
+ /** GET /:branch/conflicts */
101
+ list: (params: Record<string, string>, options?: RequestOptions) => Promise<MergeConflictsResponse>;
102
+ /** GET /:branch/conflicts/...entryPath */
103
+ entry: (params: Record<string, string>, options?: RequestOptions) => Promise<EntryConflictResponse>;
104
+ /** POST /:branch/resolve-entry/...entryPath */
105
+ resolveEntry: (params: Record<string, string>, body: ResolveEntryConflictBody, options?: RequestOptions) => Promise<ResolveEntryConflictResponse>;
106
+ /** POST /:branch/conflicts/release-hold */
107
+ releaseHold: (params: Record<string, string>, options?: RequestOptions) => Promise<ReleaseConflictsHoldResponse>;
108
+ };
98
109
  readonly workflow: {
99
110
  /** POST /:branch/withdraw */
100
111
  withdraw: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
112
+ /** POST /:branch/update-from-base */
113
+ updateFromBase: (params: Record<string, string>, options?: RequestOptions) => Promise<UpdateFromBaseResponse>;
101
114
  /** POST /:branch/request-changes */
102
115
  requestChanges: (params: Record<string, string>, body: RequestChangesBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
103
116
  /** POST /:branch/approve */
@@ -55,11 +55,33 @@ export class CanopyApiClient {
55
55
  return this.request('POST', this.buildPath('/:branch/restore-entry/...entryPath', params), body, 'standard', options);
56
56
  },
57
57
  };
58
+ this.conflicts = {
59
+ /** GET /:branch/conflicts */
60
+ list: (params, options) => {
61
+ return this.request('GET', this.buildPath('/:branch/conflicts', params), undefined, 'standard', options);
62
+ },
63
+ /** GET /:branch/conflicts/...entryPath */
64
+ entry: (params, options) => {
65
+ return this.request('GET', this.buildPath('/:branch/conflicts/...entryPath', params), undefined, 'standard', options);
66
+ },
67
+ /** POST /:branch/resolve-entry/...entryPath */
68
+ resolveEntry: (params, body, options) => {
69
+ return this.request('POST', this.buildPath('/:branch/resolve-entry/...entryPath', params), body, 'standard', options);
70
+ },
71
+ /** POST /:branch/conflicts/release-hold */
72
+ releaseHold: (params, options) => {
73
+ return this.request('POST', this.buildPath('/:branch/conflicts/release-hold', params), undefined, 'standard', options);
74
+ },
75
+ };
58
76
  this.workflow = {
59
77
  /** POST /:branch/withdraw */
60
78
  withdraw: (params, options) => {
61
79
  return this.request('POST', this.buildPath('/:branch/withdraw', params), undefined, 'standard', options);
62
80
  },
81
+ /** POST /:branch/update-from-base */
82
+ updateFromBase: (params, options) => {
83
+ return this.request('POST', this.buildPath('/:branch/update-from-base', params), undefined, 'long', options);
84
+ },
63
85
  /** POST /:branch/request-changes */
64
86
  requestChanges: (params, body, options) => {
65
87
  return this.request('POST', this.buildPath('/:branch/request-changes', params), body, 'standard', options);
@@ -60,16 +60,16 @@ export declare const COMMENT_ROUTES: {
60
60
  }, "strip", z.ZodTypeAny, {
61
61
  type: "branch" | "entry" | "field";
62
62
  text: string;
63
+ entryPath?: LogicalPath | undefined;
63
64
  threadId?: string | undefined;
64
65
  itemKeys?: string[] | undefined;
65
- entryPath?: LogicalPath | undefined;
66
66
  canopyPath?: string | undefined;
67
67
  }, {
68
68
  type: "branch" | "entry" | "field";
69
69
  text: string;
70
+ entryPath?: LogicalPath | undefined;
70
71
  threadId?: string | undefined;
71
72
  itemKeys?: string[] | undefined;
72
- entryPath?: LogicalPath | undefined;
73
73
  canopyPath?: string | undefined;
74
74
  }>, AddCommentResponse>;
75
75
  readonly resolve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import type { ApiResponse } from './types.js';
3
- import type { EntryValidationIssue } from '../config/index.js';
3
+ import type { ContentFormat, EntrySchema, EntryValidationIssue } from '../config/index.js';
4
4
  import { type LogicalPath, type Slug } from '../paths/index.js';
5
+ import type { ResolvedFieldConflict } from '../merge-conflicts.js';
5
6
  export type ContentReadResponse = ApiResponse<{
6
7
  format: string;
7
8
  data: Record<string, unknown>;
@@ -22,6 +23,11 @@ export type ContentWriteResponse = ApiResponse<{
22
23
  * editor has exactly one save-warnings notification to render (useEntryManager.ts).
23
24
  */
24
25
  validationWarnings?: EntryValidationIssue[];
26
+ /**
27
+ * Of the `resolvedConflicts` sent with an update, the ones this save cleared: one sent but not
28
+ * here was stale, unless a warning says the choice wasn't recorded.
29
+ */
30
+ resolvedConflicts?: ResolvedFieldConflict[];
25
31
  }>;
26
32
  export type ReferenceValidationResponse = ApiResponse<{
27
33
  valid: boolean;
@@ -48,6 +54,13 @@ export interface WriteContentBody {
48
54
  * from "lost the token", so an update has to prove which version it read.
49
55
  */
50
56
  expectedVersion?: number | null;
57
+ /**
58
+ * Field conflicts a merge left on this entry that the saved values resolve, each named as
59
+ * `GET /:branch/conflicts/...entryPath` names it. Cleared once the entry is written, under the
60
+ * save's locks; a pair the record no longer holds is ignored. A save that leaves a conflicting
61
+ * field out leaves its conflict open.
62
+ */
63
+ resolvedConflicts?: ResolvedFieldConflict[];
51
64
  }
52
65
  export interface ValidateReferencesBody {
53
66
  data: Record<string, unknown>;
@@ -55,6 +68,12 @@ export interface ValidateReferencesBody {
55
68
  export interface RenameEntryBody {
56
69
  newSlug: string;
57
70
  }
71
+ /**
72
+ * What a save writes for `data`, an entry's form data: an md/mdx file's body-field key dropped
73
+ * (that field is the file's body), references collapsed to IDs, and every list item and block
74
+ * keyed. `backfill-item-keys` writes through this too, so its files are a save's.
75
+ */
76
+ export declare function entryDataAsSaved(fields: EntrySchema, format: ContentFormat, data: Record<string, unknown>): Record<string, unknown>;
58
77
  /**
59
78
  * Exported routes for router registration
60
79
  */
@@ -102,16 +121,34 @@ export declare const CONTENT_ROUTES: {
102
121
  data: z.ZodOptional<z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodUnknown>, Record<string, unknown>, Record<string, unknown>>>;
103
122
  body: z.ZodOptional<z.ZodString>;
104
123
  expectedVersion: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
124
+ resolvedConflicts: z.ZodOptional<z.ZodArray<z.ZodObject<{
125
+ mergeSha: z.ZodString;
126
+ key: z.ZodString;
127
+ }, "strip", z.ZodTypeAny, {
128
+ key: string;
129
+ mergeSha: string;
130
+ }, {
131
+ key: string;
132
+ mergeSha: string;
133
+ }>, "many">>;
105
134
  }, "strip", z.ZodTypeAny, {
106
135
  format: "md" | "mdx" | "json" | "yaml";
107
136
  data?: Record<string, unknown> | undefined;
108
137
  expectedVersion?: number | null | undefined;
109
138
  body?: string | undefined;
139
+ resolvedConflicts?: {
140
+ key: string;
141
+ mergeSha: string;
142
+ }[] | undefined;
110
143
  }, {
111
144
  format: "md" | "mdx" | "json" | "yaml";
112
145
  data?: Record<string, unknown> | undefined;
113
146
  expectedVersion?: number | null | undefined;
114
147
  body?: string | undefined;
148
+ resolvedConflicts?: {
149
+ key: string;
150
+ mergeSha: string;
151
+ }[] | undefined;
115
152
  }>, ContentWriteResponse>;
116
153
  readonly validateReferences: import("./route-builder.js").RouteDefinition<z.ZodObject<{
117
154
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
@@ -14,6 +14,8 @@ import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils
14
14
  import { isDataOnlyFormat } from '../utils/format.js';
15
15
  import { withItemKeys } from '../item-keys.js';
16
16
  import { findBodyFieldName } from '../utils/body-field.js';
17
+ import { canopyLogWarn } from '../utils/logger.js';
18
+ import { clearFieldConflicts, resolvedConflictsSchema } from './merge-conflicts.js';
17
19
  /**
18
20
  * Parse an API path into logical path segments, prepending the content root if needed.
19
21
  * Shared by all content API handlers.
@@ -60,6 +62,7 @@ const writeContentBodySchema = z.object({
60
62
  body: z.string().max(MAX_CONTENT_BODY_CHARS).optional(),
61
63
  // null or omitted = create ("must not already exist"); see WriteContentBody.
62
64
  expectedVersion: z.number().nullish(),
65
+ resolvedConflicts: resolvedConflictsSchema.optional(),
63
66
  });
64
67
  const validateReferencesParamsSchema = z.object({
65
68
  branch: branchNameSchema,
@@ -133,6 +136,15 @@ function withoutKey(record, key) {
133
136
  const { [key]: _dropped, ...rest } = record;
134
137
  return rest;
135
138
  }
139
+ /**
140
+ * What a save writes for `data`, an entry's form data: an md/mdx file's body-field key dropped
141
+ * (that field is the file's body), references collapsed to IDs, and every list item and block
142
+ * keyed. `backfill-item-keys` writes through this too, so its files are a save's.
143
+ */
144
+ export function entryDataAsSaved(fields, format, data) {
145
+ const own = isDataOnlyFormat(format) ? data : withoutKey(data, findBodyFieldName(fields));
146
+ return withItemKeys(fields, normalizeReferenceValues(fields, own), 'content');
147
+ }
136
148
  /** The policy's issues in the stored entry, which a save may keep. */
137
149
  const storedMarkdownSafetyIssues = async (store, collectionPath, slug, fields, siteAllow) => {
138
150
  // A stored file that does not read keeps nothing, so the save's code is refused, not a 500.
@@ -393,9 +405,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
393
405
  // Every list item and block leaves a save keyed (`item-keys.ts`). A key sent is kept, so a
394
406
  // thread naming it still finds its item; one missing is filled from the item's content, as a
395
407
  // read fills one.
396
- const normalizedData = requestData === undefined
397
- ? undefined
398
- : withItemKeys(fields, normalizeReferenceValues(fields, requestData), 'content');
408
+ const normalizedData = requestData === undefined ? undefined : entryDataAsSaved(fields, body.format, requestData);
399
409
  // Computed before the validateEntry hook and the entry-link scan too, not just the write, so
400
410
  // every consumer agrees on the same bytes — otherwise an adopter's hook could see a resolved
401
411
  // object while the file got an ID string, depending on whether the post came from the editor.
@@ -475,10 +485,32 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
475
485
  body: body.body ?? '',
476
486
  expectedVersion,
477
487
  };
488
+ // Under the write's own locks, so no merge records a conflict between the value written and
489
+ // the record cleared. A failure to clear keeps the save: the conflict stays open, and says so.
490
+ let resolvedConflicts;
491
+ const resolving = expectedVersion === null ? [] : (body.resolvedConflicts ?? []);
492
+ const afterWrite = async ({ id }) => {
493
+ if (!id || resolving.length === 0)
494
+ return;
495
+ try {
496
+ resolvedConflicts = await clearFieldConflicts(branchContext, id, resolving);
497
+ }
498
+ catch (err) {
499
+ canopyLogWarn(`CanopyCMS: saved ${logicalPathSegments.join('/')} but couldn't mark its conflicts resolved:`, getErrorMessage(err));
500
+ resolvedConflicts = [];
501
+ validationWarnings = [
502
+ ...(validationWarnings ?? []),
503
+ {
504
+ level: 'warning',
505
+ message: 'Saved, but your choice for its conflicting changes wasn’t recorded. Choose again, then save.',
506
+ },
507
+ ];
508
+ }
509
+ };
478
510
  // Pass the resolved entryTypeName (not the raw, possibly-omitted
479
511
  // params.entryType) so the store's own format check agrees with the type
480
512
  // we just validated against.
481
- const result = await store.write(schemaItem.logicalPath, slug, writeInput, entryTypeName);
513
+ const result = await store.write(schemaItem.logicalPath, slug, writeInput, entryTypeName, undefined, { afterWrite });
482
514
  // Validate entry links in body content (warnings only, don't block save).
483
515
  // Reuses the entry-type fields resolved above for schema validation. Folded into
484
516
  // `validationWarnings` (not a separate `entryLinkWarnings` field) so the editor's one
@@ -494,7 +526,11 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
494
526
  }));
495
527
  validationWarnings = [...(validationWarnings ?? []), ...linkWarnings];
496
528
  }
497
- return { ok: true, status: 200, data: { ...result, validationWarnings } };
529
+ return {
530
+ ok: true,
531
+ status: 200,
532
+ data: { ...result, validationWarnings, ...(resolvedConflicts && { resolvedConflicts }) },
533
+ };
498
534
  }
499
535
  catch (err) {
500
536
  if (err instanceof ContentConflictError) {