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