canopycms 0.0.67 → 0.0.68-int.93

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 (107) hide show
  1. package/dist/ai/handler.js +8 -0
  2. package/dist/api/admin-branch-health.js +12 -7
  3. package/dist/api/admin.d.ts +8 -8
  4. package/dist/api/branch-status.js +6 -2
  5. package/dist/api/client.d.ts +12 -0
  6. package/dist/api/client.js +35 -11
  7. package/dist/api/content.d.ts +6 -5
  8. package/dist/api/content.js +38 -39
  9. package/dist/api/entries.js +6 -8
  10. package/dist/api/github-sync.d.ts +10 -1
  11. package/dist/api/github-sync.js +20 -10
  12. package/dist/api/reference-options.js +1 -1
  13. package/dist/api/request-url.d.ts +8 -0
  14. package/dist/api/request-url.js +15 -0
  15. package/dist/api/resolve-references.js +2 -2
  16. package/dist/api/settings-helpers.d.ts +1 -1
  17. package/dist/api/settings-helpers.js +1 -4
  18. package/dist/authorization/content.d.ts +5 -4
  19. package/dist/authorization/content.js +5 -5
  20. package/dist/authorization/path.d.ts +11 -6
  21. package/dist/authorization/path.js +11 -6
  22. package/dist/authorization/types.d.ts +2 -2
  23. package/dist/branch-health.js +3 -3
  24. package/dist/branch-schema-cache.d.ts +5 -3
  25. package/dist/branch-schema-cache.js +29 -11
  26. package/dist/branch-workspace.js +2 -2
  27. package/dist/cli/cli.js +285 -168
  28. package/dist/cli/generate-ai-content.js +190 -109
  29. package/dist/cli/github-app-manifest.d.ts +4 -3
  30. package/dist/cli/github-app-manifest.js +4 -3
  31. package/dist/cli/init.js +15 -10
  32. package/dist/cli/sync.js +15 -13
  33. package/dist/config/schemas/config.d.ts +6 -3
  34. package/dist/config/schemas/config.js +3 -1
  35. package/dist/config/schemas/field.js +1 -0
  36. package/dist/config/types.d.ts +11 -2
  37. package/dist/config/validation.d.ts +6 -0
  38. package/dist/config/validation.js +37 -0
  39. package/dist/content-listing.d.ts +2 -2
  40. package/dist/content-listing.js +3 -3
  41. package/dist/content-reader.js +4 -4
  42. package/dist/content-store.d.ts +2 -0
  43. package/dist/content-store.js +2 -1
  44. package/dist/content-tree.js +1 -1
  45. package/dist/context.d.ts +16 -7
  46. package/dist/context.js +116 -67
  47. package/dist/editor/Editor.js +4 -4
  48. package/dist/editor/FormRenderer.js +20 -5
  49. package/dist/editor/admin/SystemHealthPanel.js +37 -6
  50. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  51. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  52. package/dist/editor/hooks/useBranchesData.js +3 -1
  53. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  54. package/dist/editor/hooks/useDraftManager.js +2 -1
  55. package/dist/editor/hooks/useEntriesData.js +2 -2
  56. package/dist/editor/hooks/useEntryManager.js +17 -11
  57. package/dist/entry-schema-registry.js +2 -1
  58. package/dist/git-manager.d.ts +20 -0
  59. package/dist/git-manager.js +50 -4
  60. package/dist/github-service.d.ts +8 -0
  61. package/dist/github-service.js +5 -1
  62. package/dist/http/handler.js +24 -9
  63. package/dist/http/index.d.ts +2 -0
  64. package/dist/http/index.js +2 -0
  65. package/dist/http/router.d.ts +2 -0
  66. package/dist/http/router.js +2 -1
  67. package/dist/http/worker-not-ready.d.ts +9 -0
  68. package/dist/http/worker-not-ready.js +17 -0
  69. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  70. package/dist/operating-mode/types.d.ts +0 -3
  71. package/dist/paths/index.d.ts +1 -1
  72. package/dist/paths/index.js +1 -1
  73. package/dist/paths/normalize.d.ts +7 -0
  74. package/dist/paths/normalize.js +9 -0
  75. package/dist/reference-resolver.d.ts +3 -3
  76. package/dist/reference-resolver.js +3 -2
  77. package/dist/resolve-canopy-user.js +2 -1
  78. package/dist/services.d.ts +12 -5
  79. package/dist/services.js +56 -56
  80. package/dist/settings-workspace.js +36 -0
  81. package/dist/static/seo.d.ts +2 -14
  82. package/dist/static/seo.js +2 -25
  83. package/dist/submission-attribution.d.ts +73 -0
  84. package/dist/submission-attribution.js +221 -0
  85. package/dist/sync-core.js +7 -8
  86. package/dist/types.d.ts +23 -2
  87. package/dist/utils/content-write-lock.d.ts +3 -7
  88. package/dist/utils/content-write-lock.js +6 -4
  89. package/dist/utils/debug.d.ts +8 -0
  90. package/dist/utils/debug.js +10 -2
  91. package/dist/utils/git.d.ts +28 -0
  92. package/dist/utils/git.js +38 -0
  93. package/dist/utils/provisioning-lock.d.ts +15 -5
  94. package/dist/utils/provisioning-lock.js +31 -11
  95. package/dist/utils/request-timing.d.ts +25 -0
  96. package/dist/utils/request-timing.js +101 -0
  97. package/dist/utils/url-prefix.d.ts +15 -0
  98. package/dist/utils/url-prefix.js +26 -0
  99. package/dist/worker/canopy-state.d.ts +45 -0
  100. package/dist/worker/canopy-state.js +75 -0
  101. package/dist/worker/git-sync.d.ts +4 -2
  102. package/dist/worker/git-sync.js +111 -24
  103. package/dist/worker/provisioned-workspace.d.ts +35 -0
  104. package/dist/worker/provisioned-workspace.js +50 -0
  105. package/dist/worker/rebase.js +75 -12
  106. package/dist/worker/task-runner.js +39 -4
  107. package/package.json +1 -1
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Whether the API client ends request paths with `/`: on a `trailingSlash: true` Next host every
3
+ * unslashed call draws a 308 first. `withCanopy` sets `CANOPY_API_TRAILING_SLASH` in Next's `env`
4
+ * config, which Next substitutes for this literal member expression in server and browser bundles
5
+ * (`getNextConfigEnv`, `next/dist/build/define-env.js:54`). The try/catch covers a host whose
6
+ * bundler neither substitutes it nor shims `process` in the browser.
7
+ */
8
+ export function readApiTrailingSlashEnv() {
9
+ try {
10
+ return process.env.CANOPY_API_TRAILING_SLASH === 'true';
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
@@ -4,6 +4,7 @@ import { defineEndpoint } from './route-builder.js';
4
4
  import { ReferenceResolver } from '../reference-resolver.js';
5
5
  import { buildResolvedReference } from '../entry-schema.js';
6
6
  import { computeEntryUrl } from '../utils/entry-url.js';
7
+ import { entryLogicalPath } from '../paths/index.js';
7
8
  import { branchNameSchema, contentIdSchema } from './validators.js';
8
9
  /**
9
10
  * Resolution does sequential per-ID file I/O, so the request body caps how much filesystem work
@@ -39,8 +40,7 @@ const resolveReferencesHandler = async (gc, ctx, req, params, body) => {
39
40
  const result = await resolver.resolve(id);
40
41
  if (result && result.exists && result.collection && result.slug) {
41
42
  // Check path-level read permission before returning content
42
- const resolvedPath = await store.resolveDocumentPath(result.collection, result.slug);
43
- const access = checkAccess(resolvedPath.relativePath, 'read');
43
+ const access = checkAccess(entryLogicalPath(result.collection, result.slug), 'read');
44
44
  if (!access.allowed)
45
45
  continue;
46
46
  // `resolveReferences: false` matches the server-side resolver (content-store.ts's
@@ -30,7 +30,7 @@ export interface CommitSettingsResult {
30
30
  /**
31
31
  * Commit and push settings changes based on the mode.
32
32
  * Both prod and dev use commitToSettingsBranch.
33
- * In dev mode, commits to the settings branch but does not create a PR.
33
+ * Settings changes are never reviewed through a PR (see commitToSettingsBranch).
34
34
  */
35
35
  export declare function commitSettings(ctx: ApiContext, options: {
36
36
  context: {
@@ -23,7 +23,7 @@ export async function getSettingsBranchContext(ctx) {
23
23
  /**
24
24
  * Commit and push settings changes based on the mode.
25
25
  * Both prod and dev use commitToSettingsBranch.
26
- * In dev mode, commits to the settings branch but does not create a PR.
26
+ * Settings changes are never reviewed through a PR (see commitToSettingsBranch).
27
27
  */
28
28
  export async function commitSettings(ctx, options) {
29
29
  const strategy = operatingStrategy(options.mode);
@@ -38,9 +38,6 @@ export async function commitSettings(ctx, options) {
38
38
  branchRoot: options.branchRoot,
39
39
  files: options.fileName,
40
40
  message: options.message,
41
- createPR: strategy.shouldCreateSettingsPR({
42
- autoCreateSettingsPR: ctx.services.config.autoCreateSettingsPR,
43
- }),
44
41
  });
45
42
  if (!result.pushed) {
46
43
  console.warn(`${options.message} committed but not pushed:`, result.error);
@@ -6,12 +6,13 @@ import type { BranchContext } from '../types.js';
6
6
  import type { PermissionLevel } from '../config/index.js';
7
7
  import type { CanopyUser } from '../user.js';
8
8
  import type { ContentAccessResult, ContentAccessDeps } from './types.js';
9
- import type { PhysicalPath } from '../paths/types.js';
9
+ import type { LogicalPath } from '../paths/types.js';
10
10
  /**
11
11
  * A bound content-access checker: evaluates a single path/level synchronously
12
12
  * against permissions that were already loaded when the checker was created.
13
+ * The path is the entry's logical path (see `checkPathAccess`).
13
14
  */
14
- export type ContentAccessChecker = (relativePath: PhysicalPath, level: PermissionLevel) => ContentAccessResult;
15
+ export type ContentAccessChecker = (logicalPath: LogicalPath, level: PermissionLevel) => ContentAccessResult;
15
16
  /**
16
17
  * Resolve the request-constant work once -- branch access, the
17
18
  * settings/permissions root and the path-permission rules, all identical for a
@@ -26,5 +27,5 @@ export declare function createContentAccessChecker(deps: ContentAccessDeps, cont
26
27
  * Single-path check; loads the path permissions from the branch root per call.
27
28
  * @internal Exported for tests.
28
29
  */
29
- export declare function checkContentAccess(deps: ContentAccessDeps, context: BranchContext, branchRoot: string, relativePath: PhysicalPath, user: CanopyUser, level: PermissionLevel): Promise<ContentAccessResult>;
30
- export declare function createCheckContentAccess(deps: ContentAccessDeps): (context: BranchContext, branchRoot: string, relativePath: PhysicalPath, user: CanopyUser, level: PermissionLevel) => Promise<ContentAccessResult>;
30
+ export declare function checkContentAccess(deps: ContentAccessDeps, context: BranchContext, branchRoot: string, logicalPath: LogicalPath, user: CanopyUser, level: PermissionLevel): Promise<ContentAccessResult>;
31
+ export declare function createCheckContentAccess(deps: ContentAccessDeps): (context: BranchContext, branchRoot: string, logicalPath: LogicalPath, user: CanopyUser, level: PermissionLevel) => Promise<ContentAccessResult>;
@@ -28,9 +28,9 @@ export async function createContentAccessChecker(deps, context, branchRoot, user
28
28
  }
29
29
  const rules = await deps.loadPathPermissions(permissionsRoot, deps.mode);
30
30
  const pathChecker = createCheckPathAccess(rules, deps.defaultPathAccess);
31
- return (relativePath, level) => {
31
+ return (logicalPath, level) => {
32
32
  const path = pathChecker({
33
- relativePath,
33
+ logicalPath,
34
34
  user,
35
35
  level,
36
36
  });
@@ -45,10 +45,10 @@ export async function createContentAccessChecker(deps, context, branchRoot, user
45
45
  * Single-path check; loads the path permissions from the branch root per call.
46
46
  * @internal Exported for tests.
47
47
  */
48
- export async function checkContentAccess(deps, context, branchRoot, relativePath, user, level) {
48
+ export async function checkContentAccess(deps, context, branchRoot, logicalPath, user, level) {
49
49
  const check = await createContentAccessChecker(deps, context, branchRoot, user);
50
- return check(relativePath, level);
50
+ return check(logicalPath, level);
51
51
  }
52
52
  export function createCheckContentAccess(deps) {
53
- return (context, branchRoot, relativePath, user, level) => checkContentAccess(deps, context, branchRoot, relativePath, user, level);
53
+ return (context, branchRoot, logicalPath, user, level) => checkContentAccess(deps, context, branchRoot, logicalPath, user, level);
54
54
  }
@@ -1,7 +1,7 @@
1
1
  import type { PathPermission, DefaultPathAccess, PermissionLevel } from '../config/index.js';
2
2
  import type { CanopyUser } from '../user.js';
3
3
  import type { PathPermissionResult } from './types.js';
4
- import type { PhysicalPath } from '../paths/types.js';
4
+ import type { LogicalPath } from '../paths/types.js';
5
5
  /**
6
6
  * Resolve `defaultPathAccess` to an 'allow'/'deny' verdict for one permission
7
7
  * level. String form applies to every level; object form looks up the level,
@@ -11,19 +11,24 @@ import type { PhysicalPath } from '../paths/types.js';
11
11
  */
12
12
  export declare function resolveDefaultPathAccess(defaultAccess: DefaultPathAccess, level: PermissionLevel): 'allow' | 'deny';
13
13
  /**
14
- * Evaluate access for a relative path against config-defined rules.
15
- * Uses defaultAccess when no rule matches. First matching rule wins.
14
+ * Evaluate access for a path against config-defined rules. First matching rule wins;
15
+ * defaultAccess applies when none matches.
16
+ *
17
+ * Rule globs and the checked path are both LOGICAL paths: content-root-prefixed and
18
+ * id-free, the space the Permission Manager writes rules in. An entry's logical path is
19
+ * `<collection logical path>/<slug>` (`entryLogicalPath`), e.g. `content/blog/my-post`, never
20
+ * its on-disk `content/blog.<id>/post.my-post.<id>.json`.
16
21
  * @internal Exported for tests.
17
22
  */
18
- export declare function checkPathAccess({ rules, relativePath, user, defaultAccess, level, }: {
23
+ export declare function checkPathAccess({ rules, logicalPath, user, defaultAccess, level, }: {
19
24
  rules: PathPermission[];
20
- relativePath: PhysicalPath;
25
+ logicalPath: LogicalPath;
21
26
  user: CanopyUser;
22
27
  defaultAccess: DefaultPathAccess;
23
28
  level: PermissionLevel;
24
29
  }): PathPermissionResult;
25
30
  export declare function createCheckPathAccess(rules: PathPermission[], defaultAccess: DefaultPathAccess): (input: {
26
- relativePath: PhysicalPath;
31
+ logicalPath: LogicalPath;
27
32
  user: CanopyUser;
28
33
  level: PermissionLevel;
29
34
  }) => PathPermissionResult;
@@ -5,8 +5,8 @@ function normalize(p) {
5
5
  const normalized = p.split(path.sep).join('/');
6
6
  return normalized.replace(/^\.?\/*/, '');
7
7
  }
8
- function matchesRule(rule, relativePath) {
9
- return minimatch(relativePath, rule.path, { dot: true });
8
+ function matchesRule(rule, logicalPath) {
9
+ return minimatch(logicalPath, rule.path, { dot: true });
10
10
  }
11
11
  function isAllowedByTarget(target, user) {
12
12
  const hasUserConstraint = !!target.allowedUsers?.length;
@@ -32,12 +32,17 @@ export function resolveDefaultPathAccess(defaultAccess, level) {
32
32
  return defaultAccess[level] ?? 'deny';
33
33
  }
34
34
  /**
35
- * Evaluate access for a relative path against config-defined rules.
36
- * Uses defaultAccess when no rule matches. First matching rule wins.
35
+ * Evaluate access for a path against config-defined rules. First matching rule wins;
36
+ * defaultAccess applies when none matches.
37
+ *
38
+ * Rule globs and the checked path are both LOGICAL paths: content-root-prefixed and
39
+ * id-free, the space the Permission Manager writes rules in. An entry's logical path is
40
+ * `<collection logical path>/<slug>` (`entryLogicalPath`), e.g. `content/blog/my-post`, never
41
+ * its on-disk `content/blog.<id>/post.my-post.<id>.json`.
37
42
  * @internal Exported for tests.
38
43
  */
39
- export function checkPathAccess({ rules, relativePath, user, defaultAccess, level, }) {
40
- const normalizedPath = normalize(relativePath);
44
+ export function checkPathAccess({ rules, logicalPath, user, defaultAccess, level, }) {
45
+ const normalizedPath = normalize(logicalPath);
41
46
  // Only Admins bypass all path permissions
42
47
  if (isAdmin(user.groups)) {
43
48
  return { allowed: true, reason: 'admin' };
@@ -1,6 +1,6 @@
1
1
  /**
2
- * A path used in permission rules, e.g. "content/posts".
3
- * SECURITY CRITICAL: always validated to prevent path traversal.
2
+ * A logical-path glob used in permission rules, e.g. "content/posts/**" (see
3
+ * `PathPermission.path`). SECURITY CRITICAL: always validated to prevent path traversal.
4
4
  */
5
5
  export type PermissionPath = string & {
6
6
  readonly __brand: 'PermissionPath';
@@ -5,16 +5,16 @@ import { ContentIdIndex } from './content-id-index.js';
5
5
  import { sanitizeBranchName } from './paths/branch-name.js';
6
6
  import { getErrorMessage, isNodeError, isNotFoundError } from './utils/error.js';
7
7
  import { isRebaseInProgress } from './utils/git.js';
8
+ import { branchProvisioningLockName } from './utils/provisioning-lock.js';
8
9
  /**
9
- * The provisioning lock marker's path for a branch directory, matching what
10
- * `branch-workspace.ts`'s `ensureGitWorkspace()` passes exactly.
10
+ * The provisioning lock marker's path for a branch directory.
11
11
  *
12
12
  * `acquireProvisioningLock` passes the name as `lockfilePath`, overriding
13
13
  * proper-lockfile's default `${target}.lock`, so the marker (a mkdir-based
14
14
  * directory) sits at exactly this path — no extra `.lock` suffix.
15
15
  */
16
16
  function provisioningLockPath(baseRoot, dirName) {
17
- return path.join(baseRoot, `.${dirName}.init.lock`);
17
+ return path.join(baseRoot, branchProvisioningLockName(dirName));
18
18
  }
19
19
  /** Stat the provisioning lock marker, if present. Never throws. */
20
20
  async function readProvisioningLock(baseRoot, dirName) {
@@ -2,6 +2,8 @@ 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
4
  import type { EntrySchemaRegistry, SchemaResolutionResult } from './schema/types.js';
5
+ /** File name of the schema cache inside {@link schemaCacheDir}. */
6
+ export declare const SCHEMA_CACHE_FILE = "schema-cache.json";
5
7
  /** resource-generation.ts resource key for the schema cache's marker. */
6
8
  export declare const SCHEMA_GENERATION_RESOURCE = "schema";
7
9
  /** @internal Exported for tests. */
@@ -18,8 +20,8 @@ export interface BranchSchemaCacheEntry {
18
20
  generation: string | null;
19
21
  }
20
22
  /**
21
- * Per-branch schema cache: a file at {branchRoot}/.canopy-meta/schema-cache.json
22
- * with no in-memory layer, so it stays coherent across Lambda invocations.
23
+ * Per-branch schema cache: one file in {@link schemaCacheDir} with no in-memory
24
+ * layer, so it stays coherent across Lambda invocations.
23
25
  *
24
26
  * Freshness follows the generation-marker protocol owned by
25
27
  * resource-generation.ts, and this is one of that protocol's durable-snapshot
@@ -39,7 +41,7 @@ export declare class BranchSchemaCache {
39
41
  /**
40
42
  * Whether to skip the on-disk cache for this branchRoot.
41
43
  *
42
- * Never write `.canopy-meta/` at the project root, whichever entrypoint
44
+ * Never write a schema cache at the project root, whichever entrypoint
43
45
  * produced the cwd branchRoot. branchRoot equals process.cwd() only in the
44
46
  * synthetic contexts static deployments and build phases use; a real branch
45
47
  * root is always nested under the workspace.
@@ -5,10 +5,14 @@ import { flattenSchema } from './config/flatten.js';
5
5
  import { validateReferenceEntryTypes } from './validation/entry-type-reference-validator.js';
6
6
  import { isBuildMode } from './build-mode.js';
7
7
  import { bumpResourceGeneration, readResourceGeneration, isGenerationCurrent, } from './resource-generation.js';
8
+ import { timeRequestPhase } from './utils/request-timing.js';
9
+ import { CANOPY_META_DIR } from './utils/git.js';
8
10
  /** Bump when BranchSchemaCacheEntry shape changes to auto-invalidate stale caches */
9
11
  const SCHEMA_CACHE_VERSION = 3;
10
12
  /** Minimum interval between mtime staleness checks (ms) */
11
13
  const MTIME_CHECK_DEBOUNCE_MS = 1000;
14
+ /** File name of the schema cache inside {@link schemaCacheDir}. */
15
+ export const SCHEMA_CACHE_FILE = 'schema-cache.json';
12
16
  /** resource-generation.ts resource key for the schema cache's marker. */
13
17
  export const SCHEMA_GENERATION_RESOURCE = 'schema';
14
18
  /**
@@ -43,8 +47,22 @@ async function isStaleByMtime(dir, cachedAt) {
43
47
  return false;
44
48
  }
45
49
  /**
46
- * Per-branch schema cache: a file at {branchRoot}/.canopy-meta/schema-cache.json
47
- * with no in-memory layer, so it stays coherent across Lambda invocations.
50
+ * Directory holding a branch's schema cache: `{branchRoot}/.git/canopycms` when
51
+ * the branch root is a full clone (every canopycms branch workspace is), else
52
+ * `{branchRoot}/.canopy-meta`. Under `.git/` because nothing there is ever
53
+ * tracked or reported by `git status`, so the cache can neither dirty the
54
+ * workspace nor enter a commit, even where an adopter has committed
55
+ * `.canopy-meta/`. It also lives and dies with the clone, so a re-clone never
56
+ * reads its predecessor's snapshot.
57
+ */
58
+ async function schemaCacheDir(branchRoot) {
59
+ const gitDir = path.join(branchRoot, '.git');
60
+ const isClone = await fs.stat(gitDir).then((stat) => stat.isDirectory(), () => false);
61
+ return isClone ? path.join(gitDir, 'canopycms') : path.join(branchRoot, CANOPY_META_DIR);
62
+ }
63
+ /**
64
+ * Per-branch schema cache: one file in {@link schemaCacheDir} with no in-memory
65
+ * layer, so it stays coherent across Lambda invocations.
48
66
  *
49
67
  * Freshness follows the generation-marker protocol owned by
50
68
  * resource-generation.ts, and this is one of that protocol's durable-snapshot
@@ -65,7 +83,7 @@ export class BranchSchemaCache {
65
83
  /**
66
84
  * Whether to skip the on-disk cache for this branchRoot.
67
85
  *
68
- * Never write `.canopy-meta/` at the project root, whichever entrypoint
86
+ * Never write a schema cache at the project root, whichever entrypoint
69
87
  * produced the cwd branchRoot. branchRoot equals process.cwd() only in the
70
88
  * synthetic contexts static deployments and build phases use; a real branch
71
89
  * root is always nested under the workspace.
@@ -75,7 +93,7 @@ export class BranchSchemaCache {
75
93
  }
76
94
  /** Get schema for a branch, from the cache when fresh, else resolved fresh. */
77
95
  async getSchema(branchRoot, entrySchemaRegistry, contentRootName = 'content') {
78
- return this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName);
96
+ return timeRequestPhase('schema', () => this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName));
79
97
  }
80
98
  /**
81
99
  * Resolve the schema from disk. Protected rather than a direct resolveSchema
@@ -89,8 +107,7 @@ export class BranchSchemaCache {
89
107
  const contentRoot = path.join(branchRoot, contentRootName);
90
108
  const skipDiskCache = this.skipDiskCache(branchRoot);
91
109
  if (!skipDiskCache) {
92
- const cacheDir = path.join(branchRoot, '.canopy-meta');
93
- const cachePath = path.join(cacheDir, 'schema-cache.json');
110
+ const cachePath = path.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
94
111
  let cacheData = null;
95
112
  try {
96
113
  const cacheContent = await fs.readFile(cachePath, 'utf-8');
@@ -149,7 +166,8 @@ export class BranchSchemaCache {
149
166
  const read = skipDiskCache
150
167
  ? null
151
168
  : await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
152
- const result = await this.resolveFresh(contentRoot, entrySchemaRegistry);
169
+ // Nested under `schema`, so a request summary names a cache miss (`…schema>resolve`).
170
+ const result = await timeRequestPhase('resolve', () => this.resolveFresh(contentRoot, entrySchemaRegistry));
153
171
  // Validate schema has content
154
172
  if (!isValidSchema(result.schema)) {
155
173
  throw new Error(`No schema found in ${contentRoot}. Create .collection.json files ` +
@@ -166,11 +184,11 @@ export class BranchSchemaCache {
166
184
  // Use configured contentRoot name as base path for logical paths
167
185
  const flatSchema = flattenSchema(result.schema, contentRootName);
168
186
  if (!skipDiskCache) {
169
- const cacheDir = path.join(branchRoot, '.canopy-meta');
170
- const cachePath = path.join(cacheDir, 'schema-cache.json');
187
+ const cacheDir = await schemaCacheDir(branchRoot);
188
+ const cachePath = path.join(cacheDir, SCHEMA_CACHE_FILE);
171
189
  // Opportunistic cleanup of the retired .stale marker file, which a
172
190
  // mid-flight deploy can leave behind. Not load-bearing.
173
- await fs.unlink(path.join(cacheDir, 'schema-cache.stale')).catch(() => { });
191
+ await fs.unlink(path.join(branchRoot, CANOPY_META_DIR, 'schema-cache.stale')).catch(() => { });
174
192
  if (read && read.ok) {
175
193
  const newCache = {
176
194
  version: SCHEMA_CACHE_VERSION,
@@ -180,7 +198,7 @@ export class BranchSchemaCache {
180
198
  generation: read.token,
181
199
  };
182
200
  // Temp file then rename, with the temp file unlinked on a failed
183
- // rename so a transient error leaves no stray `.tmp` in `.canopy-meta/`.
201
+ // rename so a transient error leaves no stray `.tmp` beside the cache.
184
202
  await fs.mkdir(cacheDir, { recursive: true });
185
203
  const tmpPath = path.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
186
204
  await fs.writeFile(tmpPath, JSON.stringify(newCache, null, 2), 'utf-8');
@@ -6,7 +6,7 @@ import { operatingStrategy } from './operating-mode/index.js';
6
6
  import { GitManager } from './git-manager.js';
7
7
  import { createDebugLogger } from './utils/debug.js';
8
8
  import { resolveBaseBranch } from './utils/git.js';
9
- import { acquireProvisioningLock } from './utils/provisioning-lock.js';
9
+ import { acquireProvisioningLock, branchProvisioningLockName } from './utils/provisioning-lock.js';
10
10
  const log = createDebugLogger({ prefix: 'BranchWorkspace' });
11
11
  // In-memory lock to prevent concurrent workspace initialization
12
12
  const workspaceInitLocks = new Map();
@@ -41,7 +41,7 @@ export class BranchWorkspaceManager {
41
41
  branchName: options.branchName,
42
42
  mode: options.mode,
43
43
  });
44
- releaseLock = await acquireProvisioningLock(path.dirname(options.branchRoot), `.${path.basename(options.branchRoot)}.init.lock`);
44
+ releaseLock = await acquireProvisioningLock(path.dirname(options.branchRoot), branchProvisioningLockName(path.basename(options.branchRoot)));
45
45
  await GitManager.initializeWorkspace({
46
46
  workspacePath: options.branchRoot,
47
47
  branchName: options.branchName,