canopycms 0.0.68-int.106 → 0.0.68-int.108
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/dist/ai/generate.js +1 -1
- package/dist/api/admin.d.ts +3 -0
- package/dist/api/admin.js +21 -1
- package/dist/api/branch-status.js +26 -2
- package/dist/api/content.js +55 -6
- package/dist/api/entries.js +27 -1
- package/dist/api/github-sync.d.ts +11 -3
- package/dist/api/github-sync.js +16 -8
- package/dist/api/schema.d.ts +11 -9
- package/dist/api/schema.js +2 -0
- package/dist/api/types.d.ts +7 -0
- package/dist/branch-metadata.d.ts +6 -0
- package/dist/branch-metadata.js +15 -1
- package/dist/branch-schema-cache.d.ts +29 -12
- package/dist/branch-schema-cache.js +64 -21
- package/dist/build/generate-ai-content.js +5 -0
- package/dist/cli/cli.js +523 -154
- package/dist/cli/generate-ai-content.js +451 -114
- package/dist/cli/init.js +37 -12
- package/dist/config/flatten.js +2 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/schemas/field.js +7 -2
- package/dist/config/types.d.ts +36 -6
- package/dist/config/types.js +3 -9
- package/dist/content-listing.d.ts +6 -0
- package/dist/content-listing.js +10 -1
- package/dist/content-reader.js +3 -1
- package/dist/content-store.d.ts +18 -0
- package/dist/content-store.js +43 -5
- package/dist/content-tree.js +2 -2
- package/dist/editor/BranchManager.js +2 -3
- package/dist/editor/Editor.d.ts +5 -1
- package/dist/editor/Editor.js +71 -8
- package/dist/editor/EntryNavigator.d.ts +4 -0
- package/dist/editor/EntryNavigator.js +58 -43
- package/dist/editor/PreviewFrame.d.ts +3 -1
- package/dist/editor/PreviewFrame.js +10 -2
- package/dist/editor/admin/SystemHealthPanel.js +16 -1
- package/dist/editor/components/EditorSidebar.d.ts +3 -1
- package/dist/editor/components/EditorSidebar.js +4 -4
- package/dist/editor/components/EntryCreateModal.d.ts +5 -2
- package/dist/editor/components/EntryCreateModal.js +10 -3
- package/dist/editor/components/ReferencedByList.js +10 -4
- package/dist/editor/components/UnavailableEntryNotice.d.ts +6 -0
- package/dist/editor/components/UnavailableEntryNotice.js +9 -0
- package/dist/editor/components/UnavailableTypeMessage.d.ts +6 -0
- package/dist/editor/components/UnavailableTypeMessage.js +9 -0
- package/dist/editor/editor-config.js +1 -0
- package/dist/editor/editor-utils.js +3 -0
- package/dist/editor/fields/BlockField.js +3 -3
- package/dist/editor/fields/MarkdownField.js +3 -0
- package/dist/editor/fields/markdown-fidelity-visitors.d.ts +11 -0
- package/dist/editor/fields/markdown-fidelity-visitors.js +160 -0
- package/dist/editor/hooks/useCommentSystem.js +31 -16
- package/dist/editor/hooks/useDraftManager.js +24 -4
- package/dist/editor/hooks/useEntryManager.d.ts +10 -1
- package/dist/editor/hooks/useEntryManager.js +18 -3
- package/dist/editor/preview-bridge.d.ts +9 -0
- package/dist/editor/preview-bridge.js +38 -0
- package/dist/editor/unavailable-entry-type.d.ts +20 -0
- package/dist/editor/unavailable-entry-type.js +28 -0
- package/dist/git-manager.d.ts +9 -0
- package/dist/git-manager.js +13 -0
- package/dist/github-service.d.ts +6 -0
- package/dist/github-service.js +20 -0
- package/dist/http/handler.js +7 -1
- package/dist/http/worker-not-ready.d.ts +4 -4
- package/dist/http/worker-not-ready.js +14 -4
- package/dist/reference-resolver.js +1 -0
- package/dist/schema/meta-loader.d.ts +18 -8
- package/dist/schema/meta-loader.js +42 -9
- package/dist/schema/registry-fingerprint.d.ts +14 -0
- package/dist/schema/registry-fingerprint.js +63 -0
- package/dist/schema/resolver.d.ts +6 -3
- package/dist/schema/resolver.js +6 -5
- package/dist/schema/schema-unavailable-error.d.ts +11 -0
- package/dist/schema/schema-unavailable-error.js +15 -0
- package/dist/schema/types.d.ts +22 -0
- package/dist/schema-registry-record.d.ts +35 -0
- package/dist/schema-registry-record.js +134 -0
- package/dist/services.d.ts +10 -1
- package/dist/services.js +33 -17
- package/dist/static/index.d.ts +17 -3
- package/dist/static/index.js +54 -0
- package/dist/task-queue/cms-task-queue.d.ts +1 -1
- package/dist/task-queue/cms-task-queue.js +1 -1
- package/dist/task-queue/index.d.ts +1 -1
- package/dist/task-queue/index.js +1 -1
- package/dist/task-queue/task-queue.d.ts +6 -0
- package/dist/task-queue/task-queue.js +29 -0
- package/dist/task-queue/worker-status.d.ts +9 -9
- package/dist/task-queue/worker-status.js +29 -17
- package/dist/types.d.ts +65 -6
- package/dist/utils/content-serialize.d.ts +4 -1
- package/dist/utils/content-serialize.js +110 -9
- package/dist/utils/json-source-splice.d.ts +14 -0
- package/dist/utils/json-source-splice.js +120 -0
- package/dist/validation/deletion-checker.d.ts +5 -0
- package/dist/validation/deletion-checker.js +19 -5
- package/dist/validation/markdown-safety.d.ts +68 -0
- package/dist/validation/markdown-safety.js +604 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +57 -1
- package/dist/worker/cms-worker.js +166 -31
- package/dist/worker/git-sync.d.ts +7 -5
- package/dist/worker/git-sync.js +76 -8
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +6 -0
- package/dist/worker/schema-gate.d.ts +49 -0
- package/dist/worker/schema-gate.js +226 -0
- package/dist/worker/task-runner.d.ts +2 -2
- package/dist/worker/task-runner.js +141 -47
- package/dist/worker/worker-context.d.ts +18 -3
- package/package.json +2 -1
package/dist/ai/generate.js
CHANGED
|
@@ -177,7 +177,7 @@ async function collectEntries(read, collection, cleanPath) {
|
|
|
177
177
|
if (config?.exclude?.entryTypes?.includes(entryTypeName))
|
|
178
178
|
continue;
|
|
179
179
|
const entryTypeConfig = findEntryType(collection, entryTypeName);
|
|
180
|
-
if (!entryTypeConfig)
|
|
180
|
+
if (!entryTypeConfig || entryTypeConfig.unavailable)
|
|
181
181
|
continue;
|
|
182
182
|
try {
|
|
183
183
|
const doc = await store.read(listEntry.collection, listEntry.slug, {
|
package/dist/api/admin.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { ApiResponse } from './types.js';
|
|
|
10
10
|
import type { Task, QueueStats, CorruptTaskFile } from '../task-queue/cms-task-queue.js';
|
|
11
11
|
import type { BuildIdentity, WorkerStatusReport } from '../types.js';
|
|
12
12
|
import type { OperatingMode } from '../operating-mode/index.js';
|
|
13
|
+
import type { SchemaIssue } from '../schema/types.js';
|
|
13
14
|
export type { BranchHealthResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse, } from './admin-branch-health.js';
|
|
14
15
|
type WorkerLivenessState = 'alive' | 'stale' | 'absent';
|
|
15
16
|
export interface WorkerLiveness {
|
|
@@ -37,6 +38,8 @@ export interface AdminStatusData {
|
|
|
37
38
|
* /admin.
|
|
38
39
|
*/
|
|
39
40
|
settingsWorkspaceError?: string;
|
|
41
|
+
/** What the base branch's schema leaves out under this process's code; absent when nothing. */
|
|
42
|
+
schemaIssues?: SchemaIssue[];
|
|
40
43
|
/** Build of the API process answering this request. */
|
|
41
44
|
build: BuildIdentity;
|
|
42
45
|
/** Whether `media` is configured; without it every upload returns 501. */
|
package/dist/api/admin.js
CHANGED
|
@@ -83,6 +83,24 @@ async function readSettingsWorkspaceError(ctx) {
|
|
|
83
83
|
return redactCredentials(getErrorMessage(err));
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* What the base branch's schema leaves out under this process's registry (see
|
|
88
|
+
* `BranchSchemaCache`). Empty when the probe cannot resolve it: that failure reaches the
|
|
89
|
+
* editor through every schema-guarded route anyway.
|
|
90
|
+
*/
|
|
91
|
+
async function readBaseSchemaIssues(ctx) {
|
|
92
|
+
try {
|
|
93
|
+
const { config, branchSchemaCache, entrySchemaRegistry } = ctx.services;
|
|
94
|
+
const base = await ctx.getBranchContext(config.defaultBaseBranch ?? 'main');
|
|
95
|
+
if (!base)
|
|
96
|
+
return [];
|
|
97
|
+
const { issues } = await branchSchemaCache.getSchema(base.branchRoot, entrySchemaRegistry, config.contentRoot || 'content');
|
|
98
|
+
return issues;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
86
104
|
/** Age (ms) of the oldest file in pending/, or undefined if empty/missing. */
|
|
87
105
|
async function getOldestPendingAgeMs(taskDir) {
|
|
88
106
|
const pendingDir = path.join(taskDir, 'pending');
|
|
@@ -147,13 +165,14 @@ async function probeImageProcessing() {
|
|
|
147
165
|
const getAdminStatusHandler = async (_gc, ctx, _req) => {
|
|
148
166
|
const taskDir = getTaskQueueDir(ctx.services.config);
|
|
149
167
|
try {
|
|
150
|
-
const [queueStats, oldestPendingAgeMs, worker, { workerStatus, statusReadError }, settingsWorkspaceError, imageProcessing,] = await Promise.all([
|
|
168
|
+
const [queueStats, oldestPendingAgeMs, worker, { workerStatus, statusReadError }, settingsWorkspaceError, imageProcessing, schemaIssues,] = await Promise.all([
|
|
151
169
|
getQueueStats(taskDir),
|
|
152
170
|
getOldestPendingAgeMs(taskDir),
|
|
153
171
|
classifyWorkerLiveness(taskDir),
|
|
154
172
|
readWorkerStatus(taskDir),
|
|
155
173
|
readSettingsWorkspaceError(ctx),
|
|
156
174
|
probeImageProcessing(),
|
|
175
|
+
readBaseSchemaIssues(ctx),
|
|
157
176
|
]);
|
|
158
177
|
return {
|
|
159
178
|
ok: true,
|
|
@@ -169,6 +188,7 @@ const getAdminStatusHandler = async (_gc, ctx, _req) => {
|
|
|
169
188
|
workerStatus,
|
|
170
189
|
...(statusReadError ? { statusReadError } : {}),
|
|
171
190
|
...(settingsWorkspaceError ? { settingsWorkspaceError } : {}),
|
|
191
|
+
...(schemaIssues.length > 0 ? { schemaIssues } : {}),
|
|
172
192
|
build: getBuildIdentity(),
|
|
173
193
|
assetStore: { configured: !!ctx.assetStore },
|
|
174
194
|
imageProcessing,
|
|
@@ -11,6 +11,7 @@ import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../uti
|
|
|
11
11
|
import { isNonFastForwardRejection } from '../utils/git.js';
|
|
12
12
|
import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
|
|
13
13
|
import { submissionEditorFromUser } from '../submission-attribution.js';
|
|
14
|
+
import { NothingToSubmitError } from '../services.js';
|
|
14
15
|
const getBranchStatusHandler = async (gc, _ctx, _req, _params) => {
|
|
15
16
|
const { branchContext } = gc;
|
|
16
17
|
return { ok: true, status: 200, data: { branch: branchContext.branch } };
|
|
@@ -59,6 +60,9 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
59
60
|
({ changedPaths } = await ctx.services.submitBranch({ context: branchContext, submitter }));
|
|
60
61
|
}
|
|
61
62
|
catch (err) {
|
|
63
|
+
if (err instanceof NothingToSubmitError) {
|
|
64
|
+
return { ok: false, status: 400, error: err.message };
|
|
65
|
+
}
|
|
62
66
|
// Retriable either way: a resubmit commits nothing new and pushes only
|
|
63
67
|
// what has not reached the remote.
|
|
64
68
|
if (err instanceof ContentWriteLockBusyError) {
|
|
@@ -107,13 +111,33 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
107
111
|
};
|
|
108
112
|
}
|
|
109
113
|
// Create or update PR (sync via githubService, or async via task queue)
|
|
110
|
-
const
|
|
111
|
-
|
|
114
|
+
const submittedAt = new Date().toISOString();
|
|
115
|
+
const prResult = await syncSubmitPr(ctx, branchContext, {
|
|
116
|
+
submitter,
|
|
117
|
+
changedPaths,
|
|
118
|
+
submittedAt,
|
|
119
|
+
});
|
|
112
120
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
121
|
+
const pushed = prResult.pushedToGitHub ? { pushedToGitHubAt: submittedAt } : {};
|
|
122
|
+
// GitHub found nothing between the pushed branch and its base, though the diff above found
|
|
123
|
+
// changes: GitHub's base can already hold them, or the diff could not be computed. The branch
|
|
124
|
+
// stays editable; the stamp records that it is now on GitHub, for delete.
|
|
125
|
+
if (prResult.nothingToSubmit) {
|
|
126
|
+
await meta.save({ branch: { name: branchContext.branch.name, ...pushed } });
|
|
127
|
+
const base = branchContext.branch.baseBranch ?? ctx.services.config.defaultBaseBranch ?? 'main';
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
status: 400,
|
|
131
|
+
error: new NothingToSubmitError(branchContext.branch.name, base).message,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
113
134
|
const updated = await meta.save({
|
|
114
135
|
branch: {
|
|
115
136
|
name: branchContext.branch.name,
|
|
116
137
|
status: 'submitted',
|
|
138
|
+
submittedAt,
|
|
139
|
+
...pushed,
|
|
140
|
+
syncFailureReason: prResult.syncFailureReason,
|
|
117
141
|
pullRequestUrl: prResult.prUrl ?? branchContext.branch.pullRequestUrl,
|
|
118
142
|
pullRequestNumber: prResult.prNumber ?? branchContext.branch.pullRequestNumber,
|
|
119
143
|
...(prResult.syncStatus !== undefined ? { syncStatus: prResult.syncStatus } : {}),
|
package/dist/api/content.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { BranchSyncingError, ContentStore, ContentStoreError, ContentConflictError, DuplicateContentIdError, UrlPathConflictError, getDefaultEntryType, } from '../content-store.js';
|
|
3
3
|
import { defineEndpoint } from './route-builder.js';
|
|
4
|
+
import { SchemaUnavailableError } from '../schema/schema-unavailable-error.js';
|
|
4
5
|
import { ReferenceValidator } from '../validation/reference-validator.js';
|
|
5
6
|
import { findUnknownKeys, mergeBodyIntoData, normalizeReferenceValues, validateEntryData, } from '../validation/entry-validator.js';
|
|
6
7
|
import { validateEntryLinks } from '../validation/entry-link-validator.js';
|
|
8
|
+
import { findMarkdownSafetyIssues, splitByStored } from '../validation/markdown-safety.js';
|
|
7
9
|
import { collectReferenceIds } from '../validation/field-traversal.js';
|
|
8
10
|
import { branchNameSchema, logicalPathSchema, slugSchema } from './validators.js';
|
|
9
11
|
import { entryLogicalPath, parseSlug } from '../paths/index.js';
|
|
10
12
|
import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
|
|
11
13
|
import { isDataOnlyFormat } from '../utils/format.js';
|
|
14
|
+
import { findBodyFieldName } from '../utils/body-field.js';
|
|
12
15
|
/**
|
|
13
16
|
* Parse an API path into logical path segments, prepending the content root if needed.
|
|
14
17
|
* Shared by all content API handlers.
|
|
@@ -122,6 +125,23 @@ const storedReferenceSites = async (store, collectionPath, slug, fields) => {
|
|
|
122
125
|
const doc = await store.read(collectionPath, slug, { resolveReferences: false });
|
|
123
126
|
return new Set(collectReferenceIds(fields, doc.data).map((ref) => referenceSite(ref.path, ref.id)));
|
|
124
127
|
};
|
|
128
|
+
function withoutKey(record, key) {
|
|
129
|
+
if (!(key in record))
|
|
130
|
+
return record;
|
|
131
|
+
const { [key]: _dropped, ...rest } = record;
|
|
132
|
+
return rest;
|
|
133
|
+
}
|
|
134
|
+
/** The policy's issues in the stored entry, which a save may keep. */
|
|
135
|
+
const storedMarkdownSafetyIssues = async (store, collectionPath, slug, fields) => {
|
|
136
|
+
// A stored file that does not read keeps nothing, so the save's code is refused, not a 500.
|
|
137
|
+
const doc = await store
|
|
138
|
+
.read(collectionPath, slug, { resolveReferences: false })
|
|
139
|
+
.catch(() => undefined);
|
|
140
|
+
if (doc === undefined)
|
|
141
|
+
return [];
|
|
142
|
+
const data = 'body' in doc ? mergeBodyIntoData(fields, doc.data, doc.body) : doc.data;
|
|
143
|
+
return findMarkdownSafetyIssues(fields, doc.format, data);
|
|
144
|
+
};
|
|
125
145
|
const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
126
146
|
const { branchContext } = gc;
|
|
127
147
|
const flatSchema = branchContext.flatSchema;
|
|
@@ -210,15 +230,25 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
210
230
|
fields = entryTypeConfig?.schema ?? [];
|
|
211
231
|
maxItems = entryTypeConfig?.maxItems;
|
|
212
232
|
}
|
|
213
|
-
|
|
233
|
+
// Refused before validation, which an empty `fields` would wave through; mapped to a 503.
|
|
234
|
+
const resolvedEntryType = schemaItem.type === 'entry-type' ? schemaItem : entryTypeConfig;
|
|
235
|
+
if (resolvedEntryType?.unavailable) {
|
|
236
|
+
throw new SchemaUnavailableError(resolvedEntryType.name, resolvedEntryType.unavailable);
|
|
237
|
+
}
|
|
214
238
|
const isDataOnly = isDataOnlyFormat(body.format);
|
|
239
|
+
// An md/mdx file's body field is its body, so a frontmatter key of that name is dropped: no
|
|
240
|
+
// check would see it, and a read or a resolved reference would serve it as the body.
|
|
241
|
+
const requestData = isDataOnly || body.data === undefined
|
|
242
|
+
? body.data
|
|
243
|
+
: withoutKey(body.data, findBodyFieldName(fields));
|
|
244
|
+
const data = requestData ?? {};
|
|
215
245
|
// The store's own `undefined` (blind write) is never reachable from here: an omitted token is
|
|
216
246
|
// a create, so an update that lost its token 409s instead of overwriting unchecked.
|
|
217
247
|
const expectedVersion = body.expectedVersion ?? null;
|
|
218
248
|
const createConflictError = body.expectedVersion === null
|
|
219
249
|
? `An entry with slug "${slug}" already exists`
|
|
220
250
|
: `An entry with slug "${slug}" already exists; an update must send the expectedVersion from its last read`;
|
|
221
|
-
const
|
|
251
|
+
const keptWarnings = [];
|
|
222
252
|
try {
|
|
223
253
|
const exists = await store.documentExists(schemaItem.logicalPath, slug);
|
|
224
254
|
// Create guard: a create against a slug that already has content must never silently
|
|
@@ -270,6 +300,23 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
270
300
|
// as the schema's isBody field.
|
|
271
301
|
const dataForValidation = isDataOnly ? data : mergeBodyIntoData(fields, data, body.body ?? '');
|
|
272
302
|
const fieldErrors = validateEntryData(fields, dataForValidation);
|
|
303
|
+
// Code in markdown or MDX (validation/markdown-safety.ts). A field the stored entry held
|
|
304
|
+
// with code is kept, with a warning, when saved unchanged; any other code is refused.
|
|
305
|
+
const unsafe = findMarkdownSafetyIssues(fields, body.format, dataForValidation);
|
|
306
|
+
if (unsafe.length > 0) {
|
|
307
|
+
const stored = exists
|
|
308
|
+
? await storedMarkdownSafetyIssues(store, schemaItem.logicalPath, slug, fields)
|
|
309
|
+
: [];
|
|
310
|
+
const { refused, kept } = splitByStored(unsafe, stored);
|
|
311
|
+
fieldErrors.push(...refused);
|
|
312
|
+
for (const e of kept) {
|
|
313
|
+
keptWarnings.push({
|
|
314
|
+
level: 'warning',
|
|
315
|
+
fieldPath: e.fieldPath,
|
|
316
|
+
message: `holds code that runs when the page renders, kept because the saved entry already had it: ${e.message}. Ask a developer to move it into a component.`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
273
320
|
// Reference existence (server-only: reads the content ID index). Editor
|
|
274
321
|
// payloads may still carry resolved `{ id, ... }` objects from a prior
|
|
275
322
|
// read, so collapse them to id strings before checking.
|
|
@@ -286,7 +333,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
286
333
|
: new Set();
|
|
287
334
|
for (const e of refResult.errors) {
|
|
288
335
|
if (e.dangling && storedSites.has(referenceSite(e.fieldPath, e.id))) {
|
|
289
|
-
|
|
336
|
+
keptWarnings.push({
|
|
290
337
|
level: 'warning',
|
|
291
338
|
fieldPath: e.fieldPath,
|
|
292
339
|
message: `references a missing entry (${e.id}). It is kept as it was; repoint or clear it.`,
|
|
@@ -317,7 +364,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
317
364
|
// Adopter save-time validation, run BEFORE the file is written: 'error' issues
|
|
318
365
|
// refuse the save (e.g. a body that would break the site's production build),
|
|
319
366
|
// 'warning' issues are returned alongside the successful write.
|
|
320
|
-
let validationWarnings =
|
|
367
|
+
let validationWarnings = keptWarnings.length > 0 ? keptWarnings : undefined;
|
|
321
368
|
const validateEntry = ctx.services.config.validateEntry;
|
|
322
369
|
// Collapse resolved reference objects back to bare ID strings before persisting (the reference
|
|
323
370
|
// validator above gets its own copy). The editor's GET resolves references by default, so form
|
|
@@ -325,7 +372,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
325
372
|
// left unnormalized, a save would freeze that snapshot into the file (resolution only
|
|
326
373
|
// re-resolves plain ID strings), permanently severing the reference from its target. Idempotent:
|
|
327
374
|
// a payload that already holds ID strings is unchanged.
|
|
328
|
-
const normalizedData =
|
|
375
|
+
const normalizedData = requestData === undefined ? undefined : normalizeReferenceValues(fields, requestData);
|
|
329
376
|
// Computed before the validateEntry hook and the entry-link scan too, not just the write, so
|
|
330
377
|
// every consumer agrees on the same bytes — otherwise an adopter's hook could see a resolved
|
|
331
378
|
// object while the file got an ID string, depending on whether the post came from the editor.
|
|
@@ -360,7 +407,9 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
360
407
|
issues = await validateEntry({
|
|
361
408
|
entryPath: logicalPathSegments.join('/'),
|
|
362
409
|
branch: params.branch,
|
|
363
|
-
|
|
410
|
+
// The type the store writes with, so a request that omits `entryType` can't skip a
|
|
411
|
+
// type-gated rule.
|
|
412
|
+
...(entryTypeName ? { entryType: entryTypeName } : {}),
|
|
364
413
|
format: body.format,
|
|
365
414
|
data: normalizedData ?? {},
|
|
366
415
|
body: body.body,
|
package/dist/api/entries.js
CHANGED
|
@@ -202,6 +202,16 @@ const deleteEntryParamsSchema = z.object({
|
|
|
202
202
|
/** Delete even though other entries reference this one (query param). */
|
|
203
203
|
confirmReferenced: queryBooleanSchema.optional(),
|
|
204
204
|
});
|
|
205
|
+
/** Every entry of an `unavailable` entry type on the branch. */
|
|
206
|
+
const listUnavailableEntries = async (branchRoot, flatSchema) => {
|
|
207
|
+
const collections = flatSchema.filter((item) => item.type === 'collection' && item.entries?.some((e) => e.unavailable));
|
|
208
|
+
const listed = await Promise.all(collections.map((collection) => listCollectionEntriesShared(branchRoot, collection)));
|
|
209
|
+
return listed.flat().filter((item) => {
|
|
210
|
+
const collection = collections.find((c) => c.logicalPath === item.collectionPath);
|
|
211
|
+
return (collection?.type === 'collection' &&
|
|
212
|
+
collection.entries?.some((e) => e.name === item.entryType && e.unavailable));
|
|
213
|
+
});
|
|
214
|
+
};
|
|
205
215
|
/**
|
|
206
216
|
* Every entry on the branch that references `contentId`, split by whether `user` may read it.
|
|
207
217
|
* A full raw scan of the branch's content: references are stored only on the referencing
|
|
@@ -209,7 +219,21 @@ const deleteEntryParamsSchema = z.object({
|
|
|
209
219
|
*/
|
|
210
220
|
const findReferencedBy = async (branchContext, ctx, req, contentId) => {
|
|
211
221
|
const { branchRoot, flatSchema } = branchContext;
|
|
212
|
-
const
|
|
222
|
+
const listed = await listContentEntries(branchRoot, flatSchema, ctx.services.config.contentRoot || 'content');
|
|
223
|
+
// listEntries leaves out entries of an unavailable type; scanned raw, they still count.
|
|
224
|
+
const unavailable = await listUnavailableEntries(branchRoot, flatSchema);
|
|
225
|
+
const entries = [
|
|
226
|
+
...listed,
|
|
227
|
+
...unavailable.map((item) => ({
|
|
228
|
+
entryPath: item.logicalPath,
|
|
229
|
+
entryId: item.contentId,
|
|
230
|
+
slug: item.slug,
|
|
231
|
+
collectionPath: item.collectionPath,
|
|
232
|
+
entryType: item.entryType,
|
|
233
|
+
data: item.data,
|
|
234
|
+
schemaUnavailable: true,
|
|
235
|
+
})),
|
|
236
|
+
];
|
|
213
237
|
const referencing = findReferencingEntries(entries, contentId);
|
|
214
238
|
if (referencing.length === 0)
|
|
215
239
|
return null;
|
|
@@ -312,6 +336,8 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
|
|
|
312
336
|
error: 'Edit permission required to delete entry',
|
|
313
337
|
};
|
|
314
338
|
}
|
|
339
|
+
// Before the reference scan, whose 409 would otherwise answer first; mapped to a 503.
|
|
340
|
+
await contentStore.assertEntryAvailable(collectionLogicalPath, entrySlug);
|
|
315
341
|
let orderCleanupWarning;
|
|
316
342
|
try {
|
|
317
343
|
// Get the entry's content ID before deleting (for order update)
|
|
@@ -8,11 +8,19 @@ export interface GitHubSyncResult {
|
|
|
8
8
|
prUrl?: string;
|
|
9
9
|
prNumber?: number;
|
|
10
10
|
syncStatus?: SyncStatus;
|
|
11
|
+
/** Client-safe reason a direct GitHub call failed, recorded as the branch's syncFailureReason. */
|
|
12
|
+
syncFailureReason?: string;
|
|
13
|
+
/** GitHub refused the PR because the pushed branch has no commits its base lacks. */
|
|
14
|
+
nothingToSubmit?: boolean;
|
|
15
|
+
/** The direct path, whose submit pushes to GitHub itself: the branch is there whatever the PR call did. */
|
|
16
|
+
pushedToGitHub?: boolean;
|
|
11
17
|
}
|
|
12
18
|
/** What the PR body records about a submit. */
|
|
13
19
|
export interface SubmissionRecord {
|
|
14
20
|
submitter?: SubmissionEditor;
|
|
15
21
|
changedPaths: readonly string[];
|
|
22
|
+
/** The submit's `submittedAt`, carried to the worker so its failure handling targets this submit. */
|
|
23
|
+
submittedAt?: string;
|
|
16
24
|
}
|
|
17
25
|
/**
|
|
18
26
|
* Uses githubService directly if available, otherwise queues a task for the worker.
|
|
@@ -28,9 +36,9 @@ export declare function syncSubmitPr(ctx: ApiContext, context: BranchContext, su
|
|
|
28
36
|
export declare function syncConvertToDraft(ctx: ApiContext, context: BranchContext): Promise<void>;
|
|
29
37
|
/**
|
|
30
38
|
* Used by delete, after the local delete succeeded. Deletes the branch on GitHub when the CMS
|
|
31
|
-
* put it there: a
|
|
32
|
-
*
|
|
33
|
-
* else's (the create-time collision check is best-effort) and is never touched.
|
|
39
|
+
* put it there: a recorded PR number or `pushedToGitHubAt` stamp is the proof (a submit whose PR
|
|
40
|
+
* GitHub refused pushed the branch but has no PR number). Without either, a same-named GitHub
|
|
41
|
+
* branch is someone else's (the create-time collision check is best-effort) and is never touched.
|
|
34
42
|
*
|
|
35
43
|
* Returns a client-facing warning when the delete could not be done or queued; never throws.
|
|
36
44
|
*/
|
package/dist/api/github-sync.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { enqueueTask } from '../task-queue/cms-task-queue.js';
|
|
2
2
|
import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
|
|
3
3
|
import { clientOperatingStrategy } from '../operating-mode/index.js';
|
|
4
|
-
import { getErrorMessage } from '../utils/error.js';
|
|
4
|
+
import { getErrorMessage, sanitizeErrorMessage } from '../utils/error.js';
|
|
5
5
|
import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
6
6
|
import { buildPrSection, mergePrSection } from '../submission-attribution.js';
|
|
7
|
-
import { isRefAlreadyGoneError } from '../github-service.js';
|
|
7
|
+
import { isNoCommitsBetweenError, isRefAlreadyGoneError } from '../github-service.js';
|
|
8
8
|
/**
|
|
9
9
|
* Uses githubService directly if available, otherwise queues a task for the worker.
|
|
10
10
|
*
|
|
@@ -65,6 +65,7 @@ export async function syncSubmitPr(ctx, context, submission) {
|
|
|
65
65
|
prUrl: context.branch.pullRequestUrl,
|
|
66
66
|
prNumber: context.branch.pullRequestNumber,
|
|
67
67
|
syncStatus: 'synced',
|
|
68
|
+
pushedToGitHub: true,
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
else {
|
|
@@ -92,16 +93,21 @@ export async function syncSubmitPr(ctx, context, submission) {
|
|
|
92
93
|
prUrl: result.url,
|
|
93
94
|
prNumber: result.number,
|
|
94
95
|
syncStatus: 'synced',
|
|
96
|
+
pushedToGitHub: true,
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
catch (err) {
|
|
99
|
-
const message = err
|
|
101
|
+
const message = getErrorMessage(err);
|
|
100
102
|
console.error(`CanopyCMS: Failed to create/update PR for ${context.branch.name}:`, message);
|
|
103
|
+
if (isNoCommitsBetweenError(err))
|
|
104
|
+
return { nothingToSubmit: true, pushedToGitHub: true };
|
|
101
105
|
return {
|
|
102
106
|
prUrl: context.branch.pullRequestUrl,
|
|
103
107
|
prNumber: context.branch.pullRequestNumber,
|
|
104
108
|
syncStatus: 'sync-failed',
|
|
109
|
+
syncFailureReason: sanitizeErrorMessage(message),
|
|
110
|
+
pushedToGitHub: true,
|
|
105
111
|
};
|
|
106
112
|
}
|
|
107
113
|
}
|
|
@@ -124,6 +130,7 @@ export async function syncSubmitPr(ctx, context, submission) {
|
|
|
124
130
|
// A content submit is an explicit "ready for review" action: convert a
|
|
125
131
|
// pre-existing draft PR to ready.
|
|
126
132
|
markReadyIfDraft: true,
|
|
133
|
+
submittedAt: submission.submittedAt,
|
|
127
134
|
},
|
|
128
135
|
});
|
|
129
136
|
}
|
|
@@ -159,14 +166,15 @@ export async function syncConvertToDraft(ctx, context) {
|
|
|
159
166
|
}
|
|
160
167
|
/**
|
|
161
168
|
* Used by delete, after the local delete succeeded. Deletes the branch on GitHub when the CMS
|
|
162
|
-
* put it there: a
|
|
163
|
-
*
|
|
164
|
-
* else's (the create-time collision check is best-effort) and is never touched.
|
|
169
|
+
* put it there: a recorded PR number or `pushedToGitHubAt` stamp is the proof (a submit whose PR
|
|
170
|
+
* GitHub refused pushed the branch but has no PR number). Without either, a same-named GitHub
|
|
171
|
+
* branch is someone else's (the create-time collision check is best-effort) and is never touched.
|
|
165
172
|
*
|
|
166
173
|
* Returns a client-facing warning when the delete could not be done or queued; never throws.
|
|
167
174
|
*/
|
|
168
175
|
export async function syncDeleteRemoteBranch(ctx, context) {
|
|
169
|
-
|
|
176
|
+
const { pullRequestNumber, pushedToGitHubAt } = context.branch;
|
|
177
|
+
if (!pullRequestNumber && !pushedToGitHubAt)
|
|
170
178
|
return undefined;
|
|
171
179
|
const { githubService } = ctx.services;
|
|
172
180
|
const branch = context.branch.name;
|
|
@@ -187,7 +195,7 @@ export async function syncDeleteRemoteBranch(ctx, context) {
|
|
|
187
195
|
return undefined;
|
|
188
196
|
const result = await enqueueGitHubTask(ctx, context, {
|
|
189
197
|
action: 'delete-remote-branch',
|
|
190
|
-
payload: { branch, pullRequestNumber
|
|
198
|
+
payload: { branch, pullRequestNumber, pushedToGitHubAt },
|
|
191
199
|
});
|
|
192
200
|
return result.syncStatus === 'sync-failed'
|
|
193
201
|
? 'Deleting the branch on GitHub could not be queued; delete it there by hand'
|
package/dist/api/schema.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import type { ApiResponse } from './types.js';
|
|
9
|
-
import type { ContentFormat, EntrySchema } from '../config/index.js';
|
|
9
|
+
import type { EntryTypeUnavailable, ContentFormat, EntrySchema } from '../config/index.js';
|
|
10
10
|
import { type LogicalPath, type ContentId } from '../paths/index.js';
|
|
11
11
|
/** Entry type in wire format: schemaRef instead of resolved schema */
|
|
12
12
|
interface WireEntryType {
|
|
@@ -16,6 +16,7 @@ interface WireEntryType {
|
|
|
16
16
|
readonly label?: string;
|
|
17
17
|
readonly default?: boolean;
|
|
18
18
|
readonly maxItems?: number;
|
|
19
|
+
readonly unavailable?: EntryTypeUnavailable;
|
|
19
20
|
}
|
|
20
21
|
/** Collection in wire format (entry types carry schemaRef, not resolved schema) */
|
|
21
22
|
interface WireCollectionConfig {
|
|
@@ -52,6 +53,7 @@ type WireFlatSchemaItem = {
|
|
|
52
53
|
schemaRef: string;
|
|
53
54
|
default?: boolean;
|
|
54
55
|
maxItems?: number;
|
|
56
|
+
unavailable?: EntryTypeUnavailable;
|
|
55
57
|
};
|
|
56
58
|
interface SchemaResponse {
|
|
57
59
|
flatSchema: WireFlatSchemaItem[];
|
|
@@ -339,18 +341,18 @@ export declare const updateEntryType: import("./route-builder.js").RouteDefiniti
|
|
|
339
341
|
branch: string & {
|
|
340
342
|
readonly __brand: "BranchName";
|
|
341
343
|
};
|
|
342
|
-
entryTypeName: string;
|
|
343
344
|
collectionPath: string & {
|
|
344
345
|
readonly __brand: "LogicalPath";
|
|
345
346
|
};
|
|
347
|
+
entryTypeName: string;
|
|
346
348
|
}, {
|
|
347
349
|
branch: string & {
|
|
348
350
|
readonly __brand: "BranchName";
|
|
349
351
|
};
|
|
350
|
-
entryTypeName: string;
|
|
351
352
|
collectionPath: string & {
|
|
352
353
|
readonly __brand: "LogicalPath";
|
|
353
354
|
};
|
|
355
|
+
entryTypeName: string;
|
|
354
356
|
}>, z.ZodObject<{
|
|
355
357
|
label: z.ZodOptional<z.ZodString>;
|
|
356
358
|
format: z.ZodOptional<z.ZodEnum<["md", "mdx", "json", "yaml"]>>;
|
|
@@ -383,18 +385,18 @@ export declare const removeEntryType: import("./route-builder.js").RouteDefiniti
|
|
|
383
385
|
branch: string & {
|
|
384
386
|
readonly __brand: "BranchName";
|
|
385
387
|
};
|
|
386
|
-
entryTypeName: string;
|
|
387
388
|
collectionPath: string & {
|
|
388
389
|
readonly __brand: "LogicalPath";
|
|
389
390
|
};
|
|
391
|
+
entryTypeName: string;
|
|
390
392
|
}, {
|
|
391
393
|
branch: string & {
|
|
392
394
|
readonly __brand: "BranchName";
|
|
393
395
|
};
|
|
394
|
-
entryTypeName: string;
|
|
395
396
|
collectionPath: string & {
|
|
396
397
|
readonly __brand: "LogicalPath";
|
|
397
398
|
};
|
|
399
|
+
entryTypeName: string;
|
|
398
400
|
}>, undefined, RemoveEntryTypeApiResponse>;
|
|
399
401
|
/**
|
|
400
402
|
* PATCH /:branch/schema/order/...collectionPath - Update item order
|
|
@@ -616,18 +618,18 @@ export declare const SCHEMA_ROUTES: {
|
|
|
616
618
|
branch: string & {
|
|
617
619
|
readonly __brand: "BranchName";
|
|
618
620
|
};
|
|
619
|
-
entryTypeName: string;
|
|
620
621
|
collectionPath: string & {
|
|
621
622
|
readonly __brand: "LogicalPath";
|
|
622
623
|
};
|
|
624
|
+
entryTypeName: string;
|
|
623
625
|
}, {
|
|
624
626
|
branch: string & {
|
|
625
627
|
readonly __brand: "BranchName";
|
|
626
628
|
};
|
|
627
|
-
entryTypeName: string;
|
|
628
629
|
collectionPath: string & {
|
|
629
630
|
readonly __brand: "LogicalPath";
|
|
630
631
|
};
|
|
632
|
+
entryTypeName: string;
|
|
631
633
|
}>, z.ZodObject<{
|
|
632
634
|
label: z.ZodOptional<z.ZodString>;
|
|
633
635
|
format: z.ZodOptional<z.ZodEnum<["md", "mdx", "json", "yaml"]>>;
|
|
@@ -655,18 +657,18 @@ export declare const SCHEMA_ROUTES: {
|
|
|
655
657
|
branch: string & {
|
|
656
658
|
readonly __brand: "BranchName";
|
|
657
659
|
};
|
|
658
|
-
entryTypeName: string;
|
|
659
660
|
collectionPath: string & {
|
|
660
661
|
readonly __brand: "LogicalPath";
|
|
661
662
|
};
|
|
663
|
+
entryTypeName: string;
|
|
662
664
|
}, {
|
|
663
665
|
branch: string & {
|
|
664
666
|
readonly __brand: "BranchName";
|
|
665
667
|
};
|
|
666
|
-
entryTypeName: string;
|
|
667
668
|
collectionPath: string & {
|
|
668
669
|
readonly __brand: "LogicalPath";
|
|
669
670
|
};
|
|
671
|
+
entryTypeName: string;
|
|
670
672
|
}>, undefined, RemoveEntryTypeApiResponse>;
|
|
671
673
|
readonly updateOrder: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
672
674
|
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
package/dist/api/schema.js
CHANGED
|
@@ -57,6 +57,7 @@ function toWireEntryType(et, registry) {
|
|
|
57
57
|
...(et.label !== undefined && { label: et.label }),
|
|
58
58
|
...(et.default !== undefined && { default: et.default }),
|
|
59
59
|
...(et.maxItems !== undefined && { maxItems: et.maxItems }),
|
|
60
|
+
...(et.unavailable && { unavailable: et.unavailable }),
|
|
60
61
|
};
|
|
61
62
|
}
|
|
62
63
|
function toWireCollection(col, registry) {
|
|
@@ -99,6 +100,7 @@ function toWireFlatSchema(items, registry) {
|
|
|
99
100
|
schemaRef: resolveSchemaRef(item, registry),
|
|
100
101
|
...(item.default !== undefined && { default: item.default }),
|
|
101
102
|
...(item.maxItems !== undefined && { maxItems: item.maxItems }),
|
|
103
|
+
...(item.unavailable && { unavailable: item.unavailable }),
|
|
102
104
|
};
|
|
103
105
|
});
|
|
104
106
|
}
|
package/dist/api/types.d.ts
CHANGED
|
@@ -48,4 +48,11 @@ export interface ApiResponse<TData = unknown> {
|
|
|
48
48
|
fieldPath: string;
|
|
49
49
|
message: string;
|
|
50
50
|
}>;
|
|
51
|
+
/** A machine-readable reason for some failures; see {@link ApiErrorCode}. */
|
|
52
|
+
code?: ApiErrorCode;
|
|
51
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* `SCHEMA_UNAVAILABLE`: the entry's type names an entry schema the running code lacks (a 503,
|
|
56
|
+
* see `SchemaUnavailableError`).
|
|
57
|
+
*/
|
|
58
|
+
export type ApiErrorCode = 'SCHEMA_UNAVAILABLE';
|
|
@@ -68,6 +68,12 @@ export declare class BranchMetadataFileManager {
|
|
|
68
68
|
* creates only its own directory (`withOccFileLock`), so the save fails there.
|
|
69
69
|
*/
|
|
70
70
|
save(incoming: BranchMetadataUpdate): Promise<BranchMetadataFile>;
|
|
71
|
+
/**
|
|
72
|
+
* {@link save}, applied only when `onlyIf` accepts the metadata on disk; resolves null when it
|
|
73
|
+
* does not, having written nothing. `onlyIf` runs inside the lock stack against the version
|
|
74
|
+
* the write is checked against, so the decision and the write are one compare-and-set.
|
|
75
|
+
*/
|
|
76
|
+
saveIf(incoming: BranchMetadataUpdate, onlyIf: (existing: BranchMetadataFile | null) => boolean): Promise<BranchMetadataFile | null>;
|
|
71
77
|
/** Invalidate the registry cache so the next list() regenerates. */
|
|
72
78
|
private invalidateRegistry;
|
|
73
79
|
}
|
package/dist/branch-metadata.js
CHANGED
|
@@ -106,6 +106,17 @@ export class BranchMetadataFileManager {
|
|
|
106
106
|
* creates only its own directory (`withOccFileLock`), so the save fails there.
|
|
107
107
|
*/
|
|
108
108
|
async save(incoming) {
|
|
109
|
+
const saved = await this.saveIf(incoming, () => true);
|
|
110
|
+
if (saved === null)
|
|
111
|
+
throw new Error('unreachable: an unconditional save was skipped');
|
|
112
|
+
return saved;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* {@link save}, applied only when `onlyIf` accepts the metadata on disk; resolves null when it
|
|
116
|
+
* does not, having written nothing. `onlyIf` runs inside the lock stack against the version
|
|
117
|
+
* the write is checked against, so the decision and the write are one compare-and-set.
|
|
118
|
+
*/
|
|
119
|
+
async saveIf(incoming, onlyIf) {
|
|
109
120
|
try {
|
|
110
121
|
await fs.stat(this.branchRoot);
|
|
111
122
|
}
|
|
@@ -119,6 +130,8 @@ export class BranchMetadataFileManager {
|
|
|
119
130
|
try {
|
|
120
131
|
saved = await withLock(this.filePath, () => withOccFileLock(this.filePath, () => withOccRetry(async () => {
|
|
121
132
|
const { meta: existing, version } = await this.load();
|
|
133
|
+
if (!onlyIf(existing))
|
|
134
|
+
return null;
|
|
122
135
|
const merged = mergeBranchMetadata(existing, version, incoming);
|
|
123
136
|
const written = await this.write(merged, version);
|
|
124
137
|
merged.version = written.version;
|
|
@@ -137,7 +150,8 @@ export class BranchMetadataFileManager {
|
|
|
137
150
|
// does, and the registry's eager regeneration is O(branch count) fs reads
|
|
138
151
|
// on EFS — holding the lock through it would stretch every save's critical
|
|
139
152
|
// section for no correctness gain.
|
|
140
|
-
|
|
153
|
+
if (saved !== null)
|
|
154
|
+
await this.invalidateRegistry();
|
|
141
155
|
return saved;
|
|
142
156
|
}
|
|
143
157
|
/** Invalidate the registry cache so the next list() regenerates. */
|