canopycms 0.0.55 → 0.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/branch.d.ts +6 -0
- package/dist/api/branch.d.ts.map +1 -1
- package/dist/api/branch.js +63 -19
- package/dist/api/branch.js.map +1 -1
- package/dist/api/content.d.ts +2 -2
- package/dist/api/entries.d.ts.map +1 -1
- package/dist/api/entries.js +10 -2
- package/dist/api/entries.js.map +1 -1
- package/dist/api/github-sync.d.ts.map +1 -1
- package/dist/api/github-sync.js +28 -7
- package/dist/api/github-sync.js.map +1 -1
- package/dist/auth/caching-auth-plugin.d.ts +8 -1
- package/dist/auth/caching-auth-plugin.d.ts.map +1 -1
- package/dist/auth/caching-auth-plugin.js +6 -1
- package/dist/auth/caching-auth-plugin.js.map +1 -1
- package/dist/auth/plugin.d.ts +19 -10
- package/dist/auth/plugin.d.ts.map +1 -1
- package/dist/auth/plugin.js +12 -10
- package/dist/auth/plugin.js.map +1 -1
- package/dist/branch-metadata.d.ts +71 -6
- package/dist/branch-metadata.d.ts.map +1 -1
- package/dist/branch-metadata.js +132 -116
- package/dist/branch-metadata.js.map +1 -1
- package/dist/branch-registry.d.ts +78 -9
- package/dist/branch-registry.d.ts.map +1 -1
- package/dist/branch-registry.js +155 -29
- package/dist/branch-registry.js.map +1 -1
- package/dist/branch-schema-cache.d.ts +126 -11
- package/dist/branch-schema-cache.d.ts.map +1 -1
- package/dist/branch-schema-cache.js +208 -58
- package/dist/branch-schema-cache.js.map +1 -1
- package/dist/build/generate-ai-content.d.ts.map +1 -1
- package/dist/build/generate-ai-content.js +7 -0
- package/dist/build/generate-ai-content.js.map +1 -1
- package/dist/cli/generate-ai-content.js +2358 -1556
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +16 -8
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +25 -0
- package/dist/cli/migrate.js.map +1 -1
- package/dist/cli/sync.js +5 -5
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/templates.d.ts.map +1 -1
- package/dist/cli/templates.js +6 -3
- package/dist/cli/templates.js.map +1 -1
- package/dist/comment-store.d.ts +45 -15
- package/dist/comment-store.d.ts.map +1 -1
- package/dist/comment-store.js +80 -101
- package/dist/comment-store.js.map +1 -1
- package/dist/config/helpers.d.ts.map +1 -1
- package/dist/config/helpers.js +3 -0
- package/dist/config/helpers.js.map +1 -1
- package/dist/config/schemas/config.d.ts +3 -4
- package/dist/config/schemas/config.d.ts.map +1 -1
- package/dist/config/schemas/config.js +6 -3
- package/dist/config/schemas/config.js.map +1 -1
- package/dist/config/types.d.ts +6 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config-test.d.ts +5 -3
- package/dist/config-test.d.ts.map +1 -1
- package/dist/config-test.js +9 -1
- package/dist/config-test.js.map +1 -1
- package/dist/content-index-generation.d.ts +40 -4
- package/dist/content-index-generation.d.ts.map +1 -1
- package/dist/content-index-generation.js +70 -73
- package/dist/content-index-generation.js.map +1 -1
- package/dist/content-listing.d.ts +3 -1
- package/dist/content-listing.d.ts.map +1 -1
- package/dist/content-listing.js +1 -0
- package/dist/content-listing.js.map +1 -1
- package/dist/content-store.d.ts +63 -0
- package/dist/content-store.d.ts.map +1 -1
- package/dist/content-store.js +421 -184
- package/dist/content-store.js.map +1 -1
- package/dist/editor/Editor.d.ts.map +1 -1
- package/dist/editor/Editor.js +19 -15
- package/dist/editor/Editor.js.map +1 -1
- package/dist/editor/hooks/useDraftManager.d.ts +7 -0
- package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
- package/dist/editor/hooks/useDraftManager.js +69 -31
- package/dist/editor/hooks/useDraftManager.js.map +1 -1
- package/dist/editor/hooks/useSchemaManager.d.ts +34 -10
- package/dist/editor/hooks/useSchemaManager.d.ts.map +1 -1
- package/dist/editor/hooks/useSchemaManager.js +20 -24
- package/dist/editor/hooks/useSchemaManager.js.map +1 -1
- package/dist/editor/schema-editor/CollectionEditor.d.ts +6 -5
- package/dist/editor/schema-editor/CollectionEditor.d.ts.map +1 -1
- package/dist/editor/schema-editor/CollectionEditor.js +9 -4
- package/dist/editor/schema-editor/CollectionEditor.js.map +1 -1
- package/dist/git-manager.d.ts +15 -10
- package/dist/git-manager.d.ts.map +1 -1
- package/dist/git-manager.js +16 -11
- package/dist/git-manager.js.map +1 -1
- package/dist/github-service.d.ts +63 -7
- package/dist/github-service.d.ts.map +1 -1
- package/dist/github-service.js +147 -39
- package/dist/github-service.js.map +1 -1
- package/dist/resource-generation.d.ts +54 -0
- package/dist/resource-generation.d.ts.map +1 -0
- package/dist/resource-generation.js +156 -0
- package/dist/resource-generation.js.map +1 -0
- package/dist/schema/schema-store.d.ts +29 -2
- package/dist/schema/schema-store.d.ts.map +1 -1
- package/dist/schema/schema-store.js +47 -8
- package/dist/schema/schema-store.js.map +1 -1
- package/dist/services.js +4 -0
- package/dist/services.js.map +1 -1
- package/dist/static/index.d.ts +32 -0
- package/dist/static/index.d.ts.map +1 -1
- package/dist/static/index.js +90 -0
- package/dist/static/index.js.map +1 -1
- package/dist/sync-core.js +2 -2
- package/dist/sync-core.js.map +1 -1
- package/dist/utils/occ-json-write.d.ts +100 -0
- package/dist/utils/occ-json-write.d.ts.map +1 -0
- package/dist/utils/occ-json-write.js +234 -0
- package/dist/utils/occ-json-write.js.map +1 -0
- package/dist/worker/cms-worker.d.ts +6 -3
- package/dist/worker/cms-worker.d.ts.map +1 -1
- package/dist/worker/cms-worker.js +42 -41
- package/dist/worker/cms-worker.js.map +1 -1
- package/package.json +2 -1
|
@@ -9,11 +9,11 @@ var __export = (target, all) => {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
// dist/paths/normalize.js
|
|
12
|
-
function normalizeFilesystemPath(
|
|
13
|
-
return
|
|
12
|
+
function normalizeFilesystemPath(path19) {
|
|
13
|
+
return path19.split(/[\\/]+/).filter(Boolean).join("/");
|
|
14
14
|
}
|
|
15
|
-
function hasTraversalSequence(
|
|
16
|
-
const normalized = normalizeFilesystemPath(
|
|
15
|
+
function hasTraversalSequence(path19) {
|
|
16
|
+
const normalized = normalizeFilesystemPath(path19);
|
|
17
17
|
return normalized.includes("..");
|
|
18
18
|
}
|
|
19
19
|
function createLogicalPath(...segments) {
|
|
@@ -23,14 +23,14 @@ function createLogicalPath(...segments) {
|
|
|
23
23
|
}
|
|
24
24
|
return normalized;
|
|
25
25
|
}
|
|
26
|
-
function trimSlashes(
|
|
26
|
+
function trimSlashes(path19) {
|
|
27
27
|
let start = 0;
|
|
28
|
-
let end =
|
|
29
|
-
while (start < end &&
|
|
28
|
+
let end = path19.length;
|
|
29
|
+
while (start < end && path19[start] === "/")
|
|
30
30
|
start++;
|
|
31
|
-
while (end > start &&
|
|
31
|
+
while (end > start && path19[end - 1] === "/")
|
|
32
32
|
end--;
|
|
33
|
-
return
|
|
33
|
+
return path19.slice(start, end);
|
|
34
34
|
}
|
|
35
35
|
var init_normalize = __esm({
|
|
36
36
|
"dist/paths/normalize.js"() {
|
|
@@ -38,6 +38,118 @@ var init_normalize = __esm({
|
|
|
38
38
|
}
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
// dist/paths/types.js
|
|
42
|
+
var ROOT_COLLECTION_ID;
|
|
43
|
+
var init_types = __esm({
|
|
44
|
+
"dist/paths/types.js"() {
|
|
45
|
+
"use strict";
|
|
46
|
+
ROOT_COLLECTION_ID = "__rootcoll__";
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// dist/config/flatten.js
|
|
51
|
+
import { join as join2, normalize } from "pathe";
|
|
52
|
+
var normalizePathValue, flattenSchema;
|
|
53
|
+
var init_flatten = __esm({
|
|
54
|
+
"dist/config/flatten.js"() {
|
|
55
|
+
"use strict";
|
|
56
|
+
init_normalize();
|
|
57
|
+
init_types();
|
|
58
|
+
normalizePathValue = (val) => normalize(val).split("/").filter(Boolean).join("/");
|
|
59
|
+
flattenSchema = (root, basePath = "") => {
|
|
60
|
+
const flat = [];
|
|
61
|
+
const base = normalizePathValue(basePath || "");
|
|
62
|
+
const walkCollection = (collection, parentPath) => {
|
|
63
|
+
const normalizedPath = normalizePathValue(collection.path);
|
|
64
|
+
let logicalPath;
|
|
65
|
+
if (base) {
|
|
66
|
+
logicalPath = join2(base, normalizedPath);
|
|
67
|
+
} else {
|
|
68
|
+
logicalPath = normalizedPath;
|
|
69
|
+
}
|
|
70
|
+
const normalizedFull = normalizePathValue(logicalPath);
|
|
71
|
+
flat.push({
|
|
72
|
+
type: "collection",
|
|
73
|
+
logicalPath: createLogicalPath(normalizedFull),
|
|
74
|
+
name: collection.name,
|
|
75
|
+
label: collection.label,
|
|
76
|
+
description: collection.description,
|
|
77
|
+
contentId: collection.contentId,
|
|
78
|
+
parentPath: parentPath ? createLogicalPath(parentPath) : void 0,
|
|
79
|
+
entries: collection.entries,
|
|
80
|
+
collections: collection.collections,
|
|
81
|
+
order: collection.order
|
|
82
|
+
});
|
|
83
|
+
if (collection.entries) {
|
|
84
|
+
for (const entryType of collection.entries) {
|
|
85
|
+
const entryTypePath = join2(normalizedFull, entryType.name);
|
|
86
|
+
flat.push({
|
|
87
|
+
type: "entry-type",
|
|
88
|
+
logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
|
|
89
|
+
name: entryType.name,
|
|
90
|
+
label: entryType.label,
|
|
91
|
+
description: entryType.description,
|
|
92
|
+
parentPath: createLogicalPath(normalizedFull),
|
|
93
|
+
format: entryType.format,
|
|
94
|
+
schema: entryType.schema,
|
|
95
|
+
schemaRef: entryType.schemaRef,
|
|
96
|
+
default: entryType.default,
|
|
97
|
+
maxItems: entryType.maxItems
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (collection.collections) {
|
|
102
|
+
for (const child of collection.collections) {
|
|
103
|
+
walkCollection(child, normalizedFull);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
if (base) {
|
|
108
|
+
flat.push({
|
|
109
|
+
type: "collection",
|
|
110
|
+
logicalPath: createLogicalPath(base),
|
|
111
|
+
name: base,
|
|
112
|
+
// Use base path as the name (e.g., 'content')
|
|
113
|
+
label: void 0,
|
|
114
|
+
// Root collection has no label
|
|
115
|
+
contentId: ROOT_COLLECTION_ID,
|
|
116
|
+
// Sentinel — root dir has no embedded ID
|
|
117
|
+
parentPath: void 0,
|
|
118
|
+
// No parent - this is the root
|
|
119
|
+
entries: root.entries,
|
|
120
|
+
collections: root.collections,
|
|
121
|
+
order: root.order
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (root.entries) {
|
|
125
|
+
for (const entryType of root.entries) {
|
|
126
|
+
const entryTypePath = base ? join2(base, entryType.name) : entryType.name;
|
|
127
|
+
flat.push({
|
|
128
|
+
type: "entry-type",
|
|
129
|
+
logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
|
|
130
|
+
name: entryType.name,
|
|
131
|
+
label: entryType.label,
|
|
132
|
+
description: entryType.description,
|
|
133
|
+
parentPath: base ? createLogicalPath(base) : createLogicalPath(""),
|
|
134
|
+
// Now references the root collection (e.g., 'content')
|
|
135
|
+
format: entryType.format,
|
|
136
|
+
schema: entryType.schema,
|
|
137
|
+
schemaRef: entryType.schemaRef,
|
|
138
|
+
default: entryType.default,
|
|
139
|
+
maxItems: entryType.maxItems
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (root.collections) {
|
|
144
|
+
for (const collection of root.collections) {
|
|
145
|
+
walkCollection(collection, base || "");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return flat;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
41
153
|
// dist/operating-mode/client-safe-strategy.js
|
|
42
154
|
function clientOperatingStrategy(mode) {
|
|
43
155
|
const cached = clientStrategyCache.get(mode);
|
|
@@ -134,7 +246,7 @@ var init_client_safe_strategy = __esm({
|
|
|
134
246
|
|
|
135
247
|
// dist/config/types.js
|
|
136
248
|
var primitiveFieldTypes, fieldTypes;
|
|
137
|
-
var
|
|
249
|
+
var init_types2 = __esm({
|
|
138
250
|
"dist/config/types.js"() {
|
|
139
251
|
"use strict";
|
|
140
252
|
primitiveFieldTypes = [
|
|
@@ -160,77 +272,77 @@ var init_types = __esm({
|
|
|
160
272
|
});
|
|
161
273
|
|
|
162
274
|
// dist/config/schemas/field.js
|
|
163
|
-
import { z } from "zod";
|
|
275
|
+
import { z as z2 } from "zod";
|
|
164
276
|
var fieldBaseSchema, selectOptionSchema, referenceOptionSchema, primitiveFieldSchema, selectFieldSchema, referenceFieldSchema, fieldHolder, blockSchema, blockFieldSchema, objectFieldSchema, inlineGroupFieldSchema, customFieldSchema, knownFieldSchema, fieldSchema;
|
|
165
277
|
var init_field = __esm({
|
|
166
278
|
"dist/config/schemas/field.js"() {
|
|
167
279
|
"use strict";
|
|
168
|
-
|
|
169
|
-
fieldBaseSchema =
|
|
170
|
-
name:
|
|
171
|
-
label:
|
|
172
|
-
description:
|
|
173
|
-
required:
|
|
174
|
-
list:
|
|
175
|
-
isTitle:
|
|
176
|
-
isBody:
|
|
280
|
+
init_types2();
|
|
281
|
+
fieldBaseSchema = z2.object({
|
|
282
|
+
name: z2.string().min(1),
|
|
283
|
+
label: z2.string().optional(),
|
|
284
|
+
description: z2.string().optional(),
|
|
285
|
+
required: z2.boolean().optional(),
|
|
286
|
+
list: z2.boolean().optional(),
|
|
287
|
+
isTitle: z2.boolean().optional(),
|
|
288
|
+
isBody: z2.boolean().optional()
|
|
177
289
|
});
|
|
178
|
-
selectOptionSchema =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
label:
|
|
182
|
-
value:
|
|
290
|
+
selectOptionSchema = z2.union([
|
|
291
|
+
z2.string(),
|
|
292
|
+
z2.object({
|
|
293
|
+
label: z2.string().min(1),
|
|
294
|
+
value: z2.string().min(1)
|
|
183
295
|
})
|
|
184
296
|
]);
|
|
185
|
-
referenceOptionSchema =
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
label:
|
|
189
|
-
value:
|
|
297
|
+
referenceOptionSchema = z2.union([
|
|
298
|
+
z2.string(),
|
|
299
|
+
z2.object({
|
|
300
|
+
label: z2.string().min(1),
|
|
301
|
+
value: z2.string().min(1)
|
|
190
302
|
})
|
|
191
303
|
]);
|
|
192
304
|
primitiveFieldSchema = fieldBaseSchema.extend({
|
|
193
|
-
type:
|
|
305
|
+
type: z2.enum(primitiveFieldTypes)
|
|
194
306
|
});
|
|
195
307
|
selectFieldSchema = fieldBaseSchema.extend({
|
|
196
|
-
type:
|
|
197
|
-
options:
|
|
308
|
+
type: z2.literal("select"),
|
|
309
|
+
options: z2.array(selectOptionSchema).min(1)
|
|
198
310
|
});
|
|
199
311
|
referenceFieldSchema = fieldBaseSchema.extend({
|
|
200
|
-
type:
|
|
201
|
-
collections:
|
|
202
|
-
entryTypes:
|
|
203
|
-
displayField:
|
|
204
|
-
options:
|
|
312
|
+
type: z2.literal("reference"),
|
|
313
|
+
collections: z2.array(z2.string().min(1)).min(1).optional(),
|
|
314
|
+
entryTypes: z2.array(z2.string().min(1)).min(1).optional(),
|
|
315
|
+
displayField: z2.string().min(1).optional(),
|
|
316
|
+
options: z2.array(referenceOptionSchema).optional()
|
|
205
317
|
});
|
|
206
|
-
fieldHolder = [
|
|
207
|
-
blockSchema =
|
|
208
|
-
name:
|
|
209
|
-
label:
|
|
210
|
-
description:
|
|
211
|
-
fields:
|
|
318
|
+
fieldHolder = [z2.never()];
|
|
319
|
+
blockSchema = z2.object({
|
|
320
|
+
name: z2.string().min(1),
|
|
321
|
+
label: z2.string().optional(),
|
|
322
|
+
description: z2.string().optional(),
|
|
323
|
+
fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1)
|
|
212
324
|
});
|
|
213
325
|
blockFieldSchema = fieldBaseSchema.extend({
|
|
214
|
-
type:
|
|
215
|
-
templates:
|
|
326
|
+
type: z2.literal("block"),
|
|
327
|
+
templates: z2.array(blockSchema).min(1)
|
|
216
328
|
});
|
|
217
329
|
objectFieldSchema = fieldBaseSchema.extend({
|
|
218
|
-
type:
|
|
219
|
-
fields:
|
|
330
|
+
type: z2.literal("object"),
|
|
331
|
+
fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1)
|
|
220
332
|
});
|
|
221
|
-
inlineGroupFieldSchema =
|
|
222
|
-
type:
|
|
223
|
-
name:
|
|
224
|
-
label:
|
|
225
|
-
description:
|
|
226
|
-
fields:
|
|
333
|
+
inlineGroupFieldSchema = z2.object({
|
|
334
|
+
type: z2.literal("group"),
|
|
335
|
+
name: z2.string().min(1),
|
|
336
|
+
label: z2.string().optional(),
|
|
337
|
+
description: z2.string().optional(),
|
|
338
|
+
fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1)
|
|
227
339
|
});
|
|
228
|
-
customFieldSchema =
|
|
229
|
-
type:
|
|
340
|
+
customFieldSchema = z2.lazy(() => fieldBaseSchema.extend({
|
|
341
|
+
type: z2.string().min(1).refine((val) => !fieldTypes.includes(val), {
|
|
230
342
|
message: "Custom field types must not conflict with built-in types"
|
|
231
343
|
})
|
|
232
344
|
}).passthrough());
|
|
233
|
-
knownFieldSchema =
|
|
345
|
+
knownFieldSchema = z2.discriminatedUnion("type", [
|
|
234
346
|
primitiveFieldSchema,
|
|
235
347
|
selectFieldSchema,
|
|
236
348
|
referenceFieldSchema,
|
|
@@ -238,39 +350,39 @@ var init_field = __esm({
|
|
|
238
350
|
blockFieldSchema,
|
|
239
351
|
inlineGroupFieldSchema
|
|
240
352
|
]);
|
|
241
|
-
fieldSchema =
|
|
353
|
+
fieldSchema = z2.lazy(() => z2.union([knownFieldSchema, customFieldSchema]));
|
|
242
354
|
fieldHolder[0] = fieldSchema;
|
|
243
355
|
}
|
|
244
356
|
});
|
|
245
357
|
|
|
246
358
|
// dist/config/schemas/collection.js
|
|
247
|
-
import { z as
|
|
359
|
+
import { z as z3 } from "zod";
|
|
248
360
|
import { isAbsolute } from "pathe";
|
|
249
361
|
var relativePathSchema, entryTypeSchema, collectionSchema;
|
|
250
362
|
var init_collection = __esm({
|
|
251
363
|
"dist/config/schemas/collection.js"() {
|
|
252
364
|
"use strict";
|
|
253
365
|
init_field();
|
|
254
|
-
relativePathSchema =
|
|
366
|
+
relativePathSchema = z3.string().min(1).refine((val) => !isAbsolute(val), { message: "Path must be relative" }).refine((val) => !val.split(/[\\/]+/).includes(".."), {
|
|
255
367
|
message: 'Path must not contain ".."'
|
|
256
368
|
}).transform((val) => val.split(/[\\/]+/).filter(Boolean).join("/"));
|
|
257
|
-
entryTypeSchema =
|
|
258
|
-
name:
|
|
259
|
-
format:
|
|
260
|
-
schema:
|
|
261
|
-
label:
|
|
262
|
-
description:
|
|
263
|
-
default:
|
|
264
|
-
maxItems:
|
|
369
|
+
entryTypeSchema = z3.object({
|
|
370
|
+
name: z3.string().min(1),
|
|
371
|
+
format: z3.enum(["md", "mdx", "json", "yaml"]),
|
|
372
|
+
schema: z3.array(z3.lazy(() => fieldSchema)).min(1),
|
|
373
|
+
label: z3.string().optional(),
|
|
374
|
+
description: z3.string().optional(),
|
|
375
|
+
default: z3.boolean().optional(),
|
|
376
|
+
maxItems: z3.number().int().positive().optional()
|
|
265
377
|
});
|
|
266
|
-
collectionSchema =
|
|
267
|
-
name:
|
|
378
|
+
collectionSchema = z3.lazy(() => z3.object({
|
|
379
|
+
name: z3.string().min(1),
|
|
268
380
|
path: relativePathSchema,
|
|
269
|
-
label:
|
|
270
|
-
description:
|
|
271
|
-
entries:
|
|
272
|
-
collections:
|
|
273
|
-
order:
|
|
381
|
+
label: z3.string().optional(),
|
|
382
|
+
description: z3.string().optional(),
|
|
383
|
+
entries: z3.array(entryTypeSchema).optional(),
|
|
384
|
+
collections: z3.array(collectionSchema).optional(),
|
|
385
|
+
order: z3.array(z3.string()).optional()
|
|
274
386
|
// Embedded IDs for ordering items
|
|
275
387
|
}).refine((data) => data.entries || data.collections, {
|
|
276
388
|
message: "Collection must have entries or collections"
|
|
@@ -279,67 +391,67 @@ var init_collection = __esm({
|
|
|
279
391
|
});
|
|
280
392
|
|
|
281
393
|
// dist/config/schemas/media.js
|
|
282
|
-
import { z as
|
|
394
|
+
import { z as z4 } from "zod";
|
|
283
395
|
var mediaSchema;
|
|
284
396
|
var init_media = __esm({
|
|
285
397
|
"dist/config/schemas/media.js"() {
|
|
286
398
|
"use strict";
|
|
287
|
-
mediaSchema =
|
|
288
|
-
|
|
289
|
-
adapter:
|
|
290
|
-
publicBaseUrl:
|
|
399
|
+
mediaSchema = z4.discriminatedUnion("adapter", [
|
|
400
|
+
z4.object({
|
|
401
|
+
adapter: z4.literal("local"),
|
|
402
|
+
publicBaseUrl: z4.string().url().optional()
|
|
291
403
|
}),
|
|
292
|
-
|
|
293
|
-
adapter:
|
|
294
|
-
bucket:
|
|
295
|
-
region:
|
|
296
|
-
publicBaseUrl:
|
|
404
|
+
z4.object({
|
|
405
|
+
adapter: z4.literal("s3"),
|
|
406
|
+
bucket: z4.string().min(1),
|
|
407
|
+
region: z4.string().min(1),
|
|
408
|
+
publicBaseUrl: z4.string().url().optional()
|
|
297
409
|
}),
|
|
298
|
-
|
|
299
|
-
adapter:
|
|
300
|
-
publicBaseUrl:
|
|
410
|
+
z4.object({
|
|
411
|
+
adapter: z4.literal("lfs"),
|
|
412
|
+
publicBaseUrl: z4.string().url().optional()
|
|
301
413
|
})
|
|
302
414
|
]);
|
|
303
415
|
}
|
|
304
416
|
});
|
|
305
417
|
|
|
306
418
|
// dist/config/schemas/config.js
|
|
307
|
-
import { z as
|
|
419
|
+
import { z as z5 } from "zod";
|
|
308
420
|
var defaultBranchAccessSchema, defaultPathAccessSchema, defaultBaseBranchSchema, defaultRemoteNameSchema, defaultRemoteUrlSchema, gitBotAuthorNameSchema, gitBotAuthorEmailSchema, githubTokenEnvVarSchema, operatingModeSchema, deployedAsSchema, contentRootSchema, sourceRootSchema, deploymentNameSchema, devContentSyncSchema, devConfigSchema, editorConfigSchema, CanopyConfigSchema, DEFAULT_PROD_WORKSPACE;
|
|
309
421
|
var init_config = __esm({
|
|
310
422
|
"dist/config/schemas/config.js"() {
|
|
311
423
|
"use strict";
|
|
312
424
|
init_collection();
|
|
313
425
|
init_media();
|
|
314
|
-
defaultBranchAccessSchema =
|
|
315
|
-
defaultPathAccessSchema =
|
|
316
|
-
defaultBaseBranchSchema =
|
|
317
|
-
defaultRemoteNameSchema =
|
|
318
|
-
defaultRemoteUrlSchema =
|
|
319
|
-
gitBotAuthorNameSchema =
|
|
320
|
-
gitBotAuthorEmailSchema =
|
|
321
|
-
githubTokenEnvVarSchema =
|
|
322
|
-
operatingModeSchema =
|
|
323
|
-
deployedAsSchema =
|
|
426
|
+
defaultBranchAccessSchema = z5.enum(["allow", "deny"]).default("deny");
|
|
427
|
+
defaultPathAccessSchema = z5.enum(["allow", "deny"]).default("deny");
|
|
428
|
+
defaultBaseBranchSchema = z5.string().default("main");
|
|
429
|
+
defaultRemoteNameSchema = z5.string().default("origin");
|
|
430
|
+
defaultRemoteUrlSchema = z5.string().min(1);
|
|
431
|
+
gitBotAuthorNameSchema = z5.string().min(1);
|
|
432
|
+
gitBotAuthorEmailSchema = z5.string().email();
|
|
433
|
+
githubTokenEnvVarSchema = z5.string().default("GITHUB_BOT_TOKEN");
|
|
434
|
+
operatingModeSchema = z5.enum(["prod", "dev"]);
|
|
435
|
+
deployedAsSchema = z5.enum(["static", "server"]).default("server");
|
|
324
436
|
contentRootSchema = relativePathSchema.default("content");
|
|
325
|
-
sourceRootSchema =
|
|
326
|
-
deploymentNameSchema =
|
|
327
|
-
devContentSyncSchema =
|
|
328
|
-
devConfigSchema =
|
|
437
|
+
sourceRootSchema = z5.string().min(1).optional();
|
|
438
|
+
deploymentNameSchema = z5.string().default("prod");
|
|
439
|
+
devContentSyncSchema = z5.enum(["off", "warn"]).default("warn");
|
|
440
|
+
devConfigSchema = z5.object({
|
|
329
441
|
contentSync: devContentSyncSchema.optional()
|
|
330
442
|
});
|
|
331
|
-
editorConfigSchema =
|
|
332
|
-
title:
|
|
333
|
-
subtitle:
|
|
334
|
-
theme:
|
|
335
|
-
previewBase:
|
|
443
|
+
editorConfigSchema = z5.object({
|
|
444
|
+
title: z5.string().optional(),
|
|
445
|
+
subtitle: z5.string().optional(),
|
|
446
|
+
theme: z5.unknown().optional(),
|
|
447
|
+
previewBase: z5.record(z5.string()).optional(),
|
|
336
448
|
// UI handler functions (runtime only, don't serialize)
|
|
337
|
-
onAccountClick:
|
|
338
|
-
onLogoutClick:
|
|
449
|
+
onAccountClick: z5.function().returns(z5.void()).optional(),
|
|
450
|
+
onLogoutClick: z5.function().returns(z5.void()).optional(),
|
|
339
451
|
// Optional: custom account component (e.g., Clerk's UserButton)
|
|
340
|
-
AccountComponent:
|
|
452
|
+
AccountComponent: z5.custom().optional()
|
|
341
453
|
});
|
|
342
|
-
CanopyConfigSchema =
|
|
454
|
+
CanopyConfigSchema = z5.object({
|
|
343
455
|
media: mediaSchema.optional(),
|
|
344
456
|
// No outer .optional() here: defaultBranchAccessSchema/defaultPathAccessSchema already
|
|
345
457
|
// have .default('deny'), so the field is optional on input but always resolves to
|
|
@@ -351,25 +463,26 @@ var init_config = __esm({
|
|
|
351
463
|
// unset must stay undefined after parsing so dev mode can detect the fork
|
|
352
464
|
// point from git HEAD (see resolveBaseBranch in utils/git.ts).
|
|
353
465
|
defaultBaseBranch: defaultBaseBranchSchema.optional(),
|
|
354
|
-
defaultActiveBranch:
|
|
466
|
+
defaultActiveBranch: z5.string().min(1).optional(),
|
|
355
467
|
defaultRemoteName: defaultRemoteNameSchema.optional(),
|
|
356
468
|
defaultRemoteUrl: defaultRemoteUrlSchema.optional(),
|
|
357
469
|
gitBotAuthorName: gitBotAuthorNameSchema,
|
|
358
470
|
gitBotAuthorEmail: gitBotAuthorEmailSchema,
|
|
359
471
|
githubTokenEnvVar: githubTokenEnvVarSchema.optional(),
|
|
472
|
+
// Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
|
|
473
|
+
// validation loudly rather than silently running header-trusting dev auth semantics.
|
|
360
474
|
mode: operatingModeSchema,
|
|
361
|
-
// Has .default(), so not optional in output type
|
|
362
475
|
deployedAs: deployedAsSchema,
|
|
363
476
|
// Has .default('server'), so always present after validation
|
|
364
|
-
settingsBranch:
|
|
365
|
-
autoCreateSettingsPR:
|
|
477
|
+
settingsBranch: z5.string().optional(),
|
|
478
|
+
autoCreateSettingsPR: z5.boolean().optional(),
|
|
366
479
|
deploymentName: deploymentNameSchema.optional(),
|
|
367
480
|
contentRoot: contentRootSchema.default("content"),
|
|
368
481
|
sourceRoot: sourceRootSchema.optional(),
|
|
369
482
|
editor: editorConfigSchema.optional(),
|
|
370
|
-
authPlugin:
|
|
371
|
-
entryLinkUrl:
|
|
372
|
-
validateEntry:
|
|
483
|
+
authPlugin: z5.custom().optional(),
|
|
484
|
+
entryLinkUrl: z5.custom().optional(),
|
|
485
|
+
validateEntry: z5.custom().optional(),
|
|
373
486
|
dev: devConfigSchema.optional()
|
|
374
487
|
}).strict();
|
|
375
488
|
DEFAULT_PROD_WORKSPACE = "/mnt/efs/workspace";
|
|
@@ -377,17 +490,17 @@ var init_config = __esm({
|
|
|
377
490
|
});
|
|
378
491
|
|
|
379
492
|
// dist/config/schemas/permissions.js
|
|
380
|
-
import { z as
|
|
493
|
+
import { z as z6 } from "zod";
|
|
381
494
|
var permissionTargetSchema, pathPermissionSchema;
|
|
382
495
|
var init_permissions = __esm({
|
|
383
496
|
"dist/config/schemas/permissions.js"() {
|
|
384
497
|
"use strict";
|
|
385
|
-
permissionTargetSchema =
|
|
386
|
-
allowedUsers:
|
|
387
|
-
allowedGroups:
|
|
498
|
+
permissionTargetSchema = z6.object({
|
|
499
|
+
allowedUsers: z6.array(z6.string()).optional(),
|
|
500
|
+
allowedGroups: z6.array(z6.string()).optional()
|
|
388
501
|
});
|
|
389
|
-
pathPermissionSchema =
|
|
390
|
-
path:
|
|
502
|
+
pathPermissionSchema = z6.object({
|
|
503
|
+
path: z6.string().min(1),
|
|
391
504
|
read: permissionTargetSchema.optional(),
|
|
392
505
|
edit: permissionTargetSchema.optional(),
|
|
393
506
|
review: permissionTargetSchema.optional()
|
|
@@ -395,118 +508,6 @@ var init_permissions = __esm({
|
|
|
395
508
|
}
|
|
396
509
|
});
|
|
397
510
|
|
|
398
|
-
// dist/paths/types.js
|
|
399
|
-
var ROOT_COLLECTION_ID;
|
|
400
|
-
var init_types2 = __esm({
|
|
401
|
-
"dist/paths/types.js"() {
|
|
402
|
-
"use strict";
|
|
403
|
-
ROOT_COLLECTION_ID = "__rootcoll__";
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
// dist/config/flatten.js
|
|
408
|
-
import { join, normalize } from "pathe";
|
|
409
|
-
var normalizePathValue, flattenSchema;
|
|
410
|
-
var init_flatten = __esm({
|
|
411
|
-
"dist/config/flatten.js"() {
|
|
412
|
-
"use strict";
|
|
413
|
-
init_normalize();
|
|
414
|
-
init_types2();
|
|
415
|
-
normalizePathValue = (val) => normalize(val).split("/").filter(Boolean).join("/");
|
|
416
|
-
flattenSchema = (root, basePath = "") => {
|
|
417
|
-
const flat = [];
|
|
418
|
-
const base = normalizePathValue(basePath || "");
|
|
419
|
-
const walkCollection = (collection, parentPath) => {
|
|
420
|
-
const normalizedPath = normalizePathValue(collection.path);
|
|
421
|
-
let logicalPath;
|
|
422
|
-
if (base) {
|
|
423
|
-
logicalPath = join(base, normalizedPath);
|
|
424
|
-
} else {
|
|
425
|
-
logicalPath = normalizedPath;
|
|
426
|
-
}
|
|
427
|
-
const normalizedFull = normalizePathValue(logicalPath);
|
|
428
|
-
flat.push({
|
|
429
|
-
type: "collection",
|
|
430
|
-
logicalPath: createLogicalPath(normalizedFull),
|
|
431
|
-
name: collection.name,
|
|
432
|
-
label: collection.label,
|
|
433
|
-
description: collection.description,
|
|
434
|
-
contentId: collection.contentId,
|
|
435
|
-
parentPath: parentPath ? createLogicalPath(parentPath) : void 0,
|
|
436
|
-
entries: collection.entries,
|
|
437
|
-
collections: collection.collections,
|
|
438
|
-
order: collection.order
|
|
439
|
-
});
|
|
440
|
-
if (collection.entries) {
|
|
441
|
-
for (const entryType of collection.entries) {
|
|
442
|
-
const entryTypePath = join(normalizedFull, entryType.name);
|
|
443
|
-
flat.push({
|
|
444
|
-
type: "entry-type",
|
|
445
|
-
logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
|
|
446
|
-
name: entryType.name,
|
|
447
|
-
label: entryType.label,
|
|
448
|
-
description: entryType.description,
|
|
449
|
-
parentPath: createLogicalPath(normalizedFull),
|
|
450
|
-
format: entryType.format,
|
|
451
|
-
schema: entryType.schema,
|
|
452
|
-
schemaRef: entryType.schemaRef,
|
|
453
|
-
default: entryType.default,
|
|
454
|
-
maxItems: entryType.maxItems
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
if (collection.collections) {
|
|
459
|
-
for (const child of collection.collections) {
|
|
460
|
-
walkCollection(child, normalizedFull);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
};
|
|
464
|
-
if (base) {
|
|
465
|
-
flat.push({
|
|
466
|
-
type: "collection",
|
|
467
|
-
logicalPath: createLogicalPath(base),
|
|
468
|
-
name: base,
|
|
469
|
-
// Use base path as the name (e.g., 'content')
|
|
470
|
-
label: void 0,
|
|
471
|
-
// Root collection has no label
|
|
472
|
-
contentId: ROOT_COLLECTION_ID,
|
|
473
|
-
// Sentinel — root dir has no embedded ID
|
|
474
|
-
parentPath: void 0,
|
|
475
|
-
// No parent - this is the root
|
|
476
|
-
entries: root.entries,
|
|
477
|
-
collections: root.collections,
|
|
478
|
-
order: root.order
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
if (root.entries) {
|
|
482
|
-
for (const entryType of root.entries) {
|
|
483
|
-
const entryTypePath = base ? join(base, entryType.name) : entryType.name;
|
|
484
|
-
flat.push({
|
|
485
|
-
type: "entry-type",
|
|
486
|
-
logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
|
|
487
|
-
name: entryType.name,
|
|
488
|
-
label: entryType.label,
|
|
489
|
-
description: entryType.description,
|
|
490
|
-
parentPath: base ? createLogicalPath(base) : createLogicalPath(""),
|
|
491
|
-
// Now references the root collection (e.g., 'content')
|
|
492
|
-
format: entryType.format,
|
|
493
|
-
schema: entryType.schema,
|
|
494
|
-
schemaRef: entryType.schemaRef,
|
|
495
|
-
default: entryType.default,
|
|
496
|
-
maxItems: entryType.maxItems
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
if (root.collections) {
|
|
501
|
-
for (const collection of root.collections) {
|
|
502
|
-
walkCollection(collection, base || "");
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
return flat;
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
});
|
|
509
|
-
|
|
510
511
|
// dist/config/validation.js
|
|
511
512
|
var init_validation = __esm({
|
|
512
513
|
"dist/config/validation.js"() {
|
|
@@ -528,7 +529,7 @@ var init_helpers = __esm({
|
|
|
528
529
|
var init_config2 = __esm({
|
|
529
530
|
"dist/config/index.js"() {
|
|
530
531
|
"use strict";
|
|
531
|
-
|
|
532
|
+
init_types2();
|
|
532
533
|
init_config();
|
|
533
534
|
init_field();
|
|
534
535
|
init_collection();
|
|
@@ -541,7 +542,7 @@ var init_config2 = __esm({
|
|
|
541
542
|
});
|
|
542
543
|
|
|
543
544
|
// dist/operating-mode/client-unsafe-strategy.js
|
|
544
|
-
import
|
|
545
|
+
import path6 from "node:path";
|
|
545
546
|
function operatingStrategy(mode) {
|
|
546
547
|
const cached = strategyCache.get(mode);
|
|
547
548
|
if (cached)
|
|
@@ -578,32 +579,32 @@ var init_client_unsafe_strategy = __esm({
|
|
|
578
579
|
// - shouldCommit(), shouldPush()
|
|
579
580
|
// Add client-unsafe methods (use Node.js APIs)
|
|
580
581
|
getWorkspaceRoot(_sourceRoot) {
|
|
581
|
-
return
|
|
582
|
+
return path6.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE);
|
|
582
583
|
}
|
|
583
584
|
getContentRoot(sourceRoot) {
|
|
584
|
-
return
|
|
585
|
+
return path6.resolve(sourceRoot ?? process.cwd(), "content");
|
|
585
586
|
}
|
|
586
587
|
getContentBranchesRoot(sourceRoot) {
|
|
587
|
-
return
|
|
588
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
|
|
588
589
|
}
|
|
589
590
|
getContentBranchRoot(branchName, sourceRoot) {
|
|
590
|
-
return
|
|
591
|
+
return path6.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
|
|
591
592
|
}
|
|
592
593
|
getGitExcludePattern() {
|
|
593
594
|
return ".canopy-meta/";
|
|
594
595
|
}
|
|
595
596
|
getPermissionsFilePath(root) {
|
|
596
|
-
return
|
|
597
|
+
return path6.join(root, this.getPermissionsFileName());
|
|
597
598
|
}
|
|
598
599
|
getGroupsFilePath(root) {
|
|
599
|
-
return
|
|
600
|
+
return path6.join(root, this.getGroupsFileName());
|
|
600
601
|
}
|
|
601
602
|
getRemoteUrlConfig() {
|
|
602
603
|
return {
|
|
603
604
|
shouldAutoInitLocal: false,
|
|
604
605
|
defaultRemotePath: "",
|
|
605
606
|
envVarName: "CANOPYCMS_REMOTE_URL",
|
|
606
|
-
autoDetectRemotePath:
|
|
607
|
+
autoDetectRemotePath: path6.join(this.getWorkspaceRoot(), "remote.git")
|
|
607
608
|
};
|
|
608
609
|
}
|
|
609
610
|
requiresExistingRepo() {
|
|
@@ -616,7 +617,7 @@ var init_client_unsafe_strategy = __esm({
|
|
|
616
617
|
return `canopycms-settings-${deploymentName}`;
|
|
617
618
|
}
|
|
618
619
|
getSettingsRoot(sourceRoot) {
|
|
619
|
-
return
|
|
620
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "settings");
|
|
620
621
|
}
|
|
621
622
|
usesSeparateSettingsBranch() {
|
|
622
623
|
return true;
|
|
@@ -633,25 +634,25 @@ var init_client_unsafe_strategy = __esm({
|
|
|
633
634
|
DevStrategy = class extends DevClientSafeStrategy {
|
|
634
635
|
// Inherits client-safe methods from DevClientSafeStrategy
|
|
635
636
|
getWorkspaceRoot(sourceRoot) {
|
|
636
|
-
return
|
|
637
|
+
return path6.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
|
|
637
638
|
}
|
|
638
639
|
getContentRoot(sourceRoot) {
|
|
639
|
-
return
|
|
640
|
+
return path6.resolve(sourceRoot ?? process.cwd(), "content");
|
|
640
641
|
}
|
|
641
642
|
getContentBranchesRoot(sourceRoot) {
|
|
642
|
-
return
|
|
643
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
|
|
643
644
|
}
|
|
644
645
|
getContentBranchRoot(branchName, sourceRoot) {
|
|
645
|
-
return
|
|
646
|
+
return path6.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
|
|
646
647
|
}
|
|
647
648
|
getGitExcludePattern() {
|
|
648
649
|
return ".canopy-meta/";
|
|
649
650
|
}
|
|
650
651
|
getPermissionsFilePath(root) {
|
|
651
|
-
return
|
|
652
|
+
return path6.join(root, this.getPermissionsFileName());
|
|
652
653
|
}
|
|
653
654
|
getGroupsFilePath(root) {
|
|
654
|
-
return
|
|
655
|
+
return path6.join(root, this.getGroupsFileName());
|
|
655
656
|
}
|
|
656
657
|
getRemoteUrlConfig() {
|
|
657
658
|
return {
|
|
@@ -670,7 +671,7 @@ var init_client_unsafe_strategy = __esm({
|
|
|
670
671
|
return `canopycms-settings-${deploymentName}`;
|
|
671
672
|
}
|
|
672
673
|
getSettingsRoot(sourceRoot) {
|
|
673
|
-
return
|
|
674
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "settings");
|
|
674
675
|
}
|
|
675
676
|
usesSeparateSettingsBranch() {
|
|
676
677
|
return true;
|
|
@@ -702,16 +703,16 @@ var init_operating_mode = __esm({
|
|
|
702
703
|
});
|
|
703
704
|
|
|
704
705
|
// dist/cli/generate-ai-content.js
|
|
705
|
-
import
|
|
706
|
+
import path18 from "node:path";
|
|
706
707
|
import { createJiti } from "jiti";
|
|
707
708
|
|
|
708
709
|
// dist/build/generate-ai-content.js
|
|
709
|
-
import
|
|
710
|
-
import
|
|
710
|
+
import fs16 from "node:fs/promises";
|
|
711
|
+
import path17 from "node:path";
|
|
711
712
|
|
|
712
713
|
// dist/content-store.js
|
|
713
|
-
import
|
|
714
|
-
import
|
|
714
|
+
import fs8 from "node:fs/promises";
|
|
715
|
+
import path8 from "node:path";
|
|
715
716
|
import matter from "gray-matter";
|
|
716
717
|
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
717
718
|
|
|
@@ -736,16 +737,16 @@ async function atomicWriteFile(filePath, content) {
|
|
|
736
737
|
var locks = /* @__PURE__ */ new Map();
|
|
737
738
|
async function withLock(key, fn) {
|
|
738
739
|
const prev = locks.get(key) ?? Promise.resolve();
|
|
739
|
-
let
|
|
740
|
+
let resolve2;
|
|
740
741
|
const next = new Promise((r) => {
|
|
741
|
-
|
|
742
|
+
resolve2 = r;
|
|
742
743
|
});
|
|
743
744
|
locks.set(key, next);
|
|
744
745
|
await prev;
|
|
745
746
|
try {
|
|
746
747
|
return await fn();
|
|
747
748
|
} finally {
|
|
748
|
-
|
|
749
|
+
resolve2();
|
|
749
750
|
if (locks.get(key) === next)
|
|
750
751
|
locks.delete(key);
|
|
751
752
|
}
|
|
@@ -1076,13 +1077,13 @@ function extractIdFromFilename(filename) {
|
|
|
1076
1077
|
return null;
|
|
1077
1078
|
}
|
|
1078
1079
|
async function resolveCollectionPath(root, logicalPath) {
|
|
1079
|
-
const
|
|
1080
|
-
const
|
|
1080
|
+
const fs17 = await import("node:fs/promises");
|
|
1081
|
+
const path19 = await import("node:path");
|
|
1081
1082
|
const segments = logicalPath.split("/").filter(Boolean);
|
|
1082
1083
|
let currentPath = root;
|
|
1083
1084
|
for (const segment of segments) {
|
|
1084
1085
|
try {
|
|
1085
|
-
const entries = await
|
|
1086
|
+
const entries = await fs17.readdir(currentPath, { withFileTypes: true });
|
|
1086
1087
|
const matchingDir = entries.find((entry) => {
|
|
1087
1088
|
if (!entry.isDirectory())
|
|
1088
1089
|
return false;
|
|
@@ -1090,7 +1091,7 @@ async function resolveCollectionPath(root, logicalPath) {
|
|
|
1090
1091
|
return logicalName === segment.toLowerCase();
|
|
1091
1092
|
});
|
|
1092
1093
|
if (matchingDir) {
|
|
1093
|
-
currentPath =
|
|
1094
|
+
currentPath = path19.join(currentPath, matchingDir.name);
|
|
1094
1095
|
} else {
|
|
1095
1096
|
return null;
|
|
1096
1097
|
}
|
|
@@ -1181,7 +1182,7 @@ function invalidateContentIndexesForRoot(root) {
|
|
|
1181
1182
|
}
|
|
1182
1183
|
}
|
|
1183
1184
|
|
|
1184
|
-
// dist/
|
|
1185
|
+
// dist/resource-generation.js
|
|
1185
1186
|
import fs3 from "node:fs/promises";
|
|
1186
1187
|
import path4 from "node:path";
|
|
1187
1188
|
import { randomUUID } from "node:crypto";
|
|
@@ -1277,1237 +1278,1472 @@ var testLogger = createDebugLogger({
|
|
|
1277
1278
|
throwOnError: false
|
|
1278
1279
|
});
|
|
1279
1280
|
|
|
1280
|
-
// dist/
|
|
1281
|
-
var log = createDebugLogger({ prefix: "
|
|
1281
|
+
// dist/resource-generation.js
|
|
1282
|
+
var log = createDebugLogger({ prefix: "ResourceGeneration" });
|
|
1282
1283
|
var META_DIR = ".canopy-meta";
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
return path4.join(path4.resolve(root), META_DIR, GENERATION_FILE);
|
|
1284
|
+
function resourceGenerationPath(root, resource) {
|
|
1285
|
+
return path4.resolve(root, META_DIR, `${resource}.generation`);
|
|
1286
1286
|
}
|
|
1287
|
-
async function
|
|
1287
|
+
async function bumpResourceGeneration(root, resource, options) {
|
|
1288
1288
|
const token = randomUUID();
|
|
1289
1289
|
try {
|
|
1290
|
-
await atomicWriteFile(
|
|
1290
|
+
await atomicWriteFile(resourceGenerationPath(root, resource), token);
|
|
1291
1291
|
return token;
|
|
1292
1292
|
} catch (err) {
|
|
1293
|
-
log.warn("generation", `Failed to bump
|
|
1293
|
+
log.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
|
|
1294
1294
|
error: getErrorMessage(err)
|
|
1295
1295
|
});
|
|
1296
|
+
if (options?.mustSucceed)
|
|
1297
|
+
throw err;
|
|
1296
1298
|
return null;
|
|
1297
1299
|
}
|
|
1298
1300
|
}
|
|
1299
|
-
async function
|
|
1301
|
+
async function readResourceGeneration(root, resource) {
|
|
1300
1302
|
try {
|
|
1301
|
-
|
|
1303
|
+
const token = await fs3.readFile(resourceGenerationPath(root, resource), "utf-8");
|
|
1304
|
+
return { ok: true, token };
|
|
1302
1305
|
} catch (err) {
|
|
1303
1306
|
if (isNodeError(err) && err.code === "ENOENT")
|
|
1304
|
-
return null;
|
|
1305
|
-
log.warn("generation", `Failed to read
|
|
1307
|
+
return { ok: true, token: null };
|
|
1308
|
+
log.warn("generation", `Failed to read ${resource} generation for ${root}`, {
|
|
1306
1309
|
error: getErrorMessage(err)
|
|
1307
1310
|
});
|
|
1308
|
-
return
|
|
1311
|
+
return { ok: false };
|
|
1309
1312
|
}
|
|
1310
1313
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
invalidateContentIndexesForRoot(root);
|
|
1314
|
+
function isGenerationCurrent(snapshotToken, read) {
|
|
1315
|
+
return read.ok && read.token === snapshotToken;
|
|
1314
1316
|
}
|
|
1315
1317
|
|
|
1316
|
-
// dist/
|
|
1317
|
-
import
|
|
1318
|
-
|
|
1318
|
+
// dist/branch-schema-cache.js
|
|
1319
|
+
import fs5 from "node:fs/promises";
|
|
1320
|
+
import path5 from "node:path";
|
|
1321
|
+
|
|
1322
|
+
// dist/schema/meta-loader.js
|
|
1323
|
+
import { promises as fs4 } from "fs";
|
|
1324
|
+
import { join } from "pathe";
|
|
1325
|
+
import { z } from "zod";
|
|
1326
|
+
import chokidar from "chokidar";
|
|
1327
|
+
var entryTypeMetaSchema = z.object({
|
|
1328
|
+
name: z.string().min(1),
|
|
1329
|
+
format: z.enum(["md", "mdx", "json", "yaml"]),
|
|
1330
|
+
schema: z.string().min(1),
|
|
1331
|
+
// Entry schema registry key (validated at resolution time)
|
|
1332
|
+
label: z.string().optional(),
|
|
1333
|
+
description: z.string().optional(),
|
|
1334
|
+
default: z.boolean().optional(),
|
|
1335
|
+
maxItems: z.number().int().positive().optional()
|
|
1336
|
+
});
|
|
1337
|
+
var collectionMetaSchema = z.object({
|
|
1338
|
+
name: z.string().min(1),
|
|
1339
|
+
label: z.string().optional(),
|
|
1340
|
+
description: z.string().optional(),
|
|
1341
|
+
entries: z.array(entryTypeMetaSchema).optional(),
|
|
1342
|
+
order: z.array(z.string()).default([])
|
|
1343
|
+
// Embedded IDs for ordering; optional in the file, [] = alphabetical
|
|
1344
|
+
}).refine((data) => data.entries && data.entries.length > 0, {
|
|
1345
|
+
message: "Collection must have at least one entry type"
|
|
1346
|
+
});
|
|
1347
|
+
var rootCollectionMetaSchema = z.object({
|
|
1348
|
+
label: z.string().optional(),
|
|
1349
|
+
entries: z.array(entryTypeMetaSchema).optional(),
|
|
1350
|
+
order: z.array(z.string()).optional()
|
|
1351
|
+
// Embedded IDs for ordering items
|
|
1352
|
+
});
|
|
1353
|
+
function stripEmbeddedIdFromName(name) {
|
|
1354
|
+
return extractSlugFromFilename(name);
|
|
1355
|
+
}
|
|
1356
|
+
async function scanForCollectionMeta(baseDir, relativePath = "") {
|
|
1357
|
+
const collections = [];
|
|
1319
1358
|
try {
|
|
1320
|
-
await fs4.
|
|
1321
|
-
|
|
1359
|
+
const entries = await fs4.readdir(baseDir, { withFileTypes: true });
|
|
1360
|
+
for (const entry of entries) {
|
|
1361
|
+
if (!entry.isDirectory())
|
|
1362
|
+
continue;
|
|
1363
|
+
const folderName = entry.name;
|
|
1364
|
+
const logicalName = stripEmbeddedIdFromName(folderName);
|
|
1365
|
+
const collectionContentId = extractIdFromFilename(folderName) ?? void 0;
|
|
1366
|
+
const folderPath = relativePath ? `${relativePath}/${logicalName}` : logicalName;
|
|
1367
|
+
const absolutePath = join(baseDir, folderName);
|
|
1368
|
+
const metaPath = join(absolutePath, ".collection.json");
|
|
1369
|
+
try {
|
|
1370
|
+
await fs4.access(metaPath);
|
|
1371
|
+
const content = await fs4.readFile(metaPath, "utf-8");
|
|
1372
|
+
const parsed = JSON.parse(content);
|
|
1373
|
+
const meta = collectionMetaSchema.parse(parsed);
|
|
1374
|
+
collections.push({
|
|
1375
|
+
...meta,
|
|
1376
|
+
path: folderPath,
|
|
1377
|
+
// Path derived from folder name
|
|
1378
|
+
contentId: collectionContentId
|
|
1379
|
+
});
|
|
1380
|
+
const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
|
|
1381
|
+
collections.push(...nestedCollections);
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
if (err.code !== "ENOENT") {
|
|
1384
|
+
console.error(`Error loading ${metaPath}:`, err);
|
|
1385
|
+
throw new Error(`Invalid .collection.json in ${folderPath}: ${err.message}`);
|
|
1386
|
+
}
|
|
1387
|
+
const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
|
|
1388
|
+
collections.push(...nestedCollections);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
return collections;
|
|
1322
1392
|
} catch (err) {
|
|
1323
|
-
if (
|
|
1324
|
-
return
|
|
1393
|
+
if (err.code === "ENOENT") {
|
|
1394
|
+
return [];
|
|
1395
|
+
}
|
|
1325
1396
|
throw err;
|
|
1326
1397
|
}
|
|
1327
1398
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
};
|
|
1339
|
-
var isDataOnlyFormat = (format) => format === "json" || format === "yaml";
|
|
1340
|
-
var asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1341
|
-
|
|
1342
|
-
// dist/paths/index.js
|
|
1343
|
-
init_normalize();
|
|
1344
|
-
|
|
1345
|
-
// dist/paths/branch.js
|
|
1346
|
-
init_operating_mode();
|
|
1347
|
-
import fs5 from "node:fs/promises";
|
|
1348
|
-
import path6 from "node:path";
|
|
1349
|
-
var BranchPathError = class extends Error {
|
|
1350
|
-
};
|
|
1351
|
-
function sanitizeBranchName(branchName) {
|
|
1352
|
-
const replaced = branchName.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
1353
|
-
const squashed = replaced.replace(/-+/g, "-");
|
|
1354
|
-
const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
|
|
1355
|
-
return trimmedDots || "branch";
|
|
1356
|
-
}
|
|
1357
|
-
var resolveContentBranchesRoot = (mode, override) => {
|
|
1358
|
-
return operatingStrategy(mode).getContentBranchesRoot(override);
|
|
1359
|
-
};
|
|
1360
|
-
function resolveBranchPath(options) {
|
|
1361
|
-
if (options.branchName.includes("..")) {
|
|
1362
|
-
throw new BranchPathError("Branch name cannot contain traversal segments");
|
|
1399
|
+
async function loadCollectionMetaFiles(contentRoot) {
|
|
1400
|
+
let root = null;
|
|
1401
|
+
const rootMetaPath = join(contentRoot, ".collection.json");
|
|
1402
|
+
try {
|
|
1403
|
+
await fs4.access(rootMetaPath);
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
if (err.code === "ENOENT") {
|
|
1406
|
+
} else {
|
|
1407
|
+
throw err;
|
|
1408
|
+
}
|
|
1363
1409
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
};
|
|
1374
|
-
if (!withinBase(branchRoot)) {
|
|
1375
|
-
throw new BranchPathError("Branch path resolves outside the base root");
|
|
1410
|
+
try {
|
|
1411
|
+
const content = await fs4.readFile(rootMetaPath, "utf-8");
|
|
1412
|
+
const parsed = JSON.parse(content);
|
|
1413
|
+
root = rootCollectionMetaSchema.parse(parsed);
|
|
1414
|
+
} catch (err) {
|
|
1415
|
+
const errno = err.code;
|
|
1416
|
+
if (errno !== "ENOENT") {
|
|
1417
|
+
throw new Error(`Invalid root .collection.json`);
|
|
1418
|
+
}
|
|
1376
1419
|
}
|
|
1377
|
-
|
|
1420
|
+
const collections = await scanForCollectionMeta(contentRoot);
|
|
1421
|
+
return { root, collections };
|
|
1378
1422
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1423
|
+
function resolveEntryTypes(entryTypes, entrySchemaRegistry, contextName) {
|
|
1424
|
+
return entryTypes.map((entryType) => {
|
|
1425
|
+
const resolvedSchema = entrySchemaRegistry[entryType.schema];
|
|
1426
|
+
if (!resolvedSchema) {
|
|
1427
|
+
throw new Error(`Schema reference "${entryType.schema}" in entry type "${entryType.name}" (${contextName}) not found in registry. Available schemas: ${Object.keys(entrySchemaRegistry).join(", ")}`);
|
|
1428
|
+
}
|
|
1429
|
+
return {
|
|
1430
|
+
name: entryType.name,
|
|
1431
|
+
label: entryType.label,
|
|
1432
|
+
format: entryType.format,
|
|
1433
|
+
schema: resolvedSchema,
|
|
1434
|
+
schemaRef: entryType.schema,
|
|
1435
|
+
default: entryType.default,
|
|
1436
|
+
maxItems: entryType.maxItems
|
|
1437
|
+
};
|
|
1438
|
+
});
|
|
1383
1439
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1440
|
+
function resolveCollectionMeta(meta, entrySchemaRegistry, allCollections) {
|
|
1441
|
+
const entries = meta.entries && meta.entries.length > 0 ? resolveEntryTypes(meta.entries, entrySchemaRegistry, `collection "${meta.name}"`) : void 0;
|
|
1442
|
+
const nestedCollections = allCollections.filter((col) => {
|
|
1443
|
+
return col.path.startsWith(`${meta.path}/`) && col.path.split("/").length === meta.path.split("/").length + 1;
|
|
1444
|
+
});
|
|
1445
|
+
const collections = nestedCollections.length > 0 ? nestedCollections.map((nestedMeta) => resolveCollectionMeta(nestedMeta, entrySchemaRegistry, allCollections)) : void 0;
|
|
1446
|
+
return {
|
|
1447
|
+
name: meta.name,
|
|
1448
|
+
label: meta.label,
|
|
1449
|
+
path: meta.path,
|
|
1450
|
+
contentId: meta.contentId,
|
|
1451
|
+
...entries && { entries },
|
|
1452
|
+
...meta.order && { order: meta.order },
|
|
1453
|
+
...collections && { collections }
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
function resolveCollectionReferences(metaFiles, entrySchemaRegistry) {
|
|
1457
|
+
const result = {};
|
|
1458
|
+
if (metaFiles.root?.label) {
|
|
1459
|
+
result.label = metaFiles.root.label;
|
|
1390
1460
|
}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
constructor() {
|
|
1394
|
-
super("Content was modified by another editor");
|
|
1395
|
-
this.name = "ContentConflictError";
|
|
1461
|
+
if (metaFiles.root?.entries && metaFiles.root.entries.length > 0) {
|
|
1462
|
+
result.entries = resolveEntryTypes(metaFiles.root.entries, entrySchemaRegistry, "root collection");
|
|
1396
1463
|
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
if (!entries || entries.length === 0)
|
|
1400
|
-
return void 0;
|
|
1401
|
-
return entries.find((e) => e.default) || entries[0];
|
|
1402
|
-
}
|
|
1403
|
-
function validateSlug(slug) {
|
|
1404
|
-
if (slug.includes("/")) {
|
|
1405
|
-
throw new ContentStoreError("Slugs cannot contain forward slashes. Use nested collections instead.", "VALIDATION");
|
|
1464
|
+
if (metaFiles.root?.order) {
|
|
1465
|
+
result.order = metaFiles.root.order;
|
|
1406
1466
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1467
|
+
const topLevelCollections = metaFiles.collections.filter((meta) => !meta.path.includes("/"));
|
|
1468
|
+
if (topLevelCollections.length > 0) {
|
|
1469
|
+
result.collections = topLevelCollections.map((meta) => resolveCollectionMeta(meta, entrySchemaRegistry, metaFiles.collections));
|
|
1409
1470
|
}
|
|
1471
|
+
return result;
|
|
1410
1472
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
this.root = path7.resolve(root);
|
|
1423
|
-
this.indexFreshnessIntervalMs = options.indexFreshnessIntervalMs ?? DEFAULT_INDEX_FRESHNESS_INTERVAL_MS;
|
|
1424
|
-
this.schemaIndex = new Map(flatSchema.map((item) => [item.logicalPath, item]));
|
|
1425
|
-
this._idIndex = new ContentIdIndex(this.root);
|
|
1426
|
-
registerContentIndexForInvalidation(this.root, this);
|
|
1473
|
+
|
|
1474
|
+
// dist/schema/resolver.js
|
|
1475
|
+
async function resolveSchema(contentRoot, entrySchemaRegistry) {
|
|
1476
|
+
const metaFiles = await loadCollectionMetaFiles(contentRoot);
|
|
1477
|
+
const sources = [];
|
|
1478
|
+
if (metaFiles.root) {
|
|
1479
|
+
sources.push({
|
|
1480
|
+
path: ".collection.json",
|
|
1481
|
+
type: "root",
|
|
1482
|
+
collections: []
|
|
1483
|
+
});
|
|
1427
1484
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
* keep resolving to pre-mutation paths and saves can target moved/deleted files.
|
|
1435
|
-
*
|
|
1436
|
-
* Cheap: only bumps a generation counter; the rebuild happens lazily.
|
|
1437
|
-
*/
|
|
1438
|
-
invalidateIndex() {
|
|
1439
|
-
this.indexGeneration++;
|
|
1485
|
+
for (const collection of metaFiles.collections) {
|
|
1486
|
+
sources.push({
|
|
1487
|
+
path: `${collection.path}/.collection.json`,
|
|
1488
|
+
type: "collection",
|
|
1489
|
+
collections: [collection.name]
|
|
1490
|
+
});
|
|
1440
1491
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1492
|
+
const schema = resolveCollectionReferences(metaFiles, entrySchemaRegistry);
|
|
1493
|
+
return { schema, sources };
|
|
1494
|
+
}
|
|
1495
|
+
function isValidSchema(schema) {
|
|
1496
|
+
const hasEntries = !!(schema.entries && schema.entries.length > 0);
|
|
1497
|
+
const hasCollections = !!(schema.collections && schema.collections.length > 0);
|
|
1498
|
+
return hasEntries || hasCollections;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// dist/branch-schema-cache.js
|
|
1502
|
+
init_flatten();
|
|
1503
|
+
|
|
1504
|
+
// dist/build-mode.js
|
|
1505
|
+
var isDeployedStatic = (config) => {
|
|
1506
|
+
return config.deployedAs === "static";
|
|
1507
|
+
};
|
|
1508
|
+
var isBuildMode = () => {
|
|
1509
|
+
if (process.env.NEXT_PHASE === "phase-production-build")
|
|
1510
|
+
return true;
|
|
1511
|
+
if (process.env.CANOPY_BUILD_MODE === "true")
|
|
1512
|
+
return true;
|
|
1513
|
+
return false;
|
|
1514
|
+
};
|
|
1515
|
+
var STATIC_DEPLOY_USER = Object.freeze({
|
|
1516
|
+
type: "authenticated",
|
|
1517
|
+
userId: "__static_deploy__",
|
|
1518
|
+
groups: ["Admins"],
|
|
1519
|
+
email: "static-deploy@canopycms",
|
|
1520
|
+
name: "Static Deploy"
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
// dist/branch-schema-cache.js
|
|
1524
|
+
var SCHEMA_CACHE_VERSION = 3;
|
|
1525
|
+
var MTIME_CHECK_DEBOUNCE_MS = 1e3;
|
|
1526
|
+
var SCHEMA_GENERATION_RESOURCE = "schema";
|
|
1527
|
+
async function isStaleByMtime(dir, cachedAt) {
|
|
1528
|
+
let entries;
|
|
1529
|
+
try {
|
|
1530
|
+
entries = await fs5.readdir(dir, { recursive: true, encoding: "utf-8" });
|
|
1531
|
+
} catch {
|
|
1532
|
+
return true;
|
|
1533
|
+
}
|
|
1534
|
+
for (const entry of entries) {
|
|
1535
|
+
if (!entry.endsWith(".collection.json"))
|
|
1536
|
+
continue;
|
|
1537
|
+
const full = path5.join(dir, entry);
|
|
1538
|
+
try {
|
|
1539
|
+
const stat = await fs5.stat(full);
|
|
1540
|
+
if (stat.mtimeMs > cachedAt.getTime())
|
|
1541
|
+
return true;
|
|
1542
|
+
} catch {
|
|
1543
|
+
return true;
|
|
1478
1544
|
}
|
|
1479
|
-
|
|
1545
|
+
}
|
|
1546
|
+
return false;
|
|
1547
|
+
}
|
|
1548
|
+
var BranchSchemaCache = class {
|
|
1549
|
+
constructor(mode = "prod") {
|
|
1550
|
+
this.lastMtimeCheck = /* @__PURE__ */ new Map();
|
|
1551
|
+
this.devMode = mode === "dev";
|
|
1480
1552
|
}
|
|
1481
1553
|
/**
|
|
1482
|
-
*
|
|
1483
|
-
*
|
|
1484
|
-
*
|
|
1554
|
+
* Whether to skip the on-disk cache for this branchRoot.
|
|
1555
|
+
*
|
|
1556
|
+
* branchRoot equals the project root (process.cwd()) only in the synthetic
|
|
1557
|
+
* contexts used by static deployments and build phases — real branch roots are
|
|
1558
|
+
* always nested under the workspace (.canopy-dev/content-branches/<name> in dev,
|
|
1559
|
+
* <workspaceRoot>/content-branches/<name> in prod). Never write .canopy-meta/ at
|
|
1560
|
+
* the project root, regardless of which entrypoint produced the cwd branchRoot.
|
|
1485
1561
|
*/
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
if (now - this.lastDiskProbeMs < this.indexFreshnessIntervalMs)
|
|
1489
|
-
return false;
|
|
1490
|
-
this.lastDiskProbeMs = now;
|
|
1491
|
-
return true;
|
|
1562
|
+
skipDiskCache(branchRoot) {
|
|
1563
|
+
return isBuildMode() || path5.resolve(branchRoot) === path5.resolve(process.cwd());
|
|
1492
1564
|
}
|
|
1493
1565
|
/**
|
|
1494
|
-
*
|
|
1495
|
-
*
|
|
1496
|
-
*
|
|
1497
|
-
*
|
|
1498
|
-
*
|
|
1499
|
-
*
|
|
1500
|
-
* we leave the recorded token older and let the next probe observe our bump
|
|
1501
|
-
* and trigger the healing rebuild.
|
|
1566
|
+
* Get schema for a branch (loads from cache or resolves fresh).
|
|
1567
|
+
*
|
|
1568
|
+
* @param branchRoot - Root directory of the branch (e.g., .canopy-dev/content-branches/main)
|
|
1569
|
+
* @param entrySchemaRegistry - Map of schema names to field definitions
|
|
1570
|
+
* @param contentRootName - Name of content directory (e.g., "content") from config
|
|
1571
|
+
* @returns Resolved schema tree and flattened schema
|
|
1502
1572
|
*/
|
|
1503
|
-
async
|
|
1504
|
-
|
|
1505
|
-
if (token !== null && this.indexBuild === null && this.loadedIndexGeneration === this.indexGeneration && this._idIndex === updatedIndex) {
|
|
1506
|
-
this.loadedDiskGeneration = token;
|
|
1507
|
-
}
|
|
1573
|
+
async getSchema(branchRoot, entrySchemaRegistry, contentRootName = "content") {
|
|
1574
|
+
return this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName);
|
|
1508
1575
|
}
|
|
1509
1576
|
/**
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
* throttle how often a caller can force one. Time-boxed so genuinely
|
|
1515
|
-
* dangling IDs cost at most one rescan per window. Returns true if a
|
|
1516
|
-
* refresh was performed by THIS call; callers that lose the throttle race
|
|
1517
|
-
* still benefit — idIndex() dedupes concurrent builds, so a caller that won
|
|
1518
|
-
* the race rebuilds the index for everyone, and callers should still retry
|
|
1519
|
-
* their lookup against the live index regardless of this return value.
|
|
1577
|
+
* Resolve the schema from disk. Wrapped in a protected method (rather than
|
|
1578
|
+
* calling the imported resolveSchema directly) so tests can subclass and
|
|
1579
|
+
* override with a deferred/blocking implementation to simulate cross-process
|
|
1580
|
+
* interleavings — mirrors BranchRegistry's scanBranchDirectories() hook.
|
|
1520
1581
|
*/
|
|
1521
|
-
async
|
|
1522
|
-
|
|
1523
|
-
if (now - this.lastForcedRefreshMs < FORCED_REFRESH_MIN_INTERVAL_MS)
|
|
1524
|
-
return false;
|
|
1525
|
-
this.lastForcedRefreshMs = now;
|
|
1526
|
-
this.invalidateIndex();
|
|
1527
|
-
await this.idIndex();
|
|
1528
|
-
return true;
|
|
1582
|
+
async resolveFresh(contentRoot, entrySchemaRegistry) {
|
|
1583
|
+
return resolveSchema(contentRoot, entrySchemaRegistry);
|
|
1529
1584
|
}
|
|
1530
1585
|
/**
|
|
1531
|
-
*
|
|
1532
|
-
* Returns its root-relative path, or null if no such file (or no such dir).
|
|
1586
|
+
* Load schema from cache or resolve fresh if cache is missing or stale.
|
|
1533
1587
|
*/
|
|
1534
|
-
async
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1588
|
+
async loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName) {
|
|
1589
|
+
const contentRoot = path5.join(branchRoot, contentRootName);
|
|
1590
|
+
const skipDiskCache = this.skipDiskCache(branchRoot);
|
|
1591
|
+
if (!skipDiskCache) {
|
|
1592
|
+
const cacheDir = path5.join(branchRoot, ".canopy-meta");
|
|
1593
|
+
const cachePath = path5.join(cacheDir, "schema-cache.json");
|
|
1594
|
+
let cacheData = null;
|
|
1595
|
+
try {
|
|
1596
|
+
const cacheContent = await fs5.readFile(cachePath, "utf-8");
|
|
1597
|
+
cacheData = JSON.parse(cacheContent);
|
|
1598
|
+
} catch {
|
|
1599
|
+
cacheData = null;
|
|
1600
|
+
}
|
|
1601
|
+
if (cacheData && cacheData.version === SCHEMA_CACHE_VERSION) {
|
|
1602
|
+
const read = await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
|
|
1603
|
+
if (isGenerationCurrent(cacheData.generation, read)) {
|
|
1604
|
+
const now = Date.now();
|
|
1605
|
+
const lastCheck = this.lastMtimeCheck.get(contentRoot) ?? 0;
|
|
1606
|
+
if (this.devMode && now - lastCheck >= MTIME_CHECK_DEBOUNCE_MS && await isStaleByMtime(contentRoot, new Date(cacheData.cachedAt))) {
|
|
1607
|
+
this.lastMtimeCheck.set(contentRoot, now);
|
|
1608
|
+
cacheData = null;
|
|
1609
|
+
} else {
|
|
1610
|
+
if (this.devMode)
|
|
1611
|
+
this.lastMtimeCheck.set(contentRoot, now);
|
|
1612
|
+
return { schema: cacheData.schema, flatSchema: cacheData.flatSchema };
|
|
1613
|
+
}
|
|
1614
|
+
} else {
|
|
1615
|
+
cacheData = null;
|
|
1616
|
+
}
|
|
1617
|
+
} else {
|
|
1618
|
+
cacheData = null;
|
|
1548
1619
|
}
|
|
1549
1620
|
}
|
|
1550
|
-
return
|
|
1621
|
+
return this.resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, {
|
|
1622
|
+
skipDiskCache
|
|
1623
|
+
});
|
|
1551
1624
|
}
|
|
1552
1625
|
/**
|
|
1553
|
-
*
|
|
1554
|
-
*
|
|
1626
|
+
* Resolve the schema fresh from disk and persist it (subject to the
|
|
1627
|
+
* skip-persist rule below), UNCONDITIONALLY -- never through the cache-read
|
|
1628
|
+
* fast path in {@link loadFromCacheOrResolve}. Shared by that method's
|
|
1629
|
+
* cache-miss fallback and by {@link resolveAndPersist}.
|
|
1555
1630
|
*/
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
throw new ContentStoreError(`Unknown schema item: ${path17}`, "NO_SCHEMA_ITEM");
|
|
1631
|
+
async resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, options) {
|
|
1632
|
+
const { skipDiskCache } = options;
|
|
1633
|
+
const contentRoot = path5.join(branchRoot, contentRootName);
|
|
1634
|
+
const read = skipDiskCache ? null : await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
|
|
1635
|
+
const result = await this.resolveFresh(contentRoot, entrySchemaRegistry);
|
|
1636
|
+
if (!isValidSchema(result.schema)) {
|
|
1637
|
+
throw new Error(`No schema found in ${contentRoot}. Create .collection.json files with references to field schemas defined in your entry schema registry.`);
|
|
1564
1638
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1639
|
+
const flatSchema = flattenSchema(result.schema, contentRootName);
|
|
1640
|
+
if (!skipDiskCache) {
|
|
1641
|
+
const cacheDir = path5.join(branchRoot, ".canopy-meta");
|
|
1642
|
+
const cachePath = path5.join(cacheDir, "schema-cache.json");
|
|
1643
|
+
await fs5.unlink(path5.join(cacheDir, "schema-cache.stale")).catch(() => {
|
|
1644
|
+
});
|
|
1645
|
+
if (read && read.ok) {
|
|
1646
|
+
const newCache = {
|
|
1647
|
+
version: SCHEMA_CACHE_VERSION,
|
|
1648
|
+
schema: result.schema,
|
|
1649
|
+
flatSchema,
|
|
1650
|
+
cachedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1651
|
+
generation: read.token
|
|
1652
|
+
};
|
|
1653
|
+
await fs5.mkdir(cacheDir, { recursive: true });
|
|
1654
|
+
const tmpPath = path5.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
|
|
1655
|
+
await fs5.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
|
|
1656
|
+
try {
|
|
1657
|
+
await fs5.rename(tmpPath, cachePath);
|
|
1658
|
+
} catch (err) {
|
|
1659
|
+
await fs5.unlink(tmpPath).catch(() => {
|
|
1660
|
+
});
|
|
1661
|
+
throw err;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1571
1664
|
}
|
|
1572
|
-
return
|
|
1665
|
+
return { schema: result.schema, flatSchema };
|
|
1573
1666
|
}
|
|
1574
1667
|
/**
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
1668
|
+
* Resolve the schema fresh from disk and persist it, SKIPPING the cache
|
|
1669
|
+
* read entirely -- unlike {@link getSchema}, which can short-circuit
|
|
1670
|
+
* through a cache HIT. That distinction matters for eager re-resolve after
|
|
1671
|
+
* invalidation (see {@link invalidate}'s doc comment and
|
|
1672
|
+
* `SchemaOps.invalidateSchemaCache()` in schema/schema-store.ts, the sole
|
|
1673
|
+
* intended caller): the whole point of an eager re-resolve is to guarantee
|
|
1674
|
+
* ONE scan that is coherent with the mutation this host just made. A plain
|
|
1675
|
+
* `getSchema()` call right after `invalidate()` is NOT that guarantee --
|
|
1676
|
+
* `getSchema()`'s cache-read fast path (`loadFromCacheOrResolve`) can still
|
|
1677
|
+
* return a snapshot written by a DIFFERENT, concurrent host: if that
|
|
1678
|
+
* foreign host's own eager re-resolve raced this one and embedded the
|
|
1679
|
+
* (now-current) marker token over ITS OWN stale-NFS-cache scan (the
|
|
1680
|
+
* window-E case this class's doc comment describes), this host's
|
|
1681
|
+
* `getSchema()` would happily accept that foreign snapshot as "current"
|
|
1682
|
+
* and skip the one scan this call was specifically trying to guarantee.
|
|
1683
|
+
* `resolveAndPersist()` never reads the cache file at all, so it cannot be
|
|
1684
|
+
* short-circuited that way.
|
|
1685
|
+
*/
|
|
1686
|
+
async resolveAndPersist(branchRoot, entrySchemaRegistry, contentRootName = "content") {
|
|
1687
|
+
const skipDiskCache = this.skipDiskCache(branchRoot);
|
|
1688
|
+
return this.resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, {
|
|
1689
|
+
skipDiskCache
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Invalidate cache for a branch by bumping the cross-process generation
|
|
1694
|
+
* marker (resource-generation.ts). Every process sharing this branchRoot
|
|
1695
|
+
* will re-resolve at its next read.
|
|
1577
1696
|
*
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1581
|
-
* 3. Using path.resolve to normalize paths before validation
|
|
1697
|
+
* The bump must succeed: a swallowed failure here would leave the schema
|
|
1698
|
+
* cache stale indefinitely with no bounding backstop (unlike
|
|
1699
|
+
* BranchRegistry, there is no get-miss backstop for a resolved schema).
|
|
1582
1700
|
*
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1701
|
+
* No eager regeneration here (unlike BranchRegistry.invalidate()): this
|
|
1702
|
+
* method isn't given the entrySchemaRegistry/contentRootName resolveSchema
|
|
1703
|
+
* needs. See the class doc comment for why the mutating request's own
|
|
1704
|
+
* follow-up schema read closes the same window-E gap instead.
|
|
1585
1705
|
*
|
|
1586
|
-
* @param
|
|
1587
|
-
* @param options.entryTypeName - For collections with multiple entry types, specify which one to use. Defaults to the default entry type.
|
|
1706
|
+
* @param branchRoot - Root directory of the branch
|
|
1588
1707
|
*/
|
|
1589
|
-
async
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
const parentCollection = this.schemaIndex.get(parentPath);
|
|
1594
|
-
if (!parentCollection || parentCollection.type !== "collection") {
|
|
1595
|
-
throw new ContentStoreError(`Parent collection not found for entry type: ${schemaItem.name}`, "NO_SCHEMA_ITEM");
|
|
1596
|
-
}
|
|
1597
|
-
const effectiveSlug = slug || schemaItem.name;
|
|
1598
|
-
return this.buildPaths(parentCollection, effectiveSlug, {
|
|
1599
|
-
...options,
|
|
1600
|
-
entryTypeName: schemaItem.name
|
|
1601
|
-
});
|
|
1602
|
-
}
|
|
1603
|
-
if (schemaItem.type === "collection") {
|
|
1604
|
-
const safeSlug = slug.replace(/^\/+/, "").toLowerCase();
|
|
1605
|
-
if (!safeSlug) {
|
|
1606
|
-
throw new ContentStoreError("Slug is required for collection entries", "VALIDATION");
|
|
1607
|
-
}
|
|
1608
|
-
validateSlug(safeSlug);
|
|
1609
|
-
let entryTypeConfig;
|
|
1610
|
-
if (options.entryTypeName) {
|
|
1611
|
-
entryTypeConfig = schemaItem.entries?.find((e) => e.name === options.entryTypeName);
|
|
1612
|
-
if (!entryTypeConfig) {
|
|
1613
|
-
throw new ContentStoreError(`Entry type '${options.entryTypeName}' not found in collection`, "NO_SCHEMA_ITEM");
|
|
1614
|
-
}
|
|
1615
|
-
} else {
|
|
1616
|
-
entryTypeConfig = getDefaultEntryType(schemaItem.entries);
|
|
1617
|
-
}
|
|
1618
|
-
const format = entryTypeConfig?.format || "json";
|
|
1619
|
-
const ext = getFormatExtension(format);
|
|
1620
|
-
const entryTypeName = entryTypeConfig?.name || "entry";
|
|
1621
|
-
let collectionRoot = await resolveCollectionPath(this.root, schemaItem.logicalPath);
|
|
1622
|
-
if (!collectionRoot) {
|
|
1623
|
-
collectionRoot = path7.resolve(this.root, schemaItem.logicalPath);
|
|
1624
|
-
}
|
|
1625
|
-
if (!collectionRoot.startsWith(rootWithSep)) {
|
|
1626
|
-
throw new ContentStoreError("Path traversal detected", "VALIDATION");
|
|
1627
|
-
}
|
|
1628
|
-
let id = options.existingId;
|
|
1629
|
-
let existingFilename;
|
|
1630
|
-
let existingEntryType;
|
|
1631
|
-
if (!id) {
|
|
1632
|
-
const entries = await fs6.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
1633
|
-
const existingFile = entries.find((entry) => {
|
|
1634
|
-
if (entry.isDirectory())
|
|
1635
|
-
return false;
|
|
1636
|
-
const fileEntryType = extractEntryTypeFromFilename(entry.name);
|
|
1637
|
-
const existingSlug = extractSlugFromFilename(entry.name, fileEntryType || void 0);
|
|
1638
|
-
return existingSlug === safeSlug;
|
|
1639
|
-
});
|
|
1640
|
-
if (existingFile) {
|
|
1641
|
-
id = extractIdFromFilename(existingFile.name) || void 0;
|
|
1642
|
-
existingFilename = existingFile.name;
|
|
1643
|
-
existingEntryType = extractEntryTypeFromFilename(existingFile.name) || void 0;
|
|
1644
|
-
}
|
|
1645
|
-
}
|
|
1646
|
-
const finalEntryTypeName = existingEntryType || entryTypeName;
|
|
1647
|
-
let filename;
|
|
1648
|
-
if (existingFilename) {
|
|
1649
|
-
filename = existingFilename;
|
|
1650
|
-
} else {
|
|
1651
|
-
if (!id) {
|
|
1652
|
-
id = generateId();
|
|
1653
|
-
}
|
|
1654
|
-
filename = `${finalEntryTypeName}.${safeSlug}.${id}${ext}`;
|
|
1655
|
-
}
|
|
1656
|
-
const resolved = path7.resolve(collectionRoot, filename);
|
|
1657
|
-
const collectionRootWithSep = collectionRoot.endsWith(path7.sep) ? collectionRoot : `${collectionRoot}${path7.sep}`;
|
|
1658
|
-
if (!resolved.startsWith(collectionRootWithSep)) {
|
|
1659
|
-
throw new ContentStoreError("Path traversal detected", "VALIDATION");
|
|
1660
|
-
}
|
|
1661
|
-
return {
|
|
1662
|
-
absolutePath: resolved,
|
|
1663
|
-
relativePath: path7.relative(this.root, resolved),
|
|
1664
|
-
id,
|
|
1665
|
-
entryTypeName: finalEntryTypeName
|
|
1666
|
-
};
|
|
1667
|
-
}
|
|
1668
|
-
throw new ContentStoreError("Invalid schema item type", "VALIDATION");
|
|
1708
|
+
async invalidate(branchRoot) {
|
|
1709
|
+
if (this.skipDiskCache(branchRoot))
|
|
1710
|
+
return;
|
|
1711
|
+
await bumpResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE, { mustSucceed: true });
|
|
1669
1712
|
}
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1713
|
+
};
|
|
1714
|
+
|
|
1715
|
+
// dist/content-index-generation.js
|
|
1716
|
+
var RESOURCE = "content-index";
|
|
1717
|
+
async function bumpContentIndexGeneration(root) {
|
|
1718
|
+
return bumpResourceGeneration(root, RESOURCE);
|
|
1719
|
+
}
|
|
1720
|
+
async function readContentIndexGeneration(root) {
|
|
1721
|
+
const result = await readResourceGeneration(root, RESOURCE);
|
|
1722
|
+
return result.ok ? result.token : null;
|
|
1723
|
+
}
|
|
1724
|
+
async function invalidateBranchContentCaches(root) {
|
|
1725
|
+
await bumpContentIndexGeneration(root);
|
|
1726
|
+
await bumpResourceGeneration(root, SCHEMA_GENERATION_RESOURCE);
|
|
1727
|
+
invalidateContentIndexesForRoot(root);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// dist/utils/fs.js
|
|
1731
|
+
import fs6 from "node:fs/promises";
|
|
1732
|
+
async function filePathExists(filePath) {
|
|
1733
|
+
try {
|
|
1734
|
+
await fs6.stat(filePath);
|
|
1735
|
+
return true;
|
|
1736
|
+
} catch (err) {
|
|
1737
|
+
if (isNotFoundError(err))
|
|
1738
|
+
return false;
|
|
1739
|
+
throw err;
|
|
1690
1740
|
}
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// dist/utils/format.js
|
|
1744
|
+
var getFormatExtension = (format) => {
|
|
1745
|
+
if (format === "md")
|
|
1746
|
+
return ".md";
|
|
1747
|
+
if (format === "mdx")
|
|
1748
|
+
return ".mdx";
|
|
1749
|
+
if (format === "yaml")
|
|
1750
|
+
return ".yaml";
|
|
1751
|
+
return ".json";
|
|
1752
|
+
};
|
|
1753
|
+
var isDataOnlyFormat = (format) => format === "json" || format === "yaml";
|
|
1754
|
+
var asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1755
|
+
|
|
1756
|
+
// dist/paths/index.js
|
|
1757
|
+
init_normalize();
|
|
1758
|
+
|
|
1759
|
+
// dist/paths/normalize-server.js
|
|
1760
|
+
import { sep, resolve, relative } from "node:path";
|
|
1761
|
+
function validateAndNormalizePath(root, target) {
|
|
1762
|
+
const resolvedRoot = resolve(root);
|
|
1763
|
+
const withSep = resolvedRoot.endsWith(sep) ? resolvedRoot : `${resolvedRoot}${sep}`;
|
|
1764
|
+
const resolvedTarget = resolve(target);
|
|
1765
|
+
if (!resolvedTarget.startsWith(withSep) && resolvedTarget !== resolvedRoot) {
|
|
1766
|
+
return {
|
|
1767
|
+
valid: false,
|
|
1768
|
+
error: "Path traversal detected"
|
|
1769
|
+
};
|
|
1694
1770
|
}
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
collection: schemaItem.logicalPath,
|
|
1721
|
-
collectionName: schemaItem.name,
|
|
1722
|
-
format: "json",
|
|
1723
|
-
data,
|
|
1724
|
-
relativePath,
|
|
1725
|
-
absolutePath
|
|
1726
|
-
};
|
|
1727
|
-
} else if (format === "yaml") {
|
|
1728
|
-
const data = asRecord(yamlParse(raw));
|
|
1729
|
-
doc = {
|
|
1730
|
-
collection: schemaItem.logicalPath,
|
|
1731
|
-
collectionName: schemaItem.name,
|
|
1732
|
-
format: "yaml",
|
|
1733
|
-
data,
|
|
1734
|
-
relativePath,
|
|
1735
|
-
absolutePath
|
|
1736
|
-
};
|
|
1737
|
-
} else {
|
|
1738
|
-
const parsed = matter(raw);
|
|
1739
|
-
doc = {
|
|
1740
|
-
collection: schemaItem.logicalPath,
|
|
1741
|
-
collectionName: schemaItem.name,
|
|
1742
|
-
format,
|
|
1743
|
-
data: parsed.data ?? {},
|
|
1744
|
-
body: parsed.content,
|
|
1745
|
-
bodyFieldName: findBodyFieldName(fields),
|
|
1746
|
-
relativePath,
|
|
1747
|
-
absolutePath
|
|
1748
|
-
};
|
|
1749
|
-
}
|
|
1750
|
-
if (options.resolveReferences !== false) {
|
|
1751
|
-
doc.data = await this.resolveReferencesInData(doc.data, fields);
|
|
1752
|
-
}
|
|
1753
|
-
doc.version = stat.mtimeMs;
|
|
1754
|
-
return doc;
|
|
1771
|
+
const relativePath = relative(resolvedRoot, resolvedTarget).split(sep).join("/");
|
|
1772
|
+
return {
|
|
1773
|
+
valid: true,
|
|
1774
|
+
normalizedPath: relativePath
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// dist/paths/branch.js
|
|
1779
|
+
init_operating_mode();
|
|
1780
|
+
import fs7 from "node:fs/promises";
|
|
1781
|
+
import path7 from "node:path";
|
|
1782
|
+
var BranchPathError = class extends Error {
|
|
1783
|
+
};
|
|
1784
|
+
function sanitizeBranchName(branchName) {
|
|
1785
|
+
const replaced = branchName.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
1786
|
+
const squashed = replaced.replace(/-+/g, "-");
|
|
1787
|
+
const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
|
|
1788
|
+
return trimmedDots || "branch";
|
|
1789
|
+
}
|
|
1790
|
+
var resolveContentBranchesRoot = (mode, override) => {
|
|
1791
|
+
return operatingStrategy(mode).getContentBranchesRoot(override);
|
|
1792
|
+
};
|
|
1793
|
+
function resolveBranchPath(options) {
|
|
1794
|
+
if (options.branchName.includes("..")) {
|
|
1795
|
+
throw new BranchPathError("Branch name cannot contain traversal segments");
|
|
1755
1796
|
}
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1797
|
+
const safeBranch = sanitizeBranchName(options.branchName);
|
|
1798
|
+
const strategy = operatingStrategy(options.mode);
|
|
1799
|
+
const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
|
|
1800
|
+
const normalizedBase = path7.resolve(baseRoot);
|
|
1801
|
+
const baseWithSep = normalizedBase.endsWith(path7.sep) ? normalizedBase : `${normalizedBase}${path7.sep}`;
|
|
1802
|
+
const branchRoot = strategy.getContentBranchRoot(safeBranch, options.basePathOverride);
|
|
1803
|
+
const withinBase = (target) => {
|
|
1804
|
+
const resolved = path7.resolve(target);
|
|
1805
|
+
return resolved === normalizedBase || resolved.startsWith(baseWithSep);
|
|
1806
|
+
};
|
|
1807
|
+
if (!withinBase(branchRoot)) {
|
|
1808
|
+
throw new BranchPathError("Branch path resolves outside the base root");
|
|
1809
|
+
}
|
|
1810
|
+
return { branchRoot, baseRoot: normalizedBase, branchName: safeBranch };
|
|
1811
|
+
}
|
|
1812
|
+
async function ensureBranchRoot(options) {
|
|
1813
|
+
const result = resolveBranchPath(options);
|
|
1814
|
+
await fs7.mkdir(result.branchRoot, { recursive: true });
|
|
1815
|
+
return result;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// dist/content-store.js
|
|
1819
|
+
async function withLocks(keys, fn) {
|
|
1820
|
+
const sorted = Array.from(new Set(keys)).sort();
|
|
1821
|
+
const acquireFrom = (index) => index === sorted.length ? fn() : withLock(sorted[index], () => acquireFrom(index + 1));
|
|
1822
|
+
return acquireFrom(0);
|
|
1823
|
+
}
|
|
1824
|
+
var ContentStoreError = class extends Error {
|
|
1825
|
+
constructor(message, code) {
|
|
1826
|
+
super(message);
|
|
1827
|
+
this.code = code;
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
var ContentConflictError = class extends Error {
|
|
1831
|
+
constructor() {
|
|
1832
|
+
super("Content was modified by another editor");
|
|
1833
|
+
this.name = "ContentConflictError";
|
|
1834
|
+
}
|
|
1835
|
+
};
|
|
1836
|
+
function getDefaultEntryType(entries) {
|
|
1837
|
+
if (!entries || entries.length === 0)
|
|
1838
|
+
return void 0;
|
|
1839
|
+
return entries.find((e) => e.default) || entries[0];
|
|
1840
|
+
}
|
|
1841
|
+
function validateSlug(slug) {
|
|
1842
|
+
if (slug.includes("/")) {
|
|
1843
|
+
throw new ContentStoreError("Slugs cannot contain forward slashes. Use nested collections instead.", "VALIDATION");
|
|
1844
|
+
}
|
|
1845
|
+
if (slug.includes("\\")) {
|
|
1846
|
+
throw new ContentStoreError("Slugs cannot contain backslashes. Use nested collections instead.", "VALIDATION");
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
var DEFAULT_INDEX_FRESHNESS_INTERVAL_MS = 1e3;
|
|
1850
|
+
var FORCED_REFRESH_MIN_INTERVAL_MS = 5e3;
|
|
1851
|
+
var STALE_LOOKUP = Symbol("stale-index-lookup");
|
|
1852
|
+
var ContentStore = class {
|
|
1853
|
+
constructor(root, flatSchema, options = {}) {
|
|
1854
|
+
this.indexGeneration = 0;
|
|
1855
|
+
this.loadedIndexGeneration = -1;
|
|
1856
|
+
this.loadedDiskGeneration = void 0;
|
|
1857
|
+
this.lastDiskProbeMs = 0;
|
|
1858
|
+
this.lastForcedRefreshMs = 0;
|
|
1859
|
+
this.indexBuild = null;
|
|
1860
|
+
this.root = path8.resolve(root);
|
|
1861
|
+
this.indexFreshnessIntervalMs = options.indexFreshnessIntervalMs ?? DEFAULT_INDEX_FRESHNESS_INTERVAL_MS;
|
|
1862
|
+
this.schemaIndex = new Map(flatSchema.map((item) => [item.logicalPath, item]));
|
|
1863
|
+
this._idIndex = new ContentIdIndex(this.root);
|
|
1864
|
+
registerContentIndexForInvalidation(this.root, this);
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Mark the ID index stale so the next idIndex() access rebuilds it from disk.
|
|
1868
|
+
*
|
|
1869
|
+
* Called (via content-index-registry) after in-process operations that change the
|
|
1870
|
+
* working tree underneath this store — git checkout/merge/rebase, the worker's
|
|
1871
|
+
* rebase loop, and sync-core's content replacement. Without this, ID→path lookups
|
|
1872
|
+
* keep resolving to pre-mutation paths and saves can target moved/deleted files.
|
|
1873
|
+
*
|
|
1874
|
+
* Cheap: only bumps a generation counter; the rebuild happens lazily.
|
|
1875
|
+
*/
|
|
1876
|
+
invalidateIndex() {
|
|
1877
|
+
this.indexGeneration++;
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* Get the ID index, ensuring it's loaded and current first.
|
|
1881
|
+
* Loads lazily on first access and rebuilds after invalidateIndex(); repeated
|
|
1882
|
+
* accesses with no intervening invalidation reuse the already-built index,
|
|
1883
|
+
* except for a throttled probe of the on-disk generation marker that detects
|
|
1884
|
+
* mutations made by OTHER processes sharing this root (e.g. on EFS).
|
|
1885
|
+
*/
|
|
1886
|
+
async idIndex() {
|
|
1887
|
+
if (this.loadedIndexGeneration === this.indexGeneration && this.shouldProbeDiskGeneration()) {
|
|
1888
|
+
const diskToken = await readContentIndexGeneration(this.root);
|
|
1889
|
+
if (diskToken !== this.loadedDiskGeneration) {
|
|
1890
|
+
this.indexGeneration++;
|
|
1773
1891
|
}
|
|
1774
|
-
expectedFormat = entryTypeConfig?.format || "json";
|
|
1775
|
-
fields = entryTypeConfig?.schema || [];
|
|
1776
|
-
}
|
|
1777
|
-
if (expectedFormat !== input.format) {
|
|
1778
|
-
throw new ContentStoreError(`Format mismatch: expects ${expectedFormat}, got ${input.format}`, "VALIDATION");
|
|
1779
1892
|
}
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
return withLock(absolutePath, async () => {
|
|
1785
|
-
await fs6.mkdir(path7.dirname(absolutePath), { recursive: true });
|
|
1786
|
-
if (input.expectedVersion !== void 0) {
|
|
1787
|
-
try {
|
|
1788
|
-
const existing = await fs6.stat(absolutePath);
|
|
1789
|
-
if (existing.mtimeMs !== input.expectedVersion) {
|
|
1790
|
-
throw new ContentConflictError();
|
|
1791
|
-
}
|
|
1792
|
-
} catch (err) {
|
|
1793
|
-
if (err instanceof ContentConflictError)
|
|
1794
|
-
throw err;
|
|
1795
|
-
if (isNodeError(err) && err.code === "ENOENT") {
|
|
1796
|
-
} else {
|
|
1797
|
-
throw err;
|
|
1798
|
-
}
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
if (existingId && !await filePathExists(absolutePath)) {
|
|
1802
|
-
const actualRelPath = await this.findEntryPathById(path7.dirname(absolutePath), existingId);
|
|
1803
|
-
const indexedRelPath = (await this.idIndex()).findById(existingId)?.relativePath ?? null;
|
|
1804
|
-
if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
|
|
1805
|
-
throw new ContentConflictError();
|
|
1806
|
-
}
|
|
1807
|
-
}
|
|
1808
|
-
let content;
|
|
1809
|
-
if (input.format === "json") {
|
|
1810
|
-
content = `${JSON.stringify(input.data ?? {}, null, 2)}
|
|
1811
|
-
`;
|
|
1812
|
-
} else if (input.format === "yaml") {
|
|
1813
|
-
content = yamlStringify(input.data ?? {});
|
|
1814
|
-
} else {
|
|
1815
|
-
content = matter.stringify(input.body, input.data ?? {});
|
|
1816
|
-
}
|
|
1817
|
-
await atomicWriteFile(absolutePath, content);
|
|
1818
|
-
const liveIndex = this._idIndex;
|
|
1819
|
-
let staleOldAbsPath = null;
|
|
1820
|
-
if (id) {
|
|
1821
|
-
const existing = liveIndex.findById(id);
|
|
1822
|
-
if (existing) {
|
|
1823
|
-
if (existing.relativePath !== relativePath) {
|
|
1824
|
-
staleOldAbsPath = path7.join(this.root, existing.relativePath);
|
|
1825
|
-
liveIndex.updatePath(existing.id, relativePath);
|
|
1826
|
-
}
|
|
1827
|
-
} else {
|
|
1828
|
-
liveIndex.add({
|
|
1829
|
-
type: "entry",
|
|
1830
|
-
relativePath,
|
|
1831
|
-
collection: collectionPath,
|
|
1832
|
-
slug: slug || void 0
|
|
1833
|
-
});
|
|
1834
|
-
}
|
|
1835
|
-
}
|
|
1836
|
-
if (staleOldAbsPath) {
|
|
1837
|
-
await fs6.unlink(staleOldAbsPath).catch((err) => {
|
|
1838
|
-
if (!isNodeError(err) || err.code !== "ENOENT")
|
|
1839
|
-
throw err;
|
|
1840
|
-
});
|
|
1841
|
-
}
|
|
1842
|
-
await this.recordOwnMutation(liveIndex);
|
|
1843
|
-
const afterStat = await fs6.stat(absolutePath);
|
|
1844
|
-
const base = {
|
|
1845
|
-
collection: schemaItem.logicalPath,
|
|
1846
|
-
collectionName: schemaItem.name,
|
|
1847
|
-
relativePath,
|
|
1848
|
-
absolutePath,
|
|
1849
|
-
version: afterStat.mtimeMs
|
|
1850
|
-
};
|
|
1851
|
-
if (input.format === "json") {
|
|
1852
|
-
return { ...base, format: "json", data: input.data ?? {} };
|
|
1893
|
+
while (this.loadedIndexGeneration !== this.indexGeneration) {
|
|
1894
|
+
if (this.indexBuild) {
|
|
1895
|
+
await this.indexBuild;
|
|
1896
|
+
continue;
|
|
1853
1897
|
}
|
|
1854
|
-
|
|
1855
|
-
|
|
1898
|
+
const generation = this.indexGeneration;
|
|
1899
|
+
const build = (async () => {
|
|
1900
|
+
const diskToken = await readContentIndexGeneration(this.root);
|
|
1901
|
+
const fresh = new ContentIdIndex(this.root);
|
|
1902
|
+
await fresh.buildFromFilenames("content");
|
|
1903
|
+
return { diskToken, fresh };
|
|
1904
|
+
})();
|
|
1905
|
+
this.indexBuild = build;
|
|
1906
|
+
let result;
|
|
1907
|
+
try {
|
|
1908
|
+
result = await build;
|
|
1909
|
+
} finally {
|
|
1910
|
+
this.indexBuild = null;
|
|
1856
1911
|
}
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
};
|
|
1864
|
-
});
|
|
1912
|
+
this._idIndex = result.fresh;
|
|
1913
|
+
this.loadedIndexGeneration = generation;
|
|
1914
|
+
this.loadedDiskGeneration = result.diskToken;
|
|
1915
|
+
this.lastDiskProbeMs = Date.now();
|
|
1916
|
+
}
|
|
1917
|
+
return this._idIndex;
|
|
1865
1918
|
}
|
|
1866
1919
|
/**
|
|
1867
|
-
*
|
|
1868
|
-
*
|
|
1869
|
-
*
|
|
1870
|
-
* Suspicious results (ID missing from the index, or an index hit whose file
|
|
1871
|
-
* is gone) trigger one forced index refresh and a retry — self-healing for
|
|
1872
|
-
* mutations by other processes inside the marker's residual windows.
|
|
1920
|
+
* Throttle for the on-disk marker probe. Stamps the clock synchronously
|
|
1921
|
+
* before the caller's readFile so concurrent idIndex() callers don't
|
|
1922
|
+
* double-probe (and double-rebuild) on the same token change.
|
|
1873
1923
|
*/
|
|
1874
|
-
|
|
1875
|
-
const
|
|
1876
|
-
if (
|
|
1877
|
-
return
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
const second = await this.readByIdOnce(id);
|
|
1881
|
-
return second === STALE_LOOKUP ? null : second;
|
|
1924
|
+
shouldProbeDiskGeneration() {
|
|
1925
|
+
const now = Date.now();
|
|
1926
|
+
if (now - this.lastDiskProbeMs < this.indexFreshnessIntervalMs)
|
|
1927
|
+
return false;
|
|
1928
|
+
this.lastDiskProbeMs = now;
|
|
1929
|
+
return true;
|
|
1882
1930
|
}
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1931
|
+
/**
|
|
1932
|
+
* After one of this store's own successful mutations: publish the change to
|
|
1933
|
+
* other processes (bump the on-disk marker) and adopt the written token,
|
|
1934
|
+
* since the in-memory index was already updated incrementally — avoiding a
|
|
1935
|
+
* pointless self-rescan on the next probe. Adoption is skipped unless the
|
|
1936
|
+
* index is quiescent and `updatedIndex` is still the live instance: if a
|
|
1937
|
+
* rebuild raced with the mutation, its scan may predate our file change, so
|
|
1938
|
+
* we leave the recorded token older and let the next probe observe our bump
|
|
1939
|
+
* and trigger the healing rebuild.
|
|
1940
|
+
*/
|
|
1941
|
+
async recordOwnMutation(updatedIndex) {
|
|
1942
|
+
const token = await bumpContentIndexGeneration(this.root);
|
|
1943
|
+
if (token !== null && this.indexBuild === null && this.loadedIndexGeneration === this.indexGeneration && this._idIndex === updatedIndex) {
|
|
1944
|
+
this.loadedDiskGeneration = token;
|
|
1896
1945
|
}
|
|
1897
1946
|
}
|
|
1898
1947
|
/**
|
|
1899
|
-
*
|
|
1900
|
-
*
|
|
1948
|
+
* Backstop for the residual staleness windows of the cross-process marker
|
|
1949
|
+
* (NFS attribute caching, probe throttle, self-adoption — see
|
|
1950
|
+
* content-index-generation.ts): force one rebuild in response to a
|
|
1951
|
+
* suspicious lookup (an ID miss, or an index hit whose file is gone), but
|
|
1952
|
+
* throttle how often a caller can force one. Time-boxed so genuinely
|
|
1953
|
+
* dangling IDs cost at most one rescan per window. Returns true if a
|
|
1954
|
+
* refresh was performed by THIS call; callers that lose the throttle race
|
|
1955
|
+
* still benefit — idIndex() dedupes concurrent builds, so a caller that won
|
|
1956
|
+
* the race rebuilds the index for everyone, and callers should still retry
|
|
1957
|
+
* their lookup against the live index regardless of this return value.
|
|
1901
1958
|
*/
|
|
1902
|
-
async
|
|
1903
|
-
const
|
|
1904
|
-
|
|
1905
|
-
|
|
1959
|
+
async refreshIndexForSuspiciousLookup() {
|
|
1960
|
+
const now = Date.now();
|
|
1961
|
+
if (now - this.lastForcedRefreshMs < FORCED_REFRESH_MIN_INTERVAL_MS)
|
|
1962
|
+
return false;
|
|
1963
|
+
this.lastForcedRefreshMs = now;
|
|
1964
|
+
this.invalidateIndex();
|
|
1965
|
+
await this.idIndex();
|
|
1966
|
+
return true;
|
|
1906
1967
|
}
|
|
1907
1968
|
/**
|
|
1908
|
-
*
|
|
1909
|
-
*
|
|
1910
|
-
* scaffolds and maxItems enforcement).
|
|
1969
|
+
* Find the file in `dir` whose filename embeds `id`.
|
|
1970
|
+
* Returns its root-relative path, or null if no such file (or no such dir).
|
|
1911
1971
|
*/
|
|
1912
|
-
async
|
|
1913
|
-
|
|
1914
|
-
const { absolutePath } = await this.buildPaths(schemaItem, slug);
|
|
1972
|
+
async findEntryPathById(dir, id) {
|
|
1973
|
+
let entries;
|
|
1915
1974
|
try {
|
|
1916
|
-
await
|
|
1917
|
-
return true;
|
|
1975
|
+
entries = await fs8.readdir(dir, { withFileTypes: true });
|
|
1918
1976
|
} catch (err) {
|
|
1919
1977
|
if (isNodeError(err) && err.code === "ENOENT")
|
|
1920
|
-
return
|
|
1978
|
+
return null;
|
|
1921
1979
|
throw err;
|
|
1922
1980
|
}
|
|
1981
|
+
for (const entry of entries) {
|
|
1982
|
+
if (entry.isDirectory())
|
|
1983
|
+
continue;
|
|
1984
|
+
if (extractIdFromFilename(entry.name) === id) {
|
|
1985
|
+
return path8.relative(this.root, path8.join(dir, entry.name));
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
return null;
|
|
1923
1989
|
}
|
|
1924
1990
|
/**
|
|
1925
|
-
*
|
|
1926
|
-
*
|
|
1927
|
-
* (`{type}.{slug}.{id}.{ext}`) and it is immutable after creation — see the
|
|
1928
|
-
* existing-file lookup in buildPaths(), which this mirrors. Returns
|
|
1929
|
-
* undefined if no entry exists yet at this slug.
|
|
1930
|
-
*
|
|
1931
|
-
* Used at the API write boundary (api/content.ts) to validate an existing
|
|
1932
|
-
* entry's payload against ITS real schema rather than a caller-supplied (or
|
|
1933
|
-
* omitted/spoofed) `entryType` param — ContentStore.write() preserves the
|
|
1934
|
-
* on-disk type regardless of what's requested, so validation must agree
|
|
1935
|
-
* with what will actually be written (post-review M2).
|
|
1936
|
-
*
|
|
1937
|
-
* Cheap: one buildPaths() resolution plus a stat, no file content is read —
|
|
1938
|
-
* same cost class as documentExists().
|
|
1991
|
+
* Get all schema items for iteration.
|
|
1992
|
+
* Used internally by ReferenceResolver for path matching.
|
|
1939
1993
|
*/
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1994
|
+
getSchemaItems() {
|
|
1995
|
+
return this.schemaIndex.values();
|
|
1996
|
+
}
|
|
1997
|
+
assertSchemaItem(path19) {
|
|
1998
|
+
const normalized = normalizeFilesystemPath(path19);
|
|
1999
|
+
const item = this.schemaIndex.get(normalized);
|
|
2000
|
+
if (!item) {
|
|
2001
|
+
throw new ContentStoreError(`Unknown schema item: ${path19}`, "NO_SCHEMA_ITEM");
|
|
2002
|
+
}
|
|
2003
|
+
return item;
|
|
2004
|
+
}
|
|
2005
|
+
assertCollection(collectionPath) {
|
|
2006
|
+
const item = this.assertSchemaItem(collectionPath);
|
|
2007
|
+
if (item.type !== "collection") {
|
|
2008
|
+
throw new ContentStoreError(`Path is not a collection: ${collectionPath}`, "NO_SCHEMA_ITEM");
|
|
2009
|
+
}
|
|
2010
|
+
return item;
|
|
1944
2011
|
}
|
|
1945
2012
|
/**
|
|
1946
|
-
*
|
|
1947
|
-
* (
|
|
1948
|
-
*
|
|
2013
|
+
* Lock key for an existing entry, addressed by its permanent content ID
|
|
2014
|
+
* (stable across renames -- the whole point of this locking scheme; see
|
|
2015
|
+
* .claude/future-tasks/resolved/content-store-lock-key.md). Namespaced by store
|
|
2016
|
+
* root: the same content ID exists in every clone of a branch, and one
|
|
2017
|
+
* process can hold ContentStore instances on several clones (dev
|
|
2018
|
+
* workspace roots, prod branch clones) at once, so the root prefix keeps
|
|
2019
|
+
* the keyspace scoped per-store. It also keeps this ID keyspace disjoint
|
|
2020
|
+
* from other modules' raw-path-keyed locks sharing the same withLock()
|
|
2021
|
+
* map (comment-store, branch-metadata, etc).
|
|
1949
2022
|
*/
|
|
1950
|
-
|
|
1951
|
-
this.
|
|
1952
|
-
const collectionRoot = await resolveCollectionPath(this.root, collectionPath);
|
|
1953
|
-
if (!collectionRoot)
|
|
1954
|
-
return 0;
|
|
1955
|
-
const entries = await fs6.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
1956
|
-
return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
|
|
2023
|
+
idLockKey(id) {
|
|
2024
|
+
return `${this.root}:id:${id}`;
|
|
1957
2025
|
}
|
|
1958
2026
|
/**
|
|
1959
|
-
*
|
|
2027
|
+
* Stable collection+slug identifier for lock-key namespacing. Mirrors the
|
|
2028
|
+
* schemaItem resolution buildPaths() performs for entry-type delegation:
|
|
2029
|
+
* an entry-type item delegates to its parent collection with the slug
|
|
2030
|
+
* defaulting to the entry type's own name (see buildPaths()'s
|
|
2031
|
+
* `schemaItem.type === 'entry-type'` branch).
|
|
1960
2032
|
*/
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
return
|
|
1966
|
-
await fs6.unlink(absolutePath);
|
|
1967
|
-
const liveIndex = this._idIndex;
|
|
1968
|
-
const id = liveIndex.findByPath(relativePath);
|
|
1969
|
-
if (id) {
|
|
1970
|
-
liveIndex.remove(id);
|
|
1971
|
-
}
|
|
1972
|
-
await this.recordOwnMutation(liveIndex);
|
|
1973
|
-
});
|
|
2033
|
+
collectionSlugKey(schemaItem, slug) {
|
|
2034
|
+
if (schemaItem.type === "entry-type") {
|
|
2035
|
+
return `${schemaItem.parentPath}/${slug || schemaItem.name}`;
|
|
2036
|
+
}
|
|
2037
|
+
return `${schemaItem.logicalPath}/${slug}`;
|
|
1974
2038
|
}
|
|
1975
2039
|
/**
|
|
1976
|
-
*
|
|
1977
|
-
*
|
|
1978
|
-
*
|
|
1979
|
-
*
|
|
1980
|
-
*
|
|
1981
|
-
*
|
|
1982
|
-
*
|
|
1983
|
-
*
|
|
2040
|
+
* Lock key for a not-yet-existing entry (a create). Serializes concurrent
|
|
2041
|
+
* same-slug creates WITHIN this process: the second call's in-lock
|
|
2042
|
+
* buildPaths() re-resolution will find the first call's just-written file
|
|
2043
|
+
* and fold in as an edit (see write()). A concurrent create racing from a
|
|
2044
|
+
* DIFFERENT process is NOT covered by this in-process mutex -- accepted
|
|
2045
|
+
* per the epic's design review: each writer mints its own fresh ID and
|
|
2046
|
+
* writes its own distinct filename, so both writes succeed and the result
|
|
2047
|
+
* is two same-slug files with different IDs (a slug-uniqueness violation
|
|
2048
|
+
* surfaced on the next listing/lookup), never a duplicate-ID collision
|
|
2049
|
+
* that would poison index rebuilds.
|
|
1984
2050
|
*/
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
const collection = this.assertCollection(collectionPath);
|
|
1988
|
-
validateSlug(newSlug);
|
|
1989
|
-
const safeNewSlug = newSlug.replace(/^\/+/, "");
|
|
1990
|
-
if (!safeNewSlug) {
|
|
1991
|
-
throw new ContentStoreError("New slug cannot be empty", "VALIDATION");
|
|
1992
|
-
}
|
|
1993
|
-
if (currentSlug === safeNewSlug) {
|
|
1994
|
-
return { newPath: `${collectionPath}/${currentSlug}` };
|
|
1995
|
-
}
|
|
1996
|
-
const { absolutePath: currentPath, relativePath: currentRelPath } = await this.buildPaths(collection, currentSlug);
|
|
1997
|
-
return withLock(currentPath, async () => {
|
|
1998
|
-
try {
|
|
1999
|
-
await fs6.access(currentPath);
|
|
2000
|
-
} catch {
|
|
2001
|
-
throw new ContentStoreError(`Entry not found: ${currentSlug}`, "NOT_FOUND");
|
|
2002
|
-
}
|
|
2003
|
-
const currentFilename = path7.basename(currentPath);
|
|
2004
|
-
const parts = currentFilename.split(".");
|
|
2005
|
-
if (parts.length < 4) {
|
|
2006
|
-
throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, "VALIDATION");
|
|
2007
|
-
}
|
|
2008
|
-
const entryTypeName = parts[0];
|
|
2009
|
-
const contentId = parts[parts.length - 2];
|
|
2010
|
-
const ext = `.${parts[parts.length - 1]}`;
|
|
2011
|
-
const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
|
|
2012
|
-
const parentDir = path7.dirname(currentPath);
|
|
2013
|
-
const newPath = path7.join(parentDir, newFilename);
|
|
2014
|
-
try {
|
|
2015
|
-
const entries = await fs6.readdir(parentDir, { withFileTypes: true });
|
|
2016
|
-
for (const entry of entries) {
|
|
2017
|
-
if (entry.isDirectory())
|
|
2018
|
-
continue;
|
|
2019
|
-
const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
|
|
2020
|
-
if (existingSlug === safeNewSlug) {
|
|
2021
|
-
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
} catch (err) {
|
|
2025
|
-
if (err instanceof ContentStoreError)
|
|
2026
|
-
throw err;
|
|
2027
|
-
}
|
|
2028
|
-
try {
|
|
2029
|
-
await fs6.link(currentPath, newPath);
|
|
2030
|
-
} catch (err) {
|
|
2031
|
-
if (isNodeError(err) && err.code === "EEXIST") {
|
|
2032
|
-
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
|
|
2033
|
-
}
|
|
2034
|
-
throw err;
|
|
2035
|
-
}
|
|
2036
|
-
await fs6.unlink(currentPath);
|
|
2037
|
-
const newRelativePath = path7.relative(this.root, newPath);
|
|
2038
|
-
const liveIndex = this._idIndex;
|
|
2039
|
-
const entryId = liveIndex.findByPath(currentRelPath);
|
|
2040
|
-
if (entryId) {
|
|
2041
|
-
liveIndex.updatePath(entryId, newRelativePath);
|
|
2042
|
-
}
|
|
2043
|
-
await this.recordOwnMutation(liveIndex);
|
|
2044
|
-
return { newPath: `${collectionPath}/${safeNewSlug}` };
|
|
2045
|
-
});
|
|
2051
|
+
createLockKey(schemaItem, slug) {
|
|
2052
|
+
return `${this.root}:create:${this.collectionSlugKey(schemaItem, slug)}`;
|
|
2046
2053
|
}
|
|
2047
2054
|
/**
|
|
2048
|
-
*
|
|
2049
|
-
*
|
|
2050
|
-
*
|
|
2051
|
-
*
|
|
2052
|
-
*
|
|
2055
|
+
* Lock key for a write()/delete() pre-pass classification. Existing
|
|
2056
|
+
* entries lock on their content ID (idLockKey()). Legacy entries with no
|
|
2057
|
+
* embedded ID (pre-ID-era filenames -- renameEntry() already refuses to
|
|
2058
|
+
* touch these, via its four-part filename check, so there is no rename
|
|
2059
|
+
* race to guard against for them) fall back to the physical path, matching
|
|
2060
|
+
* this store's original locking behavior for that narrow case. Not-yet-
|
|
2061
|
+
* existing entries lock on a per-slug create-key (createLockKey()).
|
|
2053
2062
|
*/
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2063
|
+
entryLockKey(schemaItem, slug, prePass) {
|
|
2064
|
+
if (!prePass.existed)
|
|
2065
|
+
return this.createLockKey(schemaItem, slug);
|
|
2066
|
+
return prePass.id ? this.idLockKey(prePass.id) : prePass.absolutePath;
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Build absolute and relative paths with security validation.
|
|
2070
|
+
* All entries use the unified filename pattern: {type}.{slug}.{id}.{ext}
|
|
2071
|
+
*
|
|
2072
|
+
* SECURITY BOUNDARY: This method prevents path traversal attacks by:
|
|
2073
|
+
* 1. Validating that resolved paths stay within the content root
|
|
2074
|
+
* 2. Checking slugs for malicious patterns (via validateSlug)
|
|
2075
|
+
* 3. Using path.resolve to normalize paths before validation
|
|
2076
|
+
*
|
|
2077
|
+
* This validation is performed BEFORE file I/O in resolveDocumentPath(),
|
|
2078
|
+
* ensuring permission checks happen before any file system access.
|
|
2079
|
+
*
|
|
2080
|
+
* @param options.existingId - Optional ID to use (for edits). If not provided, generates new ID.
|
|
2081
|
+
* @param options.entryTypeName - For collections with multiple entry types, specify which one to use. Defaults to the default entry type.
|
|
2082
|
+
*/
|
|
2083
|
+
async buildPaths(schemaItem, slug, options = {}) {
|
|
2084
|
+
const rootWithSep = this.root.endsWith(path8.sep) ? this.root : `${this.root}${path8.sep}`;
|
|
2085
|
+
if (schemaItem.type === "entry-type") {
|
|
2086
|
+
const parentPath = schemaItem.parentPath || "";
|
|
2087
|
+
const parentCollection = this.schemaIndex.get(parentPath);
|
|
2088
|
+
if (!parentCollection || parentCollection.type !== "collection") {
|
|
2089
|
+
throw new ContentStoreError(`Parent collection not found for entry type: ${schemaItem.name}`, "NO_SCHEMA_ITEM");
|
|
2090
|
+
}
|
|
2091
|
+
const effectiveSlug = slug || schemaItem.name;
|
|
2092
|
+
return this.buildPaths(parentCollection, effectiveSlug, {
|
|
2093
|
+
...options,
|
|
2094
|
+
entryTypeName: schemaItem.name
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
if (schemaItem.type === "collection") {
|
|
2098
|
+
const safeSlug = slug.replace(/^\/+/, "").toLowerCase();
|
|
2099
|
+
if (!safeSlug) {
|
|
2100
|
+
throw new ContentStoreError("Slug is required for collection entries", "VALIDATION");
|
|
2101
|
+
}
|
|
2102
|
+
validateSlug(safeSlug);
|
|
2103
|
+
let entryTypeConfig;
|
|
2104
|
+
if (options.entryTypeName) {
|
|
2105
|
+
entryTypeConfig = schemaItem.entries?.find((e) => e.name === options.entryTypeName);
|
|
2106
|
+
if (!entryTypeConfig) {
|
|
2107
|
+
throw new ContentStoreError(`Entry type '${options.entryTypeName}' not found in collection`, "NO_SCHEMA_ITEM");
|
|
2066
2108
|
}
|
|
2109
|
+
} else {
|
|
2110
|
+
entryTypeConfig = getDefaultEntryType(schemaItem.entries);
|
|
2067
2111
|
}
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
const entries = [];
|
|
2075
|
-
for (const location of treeEntries) {
|
|
2076
|
-
if (location.type === "entry" && location.slug && location.collection) {
|
|
2077
|
-
entries.push({
|
|
2078
|
-
relativePath: location.relativePath,
|
|
2079
|
-
collection: location.collection,
|
|
2080
|
-
slug: location.slug
|
|
2081
|
-
});
|
|
2112
|
+
const format = entryTypeConfig?.format || "json";
|
|
2113
|
+
const ext = getFormatExtension(format);
|
|
2114
|
+
const entryTypeName = entryTypeConfig?.name || "entry";
|
|
2115
|
+
let collectionRoot = await resolveCollectionPath(this.root, schemaItem.logicalPath);
|
|
2116
|
+
if (!collectionRoot) {
|
|
2117
|
+
collectionRoot = path8.resolve(this.root, schemaItem.logicalPath);
|
|
2082
2118
|
}
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
}
|
|
2086
|
-
/**
|
|
2087
|
-
* Recursively resolve reference fields in data.
|
|
2088
|
-
* This traverses objects, arrays, and blocks to find and resolve all reference fields.
|
|
2089
|
-
*/
|
|
2090
|
-
async resolveReferencesInData(data, fields) {
|
|
2091
|
-
const resolved = { ...data };
|
|
2092
|
-
const idIndex = await this.idIndex();
|
|
2093
|
-
for (const field of fields) {
|
|
2094
|
-
if (field.type === "group") {
|
|
2095
|
-
const groupResolved = await this.resolveReferencesInData(resolved, field.fields);
|
|
2096
|
-
Object.assign(resolved, groupResolved);
|
|
2097
|
-
continue;
|
|
2119
|
+
if (!collectionRoot.startsWith(rootWithSep)) {
|
|
2120
|
+
throw new ContentStoreError("Path traversal detected", "VALIDATION");
|
|
2098
2121
|
}
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2122
|
+
let id = options.existingId;
|
|
2123
|
+
let existingFilename;
|
|
2124
|
+
let existingEntryType;
|
|
2125
|
+
let foundExisting = false;
|
|
2126
|
+
if (!id) {
|
|
2127
|
+
const entries = await fs8.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
2128
|
+
const existingFile = entries.find((entry) => {
|
|
2129
|
+
if (entry.isDirectory())
|
|
2130
|
+
return false;
|
|
2131
|
+
const fileEntryType = extractEntryTypeFromFilename(entry.name);
|
|
2132
|
+
const existingSlug = extractSlugFromFilename(entry.name, fileEntryType || void 0);
|
|
2133
|
+
return existingSlug === safeSlug;
|
|
2134
|
+
});
|
|
2135
|
+
if (existingFile) {
|
|
2136
|
+
id = extractIdFromFilename(existingFile.name) || void 0;
|
|
2137
|
+
existingFilename = existingFile.name;
|
|
2138
|
+
existingEntryType = extractEntryTypeFromFilename(existingFile.name) || void 0;
|
|
2139
|
+
foundExisting = true;
|
|
2105
2140
|
}
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2141
|
+
}
|
|
2142
|
+
const existed = foundExisting || Boolean(options.existingId);
|
|
2143
|
+
const finalEntryTypeName = existingEntryType || entryTypeName;
|
|
2144
|
+
let filename;
|
|
2145
|
+
if (existingFilename) {
|
|
2146
|
+
filename = existingFilename;
|
|
2147
|
+
} else {
|
|
2148
|
+
if (!id) {
|
|
2149
|
+
id = generateId();
|
|
2114
2150
|
}
|
|
2115
|
-
|
|
2116
|
-
const blockField = field;
|
|
2117
|
-
resolved[field.name] = await Promise.all(value.map(async (block) => {
|
|
2118
|
-
const b = block;
|
|
2119
|
-
if (!b || typeof b.value !== "object")
|
|
2120
|
-
return block;
|
|
2121
|
-
const template = blockField.templates.find((t) => t.name === b.template);
|
|
2122
|
-
if (!template)
|
|
2123
|
-
return block;
|
|
2124
|
-
return {
|
|
2125
|
-
...b,
|
|
2126
|
-
value: await this.resolveReferencesInData(b.value, template.fields)
|
|
2127
|
-
};
|
|
2128
|
-
}));
|
|
2151
|
+
filename = `${finalEntryTypeName}.${safeSlug}.${id}${ext}`;
|
|
2129
2152
|
}
|
|
2153
|
+
const resolved = path8.resolve(collectionRoot, filename);
|
|
2154
|
+
const collectionRootWithSep = collectionRoot.endsWith(path8.sep) ? collectionRoot : `${collectionRoot}${path8.sep}`;
|
|
2155
|
+
if (!resolved.startsWith(collectionRootWithSep)) {
|
|
2156
|
+
throw new ContentStoreError("Path traversal detected", "VALIDATION");
|
|
2157
|
+
}
|
|
2158
|
+
return {
|
|
2159
|
+
absolutePath: resolved,
|
|
2160
|
+
relativePath: path8.relative(this.root, resolved),
|
|
2161
|
+
id,
|
|
2162
|
+
entryTypeName: finalEntryTypeName,
|
|
2163
|
+
existed
|
|
2164
|
+
};
|
|
2130
2165
|
}
|
|
2131
|
-
|
|
2166
|
+
throw new ContentStoreError("Invalid schema item type", "VALIDATION");
|
|
2132
2167
|
}
|
|
2133
2168
|
/**
|
|
2134
|
-
*
|
|
2135
|
-
*
|
|
2136
|
-
* Includes id, slug, and collection fields for debugging.
|
|
2137
|
-
*
|
|
2138
|
-
* Suspicious results (ID missing from the index, or an index hit whose file
|
|
2139
|
-
* is gone) trigger one forced index refresh and a retry — self-healing for
|
|
2140
|
-
* mutations by other processes inside the marker's residual windows. The
|
|
2141
|
-
* forced refresh itself is throttled (see refreshIndexForSuspiciousLookup),
|
|
2142
|
-
* but the retry against the live index always runs regardless of whether
|
|
2143
|
-
* this call won the throttle race: when a `list: true` reference array is
|
|
2144
|
-
* resolved via Promise.all, every miss shares the same stale snapshot, and
|
|
2145
|
-
* idIndex() dedupes concurrent builds — so a sibling call that wins the
|
|
2146
|
-
* throttle and rebuilds heals every other miss in the same batch, not just
|
|
2147
|
-
* the first.
|
|
2169
|
+
* Path resolution: resolves a URL path to a schema item
|
|
2170
|
+
* - Try as collection + slug (last segment = slug)
|
|
2148
2171
|
*/
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
const
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
const location = idIndex.findById(id);
|
|
2160
|
-
if (!location)
|
|
2161
|
-
return STALE_LOOKUP;
|
|
2162
|
-
if (location.type !== "entry" || !location.collection || !location.slug) {
|
|
2163
|
-
return null;
|
|
2164
|
-
}
|
|
2165
|
-
const doc = await this.read(location.collection, location.slug, {
|
|
2166
|
-
resolveReferences: false
|
|
2167
|
-
});
|
|
2172
|
+
resolvePath(pathSegments) {
|
|
2173
|
+
if (pathSegments.length === 0) {
|
|
2174
|
+
throw new ContentStoreError("Empty path", "VALIDATION");
|
|
2175
|
+
}
|
|
2176
|
+
const logicalPath = pathSegments.join("/");
|
|
2177
|
+
const slug = pathSegments[pathSegments.length - 1].toLowerCase();
|
|
2178
|
+
const collectionPath = pathSegments.slice(0, -1).join("/");
|
|
2179
|
+
const normalizedCollection = normalizeFilesystemPath(collectionPath);
|
|
2180
|
+
const collection = this.schemaIndex.get(normalizedCollection);
|
|
2181
|
+
if (collection?.type === "collection" && collection.entries) {
|
|
2168
2182
|
return {
|
|
2169
|
-
|
|
2170
|
-
slug
|
|
2171
|
-
collection: location.collection,
|
|
2172
|
-
...doc.data
|
|
2183
|
+
schemaItem: collection,
|
|
2184
|
+
slug
|
|
2173
2185
|
};
|
|
2174
|
-
} catch (error) {
|
|
2175
|
-
if (isNodeError(error) && error.code === "ENOENT")
|
|
2176
|
-
return STALE_LOOKUP;
|
|
2177
|
-
console.error(`Failed to resolve reference ${id}:`, error);
|
|
2178
|
-
return null;
|
|
2179
2186
|
}
|
|
2187
|
+
throw new ContentStoreError(`No schema item found for path: ${logicalPath}`, "NO_SCHEMA_ITEM");
|
|
2180
2188
|
}
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
name: z6.string().min(1),
|
|
2204
|
-
label: z6.string().optional(),
|
|
2205
|
-
description: z6.string().optional(),
|
|
2206
|
-
entries: z6.array(entryTypeMetaSchema).optional(),
|
|
2207
|
-
order: z6.array(z6.string()).default([])
|
|
2208
|
-
// Embedded IDs for ordering; optional in the file, [] = alphabetical
|
|
2209
|
-
}).refine((data) => data.entries && data.entries.length > 0, {
|
|
2210
|
-
message: "Collection must have at least one entry type"
|
|
2211
|
-
});
|
|
2212
|
-
var rootCollectionMetaSchema = z6.object({
|
|
2213
|
-
label: z6.string().optional(),
|
|
2214
|
-
entries: z6.array(entryTypeMetaSchema).optional(),
|
|
2215
|
-
order: z6.array(z6.string()).optional()
|
|
2216
|
-
// Embedded IDs for ordering items
|
|
2217
|
-
});
|
|
2218
|
-
function stripEmbeddedIdFromName(name) {
|
|
2219
|
-
return extractSlugFromFilename(name);
|
|
2220
|
-
}
|
|
2221
|
-
async function scanForCollectionMeta(baseDir, relativePath = "") {
|
|
2222
|
-
const collections = [];
|
|
2223
|
-
try {
|
|
2224
|
-
const entries = await fs7.readdir(baseDir, { withFileTypes: true });
|
|
2225
|
-
for (const entry of entries) {
|
|
2226
|
-
if (!entry.isDirectory())
|
|
2227
|
-
continue;
|
|
2228
|
-
const folderName = entry.name;
|
|
2229
|
-
const logicalName = stripEmbeddedIdFromName(folderName);
|
|
2230
|
-
const collectionContentId = extractIdFromFilename(folderName) ?? void 0;
|
|
2231
|
-
const folderPath = relativePath ? `${relativePath}/${logicalName}` : logicalName;
|
|
2232
|
-
const absolutePath = join2(baseDir, folderName);
|
|
2233
|
-
const metaPath = join2(absolutePath, ".collection.json");
|
|
2234
|
-
try {
|
|
2235
|
-
await fs7.access(metaPath);
|
|
2236
|
-
const content = await fs7.readFile(metaPath, "utf-8");
|
|
2237
|
-
const parsed = JSON.parse(content);
|
|
2238
|
-
const meta = collectionMetaSchema.parse(parsed);
|
|
2239
|
-
collections.push({
|
|
2240
|
-
...meta,
|
|
2241
|
-
path: folderPath,
|
|
2242
|
-
// Path derived from folder name
|
|
2243
|
-
contentId: collectionContentId
|
|
2244
|
-
});
|
|
2245
|
-
const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
|
|
2246
|
-
collections.push(...nestedCollections);
|
|
2247
|
-
} catch (err) {
|
|
2248
|
-
if (err.code !== "ENOENT") {
|
|
2249
|
-
console.error(`Error loading ${metaPath}:`, err);
|
|
2250
|
-
throw new Error(`Invalid .collection.json in ${folderPath}: ${err.message}`);
|
|
2251
|
-
}
|
|
2252
|
-
const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
|
|
2253
|
-
collections.push(...nestedCollections);
|
|
2189
|
+
async resolveDocumentPath(schemaPath, slug = "") {
|
|
2190
|
+
const schemaItem = this.assertSchemaItem(schemaPath);
|
|
2191
|
+
return await this.buildPaths(schemaItem, slug);
|
|
2192
|
+
}
|
|
2193
|
+
async read(collectionPath, slug = "", options = {}) {
|
|
2194
|
+
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
2195
|
+
const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName } = await this.buildPaths(schemaItem, slug);
|
|
2196
|
+
const stat = await fs8.stat(absolutePath);
|
|
2197
|
+
const raw = await fs8.readFile(absolutePath, "utf8");
|
|
2198
|
+
let doc;
|
|
2199
|
+
let format;
|
|
2200
|
+
let fields;
|
|
2201
|
+
if (schemaItem.type === "entry-type") {
|
|
2202
|
+
format = schemaItem.format;
|
|
2203
|
+
fields = schemaItem.schema;
|
|
2204
|
+
} else {
|
|
2205
|
+
let entryTypeConfig;
|
|
2206
|
+
if (resolvedEntryTypeName && schemaItem.entries) {
|
|
2207
|
+
entryTypeConfig = schemaItem.entries.find((e) => e.name === resolvedEntryTypeName);
|
|
2208
|
+
}
|
|
2209
|
+
if (!entryTypeConfig) {
|
|
2210
|
+
entryTypeConfig = getDefaultEntryType(schemaItem.entries);
|
|
2254
2211
|
}
|
|
2212
|
+
format = entryTypeConfig?.format || "json";
|
|
2213
|
+
fields = entryTypeConfig?.schema || [];
|
|
2214
|
+
}
|
|
2215
|
+
if (format === "json") {
|
|
2216
|
+
const data = asRecord(JSON.parse(raw));
|
|
2217
|
+
doc = {
|
|
2218
|
+
collection: schemaItem.logicalPath,
|
|
2219
|
+
collectionName: schemaItem.name,
|
|
2220
|
+
format: "json",
|
|
2221
|
+
data,
|
|
2222
|
+
relativePath,
|
|
2223
|
+
absolutePath
|
|
2224
|
+
};
|
|
2225
|
+
} else if (format === "yaml") {
|
|
2226
|
+
const data = asRecord(yamlParse(raw));
|
|
2227
|
+
doc = {
|
|
2228
|
+
collection: schemaItem.logicalPath,
|
|
2229
|
+
collectionName: schemaItem.name,
|
|
2230
|
+
format: "yaml",
|
|
2231
|
+
data,
|
|
2232
|
+
relativePath,
|
|
2233
|
+
absolutePath
|
|
2234
|
+
};
|
|
2235
|
+
} else {
|
|
2236
|
+
const parsed = matter(raw);
|
|
2237
|
+
doc = {
|
|
2238
|
+
collection: schemaItem.logicalPath,
|
|
2239
|
+
collectionName: schemaItem.name,
|
|
2240
|
+
format,
|
|
2241
|
+
data: parsed.data ?? {},
|
|
2242
|
+
body: parsed.content,
|
|
2243
|
+
bodyFieldName: findBodyFieldName(fields),
|
|
2244
|
+
relativePath,
|
|
2245
|
+
absolutePath
|
|
2246
|
+
};
|
|
2255
2247
|
}
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
if (err.code === "ENOENT") {
|
|
2259
|
-
return [];
|
|
2248
|
+
if (options.resolveReferences !== false) {
|
|
2249
|
+
doc.data = await this.resolveReferencesInData(doc.data, fields);
|
|
2260
2250
|
}
|
|
2261
|
-
|
|
2251
|
+
doc.version = stat.mtimeMs;
|
|
2252
|
+
return doc;
|
|
2262
2253
|
}
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2254
|
+
async write(collectionPath, slug = "", input, entryTypeName, existingId) {
|
|
2255
|
+
await this.idIndex();
|
|
2256
|
+
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
2257
|
+
let expectedFormat;
|
|
2258
|
+
let fields = [];
|
|
2259
|
+
if (schemaItem.type === "entry-type") {
|
|
2260
|
+
expectedFormat = schemaItem.format;
|
|
2261
|
+
fields = schemaItem.schema;
|
|
2271
2262
|
} else {
|
|
2272
|
-
|
|
2263
|
+
let entryTypeConfig;
|
|
2264
|
+
if (entryTypeName) {
|
|
2265
|
+
entryTypeConfig = schemaItem.entries?.find((e) => e.name === entryTypeName);
|
|
2266
|
+
if (!entryTypeConfig) {
|
|
2267
|
+
throw new ContentStoreError(`Entry type '${entryTypeName}' not found in collection`, "NO_SCHEMA_ITEM");
|
|
2268
|
+
}
|
|
2269
|
+
} else {
|
|
2270
|
+
entryTypeConfig = getDefaultEntryType(schemaItem.entries);
|
|
2271
|
+
}
|
|
2272
|
+
expectedFormat = entryTypeConfig?.format || "json";
|
|
2273
|
+
fields = entryTypeConfig?.schema || [];
|
|
2273
2274
|
}
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
const content = await fs7.readFile(rootMetaPath, "utf-8");
|
|
2277
|
-
const parsed = JSON.parse(content);
|
|
2278
|
-
root = rootCollectionMetaSchema.parse(parsed);
|
|
2279
|
-
} catch (err) {
|
|
2280
|
-
const errno = err.code;
|
|
2281
|
-
if (errno !== "ENOENT") {
|
|
2282
|
-
throw new Error(`Invalid root .collection.json`);
|
|
2275
|
+
if (expectedFormat !== input.format) {
|
|
2276
|
+
throw new ContentStoreError(`Format mismatch: expects ${expectedFormat}, got ${input.format}`, "VALIDATION");
|
|
2283
2277
|
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2278
|
+
const prePass = await this.buildPaths(schemaItem, slug, { entryTypeName, existingId });
|
|
2279
|
+
let lockKey = this.entryLockKey(schemaItem, slug, prePass);
|
|
2280
|
+
const RETRY_KEY = Symbol("retry-with-new-lock-key");
|
|
2281
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
2282
|
+
const outcome = await withLock(lockKey, async () => {
|
|
2283
|
+
const inLock = await this.buildPaths(schemaItem, slug, {
|
|
2284
|
+
entryTypeName,
|
|
2285
|
+
existingId
|
|
2286
|
+
});
|
|
2287
|
+
const currentKey = this.entryLockKey(schemaItem, slug, inLock);
|
|
2288
|
+
if (currentKey !== lockKey) {
|
|
2289
|
+
lockKey = currentKey;
|
|
2290
|
+
return RETRY_KEY;
|
|
2291
|
+
}
|
|
2292
|
+
const { absolutePath, relativePath, id } = inLock;
|
|
2293
|
+
await fs8.mkdir(path8.dirname(absolutePath), { recursive: true });
|
|
2294
|
+
if (input.expectedVersion !== void 0) {
|
|
2295
|
+
try {
|
|
2296
|
+
const existing = await fs8.stat(absolutePath);
|
|
2297
|
+
if (existing.mtimeMs !== input.expectedVersion) {
|
|
2298
|
+
throw new ContentConflictError();
|
|
2299
|
+
}
|
|
2300
|
+
} catch (err) {
|
|
2301
|
+
if (err instanceof ContentConflictError)
|
|
2302
|
+
throw err;
|
|
2303
|
+
if (isNodeError(err) && err.code === "ENOENT") {
|
|
2304
|
+
} else {
|
|
2305
|
+
throw err;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
if (existingId && !await filePathExists(absolutePath)) {
|
|
2310
|
+
const actualRelPath = await this.findEntryPathById(path8.dirname(absolutePath), existingId);
|
|
2311
|
+
const indexedRelPath = this._idIndex.findById(existingId)?.relativePath ?? null;
|
|
2312
|
+
if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
|
|
2313
|
+
throw new ContentConflictError();
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
let content;
|
|
2317
|
+
if (input.format === "json") {
|
|
2318
|
+
content = `${JSON.stringify(input.data ?? {}, null, 2)}
|
|
2319
|
+
`;
|
|
2320
|
+
} else if (input.format === "yaml") {
|
|
2321
|
+
content = yamlStringify(input.data ?? {});
|
|
2322
|
+
} else {
|
|
2323
|
+
content = matter.stringify(input.body, input.data ?? {});
|
|
2324
|
+
}
|
|
2325
|
+
await atomicWriteFile(absolutePath, content);
|
|
2326
|
+
const liveIndex = this._idIndex;
|
|
2327
|
+
let staleOldAbsPath = null;
|
|
2328
|
+
if (id) {
|
|
2329
|
+
const existing = liveIndex.findById(id);
|
|
2330
|
+
if (existing) {
|
|
2331
|
+
if (existing.relativePath !== relativePath) {
|
|
2332
|
+
staleOldAbsPath = path8.join(this.root, existing.relativePath);
|
|
2333
|
+
liveIndex.updatePath(existing.id, relativePath);
|
|
2334
|
+
}
|
|
2335
|
+
} else {
|
|
2336
|
+
liveIndex.add({
|
|
2337
|
+
type: "entry",
|
|
2338
|
+
relativePath,
|
|
2339
|
+
collection: collectionPath,
|
|
2340
|
+
slug: slug || void 0
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
if (staleOldAbsPath) {
|
|
2345
|
+
await fs8.unlink(staleOldAbsPath).catch((err) => {
|
|
2346
|
+
if (!isNodeError(err) || err.code !== "ENOENT")
|
|
2347
|
+
throw err;
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
await this.recordOwnMutation(liveIndex);
|
|
2351
|
+
const afterStat = await fs8.stat(absolutePath);
|
|
2352
|
+
const base = {
|
|
2353
|
+
collection: schemaItem.logicalPath,
|
|
2354
|
+
collectionName: schemaItem.name,
|
|
2355
|
+
relativePath,
|
|
2356
|
+
absolutePath,
|
|
2357
|
+
version: afterStat.mtimeMs
|
|
2358
|
+
};
|
|
2359
|
+
if (input.format === "json") {
|
|
2360
|
+
return { ...base, format: "json", data: input.data ?? {} };
|
|
2361
|
+
}
|
|
2362
|
+
if (input.format === "yaml") {
|
|
2363
|
+
return { ...base, format: "yaml", data: input.data ?? {} };
|
|
2364
|
+
}
|
|
2365
|
+
return {
|
|
2366
|
+
...base,
|
|
2367
|
+
format: input.format,
|
|
2368
|
+
data: input.data ?? {},
|
|
2369
|
+
body: input.body,
|
|
2370
|
+
bodyFieldName: findBodyFieldName(fields)
|
|
2371
|
+
};
|
|
2372
|
+
});
|
|
2373
|
+
if (typeof outcome !== "symbol")
|
|
2374
|
+
return outcome;
|
|
2293
2375
|
}
|
|
2294
|
-
|
|
2295
|
-
name: entryType.name,
|
|
2296
|
-
label: entryType.label,
|
|
2297
|
-
format: entryType.format,
|
|
2298
|
-
schema: resolvedSchema,
|
|
2299
|
-
schemaRef: entryType.schema,
|
|
2300
|
-
default: entryType.default,
|
|
2301
|
-
maxItems: entryType.maxItems
|
|
2302
|
-
};
|
|
2303
|
-
});
|
|
2304
|
-
}
|
|
2305
|
-
function resolveCollectionMeta(meta, entrySchemaRegistry, allCollections) {
|
|
2306
|
-
const entries = meta.entries && meta.entries.length > 0 ? resolveEntryTypes(meta.entries, entrySchemaRegistry, `collection "${meta.name}"`) : void 0;
|
|
2307
|
-
const nestedCollections = allCollections.filter((col) => {
|
|
2308
|
-
return col.path.startsWith(`${meta.path}/`) && col.path.split("/").length === meta.path.split("/").length + 1;
|
|
2309
|
-
});
|
|
2310
|
-
const collections = nestedCollections.length > 0 ? nestedCollections.map((nestedMeta) => resolveCollectionMeta(nestedMeta, entrySchemaRegistry, allCollections)) : void 0;
|
|
2311
|
-
return {
|
|
2312
|
-
name: meta.name,
|
|
2313
|
-
label: meta.label,
|
|
2314
|
-
path: meta.path,
|
|
2315
|
-
contentId: meta.contentId,
|
|
2316
|
-
...entries && { entries },
|
|
2317
|
-
...meta.order && { order: meta.order },
|
|
2318
|
-
...collections && { collections }
|
|
2319
|
-
};
|
|
2320
|
-
}
|
|
2321
|
-
function resolveCollectionReferences(metaFiles, entrySchemaRegistry) {
|
|
2322
|
-
const result = {};
|
|
2323
|
-
if (metaFiles.root?.label) {
|
|
2324
|
-
result.label = metaFiles.root.label;
|
|
2325
|
-
}
|
|
2326
|
-
if (metaFiles.root?.entries && metaFiles.root.entries.length > 0) {
|
|
2327
|
-
result.entries = resolveEntryTypes(metaFiles.root.entries, entrySchemaRegistry, "root collection");
|
|
2376
|
+
throw new ContentConflictError();
|
|
2328
2377
|
}
|
|
2329
|
-
|
|
2330
|
-
|
|
2378
|
+
/**
|
|
2379
|
+
* Read an entry by its ID (UUID).
|
|
2380
|
+
* Returns null if the ID doesn't exist or points to a collection.
|
|
2381
|
+
*
|
|
2382
|
+
* Suspicious results (ID missing from the index, or an index hit whose file
|
|
2383
|
+
* is gone) trigger one forced index refresh and a retry — self-healing for
|
|
2384
|
+
* mutations by other processes inside the marker's residual windows.
|
|
2385
|
+
*/
|
|
2386
|
+
async readById(id) {
|
|
2387
|
+
const first = await this.readByIdOnce(id);
|
|
2388
|
+
if (first !== STALE_LOOKUP)
|
|
2389
|
+
return first;
|
|
2390
|
+
if (!await this.refreshIndexForSuspiciousLookup())
|
|
2391
|
+
return null;
|
|
2392
|
+
const second = await this.readByIdOnce(id);
|
|
2393
|
+
return second === STALE_LOOKUP ? null : second;
|
|
2331
2394
|
}
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2395
|
+
async readByIdOnce(id) {
|
|
2396
|
+
const idIndex = await this.idIndex();
|
|
2397
|
+
const location = idIndex.findById(id);
|
|
2398
|
+
if (!location)
|
|
2399
|
+
return STALE_LOOKUP;
|
|
2400
|
+
if (location.type !== "entry")
|
|
2401
|
+
return null;
|
|
2402
|
+
try {
|
|
2403
|
+
return await this.read(location.collection, location.slug);
|
|
2404
|
+
} catch (err) {
|
|
2405
|
+
if (isNodeError(err) && err.code === "ENOENT")
|
|
2406
|
+
return STALE_LOOKUP;
|
|
2407
|
+
throw err;
|
|
2408
|
+
}
|
|
2335
2409
|
}
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
async
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
sources.push({
|
|
2345
|
-
path: ".collection.json",
|
|
2346
|
-
type: "root",
|
|
2347
|
-
collections: []
|
|
2348
|
-
});
|
|
2410
|
+
/**
|
|
2411
|
+
* Get the ID for an entry given its collection and slug.
|
|
2412
|
+
* Returns null if no ID exists yet.
|
|
2413
|
+
*/
|
|
2414
|
+
async getIdForEntry(collectionPath, slug) {
|
|
2415
|
+
const idIndex = await this.idIndex();
|
|
2416
|
+
const { relativePath } = await this.buildPaths(this.assertCollection(collectionPath), slug);
|
|
2417
|
+
return idIndex.findByPath(relativePath);
|
|
2349
2418
|
}
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2419
|
+
/**
|
|
2420
|
+
* Check whether a document already exists on disk for this collection+slug.
|
|
2421
|
+
* Used by the write boundary to distinguish creates from edits (create
|
|
2422
|
+
* scaffolds and maxItems enforcement).
|
|
2423
|
+
*/
|
|
2424
|
+
async documentExists(collectionPath, slug = "") {
|
|
2425
|
+
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
2426
|
+
const { absolutePath } = await this.buildPaths(schemaItem, slug);
|
|
2427
|
+
try {
|
|
2428
|
+
await fs8.access(absolutePath);
|
|
2429
|
+
return true;
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
if (isNodeError(err) && err.code === "ENOENT")
|
|
2432
|
+
return false;
|
|
2433
|
+
throw err;
|
|
2434
|
+
}
|
|
2356
2435
|
}
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
return true;
|
|
2378
|
-
return false;
|
|
2379
|
-
};
|
|
2380
|
-
var STATIC_DEPLOY_USER = Object.freeze({
|
|
2381
|
-
type: "authenticated",
|
|
2382
|
-
userId: "__static_deploy__",
|
|
2383
|
-
groups: ["Admins"],
|
|
2384
|
-
email: "static-deploy@canopycms",
|
|
2385
|
-
name: "Static Deploy"
|
|
2386
|
-
});
|
|
2387
|
-
|
|
2388
|
-
// dist/branch-schema-cache.js
|
|
2389
|
-
var SCHEMA_CACHE_VERSION = 2;
|
|
2390
|
-
var MTIME_CHECK_DEBOUNCE_MS = 1e3;
|
|
2391
|
-
async function isStaleByMtime(dir, cachedAt) {
|
|
2392
|
-
let entries;
|
|
2393
|
-
try {
|
|
2394
|
-
entries = await fs8.readdir(dir, { recursive: true, encoding: "utf-8" });
|
|
2395
|
-
} catch {
|
|
2396
|
-
return true;
|
|
2436
|
+
/**
|
|
2437
|
+
* Resolve the actual on-disk entry type of an existing collection entry, by
|
|
2438
|
+
* inspecting its filename directly. Entry filenames embed their type
|
|
2439
|
+
* (`{type}.{slug}.{id}.{ext}`) and it is immutable after creation — see the
|
|
2440
|
+
* existing-file lookup in buildPaths(), which this mirrors. Returns
|
|
2441
|
+
* undefined if no entry exists yet at this slug.
|
|
2442
|
+
*
|
|
2443
|
+
* Used at the API write boundary (api/content.ts) to validate an existing
|
|
2444
|
+
* entry's payload against ITS real schema rather than a caller-supplied (or
|
|
2445
|
+
* omitted/spoofed) `entryType` param — ContentStore.write() preserves the
|
|
2446
|
+
* on-disk type regardless of what's requested, so validation must agree
|
|
2447
|
+
* with what will actually be written (post-review M2).
|
|
2448
|
+
*
|
|
2449
|
+
* Cheap: one buildPaths() resolution plus a stat, no file content is read —
|
|
2450
|
+
* same cost class as documentExists().
|
|
2451
|
+
*/
|
|
2452
|
+
async getExistingEntryType(collectionPath, slug = "") {
|
|
2453
|
+
const schemaItem = this.assertCollection(collectionPath);
|
|
2454
|
+
const { absolutePath, entryTypeName } = await this.buildPaths(schemaItem, slug);
|
|
2455
|
+
return await filePathExists(absolutePath) ? entryTypeName : void 0;
|
|
2397
2456
|
}
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
return
|
|
2408
|
-
}
|
|
2457
|
+
/**
|
|
2458
|
+
* Count existing entries of a given entry type in a collection, by filename
|
|
2459
|
+
* (entry filenames embed their type: `{type}.{slug}.{id}.{ext}`). Used to
|
|
2460
|
+
* enforce `EntryTypeConfig.maxItems` server-side at the create boundary.
|
|
2461
|
+
*/
|
|
2462
|
+
async countEntriesOfType(collectionPath, entryTypeName) {
|
|
2463
|
+
this.assertCollection(collectionPath);
|
|
2464
|
+
const collectionRoot = await resolveCollectionPath(this.root, collectionPath);
|
|
2465
|
+
if (!collectionRoot)
|
|
2466
|
+
return 0;
|
|
2467
|
+
const entries = await fs8.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
2468
|
+
return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
|
|
2409
2469
|
}
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2470
|
+
/**
|
|
2471
|
+
* Test-only seam: awaited right after the pre-pass buildPaths() resolves
|
|
2472
|
+
* in delete() and renameEntry(), before the lock key is used to acquire
|
|
2473
|
+
* anything. No-op in production. A test subclass can override this to
|
|
2474
|
+
* inject a controlled pause in that exact window, letting a test
|
|
2475
|
+
* deterministically run OTHER mutations (which don't hold this call's
|
|
2476
|
+
* not-yet-acquired lock) to completion before this call proceeds to
|
|
2477
|
+
* acquire its (possibly now-stale) lock key -- see the "Deterministic
|
|
2478
|
+
* interleavings" testing pattern in docs/concurrency.md and
|
|
2479
|
+
* branch-registry.test.ts's `BlockingRegistry` for the same idiom.
|
|
2480
|
+
*/
|
|
2481
|
+
async afterPrePassForTesting() {
|
|
2416
2482
|
}
|
|
2417
2483
|
/**
|
|
2418
|
-
*
|
|
2419
|
-
*
|
|
2420
|
-
* branchRoot equals the project root (process.cwd()) only in the synthetic
|
|
2421
|
-
* contexts used by static deployments and build phases — real branch roots are
|
|
2422
|
-
* always nested under the workspace (.canopy-dev/content-branches/<name> in dev,
|
|
2423
|
-
* <workspaceRoot>/content-branches/<name> in prod). Never write .canopy-meta/ at
|
|
2424
|
-
* the project root, regardless of which entrypoint produced the cwd branchRoot.
|
|
2484
|
+
* Delete an entry and remove it from the index.
|
|
2425
2485
|
*/
|
|
2426
|
-
|
|
2427
|
-
|
|
2486
|
+
async delete(collectionPath, slug) {
|
|
2487
|
+
await this.idIndex();
|
|
2488
|
+
const collection = this.assertCollection(collectionPath);
|
|
2489
|
+
const prePass = await this.buildPaths(collection, slug);
|
|
2490
|
+
if (!prePass.existed) {
|
|
2491
|
+
await fs8.unlink(prePass.absolutePath);
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
let lockKey = this.entryLockKey(collection, slug, prePass);
|
|
2495
|
+
await this.afterPrePassForTesting();
|
|
2496
|
+
const RETRY_KEY = Symbol("retry-with-new-lock-key");
|
|
2497
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
2498
|
+
const outcome = await withLock(lockKey, async () => {
|
|
2499
|
+
const inLock = await this.buildPaths(collection, slug);
|
|
2500
|
+
const currentKey = this.entryLockKey(collection, slug, inLock);
|
|
2501
|
+
if (currentKey !== lockKey) {
|
|
2502
|
+
lockKey = currentKey;
|
|
2503
|
+
return RETRY_KEY;
|
|
2504
|
+
}
|
|
2505
|
+
const { absolutePath, relativePath } = inLock;
|
|
2506
|
+
await fs8.unlink(absolutePath);
|
|
2507
|
+
const liveIndex = this._idIndex;
|
|
2508
|
+
const id = liveIndex.findByPath(relativePath);
|
|
2509
|
+
if (id) {
|
|
2510
|
+
liveIndex.remove(id);
|
|
2511
|
+
}
|
|
2512
|
+
await this.recordOwnMutation(liveIndex);
|
|
2513
|
+
return void 0;
|
|
2514
|
+
});
|
|
2515
|
+
if (outcome !== RETRY_KEY)
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
throw new ContentConflictError();
|
|
2428
2519
|
}
|
|
2429
2520
|
/**
|
|
2430
|
-
*
|
|
2521
|
+
* Rename an entry by changing its slug (middle segment of filename).
|
|
2522
|
+
* Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
|
|
2431
2523
|
*
|
|
2432
|
-
* @param
|
|
2433
|
-
* @param
|
|
2434
|
-
* @param
|
|
2435
|
-
* @returns
|
|
2524
|
+
* @param collectionPath - Logical path to the collection
|
|
2525
|
+
* @param currentSlug - Current slug of the entry
|
|
2526
|
+
* @param newSlug - New slug (must be unique within collection)
|
|
2527
|
+
* @returns Object with new logical path
|
|
2528
|
+
* @throws ContentStoreError if entry doesn't exist, new slug conflicts, or validation fails
|
|
2436
2529
|
*/
|
|
2437
|
-
async
|
|
2438
|
-
|
|
2530
|
+
async renameEntry(collectionPath, currentSlug, newSlug) {
|
|
2531
|
+
await this.idIndex();
|
|
2532
|
+
const collection = this.assertCollection(collectionPath);
|
|
2533
|
+
validateSlug(newSlug);
|
|
2534
|
+
const safeNewSlug = newSlug.replace(/^\/+/, "");
|
|
2535
|
+
if (!safeNewSlug) {
|
|
2536
|
+
throw new ContentStoreError("New slug cannot be empty", "VALIDATION");
|
|
2537
|
+
}
|
|
2538
|
+
if (currentSlug === safeNewSlug) {
|
|
2539
|
+
return { newPath: `${collectionPath}/${currentSlug}` };
|
|
2540
|
+
}
|
|
2541
|
+
const prePass = await this.buildPaths(collection, currentSlug);
|
|
2542
|
+
if (!prePass.existed) {
|
|
2543
|
+
throw new ContentStoreError(`Entry not found: ${currentSlug}`, "NOT_FOUND");
|
|
2544
|
+
}
|
|
2545
|
+
let sourceLockKey = this.entryLockKey(collection, currentSlug, prePass);
|
|
2546
|
+
let sourceId = prePass.id;
|
|
2547
|
+
let destLockKey = this.createLockKey(collection, safeNewSlug);
|
|
2548
|
+
await this.afterPrePassForTesting();
|
|
2549
|
+
const RETRY_KEY = Symbol("retry-with-new-lock-key");
|
|
2550
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
2551
|
+
const outcome = await withLocks([sourceLockKey, destLockKey], async () => {
|
|
2552
|
+
const inLock = await this.buildPaths(collection, currentSlug);
|
|
2553
|
+
if (!inLock.existed) {
|
|
2554
|
+
throw new ContentStoreError(`Entry not found: ${currentSlug}`, "NOT_FOUND");
|
|
2555
|
+
}
|
|
2556
|
+
const currentSourceKey = this.entryLockKey(collection, currentSlug, inLock);
|
|
2557
|
+
const currentDestKey = this.createLockKey(collection, safeNewSlug);
|
|
2558
|
+
if (currentSourceKey !== sourceLockKey || inLock.id !== sourceId) {
|
|
2559
|
+
sourceLockKey = currentSourceKey;
|
|
2560
|
+
destLockKey = currentDestKey;
|
|
2561
|
+
sourceId = inLock.id;
|
|
2562
|
+
return RETRY_KEY;
|
|
2563
|
+
}
|
|
2564
|
+
destLockKey = currentDestKey;
|
|
2565
|
+
const { absolutePath: currentPath, relativePath: currentRelPath } = inLock;
|
|
2566
|
+
const currentFilename = path8.basename(currentPath);
|
|
2567
|
+
const parts = currentFilename.split(".");
|
|
2568
|
+
if (parts.length < 4) {
|
|
2569
|
+
throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, "VALIDATION");
|
|
2570
|
+
}
|
|
2571
|
+
const entryTypeName = parts[0];
|
|
2572
|
+
const contentId = parts[parts.length - 2];
|
|
2573
|
+
const ext = `.${parts[parts.length - 1]}`;
|
|
2574
|
+
const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
|
|
2575
|
+
const parentDir = path8.dirname(currentPath);
|
|
2576
|
+
const newPath = path8.join(parentDir, newFilename);
|
|
2577
|
+
try {
|
|
2578
|
+
const entries = await fs8.readdir(parentDir, { withFileTypes: true });
|
|
2579
|
+
for (const entry of entries) {
|
|
2580
|
+
if (entry.isDirectory())
|
|
2581
|
+
continue;
|
|
2582
|
+
const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
|
|
2583
|
+
if (existingSlug === safeNewSlug) {
|
|
2584
|
+
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
} catch (err) {
|
|
2588
|
+
if (err instanceof ContentStoreError)
|
|
2589
|
+
throw err;
|
|
2590
|
+
}
|
|
2591
|
+
try {
|
|
2592
|
+
await fs8.link(currentPath, newPath);
|
|
2593
|
+
} catch (err) {
|
|
2594
|
+
if (isNodeError(err) && err.code === "EEXIST") {
|
|
2595
|
+
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
|
|
2596
|
+
}
|
|
2597
|
+
throw err;
|
|
2598
|
+
}
|
|
2599
|
+
await fs8.unlink(currentPath);
|
|
2600
|
+
const newRelativePath = path8.relative(this.root, newPath);
|
|
2601
|
+
const liveIndex = this._idIndex;
|
|
2602
|
+
const entryId = liveIndex.findByPath(currentRelPath);
|
|
2603
|
+
if (entryId) {
|
|
2604
|
+
liveIndex.updatePath(entryId, newRelativePath);
|
|
2605
|
+
}
|
|
2606
|
+
await this.recordOwnMutation(liveIndex);
|
|
2607
|
+
return { newPath: `${collectionPath}/${safeNewSlug}` };
|
|
2608
|
+
});
|
|
2609
|
+
if (typeof outcome !== "symbol")
|
|
2610
|
+
return outcome;
|
|
2611
|
+
}
|
|
2612
|
+
throw new ContentConflictError();
|
|
2439
2613
|
}
|
|
2440
2614
|
/**
|
|
2441
|
-
*
|
|
2615
|
+
* List all entries in a collection tree (including subcollections).
|
|
2616
|
+
* For example, passing 'content/data-catalog' returns entries from
|
|
2617
|
+
* 'content/data-catalog', 'content/data-catalog/partner-a', etc.
|
|
2618
|
+
* Returns array of entry metadata (relativePath, collection, slug).
|
|
2619
|
+
* Returns empty array if the collection doesn't exist.
|
|
2442
2620
|
*/
|
|
2443
|
-
async
|
|
2444
|
-
const
|
|
2445
|
-
const
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
const
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
cacheData = JSON.parse(cacheContent);
|
|
2456
|
-
}
|
|
2457
|
-
} catch {
|
|
2458
|
-
cacheData = null;
|
|
2459
|
-
}
|
|
2460
|
-
if (cacheData && cacheData.version === SCHEMA_CACHE_VERSION) {
|
|
2461
|
-
const now = Date.now();
|
|
2462
|
-
const lastCheck = this.lastMtimeCheck.get(contentRoot) ?? 0;
|
|
2463
|
-
if (this.devMode && now - lastCheck >= MTIME_CHECK_DEBOUNCE_MS && await isStaleByMtime(contentRoot, new Date(cacheData.cachedAt))) {
|
|
2464
|
-
this.lastMtimeCheck.set(contentRoot, now);
|
|
2465
|
-
cacheData = null;
|
|
2466
|
-
} else {
|
|
2467
|
-
if (this.devMode)
|
|
2468
|
-
this.lastMtimeCheck.set(contentRoot, now);
|
|
2469
|
-
return { schema: cacheData.schema, flatSchema: cacheData.flatSchema };
|
|
2621
|
+
async getCollectionEntryPaths(collectionPath) {
|
|
2622
|
+
const idIndex = await this.idIndex();
|
|
2623
|
+
const normalized = normalizeFilesystemPath(collectionPath);
|
|
2624
|
+
let item = this.schemaIndex.get(normalized);
|
|
2625
|
+
if (!item) {
|
|
2626
|
+
for (const schemaItem of this.schemaIndex.values()) {
|
|
2627
|
+
if (schemaItem.type === "collection") {
|
|
2628
|
+
const lastSegment = schemaItem.logicalPath.split("/").pop();
|
|
2629
|
+
if (lastSegment === collectionPath) {
|
|
2630
|
+
item = schemaItem;
|
|
2631
|
+
break;
|
|
2632
|
+
}
|
|
2470
2633
|
}
|
|
2471
2634
|
}
|
|
2472
2635
|
}
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
throw new Error(`No schema found in ${contentRoot}. Create .collection.json files with references to field schemas defined in your entry schema registry.`);
|
|
2636
|
+
if (!item || item.type !== "collection") {
|
|
2637
|
+
return [];
|
|
2476
2638
|
}
|
|
2477
|
-
const
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
cachedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2488
|
-
};
|
|
2489
|
-
const tmpPath = path8.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
|
|
2490
|
-
await fs8.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
|
|
2491
|
-
await fs8.rename(tmpPath, cachePath);
|
|
2492
|
-
try {
|
|
2493
|
-
await fs8.unlink(stalePath);
|
|
2494
|
-
} catch {
|
|
2639
|
+
const collection = item;
|
|
2640
|
+
const treeEntries = idIndex.getEntriesInCollectionTree(collection.logicalPath);
|
|
2641
|
+
const entries = [];
|
|
2642
|
+
for (const location of treeEntries) {
|
|
2643
|
+
if (location.type === "entry" && location.slug && location.collection) {
|
|
2644
|
+
entries.push({
|
|
2645
|
+
relativePath: location.relativePath,
|
|
2646
|
+
collection: location.collection,
|
|
2647
|
+
slug: location.slug
|
|
2648
|
+
});
|
|
2495
2649
|
}
|
|
2496
2650
|
}
|
|
2497
|
-
return
|
|
2651
|
+
return entries;
|
|
2652
|
+
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Recursively resolve reference fields in data.
|
|
2655
|
+
* This traverses objects, arrays, and blocks to find and resolve all reference fields.
|
|
2656
|
+
*/
|
|
2657
|
+
async resolveReferencesInData(data, fields) {
|
|
2658
|
+
const resolved = { ...data };
|
|
2659
|
+
const idIndex = await this.idIndex();
|
|
2660
|
+
for (const field of fields) {
|
|
2661
|
+
if (field.type === "group") {
|
|
2662
|
+
const groupResolved = await this.resolveReferencesInData(resolved, field.fields);
|
|
2663
|
+
Object.assign(resolved, groupResolved);
|
|
2664
|
+
continue;
|
|
2665
|
+
}
|
|
2666
|
+
const value = data[field.name];
|
|
2667
|
+
if (field.type === "reference") {
|
|
2668
|
+
if (typeof value === "string" && value) {
|
|
2669
|
+
resolved[field.name] = await this.resolveSingleReference(value, idIndex);
|
|
2670
|
+
} else if (field.list && Array.isArray(value)) {
|
|
2671
|
+
resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex) : null));
|
|
2672
|
+
}
|
|
2673
|
+
} else if (field.type === "object" && value) {
|
|
2674
|
+
const objectField = field;
|
|
2675
|
+
if (!objectField.fields)
|
|
2676
|
+
continue;
|
|
2677
|
+
if (objectField.list && Array.isArray(value)) {
|
|
2678
|
+
resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields) : item));
|
|
2679
|
+
} else if (typeof value === "object") {
|
|
2680
|
+
resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields);
|
|
2681
|
+
}
|
|
2682
|
+
} else if (field.type === "block" && Array.isArray(value)) {
|
|
2683
|
+
const blockField = field;
|
|
2684
|
+
resolved[field.name] = await Promise.all(value.map(async (block) => {
|
|
2685
|
+
const b = block;
|
|
2686
|
+
if (!b || typeof b.value !== "object")
|
|
2687
|
+
return block;
|
|
2688
|
+
const template = blockField.templates.find((t) => t.name === b.template);
|
|
2689
|
+
if (!template)
|
|
2690
|
+
return block;
|
|
2691
|
+
return {
|
|
2692
|
+
...b,
|
|
2693
|
+
value: await this.resolveReferencesInData(b.value, template.fields)
|
|
2694
|
+
};
|
|
2695
|
+
}));
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
return resolved;
|
|
2498
2699
|
}
|
|
2499
2700
|
/**
|
|
2500
|
-
*
|
|
2701
|
+
* Resolve a single reference ID to full entry data.
|
|
2702
|
+
* Returns null if the reference is invalid or missing.
|
|
2703
|
+
* Includes id, slug, and collection fields for debugging.
|
|
2501
2704
|
*
|
|
2502
|
-
*
|
|
2705
|
+
* Suspicious results (ID missing from the index, or an index hit whose file
|
|
2706
|
+
* is gone) trigger one forced index refresh and a retry — self-healing for
|
|
2707
|
+
* mutations by other processes inside the marker's residual windows. The
|
|
2708
|
+
* forced refresh itself is throttled (see refreshIndexForSuspiciousLookup),
|
|
2709
|
+
* but the retry against the live index always runs regardless of whether
|
|
2710
|
+
* this call won the throttle race: when a `list: true` reference array is
|
|
2711
|
+
* resolved via Promise.all, every miss shares the same stale snapshot, and
|
|
2712
|
+
* idIndex() dedupes concurrent builds — so a sibling call that wins the
|
|
2713
|
+
* throttle and rebuilds heals every other miss in the same batch, not just
|
|
2714
|
+
* the first.
|
|
2503
2715
|
*/
|
|
2504
|
-
async
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
await
|
|
2510
|
-
|
|
2716
|
+
async resolveSingleReference(id, idIndex) {
|
|
2717
|
+
const first = await this.resolveSingleReferenceOnce(id, idIndex);
|
|
2718
|
+
if (first !== STALE_LOOKUP)
|
|
2719
|
+
return first;
|
|
2720
|
+
await this.refreshIndexForSuspiciousLookup();
|
|
2721
|
+
const second = await this.resolveSingleReferenceOnce(id, await this.idIndex());
|
|
2722
|
+
return second === STALE_LOOKUP ? null : second;
|
|
2723
|
+
}
|
|
2724
|
+
async resolveSingleReferenceOnce(id, idIndex) {
|
|
2725
|
+
try {
|
|
2726
|
+
const location = idIndex.findById(id);
|
|
2727
|
+
if (!location)
|
|
2728
|
+
return STALE_LOOKUP;
|
|
2729
|
+
if (location.type !== "entry" || !location.collection || !location.slug) {
|
|
2730
|
+
return null;
|
|
2731
|
+
}
|
|
2732
|
+
const doc = await this.read(location.collection, location.slug, {
|
|
2733
|
+
resolveReferences: false
|
|
2734
|
+
});
|
|
2735
|
+
return {
|
|
2736
|
+
id,
|
|
2737
|
+
slug: location.slug,
|
|
2738
|
+
collection: location.collection,
|
|
2739
|
+
...doc.data
|
|
2740
|
+
};
|
|
2741
|
+
} catch (error) {
|
|
2742
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
2743
|
+
return STALE_LOOKUP;
|
|
2744
|
+
console.error(`Failed to resolve reference ${id}:`, error);
|
|
2745
|
+
return null;
|
|
2746
|
+
}
|
|
2511
2747
|
}
|
|
2512
2748
|
};
|
|
2513
2749
|
|
|
@@ -3033,8 +3269,8 @@ function computeEntryUrl(collection, slug, contentRoot) {
|
|
|
3033
3269
|
if (slug && slug !== "index") {
|
|
3034
3270
|
segments.push(slug);
|
|
3035
3271
|
}
|
|
3036
|
-
const
|
|
3037
|
-
return
|
|
3272
|
+
const path19 = segments.length > 0 ? `/${segments.join("/")}` : "/";
|
|
3273
|
+
return path19.toLowerCase();
|
|
3038
3274
|
}
|
|
3039
3275
|
|
|
3040
3276
|
// dist/entry-link-resolver.js
|
|
@@ -3407,77 +3643,139 @@ function matchesBundleFilter(entry, filter, contentRoot) {
|
|
|
3407
3643
|
}
|
|
3408
3644
|
|
|
3409
3645
|
// dist/branch-workspace.js
|
|
3410
|
-
import
|
|
3646
|
+
import path15 from "node:path";
|
|
3411
3647
|
|
|
3412
3648
|
// dist/branch-metadata.js
|
|
3413
|
-
import
|
|
3414
|
-
import
|
|
3415
|
-
import path11 from "node:path";
|
|
3649
|
+
import fs12 from "node:fs/promises";
|
|
3650
|
+
import path12 from "node:path";
|
|
3416
3651
|
|
|
3417
3652
|
// dist/branch-registry.js
|
|
3418
3653
|
import fs10 from "node:fs/promises";
|
|
3419
3654
|
import path10 from "node:path";
|
|
3655
|
+
var log3 = createDebugLogger({ prefix: "BranchRegistry" });
|
|
3420
3656
|
var REGISTRY_FILE = "branches.json";
|
|
3421
3657
|
var REGISTRY_STALE_FILE = "branches.stale.json";
|
|
3422
3658
|
var REGISTRY_TEMP_FILE = "branches.tmp.json";
|
|
3423
|
-
var REGISTRY_VERSION =
|
|
3659
|
+
var REGISTRY_VERSION = 2;
|
|
3660
|
+
var RESOURCE2 = "branch-registry";
|
|
3661
|
+
var GET_MISS_REFRESH_MIN_INTERVAL_MS = 5e3;
|
|
3424
3662
|
var BranchRegistry = class {
|
|
3425
3663
|
constructor(root) {
|
|
3664
|
+
this.regenInFlight = null;
|
|
3665
|
+
this.lastForcedRefreshMs = 0;
|
|
3426
3666
|
this.root = path10.resolve(root);
|
|
3427
3667
|
this.registryPath = path10.join(this.root, REGISTRY_FILE);
|
|
3428
3668
|
this.stalePath = path10.join(this.root, REGISTRY_STALE_FILE);
|
|
3429
3669
|
this.tempPath = path10.join(this.root, REGISTRY_TEMP_FILE);
|
|
3430
3670
|
}
|
|
3431
3671
|
/**
|
|
3432
|
-
* Returns all branches. Uses
|
|
3672
|
+
* Returns all branches. Uses the cached snapshot if its embedded generation
|
|
3673
|
+
* token matches the live marker, regenerates otherwise.
|
|
3433
3674
|
*/
|
|
3434
3675
|
async list() {
|
|
3676
|
+
let parsed;
|
|
3435
3677
|
try {
|
|
3436
3678
|
const raw = await fs10.readFile(this.registryPath, "utf8");
|
|
3437
|
-
|
|
3438
|
-
if (!parsed.version || !Array.isArray(parsed.branches)) {
|
|
3439
|
-
return await this.regenerate();
|
|
3440
|
-
}
|
|
3441
|
-
return parsed.branches;
|
|
3679
|
+
parsed = JSON.parse(raw);
|
|
3442
3680
|
} catch (err) {
|
|
3443
3681
|
if (isNotFoundError(err)) {
|
|
3444
3682
|
return await this.regenerate();
|
|
3445
3683
|
}
|
|
3446
3684
|
throw err;
|
|
3447
3685
|
}
|
|
3686
|
+
if (parsed.version !== REGISTRY_VERSION || !Array.isArray(parsed.branches)) {
|
|
3687
|
+
return await this.regenerate();
|
|
3688
|
+
}
|
|
3689
|
+
const read = await readResourceGeneration(this.root, RESOURCE2);
|
|
3690
|
+
if (isGenerationCurrent(parsed.generation, read)) {
|
|
3691
|
+
return parsed.branches;
|
|
3692
|
+
}
|
|
3693
|
+
return await this.regenerate();
|
|
3448
3694
|
}
|
|
3449
3695
|
/**
|
|
3450
3696
|
* Returns a single branch by name. Uses cache if available.
|
|
3697
|
+
*
|
|
3698
|
+
* Suspicious-miss backstop: if `name` isn't in the (possibly cached) list,
|
|
3699
|
+
* that's a signal the snapshot may be stale in the specific direction where
|
|
3700
|
+
* a branch that now exists is missing from it - force one fresh
|
|
3701
|
+
* regeneration and re-search before giving up, throttled per instance so a
|
|
3702
|
+
* genuinely-absent branch costs at most one extra scan per throttle window.
|
|
3703
|
+
* This bounds durable window-E staleness for the "branch exists but
|
|
3704
|
+
* snapshot predates it" direction; see the class doc comment.
|
|
3451
3705
|
*/
|
|
3452
3706
|
async get(name) {
|
|
3453
3707
|
const branches = await this.list();
|
|
3454
|
-
|
|
3708
|
+
const found = branches.find((b) => b.branch.name === name);
|
|
3709
|
+
if (found)
|
|
3710
|
+
return found;
|
|
3711
|
+
if (!this.shouldForceRefresh())
|
|
3712
|
+
return void 0;
|
|
3713
|
+
const refreshed = await this.regenerate();
|
|
3714
|
+
return refreshed.find((b) => b.branch.name === name);
|
|
3715
|
+
}
|
|
3716
|
+
shouldForceRefresh() {
|
|
3717
|
+
const now = Date.now();
|
|
3718
|
+
if (now - this.lastForcedRefreshMs < GET_MISS_REFRESH_MIN_INTERVAL_MS)
|
|
3719
|
+
return false;
|
|
3720
|
+
this.lastForcedRefreshMs = now;
|
|
3721
|
+
return true;
|
|
3455
3722
|
}
|
|
3456
3723
|
/**
|
|
3457
|
-
* Marks the cache as stale
|
|
3458
|
-
*
|
|
3724
|
+
* Marks the cache as stale for every process sharing this root by bumping
|
|
3725
|
+
* the cross-process generation marker, then eager-regenerates on this host
|
|
3726
|
+
* (see class doc comment for why). The bump must succeed: a swallowed
|
|
3727
|
+
* failure here would leave the registry stale indefinitely with no
|
|
3728
|
+
* bounding backstop, unlike a failed eager regen below, which only forgoes
|
|
3729
|
+
* closing window (E) early - the bump alone already restored correctness
|
|
3730
|
+
* for every future reader. A failed eager regen therefore must not fail the
|
|
3731
|
+
* caller's save/delete.
|
|
3459
3732
|
*/
|
|
3460
3733
|
async invalidate() {
|
|
3734
|
+
await bumpResourceGeneration(this.root, RESOURCE2, { mustSucceed: true });
|
|
3461
3735
|
try {
|
|
3462
|
-
|
|
3736
|
+
if (this.regenInFlight)
|
|
3737
|
+
await this.regenInFlight.catch(() => {
|
|
3738
|
+
});
|
|
3739
|
+
await this.regenerate();
|
|
3463
3740
|
} catch (err) {
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
}
|
|
3741
|
+
log3.warn("invalidate", "Eager regeneration after invalidate() failed", {
|
|
3742
|
+
error: getErrorMessage(err)
|
|
3743
|
+
});
|
|
3467
3744
|
}
|
|
3468
3745
|
}
|
|
3469
3746
|
/**
|
|
3470
|
-
* Scans branch directories and rebuilds the cache
|
|
3471
|
-
*
|
|
3747
|
+
* Scans branch directories and rebuilds the cache, deduping concurrent
|
|
3748
|
+
* callers on this instance to a single underlying scan. Never loops waiting
|
|
3749
|
+
* for the snapshot's embedded token to match the marker - under a bump
|
|
3750
|
+
* storm that could livelock; a caller that wants the very latest state
|
|
3751
|
+
* after a concurrent bump should call list() again after this resolves.
|
|
3472
3752
|
*/
|
|
3473
3753
|
async regenerate() {
|
|
3754
|
+
if (this.regenInFlight)
|
|
3755
|
+
return this.regenInFlight;
|
|
3756
|
+
const run = this.performRegenerate();
|
|
3757
|
+
this.regenInFlight = run;
|
|
3758
|
+
try {
|
|
3759
|
+
return await run;
|
|
3760
|
+
} finally {
|
|
3761
|
+
this.regenInFlight = null;
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
async performRegenerate() {
|
|
3765
|
+
const read = await readResourceGeneration(this.root, RESOURCE2);
|
|
3474
3766
|
const branches = await this.scanBranchDirectories();
|
|
3475
|
-
|
|
3476
|
-
|
|
3767
|
+
await fs10.unlink(this.stalePath).catch(() => {
|
|
3768
|
+
});
|
|
3769
|
+
if (!read.ok) {
|
|
3770
|
+
return branches;
|
|
3771
|
+
}
|
|
3477
3772
|
const snapshot = {
|
|
3478
3773
|
version: REGISTRY_VERSION,
|
|
3479
|
-
branches
|
|
3774
|
+
branches,
|
|
3775
|
+
generation: read.token
|
|
3480
3776
|
};
|
|
3777
|
+
const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
3778
|
+
await fs10.mkdir(this.root, { recursive: true });
|
|
3481
3779
|
await fs10.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
|
3482
3780
|
try {
|
|
3483
3781
|
await fs10.rename(uniqueTempPath, this.registryPath);
|
|
@@ -3486,12 +3784,12 @@ var BranchRegistry = class {
|
|
|
3486
3784
|
});
|
|
3487
3785
|
throw err;
|
|
3488
3786
|
}
|
|
3489
|
-
await fs10.unlink(this.stalePath).catch(() => {
|
|
3490
|
-
});
|
|
3491
3787
|
return branches;
|
|
3492
3788
|
}
|
|
3493
3789
|
/**
|
|
3494
3790
|
* Scans the root directory for branch subdirectories with valid branch.json files.
|
|
3791
|
+
* Protected (rather than exported as a test hook) so tests can subclass and
|
|
3792
|
+
* override to simulate cross-process interleavings.
|
|
3495
3793
|
*/
|
|
3496
3794
|
async scanBranchDirectories() {
|
|
3497
3795
|
const branches = [];
|
|
@@ -3521,30 +3819,156 @@ var BranchRegistry = class {
|
|
|
3521
3819
|
}
|
|
3522
3820
|
};
|
|
3523
3821
|
|
|
3822
|
+
// dist/utils/occ-json-write.js
|
|
3823
|
+
import fs11 from "node:fs/promises";
|
|
3824
|
+
import path11 from "node:path";
|
|
3825
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
3826
|
+
import lockfile from "proper-lockfile";
|
|
3827
|
+
var log4 = createDebugLogger({ prefix: "OccJsonWrite" });
|
|
3828
|
+
var OccWriteConflictError = class extends Error {
|
|
3829
|
+
constructor(message = "Concurrent modification detected") {
|
|
3830
|
+
super(message);
|
|
3831
|
+
this.name = "OccWriteConflictError";
|
|
3832
|
+
}
|
|
3833
|
+
};
|
|
3834
|
+
async function writeOccJsonFile(filePath, payload, options) {
|
|
3835
|
+
const { expectedVersion, settleMs = 50, trailingNewline = false } = options;
|
|
3836
|
+
const newVersion = expectedVersion === null ? 1 : expectedVersion + 1;
|
|
3837
|
+
const writeId = randomUUID2();
|
|
3838
|
+
const data = { ...payload, version: newVersion, writeId };
|
|
3839
|
+
const content = JSON.stringify(data, null, 2) + (trailingNewline ? "\n" : "");
|
|
3840
|
+
const dir = path11.dirname(filePath);
|
|
3841
|
+
await fs11.mkdir(dir, { recursive: true });
|
|
3842
|
+
const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
3843
|
+
await fs11.writeFile(tempPath, content, "utf-8");
|
|
3844
|
+
if (expectedVersion === null) {
|
|
3845
|
+
try {
|
|
3846
|
+
await fs11.link(tempPath, filePath);
|
|
3847
|
+
} catch (err) {
|
|
3848
|
+
await fs11.unlink(tempPath).catch(() => {
|
|
3849
|
+
});
|
|
3850
|
+
if (isFileExistsError(err)) {
|
|
3851
|
+
throw new OccWriteConflictError();
|
|
3852
|
+
}
|
|
3853
|
+
throw err;
|
|
3854
|
+
}
|
|
3855
|
+
await fs11.unlink(tempPath).catch(() => {
|
|
3856
|
+
});
|
|
3857
|
+
return { version: newVersion, writeId };
|
|
3858
|
+
}
|
|
3859
|
+
try {
|
|
3860
|
+
let currentVersion = null;
|
|
3861
|
+
try {
|
|
3862
|
+
const current = JSON.parse(await fs11.readFile(filePath, "utf-8"));
|
|
3863
|
+
currentVersion = current.version ?? 0;
|
|
3864
|
+
} catch {
|
|
3865
|
+
currentVersion = null;
|
|
3866
|
+
}
|
|
3867
|
+
if (currentVersion !== expectedVersion) {
|
|
3868
|
+
throw new OccWriteConflictError();
|
|
3869
|
+
}
|
|
3870
|
+
await fs11.rename(tempPath, filePath);
|
|
3871
|
+
if (settleMs > 0) {
|
|
3872
|
+
await new Promise((resolve2) => setTimeout(resolve2, settleMs));
|
|
3873
|
+
}
|
|
3874
|
+
const afterWrite = JSON.parse(await fs11.readFile(filePath, "utf-8"));
|
|
3875
|
+
if (afterWrite.writeId !== writeId) {
|
|
3876
|
+
throw new OccWriteConflictError();
|
|
3877
|
+
}
|
|
3878
|
+
} catch (err) {
|
|
3879
|
+
await fs11.unlink(tempPath).catch(() => {
|
|
3880
|
+
});
|
|
3881
|
+
throw err;
|
|
3882
|
+
}
|
|
3883
|
+
return { version: newVersion, writeId };
|
|
3884
|
+
}
|
|
3885
|
+
async function withOccRetry(operation, options) {
|
|
3886
|
+
const maxAttempts = options?.maxAttempts ?? 10;
|
|
3887
|
+
const isRetryable = options?.isRetryable;
|
|
3888
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3889
|
+
try {
|
|
3890
|
+
return await operation();
|
|
3891
|
+
} catch (err) {
|
|
3892
|
+
const retryable = err instanceof OccWriteConflictError || (isRetryable?.(err) ?? false);
|
|
3893
|
+
if (retryable && attempt < maxAttempts) {
|
|
3894
|
+
const baseDelay = Math.min(10 * Math.pow(2, attempt - 1), 100);
|
|
3895
|
+
const jitter = Math.random() * baseDelay;
|
|
3896
|
+
await new Promise((resolve2) => setTimeout(resolve2, baseDelay + jitter));
|
|
3897
|
+
continue;
|
|
3898
|
+
}
|
|
3899
|
+
throw err;
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
throw new Error("Unreachable");
|
|
3903
|
+
}
|
|
3904
|
+
async function withOccFileLock(filePath, fn) {
|
|
3905
|
+
const dir = path11.dirname(filePath);
|
|
3906
|
+
await fs11.mkdir(dir, { recursive: true });
|
|
3907
|
+
const MAX_ATTEMPTS = 14;
|
|
3908
|
+
let release = null;
|
|
3909
|
+
for (let attempt = 1; release === null; attempt++) {
|
|
3910
|
+
try {
|
|
3911
|
+
release = await lockfile.lock(filePath, {
|
|
3912
|
+
lockfilePath: `${filePath}.lock`,
|
|
3913
|
+
realpath: false,
|
|
3914
|
+
stale: 1e4,
|
|
3915
|
+
retries: 0,
|
|
3916
|
+
// A compromised lock (the lock dir vanished or refresh failed
|
|
3917
|
+
// mid-hold, e.g. the branch directory containing it was deleted)
|
|
3918
|
+
// must not crash the process, which is proper-lockfile's default.
|
|
3919
|
+
// Our critical sections are short and idempotent-on-conflict (OCC
|
|
3920
|
+
// verify inside); log and let the section finish.
|
|
3921
|
+
onCompromised: (err) => {
|
|
3922
|
+
log4.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
|
|
3923
|
+
error: getErrorMessage(err)
|
|
3924
|
+
});
|
|
3925
|
+
}
|
|
3926
|
+
});
|
|
3927
|
+
} catch (err) {
|
|
3928
|
+
const code = isNodeError(err) ? err.code : void 0;
|
|
3929
|
+
const contended = code === "ELOCKED";
|
|
3930
|
+
if (!contended || attempt >= MAX_ATTEMPTS) {
|
|
3931
|
+
throw new OccWriteConflictError(`Could not acquire file lock: ${getErrorMessage(err)}`);
|
|
3932
|
+
}
|
|
3933
|
+
const baseDelay = Math.min(50 * Math.pow(1.6, attempt - 1), 2e3);
|
|
3934
|
+
const jitter = Math.random() * baseDelay;
|
|
3935
|
+
await new Promise((resolve2) => setTimeout(resolve2, baseDelay + jitter));
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
try {
|
|
3939
|
+
return await fn();
|
|
3940
|
+
} finally {
|
|
3941
|
+
await release().catch((err) => {
|
|
3942
|
+
log4.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err) });
|
|
3943
|
+
});
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3524
3947
|
// dist/branch-metadata.js
|
|
3525
3948
|
var BRANCH_META_DIR = ".canopy-meta";
|
|
3526
3949
|
var BRANCH_META_FILE = "branch.json";
|
|
3527
3950
|
var CURRENT_SCHEMA_VERSION = 1;
|
|
3528
3951
|
var BranchMetadataConflictError = class extends Error {
|
|
3529
|
-
constructor() {
|
|
3530
|
-
super(
|
|
3952
|
+
constructor(message = "Concurrent modification detected in branch metadata") {
|
|
3953
|
+
super(message);
|
|
3531
3954
|
this.name = "BranchMetadataConflictError";
|
|
3532
3955
|
}
|
|
3533
3956
|
};
|
|
3534
3957
|
var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
3535
|
-
constructor(branchRoot, baseRoot) {
|
|
3536
|
-
this.branchRoot =
|
|
3537
|
-
this.filePath =
|
|
3958
|
+
constructor(branchRoot, baseRoot, options) {
|
|
3959
|
+
this.branchRoot = path12.resolve(branchRoot);
|
|
3960
|
+
this.filePath = path12.join(this.branchRoot, BRANCH_META_DIR, BRANCH_META_FILE);
|
|
3538
3961
|
this.baseRoot = baseRoot;
|
|
3962
|
+
this.settleMs = options?.settleMs;
|
|
3539
3963
|
}
|
|
3540
3964
|
/**
|
|
3541
3965
|
* Load branch metadata without requiring baseRoot.
|
|
3542
3966
|
* Use this for read-only access (e.g., in registry scanning or loadBranchContext).
|
|
3543
3967
|
*/
|
|
3544
3968
|
static async loadOnly(branchRoot) {
|
|
3545
|
-
const filePath =
|
|
3969
|
+
const filePath = path12.join(path12.resolve(branchRoot), BRANCH_META_DIR, BRANCH_META_FILE);
|
|
3546
3970
|
try {
|
|
3547
|
-
const raw = await
|
|
3971
|
+
const raw = await fs12.readFile(filePath, "utf8");
|
|
3548
3972
|
return JSON.parse(raw);
|
|
3549
3973
|
} catch (err) {
|
|
3550
3974
|
if (isNotFoundError(err)) {
|
|
@@ -3557,12 +3981,12 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
3557
3981
|
* Get a BranchMetadataFileManager instance configured for registry invalidation.
|
|
3558
3982
|
* Use this in API handlers to ensure registry cache is invalidated on updates.
|
|
3559
3983
|
*/
|
|
3560
|
-
static get(branchRoot, baseRoot) {
|
|
3561
|
-
return new _BranchMetadataFileManager(branchRoot, baseRoot);
|
|
3984
|
+
static get(branchRoot, baseRoot, options) {
|
|
3985
|
+
return new _BranchMetadataFileManager(branchRoot, baseRoot, options);
|
|
3562
3986
|
}
|
|
3563
3987
|
async load() {
|
|
3564
3988
|
try {
|
|
3565
|
-
const raw = await
|
|
3989
|
+
const raw = await fs12.readFile(this.filePath, "utf8");
|
|
3566
3990
|
const parsed = JSON.parse(raw);
|
|
3567
3991
|
const version = parsed.version ?? 0;
|
|
3568
3992
|
return { meta: parsed, version };
|
|
@@ -3574,114 +3998,107 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
3574
3998
|
}
|
|
3575
3999
|
}
|
|
3576
4000
|
/**
|
|
3577
|
-
*
|
|
3578
|
-
*
|
|
4001
|
+
* Write branch.json via the shared OCC helper, with the schemaVersion
|
|
4002
|
+
* default applied here (payload shaping stays branch-metadata's concern).
|
|
4003
|
+
*
|
|
4004
|
+
* Throws the helper's raw {@link OccWriteConflictError} so the surrounding
|
|
4005
|
+
* {@link withOccRetry} in save() recognizes and retries it; translation to
|
|
4006
|
+
* the public `BranchMetadataConflictError` contract happens at the save()
|
|
4007
|
+
* boundary, after retries are exhausted (translating earlier would make
|
|
4008
|
+
* withOccRetry's default predicate miss it, since it only recognizes the
|
|
4009
|
+
* raw error type).
|
|
4010
|
+
*
|
|
4011
|
+
* branch-metadata historically writes WITH a trailing newline, unlike
|
|
4012
|
+
* comment-store; `trailingNewline: true` preserves that.
|
|
3579
4013
|
*/
|
|
3580
4014
|
async write(meta, expectedVersion) {
|
|
3581
|
-
const newVersion = expectedVersion === null ? 1 : expectedVersion + 1;
|
|
3582
|
-
const writeId = randomUUID2();
|
|
3583
4015
|
const payload = {
|
|
3584
4016
|
...meta,
|
|
3585
|
-
schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION
|
|
3586
|
-
version: newVersion,
|
|
3587
|
-
writeId
|
|
4017
|
+
schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION
|
|
3588
4018
|
};
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
4019
|
+
return writeOccJsonFile(this.filePath, payload, {
|
|
4020
|
+
expectedVersion,
|
|
4021
|
+
settleMs: this.settleMs,
|
|
4022
|
+
trailingNewline: true
|
|
4023
|
+
});
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* Run a save cycle under the full lock + OCC-retry stack described in the
|
|
4027
|
+
* class doc comment. A conflict that survives every retry surfaces as the
|
|
4028
|
+
* public `BranchMetadataConflictError`.
|
|
4029
|
+
*
|
|
4030
|
+
* Guards against a phantom-resurrection race with branch deletion: a
|
|
4031
|
+
* caller's branchContext can be resolved BEFORE a concurrent
|
|
4032
|
+
* deleteBranchHandler removes the branch directory, but this save() call
|
|
4033
|
+
* only reaches here (and its own mkdir({recursive:true}) inside write())
|
|
4034
|
+
* AFTER the removal. Without this check, that mkdir would silently
|
|
4035
|
+
* recreate `.canopy-meta/` (and this save would recreate branch.json from
|
|
4036
|
+
* defaults) inside a directory tree that no longer exists anywhere else --
|
|
4037
|
+
* a registry entry with no clone behind it. Checking BEFORE the lock stack
|
|
4038
|
+
* (rather than after) fails fast without paying for a lock acquisition on
|
|
4039
|
+
* a doomed save.
|
|
4040
|
+
*
|
|
4041
|
+
* Residual window (accepted): a save that passes this check can still
|
|
4042
|
+
* race a `rm` that starts moments later and is still mid-flight when this
|
|
4043
|
+
* save's write lands, resurrecting the tree. Closing that fully would
|
|
4044
|
+
* need a tombstone OUTSIDE the tree being removed -- the lockfile
|
|
4045
|
+
* (`withOccFileLock`) this save takes next lives INSIDE `branchRoot`, so
|
|
4046
|
+
* it cannot itself provide a wider guarantee than "the directory existed
|
|
4047
|
+
* a moment ago."
|
|
4048
|
+
*/
|
|
4049
|
+
async save(incoming) {
|
|
4050
|
+
try {
|
|
4051
|
+
await fs12.stat(this.branchRoot);
|
|
4052
|
+
} catch (err) {
|
|
4053
|
+
if (isNotFoundError(err)) {
|
|
4054
|
+
throw new BranchMetadataConflictError("Branch no longer exists");
|
|
3603
4055
|
}
|
|
3604
|
-
|
|
3605
|
-
});
|
|
3606
|
-
return { version: newVersion, writeId };
|
|
4056
|
+
throw err;
|
|
3607
4057
|
}
|
|
3608
|
-
|
|
3609
|
-
await fs11.writeFile(tempPath, content, "utf-8");
|
|
4058
|
+
let saved;
|
|
3610
4059
|
try {
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
const
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
4060
|
+
saved = await withLock(this.filePath, () => withOccFileLock(this.filePath, () => withOccRetry(async () => {
|
|
4061
|
+
const { meta: existing, version } = await this.load();
|
|
4062
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4063
|
+
const defaults = {
|
|
4064
|
+
name: "unknown",
|
|
4065
|
+
status: "editing",
|
|
4066
|
+
access: {},
|
|
4067
|
+
createdBy: "unknown",
|
|
4068
|
+
createdAt: now,
|
|
4069
|
+
updatedAt: now
|
|
4070
|
+
};
|
|
4071
|
+
const merged = {
|
|
4072
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
4073
|
+
version: version ?? 0,
|
|
4074
|
+
branch: {
|
|
4075
|
+
...defaults,
|
|
4076
|
+
...existing?.branch,
|
|
4077
|
+
...incoming.branch,
|
|
4078
|
+
access: {
|
|
4079
|
+
...existing?.branch?.access,
|
|
4080
|
+
...incoming.branch?.access
|
|
4081
|
+
},
|
|
4082
|
+
// Immutable after creation
|
|
4083
|
+
createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
|
|
4084
|
+
createdAt: existing?.branch.createdAt ?? defaults.createdAt,
|
|
4085
|
+
// Fork point is recorded once at creation; later saves must not move it
|
|
4086
|
+
baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch
|
|
4087
|
+
}
|
|
4088
|
+
};
|
|
4089
|
+
const written = await this.write(merged, version);
|
|
4090
|
+
merged.version = written.version;
|
|
4091
|
+
merged.writeId = written.writeId;
|
|
4092
|
+
return merged;
|
|
4093
|
+
})));
|
|
4094
|
+
} catch (err) {
|
|
4095
|
+
if (err instanceof OccWriteConflictError) {
|
|
3624
4096
|
throw new BranchMetadataConflictError();
|
|
3625
4097
|
}
|
|
3626
|
-
} catch (err) {
|
|
3627
|
-
await fs11.unlink(tempPath).catch(() => {
|
|
3628
|
-
});
|
|
3629
4098
|
throw err;
|
|
3630
4099
|
}
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
async withRetry(operation, maxAttempts = 5) {
|
|
3634
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3635
|
-
try {
|
|
3636
|
-
return await operation();
|
|
3637
|
-
} catch (err) {
|
|
3638
|
-
if (err instanceof BranchMetadataConflictError && attempt < maxAttempts) {
|
|
3639
|
-
const baseDelay = Math.min(10 * Math.pow(2, attempt - 1), 100);
|
|
3640
|
-
const jitter = Math.random() * baseDelay;
|
|
3641
|
-
await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
|
|
3642
|
-
continue;
|
|
3643
|
-
}
|
|
3644
|
-
throw err;
|
|
3645
|
-
}
|
|
3646
|
-
}
|
|
3647
|
-
throw new Error("Unreachable");
|
|
3648
|
-
}
|
|
3649
|
-
async save(incoming) {
|
|
3650
|
-
return withLock(this.filePath, () => this.withRetry(async () => {
|
|
3651
|
-
const { meta: existing, version } = await this.load();
|
|
3652
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3653
|
-
const defaults = {
|
|
3654
|
-
name: "unknown",
|
|
3655
|
-
status: "editing",
|
|
3656
|
-
access: {},
|
|
3657
|
-
createdBy: "unknown",
|
|
3658
|
-
createdAt: now,
|
|
3659
|
-
updatedAt: now
|
|
3660
|
-
};
|
|
3661
|
-
const merged = {
|
|
3662
|
-
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
3663
|
-
version: version ?? 0,
|
|
3664
|
-
branch: {
|
|
3665
|
-
...defaults,
|
|
3666
|
-
...existing?.branch,
|
|
3667
|
-
...incoming.branch,
|
|
3668
|
-
access: {
|
|
3669
|
-
...existing?.branch?.access,
|
|
3670
|
-
...incoming.branch?.access
|
|
3671
|
-
},
|
|
3672
|
-
// Immutable after creation
|
|
3673
|
-
createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
|
|
3674
|
-
createdAt: existing?.branch.createdAt ?? defaults.createdAt,
|
|
3675
|
-
// Fork point is recorded once at creation; later saves must not move it
|
|
3676
|
-
baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch
|
|
3677
|
-
}
|
|
3678
|
-
};
|
|
3679
|
-
const written = await this.write(merged, version);
|
|
3680
|
-
merged.version = written.version;
|
|
3681
|
-
merged.writeId = written.writeId;
|
|
3682
|
-
await this.invalidateRegistry();
|
|
3683
|
-
return merged;
|
|
3684
|
-
}));
|
|
4100
|
+
await this.invalidateRegistry();
|
|
4101
|
+
return saved;
|
|
3685
4102
|
}
|
|
3686
4103
|
/**
|
|
3687
4104
|
* Invalidates the registry cache so next list() call regenerates from branch.json files.
|
|
@@ -3691,8 +4108,8 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
3691
4108
|
await registry2.invalidate();
|
|
3692
4109
|
}
|
|
3693
4110
|
};
|
|
3694
|
-
var getBranchMetadataFileManager = (branchRoot, baseRoot) => {
|
|
3695
|
-
return BranchMetadataFileManager.get(branchRoot, baseRoot);
|
|
4111
|
+
var getBranchMetadataFileManager = (branchRoot, baseRoot, options) => {
|
|
4112
|
+
return BranchMetadataFileManager.get(branchRoot, baseRoot, options);
|
|
3696
4113
|
};
|
|
3697
4114
|
var loadBranchContext = async (options) => {
|
|
3698
4115
|
const { branchRoot, baseRoot } = resolveBranchPath({
|
|
@@ -3715,8 +4132,8 @@ var loadBranchContext = async (options) => {
|
|
|
3715
4132
|
init_operating_mode();
|
|
3716
4133
|
|
|
3717
4134
|
// dist/git-manager.js
|
|
3718
|
-
import
|
|
3719
|
-
import
|
|
4135
|
+
import fs14 from "node:fs/promises";
|
|
4136
|
+
import path14 from "node:path";
|
|
3720
4137
|
import { simpleGit as simpleGit2 } from "simple-git";
|
|
3721
4138
|
|
|
3722
4139
|
// dist/utils/git.js
|
|
@@ -3740,40 +4157,40 @@ async function resolveBaseBranch(options) {
|
|
|
3740
4157
|
}
|
|
3741
4158
|
|
|
3742
4159
|
// dist/utils/provisioning-lock.js
|
|
3743
|
-
import
|
|
3744
|
-
import
|
|
3745
|
-
import
|
|
4160
|
+
import fs13 from "node:fs/promises";
|
|
4161
|
+
import path13 from "node:path";
|
|
4162
|
+
import lockfile2 from "proper-lockfile";
|
|
3746
4163
|
async function acquireProvisioningLock(lockTargetDir, lockName) {
|
|
3747
|
-
await
|
|
3748
|
-
return
|
|
3749
|
-
lockfilePath:
|
|
4164
|
+
await fs13.mkdir(lockTargetDir, { recursive: true });
|
|
4165
|
+
return lockfile2.lock(lockTargetDir, {
|
|
4166
|
+
lockfilePath: path13.join(lockTargetDir, lockName),
|
|
3750
4167
|
retries: { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true },
|
|
3751
4168
|
stale: 3e4
|
|
3752
4169
|
});
|
|
3753
4170
|
}
|
|
3754
4171
|
|
|
3755
4172
|
// dist/git-manager.js
|
|
3756
|
-
var
|
|
4173
|
+
var log5 = createDebugLogger({ prefix: "GitManager" });
|
|
3757
4174
|
var remoteInitLocks = /* @__PURE__ */ new Map();
|
|
3758
4175
|
async function ensureGitExcludePattern(repoPath, pattern) {
|
|
3759
|
-
const excludePath =
|
|
3760
|
-
await
|
|
4176
|
+
const excludePath = path14.join(repoPath, ".git", "info", "exclude");
|
|
4177
|
+
await fs14.mkdir(path14.dirname(excludePath), { recursive: true });
|
|
3761
4178
|
let content = "";
|
|
3762
4179
|
try {
|
|
3763
|
-
content = await
|
|
4180
|
+
content = await fs14.readFile(excludePath, "utf-8");
|
|
3764
4181
|
} catch (err) {
|
|
3765
4182
|
if (!isNotFoundError(err))
|
|
3766
4183
|
throw err;
|
|
3767
4184
|
}
|
|
3768
4185
|
const lines = content.split("\n");
|
|
3769
4186
|
if (lines.some((line) => line.trim() === pattern)) {
|
|
3770
|
-
|
|
4187
|
+
log5.debug("git", "Pattern already in .git/info/exclude", { pattern });
|
|
3771
4188
|
return;
|
|
3772
4189
|
}
|
|
3773
4190
|
const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
|
|
3774
4191
|
const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
|
|
3775
|
-
await
|
|
3776
|
-
|
|
4192
|
+
await fs14.writeFile(excludePath, newContent, "utf-8");
|
|
4193
|
+
log5.debug("git", "Added pattern to .git/info/exclude", { pattern });
|
|
3777
4194
|
}
|
|
3778
4195
|
var GitConflictError = class extends Error {
|
|
3779
4196
|
constructor(conflictedFiles) {
|
|
@@ -3784,22 +4201,22 @@ var GitConflictError = class extends Error {
|
|
|
3784
4201
|
};
|
|
3785
4202
|
var GitManager = class _GitManager {
|
|
3786
4203
|
constructor(options, gitOptions) {
|
|
3787
|
-
this.repoPath =
|
|
4204
|
+
this.repoPath = path14.resolve(options.repoPath);
|
|
3788
4205
|
this.baseBranch = options.baseBranch ?? "main";
|
|
3789
4206
|
this.remote = options.remote ?? "origin";
|
|
3790
4207
|
this.skipIndexMarker = options.skipIndexMarker ?? false;
|
|
3791
4208
|
this.git = simpleGit2({ baseDir: this.repoPath, ...gitOptions });
|
|
3792
|
-
this.git.env("GIT_CEILING_DIRECTORIES",
|
|
4209
|
+
this.git.env("GIT_CEILING_DIRECTORIES", path14.dirname(this.repoPath));
|
|
3793
4210
|
}
|
|
3794
4211
|
static async cloneRepo(remoteUrl, targetPath, baseBranch = "main") {
|
|
3795
|
-
|
|
4212
|
+
log5.debug("git", "Cloning repository", {
|
|
3796
4213
|
remoteUrl,
|
|
3797
4214
|
targetPath,
|
|
3798
4215
|
baseBranch
|
|
3799
4216
|
});
|
|
3800
4217
|
const git = simpleGit2();
|
|
3801
4218
|
await git.clone(remoteUrl, targetPath, ["--branch", baseBranch, "--single-branch"]);
|
|
3802
|
-
|
|
4219
|
+
log5.debug("git", "Clone complete");
|
|
3803
4220
|
}
|
|
3804
4221
|
/**
|
|
3805
4222
|
* Initializes a local bare git repository to simulate a remote for dev mode.
|
|
@@ -3819,30 +4236,30 @@ var GitManager = class _GitManager {
|
|
|
3819
4236
|
static async ensureLocalSimulatedRemote(options) {
|
|
3820
4237
|
const existingLock = remoteInitLocks.get(options.remotePath);
|
|
3821
4238
|
if (existingLock) {
|
|
3822
|
-
|
|
4239
|
+
log5.debug("git", "Waiting for existing remote initialization", {
|
|
3823
4240
|
remotePath: options.remotePath
|
|
3824
4241
|
});
|
|
3825
4242
|
await existingLock;
|
|
3826
4243
|
}
|
|
3827
|
-
const lockPromise =
|
|
4244
|
+
const lockPromise = log5.timed("git", "ensureLocalSimulatedRemote", async () => {
|
|
3828
4245
|
let releaseLock;
|
|
3829
4246
|
try {
|
|
3830
|
-
|
|
4247
|
+
log5.debug("git", "Initializing local simulated remote", {
|
|
3831
4248
|
remotePath: options.remotePath,
|
|
3832
4249
|
baseBranch: options.baseBranch
|
|
3833
4250
|
});
|
|
3834
|
-
const remoteParent =
|
|
4251
|
+
const remoteParent = path14.dirname(options.remotePath);
|
|
3835
4252
|
releaseLock = await acquireProvisioningLock(remoteParent, ".remote-init.lock");
|
|
3836
4253
|
let remoteExists = false;
|
|
3837
4254
|
try {
|
|
3838
|
-
const stat = await
|
|
4255
|
+
const stat = await fs14.stat(options.remotePath);
|
|
3839
4256
|
remoteExists = stat.isDirectory();
|
|
3840
4257
|
} catch (err) {
|
|
3841
4258
|
if (!isNotFoundError(err))
|
|
3842
4259
|
throw err;
|
|
3843
4260
|
}
|
|
3844
4261
|
if (remoteExists && await _GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch)) {
|
|
3845
|
-
|
|
4262
|
+
log5.debug("git", "Remote already has base branch, skipping");
|
|
3846
4263
|
return;
|
|
3847
4264
|
}
|
|
3848
4265
|
let gitRoot = options.sourcePath;
|
|
@@ -3861,8 +4278,8 @@ var GitManager = class _GitManager {
|
|
|
3861
4278
|
}
|
|
3862
4279
|
let hasCommits = false;
|
|
3863
4280
|
try {
|
|
3864
|
-
const
|
|
3865
|
-
hasCommits =
|
|
4281
|
+
const log8 = await sourceGit.log(["-1"]);
|
|
4282
|
+
hasCommits = log8.total > 0;
|
|
3866
4283
|
} catch {
|
|
3867
4284
|
hasCommits = false;
|
|
3868
4285
|
}
|
|
@@ -3874,12 +4291,12 @@ var GitManager = class _GitManager {
|
|
|
3874
4291
|
throw new Error(`Cannot initialize local simulated remote: base branch '${options.baseBranch}' does not exist locally. Please checkout '${options.baseBranch}' first or provide an explicit remoteUrl.`);
|
|
3875
4292
|
}
|
|
3876
4293
|
if (remoteExists) {
|
|
3877
|
-
|
|
4294
|
+
log5.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
|
|
3878
4295
|
remotePath: options.remotePath,
|
|
3879
4296
|
baseBranch: options.baseBranch
|
|
3880
4297
|
});
|
|
3881
4298
|
} else {
|
|
3882
|
-
|
|
4299
|
+
log5.debug("git", "Creating bare remote repository");
|
|
3883
4300
|
await simpleGit2().raw([
|
|
3884
4301
|
"init",
|
|
3885
4302
|
"--bare",
|
|
@@ -3893,13 +4310,13 @@ var GitManager = class _GitManager {
|
|
|
3893
4310
|
baseBranch: options.baseBranch,
|
|
3894
4311
|
subdirectory: options.subdirectory
|
|
3895
4312
|
});
|
|
3896
|
-
|
|
4313
|
+
log5.debug("git", "Remote initialization complete");
|
|
3897
4314
|
} finally {
|
|
3898
4315
|
if (releaseLock) {
|
|
3899
4316
|
try {
|
|
3900
4317
|
await releaseLock();
|
|
3901
4318
|
} catch (err) {
|
|
3902
|
-
|
|
4319
|
+
log5.debug("git", "Failed to release remote-init lock", { err });
|
|
3903
4320
|
}
|
|
3904
4321
|
}
|
|
3905
4322
|
remoteInitLocks.delete(options.remotePath);
|
|
@@ -3998,7 +4415,7 @@ var GitManager = class _GitManager {
|
|
|
3998
4415
|
*/
|
|
3999
4416
|
static async validateGitRepoExists(repoPath) {
|
|
4000
4417
|
try {
|
|
4001
|
-
const stat = await
|
|
4418
|
+
const stat = await fs14.stat(path14.join(repoPath, ".git"));
|
|
4002
4419
|
if (!stat.isDirectory()) {
|
|
4003
4420
|
throw new Error(`Expected git repo at ${repoPath}`);
|
|
4004
4421
|
}
|
|
@@ -4036,9 +4453,9 @@ var GitManager = class _GitManager {
|
|
|
4036
4453
|
return process.env[config.envVarName];
|
|
4037
4454
|
if (config.autoDetectRemotePath) {
|
|
4038
4455
|
try {
|
|
4039
|
-
const stat = await
|
|
4456
|
+
const stat = await fs14.stat(config.autoDetectRemotePath);
|
|
4040
4457
|
if (stat.isDirectory()) {
|
|
4041
|
-
|
|
4458
|
+
log5.debug("git", "Auto-detected local remote", {
|
|
4042
4459
|
path: config.autoDetectRemotePath
|
|
4043
4460
|
});
|
|
4044
4461
|
return config.autoDetectRemotePath;
|
|
@@ -4049,8 +4466,8 @@ var GitManager = class _GitManager {
|
|
|
4049
4466
|
if (config.shouldAutoInitLocal) {
|
|
4050
4467
|
const gitRoot = await this.findGitRoot();
|
|
4051
4468
|
const sourceRoot = options.sourceRoot;
|
|
4052
|
-
const sourcePath = sourceRoot ?
|
|
4053
|
-
const localRemotePath =
|
|
4469
|
+
const sourcePath = sourceRoot ? path14.resolve(gitRoot, sourceRoot) : gitRoot;
|
|
4470
|
+
const localRemotePath = path14.join(sourcePath, config.defaultRemotePath);
|
|
4054
4471
|
await this.ensureLocalSimulatedRemote({
|
|
4055
4472
|
remotePath: localRemotePath,
|
|
4056
4473
|
sourcePath: gitRoot,
|
|
@@ -4076,24 +4493,24 @@ var GitManager = class _GitManager {
|
|
|
4076
4493
|
const baseBranch = await resolveBaseBranch({
|
|
4077
4494
|
defaultBaseBranch: options.baseBranch,
|
|
4078
4495
|
mode: options.mode,
|
|
4079
|
-
detectFrom: options.sourceRoot ?
|
|
4496
|
+
detectFrom: options.sourceRoot ? path14.resolve(process.cwd(), options.sourceRoot) : process.cwd()
|
|
4080
4497
|
});
|
|
4081
4498
|
const remoteName = options.remoteName ?? "origin";
|
|
4082
4499
|
let repoExists = false;
|
|
4083
4500
|
try {
|
|
4084
4501
|
const checkGit = simpleGit2({ baseDir: options.workspacePath });
|
|
4085
|
-
checkGit.env("GIT_CEILING_DIRECTORIES",
|
|
4502
|
+
checkGit.env("GIT_CEILING_DIRECTORIES", path14.dirname(options.workspacePath));
|
|
4086
4503
|
await checkGit.raw(["rev-parse", "--git-dir"]);
|
|
4087
4504
|
repoExists = true;
|
|
4088
4505
|
} catch {
|
|
4089
|
-
const gitPath =
|
|
4506
|
+
const gitPath = path14.join(options.workspacePath, ".git");
|
|
4090
4507
|
try {
|
|
4091
|
-
const stat = await
|
|
4508
|
+
const stat = await fs14.stat(gitPath);
|
|
4092
4509
|
if (stat.isDirectory()) {
|
|
4093
|
-
|
|
4510
|
+
log5.debug("git", "Removing corrupt .git directory", {
|
|
4094
4511
|
workspacePath: options.workspacePath
|
|
4095
4512
|
});
|
|
4096
|
-
await
|
|
4513
|
+
await fs14.rm(gitPath, { recursive: true });
|
|
4097
4514
|
}
|
|
4098
4515
|
} catch (cleanupErr) {
|
|
4099
4516
|
if (!isNotFoundError(cleanupErr))
|
|
@@ -4119,7 +4536,7 @@ var GitManager = class _GitManager {
|
|
|
4119
4536
|
}
|
|
4120
4537
|
justCloned = true;
|
|
4121
4538
|
const freshGit = simpleGit2({ baseDir: options.workspacePath });
|
|
4122
|
-
freshGit.env("GIT_CEILING_DIRECTORIES",
|
|
4539
|
+
freshGit.env("GIT_CEILING_DIRECTORIES", path14.dirname(options.workspacePath));
|
|
4123
4540
|
await freshGit.addConfig("canopycms.managed", "true");
|
|
4124
4541
|
await freshGit.addConfig("user.name", options.gitBotAuthorName);
|
|
4125
4542
|
await freshGit.addConfig("user.email", options.gitBotAuthorEmail);
|
|
@@ -4133,7 +4550,7 @@ var GitManager = class _GitManager {
|
|
|
4133
4550
|
await git.git.addConfig("canopycms.managed", "true");
|
|
4134
4551
|
await git.git.addConfig("user.name", options.gitBotAuthorName);
|
|
4135
4552
|
await git.git.addConfig("user.email", options.gitBotAuthorEmail);
|
|
4136
|
-
|
|
4553
|
+
log5.debug("git", "Marked workspace as CanopyCMS-managed", {
|
|
4137
4554
|
workspacePath: options.workspacePath
|
|
4138
4555
|
});
|
|
4139
4556
|
if (!justCloned) {
|
|
@@ -4169,23 +4586,28 @@ var GitManager = class _GitManager {
|
|
|
4169
4586
|
};
|
|
4170
4587
|
}
|
|
4171
4588
|
/**
|
|
4172
|
-
* Mark ContentStore ID indexes rooted at (or
|
|
4173
|
-
* Called after operations that mutate the
|
|
4174
|
-
* ID→path lookups don't keep
|
|
4175
|
-
*
|
|
4176
|
-
*
|
|
4589
|
+
* Mark ContentStore ID indexes AND the resolved-schema cache rooted at (or
|
|
4590
|
+
* under) this repo as stale. Called after operations that mutate the
|
|
4591
|
+
* working tree (checkout/merge/rebase) so ID→path lookups don't keep
|
|
4592
|
+
* resolving to pre-mutation paths, and so a rebase/checkout that pulled in
|
|
4593
|
+
* upstream `.collection.json` changes doesn't leave the schema cache
|
|
4594
|
+
* pinned to the pre-mutation schema. Invoked in `finally` blocks because
|
|
4595
|
+
* even failed merges/rebases may have touched the tree before aborting;
|
|
4596
|
+
* over-invalidating is safe.
|
|
4177
4597
|
*
|
|
4178
|
-
* Covers both scopes: in-process stores via
|
|
4179
|
-
* processes sharing the filesystem (worker vs Lambda on
|
|
4180
|
-
* generation
|
|
4181
|
-
* (skipIndexMarker), where only the free in-process
|
|
4598
|
+
* Covers both scopes: in-process stores/caches via their registries, and
|
|
4599
|
+
* consumers in OTHER processes sharing the filesystem (worker vs Lambda on
|
|
4600
|
+
* EFS) via the on-disk generation markers — unless this manager targets a
|
|
4601
|
+
* settings workspace (skipIndexMarker), where only the free in-process
|
|
4602
|
+
* content-index invalidation runs and NEITHER marker is bumped (settings
|
|
4603
|
+
* workspaces have no schema cache of their own either).
|
|
4182
4604
|
*/
|
|
4183
4605
|
async invalidateContentIndexes() {
|
|
4184
4606
|
if (this.skipIndexMarker) {
|
|
4185
4607
|
invalidateContentIndexesForRoot(this.repoPath);
|
|
4186
4608
|
return;
|
|
4187
4609
|
}
|
|
4188
|
-
await
|
|
4610
|
+
await invalidateBranchContentCaches(this.repoPath);
|
|
4189
4611
|
}
|
|
4190
4612
|
async checkoutBranch(branch) {
|
|
4191
4613
|
try {
|
|
@@ -4411,10 +4833,10 @@ var GitManager = class _GitManager {
|
|
|
4411
4833
|
}
|
|
4412
4834
|
}
|
|
4413
4835
|
async createOrphanSettingsBranchInner(branchName, initialFiles) {
|
|
4414
|
-
|
|
4836
|
+
log5.debug("git", "Creating orphan settings branch", { branchName });
|
|
4415
4837
|
const branches = await this.git.branch();
|
|
4416
4838
|
if (branches.all.includes(branchName)) {
|
|
4417
|
-
|
|
4839
|
+
log5.debug("git", "Orphan branch already exists", { branchName });
|
|
4418
4840
|
await this.git.checkout(branchName);
|
|
4419
4841
|
return;
|
|
4420
4842
|
}
|
|
@@ -4424,25 +4846,25 @@ var GitManager = class _GitManager {
|
|
|
4424
4846
|
} catch {
|
|
4425
4847
|
}
|
|
4426
4848
|
for (const [filePath, content] of Object.entries(initialFiles)) {
|
|
4427
|
-
const absolutePath =
|
|
4428
|
-
await
|
|
4429
|
-
await
|
|
4849
|
+
const absolutePath = path14.join(this.repoPath, filePath);
|
|
4850
|
+
await fs14.mkdir(path14.dirname(absolutePath), { recursive: true });
|
|
4851
|
+
await fs14.writeFile(absolutePath, content, "utf-8");
|
|
4430
4852
|
await this.git.add(filePath);
|
|
4431
4853
|
}
|
|
4432
4854
|
await this.git.commit("Initialize settings branch", ["--allow-empty"]);
|
|
4433
|
-
|
|
4855
|
+
log5.debug("git", "Orphan settings branch created", { branchName });
|
|
4434
4856
|
}
|
|
4435
4857
|
};
|
|
4436
4858
|
|
|
4437
4859
|
// dist/branch-workspace.js
|
|
4438
|
-
var
|
|
4860
|
+
var log6 = createDebugLogger({ prefix: "BranchWorkspace" });
|
|
4439
4861
|
var workspaceInitLocks = /* @__PURE__ */ new Map();
|
|
4440
4862
|
var BranchWorkspaceManager = class {
|
|
4441
4863
|
constructor(config) {
|
|
4442
4864
|
this.config = config;
|
|
4443
4865
|
}
|
|
4444
4866
|
async ensureGitWorkspace(options) {
|
|
4445
|
-
return
|
|
4867
|
+
return log6.timed("workspace", "ensureGitWorkspace", async () => {
|
|
4446
4868
|
const existingLock = workspaceInitLocks.get(options.branchRoot);
|
|
4447
4869
|
if (existingLock) {
|
|
4448
4870
|
await existingLock;
|
|
@@ -4451,11 +4873,11 @@ var BranchWorkspaceManager = class {
|
|
|
4451
4873
|
const lockPromise = (async () => {
|
|
4452
4874
|
let releaseLock;
|
|
4453
4875
|
try {
|
|
4454
|
-
|
|
4876
|
+
log6.debug("workspace", "Ensuring git workspace", {
|
|
4455
4877
|
branchName: options.branchName,
|
|
4456
4878
|
mode: options.mode
|
|
4457
4879
|
});
|
|
4458
|
-
releaseLock = await acquireProvisioningLock(
|
|
4880
|
+
releaseLock = await acquireProvisioningLock(path15.dirname(options.branchRoot), `.${path15.basename(options.branchRoot)}.init.lock`);
|
|
4459
4881
|
await GitManager.initializeWorkspace({
|
|
4460
4882
|
workspacePath: options.branchRoot,
|
|
4461
4883
|
branchName: options.branchName,
|
|
@@ -4475,7 +4897,7 @@ var BranchWorkspaceManager = class {
|
|
|
4475
4897
|
try {
|
|
4476
4898
|
await releaseLock();
|
|
4477
4899
|
} catch (err) {
|
|
4478
|
-
|
|
4900
|
+
log6.debug("workspace", "Failed to release workspace-init lock", { err });
|
|
4479
4901
|
}
|
|
4480
4902
|
}
|
|
4481
4903
|
workspaceInitLocks.delete(options.branchRoot);
|
|
@@ -4495,7 +4917,7 @@ var BranchWorkspaceManager = class {
|
|
|
4495
4917
|
const baseBranch = await resolveBaseBranch({
|
|
4496
4918
|
defaultBaseBranch: this.config.defaultBaseBranch,
|
|
4497
4919
|
mode,
|
|
4498
|
-
detectFrom: this.config.sourceRoot ?
|
|
4920
|
+
detectFrom: this.config.sourceRoot ? path15.resolve(process.cwd(), this.config.sourceRoot) : void 0
|
|
4499
4921
|
});
|
|
4500
4922
|
await this.ensureGitWorkspace({
|
|
4501
4923
|
branchRoot,
|
|
@@ -4578,6 +5000,384 @@ async function resolveBranchRoot(config) {
|
|
|
4578
5000
|
return context.branchRoot;
|
|
4579
5001
|
}
|
|
4580
5002
|
|
|
5003
|
+
// dist/content-listing.js
|
|
5004
|
+
import fs15 from "node:fs/promises";
|
|
5005
|
+
import path16 from "node:path";
|
|
5006
|
+
import matter2 from "gray-matter";
|
|
5007
|
+
import { parse as yamlParse2 } from "yaml";
|
|
5008
|
+
var log7 = createDebugLogger({ prefix: "ContentListing" });
|
|
5009
|
+
var normalizePath = (root, target) => {
|
|
5010
|
+
const result = validateAndNormalizePath(root, target);
|
|
5011
|
+
if (!result.valid) {
|
|
5012
|
+
throw new ContentStoreError(result.error || "Path traversal detected", "VALIDATION");
|
|
5013
|
+
}
|
|
5014
|
+
return result.normalizedPath;
|
|
5015
|
+
};
|
|
5016
|
+
var readEntryData = async (filePath, format, bodyFieldName = "body") => {
|
|
5017
|
+
try {
|
|
5018
|
+
const raw = await fs15.readFile(filePath, "utf8");
|
|
5019
|
+
if (format === "json") {
|
|
5020
|
+
return asRecord(JSON.parse(raw));
|
|
5021
|
+
}
|
|
5022
|
+
if (format === "yaml") {
|
|
5023
|
+
return asRecord(yamlParse2(raw));
|
|
5024
|
+
}
|
|
5025
|
+
const parsed = matter2(raw);
|
|
5026
|
+
const data = parsed.data ?? {};
|
|
5027
|
+
if (parsed.content) {
|
|
5028
|
+
data[bodyFieldName] = parsed.content;
|
|
5029
|
+
}
|
|
5030
|
+
return data;
|
|
5031
|
+
} catch (err) {
|
|
5032
|
+
if (isNotFoundError(err))
|
|
5033
|
+
return {};
|
|
5034
|
+
log7.warn("readEntryData", `Failed to read entry data from ${filePath}: ${getErrorMessage(err)}`);
|
|
5035
|
+
return {};
|
|
5036
|
+
}
|
|
5037
|
+
};
|
|
5038
|
+
var parseTypedFilename = (filename, entryTypes) => {
|
|
5039
|
+
const lastDot = filename.lastIndexOf(".");
|
|
5040
|
+
if (lastDot === -1)
|
|
5041
|
+
return null;
|
|
5042
|
+
const nameWithoutExt = filename.slice(0, lastDot);
|
|
5043
|
+
const parts = nameWithoutExt.split(".");
|
|
5044
|
+
if (parts.length >= 3) {
|
|
5045
|
+
const potentialType = parts[0];
|
|
5046
|
+
const matchingType = entryTypes.find((e) => e.name === potentialType);
|
|
5047
|
+
if (matchingType) {
|
|
5048
|
+
const id = parts[parts.length - 1];
|
|
5049
|
+
if (!isValidId(id))
|
|
5050
|
+
return null;
|
|
5051
|
+
const slug = parts.slice(1, -1).join(".").toLowerCase();
|
|
5052
|
+
return {
|
|
5053
|
+
type: potentialType,
|
|
5054
|
+
slug,
|
|
5055
|
+
id
|
|
5056
|
+
};
|
|
5057
|
+
}
|
|
5058
|
+
}
|
|
5059
|
+
return null;
|
|
5060
|
+
};
|
|
5061
|
+
async function listEntries(branchRoot, flatSchema, contentRootName, options) {
|
|
5062
|
+
const rootPath = options?.rootPath ?? contentRootName;
|
|
5063
|
+
const extract = options?.extract;
|
|
5064
|
+
const filter = options?.filter;
|
|
5065
|
+
const customSort = options?.sort;
|
|
5066
|
+
const collections = flatSchema.filter((item) => item.type === "collection" && item.entries !== void 0 && (item.logicalPath === rootPath || item.logicalPath.startsWith(`${rootPath}/`)));
|
|
5067
|
+
const collectionResults = await Promise.all(collections.map(async (collection) => {
|
|
5068
|
+
const entries = await listCollectionEntries(branchRoot, collection);
|
|
5069
|
+
return entries.map((entry) => ({ entry, collection }));
|
|
5070
|
+
}));
|
|
5071
|
+
const contentPrefix = contentRootName ? `${contentRootName}/` : "";
|
|
5072
|
+
const items = [];
|
|
5073
|
+
for (const results of collectionResults) {
|
|
5074
|
+
for (const { entry, collection } of results) {
|
|
5075
|
+
const pathWithoutRoot = entry.logicalPath.startsWith(contentPrefix) ? entry.logicalPath.slice(contentPrefix.length) : entry.logicalPath;
|
|
5076
|
+
const pathSegments = pathWithoutRoot.split("/").filter(Boolean);
|
|
5077
|
+
const urlSegments = entry.slug === "index" ? pathSegments.slice(0, -1) : pathSegments;
|
|
5078
|
+
const urlPath = urlSegments.length > 0 ? `/${urlSegments.join("/")}`.toLowerCase() : "/";
|
|
5079
|
+
const raw = entry.data;
|
|
5080
|
+
const meta = {
|
|
5081
|
+
entryPath: entry.logicalPath,
|
|
5082
|
+
entryType: entry.entryType,
|
|
5083
|
+
format: entry.format
|
|
5084
|
+
};
|
|
5085
|
+
const data = extract ? extract(raw, meta) : raw;
|
|
5086
|
+
const item = {
|
|
5087
|
+
pathSegments,
|
|
5088
|
+
urlPath,
|
|
5089
|
+
slug: entry.slug,
|
|
5090
|
+
entryPath: entry.logicalPath,
|
|
5091
|
+
entryId: entry.contentId,
|
|
5092
|
+
collectionId: collection.contentId,
|
|
5093
|
+
collectionPath: entry.collectionPath,
|
|
5094
|
+
entryType: entry.entryType,
|
|
5095
|
+
format: entry.format,
|
|
5096
|
+
data,
|
|
5097
|
+
schema: collection.entries?.find((e) => e.name === entry.entryType)?.schema
|
|
5098
|
+
};
|
|
5099
|
+
if (filter && !filter(item))
|
|
5100
|
+
continue;
|
|
5101
|
+
items.push(item);
|
|
5102
|
+
}
|
|
5103
|
+
}
|
|
5104
|
+
if (customSort) {
|
|
5105
|
+
items.sort(customSort);
|
|
5106
|
+
} else {
|
|
5107
|
+
items.sort((a, b) => a.entryPath.localeCompare(b.entryPath));
|
|
5108
|
+
}
|
|
5109
|
+
return items;
|
|
5110
|
+
}
|
|
5111
|
+
var listCollectionEntries = async (root, collection) => {
|
|
5112
|
+
if (collection.type !== "collection" || !collection.entries) {
|
|
5113
|
+
return [];
|
|
5114
|
+
}
|
|
5115
|
+
const entryTypes = collection.entries;
|
|
5116
|
+
const extToTypes = /* @__PURE__ */ new Map();
|
|
5117
|
+
for (const entryType of entryTypes) {
|
|
5118
|
+
const ext = getFormatExtension(entryType.format);
|
|
5119
|
+
const existing = extToTypes.get(ext) || [];
|
|
5120
|
+
existing.push(entryType);
|
|
5121
|
+
extToTypes.set(ext, existing);
|
|
5122
|
+
}
|
|
5123
|
+
const validExts = Array.from(extToTypes.keys());
|
|
5124
|
+
const collectionRoot = await resolveCollectionPath(root, collection.logicalPath);
|
|
5125
|
+
if (!collectionRoot) {
|
|
5126
|
+
return [];
|
|
5127
|
+
}
|
|
5128
|
+
normalizePath(root, collectionRoot);
|
|
5129
|
+
let dirents;
|
|
5130
|
+
try {
|
|
5131
|
+
dirents = await fs15.readdir(collectionRoot, { withFileTypes: true });
|
|
5132
|
+
} catch (err) {
|
|
5133
|
+
if (isNotFoundError(err))
|
|
5134
|
+
return [];
|
|
5135
|
+
throw err;
|
|
5136
|
+
}
|
|
5137
|
+
const files = dirents.filter((d) => d.isFile() && validExts.some((ext) => d.name.endsWith(ext)) && d.name !== ".collection.json").sort((a, b) => a.name.localeCompare(b.name));
|
|
5138
|
+
const entries = await Promise.all(files.map(async (file) => {
|
|
5139
|
+
const absolutePath = path16.join(collectionRoot, file.name);
|
|
5140
|
+
const relativePath = normalizePath(root, absolutePath);
|
|
5141
|
+
const parsed = parseTypedFilename(file.name, entryTypes);
|
|
5142
|
+
if (!parsed) {
|
|
5143
|
+
log7.warn("listCollectionEntries", `Skipping file with unrecognized filename format: ${file.name} (expected {type}.{slug}.{id}.{ext} with a known entry type and valid 12-char Base58 ID)`);
|
|
5144
|
+
return null;
|
|
5145
|
+
}
|
|
5146
|
+
const { type: entryTypeName, slug, id: contentId } = parsed;
|
|
5147
|
+
const entryType = entryTypes.find((e) => e.name === entryTypeName);
|
|
5148
|
+
const format = entryType?.format || "json";
|
|
5149
|
+
const bodyField = entryType?.schema ? findBodyFieldName(entryType.schema) : "body";
|
|
5150
|
+
const [stats, data] = await Promise.all([
|
|
5151
|
+
fs15.stat(absolutePath),
|
|
5152
|
+
readEntryData(absolutePath, format, bodyField)
|
|
5153
|
+
]);
|
|
5154
|
+
const item = {
|
|
5155
|
+
logicalPath: `${collection.logicalPath}/${slug}`,
|
|
5156
|
+
contentId,
|
|
5157
|
+
slug,
|
|
5158
|
+
collectionPath: collection.logicalPath,
|
|
5159
|
+
collectionName: collection.name,
|
|
5160
|
+
format,
|
|
5161
|
+
entryType: entryTypeName,
|
|
5162
|
+
physicalPath: relativePath,
|
|
5163
|
+
data,
|
|
5164
|
+
updatedAt: stats.mtime.toISOString()
|
|
5165
|
+
};
|
|
5166
|
+
return item;
|
|
5167
|
+
}));
|
|
5168
|
+
return entries.filter((e) => e !== null);
|
|
5169
|
+
};
|
|
5170
|
+
|
|
5171
|
+
// dist/validation/entry-validator.js
|
|
5172
|
+
init_config2();
|
|
5173
|
+
|
|
5174
|
+
// dist/validation/field-traversal.js
|
|
5175
|
+
function isPlainRecord(value) {
|
|
5176
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5177
|
+
}
|
|
5178
|
+
function resolveBlockItem(blockField, item) {
|
|
5179
|
+
const templateName = typeof item.template === "string" ? item.template : typeof item._type === "string" ? item._type : void 0;
|
|
5180
|
+
if (!templateName)
|
|
5181
|
+
return void 0;
|
|
5182
|
+
const template = blockField.templates?.find((t) => t.name === templateName);
|
|
5183
|
+
if (!template?.fields)
|
|
5184
|
+
return void 0;
|
|
5185
|
+
const data = isPlainRecord(item.value) ? item.value : item;
|
|
5186
|
+
return { fields: template.fields, data };
|
|
5187
|
+
}
|
|
5188
|
+
|
|
5189
|
+
// dist/validation/entry-validator.js
|
|
5190
|
+
var KNOWN_FIELD_TYPES = new Set(fieldTypes);
|
|
5191
|
+
var STRING_FIELD_TYPES = /* @__PURE__ */ new Set(["string", "rich-text", "markdown", "mdx", "image", "code"]);
|
|
5192
|
+
function isPlainRecord2(value) {
|
|
5193
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5194
|
+
}
|
|
5195
|
+
function referenceValueId(value) {
|
|
5196
|
+
if (typeof value === "string")
|
|
5197
|
+
return value;
|
|
5198
|
+
if (isPlainRecord2(value) && typeof value.id === "string")
|
|
5199
|
+
return value.id;
|
|
5200
|
+
return void 0;
|
|
5201
|
+
}
|
|
5202
|
+
var selectOptionValues = (field) => (field.options ?? []).map((opt) => typeof opt === "string" ? opt : opt.value);
|
|
5203
|
+
function validateScalar(field, value, path19) {
|
|
5204
|
+
switch (field.type) {
|
|
5205
|
+
case "number":
|
|
5206
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
5207
|
+
return [{ fieldPath: path19, message: "Expected a number" }];
|
|
5208
|
+
}
|
|
5209
|
+
return [];
|
|
5210
|
+
case "boolean":
|
|
5211
|
+
if (typeof value !== "boolean") {
|
|
5212
|
+
return [{ fieldPath: path19, message: "Expected true or false" }];
|
|
5213
|
+
}
|
|
5214
|
+
return [];
|
|
5215
|
+
case "datetime":
|
|
5216
|
+
if (typeof value !== "string" || value !== "" && Number.isNaN(Date.parse(value))) {
|
|
5217
|
+
return [{ fieldPath: path19, message: "Expected a valid date/time string" }];
|
|
5218
|
+
}
|
|
5219
|
+
return [];
|
|
5220
|
+
case "select": {
|
|
5221
|
+
if (typeof value !== "string") {
|
|
5222
|
+
return [{ fieldPath: path19, message: "Expected a selection" }];
|
|
5223
|
+
}
|
|
5224
|
+
const options = selectOptionValues(field);
|
|
5225
|
+
if (value !== "" && !options.includes(value)) {
|
|
5226
|
+
return [{ fieldPath: path19, message: `Must be one of: ${options.join(", ")}` }];
|
|
5227
|
+
}
|
|
5228
|
+
return [];
|
|
5229
|
+
}
|
|
5230
|
+
case "reference":
|
|
5231
|
+
if (referenceValueId(value) === void 0) {
|
|
5232
|
+
return [{ fieldPath: path19, message: "Expected a reference id" }];
|
|
5233
|
+
}
|
|
5234
|
+
return [];
|
|
5235
|
+
default:
|
|
5236
|
+
if (STRING_FIELD_TYPES.has(field.type)) {
|
|
5237
|
+
if (typeof value !== "string") {
|
|
5238
|
+
return [{ fieldPath: path19, message: "Expected text" }];
|
|
5239
|
+
}
|
|
5240
|
+
}
|
|
5241
|
+
return [];
|
|
5242
|
+
}
|
|
5243
|
+
}
|
|
5244
|
+
function isEmptyForRequired(field, value) {
|
|
5245
|
+
if (value === void 0 || value === null)
|
|
5246
|
+
return true;
|
|
5247
|
+
if (Array.isArray(value))
|
|
5248
|
+
return value.length === 0;
|
|
5249
|
+
if (typeof value === "string") {
|
|
5250
|
+
return value.trim() === "";
|
|
5251
|
+
}
|
|
5252
|
+
return false;
|
|
5253
|
+
}
|
|
5254
|
+
function validateEntryData(fields, data, pathPrefix = "") {
|
|
5255
|
+
const errors = [];
|
|
5256
|
+
for (const field of fields) {
|
|
5257
|
+
if (field.type === "group") {
|
|
5258
|
+
errors.push(...validateEntryData(field.fields, data, pathPrefix));
|
|
5259
|
+
continue;
|
|
5260
|
+
}
|
|
5261
|
+
const path19 = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
|
|
5262
|
+
const value = data[field.name];
|
|
5263
|
+
const isRequired = "required" in field && field.required === true;
|
|
5264
|
+
if (isRequired && isEmptyForRequired(field, value)) {
|
|
5265
|
+
const message = Array.isArray(value) ? "At least one item is required" : "This field is required";
|
|
5266
|
+
errors.push({ fieldPath: path19, message });
|
|
5267
|
+
continue;
|
|
5268
|
+
}
|
|
5269
|
+
if (value === void 0 || value === null)
|
|
5270
|
+
continue;
|
|
5271
|
+
if (field.type === "block") {
|
|
5272
|
+
const blockField = field;
|
|
5273
|
+
if (!Array.isArray(value)) {
|
|
5274
|
+
errors.push({ fieldPath: path19, message: "Expected a list of blocks" });
|
|
5275
|
+
continue;
|
|
5276
|
+
}
|
|
5277
|
+
value.forEach((item, index) => {
|
|
5278
|
+
const itemPath = `${path19}[${index}]`;
|
|
5279
|
+
if (!isPlainRecord2(item)) {
|
|
5280
|
+
errors.push({ fieldPath: itemPath, message: "Expected a block object" });
|
|
5281
|
+
return;
|
|
5282
|
+
}
|
|
5283
|
+
const resolved = resolveBlockItem(blockField, item);
|
|
5284
|
+
if (!resolved) {
|
|
5285
|
+
const template = typeof item.template === "string" ? item.template : void 0;
|
|
5286
|
+
errors.push({
|
|
5287
|
+
fieldPath: itemPath,
|
|
5288
|
+
message: template ? `Unknown block template "${template}"` : "Missing block template"
|
|
5289
|
+
});
|
|
5290
|
+
return;
|
|
5291
|
+
}
|
|
5292
|
+
errors.push(...validateEntryData(resolved.fields, resolved.data, itemPath));
|
|
5293
|
+
});
|
|
5294
|
+
continue;
|
|
5295
|
+
}
|
|
5296
|
+
if (field.type === "object") {
|
|
5297
|
+
const objectField = field;
|
|
5298
|
+
if (objectField.list) {
|
|
5299
|
+
if (!Array.isArray(value)) {
|
|
5300
|
+
errors.push({ fieldPath: path19, message: "Expected a list of items" });
|
|
5301
|
+
continue;
|
|
5302
|
+
}
|
|
5303
|
+
value.forEach((item, index) => {
|
|
5304
|
+
const itemPath = `${path19}[${index}]`;
|
|
5305
|
+
if (!isPlainRecord2(item)) {
|
|
5306
|
+
errors.push({ fieldPath: itemPath, message: "Expected an object" });
|
|
5307
|
+
return;
|
|
5308
|
+
}
|
|
5309
|
+
if (objectField.fields) {
|
|
5310
|
+
errors.push(...validateEntryData(objectField.fields, item, itemPath));
|
|
5311
|
+
}
|
|
5312
|
+
});
|
|
5313
|
+
} else if (!isPlainRecord2(value)) {
|
|
5314
|
+
errors.push({ fieldPath: path19, message: "Expected an object" });
|
|
5315
|
+
} else if (objectField.fields) {
|
|
5316
|
+
errors.push(...validateEntryData(objectField.fields, value, path19));
|
|
5317
|
+
}
|
|
5318
|
+
continue;
|
|
5319
|
+
}
|
|
5320
|
+
if ("list" in field && field.list) {
|
|
5321
|
+
if (!Array.isArray(value)) {
|
|
5322
|
+
errors.push({ fieldPath: path19, message: "Expected a list of values" });
|
|
5323
|
+
continue;
|
|
5324
|
+
}
|
|
5325
|
+
value.forEach((item, index) => {
|
|
5326
|
+
errors.push(...validateScalar(field, item, `${path19}[${index}]`));
|
|
5327
|
+
});
|
|
5328
|
+
continue;
|
|
5329
|
+
}
|
|
5330
|
+
if (Array.isArray(value) && KNOWN_FIELD_TYPES.has(field.type)) {
|
|
5331
|
+
errors.push({ fieldPath: path19, message: "Expected a single value, not a list" });
|
|
5332
|
+
continue;
|
|
5333
|
+
}
|
|
5334
|
+
errors.push(...validateScalar(field, value, path19));
|
|
5335
|
+
}
|
|
5336
|
+
return errors;
|
|
5337
|
+
}
|
|
5338
|
+
|
|
5339
|
+
// dist/static/index.js
|
|
5340
|
+
function normalizeDatesDeep(value) {
|
|
5341
|
+
if (value instanceof Date)
|
|
5342
|
+
return value.toISOString();
|
|
5343
|
+
if (Array.isArray(value))
|
|
5344
|
+
return value.map(normalizeDatesDeep);
|
|
5345
|
+
if (value && typeof value === "object") {
|
|
5346
|
+
const result = {};
|
|
5347
|
+
for (const [key, v] of Object.entries(value)) {
|
|
5348
|
+
result[key] = normalizeDatesDeep(v);
|
|
5349
|
+
}
|
|
5350
|
+
return result;
|
|
5351
|
+
}
|
|
5352
|
+
return value;
|
|
5353
|
+
}
|
|
5354
|
+
function findInvalidEntries(items) {
|
|
5355
|
+
const invalid = [];
|
|
5356
|
+
for (const item of items) {
|
|
5357
|
+
if (!item.schema)
|
|
5358
|
+
continue;
|
|
5359
|
+
const data = normalizeDatesDeep(item.data);
|
|
5360
|
+
const errors = validateEntryData(item.schema, data);
|
|
5361
|
+
if (errors.length > 0) {
|
|
5362
|
+
invalid.push({ entryPath: item.entryPath, errors });
|
|
5363
|
+
}
|
|
5364
|
+
}
|
|
5365
|
+
return invalid;
|
|
5366
|
+
}
|
|
5367
|
+
function assertBuildEntriesValid(items, phaseLabel) {
|
|
5368
|
+
const invalid = findInvalidEntries(items);
|
|
5369
|
+
if (invalid.length === 0)
|
|
5370
|
+
return;
|
|
5371
|
+
const lines = invalid.map(({ entryPath, errors }) => {
|
|
5372
|
+
const [first, ...rest] = errors;
|
|
5373
|
+
const summary = rest.length > 0 ? `${first.fieldPath}: ${first.message} (+${rest.length} more)` : `${first.fieldPath}: ${first.message}`;
|
|
5374
|
+
return ` - ${entryPath} \u2014 ${summary}`;
|
|
5375
|
+
});
|
|
5376
|
+
throw new Error(`CanopyCMS static build: found ${invalid.length} schema-invalid ${invalid.length === 1 ? "entry" : "entries"} during ${phaseLabel}:
|
|
5377
|
+
${lines.join("\n")}
|
|
5378
|
+
These are likely abandoned create-scaffolds (empty entries left behind when a create was started but never finished). Finish editing the entry (fill in its required fields) or delete the abandoned draft, then rebuild.`);
|
|
5379
|
+
}
|
|
5380
|
+
|
|
4581
5381
|
// dist/build/generate-ai-content.js
|
|
4582
5382
|
async function generateAIContentFiles(options) {
|
|
4583
5383
|
const { config, entrySchemaRegistry, outputDir, aiConfig, _testFlatSchema } = options;
|
|
@@ -4591,6 +5391,8 @@ async function generateAIContentFiles(options) {
|
|
|
4591
5391
|
const cached = await schemaCache.getSchema(branchRoot, entrySchemaRegistry, contentRootName);
|
|
4592
5392
|
flatSchema = cached.flatSchema;
|
|
4593
5393
|
}
|
|
5394
|
+
const entriesForValidation = await listEntries(branchRoot, flatSchema, contentRootName);
|
|
5395
|
+
assertBuildEntriesValid(entriesForValidation, "AI content generation");
|
|
4594
5396
|
const store = new ContentStore(branchRoot, flatSchema);
|
|
4595
5397
|
const result = await generateAIContent({
|
|
4596
5398
|
store,
|
|
@@ -4599,15 +5401,15 @@ async function generateAIContentFiles(options) {
|
|
|
4599
5401
|
config: aiConfig,
|
|
4600
5402
|
entryLinkUrl: config.entryLinkUrl
|
|
4601
5403
|
});
|
|
4602
|
-
const absoluteOutputDir =
|
|
5404
|
+
const absoluteOutputDir = path17.resolve(outputDir) + path17.sep;
|
|
4603
5405
|
let fileCount = 0;
|
|
4604
5406
|
for (const [filePath, content] of result.files) {
|
|
4605
|
-
const absolutePath =
|
|
5407
|
+
const absolutePath = path17.resolve(path17.join(absoluteOutputDir, filePath));
|
|
4606
5408
|
if (!absolutePath.startsWith(absoluteOutputDir)) {
|
|
4607
5409
|
throw new Error(`Path traversal detected in AI content output: ${filePath}`);
|
|
4608
5410
|
}
|
|
4609
|
-
await
|
|
4610
|
-
await
|
|
5411
|
+
await fs16.mkdir(path17.dirname(absolutePath), { recursive: true });
|
|
5412
|
+
await fs16.writeFile(absolutePath, content, "utf-8");
|
|
4611
5413
|
fileCount++;
|
|
4612
5414
|
}
|
|
4613
5415
|
return { fileCount, outputDir: absoluteOutputDir };
|
|
@@ -4618,7 +5420,7 @@ var jiti = createJiti(import.meta.url);
|
|
|
4618
5420
|
async function generateAIContentCLI(options) {
|
|
4619
5421
|
const { projectDir, outputDir = "public/ai", configPath, appDir = "app" } = options;
|
|
4620
5422
|
console.log("\nCanopyCMS generate-ai-content\n");
|
|
4621
|
-
const canopyConfigPath =
|
|
5423
|
+
const canopyConfigPath = path18.join(projectDir, "canopycms.config.ts");
|
|
4622
5424
|
let canopyConfigModule;
|
|
4623
5425
|
try {
|
|
4624
5426
|
canopyConfigModule = await jiti.import(canopyConfigPath);
|
|
@@ -4629,7 +5431,7 @@ async function generateAIContentCLI(options) {
|
|
|
4629
5431
|
}
|
|
4630
5432
|
const configExport = canopyConfigModule.default ?? canopyConfigModule.config ?? canopyConfigModule;
|
|
4631
5433
|
const serverConfig = typeof configExport === "object" && configExport !== null && "server" in configExport ? configExport.server : configExport;
|
|
4632
|
-
const schemasPath =
|
|
5434
|
+
const schemasPath = path18.join(projectDir, appDir, "schemas.ts");
|
|
4633
5435
|
let entrySchemaRegistry = {};
|
|
4634
5436
|
try {
|
|
4635
5437
|
const schemasModule = await jiti.import(schemasPath);
|
|
@@ -4640,7 +5442,7 @@ async function generateAIContentCLI(options) {
|
|
|
4640
5442
|
let aiConfig;
|
|
4641
5443
|
if (configPath) {
|
|
4642
5444
|
try {
|
|
4643
|
-
const aiConfigModule = await jiti.import(
|
|
5445
|
+
const aiConfigModule = await jiti.import(path18.resolve(configPath));
|
|
4644
5446
|
aiConfig = aiConfigModule.aiContentConfig ?? aiConfigModule.default ?? aiConfigModule.config;
|
|
4645
5447
|
} catch (err) {
|
|
4646
5448
|
console.error(`Could not load AI config from ${configPath}`);
|
|
@@ -4657,7 +5459,7 @@ async function generateAIContentCLI(options) {
|
|
|
4657
5459
|
console.error("Make sure canopycms.config.ts uses defineCanopyConfig().");
|
|
4658
5460
|
process.exit(1);
|
|
4659
5461
|
}
|
|
4660
|
-
const resolvedOutput =
|
|
5462
|
+
const resolvedOutput = path18.resolve(projectDir, outputDir);
|
|
4661
5463
|
console.log(` Output: ${resolvedOutput}`);
|
|
4662
5464
|
console.log(` Mode: ${serverConfig.mode ?? "dev"}`);
|
|
4663
5465
|
const result = await generateAIContentFiles({
|