canopycms 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +174 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-changes.d.ts +1 -0
  4. package/dist/api/branch-changes.js +1 -1
  5. package/dist/api/branch-entry-changes.d.ts +92 -7
  6. package/dist/api/branch-entry-changes.js +272 -38
  7. package/dist/api/branch-review.js +45 -13
  8. package/dist/api/branch-status.d.ts +12 -0
  9. package/dist/api/branch-status.js +13 -0
  10. package/dist/api/branch-update-from-base.d.ts +23 -0
  11. package/dist/api/branch-update-from-base.js +176 -0
  12. package/dist/api/branch.d.ts +5 -0
  13. package/dist/api/branch.js +1 -0
  14. package/dist/api/client.d.ts +17 -2
  15. package/dist/api/client.js +26 -0
  16. package/dist/api/comments.d.ts +2 -2
  17. package/dist/api/content.d.ts +38 -1
  18. package/dist/api/content.js +41 -5
  19. package/dist/api/merge-conflicts.d.ts +226 -0
  20. package/dist/api/merge-conflicts.js +674 -0
  21. package/dist/api/routes.js +2 -0
  22. package/dist/api/types.d.ts +7 -3
  23. package/dist/base-update.d.ts +26 -0
  24. package/dist/base-update.js +31 -0
  25. package/dist/branch-metadata-file.d.ts +51 -0
  26. package/dist/branch-metadata-file.js +11 -0
  27. package/dist/branch-schema-cache.d.ts +12 -0
  28. package/dist/branch-schema-cache.js +27 -2
  29. package/dist/cli/backfill-item-keys.d.ts +77 -0
  30. package/dist/cli/backfill-item-keys.js +320 -0
  31. package/dist/cli/cli.d.ts +1 -1
  32. package/dist/cli/cli.js +9713 -1493
  33. package/dist/cli/generate-ai-content.js +415 -244
  34. package/dist/cli/init.js +6 -0
  35. package/dist/cli/site-config.d.ts +14 -2
  36. package/dist/cli/site-config.js +20 -4
  37. package/dist/comment-store.d.ts +8 -0
  38. package/dist/comment-store.js +28 -0
  39. package/dist/content-index-generation.d.ts +1 -4
  40. package/dist/content-index-generation.js +1 -4
  41. package/dist/content-store.d.ts +65 -3
  42. package/dist/content-store.js +121 -15
  43. package/dist/editor/BranchManager.d.ts +4 -1
  44. package/dist/editor/BranchManager.js +9 -5
  45. package/dist/editor/Editor.js +549 -53
  46. package/dist/editor/EntryNavigator.d.ts +10 -2
  47. package/dist/editor/EntryNavigator.js +12 -4
  48. package/dist/editor/FormRenderer.d.ts +8 -2
  49. package/dist/editor/FormRenderer.js +5 -6
  50. package/dist/editor/base-update-wait.d.ts +74 -0
  51. package/dist/editor/base-update-wait.js +91 -0
  52. package/dist/editor/branch-status.js +1 -1
  53. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  54. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  55. package/dist/editor/components/BranchPill.d.ts +4 -1
  56. package/dist/editor/components/BranchPill.js +5 -2
  57. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  58. package/dist/editor/components/ConflictsGroup.js +364 -0
  59. package/dist/editor/components/EditorHeader.d.ts +16 -1
  60. package/dist/editor/components/EditorHeader.js +92 -30
  61. package/dist/editor/components/EntryChangesDrawer.d.ts +52 -2
  62. package/dist/editor/components/EntryChangesDrawer.js +164 -162
  63. package/dist/editor/components/EntryHeader.d.ts +3 -1
  64. package/dist/editor/components/EntryHeader.js +4 -6
  65. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  66. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  67. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  68. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  69. package/dist/editor/components/RetryToastMessage.js +1 -1
  70. package/dist/editor/components/change-parts.d.ts +51 -0
  71. package/dist/editor/components/change-parts.js +171 -0
  72. package/dist/editor/conflict-cards.d.ts +90 -0
  73. package/dist/editor/conflict-cards.js +279 -0
  74. package/dist/editor/conflict-choices.d.ts +60 -0
  75. package/dist/editor/conflict-choices.js +108 -0
  76. package/dist/editor/conflict-notice.d.ts +25 -0
  77. package/dist/editor/conflict-notice.js +39 -0
  78. package/dist/editor/copy.d.ts +20 -0
  79. package/dist/editor/copy.js +20 -0
  80. package/dist/editor/entry-diff.d.ts +18 -2
  81. package/dist/editor/entry-diff.js +26 -32
  82. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  83. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  84. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  85. package/dist/editor/hooks/useBranchManager.js +28 -7
  86. package/dist/editor/hooks/useBranchesData.js +3 -0
  87. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  88. package/dist/editor/hooks/useConflictChoices.js +75 -0
  89. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  90. package/dist/editor/hooks/useConflictsData.js +89 -0
  91. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  92. package/dist/editor/hooks/useDraftManager.js +24 -8
  93. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  94. package/dist/editor/hooks/useEntryManager.js +5 -0
  95. package/dist/editor/release-hold.d.ts +12 -0
  96. package/dist/editor/release-hold.js +39 -0
  97. package/dist/editor/server-clock.d.ts +4 -0
  98. package/dist/editor/server-clock.js +15 -0
  99. package/dist/editor/undo-change.d.ts +52 -0
  100. package/dist/editor/undo-change.js +241 -0
  101. package/dist/git-manager.d.ts +5 -0
  102. package/dist/git-manager.js +30 -0
  103. package/dist/item-keys.d.ts +13 -0
  104. package/dist/item-keys.js +36 -14
  105. package/dist/merge/conflict-path.d.ts +40 -0
  106. package/dist/merge/conflict-path.js +128 -0
  107. package/dist/merge/diff3.d.ts +27 -0
  108. package/dist/merge/diff3.js +113 -0
  109. package/dist/merge/entry-merge.d.ts +94 -0
  110. package/dist/merge/entry-merge.js +875 -0
  111. package/dist/merge/list-alignment.d.ts +52 -0
  112. package/dist/merge/list-alignment.js +441 -0
  113. package/dist/merge/text-merge.d.ts +29 -0
  114. package/dist/merge/text-merge.js +59 -0
  115. package/dist/merge/value-equality.d.ts +18 -0
  116. package/dist/merge/value-equality.js +50 -0
  117. package/dist/merge-conflicts.d.ts +232 -0
  118. package/dist/merge-conflicts.js +451 -0
  119. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  120. package/dist/operating-mode/client-safe-strategy.js +6 -0
  121. package/dist/operating-mode/types.d.ts +5 -0
  122. package/dist/services.d.ts +16 -1
  123. package/dist/services.js +82 -22
  124. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  125. package/dist/types.d.ts +28 -2
  126. package/dist/utils/content-serialize.d.ts +18 -1
  127. package/dist/utils/content-serialize.js +12 -0
  128. package/dist/utils/content-write-lock.d.ts +3 -3
  129. package/dist/utils/content-write-lock.js +3 -3
  130. package/dist/utils/markdown-body-splice.d.ts +1 -1
  131. package/dist/utils/markdown-body-splice.js +1 -19
  132. package/dist/utils/markdown-parse.d.ts +9 -0
  133. package/dist/utils/markdown-parse.js +27 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/dist/worker/branch-merge.d.ts +53 -37
  137. package/dist/worker/branch-merge.js +139 -54
  138. package/dist/worker/cms-worker.d.ts +16 -3
  139. package/dist/worker/cms-worker.js +59 -12
  140. package/dist/worker/field-merge.d.ts +65 -0
  141. package/dist/worker/field-merge.js +636 -0
  142. package/dist/worker/git-sync.d.ts +1 -1
  143. package/dist/worker/git-sync.js +7 -6
  144. package/dist/worker/github-commits.d.ts +42 -10
  145. package/dist/worker/github-commits.js +287 -47
  146. package/dist/worker/github-gateway-http.js +17 -9
  147. package/dist/worker/github-gateway-wire.d.ts +21 -12
  148. package/dist/worker/github-gateway-wire.js +5 -1
  149. package/dist/worker/github-gateway.d.ts +10 -1
  150. package/dist/worker/github-gateway.js +18 -6
  151. package/dist/worker/github-transfer.d.ts +5 -3
  152. package/dist/worker/github-transfer.js +8 -5
  153. package/dist/worker/rebase.d.ts +5 -4
  154. package/dist/worker/rebase.js +293 -156
  155. package/dist/worker/task-runner.d.ts +1 -1
  156. package/dist/worker/task-runner.js +14 -2
  157. package/dist/worker/worker-context.d.ts +18 -2
  158. package/package.json +1 -1
