canopycms 0.0.68-int.106 → 0.0.68-int.107
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/content.js +6 -0
- package/dist/api/entries.js +27 -1
- 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-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 +188 -41
- package/dist/cli/generate-ai-content.js +191 -40
- package/dist/config/flatten.js +2 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/types.d.ts +19 -0
- 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 +37 -1
- package/dist/content-tree.js +2 -2
- package/dist/editor/Editor.d.ts +5 -1
- package/dist/editor/Editor.js +60 -6
- package/dist/editor/EntryNavigator.d.ts +4 -0
- package/dist/editor/EntryNavigator.js +58 -43
- package/dist/editor/admin/SystemHealthPanel.js +12 -1
- package/dist/editor/components/EntryCreateModal.d.ts +5 -2
- package/dist/editor/components/EntryCreateModal.js +10 -3
- 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/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/unavailable-entry-type.d.ts +20 -0
- package/dist/editor/unavailable-entry-type.js +28 -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/types.d.ts +28 -0
- package/dist/validation/deletion-checker.d.ts +5 -0
- package/dist/validation/deletion-checker.js +19 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +6 -0
- package/dist/worker/cms-worker.js +13 -0
- package/dist/worker/git-sync.d.ts +7 -5
- package/dist/worker/git-sync.js +44 -3
- package/dist/worker/schema-gate.d.ts +49 -0
- package/dist/worker/schema-gate.js +226 -0
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -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,
|
package/dist/api/content.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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';
|
|
@@ -210,6 +211,11 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
210
211
|
fields = entryTypeConfig?.schema ?? [];
|
|
211
212
|
maxItems = entryTypeConfig?.maxItems;
|
|
212
213
|
}
|
|
214
|
+
// Refused before validation, which an empty `fields` would wave through; mapped to a 503.
|
|
215
|
+
const resolvedEntryType = schemaItem.type === 'entry-type' ? schemaItem : entryTypeConfig;
|
|
216
|
+
if (resolvedEntryType?.unavailable) {
|
|
217
|
+
throw new SchemaUnavailableError(resolvedEntryType.name, resolvedEntryType.unavailable);
|
|
218
|
+
}
|
|
213
219
|
const data = body.data ?? {};
|
|
214
220
|
const isDataOnly = isDataOnlyFormat(body.format);
|
|
215
221
|
// The store's own `undefined` (blind write) is never reachable from here: an omitted token is
|
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)
|
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';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RootCollectionConfig } from './config/index.js';
|
|
2
2
|
import type { FlatSchemaItem } from './config/types.js';
|
|
3
3
|
import type { OperatingMode } from './operating-mode/index.js';
|
|
4
|
-
import type { EntrySchemaRegistry, SchemaResolutionResult } from './schema/types.js';
|
|
4
|
+
import type { EntrySchemaRegistry, SchemaIssue, SchemaResolutionResult, UnknownSchemaPolicy } from './schema/types.js';
|
|
5
5
|
/** File name of the schema cache inside {@link schemaCacheDir}. */
|
|
6
6
|
export declare const SCHEMA_CACHE_FILE = "schema-cache.json";
|
|
7
7
|
/** resource-generation.ts resource key for the schema cache's marker. */
|
|
@@ -18,6 +18,19 @@ export interface BranchSchemaCacheEntry {
|
|
|
18
18
|
* isGenerationCurrent) to decide freshness.
|
|
19
19
|
*/
|
|
20
20
|
generation: string | null;
|
|
21
|
+
/**
|
|
22
|
+
* {@link registryFingerprint} of the registry this snapshot was resolved
|
|
23
|
+
* against. A deploy bumps no marker, so without it a new image would keep
|
|
24
|
+
* serving schemas resolved by the old image's registry.
|
|
25
|
+
*/
|
|
26
|
+
registryFingerprint: string;
|
|
27
|
+
issues: SchemaIssue[];
|
|
28
|
+
}
|
|
29
|
+
/** A branch's resolved schema, plus what a degraded resolve left out of it. */
|
|
30
|
+
export interface ResolvedBranchSchema {
|
|
31
|
+
schema: RootCollectionConfig;
|
|
32
|
+
flatSchema: FlatSchemaItem[];
|
|
33
|
+
issues: SchemaIssue[];
|
|
21
34
|
}
|
|
22
35
|
/**
|
|
23
36
|
* Per-branch schema cache: one file in {@link schemaCacheDir} with no in-memory
|
|
@@ -32,6 +45,15 @@ export interface BranchSchemaCacheEntry {
|
|
|
32
45
|
* Callers that bypass SchemaOps (api/schema.ts's invalidate endpoint, and the
|
|
33
46
|
* bulk git-op bump in invalidateBranchContentCaches) accept the lazy
|
|
34
47
|
* next-read regen instead.
|
|
48
|
+
*
|
|
49
|
+
* The owner of the strict/degrade split. Content read from the checkout (a
|
|
50
|
+
* build, a static deployment) shares a commit with the code, so a schema
|
|
51
|
+
* reference the registry lacks is a real bug and resolution throws. A branch
|
|
52
|
+
* workspace's content arrives by sync, which can land before the image whose
|
|
53
|
+
* registry it needs, or after an image that needs content it lacks; there an
|
|
54
|
+
* unknown schema marks only its entry type `unavailable`, an unknown reference
|
|
55
|
+
* `entryTypes` value only empties that field's options, and both are returned
|
|
56
|
+
* as `issues`.
|
|
35
57
|
*/
|
|
36
58
|
export declare class BranchSchemaCache {
|
|
37
59
|
/** Tracks when we last checked mtimes per contentRoot, to debounce rapid requests */
|
|
@@ -39,25 +61,23 @@ export declare class BranchSchemaCache {
|
|
|
39
61
|
private readonly devMode;
|
|
40
62
|
constructor(mode?: OperatingMode);
|
|
41
63
|
/**
|
|
42
|
-
* Whether
|
|
64
|
+
* Whether this branchRoot is the checkout rather than a branch workspace,
|
|
65
|
+
* which skips the on-disk cache and resolves strictly (see the class doc).
|
|
43
66
|
*
|
|
44
67
|
* Never write a schema cache at the project root, whichever entrypoint
|
|
45
68
|
* produced the cwd branchRoot. branchRoot equals process.cwd() only in the
|
|
46
69
|
* synthetic contexts static deployments and build phases use; a real branch
|
|
47
70
|
* root is always nested under the workspace.
|
|
48
71
|
*/
|
|
49
|
-
private
|
|
72
|
+
private readsCheckout;
|
|
50
73
|
/** Get schema for a branch, from the cache when fresh, else resolved fresh. */
|
|
51
|
-
getSchema(branchRoot: string, entrySchemaRegistry: EntrySchemaRegistry, contentRootName?: string): Promise<
|
|
52
|
-
schema: RootCollectionConfig;
|
|
53
|
-
flatSchema: FlatSchemaItem[];
|
|
54
|
-
}>;
|
|
74
|
+
getSchema(branchRoot: string, entrySchemaRegistry: EntrySchemaRegistry, contentRootName?: string): Promise<ResolvedBranchSchema>;
|
|
55
75
|
/**
|
|
56
76
|
* Resolve the schema from disk. Protected rather than a direct resolveSchema
|
|
57
77
|
* call so tests can subclass and block it to simulate cross-process
|
|
58
78
|
* interleavings — mirrors BranchRegistry's scanBranchDirectories() hook.
|
|
59
79
|
*/
|
|
60
|
-
protected resolveFresh(contentRoot: string, entrySchemaRegistry: EntrySchemaRegistry): Promise<SchemaResolutionResult>;
|
|
80
|
+
protected resolveFresh(contentRoot: string, entrySchemaRegistry: EntrySchemaRegistry, unknownSchema: UnknownSchemaPolicy): Promise<SchemaResolutionResult>;
|
|
61
81
|
private loadFromCacheOrResolve;
|
|
62
82
|
/**
|
|
63
83
|
* Resolve the schema fresh from disk and persist it (subject to the
|
|
@@ -75,10 +95,7 @@ export declare class BranchSchemaCache {
|
|
|
75
95
|
* instead return a snapshot a concurrent host wrote — possibly one embedding
|
|
76
96
|
* the now-current token over ITS own stale-NFS scan — skipping that scan.
|
|
77
97
|
*/
|
|
78
|
-
resolveAndPersist(branchRoot: string, entrySchemaRegistry: EntrySchemaRegistry, contentRootName?: string): Promise<
|
|
79
|
-
schema: RootCollectionConfig;
|
|
80
|
-
flatSchema: FlatSchemaItem[];
|
|
81
|
-
}>;
|
|
98
|
+
resolveAndPersist(branchRoot: string, entrySchemaRegistry: EntrySchemaRegistry, contentRootName?: string): Promise<ResolvedBranchSchema>;
|
|
82
99
|
/**
|
|
83
100
|
* Invalidate a branch's cache by bumping the marker; every process sharing
|
|
84
101
|
* this branchRoot re-resolves at its next read. The bump must succeed —
|
|
@@ -1,20 +1,39 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveSchema, isValidSchema } from './schema/resolver.js';
|
|
4
|
+
import { registryFingerprint } from './schema/registry-fingerprint.js';
|
|
4
5
|
import { flattenSchema } from './config/flatten.js';
|
|
5
6
|
import { validateReferenceEntryTypes } from './validation/entry-type-reference-validator.js';
|
|
6
7
|
import { isBuildMode } from './build-mode.js';
|
|
7
8
|
import { bumpResourceGeneration, readResourceGeneration, isGenerationCurrent, } from './resource-generation.js';
|
|
8
9
|
import { timeRequestPhase } from './utils/request-timing.js';
|
|
9
10
|
import { CANOPY_META_DIR } from './utils/git.js';
|
|
11
|
+
import { canopyLogWarn } from './utils/logger.js';
|
|
10
12
|
/** Bump when BranchSchemaCacheEntry shape changes to auto-invalidate stale caches */
|
|
11
|
-
const SCHEMA_CACHE_VERSION =
|
|
13
|
+
const SCHEMA_CACHE_VERSION = 4;
|
|
12
14
|
/** Minimum interval between mtime staleness checks (ms) */
|
|
13
15
|
const MTIME_CHECK_DEBOUNCE_MS = 1000;
|
|
14
16
|
/** File name of the schema cache inside {@link schemaCacheDir}. */
|
|
15
17
|
export const SCHEMA_CACHE_FILE = 'schema-cache.json';
|
|
16
18
|
/** resource-generation.ts resource key for the schema cache's marker. */
|
|
17
19
|
export const SCHEMA_GENERATION_RESOURCE = 'schema';
|
|
20
|
+
const reportedIssues = new Set();
|
|
21
|
+
/** Log each schema issue once per process, whether it was resolved here or read from a snapshot. */
|
|
22
|
+
function reportSchemaIssues(issues) {
|
|
23
|
+
for (const issue of issues) {
|
|
24
|
+
const line = issue.kind === 'unknown-schema'
|
|
25
|
+
? `CanopyCMS: ${issue.metaFile} names entry schema "${issue.schemaRef}", which this ` +
|
|
26
|
+
`deployment's entry schema registry does not define, so entry type ` +
|
|
27
|
+
`"${issue.entryType}" is unavailable until code defining it is deployed or the ` +
|
|
28
|
+
`content stops naming it.`
|
|
29
|
+
: `CanopyCMS: ${issue.message} That reference field offers no options until the ` +
|
|
30
|
+
`content declares the entry type.`;
|
|
31
|
+
if (reportedIssues.has(line))
|
|
32
|
+
continue;
|
|
33
|
+
reportedIssues.add(line);
|
|
34
|
+
canopyLogWarn(line);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
18
37
|
/**
|
|
19
38
|
* True when a .collection.json under `dir` is newer than `cachedAt`.
|
|
20
39
|
*
|
|
@@ -73,6 +92,15 @@ async function schemaCacheDir(branchRoot) {
|
|
|
73
92
|
* Callers that bypass SchemaOps (api/schema.ts's invalidate endpoint, and the
|
|
74
93
|
* bulk git-op bump in invalidateBranchContentCaches) accept the lazy
|
|
75
94
|
* next-read regen instead.
|
|
95
|
+
*
|
|
96
|
+
* The owner of the strict/degrade split. Content read from the checkout (a
|
|
97
|
+
* build, a static deployment) shares a commit with the code, so a schema
|
|
98
|
+
* reference the registry lacks is a real bug and resolution throws. A branch
|
|
99
|
+
* workspace's content arrives by sync, which can land before the image whose
|
|
100
|
+
* registry it needs, or after an image that needs content it lacks; there an
|
|
101
|
+
* unknown schema marks only its entry type `unavailable`, an unknown reference
|
|
102
|
+
* `entryTypes` value only empties that field's options, and both are returned
|
|
103
|
+
* as `issues`.
|
|
76
104
|
*/
|
|
77
105
|
export class BranchSchemaCache {
|
|
78
106
|
constructor(mode = 'prod') {
|
|
@@ -81,14 +109,15 @@ export class BranchSchemaCache {
|
|
|
81
109
|
this.devMode = mode === 'dev';
|
|
82
110
|
}
|
|
83
111
|
/**
|
|
84
|
-
* Whether
|
|
112
|
+
* Whether this branchRoot is the checkout rather than a branch workspace,
|
|
113
|
+
* which skips the on-disk cache and resolves strictly (see the class doc).
|
|
85
114
|
*
|
|
86
115
|
* Never write a schema cache at the project root, whichever entrypoint
|
|
87
116
|
* produced the cwd branchRoot. branchRoot equals process.cwd() only in the
|
|
88
117
|
* synthetic contexts static deployments and build phases use; a real branch
|
|
89
118
|
* root is always nested under the workspace.
|
|
90
119
|
*/
|
|
91
|
-
|
|
120
|
+
readsCheckout(branchRoot) {
|
|
92
121
|
return isBuildMode() || path.resolve(branchRoot) === path.resolve(process.cwd());
|
|
93
122
|
}
|
|
94
123
|
/** Get schema for a branch, from the cache when fresh, else resolved fresh. */
|
|
@@ -100,13 +129,13 @@ export class BranchSchemaCache {
|
|
|
100
129
|
* call so tests can subclass and block it to simulate cross-process
|
|
101
130
|
* interleavings — mirrors BranchRegistry's scanBranchDirectories() hook.
|
|
102
131
|
*/
|
|
103
|
-
async resolveFresh(contentRoot, entrySchemaRegistry) {
|
|
104
|
-
return resolveSchema(contentRoot, entrySchemaRegistry);
|
|
132
|
+
async resolveFresh(contentRoot, entrySchemaRegistry, unknownSchema) {
|
|
133
|
+
return resolveSchema(contentRoot, entrySchemaRegistry, { unknownSchema });
|
|
105
134
|
}
|
|
106
135
|
async loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName) {
|
|
107
136
|
const contentRoot = path.join(branchRoot, contentRootName);
|
|
108
|
-
const
|
|
109
|
-
if (!
|
|
137
|
+
const fromCheckout = this.readsCheckout(branchRoot);
|
|
138
|
+
if (!fromCheckout) {
|
|
110
139
|
const cachePath = path.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
|
|
111
140
|
let cacheData = null;
|
|
112
141
|
try {
|
|
@@ -120,7 +149,9 @@ export class BranchSchemaCache {
|
|
|
120
149
|
// Strict version check, not truthiness: a snapshot from an older version
|
|
121
150
|
// left on EFS by a rolling deploy has no `generation` field, and an
|
|
122
151
|
// `undefined` token breaks the freshness comparison below.
|
|
123
|
-
if (cacheData &&
|
|
152
|
+
if (cacheData &&
|
|
153
|
+
cacheData.version === SCHEMA_CACHE_VERSION &&
|
|
154
|
+
cacheData.registryFingerprint === registryFingerprint(entrySchemaRegistry)) {
|
|
124
155
|
const read = await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
|
|
125
156
|
if (isGenerationCurrent(cacheData.generation, read)) {
|
|
126
157
|
// Dev also walks mtimes, debounced, to catch edits made outside the CMS.
|
|
@@ -135,7 +166,12 @@ export class BranchSchemaCache {
|
|
|
135
166
|
else {
|
|
136
167
|
if (this.devMode)
|
|
137
168
|
this.lastMtimeCheck.set(contentRoot, now);
|
|
138
|
-
|
|
169
|
+
reportSchemaIssues(cacheData.issues);
|
|
170
|
+
return {
|
|
171
|
+
schema: cacheData.schema,
|
|
172
|
+
flatSchema: cacheData.flatSchema,
|
|
173
|
+
issues: cacheData.issues,
|
|
174
|
+
};
|
|
139
175
|
}
|
|
140
176
|
}
|
|
141
177
|
else {
|
|
@@ -148,7 +184,7 @@ export class BranchSchemaCache {
|
|
|
148
184
|
}
|
|
149
185
|
}
|
|
150
186
|
return this.resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, {
|
|
151
|
-
|
|
187
|
+
fromCheckout,
|
|
152
188
|
});
|
|
153
189
|
}
|
|
154
190
|
/**
|
|
@@ -158,32 +194,36 @@ export class BranchSchemaCache {
|
|
|
158
194
|
* cache-miss fallback and by {@link resolveAndPersist}.
|
|
159
195
|
*/
|
|
160
196
|
async resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, options) {
|
|
161
|
-
const {
|
|
197
|
+
const { fromCheckout } = options;
|
|
162
198
|
const contentRoot = path.join(branchRoot, contentRootName);
|
|
163
199
|
// Capture the marker strictly BEFORE resolving, so a bump landing
|
|
164
200
|
// mid-resolve differs from the token persisted below and forces a
|
|
165
201
|
// re-resolve on the next read.
|
|
166
|
-
const read =
|
|
202
|
+
const read = fromCheckout
|
|
167
203
|
? null
|
|
168
204
|
: await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
|
|
169
205
|
// Nested under `schema`, so a request summary names a cache miss (`…schema>resolve`).
|
|
170
|
-
const result = await timeRequestPhase('resolve', () => this.resolveFresh(contentRoot, entrySchemaRegistry));
|
|
206
|
+
const result = await timeRequestPhase('resolve', () => this.resolveFresh(contentRoot, entrySchemaRegistry, fromCheckout ? 'throw' : 'degrade'));
|
|
171
207
|
// Validate schema has content
|
|
172
208
|
if (!isValidSchema(result.schema)) {
|
|
173
209
|
throw new Error(`No schema found in ${contentRoot}. Create .collection.json files ` +
|
|
174
210
|
'with references to field schemas defined in your entry schema registry.');
|
|
175
211
|
}
|
|
176
212
|
// Reference fields may only scope themselves to entry types that exist.
|
|
177
|
-
// Checked here, before anything is cached, so a typo fails loudly
|
|
178
|
-
//
|
|
213
|
+
// Checked here, before anything is cached, so a typo fails a build loudly
|
|
214
|
+
// instead of silently resolving to zero reference options.
|
|
179
215
|
const entryTypeIssues = validateReferenceEntryTypes(result.schema);
|
|
180
|
-
if (entryTypeIssues.length > 0) {
|
|
216
|
+
if (entryTypeIssues.length > 0 && fromCheckout) {
|
|
181
217
|
throw new Error(`Invalid reference field entryTypes in ${contentRoot}:\n` +
|
|
182
218
|
entryTypeIssues.map((issue) => ` - ${issue}`).join('\n'));
|
|
183
219
|
}
|
|
220
|
+
const issues = [
|
|
221
|
+
...result.issues,
|
|
222
|
+
...entryTypeIssues.map((message) => ({ kind: 'reference-entry-type', message })),
|
|
223
|
+
];
|
|
184
224
|
// Use configured contentRoot name as base path for logical paths
|
|
185
225
|
const flatSchema = flattenSchema(result.schema, contentRootName);
|
|
186
|
-
if (!
|
|
226
|
+
if (!fromCheckout) {
|
|
187
227
|
const cacheDir = await schemaCacheDir(branchRoot);
|
|
188
228
|
const cachePath = path.join(cacheDir, SCHEMA_CACHE_FILE);
|
|
189
229
|
// Opportunistic cleanup of the retired .stale marker file, which a
|
|
@@ -196,6 +236,8 @@ export class BranchSchemaCache {
|
|
|
196
236
|
flatSchema,
|
|
197
237
|
cachedAt: new Date().toISOString(),
|
|
198
238
|
generation: read.token,
|
|
239
|
+
registryFingerprint: registryFingerprint(entrySchemaRegistry),
|
|
240
|
+
issues,
|
|
199
241
|
};
|
|
200
242
|
// Temp file then rename, with the temp file unlinked on a failed
|
|
201
243
|
// rename so a transient error leaves no stray `.tmp` beside the cache.
|
|
@@ -215,7 +257,8 @@ export class BranchSchemaCache {
|
|
|
215
257
|
// with an unattributable one would look correctly attributed to every
|
|
216
258
|
// future reader. Serve the fresh result without persisting it.
|
|
217
259
|
}
|
|
218
|
-
|
|
260
|
+
reportSchemaIssues(issues);
|
|
261
|
+
return { schema: result.schema, flatSchema, issues };
|
|
219
262
|
}
|
|
220
263
|
/**
|
|
221
264
|
* Resolve fresh from disk and persist, SKIPPING the cache read entirely.
|
|
@@ -227,9 +270,9 @@ export class BranchSchemaCache {
|
|
|
227
270
|
* the now-current token over ITS own stale-NFS scan — skipping that scan.
|
|
228
271
|
*/
|
|
229
272
|
async resolveAndPersist(branchRoot, entrySchemaRegistry, contentRootName = 'content') {
|
|
230
|
-
const
|
|
273
|
+
const fromCheckout = this.readsCheckout(branchRoot);
|
|
231
274
|
return this.resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, {
|
|
232
|
-
|
|
275
|
+
fromCheckout,
|
|
233
276
|
});
|
|
234
277
|
}
|
|
235
278
|
/**
|
|
@@ -241,7 +284,7 @@ export class BranchSchemaCache {
|
|
|
241
284
|
* No eager re-resolve here; it lives in SchemaOps (see the class doc).
|
|
242
285
|
*/
|
|
243
286
|
async invalidate(branchRoot) {
|
|
244
|
-
if (this.
|
|
287
|
+
if (this.readsCheckout(branchRoot))
|
|
245
288
|
return;
|
|
246
289
|
await bumpResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE, { mustSucceed: true });
|
|
247
290
|
}
|
|
@@ -181,6 +181,11 @@ export async function generateAIContentFiles(options) {
|
|
|
181
181
|
else {
|
|
182
182
|
const schemaCache = new BranchSchemaCache(config.mode);
|
|
183
183
|
const cached = await schemaCache.getSchema(branchRoot, entrySchemaRegistry, contentRootName);
|
|
184
|
+
// A branch workspace resolves degraded (see BranchSchemaCache); a build command fails instead.
|
|
185
|
+
if (cached.issues.length > 0) {
|
|
186
|
+
throw new Error(`AI content generation: the schema in ${branchRoot} does not match this code's entry ` +
|
|
187
|
+
`schema registry:\n${cached.issues.map((issue) => ` - ${issue.message}`).join('\n')}`);
|
|
188
|
+
}
|
|
184
189
|
flatSchema = cached.flatSchema;
|
|
185
190
|
}
|
|
186
191
|
// Fail the build on abandoned create-scaffolds (schema-invalid entries) rather than baking them
|