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.
- package/CHANGELOG.md +164 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.js +45 -13
- package/dist/api/branch-status.d.ts +12 -0
- package/dist/api/branch-status.js +13 -0
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +176 -0
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +1 -0
- package/dist/api/client.d.ts +14 -1
- package/dist/api/client.js +22 -0
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +226 -0
- package/dist/api/merge-conflicts.js +674 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +7 -3
- package/dist/base-update.d.ts +26 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +51 -0
- package/dist/branch-metadata-file.js +11 -0
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9658 -1499
- package/dist/cli/generate-ai-content.js +354 -244
- package/dist/cli/init.js +6 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +429 -52
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/base-update-wait.d.ts +74 -0
- package/dist/editor/base-update-wait.js +91 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +100 -0
- package/dist/editor/components/ConflictsGroup.js +364 -0
- package/dist/editor/components/EditorHeader.d.ts +16 -1
- package/dist/editor/components/EditorHeader.js +92 -30
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +33 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +90 -0
- package/dist/editor/conflict-cards.js +279 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
- package/dist/editor/hooks/useBaseUpdate.js +269 -0
- package/dist/editor/hooks/useBranchManager.d.ts +6 -1
- package/dist/editor/hooks/useBranchManager.js +28 -7
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +5 -0
- package/dist/editor/hooks/useDraftManager.js +24 -8
- package/dist/editor/hooks/useEntryManager.d.ts +9 -0
- package/dist/editor/hooks/useEntryManager.js +5 -0
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +5 -0
- package/dist/git-manager.js +30 -0
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +875 -0
- package/dist/merge/list-alignment.d.ts +52 -0
- package/dist/merge/list-alignment.js +441 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +5 -0
- package/dist/services.d.ts +16 -1
- package/dist/services.js +82 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +28 -2
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +53 -37
- package/dist/worker/branch-merge.js +139 -54
- package/dist/worker/cms-worker.d.ts +16 -3
- package/dist/worker/cms-worker.js +59 -12
- package/dist/worker/field-merge.d.ts +65 -0
- package/dist/worker/field-merge.js +636 -0
- package/dist/worker/git-sync.d.ts +1 -1
- package/dist/worker/git-sync.js +7 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +287 -47
- package/dist/worker/github-gateway-http.js +17 -9
- package/dist/worker/github-gateway-wire.d.ts +21 -12
- package/dist/worker/github-gateway-wire.js +5 -1
- package/dist/worker/github-gateway.d.ts +10 -1
- package/dist/worker/github-gateway.js +18 -6
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/rebase.d.ts +5 -4
- package/dist/worker/rebase.js +293 -156
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +14 -2
- package/dist/worker/worker-context.d.ts +18 -2
- 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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
});
|
package/dist/api/branch.d.ts
CHANGED
|
@@ -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<{
|
package/dist/api/branch.js
CHANGED
|
@@ -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
|
};
|
package/dist/api/client.d.ts
CHANGED
|
@@ -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 {
|
|
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 */
|
package/dist/api/client.js
CHANGED
|
@@ -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);
|
package/dist/api/comments.d.ts
CHANGED
|
@@ -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<{
|
package/dist/api/content.d.ts
CHANGED
|
@@ -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>;
|
package/dist/api/content.js
CHANGED
|
@@ -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 {
|
|
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) {
|