@@ -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 false;
50
+ }
45
51
  getPermissionsFileName() {
46
52
  return 'permissions.json';
47
53
  }
@@ -33,6 +33,11 @@ export interface ClientSafeStrategy {
33
33
  supportsBranching(): boolean;
34
34
  supportsComments(): boolean;
35
35
  supportsPullRequests(): boolean;
36
+ /**
37
+ * Whether a worker brings the base branch into editing branches, so a request for the latest
38
+ * changes (base-update.ts) gets an answer. Dev runs no git sync.
39
+ */
40
+ syncsBranchesWithBase(): boolean;
36
41
  /** Get the permissions file name (e.g., 'permissions.json' or 'permissions.local.json') */
37
42
  getPermissionsFileName(): string;
38
43
  /** Get the groups file name (e.g., 'groups.json' or 'groups.local.json') */
@@ -79,13 +79,28 @@ export interface CanopyServices {
79
79
  files: string | string[];
80
80
  message: string;
81
81
  }) => Promise<void>;
82
+ /**
83
+ * Commit the branch's saved but uncommitted work in its clone, pushing nothing. A later submit
84
+ * pushes it, or, on a branch published before, the worker's merge of the base once it moves
85
+ * (worker/rebase.ts, `aheadOfPublished`). Its trailers name the editors who saved since the last
86
+ * 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, and with `NothingToSubmitError`
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: {
package/dist/services.js CHANGED
@@ -30,14 +30,15 @@ import { enqueueTask } from './task-queue/cms-task-queue.js';
30
30
  import { getTaskQueueDir } from './task-queue/task-queue-config.js';
31
31
  import { baseBranchOf } from './utils/base-branch.js';
32
32
  import { shortNameOf } from './branch-param.js';
33
- import { detectHeadBranch, isCanopyInternalPath } from './utils/git.js';
33
+ import { detectHeadBranch, isCanopyInternalPath, isRebaseInProgress } from './utils/git.js';
34
34
  import { readsFromCheckout } from './build-mode.js';
35
35
  import { timeRequestPhase } from './utils/request-timing.js';
36
36
  import { appendTrailers, buildEditorTrailers, describeEditors, MAX_LISTED_ENTRIES, } from './submission-attribution.js';
37
37
  import { getBranchMetadataFileManager, recordBranchEditor } from './branch-metadata.js';
38
38
  import { readBranchMetadataFile } from './branch-metadata-file.js';
39
39
  import { getErrorMessage, redactCredentials } from './utils/error.js';
40
- import { withContentWriteLock } from './utils/content-write-lock.js';
40
+ import { ContentWriteLockBusyError, withContentWriteLock } from './utils/content-write-lock.js';
41
+ import { assertNoOpenConflicts } from './merge-conflicts.js';
41
42
  import { ContentStore } from './content-store.js';
42
43
  import { entryLogicalPathOfFile } from './content-id-index.js';
43
44
  import { stampNewEntries } from './stamp-on-publish.js';
@@ -213,10 +214,77 @@ async function _createCanopyServicesInternal(config, options) {
213
214
  // lands on its detached head with the rebase's half-resolved index, and the
214
215
  // rebase's `--abort` discards it.
215
216
  await withContentWriteLock(options.context.branchRoot, async () => {
217
+ await refuseDuringRebase(options.context.branchRoot);
216
218
  await git.add(options.files);
217
219
  await git.commit(options.message);
218
220
  });
219
221
  };
222
+ /**
223
+ * Inside the content-write lock, before a commit: a rebase left by a worker that died holds no
224
+ * lock, and its recovery (`rebase --abort`, worker/rebase.ts) would reset the branch past the
225
+ * commit, silently. In prod the worker clears it on its next cycle, so this is a retry.
226
+ */
227
+ const refuseDuringRebase = async (branchRoot) => {
228
+ if (await isRebaseInProgress(branchRoot))
229
+ throw new ContentWriteLockBusyError();
230
+ };
231
+ /**
232
+ * Commit every saved change but canopycms's own state, if there is one, with trailers naming
233
+ * `credited` and then the editors who saved since the last commit. Runs inside the caller's
234
+ * content-write lock. Resolves whether it committed.
235
+ */
236
+ const commitSavedChanges = async (git, status, commit) => {
237
+ if (!status.files.some((f) => !isCanopyInternalPath(f.path)))
238
+ return false;
239
+ await git.addAllExceptCanopyState();
240
+ const committers = commit.uncommitted.map((userId) => commit.named.get(userId) ?? { userId });
241
+ const trailers = buildEditorTrailers(commit.credited ? [commit.credited, ...committers] : committers, {
242
+ editedBy: config.gitEditedByTrailers ?? true,
243
+ coAuthoredBy: config.gitCoAuthoredByTrailers ?? false,
244
+ });
245
+ await git.commit(appendTrailers(commit.message, trailers));
246
+ return true;
247
+ };
248
+ /** Clear the editors a commit named; a failure leaves them for the next commit to name again. */
249
+ const markCommitted = async (context, uncommitted) => {
250
+ if (uncommitted.length === 0)
251
+ return;
252
+ try {
253
+ await getBranchMetadataFileManager(context.branchRoot, context.baseRoot).markEditorsCommitted(uncommitted);
254
+ }
255
+ catch (err) {
256
+ console.warn(`CanopyCMS: Could not clear the committed editors of ${context.branch.name}:`, getErrorMessage(err));
257
+ }
258
+ };
259
+ /** The editors recorded on the branch, each with the name `lookupEditor` gives. */
260
+ const namedEditors = async (context, lookupEditor) => new Map((await describeEditors((await readRecordedEditors(context))?.editors ?? [], memoizeLookup(lookupEditor))).map((e) => [e.userId, e]));
261
+ const commitSavedWork = async (options) => {
262
+ const git = createGitManagerFor(options.context.branchRoot, {
263
+ baseBranch: options.context.branch.baseBranch,
264
+ });
265
+ await git.ensureAuthor({ name: config.gitBotAuthorName, email: config.gitBotAuthorEmail });
266
+ // Names are looked up before taking the lock, so a slow auth provider never holds it.
267
+ const named = await namedEditors(options.context, options.lookupEditor);
268
+ // [SYNC-C1] Locked for the same reason as submitBranch: a commit landing while a rebase is
269
+ // stopped on a conflict is discarded by its abort.
270
+ const committed = await withContentWriteLock(options.context.branchRoot, async () => {
271
+ await refuseDuringRebase(options.context.branchRoot);
272
+ await git.checkoutBranch(options.context.branch.name);
273
+ const status = await git.status();
274
+ const uncommitted = (await readRecordedEditors(options.context))?.uncommittedEditors ?? [];
275
+ const done = await commitSavedChanges(git, status, {
276
+ message: options.message,
277
+ uncommitted,
278
+ named,
279
+ });
280
+ // Inside the lock, as submitBranch does: a save after it would record its editor as still
281
+ // uncommitted, and this would then clear them with the save left out of the commit.
282
+ if (done)
283
+ await markCommitted(options.context, uncommitted);
284
+ return done;
285
+ });
286
+ return committed;
287
+ };
220
288
  /** Runs inside submitBranch's content-write lock; a stamp that fails never blocks the submit. */
