canopycms 0.8.0 → 0.8.2

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 (86) hide show
  1. package/CHANGELOG.md +112 -47
  2. package/dist/api/admin-branch-health.d.ts +11 -0
  3. package/dist/api/admin-branch-health.js +36 -0
  4. package/dist/api/admin.d.ts +8 -1
  5. package/dist/api/branch-merge.js +1 -1
  6. package/dist/api/branch-review.js +20 -3
  7. package/dist/api/branch-status.js +30 -19
  8. package/dist/api/branch.d.ts +8 -3
  9. package/dist/api/branch.js +32 -3
  10. package/dist/api/client.d.ts +3 -1
  11. package/dist/api/client.js +4 -0
  12. package/dist/api/github-sync.d.ts +2 -0
  13. package/dist/api/github-sync.js +6 -1
  14. package/dist/api/pr-comment-sync.js +3 -1
  15. package/dist/branch-metadata-file.d.ts +66 -0
  16. package/dist/branch-metadata-file.js +14 -0
  17. package/dist/branch-metadata.d.ts +4 -2
  18. package/dist/branch-metadata.js +10 -2
  19. package/dist/branch-schema-cache.d.ts +22 -2
  20. package/dist/branch-schema-cache.js +88 -12
  21. package/dist/cli/cli.js +91 -18
  22. package/dist/cli/generate-ai-content.js +72 -11
  23. package/dist/cli/init.js +2 -2
  24. package/dist/content-reader.d.ts +24 -10
  25. package/dist/content-reader.js +43 -6
  26. package/dist/context.js +43 -41
  27. package/dist/editor/BranchManager.d.ts +8 -4
  28. package/dist/editor/BranchManager.js +43 -18
  29. package/dist/editor/Editor.js +3 -2
  30. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  31. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  32. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  33. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  34. package/dist/editor/admin/useSystemHealth.js +21 -0
  35. package/dist/editor/branch-lock.d.ts +2 -0
  36. package/dist/editor/branch-lock.js +5 -3
  37. package/dist/editor/branch-status.d.ts +25 -1
  38. package/dist/editor/branch-status.js +75 -0
  39. package/dist/editor/components/EditorHeader.d.ts +6 -2
  40. package/dist/editor/components/EditorHeader.js +51 -30
  41. package/dist/editor/components/EntryHeader.d.ts +3 -1
  42. package/dist/editor/components/EntryHeader.js +2 -1
  43. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  44. package/dist/editor/components/TooltipDetail.js +18 -0
  45. package/dist/editor/copy.d.ts +7 -0
  46. package/dist/editor/copy.js +9 -0
  47. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  48. package/dist/editor/hooks/useBranchManager.js +48 -5
  49. package/dist/pr-comments.d.ts +2 -0
  50. package/dist/pr-comments.js +20 -14
  51. package/dist/resource-generation.js +2 -3
  52. package/dist/server.d.ts +6 -1
  53. package/dist/server.js +6 -1
  54. package/dist/submission-attribution.d.ts +1 -1
  55. package/dist/submission-attribution.js +20 -2
  56. package/dist/task-queue/task-queue.d.ts +5 -1
  57. package/dist/task-queue/task-queue.js +4 -2
  58. package/dist/types.d.ts +61 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/worker/branch-merge.d.ts +68 -0
  62. package/dist/worker/branch-merge.js +228 -0
  63. package/dist/worker/canopy-state.d.ts +2 -0
  64. package/dist/worker/canopy-state.js +1 -1
  65. package/dist/worker/cms-worker.d.ts +1 -0
  66. package/dist/worker/cms-worker.js +5 -3
  67. package/dist/worker/git-sync.js +15 -8
  68. package/dist/worker/github-commits.d.ts +106 -0
  69. package/dist/worker/github-commits.js +485 -0
  70. package/dist/worker/github-gateway-http.js +3 -2
  71. package/dist/worker/github-gateway-wire.d.ts +24 -15
  72. package/dist/worker/github-gateway-wire.js +4 -1
  73. package/dist/worker/github-gateway.d.ts +16 -5
  74. package/dist/worker/github-gateway.js +22 -5
  75. package/dist/worker/github-mirror.d.ts +8 -0
  76. package/dist/worker/github-mirror.js +12 -1
  77. package/dist/worker/github-transfer.d.ts +3 -1
  78. package/dist/worker/github-transfer.js +3 -2
  79. package/dist/worker/history-rewrite.d.ts +13 -1
  80. package/dist/worker/history-rewrite.js +27 -3
  81. package/dist/worker/rebase.d.ts +13 -7
  82. package/dist/worker/rebase.js +351 -95
  83. package/dist/worker/task-runner.d.ts +1 -1
  84. package/dist/worker/task-runner.js +172 -34
  85. package/dist/worker/worker-context.d.ts +2 -0
  86. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { LRUCache } from 'lru-cache';
3
4
  import { resolveSchema, isValidSchema } from './schema/resolver.js';
4
5
  import { registryFingerprint } from './schema/registry-fingerprint.js';
5
6
  import { flattenSchema } from './config/flatten.js';
@@ -13,6 +14,8 @@ import { canopyLogWarn } from './utils/logger.js';
13
14
  const SCHEMA_CACHE_VERSION = 4;
14
15
  /** Minimum interval between mtime staleness checks (ms) */
15
16
  const MTIME_CHECK_DEBOUNCE_MS = 1000;
17
+ /** Cap on the parsed snapshots one process keeps, counted in bytes of their files. */
18
+ const SNAPSHOT_MEMO_MAX_BYTES = 64 * 1024 * 1024;
16
19
  /** File name of the schema cache inside {@link schemaCacheDir}. */
17
20
  export const SCHEMA_CACHE_FILE = 'schema-cache.json';
18
21
  /** resource-generation.ts resource key for the schema cache's marker. */
@@ -65,6 +68,13 @@ async function isStaleByMtime(dir, cachedAt) {
65
68
  }
66
69
  return false;
67
70
  }
