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
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { ApiResponse } from './types.js';
3
3
  import type { ContentFormat } from '../config/index.js';
4
- import type { LogicalPath } from '../paths/types.js';
4
+ import type { ContentId, LogicalPath } from '../paths/types.js';
5
5
  /** One side of an entry's change: its file as the editor reads it, references unresolved. */
6
6
  export interface EntryChangeSide {
7
7
  format: ContentFormat;
@@ -33,7 +33,7 @@ export declare const readBranchEntryChange: import("./route-builder.js").RouteDe
33
33
  /** A moved entry's path at the base. */
34
34
  from: z.ZodOptional<z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>>;
35
35
  /** The entry's content id: both sides must carry it, so a slug reused by another entry isn't it. */
36
- id: z.ZodOptional<z.ZodType<import("../paths/index.js").ContentId, z.ZodTypeDef, import("../paths/index.js").ContentId>>;
36
+ id: z.ZodOptional<z.ZodType<ContentId, z.ZodTypeDef, ContentId>>;
37
37
  }, "strip", z.ZodTypeAny, {
38
38
  branch: string & {
39
39
  readonly __brand: "BranchName";
@@ -41,7 +41,7 @@ export declare const readBranchEntryChange: import("./route-builder.js").RouteDe
41
41
  entryPath: string & {
42
42
  readonly __brand: "LogicalPath";
43
43
  };
44
- id?: import("../paths/index.js").ContentId | undefined;
44
+ id?: ContentId | undefined;
45
45
  from?: LogicalPath | undefined;
46
46
  }, {
47
47
  branch: string & {
@@ -50,9 +50,64 @@ export declare const readBranchEntryChange: import("./route-builder.js").RouteDe
50
50
  entryPath: string & {
51
51
  readonly __brand: "LogicalPath";
52
52
  };
53
- id?: import("../paths/index.js").ContentId | undefined;
53
+ id?: ContentId | undefined;
54
54
  from?: LogicalPath | undefined;
55
55
  }>, undefined, BranchEntryChangeResponse>;
56
+ declare const restoreBodySchema: z.ZodObject<{
57
+ /** The deleted entry's content id, which the base file must carry and the restore keeps. */
58
+ id: z.ZodType<ContentId, z.ZodTypeDef, ContentId>;
59
+ }, "strip", z.ZodTypeAny, {
60
+ id: string & {
61
+ readonly __brand: "ContentId";
62
+ };
63
+ }, {
64
+ id: string & {
65
+ readonly __brand: "ContentId";
66
+ };
67
+ }>;
68
+ interface RestoreEntryData {
69
+ logicalPath: string;
70
+ contentId: string;
71
+ /** The entry is back, but its collection's manual order couldn't be updated. */
72
+ warning?: string;
73
+ }
74
+ export type RestoreEntryResponse = ApiResponse<RestoreEntryData>;
75
+ /**
76
+ * Puts back an entry deleted on the branch, as the commit the branch forked from holds it, with
77
+ * its content id (`ContentStore.restore`).
78
+ * POST /:branch/restore-entry/...entryPath
79
+ * @internal Exported for tests.
80
+ */
81
+ export declare const restoreBranchEntry: import("./route-builder.js").RouteDefinition<z.ZodObject<{
82
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
83
+ entryPath: z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>;
84
+ }, "strip", z.ZodTypeAny, {
85
+ branch: string & {
86
+ readonly __brand: "BranchName";
87
+ };
88
+ entryPath: string & {
89
+ readonly __brand: "LogicalPath";
90
+ };
91
+ }, {
92
+ branch: string & {
93
+ readonly __brand: "BranchName";
94
+ };
95
+ entryPath: string & {
96
+ readonly __brand: "LogicalPath";
97
+ };
98
+ }>, z.ZodObject<{
99
+ /** The deleted entry's content id, which the base file must carry and the restore keeps. */
100
+ id: z.ZodType<ContentId, z.ZodTypeDef, ContentId>;
101
+ }, "strip", z.ZodTypeAny, {
102
+ id: string & {
103
+ readonly __brand: "ContentId";
104
+ };
105
+ }, {
106
+ id: string & {
107
+ readonly __brand: "ContentId";
108
+ };
109
+ }>, RestoreEntryResponse>;
110
+ export type RestoreEntryBody = z.infer<typeof restoreBodySchema>;
56
111
  export declare const BRANCH_ENTRY_CHANGE_ROUTES: {
57
112
  readonly entry: import("./route-builder.js").RouteDefinition<z.ZodObject<{
58
113
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
@@ -60,7 +115,7 @@ export declare const BRANCH_ENTRY_CHANGE_ROUTES: {
60
115
  /** A moved entry's path at the base. */
61
116
  from: z.ZodOptional<z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>>;
62
117
  /** The entry's content id: both sides must carry it, so a slug reused by another entry isn't it. */
63
- id: z.ZodOptional<z.ZodType<import("../paths/index.js").ContentId, z.ZodTypeDef, import("../paths/index.js").ContentId>>;
118
+ id: z.ZodOptional<z.ZodType<ContentId, z.ZodTypeDef, ContentId>>;
64
119
  }, "strip", z.ZodTypeAny, {
65
120
  branch: string & {
66
121
  readonly __brand: "BranchName";
@@ -68,7 +123,7 @@ export declare const BRANCH_ENTRY_CHANGE_ROUTES: {
68
123
  entryPath: string & {
69
124
  readonly __brand: "LogicalPath";
70
125
  };
71
- id?: import("../paths/index.js").ContentId | undefined;
126
+ id?: ContentId | undefined;
72
127
  from?: LogicalPath | undefined;
73
128
  }, {
74
129
  branch: string & {
@@ -77,7 +132,37 @@ export declare const BRANCH_ENTRY_CHANGE_ROUTES: {
77
132
  entryPath: string & {
78
133
  readonly __brand: "LogicalPath";
79
134
  };
80
- id?: import("../paths/index.js").ContentId | undefined;
135
+ id?: ContentId | undefined;
81
136
  from?: LogicalPath | undefined;
82
137
  }>, undefined, BranchEntryChangeResponse>;
138
+ readonly restore: import("./route-builder.js").RouteDefinition<z.ZodObject<{
139
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
140
+ entryPath: z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>;
141
+ }, "strip", z.ZodTypeAny, {
142
+ branch: string & {
143
+ readonly __brand: "BranchName";
144
+ };
145
+ entryPath: string & {
146
+ readonly __brand: "LogicalPath";
147
+ };
148
+ }, {
149
+ branch: string & {
150
+ readonly __brand: "BranchName";
151
+ };
152
+ entryPath: string & {
153
+ readonly __brand: "LogicalPath";
154
+ };
155
+ }>, z.ZodObject<{
156
+ /** The deleted entry's content id, which the base file must carry and the restore keeps. */
157
+ id: z.ZodType<ContentId, z.ZodTypeDef, ContentId>;
158
+ }, "strip", z.ZodTypeAny, {
159
+ id: string & {
160
+ readonly __brand: "ContentId";
161
+ };
162
+ }, {
163
+ id: string & {
164
+ readonly __brand: "ContentId";
165
+ };
166
+ }>, RestoreEntryResponse>;
83
167
  };
168
+ export {};
@@ -2,19 +2,22 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { entryRefFromRepoPath, entryRefFromRepoPathShape, parseEntryParts, } from '../content-listing.js';
5
- import { ContentStore, ContentStoreError } from '../content-store.js';
5
+ import { BranchSyncingError, ContentConflictError, ContentStore, ContentStoreError, } from '../content-store.js';
6
6
  import { resolveCollectionPath } from '../content-id-index.js';
7
7
  import { BaseBranchMissingError } from '../git-manager.js';
8
8
  import { getBranchProtection } from '../authorization/protected-branch.js';
9
+ import { SchemaOps, SchemaStoreBusyError } from '../schema/schema-store.js';
10
+ import { canopyLogWarn } from '../utils/logger.js';
11
+ import { isRebaseInProgress } from '../utils/git.js';
9
12
  import { baseBranchOf } from '../utils/base-branch.js';
10
13
  import { findBodyFieldName } from '../utils/body-field.js';
11
14
  import { entryAsRead } from '../utils/calendar-day.js';
12
- import { isNodeError, isNotFoundError } from '../utils/error.js';
15
+ import { isNodeError, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
13
16
  import { getFormatExtension } from '../utils/format.js';
14
17
  import { entryLogicalPath } from '../paths/index.js';
15
18
  import { defineEndpoint } from './route-builder.js';
16
19
  import { branchNameSchema, contentIdSchema, logicalPathSchema } from './validators.js';
17
- import { branchReadFailedResponse, readWhileSteady, unsteadyReadResponse, } from './branch-changes.js';
20
+ import { branchReadFailedResponse, readWhileSteady, SYNCING_ERROR, unsteadyReadResponse, } from './branch-changes.js';
18
21
  /** The most of a side's file read, so one huge file can't hold the API or the browser. */
19
22
  const MAX_SIDE_BYTES = 1024 * 1024;
20
23
  const NOT_COMPARABLE_ERROR = "This entry can't be compared.";
@@ -110,14 +113,13 @@ const readAfter = async (store, flatSchema, target, id) => {
110
113
  }
111
114
  };
112
115
  /**
113
- * The entry at `logicalPath` in `mergeBase`'s tree, matched by `contentId` when there is one.
114
- * Looked for under the collection's directory first, then the whole content root when that
115
- * directory isn't in the base tree (a collection renamed or deleted on the branch). Files map
116
- * to entries as GET /:branch/changes maps them, so the entry it lists resolves to the same
117
- * file here. A base file whose collection or entry type the schema no longer has is keyed with
118
- * `afterFields`, the saved side's, so its unchanged items carry the keys the saved side's do.
116
+ * The file of the entry at `logicalPath` in `mergeBase`'s tree, matched by `contentId` when
117
+ * there is one. Looked for under the collection's directory first, then the whole content root
118
+ * when that directory isn't in the base tree (a collection renamed or deleted on the branch).
119
+ * Files map to entries as GET /:branch/changes maps them, so the entry it lists resolves to the
120
+ * same file here.
119
121
  */
120
- const readBefore = async (git, mergeBase, logicalPath, match, branchContext, contentRoot) => {
122
+ const findBaseFile = async (git, mergeBase, logicalPath, contentId, branchContext, contentRoot) => {
121
123
  const { flatSchema, branchRoot } = branchContext;
122
124
  const collectionPath = logicalPath.slice(0, logicalPath.lastIndexOf('/'));
123
125
  const collectionDir = await resolveCollectionPath(branchRoot, collectionPath);
@@ -132,35 +134,9 @@ const readBefore = async (git, mergeBase, logicalPath, match, branchContext, con
132
134
  const ref = placed.kind === 'other' ? entryRefFromRepoPathShape(file.path, contentRoot) : placed;
133
135
  if (ref?.kind !== 'entry' || ref.logicalPath !== logicalPath)
134
136
  continue;
135
- if (match.contentId !== undefined && ref.contentId !== match.contentId)
137
+ if (contentId !== undefined && ref.contentId !== contentId)
136
138
  continue;
137
- if (file.size > MAX_SIDE_BYTES)
138
- return 'too-large';
139
- const entryType = entryTypeIn(flatSchema, ref.collectionPath, ref.entryType);
140
- const fields = entryType?.schema ?? match.afterFields ?? [];
141
- // An entry type the schema no longer has is parsed by its extension.
142
- const format = entryType?.format ?? FORMATS.find((f) => file.path.endsWith(getFormatExtension(f)));
143
- if (!format)
144
- return 'unreadable';
145
- const raw = await git.showFile(mergeBase, file.path);
146
- if (raw === undefined)
147
- return undefined;
148
- let parsed;
149
- try {
150
- parsed = parseEntryParts(raw, format);
151
- }
152
- catch {
153
- return 'unreadable';
154
- }
155
- return {
156
- format,
157
- entryType: ref.entryType,
158
- data: entryAsRead(fields, parsed.data),
159
- ...(parsed.body !== undefined && {
160
- body: parsed.body,
161
- bodyFieldName: findBodyFieldName(fields),
162
- }),
163
- };
139
+ return { path: file.path, size: file.size, ref, placed: placed.kind === 'entry' };
164
140
  }
165
141
  // The collection's directory is in the base tree, so an entry not in it is new.
166
142
  if (files.length > 0)
@@ -168,6 +144,44 @@ const readBefore = async (git, mergeBase, logicalPath, match, branchContext, con
168
144
  }
169
145
  return undefined;
170
146
  };
147
+ /**
148
+ * The entry at `logicalPath` in `mergeBase`'s tree (`findBaseFile`), read as the editor reads
149
+ * one. A base file whose collection or entry type the schema no longer has is keyed with
150
+ * `afterFields`, the saved side's, so its unchanged items carry the keys the saved side's do.
151
+ */
152
+ const readBefore = async (git, mergeBase, logicalPath, match, branchContext, contentRoot) => {
153
+ const file = await findBaseFile(git, mergeBase, logicalPath, match.contentId, branchContext, contentRoot);
154
+ if (!file)
155
+ return undefined;
156
+ const { ref } = file;
157
+ if (file.size > MAX_SIDE_BYTES)
158
+ return 'too-large';
159
+ const entryType = entryTypeIn(branchContext.flatSchema, ref.collectionPath, ref.entryType);
160
+ const fields = entryType?.schema ?? match.afterFields ?? [];
161
+ // An entry type the schema no longer has is parsed by its extension.
162
+ const format = entryType?.format ?? FORMATS.find((f) => file.path.endsWith(getFormatExtension(f)));
163
+ if (!format)
164
+ return 'unreadable';
165
+ const raw = await git.showFile(mergeBase, file.path);
166
+ if (raw === undefined)
167
+ return undefined;
168
+ let parsed;
169
+ try {
170
+ parsed = parseEntryParts(raw, format);
171
+ }
172
+ catch {
173
+ return 'unreadable';
174
+ }
175
+ return {
176
+ format,
177
+ entryType: ref.entryType,
178
+ data: entryAsRead(fields, parsed.data),
179
+ ...(parsed.body !== undefined && {
180
+ body: parsed.body,
181
+ bodyFieldName: findBodyFieldName(fields),
182
+ }),
183
+ };
184
+ };
171
185
  const entryChangeHandler = async (gc, ctx, req, params) => {
172
186
  const { branchContext } = gc;
173
187
  const { config } = ctx.services;
@@ -255,6 +269,226 @@ export const readBranchEntryChange = defineEndpoint({
255
269
  guards: ['branchAccessWithSchema'],
256
270
  handler: entryChangeHandler,
257
271
  });
272
+ const restoreParamsSchema = z.object({
273
+ branch: branchNameSchema,
274
+ entryPath: logicalPathSchema,
275
+ });
276
+ const restoreBodySchema = z.object({
277
+ /** The deleted entry's content id, which the base file must carry and the restore keeps. */
278
+ id: contentIdSchema,
279
+ });
280
+ const NOT_RESTORABLE_ERROR = "This entry isn't on the base branch, so it can't be restored.";
281
+ const TYPE_CHANGED_ERROR = "This entry's type or format has changed since it was deleted, so it can't be restored.";
282
+ const TOO_LARGE_TO_RESTORE_ERROR = 'This entry is too large to restore here.';
283
+ const UNREADABLE_TO_RESTORE_ERROR = "This entry's saved file doesn't read, so it can't be restored.";
284
+ /** `order` with `id` put back after its nearest predecessor in `baseOrder` that `order` holds. */
285
+ const orderWith = (order, id, baseOrder) => {
286
+ const at = baseOrder.indexOf(id);
287
+ for (let i = at - 1; i >= 0; i--) {
288
+ const index = order.indexOf(baseOrder[i]);
289
+ if (index >= 0)
290
+ return [...order.slice(0, index + 1), id, ...order.slice(index + 1)];
291
+ }
292
+ return [id, ...order];
293
+ };
294
+ /** A collection's `order` in its `.collection.json` at `rev`, or undefined when it has none. */
295
+ const orderAt = async (git, rev, collectionRepoDir) => {
296
+ const raw = await git.showFile(rev, `${collectionRepoDir}/.collection.json`);
297
+ if (raw === undefined)
298
+ return undefined;
299
+ try {
300
+ const order = JSON.parse(raw)?.order;
301
+ return Array.isArray(order) && order.every((id) => typeof id === 'string') ? order : undefined;
302
+ }
303
+ catch {
304
+ return undefined;
305
+ }
306
+ };
307
+ /** Git failing in a restore's pre-write HEAD check, answered as a failed read is. */
308
+ class HeadReadError extends Error {
309
+ constructor(failure) {
310
+ super("Couldn't read the branch's HEAD");
311
+ this.failure = failure;
312
+ }
313
+ }
314
+ const restoreEntryHandler = async (gc, ctx, req, params, body) => {
315
+ const { branchContext } = gc;
316
+ const { branchRoot } = branchContext;
317
+ const { config } = ctx.services;
318
+ const base = branchContext.branch.baseBranch ?? baseBranchOf(config);
319
+ const contentRoot = config.contentRoot || 'content';
320
+ const store = new ContentStore(branchRoot, branchContext.flatSchema, {
321
+ contentRootName: contentRoot,
322
+ });
323
+ const target = canonicalPath(store, underContentRoot(params.entryPath, contentRoot));
324
+ const notRestorable = {
325
+ ok: false,
326
+ status: 404,
327
+ error: NOT_RESTORABLE_ERROR,
328
+ };
329
+ if (!target.stored)
330
+ return notRestorable;
331
+ const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchRoot, req.user);
332
+ if (!checkAccess(target.logicalPath, 'edit').allowed) {
333
+ return { ok: false, status: 403, error: 'Forbidden' };
334
+ }
335
+ const { collectionPath, slug } = target.stored;
336
+ const collectionDir = await resolveCollectionPath(branchRoot, collectionPath);
337
+ const collectionRepoDir = collectionDir && path.relative(branchRoot, collectionDir).split(path.sep).join('/');
338
+ const git = ctx.services.createGitManagerFor(branchRoot, {
339
+ baseBranch: branchContext.branch.baseBranch,
340
+ });
341
+ let read;
342
+ try {
343
+ read = await readWhileSteady(branchRoot, git, async () => {
344
+ const mergeBase = await git.mergeBaseWithBase();
345
+ if (!mergeBase)
346
+ return undefined;
347
+ const file = await findBaseFile(git, mergeBase, target.logicalPath, body.id, branchContext, contentRoot);
348
+ if (!file)
349
+ return undefined;
350
+ // Only a file the current schema places as this entry type, by name and extension, goes
351
+ // back: another format's bytes under this type's extension would not read.
352
+ if (!file.placed)
353
+ return 'type-changed';
354
+ if (file.size > MAX_SIDE_BYTES)
355
+ return 'too-large';
356
+ const raw = await git.showFile(mergeBase, file.path);
357
+ if (raw === undefined)
358
+ return undefined;
359
+ const baseOrder = collectionRepoDir
360
+ ? await orderAt(git, mergeBase, collectionRepoDir)
361
+ : undefined;
362
+ return {
363
+ raw,
364
+ entryType: file.ref.entryType,
365
+ head: await git.headSha(),
366
+ ...(baseOrder && { baseOrder }),
367
+ };
368
+ });
369
+ }
370
+ catch (err) {
371
+ if (err instanceof BaseBranchMissingError)
372
+ return notRestorable;
373
+ return branchReadFailedResponse(branchContext.branch.name, base, err);
374
+ }
375
+ if (!read.steady)
376
+ return unsteadyReadResponse(read.reason, base);
377
+ const entry = read.value;
378
+ if (entry === 'too-large')
379
+ return { ok: false, status: 413, error: TOO_LARGE_TO_RESTORE_ERROR };
380
+ if (entry === 'type-changed')
381
+ return { ok: false, status: 409, error: TYPE_CHANGED_ERROR };
382
+ if (!entry)
383
+ return notRestorable;
384
+ const entryType = entryTypeIn(branchContext.flatSchema, collectionPath, entry.entryType);
385
+ if (!entryType)
386
+ return { ok: false, status: 409, error: TYPE_CHANGED_ERROR };
387
+ try {
388
+ parseEntryParts(entry.raw, entryType.format);
389
+ }
390
+ catch {
391
+ return { ok: false, status: 422, error: UNREADABLE_TO_RESTORE_ERROR };
392
+ }
393
+ // As a create checks it (SCH-H3, api/content.ts), best-effort: count-then-write isn't atomic.
394
+ const { maxItems } = entryType;
395
+ if (maxItems !== undefined &&
396
+ (await store.countEntriesOfType(collectionPath, entry.entryType)) >= maxItems) {
397
+ return {
398
+ ok: false,
399
+ status: 422,
400
+ error: `Cannot restore entry: type "${entry.entryType}" allows at most ${maxItems} ${maxItems === 1 ? 'entry' : 'entries'}`,
401
+ };
402
+ }
403
+ try {
404
+ await store.restore(collectionPath, slug, { id: body.id, entryTypeName: entry.entryType, raw: entry.raw }, {
405
+ // The bytes are the merge base's as HEAD stood at the read; a sync since may have
406
+ // updated the entry upstream, so a moved HEAD refuses rather than reverting that.
407
+ beforeWrite: async () => {
408
+ let moved;
409
+ try {
410
+ moved = (await isRebaseInProgress(branchRoot)) || (await git.headSha()) !== entry.head;
411
+ }
412
+ catch (err) {
413
+ throw new HeadReadError(err);
414
+ }
415
+ if (moved)
416
+ throw new BranchSyncingError(SYNCING_ERROR(base));
417
+ },
418
+ });
419
+ }
420
+ catch (err) {
421
+ if (err instanceof HeadReadError) {
422
+ return branchReadFailedResponse(branchContext.branch.name, base, err.failure);
423
+ }
424
+ if (err instanceof BranchSyncingError) {
425
+ return {
426
+ ok: false,
427
+ status: 409,
428
+ error: err.message,
429
+ code: err.outcome === 'unknown' ? 'WRITE_OUTCOME_UNKNOWN' : 'BRANCH_SYNCING',
430
+ };
431
+ }
432
+ // A refusal the editor can act on: the slug or the id is taken, or a URL is contested.
433
+ if (err instanceof ContentConflictError)
434
+ return { ok: false, status: 409, error: err.message };
435
+ if (err instanceof ContentStoreError) {
436
+ return { ok: false, status: 400, error: sanitizeErrorMessage(err.message) };
437
+ }
438
+ throw err;
439
+ }
440
+ const warning = await restoreOrder(ctx, branchContext, collectionPath, body.id, entry.baseOrder);
441
+ return {
442
+ ok: true,
443
+ status: 200,
444
+ data: { logicalPath: target.logicalPath, contentId: body.id, ...(warning && { warning }) },
445
+ };
446
+ };
447
+ /**
448
+ * Puts a restored entry's id back in its collection's manual order, after its nearest neighbour
449
+ * at the base, undoing what a delete took out (api/entries.ts). Best-effort: the entry is back
450
+ * either way, so a failure is a warning. Runs after the restore, whose content-write lock a
451
+ * schema write also takes.
452
+ */
453
+ const restoreOrder = async (ctx, branchContext, collectionPath, id, baseOrder) => {
454
+ const collection = branchContext.flatSchema.find((item) => item.type === 'collection' && item.logicalPath === collectionPath);
455
+ const order = collection?.order;
456
+ if (!order || !baseOrder?.includes(id) || order.includes(id))
457
+ return undefined;
458
+ const schemaStore = new SchemaOps(path.join(branchContext.branchRoot, ctx.services.config.contentRoot || 'content'), ctx.services.entrySchemaRegistry, ctx.services, branchContext.branchRoot);
459
+ try {
460
+ await schemaStore.updateOrder(collectionPath, orderWith(order, id, baseOrder));
461
+ return undefined;
462
+ }
463
+ catch (err) {
464
+ // An `'unknown'` outcome means the update ran to completion.
465
+ if (err instanceof SchemaStoreBusyError && err.outcome === 'unknown')
466
+ return undefined;
467
+ canopyLogWarn(`CanopyCMS: restored ${collectionPath}/${id} but not its place in the order:`, err);
468
+ return 'Entry restored, but its place in the collection order couldn’t be put back, so it is listed last.';
469
+ }
470
+ };
471
+ /**
472
+ * Puts back an entry deleted on the branch, as the commit the branch forked from holds it, with
473
+ * its content id (`ContentStore.restore`).
474
+ * POST /:branch/restore-entry/...entryPath
475
+ * @internal Exported for tests.
476
+ */
477
+ export const restoreBranchEntry = defineEndpoint({
478
+ namespace: 'changes',
479
+ name: 'restore',
480
+ method: 'POST',
481
+ path: '/:branch/restore-entry/...entryPath',
482
+ params: restoreParamsSchema,
483
+ body: restoreBodySchema,
484
+ bodyType: 'RestoreEntryBody',
485
+ responseType: 'RestoreEntryResponse',
486
+ response: {},
487
+ defaultMockData: { logicalPath: '', contentId: '' },
488
+ guards: ['schema', 'writableBranch'],
489
+ handler: restoreEntryHandler,
490
+ });
258
491
  export const BRANCH_ENTRY_CHANGE_ROUTES = {
259
492
  entry: readBranchEntryChange,
493
+ restore: restoreBranchEntry,
260
494
  };
@@ -6,6 +6,8 @@ import { defineEndpoint } from './route-builder.js';
6
6
  import { syncConvertToDraft } from './github-sync.js';
7
7
  import { syncPrComments } from './pr-comment-sync.js';
8
8
  import { CommentStore } from '../comment-store.js';
9
+ import { assertNoOpenConflicts, OpenMergeConflictsError } from '../merge-conflicts.js';
10
+ import { ContentWriteLockBusyError, withContentWriteLock } from '../utils/content-write-lock.js';
9
11
  import { getErrorMessage } from '../utils/error.js';
10
12
  import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../authorization/review-role.js';
11
13
  import { getBranchProtection, isAdmin } from '../authorization/index.js';
@@ -131,22 +133,52 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
131
133
  }
132
134
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
133
135
  // As for request changes, the status on disk decides, in the step that writes. So does an
134
- // unfinished submit: what's approved may not be what the pull request holds.
136
+ // unfinished submit: what's approved may not be what the pull request holds. So do conflicts: a
137
+ // merge of GitHub's commits can record some on a branch in review, under the content-write lock
138
+ // held here, so none lands between the check and the write.
135
139
  let found;
136
140
  let unfinished = false;
137
141
  let notQueued = false;
138
- const updated = await meta.saveIf({
139
- branch: {
140
- name: branchContext.branch.name,
141
- status: 'approved',
142
- review: reviewBy(req.user, 'approved'),
143
- },
144
- }, (existing) => {
145
- found = existing?.branch.status;
146
- unfinished = existing?.branch.submitUnfinished === true;
147
- notQueued = existing?.branch.submitNotQueued === true;
148
- return found === 'submitted' && !unfinished;
149
- });
142
+ let updated;
143
+ try {
144
+ updated = await withContentWriteLock(branchContext.branchRoot, async () => {
145
+ await assertNoOpenConflicts(branchContext.branchRoot);
146
+ return meta.saveIf({
147
+ branch: {
148
+ name: branchContext.branch.name,
149
+ status: 'approved',
150
+ review: reviewBy(req.user, 'approved'),
151
+ },
152
+ }, (existing) => {
153
+ found = existing?.branch.status;
154
+ unfinished = existing?.branch.submitUnfinished === true;
155
+ notQueued = existing?.branch.submitNotQueued === true;
156
+ return found === 'submitted' && !unfinished;
157
+ });
158
+ });
159
+ }
160
+ catch (err) {
161
+ if (err instanceof OpenMergeConflictsError) {
162
+ return {
163
+ ok: false,
164
+ status: 409,
165
+ code: 'OPEN_CONFLICTS',
166
+ error: err.count === null
167
+ ? err.message
168
+ : `${err.message} Use "Request changes" so its editor can choose what to keep.`,
169
+ };
170
+ }
171
+ if (err instanceof ContentWriteLockBusyError) {
172
+ return {
173
+ ok: false,
174
+ status: 409,
175
+ error: err.outcome === 'not-run'
176
+ ? 'This branch is being synced, or a save is in flight, so it was not approved. Try again in a moment.'
177
+ : 'This branch was being synced while it was approved, so the approval may not have been recorded. Refresh to check.',
178
+ };
179
+ }
180
+ throw err;
181
+ }
150
182
  if (unfinished && found === 'submitted') {
151
183
  return {
152
184
  ok: false,
@@ -4,6 +4,7 @@ import { type BranchListItem, type BranchListItemResponse } from './branch.js';
4
4
  import type { StampedEntry } from '../stamp-on-publish.js';
5
5
  export type { BranchMergeResponse } from './branch-merge.js';
6
6
  export type { RequestChangesBody } from './branch-review.js';
7
+ export type { UpdateFromBaseResponse } from './branch-update-from-base.js';
7
8
  /**
8
9
  * A submit's answer: the branch, plus the entries the submit stamped (stamp-on-publish.ts) when
9
10
  * there were any, so the editor can carry its open forms and drafts onto their new versions.
@@ -74,6 +75,17 @@ export declare const WORKFLOW_ROUTES: {
74
75
  readonly __brand: "BranchName";
75
76
  };
76
77
  }>, undefined, BranchListItemResponse>;
78
+ readonly updateFromBase: import("./route-builder.js").RouteDefinition<z.ZodObject<{
79
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
80
+ }, "strip", z.ZodTypeAny, {
81
+ branch: string & {
82
+ readonly __brand: "BranchName";
83
+ };
84
+ }, {
85
+ branch: string & {
86
+ readonly __brand: "BranchName";
87
+ };
88
+ }>, undefined, import("./branch-update-from-base.js").UpdateFromBaseResponse>;
77
89
  readonly requestChanges: import("./route-builder.js").RouteDefinition<z.ZodObject<{
78
90
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
79
91
  }, "strip", z.ZodTypeAny, {
@@ -3,6 +3,7 @@ import { branchParamSchema } from './validators.js';
3
3
  import { toBranchListItem } from './branch.js';
4
4
  import { getBranchMetadataFileManager } from '../branch-metadata.js';
5
5
  import { withdrawBranch } from './branch-withdraw.js';
6
+ import { updateFromBase } from './branch-update-from-base.js';
6
7
  import { requestChanges, approveBranch } from './branch-review.js';
7
8
  import { markAsMerged } from './branch-merge.js';
8
9
  import { ARCHIVE_BRANCH_ROUTES } from './archived-branches.js';
@@ -14,6 +15,7 @@ import { isNonFastForwardRejection } from '../utils/git.js';
14
15
  import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
15
16
  import { submissionEditorFromUser } from '../submission-attribution.js';
16
17
  import { NothingToSubmitError } from '../services.js';
18
+ import { OpenMergeConflictsError } from '../merge-conflicts.js';
17
19
  import { baseBranchOf } from '../utils/base-branch.js';
18
20
  import { shortNameOf } from '../branch-param.js';
19
21
  import { resolveEditorUrl } from '../editor-url.js';
@@ -118,6 +120,16 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
118
120
  if (err instanceof NothingToSubmitError) {
119
121
  return { ok: false, status: 400, error: err.message };
120
122
  }
123
+ if (err instanceof OpenMergeConflictsError) {
124
+ return {
125
+ ok: false,
126
+ status: 409,
127
+ code: 'OPEN_CONFLICTS',
128
+ error: err.count === null
129
+ ? err.message
130
+ : `${err.message} Choose what to keep in "Show changes", then submit again.`,
131
+ };
132
+ }
121
133
  const shortName = shortNameOf(ctx.services.config, branchContext.branch.name);
122
134
  // Retriable either way: a resubmit commits nothing new and pushes only
123
135
  // what has not reached the remote.
@@ -275,6 +287,7 @@ export const WORKFLOW_ROUTES = {
275
287
  getStatus: getBranchStatus,
276
288
  submit: submitBranchForMerge,
277
289
  withdraw: withdrawBranch,
290
+ updateFromBase,
278
291
  requestChanges: requestChanges,
279
292
  approve: approveBranch,
280
293
  markMerged: markAsMerged,
@@ -0,0 +1,23 @@
1
+ import type { z } from 'zod';
2
+ import type { ApiResponse } from './types.js';
3
+ import { type BranchListItem } from './branch.js';
4
+ /**
5
+ * A request for the latest changes: when it was made, which the editor waits on until the
6
+ * branch's `lastBaseUpdate.at` reaches it, and the branch as it stands with the request recorded.
7
+ */
8
+ export type UpdateFromBaseResponse = ApiResponse<{
9
+ requestedAt: string;
10
+ branch: BranchListItem;
11
+ }>;
12
+ /** Get the latest changes: bring the base branch into this one at the worker's next chance. */
13
+ export declare const updateFromBase: import("./route-builder.js").RouteDefinition<z.ZodObject<{
14
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
15
+ }, "strip", z.ZodTypeAny, {
16
+ branch: string & {
17
+ readonly __brand: "BranchName";
18
+ };
19
+ }, {
20
+ branch: string & {
21
+ readonly __brand: "BranchName";
22
+ };
23
+ }>, undefined, UpdateFromBaseResponse>;