221
289
  const stampNewEntriesOrWarn = async (context, git) => {
222
290
  const contentRoot = services.config.contentRoot || 'content';
@@ -286,6 +354,9 @@ async function _createCanopyServicesInternal(config, options) {
286
354
  let recorded = {};
287
355
  let stamped = [];
288
356
  const submitted = await withContentWriteLock(options.context.branchRoot, async () => {
357
+ await refuseDuringRebase(options.context.branchRoot);
358
+ // In the lock, as the worker records a merge's conflicts in it before the merge lands.
359
+ await assertNoOpenConflicts(options.context.branchRoot);
289
360
  await git.checkoutBranch(options.context.branch.name);
290
361
  // Before the status read, so the stamp joins this commit even when an earlier attempt
291
362
  // already committed the entry and only its push failed.
@@ -303,18 +374,13 @@ async function _createCanopyServicesInternal(config, options) {
303
374
  // branch already had unpushed commits from an earlier attempt.
304
375
  // canopycms's own state under .canopy-meta/ is never content, so it neither
305
376
  // makes a commit worth creating nor gets staged into one.
306
- let committed = false;
307
377
  const preCommitSha = await git.headSha();
308
- if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
309
- await git.addAllExceptCanopyState();
310
- const committers = uncommitted.map((userId) => named.get(userId) ?? { userId });
311
- const trailers = buildEditorTrailers(options.submitter ? [options.submitter, ...committers] : committers, {
312
- editedBy: config.gitEditedByTrailers ?? true,
313
- coAuthoredBy: config.gitCoAuthoredByTrailers ?? false,
314
- });
315
- await git.commit(appendTrailers(options.message ?? `Submit ${options.context.branch.name}`, trailers));
316
- committed = true;
317
- }
378
+ const committed = await commitSavedChanges(git, status, {
379
+ message: options.message ?? `Submit ${options.context.branch.name}`,
380
+ credited: options.submitter,
381
+ uncommitted,
382
+ named,
383
+ });
318
384
  // The diff runs after the commit so it covers saved-but-uncommitted edits too, and
319
385
  // before the push so a branch with nothing to submit never reaches the remote. A diff
320
386
  // that cannot be computed lets the submit through: the list only feeds the PR body, and
@@ -333,15 +399,8 @@ async function _createCanopyServicesInternal(config, options) {
333
399
  await git.resetKeepingChanges(preCommitSha);
334
400
  throw new NothingToSubmitError(shortNameOf(config, options.context.branch.name), effectiveBase);
335
401
  }
336
- // A failure here leaves them uncommitted, so the next commit names them again.
337
- if (committed && uncommitted.length > 0) {
338
- try {
339
- await getBranchMetadataFileManager(options.context.branchRoot, options.context.baseRoot).markEditorsCommitted(uncommitted);
340
- }
341
- catch (err) {
342
- console.warn(`CanopyCMS: Could not clear the committed editors of ${options.context.branch.name}:`, getErrorMessage(err));
343
- }
344
- }
402
+ if (committed)
403
+ await markCommitted(options.context, uncommitted);
345
404
  if (committed || (await git.hasUnpushedCommits(options.context.branch.name))) {
346
405
  await git.push(options.context.branch.name);
347
406
  }
@@ -467,6 +526,7 @@ async function _createCanopyServicesInternal(config, options) {
467
526
  githubService,
468
527
  bootstrapAdminIds,
469
528
  commitFiles,
529
+ commitSavedWork,
470
530
  submitBranch,
471
531
  commitToSettingsBranch,
472
532
  getSettingsBranchRoot,
@@ -5,7 +5,9 @@
5
5
  */
6
6
  import type { Task, TaskQueueLogger } from './types.js';
7
7
  /** Actions the EC2 worker can execute on behalf of Lambda. */
8
- export type TaskAction = 'push-and-create-pr' | 'push-and-update-pr' | 'push-and-create-or-update-pr' | 'convert-to-draft' | 'close-pr' | 'delete-remote-branch' | 'push-branch' | 'sync-pr-comments';
8
+ export type TaskAction = 'push-and-create-pr' | 'push-and-update-pr' | 'push-and-create-or-update-pr' | 'convert-to-draft' | 'close-pr' | 'delete-remote-branch' | 'push-branch' | 'sync-pr-comments'
9
+ /** Wake the git sync early for `payload.branch` (base-update.ts). */
10
+ | 'sync-branch';
9
11
  /** A task with a CMS-specific action. */
10
12
  export type WorkerTask = Task & {
11
13
  action: TaskAction;
package/dist/types.d.ts CHANGED
@@ -115,9 +115,12 @@ export interface BranchMetadata {
115
115
  editorUrl?: string;
116
116
  /** Sync status for async GitHub operations (used when Lambda has no internet) */
117
117
  syncStatus?: SyncStatus;
118
- /** Whether any rebase of this branch kept its version of an entry base also changed */
118
+ /** Whether {@link conflictFiles} names any entry */
119
119
  conflictStatus?: ConflictStatus;
120
- /** ContentIds of the entries where a rebase kept the branch's version; cleared when it merges */
120
+ /**
121
+ * ContentIds of the entries with a merge conflict a person has yet to resolve, derived from the
122
+ * clone's merge-conflicts.json (merge-conflicts.ts); cleared when the branch merges
123
+ */
121
124
  conflictFiles?: ContentId[];
122
125
  /**
123
126
  * Lifecycle state of the branch's PR as last observed by the worker's merge-poll
@@ -217,6 +220,29 @@ export interface BranchMetadata {
217
220
  uncommittedEditors?: CanopyUserId[];
218
221
  /** The reviewer's verdict on the last submit; absent until a reviewer acts. */
219
222
  review?: BranchReview;
223
+ /**
224
+ * When someone last asked for the latest changes (api/branch-update-from-base.ts). The request
225
+ * stands while this is newer than `lastBaseUpdate.at`; the editor holds the form read-only and
226
+ * polls meanwhile, and the worker answers it even after a restart lost its wake-up.
227
+ */
228
+ baseUpdateRequestedAt?: string;
229
+ /** The worker's answer to the latest request for the latest changes (worker/rebase.ts). */
230
+ lastBaseUpdate?: LastBaseUpdate;
231
+ }
232
+ /** How a requested update from the base went. */
233
+ export type BaseUpdateOutcome = 'merged' | 'up-to-date' | 'conflicts' | 'failed';
234
+ export interface LastBaseUpdate {
235
+ /**
236
+ * When the GitHub fetch began that the branch was brought up to, as the gateway reports it
237
+ * (worker/github-gateway.ts, `GitHubFetchResult.startedAt`): a request is answered only from a
238
+ * fetch begun at or after it, so a cycle already running when it arrived never ends it.
239
+ */
240
+ at: string;
241
+ outcome: BaseUpdateOutcome;
242
+ /** The base commit the branch now contains; absent when it failed before reading one. */
243
+ baseSha?: string;
244
+ /** For `conflicts`: how many entries the merge left a conflict in for a person. */
245
+ keptEntries?: number;
220
246
  }
221
247
  export interface BranchPaths {
222
248
  /** Root where all branches live (e.g., /mnt/efs/site, ~/.canopycms/branches) */
@@ -17,7 +17,7 @@
17
17
  * (validation/entry-validator.ts) at the API boundary, not by a schema-blind serialiser.
18
18
  */
19
19
  import type { FieldConfig } from '../config/index.js';
20
- import { type MarkdownBodyFormat } from './markdown-body-splice.js';
20
+ import type { MarkdownBodyFormat } from './markdown-parse.js';
21
21
  /**
22
22
  * Serialise entry data as a YAML file, carrying the comments of `existingRaw` through.
23
23
  *
@@ -39,3 +39,20 @@ export declare function serializeYaml(data: Record<string, unknown>, existingRaw
39
39
  * what the read path reads, so a `date` field's bare date counts as the day it writes.
40
40
  */
41
41
  export declare function serializeFrontmatter(editorBody: string, data: Record<string, unknown>, existingRaw: string | undefined, format: MarkdownBodyFormat, fields?: readonly FieldConfig[]): string;
42
+ /** What a save hands the serialiser: `ContentStore.write`'s input, less its version token. */
43
+ export type SerializableEntry = {
44
+ format: MarkdownBodyFormat;
45
+ data?: Record<string, unknown>;
46
+ body: string;
47
+ } | {
48
+ format: 'json';
49
+ data: Record<string, unknown>;
50
+ } | {
51
+ format: 'yaml';
52
+ data: Record<string, unknown>;
53
+ };
54
+ /**
55
+ * An entry's file text as a save writes it: `entry` serialised onto `existingRaw`, the file's
56
+ * current text (absent for a new file). `fields` is the entry type's schema.
57
+ */
58
+ export declare function serializeEntry(entry: SerializableEntry, existingRaw: string | undefined, fields: readonly FieldConfig[]): string;
@@ -24,6 +24,7 @@ import { ITEM_KEY, isItemKey } from '../item-keys.js';
24
24
  import { withCalendarDays } from './calendar-day.js';
25
25
  import { createDebugLogger } from './debug.js';
26
26
  import { getErrorMessage } from './error.js';
27
+ import { serializeJson } from './json-source-splice.js';
27
28
  import { preserveMarkdownSource } from './markdown-body-splice.js';
28
29
  import { snapshotDocument, spliceSource, withSourceLineEndings } from './yaml-source-splice.js';
29
30
  const log = createDebugLogger({ prefix: 'ContentSerialize' });
@@ -681,3 +682,14 @@ export function serializeFrontmatter(editorBody, data, existingRaw, format, fiel
681
682
  log.debug('content-serialize', 'reconciled frontmatter reads back differently; writing as gray-matter prints it');
682
683
  return matter.stringify(file, data);
683
684
  }
685
+ /**
686
+ * An entry's file text as a save writes it: `entry` serialised onto `existingRaw`, the file's
687
+ * current text (absent for a new file). `fields` is the entry type's schema.
688
+ */
689
+ export function serializeEntry(entry, existingRaw, fields) {
690
+ if (entry.format === 'json')
691
+ return serializeJson(entry.data, existingRaw);
692
+ if (entry.format === 'yaml')
693
+ return serializeYaml(entry.data, existingRaw);
694
+ return serializeFrontmatter(entry.body, entry.data ?? {}, existingRaw, entry.format, fields);
695
+ }
@@ -4,9 +4,9 @@
4
4
  *
5
5
  * Production runs a Lambda (API) and an EC2 worker over one EFS filesystem, so `ContentStore`'s
6
6
  * in-process mutex (`utils/async-mutex`) does not cover the worker's rebase (`rebaseOneBranch` in
7
- * worker/rebase.ts) rewriting the same tree. A write landing after `git rebase` started is
8
- * destroyed either way -- `git checkout --theirs` overwrites it and the rebase then SUCCEEDS
9
- * silently, or `git rebase --abort` hard-resets the tree -- and the editor already got its 200.
7
+ * worker/rebase.ts) rewriting the same tree. A write landing after `git rebase` started makes the
8
+ * next replayed commit fail, and the `git rebase --abort` that follows hard-resets the tree,
9
+ * discarding it -- and the editor already got its 200.
10
10
  *
11
11
  * **Asymmetric by design.** The worker retries every branch on its next sync cycle (~5 minutes)
12
12
  * while the editor is a person waiting on a save, so the worker yields and the writer waits:
@@ -4,9 +4,9 @@
4
4
  *
5
5
  * Production runs a Lambda (API) and an EC2 worker over one EFS filesystem, so `ContentStore`'s
6
6
  * in-process mutex (`utils/async-mutex`) does not cover the worker's rebase (`rebaseOneBranch` in
7
- * worker/rebase.ts) rewriting the same tree. A write landing after `git rebase` started is
8
- * destroyed either way -- `git checkout --theirs` overwrites it and the rebase then SUCCEEDS
9
- * silently, or `git rebase --abort` hard-resets the tree -- and the editor already got its 200.
7
+ * worker/rebase.ts) rewriting the same tree. A write landing after `git rebase` started makes the
8
+ * next replayed commit fail, and the `git rebase --abort` that follows hard-resets the tree,
9
+ * discarding it -- and the editor already got its 200.
10
10
  *
11
11
  * **Asymmetric by design.** The worker retries every branch on its next sync cycle (~5 minutes)
12
12
  * while the editor is a person waiting on a save, so the worker yields and the writer waits:
@@ -23,7 +23,7 @@
23
23
  * then both again from the edit as sent; failing all four, the restyled edit is written whole,
24
24
  * or the edit as sent when it did not read back: the worst case is never different content.
25
25
  */
26
- export type MarkdownBodyFormat = 'md' | 'mdx';
26
+ import { type MarkdownBodyFormat } from './markdown-parse.js';
27
27
  /**
28
28
  * `text`'s top-level blocks, each as its source text and its `canonical` form, or undefined when
29
29
  * `text` does not parse: two bodies mean the same to the splice exactly when their `meaning`
@@ -23,32 +23,14 @@
23
23
  * then both again from the edit as sent; failing all four, the restyled edit is written whole,
24
24
  * or the edit as sent when it did not read back: the worst case is never different content.
25
25
  */
26
- import { fromMarkdown } from 'mdast-util-from-markdown';
27
- import { gfmFromMarkdown } from 'mdast-util-gfm';
28
- import { mdxFromMarkdown } from 'mdast-util-mdx';
29
- import { gfm } from 'micromark-extension-gfm';
30
- import { mdxjs } from 'micromark-extension-mdxjs';
31
26
  import { createDebugLogger } from './debug.js';
32
27
  import { getErrorMessage } from './error.js';
28
+ import { parseMarkdownBody as parse, } from './markdown-parse.js';
33
29
  import { orderedNumber, restyleMarkdown } from './markdown-restyle.js';
34
30
  import { withSourceLineEndings } from './yaml-source-splice.js';
35
31
  const log = createDebugLogger({ prefix: 'MarkdownBodySplice' });
36
32
  /** Above this many LCS cells the middle of a sequence is treated as one changed run. */
37
33
  const MAX_LCS_CELLS = 4_000_000;
38
- function parse(text, format) {
39
- try {
40
- return format === 'mdx'
41
- ? fromMarkdown(text, {
42
- extensions: [gfm(), mdxjs()],
43
- mdastExtensions: [gfmFromMarkdown(), mdxFromMarkdown()],
44
- })
45
- : fromMarkdown(text, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
46
- }
47
- catch (err) {
48
- log.debug('markdown-body-splice', 'body does not parse', { error: getErrorMessage(err) });
49
- return undefined;
50
- }
51
- }
52
34
  /**
53
35
  * A node's meaning as a string: its tree without source positions. `data` goes too, because
54
36
  * the parser fills it only with derived information (an MDX expression's estree, which carries
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The md/mdx body parser the save path and the merge engine share, so both read a body the same
3
+ * way. Dependency-free of node built-ins: the merge engine runs in the browser bundle too.
4
+ */
5
+ import { fromMarkdown } from 'mdast-util-from-markdown';
6
+ export type MarkdownBodyFormat = 'md' | 'mdx';
7
+ export type MarkdownRoot = ReturnType<typeof fromMarkdown>;
8
+ /** `text`'s mdast with GFM, and MDX for `mdx`; undefined when it does not parse. */
9
+ export declare function parseMarkdownBody(text: string, format: MarkdownBodyFormat): MarkdownRoot | undefined;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The md/mdx body parser the save path and the merge engine share, so both read a body the same
3
+ * way. Dependency-free of node built-ins: the merge engine runs in the browser bundle too.
4
+ */
5
+ import { fromMarkdown } from 'mdast-util-from-markdown';
6
+ import { gfmFromMarkdown } from 'mdast-util-gfm';
7
+ import { mdxFromMarkdown } from 'mdast-util-mdx';
8
+ import { gfm } from 'micromark-extension-gfm';
9
+ import { mdxjs } from 'micromark-extension-mdxjs';
10
+ import { createDebugLogger } from './debug.js';
11
+ import { getErrorMessage } from './error.js';
12
+ const log = createDebugLogger({ prefix: 'MarkdownParse' });
13
+ /** `text`'s mdast with GFM, and MDX for `mdx`; undefined when it does not parse. */
14
+ export function parseMarkdownBody(text, format) {
15
+ try {
16
+ return format === 'mdx'
17
+ ? fromMarkdown(text, {
18
+ extensions: [gfm(), mdxjs()],
19
+ mdastExtensions: [gfmFromMarkdown(), mdxFromMarkdown()],
20
+ })
21
+ : fromMarkdown(text, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
22
+ }
23
+ catch (err) {
24
+ log.debug('markdown-parse', 'body does not parse', { error: getErrorMessage(err) });
25
+ return undefined;
26
+ }
27
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.11.1";
1
+ export declare const CANOPYCMS_VERSION = "0.12.1";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
2
  // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
- export const CANOPYCMS_VERSION = '0.11.1';
3
+ export const CANOPYCMS_VERSION = '0.12.1';
@@ -1,14 +1,30 @@
1
1
  import type { SimpleGit } from 'simple-git';
2
- import { type ContentId } from '../paths/types.js';
3
- import type { ConflictStatus } from '../types.js';
2
+ import type { KeyRemap } from '../merge/entry-merge.js';
3
+ import type { FoundEntryConflict } from '../merge-conflicts.js';
4
4
  import type { WorkerContext } from './worker-context.js';
5
- /** What {@link buildMergeCommit} made of the two commits. */
6
- export type BuiltMerge =
7
- /** `sha` is the merge, reachable from no ref yet. `conflicted` kept the branch's version. */
8
- {
9
- kind: 'built';
10
- sha: string;
11
- conflicted: string[];
5
+ /** What {@link planMerge} made of the two commits. */
6
+ export type MergePlan = {
7
+ kind: 'planned';
8
+ /** The merge's tree. */
9
+ tree: string;
10
+ /** The tree git's own merge wrote, conflict markers and all. */
11
+ gitTree: string;
12
+ /** Paths git could not merge, whoever settled them. */
13
+ gitConflicted: string[];
14
+ /** What a person must still decide, entry by entry. */
15
+ conflicts: FoundEntryConflict[];
16
+ /** Paths where the branch's version stands over the other side's change, whole or in part. */
17
+ keptPaths: string[];
18
+ /** Git's own merge with nothing for a person: no conflict, and every file as git merged it. */
19
+ asGitDoes: boolean;
20
+ /** Item keys the merge changed, by the entry path the branch's comment threads name. */
21
+ keyRemaps: Array<{
22
+ entryPath: string;
23
+ contentId?: string;
24
+ remaps: KeyRemap[];
25
+ }>;
26
+ /** Where each entry the merge decided stands in it. */
27
+ paths: Map<string, string>;
12
28
  }
13
29
  /** Git met conflicts no rule settles safely. Nothing was built. */
14
30
  | {
@@ -16,19 +32,40 @@ export type BuiltMerge =
16
32
  conflicts: string[];
17
33
  paths: string[];
18
34
  };
35
+ /** {@link commitMerge}'s commit of a planned merge; `sha` is reachable from no ref yet. */
36
+ export type BuiltMerge = Omit<Extract<MergePlan, {
37
+ kind: 'planned';
38
+ }>, 'kind' | 'tree' | 'gitTree'> & {
39
+ kind: 'built';
40
+ sha: string;
41
+ };
19
42
  /**
20
- * The merge of `other` into `head`, as a commit no ref names, with `head` the first parent. A path
21
- * git cannot merge keeps `head`'s side, stage 2, at the path git reports, or stays deleted when
22
- * `head` has no such stage: the rule the rebase loop applies to a conflict, since `head` has the
23
- * editor's work. The commit takes its identity from the clone's `user.*`, written when the clone
24
- * is provisioned.
43
+ * The merge of `other` into `head`, as a tree. Every entry and `.collection.json` both sides
44
+ * changed is merged field by field (field-merge.ts), which settles git's conflicts there, whatever
45
+ * their kind. A path git cannot merge that is no entry keeps `head`'s side, stage 2, at the path
46
+ * git reports, or stays deleted when `head` has no such stage: `head` has the editor's work. With
47
+ * no single merge base, git's merge stands as that rule leaves it, since a field merge needs one
48
+ * version both sides started from.
25
49
  */
26
- export declare function buildMergeCommit(git: SimpleGit, clonePath: string, args: {
50
+ export declare function planMerge(git: SimpleGit, clonePath: string, args: {
27
51
  head: string;
28
52
  other: string;
29
- message: string;
53
+ contentRoot: string;
30
54
  timeoutMs: number;
55
+ }): Promise<MergePlan>;
56
+ /**
57
+ * {@link planMerge}'s merge as a commit no ref names, with `head` the first parent. The commit takes
58
+ * its identity from the clone's `user.*`, written when the clone is provisioned.
59
+ */
60
+ export declare function commitMerge(git: SimpleGit, plan: Extract<MergePlan, {
61
+ kind: 'planned';
62
+ }>, { head, other, message }: {
63
+ head: string;
64
+ other: string;
65
+ message: string;
31
66
  }): Promise<BuiltMerge>;
67
+ /** Whether `plan` is git's own merge with nothing for a person. */
68
+ export declare const mergesAsGitDoes: (plan: MergePlan) => boolean;
32
69
  /**
33
70
  * Whether `merged` holds exactly what `from` and `other` merge to cleanly, and so nothing else. A
34
71
  * conflict counts as no: whoever settled it chose what `merged` holds.
@@ -45,24 +82,3 @@ export declare function commitsApart(git: SimpleGit, a: string, b: string): Prom
45
82
  onlyA: number;
46
83
  onlyB: number;
47
84
  }>;
48
- /**
49
- * Map the file paths a rebase or merge kept the branch's version of onto the ContentIds the
50
- * editor shows as conflicted, deduplicated. IDs rather than paths because they are
51
- * immutable: a later slug rename must not orphan a conflict marker. Paths that
52
- * carry no recoverable ID are dropped rather than guessed at.
53
- *
54
- * Pure.
55
- */
56
- export declare function conflictFilesToContentIds(conflictedFiles: readonly string[], contentRoot: string): ContentId[];
57
- /**
58
- * The conflict record after a rebase or merge: the IDs already recorded plus
59
- * its own. A conflict means this branch's version replaced base's change to
60
- * that entry, which a later clean rebase does not undo, so the record only
61
- * grows until the branch merges (buildMergedBranchUpdate clears it).
62
- *
63
- * Pure.
64
- */
65
- export declare function recordConflicts(recorded: readonly ContentId[] | undefined, found: readonly ContentId[]): {
66
- conflictStatus: ConflictStatus;
67
- conflictFiles: ContentId[];
68
- };