71
+ /**
72
+ * Names one version of a file. Every write of the schema cache renames a new temp file into
73
+ * place, so a rewrite always changes the inode, whatever token or content it carries.
74
+ */
75
+ function fileIdentity(stat) {
76
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}`;
77
+ }
68
78
  /**
69
79
  * Directory holding a branch's schema cache: `{branchRoot}/.git/canopycms` when
70
80
  * the branch root is a full clone (every canopycms branch workspace is), else
@@ -80,8 +90,15 @@ async function schemaCacheDir(branchRoot) {
80
90
  return isClone ? path.join(gitDir, 'canopycms') : path.join(branchRoot, CANOPY_META_DIR);
81
91
  }
82
92
  /**
83
- * Per-branch schema cache: one file in {@link schemaCacheDir} with no in-memory
84
- * layer, so it stays coherent across Lambda invocations.
93
+ * Per-branch schema cache: one file in {@link schemaCacheDir}, the only state
94
+ * shared across processes. Each process keeps the entries it parsed or wrote,
95
+ * keyed by the file's identity, so it parses an unchanged file once while the
96
+ * entry stays in its size-bounded memo. Every call still opens the file and
97
+ * runs every freshness check below on the entry, and the file is only ever
98
+ * replaced by rename (see {@link fileIdentity}), so a process is as fresh as if
99
+ * it re-read the file. The identity is fstat'ed on an open handle because NFS
100
+ * revalidates attributes on open (close-to-open), while a bare stat() may
101
+ * answer from its attribute cache.
85
102
  *
86
103
  * Freshness follows the generation-marker protocol owned by
87
104
  * resource-generation.ts, and this is one of that protocol's durable-snapshot
@@ -106,6 +123,19 @@ export class BranchSchemaCache {
106
123
  constructor(mode = 'prod') {
107
124
  /** Tracks when we last checked mtimes per contentRoot, to debounce rapid requests */
108
125
  this.lastMtimeCheck = new Map();
126
+ /** Parsed cache files, by absolute path; see the class doc. */
127
+ this.snapshots = new LRUCache({
128
+ maxSize: SNAPSHOT_MEMO_MAX_BYTES,
129
+ sizeCalculation: (snapshot) => Math.max(1, snapshot.bytes),
130
+ });
131
+ /**
132
+ * In-flight resolves of the checkout during a build, by content root and registry. A build
133
+ * reads a checkout that does not change under it, so those two are its whole input and the
134
+ * result needs no revalidation. Only {@link isBuildMode}: `CANOPY_BUILD_MODE` is a build and
135
+ * script switch, and a long-lived process setting it would see a schema edit only on
136
+ * restart. A rejected resolve is dropped, so a strict-mode failure fails every read.
137
+ */
138
+ this.buildResolves = new Map();
109
139
  this.devMode = mode === 'dev';
110
140
  }
111
141
  /**
@@ -135,17 +165,12 @@ export class BranchSchemaCache {
135
165
  async loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName) {
136
166
  const contentRoot = path.join(branchRoot, contentRootName);
137
167
  const fromCheckout = this.readsCheckout(branchRoot);
168
+ if (fromCheckout && isBuildMode()) {
169
+ return this.resolveForBuild(branchRoot, entrySchemaRegistry, contentRootName);
170
+ }
138
171
  if (!fromCheckout) {
139
172
  const cachePath = path.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
140
- let cacheData = null;
141
- try {
142
- const cacheContent = await fs.readFile(cachePath, 'utf-8');
143
- cacheData = JSON.parse(cacheContent);
144
- }
145
- catch {
146
- // Cache doesn't exist or can't be read
147
- cacheData = null;
148
- }
173
+ let cacheData = await this.readSnapshot(cachePath);
149
174
  // Strict version check, not truthiness: a snapshot from an older version
150
175
  // left on EFS by a rolling deploy has no `generation` field, and an
151
176
  // `undefined` token breaks the freshness comparison below.
@@ -187,6 +212,44 @@ export class BranchSchemaCache {
187
212
  fromCheckout,
188
213
  });
189
214
  }
215
+ resolveForBuild(branchRoot, entrySchemaRegistry, contentRootName) {
216
+ const key = `${path.resolve(branchRoot, contentRootName)}\0${registryFingerprint(entrySchemaRegistry)}`;
217
+ let pending = this.buildResolves.get(key);
218
+ if (!pending) {
219
+ pending = this.resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, {
220
+ fromCheckout: true,
221
+ });
222
+ this.buildResolves.set(key, pending);
223
+ pending.catch(() => this.buildResolves.delete(key));
224
+ }
225
+ return pending;
226
+ }
227
+ /** The parsed cache file, or null when it is absent or unreadable. */
228
+ async readSnapshot(cachePath) {
229
+ let handle;
230
+ try {
231
+ handle = await fs.open(cachePath, 'r');
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ try {
237
+ const stat = await handle.stat({ bigint: true });
238
+ const identity = fileIdentity(stat);
239
+ const memo = this.snapshots.get(cachePath);
240
+ if (memo?.identity === identity)
241
+ return memo.entry;
242
+ const entry = JSON.parse(await fs.readFile(handle, 'utf-8'));
243
+ this.snapshots.set(cachePath, { identity, entry, bytes: Number(stat.size) });
244
+ return entry;
245
+ }
246
+ catch {
247
+ return null;
248
+ }
249
+ finally {
250
+ await handle.close().catch(() => { });
251
+ }
252
+ }
190
253
  /**
191
254
  * Resolve the schema fresh from disk and persist it (subject to the
192
255
  * skip-persist rule below), UNCONDITIONALLY -- never through the cache-read
@@ -241,16 +304,29 @@ export class BranchSchemaCache {
241
304
  };
242
305
  // Temp file then rename, with the temp file unlinked on a failed
243
306
  // rename so a transient error leaves no stray `.tmp` beside the cache.
307
+ // The memo is seeded from the temp file's own identity, which the
308
+ // rename keeps: stat'ing cachePath afterwards could see a concurrent
309
+ // writer's file and pair it with this entry. It holds the parsed body,
310
+ // as every reader does, not the resolver's objects, which alias the
311
+ // registry and keep values JSON changes or drops.
244
312
  await fs.mkdir(cacheDir, { recursive: true });
245
313
  const tmpPath = path.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
246
- await fs.writeFile(tmpPath, JSON.stringify(newCache, null, 2), 'utf-8');
314
+ const body = JSON.stringify(newCache);
315
+ let identity;
247
316
  try {
317
+ await fs.writeFile(tmpPath, body, 'utf-8');
318
+ identity = fileIdentity(await fs.stat(tmpPath, { bigint: true }));
248
319
  await fs.rename(tmpPath, cachePath);
249
320
  }
250
321
  catch (err) {
251
322
  await fs.unlink(tmpPath).catch(() => { });
252
323
  throw err;
253
324
  }
325
+ this.snapshots.set(cachePath, {
326
+ identity,
327
+ entry: JSON.parse(body),
328
+ bytes: Buffer.byteLength(body),
329
+ });
254
330
  }
255
331
  // else: the marker read failed for a reason other than "never bumped",
256
332
  // so no token can be attributed to this resolve, and a snapshot stamped
package/dist/cli/cli.js CHANGED
@@ -562,7 +562,7 @@ async function failTask(taskDir, taskId, error, logger = nullLogger) {
562
562
  });
563
563
  logger.debug("Failed task", { id: taskId, error });
564
564
  }
565
- async function retryTask(taskDir, taskId, error, logger = nullLogger) {
565
+ async function retryTask(taskDir, taskId, error, logger = nullLogger, options = {}) {
566
566
  const processingPath = path6.join(taskDir, "processing", `${taskId}.json`);
567
567
  const pendingDir = path6.join(taskDir, "pending");
568
568
  const pendingPath = path6.join(pendingDir, `${taskId}.json`);
@@ -583,7 +583,7 @@ async function retryTask(taskDir, taskId, error, logger = nullLogger) {
583
583
  throw err2;
584
584
  }
585
585
  const retryCount = (task.retryCount ?? 0) + 1;
586
- const backoffMs = Math.min(5e3 * Math.pow(2, retryCount - 1), 6e4);
586
+ const backoffMs = options.delayMs ?? Math.min(5e3 * Math.pow(2, retryCount - 1), 6e4);
587
587
  task.status = "pending";
588
588
  task.retryCount = retryCount;
589
589
  task.retryAfter = new Date(Date.now() + backoffMs).toISOString();
@@ -3405,6 +3405,7 @@ import path72 from "node:path";
3405
3405
  import { randomUUID as randomUUID2 } from "node:crypto";
3406
3406
  import fs72 from "node:fs/promises";
3407
3407
  import path82 from "node:path";
3408
+ import { LRUCache } from "lru-cache";
3408
3409
  import { promises as fs62 } from "fs";
3409
3410
  import { join } from "pathe";
3410
3411
  import { z as z7 } from "zod";
@@ -5356,9 +5357,7 @@ async function bumpResourceGeneration(root, resource, options) {
5356
5357
  await atomicWriteFile2(resourceGenerationPath(root, resource), token);
5357
5358
  return token;
5358
5359
  } catch (err2) {
5359
- log4.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
5360
- error: getErrorMessage3(err2)
5361
- });
5360
+ canopyLogWarn2(`CanopyCMS: failed to bump ${resource} generation for ${root}:`, getErrorMessage3(err2));
5362
5361
  if (options?.mustSucceed)
5363
5362
  throw err2;
5364
5363
  return null;
@@ -5706,6 +5705,9 @@ async function isStaleByMtime(dir, cachedAt) {
5706
5705
  }
5707
5706
  return false;
5708
5707
  }
5708
+ function fileIdentity(stat) {
5709
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}`;
5710
+ }
5709
5711
  async function schemaCacheDir(branchRoot) {
5710
5712
  const gitDir = path82.join(branchRoot, ".git");
5711
5713
  const isClone = await fs72.stat(gitDir).then((stat) => stat.isDirectory(), () => false);
@@ -8432,7 +8434,7 @@ async function generateAIContentCLI(options) {
8432
8434
  console.log(` Output: ${result.outputDir}
8433
8435
  `);
8434
8436
  }
8435
- var __defProp3, __getOwnPropNames3, __esm3, __export3, init_error3, init_atomic_write2, init_field_traversal, ITEM_KEY, ITEM_KEY_PATTERN, isItemKey, BASE58, isListField, init_item_keys, active2, init_logger2, SENTINEL_BASE2, DOT_SEGMENT2, init_sanitize_href2, init_normalize, RESERVED_SETTINGS_BRANCH_PREFIX2, init_branch_name, BASE58_PATTERN2, CONTENT_ID_PATTERN2, PHYSICAL_SEGMENT_PATTERN2, MAX_BRANCH_NAME_LENGTH2, init_validation, ROOT_COLLECTION_ID, init_types2, normalizePathValue, flattenSchema, init_flatten, scalarFieldTypes2, markdownFieldTypes2, primitiveFieldTypes2, fieldTypes2, init_types22, SAFE_HTML_TAGS2, COMPONENT_NAME2, MDX_BINDINGS2, FORBIDDEN_ATTRIBUTES2, init_mdx_allowlist2, fieldBaseSchema2, selectOptionSchema2, referenceOptionSchema2, primitiveFieldSchema2, mdxPropAllowSchema2, mdxAllowlistSchema2, markdownFieldSchema2, ASPECT_RATIO_RE2, imageFieldSchema2, selectFieldSchema2, referenceSortFieldSchema2, referenceFieldSchema2, fieldHolder2, blockSchema2, blockFieldSchema2, objectFieldSchema2, inlineGroupFieldSchema2, customFieldSchema2, knownFieldSchema2, fieldSchema2, init_field2, relativePathSchema2, entryTypeSchema2, collectionSchema2, init_collection2, uploadTargetUrlSchema2, previewPrefixSchema2, editorUrlSchema2, init_url2, mediaSchema2, init_media2, defaultBranchAccessSchema2, defaultPathAccessLevelSchema2, defaultPathAccessLevelsSchema2, defaultPathAccessSchema2, defaultBaseBranchSchema2, defaultRemoteNameSchema2, defaultRemoteUrlSchema2, gitBotAuthorNameSchema2, gitBotAuthorEmailSchema2, githubTokenEnvVarSchema2, operatingModeSchema2, deployedAsSchema2, contentRootSchema2, sourceRootSchema2, basePathSchema2, deploymentNameSchema2, devContentSyncSchema2, devConfigSchema2, editorConfigSchema2, seoFieldNameSchema2, seoSchema2, CanopyConfigSchema2, DEFAULT_PROD_WORKSPACE2, init_config3, SERVER_MODE_ENV_VAR, BROWSER_MODE_ENV_VAR, warned22, init_mode_env, forEachReferenceField, init_validation2, ProdClientSafeStrategy2, DevClientSafeStrategy2, clientStrategyCache, init_client_safe_strategy, warned3, VALID_DEPLOYMENT_NAME2, isValidDeploymentName2, init_deployment_name, warned4, DEPLOYMENT_PLACEHOLDER2, DEFAULT_BRANCH_NAMESPACE_TEMPLATE2, BRANCH_NAMESPACE_ENV2, init_branch_namespace, init_config22, ProdStrategy2, DevStrategy2, strategyCache2, init_client_unsafe_strategy, operating_mode_exports, init_operating_mode, task_queue_config_exports2, init_task_queue_config2, worker_status_exports, WORKER_STATUS_FILE, init_worker_status, BLOCK_DISCRIMINATOR_KEYS, BLOCK_STRUCTURAL_KEYS, envFlag2, LOG_LEVELS2, DebugLogger2, testLogger2, MARKDOWN_EXPORT_OPTIONS, BULLETS, DELIMITERS, EMPHASIS, STRONG, RULE, WORD_CHAR, GAP, Restyler, END, FRESH, LINE_WIDTH, MIN_LINE_WIDTH, SourceSplicer, log2, MAX_LCS_CELLS, Splicer, log22, EVIDENCE_MAX_DEPTH, log3, locks, PROVISIONING_LOCK_STALE_MS, sleep, META_DIR, CONTENT_WRITE_LOCK_NAME, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, ContentWriteLockBusyError, sleep2, CONTENT_WRITE_LOCK_STALE_MS, heldRoots, getFormatExtension, isDataOnlyFormat, asRecord, DEFAULT_SEO_FIELD_NAMES, asRecord2, asText, asFlag, asImageUrl, asOgType, asTwitterCard, RESTRICTED_REFERENCE_MARKER, SEO_GROUP_FIELDS, MAX_WARNED, warned5, SchemaUnavailableError, isValidId, parseTypedFilename, EMPTY_LOGICAL_PATH, ENTRY_EXTENSIONS, ContentIdIndex, CONTENT_EXTENSIONS, registry, finalization, log4, META_DIR2, entryTypeMetaSchema, collectionMetaSchema, rootCollectionMetaSchema, fingerprints, editDistance, closestName, forEachEntryType, collectEntryTypeNames, validateReferenceEntryTypes, isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER, storage, log5, BaseBranchUnresolvedError, NETWORK_SCHEME_PATTERN, TRANSPORT_HELPER_PATTERN, LEADING_OPTION_PATTERN, WINDOWS_DRIVE_PATTERN, COLON_BEFORE_SLASH_PATTERN, MISSING_REMOTE_REF_REASONS, CANOPY_META_DIR, SCHEMA_CACHE_VERSION, MTIME_CHECK_DEBOUNCE_MS, SCHEMA_CACHE_FILE, SCHEMA_GENERATION_RESOURCE, reportedIssues, BranchSchemaCache, RESOURCE, BranchPathError, resolveContentBranchesRoot, ContentStoreError, ContentConflictError, EntryGoneError, BranchSyncingError, DuplicateContentIdError, UrlPathConflictError, isEmptyFieldValue, DEFAULT_INDEX_FRESHNESS_INTERVAL_MS, FORCED_REFRESH_MIN_INTERVAL_MS, STALE_LOOKUP, isMissingReference, createReferenceResolveCache, ContentStore, ATTR_CONTENT, BLOCK_PREFIX, BLOCK_SUFFIX, BLOCK_RESTORE_RE, INLINE_PREFIX, INLINE_SUFFIX, INLINE_RESTORE_RE, DEFAULT_REFERENCE_RENDERING, FLAT_CELL_TYPES, log6, BASE58_CHAR, ENTRY_LINK_PATTERN, INLINE_LINK_TARGET, BranchMetadataCorruptError, BRANCH_META_DIR, BRANCH_META_FILE, branchMetadataFilePath, UNKNOWN_TIME, enumOf, branchMetadataShape, branchMetadataSchema, branchMetadataFileSchema, log7, REGISTRY_FILE, REGISTRY_STALE_FILE, REGISTRY_TEMP_FILE, REGISTRY_VERSION, RESOURCE2, GET_MISS_REFRESH_MIN_INTERVAL_MS, snapshotSchema, reportedUnreadable, BranchRegistry, log8, OccWriteConflictError, CURRENT_SCHEMA_VERSION, BranchMetadataConflictError, BranchMetadataFileManager, loadBranchContext, GitProcessError, STDERR_CAP_BYTES, KILL_GRACE_MS, log9, GIT_ENV_PASSTHROUGH, FORCE_C_LOCALE, GIT_NETWORK_ENV_PASSTHROUGH, NO_AUTO_GC_CONFIG, HASH_INACTIVITY_TIMEOUT_MS, MAX_HASH_RUNS, LS_REMOTE_INACTIVITY_TIMEOUT_MS, symlinkBlobSha, REMOTE_GIT_CONFIG, REMOTE_GIT_MAX_LOOSE_OBJECTS, REMOTE_GIT_MAX_PACKS, PARALLEL_CHECKOUT_ARGS, remoteInitLocks, GITHUB_TRACKING_REF_PREFIX, BaseBranchMissingError, GitConflictError, GitRemoteRefMissingError, RemoteNotReadyError, SettingsRemoteUnreadableError, SettingsBranchDivergedError, SETTINGS_BRANCH_FILES, SettingsBranchHasContentHistoryError, GitManager, PUBLISH_LOCK_WAIT_MS, CREATE_RESIDUE_MIN_QUIET_MS, STAGING_MAX_AGE_MS, REPAIR_MAX_AGE_MS, DELETING_MAX_AGE_MS, SETTINGS_TRASH_MAX_AGE_MS, BranchProvisioningBusyError, BranchDirOccupiedError, STAGING_PREFIX, REPAIR_PREFIX, TRASH_PREFIX, stagingDirName, NO_GIT, RENAME_BLOCKED, SPARSE_CONE_RECORD, defaultSink, sink, ProvisionLog, testHooks, provisionsInFlight, BranchWorkspaceManager, log10, normalizePath, parseEntryParts, parseEntryData, readEntryData, createReferenceResolver, withoutUnavailableEntries, resolveCollectionItemReferences, looksLikeMalformedEntry, listCollectionEntries, MAX_INPUT_PIXELS, CROP_PRECISION, KNOWN_FIELD_TYPES, STRING_FIELD_TYPES, selectOptionValues, GENERATED_RECORD_FILENAME, SAFE_BUILD_ID, BUILD_ID_RULE, jiti;
8437
+ var __defProp3, __getOwnPropNames3, __esm3, __export3, init_error3, init_atomic_write2, init_field_traversal, ITEM_KEY, ITEM_KEY_PATTERN, isItemKey, BASE58, isListField, init_item_keys, active2, init_logger2, SENTINEL_BASE2, DOT_SEGMENT2, init_sanitize_href2, init_normalize, RESERVED_SETTINGS_BRANCH_PREFIX2, init_branch_name, BASE58_PATTERN2, CONTENT_ID_PATTERN2, PHYSICAL_SEGMENT_PATTERN2, MAX_BRANCH_NAME_LENGTH2, init_validation, ROOT_COLLECTION_ID, init_types2, normalizePathValue, flattenSchema, init_flatten, scalarFieldTypes2, markdownFieldTypes2, primitiveFieldTypes2, fieldTypes2, init_types22, SAFE_HTML_TAGS2, COMPONENT_NAME2, MDX_BINDINGS2, FORBIDDEN_ATTRIBUTES2, init_mdx_allowlist2, fieldBaseSchema2, selectOptionSchema2, referenceOptionSchema2, primitiveFieldSchema2, mdxPropAllowSchema2, mdxAllowlistSchema2, markdownFieldSchema2, ASPECT_RATIO_RE2, imageFieldSchema2, selectFieldSchema2, referenceSortFieldSchema2, referenceFieldSchema2, fieldHolder2, blockSchema2, blockFieldSchema2, objectFieldSchema2, inlineGroupFieldSchema2, customFieldSchema2, knownFieldSchema2, fieldSchema2, init_field2, relativePathSchema2, entryTypeSchema2, collectionSchema2, init_collection2, uploadTargetUrlSchema2, previewPrefixSchema2, editorUrlSchema2, init_url2, mediaSchema2, init_media2, defaultBranchAccessSchema2, defaultPathAccessLevelSchema2, defaultPathAccessLevelsSchema2, defaultPathAccessSchema2, defaultBaseBranchSchema2, defaultRemoteNameSchema2, defaultRemoteUrlSchema2, gitBotAuthorNameSchema2, gitBotAuthorEmailSchema2, githubTokenEnvVarSchema2, operatingModeSchema2, deployedAsSchema2, contentRootSchema2, sourceRootSchema2, basePathSchema2, deploymentNameSchema2, devContentSyncSchema2, devConfigSchema2, editorConfigSchema2, seoFieldNameSchema2, seoSchema2, CanopyConfigSchema2, DEFAULT_PROD_WORKSPACE2, init_config3, SERVER_MODE_ENV_VAR, BROWSER_MODE_ENV_VAR, warned22, init_mode_env, forEachReferenceField, init_validation2, ProdClientSafeStrategy2, DevClientSafeStrategy2, clientStrategyCache, init_client_safe_strategy, warned3, VALID_DEPLOYMENT_NAME2, isValidDeploymentName2, init_deployment_name, warned4, DEPLOYMENT_PLACEHOLDER2, DEFAULT_BRANCH_NAMESPACE_TEMPLATE2, BRANCH_NAMESPACE_ENV2, init_branch_namespace, init_config22, ProdStrategy2, DevStrategy2, strategyCache2, init_client_unsafe_strategy, operating_mode_exports, init_operating_mode, task_queue_config_exports2, init_task_queue_config2, worker_status_exports, WORKER_STATUS_FILE, init_worker_status, BLOCK_DISCRIMINATOR_KEYS, BLOCK_STRUCTURAL_KEYS, envFlag2, LOG_LEVELS2, DebugLogger2, testLogger2, MARKDOWN_EXPORT_OPTIONS, BULLETS, DELIMITERS, EMPHASIS, STRONG, RULE, WORD_CHAR, GAP, Restyler, END, FRESH, LINE_WIDTH, MIN_LINE_WIDTH, SourceSplicer, log2, MAX_LCS_CELLS, Splicer, log22, EVIDENCE_MAX_DEPTH, log3, locks, PROVISIONING_LOCK_STALE_MS, sleep, META_DIR, CONTENT_WRITE_LOCK_NAME, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, ContentWriteLockBusyError, sleep2, CONTENT_WRITE_LOCK_STALE_MS, heldRoots, getFormatExtension, isDataOnlyFormat, asRecord, DEFAULT_SEO_FIELD_NAMES, asRecord2, asText, asFlag, asImageUrl, asOgType, asTwitterCard, RESTRICTED_REFERENCE_MARKER, SEO_GROUP_FIELDS, MAX_WARNED, warned5, SchemaUnavailableError, isValidId, parseTypedFilename, EMPTY_LOGICAL_PATH, ENTRY_EXTENSIONS, ContentIdIndex, CONTENT_EXTENSIONS, registry, finalization, log4, META_DIR2, entryTypeMetaSchema, collectionMetaSchema, rootCollectionMetaSchema, fingerprints, editDistance, closestName, forEachEntryType, collectEntryTypeNames, validateReferenceEntryTypes, isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER, storage, log5, BaseBranchUnresolvedError, NETWORK_SCHEME_PATTERN, TRANSPORT_HELPER_PATTERN, LEADING_OPTION_PATTERN, WINDOWS_DRIVE_PATTERN, COLON_BEFORE_SLASH_PATTERN, MISSING_REMOTE_REF_REASONS, CANOPY_META_DIR, SCHEMA_CACHE_VERSION, MTIME_CHECK_DEBOUNCE_MS, SNAPSHOT_MEMO_MAX_BYTES, SCHEMA_CACHE_FILE, SCHEMA_GENERATION_RESOURCE, reportedIssues, BranchSchemaCache, RESOURCE, BranchPathError, resolveContentBranchesRoot, ContentStoreError, ContentConflictError, EntryGoneError, BranchSyncingError, DuplicateContentIdError, UrlPathConflictError, isEmptyFieldValue, DEFAULT_INDEX_FRESHNESS_INTERVAL_MS, FORCED_REFRESH_MIN_INTERVAL_MS, STALE_LOOKUP, isMissingReference, createReferenceResolveCache, ContentStore, ATTR_CONTENT, BLOCK_PREFIX, BLOCK_SUFFIX, BLOCK_RESTORE_RE, INLINE_PREFIX, INLINE_SUFFIX, INLINE_RESTORE_RE, DEFAULT_REFERENCE_RENDERING, FLAT_CELL_TYPES, log6, BASE58_CHAR, ENTRY_LINK_PATTERN, INLINE_LINK_TARGET, BranchMetadataCorruptError, BRANCH_META_DIR, BRANCH_META_FILE, branchMetadataFilePath, UNKNOWN_TIME, enumOf, branchMetadataShape, branchMetadataSchema, branchMetadataFileSchema, log7, REGISTRY_FILE, REGISTRY_STALE_FILE, REGISTRY_TEMP_FILE, REGISTRY_VERSION, RESOURCE2, GET_MISS_REFRESH_MIN_INTERVAL_MS, snapshotSchema, reportedUnreadable, BranchRegistry, log8, OccWriteConflictError, CURRENT_SCHEMA_VERSION, BranchMetadataConflictError, BranchMetadataFileManager, loadBranchContext, GitProcessError, STDERR_CAP_BYTES, KILL_GRACE_MS, log9, GIT_ENV_PASSTHROUGH, FORCE_C_LOCALE, GIT_NETWORK_ENV_PASSTHROUGH, NO_AUTO_GC_CONFIG, HASH_INACTIVITY_TIMEOUT_MS, MAX_HASH_RUNS, LS_REMOTE_INACTIVITY_TIMEOUT_MS, symlinkBlobSha, REMOTE_GIT_CONFIG, REMOTE_GIT_MAX_LOOSE_OBJECTS, REMOTE_GIT_MAX_PACKS, PARALLEL_CHECKOUT_ARGS, remoteInitLocks, GITHUB_TRACKING_REF_PREFIX, BaseBranchMissingError, GitConflictError, GitRemoteRefMissingError, RemoteNotReadyError, SettingsRemoteUnreadableError, SettingsBranchDivergedError, SETTINGS_BRANCH_FILES, SettingsBranchHasContentHistoryError, GitManager, PUBLISH_LOCK_WAIT_MS, CREATE_RESIDUE_MIN_QUIET_MS, STAGING_MAX_AGE_MS, REPAIR_MAX_AGE_MS, DELETING_MAX_AGE_MS, SETTINGS_TRASH_MAX_AGE_MS, BranchProvisioningBusyError, BranchDirOccupiedError, STAGING_PREFIX, REPAIR_PREFIX, TRASH_PREFIX, stagingDirName, NO_GIT, RENAME_BLOCKED, SPARSE_CONE_RECORD, defaultSink, sink, ProvisionLog, testHooks, provisionsInFlight, BranchWorkspaceManager, log10, normalizePath, parseEntryParts, parseEntryData, readEntryData, createReferenceResolver, withoutUnavailableEntries, resolveCollectionItemReferences, looksLikeMalformedEntry, listCollectionEntries, MAX_INPUT_PIXELS, CROP_PRECISION, KNOWN_FIELD_TYPES, STRING_FIELD_TYPES, selectOptionValues, GENERATED_RECORD_FILENAME, SAFE_BUILD_ID, BUILD_ID_RULE, jiti;
8436
8438
  var init_generate_ai_content = __esm({
8437
8439
  "dist/cli/generate-ai-content.js"() {
8438
8440
  "use strict";
@@ -10742,6 +10744,7 @@ var init_generate_ai_content = __esm({
10742
10744
  init_field_traversal();
10743
10745
  init_atomic_write2();
10744
10746
  init_error3();
10747
+ init_logger2();
10745
10748
  log4 = createDebugLogger2({ prefix: "ResourceGeneration" });
10746
10749
  META_DIR2 = ".canopy-meta";
10747
10750
  init_logger2();
@@ -10878,12 +10881,18 @@ var init_generate_ai_content = __esm({
10878
10881
  init_logger2();
10879
10882
  SCHEMA_CACHE_VERSION = 4;
10880
10883
  MTIME_CHECK_DEBOUNCE_MS = 1e3;
10884
+ SNAPSHOT_MEMO_MAX_BYTES = 64 * 1024 * 1024;
10881
10885
  SCHEMA_CACHE_FILE = "schema-cache.json";
10882
10886
  SCHEMA_GENERATION_RESOURCE = "schema";
10883
10887
  reportedIssues = /* @__PURE__ */ new Set();
10884
10888
  BranchSchemaCache = class {
10885
10889
  constructor(mode = "prod") {
10886
10890
  this.lastMtimeCheck = /* @__PURE__ */ new Map();
10891
+ this.snapshots = new LRUCache({
10892
+ maxSize: SNAPSHOT_MEMO_MAX_BYTES,
10893
+ sizeCalculation: (snapshot) => Math.max(1, snapshot.bytes)
10894
+ });
10895
+ this.buildResolves = /* @__PURE__ */ new Map();
10887
10896
  this.devMode = mode === "dev";
10888
10897
  }
10889
10898
  /**
@@ -10913,15 +10922,12 @@ var init_generate_ai_content = __esm({
10913
10922
  async loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName) {
10914
10923
  const contentRoot = path82.join(branchRoot, contentRootName);
10915
10924
  const fromCheckout = this.readsCheckout(branchRoot);
10925
+ if (fromCheckout && isBuildMode()) {
10926
+ return this.resolveForBuild(branchRoot, entrySchemaRegistry, contentRootName);
10927
+ }
10916
10928
  if (!fromCheckout) {
10917
10929
  const cachePath = path82.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
10918
- let cacheData = null;
10919
- try {
10920
- const cacheContent = await fs72.readFile(cachePath, "utf-8");
10921
- cacheData = JSON.parse(cacheContent);
10922
- } catch {
10923
- cacheData = null;
10924
- }
10930
+ let cacheData = await this.readSnapshot(cachePath);
10925
10931
  if (cacheData && cacheData.version === SCHEMA_CACHE_VERSION && cacheData.registryFingerprint === registryFingerprint(entrySchemaRegistry)) {
10926
10932
  const read = await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
10927
10933
  if (isGenerationCurrent(cacheData.generation, read)) {
@@ -10951,6 +10957,42 @@ var init_generate_ai_content = __esm({
10951
10957
  fromCheckout
10952
10958
  });
10953
10959
  }
10960
+ resolveForBuild(branchRoot, entrySchemaRegistry, contentRootName) {
10961
+ const key = `${path82.resolve(branchRoot, contentRootName)}\0${registryFingerprint(entrySchemaRegistry)}`;
10962
+ let pending = this.buildResolves.get(key);
10963
+ if (!pending) {
10964
+ pending = this.resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, {
10965
+ fromCheckout: true
10966
+ });
10967
+ this.buildResolves.set(key, pending);
10968
+ pending.catch(() => this.buildResolves.delete(key));
10969
+ }
10970
+ return pending;
10971
+ }
10972
+ /** The parsed cache file, or null when it is absent or unreadable. */
10973
+ async readSnapshot(cachePath) {
10974
+ let handle;
10975
+ try {
10976
+ handle = await fs72.open(cachePath, "r");
10977
+ } catch {
10978
+ return null;
10979
+ }
10980
+ try {
10981
+ const stat = await handle.stat({ bigint: true });
10982
+ const identity = fileIdentity(stat);
10983
+ const memo = this.snapshots.get(cachePath);
10984
+ if (memo?.identity === identity)
10985
+ return memo.entry;
10986
+ const entry = JSON.parse(await fs72.readFile(handle, "utf-8"));
10987
+ this.snapshots.set(cachePath, { identity, entry, bytes: Number(stat.size) });
10988
+ return entry;
10989
+ } catch {
10990
+ return null;
10991
+ } finally {
10992
+ await handle.close().catch(() => {
10993
+ });
10994
+ }
10995
+ }
10954
10996
  /**
10955
10997
  * Resolve the schema fresh from disk and persist it (subject to the
10956
10998
  * skip-persist rule below), UNCONDITIONALLY -- never through the cache-read
@@ -10992,14 +11034,22 @@ var init_generate_ai_content = __esm({
10992
11034
  };
10993
11035
  await fs72.mkdir(cacheDir, { recursive: true });
10994
11036
  const tmpPath = path82.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
10995
- await fs72.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
11037
+ const body = JSON.stringify(newCache);
11038
+ let identity;
10996
11039
  try {
11040
+ await fs72.writeFile(tmpPath, body, "utf-8");
11041
+ identity = fileIdentity(await fs72.stat(tmpPath, { bigint: true }));
10997
11042
  await fs72.rename(tmpPath, cachePath);
10998
11043
  } catch (err2) {
10999
11044
  await fs72.unlink(tmpPath).catch(() => {
11000
11045
  });
11001
11046
  throw err2;
11002
11047
  }
11048
+ this.snapshots.set(cachePath, {
11049
+ identity,
11050
+ entry: JSON.parse(body),
11051
+ bytes: Buffer.byteLength(body)
11052
+ });
11003
11053
  }
11004
11054
  }
11005
11055
  reportSchemaIssues(issues);
@@ -12343,9 +12393,20 @@ var init_generate_ai_content = __esm({
12343
12393
  mergedAt: z72.string().optional(),
12344
12394
  rebaseFailure: z72.object({ message: z72.string(), firstAt: z72.string(), lastAt: z72.string() }).optional(),
12345
12395
  historyRewrittenFrom: z72.string().optional(),
12396
+ lastGitHubTip: z72.string().optional(),
12397
+ keptOverGitHub: z72.array(z72.string()).optional(),
12398
+ rewrittenOnGitHub: z72.string().optional(),
12399
+ submitUnfinished: z72.boolean().optional(),
12400
+ submitNotQueued: z72.boolean().optional(),
12401
+ unpublishedAtMerge: z72.boolean().optional(),
12346
12402
  syncFailureReason: z72.string().optional(),
12347
12403
  editors: z72.array(z72.string()).optional(),
12348
12404
  uncommittedEditors: z72.array(z72.string()).optional(),
12405
+ // Dropped when malformed: it only decorates the branch card, so it never fails a read.
12406
+ pullRequestHold: z72.discriminatedUnion("kind", [
12407
+ z72.object({ kind: z72.literal("conflicts") }),
12408
+ z72.object({ kind: z72.literal("retargeted"), base: z72.string() })
12409
+ ]).optional().catch(void 0),
12349
12410
  // Dropped when malformed: it only decorates the PR body, so it never fails a read.
12350
12411
  review: z72.object({
12351
12412
  state: z72.enum(["approved", "changes-requested"]),
@@ -17797,9 +17858,7 @@ async function bumpResourceGeneration2(root, resource, options) {
17797
17858
  await atomicWriteFile3(resourceGenerationPath2(root, resource), token);
17798
17859
  return token;
17799
17860
  } catch (err2) {
17800
- log11.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
17801
- error: getErrorMessage(err2)
17802
- });
17861
+ canopyLogWarn3(`CanopyCMS: failed to bump ${resource} generation for ${root}:`, getErrorMessage(err2));
17803
17862
  if (options?.mustSucceed)
17804
17863
  throw err2;
17805
17864
  return null;
@@ -17828,6 +17887,7 @@ var init_resource_generation = __esm({
17828
17887
  init_atomic_write3();
17829
17888
  init_error();
17830
17889
  init_debug2();
17890
+ init_logger3();
17831
17891
  log11 = createDebugLogger3({ prefix: "ResourceGeneration" });
17832
17892
  META_DIR3 = ".canopy-meta";
17833
17893
  }
@@ -18064,10 +18124,12 @@ var init_meta_loader = __esm({
18064
18124
  });
18065
18125
 
18066
18126
  // dist/branch-schema-cache.js
18067
- var SCHEMA_GENERATION_RESOURCE2;
18127
+ import { LRUCache as LRUCache2 } from "lru-cache";
18128
+ var SNAPSHOT_MEMO_MAX_BYTES2, SCHEMA_GENERATION_RESOURCE2;
18068
18129
  var init_branch_schema_cache = __esm({
18069
18130
  "dist/branch-schema-cache.js"() {
18070
18131
  "use strict";
18132
+ SNAPSHOT_MEMO_MAX_BYTES2 = 64 * 1024 * 1024;
18071
18133
  SCHEMA_GENERATION_RESOURCE2 = "schema";
18072
18134
  }
18073
18135
  });
@@ -19153,9 +19215,20 @@ var init_branch_metadata_file = __esm({
19153
19215
  mergedAt: z18.string().optional(),
19154
19216
  rebaseFailure: z18.object({ message: z18.string(), firstAt: z18.string(), lastAt: z18.string() }).optional(),
19155
19217
  historyRewrittenFrom: z18.string().optional(),
19218
+ lastGitHubTip: z18.string().optional(),
19219
+ keptOverGitHub: z18.array(z18.string()).optional(),
19220
+ rewrittenOnGitHub: z18.string().optional(),
19221
+ submitUnfinished: z18.boolean().optional(),
19222
+ submitNotQueued: z18.boolean().optional(),
19223
+ unpublishedAtMerge: z18.boolean().optional(),
19156
19224
  syncFailureReason: z18.string().optional(),
19157
19225
  editors: z18.array(z18.string()).optional(),
19158
19226
  uncommittedEditors: z18.array(z18.string()).optional(),
19227
+ // Dropped when malformed: it only decorates the branch card, so it never fails a read.
19228
+ pullRequestHold: z18.discriminatedUnion("kind", [
19229
+ z18.object({ kind: z18.literal("conflicts") }),
19230
+ z18.object({ kind: z18.literal("retargeted"), base: z18.string() })
19231
+ ]).optional().catch(void 0),
19159
19232
  // Dropped when malformed: it only decorates the PR body, so it never fails a read.
19160
19233
  review: z18.object({
19161
19234
  state: z18.enum(["approved", "changes-requested"]),
@@ -4404,6 +4404,7 @@ init_error();
4404
4404
  import fs5 from "node:fs/promises";
4405
4405
  import path7 from "node:path";
4406
4406
  import { randomUUID } from "node:crypto";
4407
+ init_logger();
4407
4408
  var log4 = createDebugLogger({ prefix: "ResourceGeneration" });
4408
4409
  var META_DIR2 = ".canopy-meta";
4409
4410
  function resourceGenerationPath(root, resource) {
@@ -4415,9 +4416,7 @@ async function bumpResourceGeneration(root, resource, options) {
4415
4416
  await atomicWriteFile(resourceGenerationPath(root, resource), token);
4416
4417
  return token;
4417
4418
  } catch (err) {
4418
- log4.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
4419
- error: getErrorMessage(err)
4420
- });
4419
+ canopyLogWarn(`CanopyCMS: failed to bump ${resource} generation for ${root}:`, getErrorMessage(err));
4421
4420
  if (options?.mustSucceed)
4422
4421
  throw err;
4423
4422
  return null;
@@ -4443,6 +4442,7 @@ function isGenerationCurrent(snapshotToken, read) {
4443
4442
  // dist/branch-schema-cache.js
4444
4443
  import fs7 from "node:fs/promises";
4445
4444
  import path8 from "node:path";
4445
+ import { LRUCache } from "lru-cache";
4446
4446
 
4447
4447
  // dist/schema/meta-loader.js
4448
4448
  import { promises as fs6 } from "fs";
@@ -4902,6 +4902,7 @@ async function resolveBaseBranch(options) {
4902
4902
  init_logger();
4903
4903
  var SCHEMA_CACHE_VERSION = 4;
4904
4904
  var MTIME_CHECK_DEBOUNCE_MS = 1e3;
4905
+ var SNAPSHOT_MEMO_MAX_BYTES = 64 * 1024 * 1024;
4905
4906
  var SCHEMA_CACHE_FILE = "schema-cache.json";
4906
4907
  var SCHEMA_GENERATION_RESOURCE = "schema";
4907
4908
  var reportedIssues = /* @__PURE__ */ new Set();
@@ -4935,6 +4936,9 @@ async function isStaleByMtime(dir, cachedAt) {
4935
4936
  }
4936
4937
  return false;
4937
4938
  }
4939
+ function fileIdentity(stat) {
4940
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}`;
4941
+ }
4938
4942
  async function schemaCacheDir(branchRoot) {
4939
4943
  const gitDir = path8.join(branchRoot, ".git");
4940
4944
  const isClone = await fs7.stat(gitDir).then((stat) => stat.isDirectory(), () => false);
@@ -4943,6 +4947,11 @@ async function schemaCacheDir(branchRoot) {
4943
4947
  var BranchSchemaCache = class {
4944
4948
  constructor(mode = "prod") {
4945
4949
  this.lastMtimeCheck = /* @__PURE__ */ new Map();
4950
+ this.snapshots = new LRUCache({
4951
+ maxSize: SNAPSHOT_MEMO_MAX_BYTES,
4952
+ sizeCalculation: (snapshot) => Math.max(1, snapshot.bytes)
4953
+ });
4954
+ this.buildResolves = /* @__PURE__ */ new Map();
4946
4955
  this.devMode = mode === "dev";
4947
4956
  }
4948
4957
  /**
@@ -4972,15 +4981,12 @@ var BranchSchemaCache = class {
4972
4981
  async loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName) {
4973
4982
  const contentRoot = path8.join(branchRoot, contentRootName);
4974
4983
  const fromCheckout = this.readsCheckout(branchRoot);
4984
+ if (fromCheckout && isBuildMode()) {
4985
+ return this.resolveForBuild(branchRoot, entrySchemaRegistry, contentRootName);
4986
+ }
4975
4987
  if (!fromCheckout) {
4976
4988
  const cachePath = path8.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
4977
- let cacheData = null;
4978
- try {
4979
- const cacheContent = await fs7.readFile(cachePath, "utf-8");
4980
- cacheData = JSON.parse(cacheContent);
4981
- } catch {
4982
- cacheData = null;
4983
- }
4989
+ let cacheData = await this.readSnapshot(cachePath);
4984
4990
  if (cacheData && cacheData.version === SCHEMA_CACHE_VERSION && cacheData.registryFingerprint === registryFingerprint(entrySchemaRegistry)) {
4985
4991
  const read = await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
4986
4992
  if (isGenerationCurrent(cacheData.generation, read)) {
@@ -5010,6 +5016,42 @@ var BranchSchemaCache = class {
5010
5016
  fromCheckout
5011
5017
  });
5012
5018
  }
5019
+ resolveForBuild(branchRoot, entrySchemaRegistry, contentRootName) {
5020
+ const key = `${path8.resolve(branchRoot, contentRootName)}\0${registryFingerprint(entrySchemaRegistry)}`;
5021
+ let pending = this.buildResolves.get(key);
5022
+ if (!pending) {
5023
+ pending = this.resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, {
5024
+ fromCheckout: true
5025
+ });
5026
+ this.buildResolves.set(key, pending);
5027
+ pending.catch(() => this.buildResolves.delete(key));
5028
+ }
5029
+ return pending;
5030
+ }
5031
+ /** The parsed cache file, or null when it is absent or unreadable. */
5032
+ async readSnapshot(cachePath) {
5033
+ let handle;
5034
+ try {
5035
+ handle = await fs7.open(cachePath, "r");
5036
+ } catch {
5037
+ return null;
5038
+ }
5039
+ try {
5040
+ const stat = await handle.stat({ bigint: true });
5041
+ const identity = fileIdentity(stat);
5042
+ const memo = this.snapshots.get(cachePath);
5043
+ if (memo?.identity === identity)
5044
+ return memo.entry;
5045
+ const entry = JSON.parse(await fs7.readFile(handle, "utf-8"));
5046
+ this.snapshots.set(cachePath, { identity, entry, bytes: Number(stat.size) });
5047
+ return entry;
5048
+ } catch {
5049
+ return null;
5050
+ } finally {
5051
+ await handle.close().catch(() => {
5052
+ });
5053
+ }
5054
+ }
5013
5055
  /**
5014
5056
  * Resolve the schema fresh from disk and persist it (subject to the
5015
5057
  * skip-persist rule below), UNCONDITIONALLY -- never through the cache-read
@@ -5051,14 +5093,22 @@ var BranchSchemaCache = class {
5051
5093
  };
5052
5094
  await fs7.mkdir(cacheDir, { recursive: true });
5053
5095
  const tmpPath = path8.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
5054
- await fs7.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
5096
+ const body = JSON.stringify(newCache);
5097
+ let identity;
5055
5098
  try {
5099
+ await fs7.writeFile(tmpPath, body, "utf-8");
5100
+ identity = fileIdentity(await fs7.stat(tmpPath, { bigint: true }));
5056
5101
  await fs7.rename(tmpPath, cachePath);
5057
5102
  } catch (err) {
5058
5103
  await fs7.unlink(tmpPath).catch(() => {
5059
5104
  });
5060
5105
  throw err;
5061
5106
  }
5107
+ this.snapshots.set(cachePath, {
5108
+ identity,
5109
+ entry: JSON.parse(body),
5110
+ bytes: Buffer.byteLength(body)
5111
+ });
5062
5112
  }
5063
5113
  }
5064
5114
  reportSchemaIssues(issues);
@@ -7797,9 +7847,20 @@ var branchMetadataShape = {
7797
7847
  mergedAt: z7.string().optional(),
7798
7848
  rebaseFailure: z7.object({ message: z7.string(), firstAt: z7.string(), lastAt: z7.string() }).optional(),
7799
7849
  historyRewrittenFrom: z7.string().optional(),
7850
+ lastGitHubTip: z7.string().optional(),
7851
+ keptOverGitHub: z7.array(z7.string()).optional(),
7852
+ rewrittenOnGitHub: z7.string().optional(),
7853
+ submitUnfinished: z7.boolean().optional(),
7854
+ submitNotQueued: z7.boolean().optional(),
7855
+ unpublishedAtMerge: z7.boolean().optional(),
7800
7856
  syncFailureReason: z7.string().optional(),
7801
7857
  editors: z7.array(z7.string()).optional(),
7802
7858
  uncommittedEditors: z7.array(z7.string()).optional(),
7859
+ // Dropped when malformed: it only decorates the branch card, so it never fails a read.
7860
+ pullRequestHold: z7.discriminatedUnion("kind", [
7861
+ z7.object({ kind: z7.literal("conflicts") }),
7862
+ z7.object({ kind: z7.literal("retargeted"), base: z7.string() })
7863
+ ]).optional().catch(void 0),
7803
7864
  // Dropped when malformed: it only decorates the PR body, so it never fails a read.
7804
7865
  review: z7.object({
7805
7866
  state: z7.enum(["approved", "changes-requested"]),
package/dist/cli/init.js CHANGED
@@ -1207,7 +1207,7 @@ async function failTask(taskDir, taskId, error, logger = nullLogger) {
1207
1207
  });
1208
1208
  logger.debug("Failed task", { id: taskId, error });
1209
1209
  }
1210
- async function retryTask(taskDir, taskId, error, logger = nullLogger) {
1210
+ async function retryTask(taskDir, taskId, error, logger = nullLogger, options = {}) {
1211
1211
  const processingPath = path6.join(taskDir, "processing", `${taskId}.json`);
1212
1212
  const pendingDir = path6.join(taskDir, "pending");
1213
1213
  const pendingPath = path6.join(pendingDir, `${taskId}.json`);
@@ -1228,7 +1228,7 @@ async function retryTask(taskDir, taskId, error, logger = nullLogger) {
1228
1228
  throw err;
1229
1229
  }
1230
1230
  const retryCount = (task.retryCount ?? 0) + 1;
1231
- const backoffMs = Math.min(5e3 * Math.pow(2, retryCount - 1), 6e4);
1231
+ const backoffMs = options.delayMs ?? Math.min(5e3 * Math.pow(2, retryCount - 1), 6e4);
1232
1232
  task.status = "pending";
1233
1233
  task.retryCount = retryCount;
1234
1234
  task.retryAfter = new Date(Date.now() + backoffMs).toISOString();
@@ -89,20 +89,34 @@ export interface ContentReadMeta {
89
89
  /** The entry's URL path with no query, by the rule `listEntries` uses. */
90
90
  urlPath: string;
91
91
  }
92
+ /** What a content read resolves to. */
93
+ export interface ContentReadResult<T = unknown> {
94
+ data: T;
95
+ /**
96
+ * The requested `entryPath` and slug as a percent-encoded URL path, plus `?branch=<name>`.
97
+ * Through an entry-type `entryPath` it is not the entry's URL. Compare entries by
98
+ * `meta.urlPath`.
99
+ */
100
+ path: string;
101
+ meta: ContentReadMeta;
102
+ }
92
103
  export interface ContentReader {
93
- read: <T = unknown>(input: ReadContentInput, message?: string) => Promise<{
94
- data: T;
95
- /**
96
- * The requested `entryPath` and slug as a percent-encoded URL path, plus `?branch=<name>`.
97
- * Through an entry-type `entryPath` it is not the entry's URL. Compare entries by
98
- * `meta.urlPath`.
99
- */
100
- path: string;
101
- meta: ContentReadMeta;
102
- }>;
104
+ read: <T = unknown>(input: ReadContentInput, message?: string) => Promise<ContentReadResult<T>>;
105
+ }
106
+ /** @internal The reader `CanopyContext` (context.ts) builds on. */
107
+ export interface ContentReaderInternal extends ContentReader {
108
+ /**
109
+ * Read the first of `inputs` that resolves, against one store resolved once for their branch,
110
+ * so a lookup trying several paths resolves the branch and its schema once. Every input must
111
+ * name the same branch and user. An error `isMiss` accepts moves on to the next input; null
112
+ * when every input misses, or when resolving the branch itself throws a miss.
113
+ */
114
+ readFirst: <T = unknown>(inputs: readonly ReadContentInput[], isMiss: (err: unknown, input: ReadContentInput) => boolean) => Promise<ContentReadResult<T> | null>;
103
115
  }
104
116
  /**
105
117
  * Server-side helper to read content directly from a branch workspace. A branch with no workspace
106
118
  * reads as NOT_FOUND unless `allowCreateBranch` or a `getBranchContext` resolver says otherwise.
107
119
  */
108
120
  export declare const createContentReader: (options: ContentReaderOptions) => ContentReader;
121
+ /** @internal {@link createContentReader} plus `readFirst`, for context.ts. */
122
+ export declare const createContentReaderInternal: (options: ContentReaderOptions) => ContentReaderInternal;