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.
Files changed (68) hide show
  1. package/dist/ai/generate.js +1 -1
  2. package/dist/api/admin.d.ts +3 -0
  3. package/dist/api/admin.js +21 -1
  4. package/dist/api/content.js +6 -0
  5. package/dist/api/entries.js +27 -1
  6. package/dist/api/schema.d.ts +11 -9
  7. package/dist/api/schema.js +2 -0
  8. package/dist/api/types.d.ts +7 -0
  9. package/dist/branch-schema-cache.d.ts +29 -12
  10. package/dist/branch-schema-cache.js +64 -21
  11. package/dist/build/generate-ai-content.js +5 -0
  12. package/dist/cli/cli.js +188 -41
  13. package/dist/cli/generate-ai-content.js +191 -40
  14. package/dist/config/flatten.js +2 -0
  15. package/dist/config/index.d.ts +1 -1
  16. package/dist/config/types.d.ts +19 -0
  17. package/dist/content-listing.d.ts +6 -0
  18. package/dist/content-listing.js +10 -1
  19. package/dist/content-reader.js +3 -1
  20. package/dist/content-store.d.ts +18 -0
  21. package/dist/content-store.js +37 -1
  22. package/dist/content-tree.js +2 -2
  23. package/dist/editor/Editor.d.ts +5 -1
  24. package/dist/editor/Editor.js +60 -6
  25. package/dist/editor/EntryNavigator.d.ts +4 -0
  26. package/dist/editor/EntryNavigator.js +58 -43
  27. package/dist/editor/admin/SystemHealthPanel.js +12 -1
  28. package/dist/editor/components/EntryCreateModal.d.ts +5 -2
  29. package/dist/editor/components/EntryCreateModal.js +10 -3
  30. package/dist/editor/components/UnavailableEntryNotice.d.ts +6 -0
  31. package/dist/editor/components/UnavailableEntryNotice.js +9 -0
  32. package/dist/editor/components/UnavailableTypeMessage.d.ts +6 -0
  33. package/dist/editor/components/UnavailableTypeMessage.js +9 -0
  34. package/dist/editor/editor-config.js +1 -0
  35. package/dist/editor/editor-utils.js +3 -0
  36. package/dist/editor/hooks/useDraftManager.js +24 -4
  37. package/dist/editor/hooks/useEntryManager.d.ts +10 -1
  38. package/dist/editor/hooks/useEntryManager.js +18 -3
  39. package/dist/editor/unavailable-entry-type.d.ts +20 -0
  40. package/dist/editor/unavailable-entry-type.js +28 -0
  41. package/dist/http/handler.js +7 -1
  42. package/dist/http/worker-not-ready.d.ts +4 -4
  43. package/dist/http/worker-not-ready.js +14 -4
  44. package/dist/reference-resolver.js +1 -0
  45. package/dist/schema/meta-loader.d.ts +18 -8
  46. package/dist/schema/meta-loader.js +42 -9
  47. package/dist/schema/registry-fingerprint.d.ts +14 -0
  48. package/dist/schema/registry-fingerprint.js +63 -0
  49. package/dist/schema/resolver.d.ts +6 -3
  50. package/dist/schema/resolver.js +6 -5
  51. package/dist/schema/schema-unavailable-error.d.ts +11 -0
  52. package/dist/schema/schema-unavailable-error.js +15 -0
  53. package/dist/schema/types.d.ts +22 -0
  54. package/dist/schema-registry-record.d.ts +35 -0
  55. package/dist/schema-registry-record.js +134 -0
  56. package/dist/types.d.ts +28 -0
  57. package/dist/validation/deletion-checker.d.ts +5 -0
  58. package/dist/validation/deletion-checker.js +19 -5
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/worker/cms-worker.d.ts +6 -0
  62. package/dist/worker/cms-worker.js +13 -0
  63. package/dist/worker/git-sync.d.ts +7 -5
  64. package/dist/worker/git-sync.js +44 -3
  65. package/dist/worker/schema-gate.d.ts +49 -0
  66. package/dist/worker/schema-gate.js +226 -0
  67. package/dist/worker/worker-context.d.ts +2 -0
  68. package/package.json +1 -1
@@ -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, {
@@ -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,
@@ -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
@@ -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 entries = await listContentEntries(branchRoot, flatSchema, ctx.services.config.contentRoot || 'content');
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)
@@ -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>;
@@ -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
  }
@@ -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 to skip the on-disk cache for this branchRoot.
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 skipDiskCache;
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 = 3;
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 to skip the on-disk cache for this branchRoot.
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
- skipDiskCache(branchRoot) {
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 skipDiskCache = this.skipDiskCache(branchRoot);
109
- if (!skipDiskCache) {
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 && cacheData.version === SCHEMA_CACHE_VERSION) {
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
- return { schema: cacheData.schema, flatSchema: cacheData.flatSchema };
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
- skipDiskCache,
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 { skipDiskCache } = options;
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 = skipDiskCache
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 and
178
- // consistently instead of silently resolving to zero reference options.
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 (!skipDiskCache) {
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
- return { schema: result.schema, flatSchema };
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 skipDiskCache = this.skipDiskCache(branchRoot);
273
+ const fromCheckout = this.readsCheckout(branchRoot);
231
274
  return this.resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, {
232
- skipDiskCache,
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.skipDiskCache(branchRoot))
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