canopycms 0.0.56 → 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/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/cli/generate-ai-content.js +1561 -1159
- 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/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/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-store.d.ts +63 -0
- package/dist/content-store.d.ts.map +1 -1
- package/dist/content-store.js +421 -194
- package/dist/content-store.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/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/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.map +1 -1
- package/dist/worker/cms-worker.js +21 -3
- package/dist/worker/cms-worker.js.map +1 -1
- package/package.json +1 -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,7 +463,7 @@ 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,
|
|
@@ -362,15 +474,15 @@ var init_config = __esm({
|
|
|
362
474
|
mode: operatingModeSchema,
|
|
363
475
|
deployedAs: deployedAsSchema,
|
|
364
476
|
// Has .default('server'), so always present after validation
|
|
365
|
-
settingsBranch:
|
|
366
|
-
autoCreateSettingsPR:
|
|
477
|
+
settingsBranch: z5.string().optional(),
|
|
478
|
+
autoCreateSettingsPR: z5.boolean().optional(),
|
|
367
479
|
deploymentName: deploymentNameSchema.optional(),
|
|
368
480
|
contentRoot: contentRootSchema.default("content"),
|
|
369
481
|
sourceRoot: sourceRootSchema.optional(),
|
|
370
482
|
editor: editorConfigSchema.optional(),
|
|
371
|
-
authPlugin:
|
|
372
|
-
entryLinkUrl:
|
|
373
|
-
validateEntry:
|
|
483
|
+
authPlugin: z5.custom().optional(),
|
|
484
|
+
entryLinkUrl: z5.custom().optional(),
|
|
485
|
+
validateEntry: z5.custom().optional(),
|
|
374
486
|
dev: devConfigSchema.optional()
|
|
375
487
|
}).strict();
|
|
376
488
|
DEFAULT_PROD_WORKSPACE = "/mnt/efs/workspace";
|
|
@@ -378,17 +490,17 @@ var init_config = __esm({
|
|
|
378
490
|
});
|
|
379
491
|
|
|
380
492
|
// dist/config/schemas/permissions.js
|
|
381
|
-
import { z as
|
|
493
|
+
import { z as z6 } from "zod";
|
|
382
494
|
var permissionTargetSchema, pathPermissionSchema;
|
|
383
495
|
var init_permissions = __esm({
|
|
384
496
|
"dist/config/schemas/permissions.js"() {
|
|
385
497
|
"use strict";
|
|
386
|
-
permissionTargetSchema =
|
|
387
|
-
allowedUsers:
|
|
388
|
-
allowedGroups:
|
|
498
|
+
permissionTargetSchema = z6.object({
|
|
499
|
+
allowedUsers: z6.array(z6.string()).optional(),
|
|
500
|
+
allowedGroups: z6.array(z6.string()).optional()
|
|
389
501
|
});
|
|
390
|
-
pathPermissionSchema =
|
|
391
|
-
path:
|
|
502
|
+
pathPermissionSchema = z6.object({
|
|
503
|
+
path: z6.string().min(1),
|
|
392
504
|
read: permissionTargetSchema.optional(),
|
|
393
505
|
edit: permissionTargetSchema.optional(),
|
|
394
506
|
review: permissionTargetSchema.optional()
|
|
@@ -396,118 +508,6 @@ var init_permissions = __esm({
|
|
|
396
508
|
}
|
|
397
509
|
});
|
|
398
510
|
|
|
399
|
-
// dist/paths/types.js
|
|
400
|
-
var ROOT_COLLECTION_ID;
|
|
401
|
-
var init_types2 = __esm({
|
|
402
|
-
"dist/paths/types.js"() {
|
|
403
|
-
"use strict";
|
|
404
|
-
ROOT_COLLECTION_ID = "__rootcoll__";
|
|
405
|
-
}
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
// dist/config/flatten.js
|
|
409
|
-
import { join, normalize } from "pathe";
|
|
410
|
-
var normalizePathValue, flattenSchema;
|
|
411
|
-
var init_flatten = __esm({
|
|
412
|
-
"dist/config/flatten.js"() {
|
|
413
|
-
"use strict";
|
|
414
|
-
init_normalize();
|
|
415
|
-
init_types2();
|
|
416
|
-
normalizePathValue = (val) => normalize(val).split("/").filter(Boolean).join("/");
|
|
417
|
-
flattenSchema = (root, basePath = "") => {
|
|
418
|
-
const flat = [];
|
|
419
|
-
const base = normalizePathValue(basePath || "");
|
|
420
|
-
const walkCollection = (collection, parentPath) => {
|
|
421
|
-
const normalizedPath = normalizePathValue(collection.path);
|
|
422
|
-
let logicalPath;
|
|
423
|
-
if (base) {
|
|
424
|
-
logicalPath = join(base, normalizedPath);
|
|
425
|
-
} else {
|
|
426
|
-
logicalPath = normalizedPath;
|
|
427
|
-
}
|
|
428
|
-
const normalizedFull = normalizePathValue(logicalPath);
|
|
429
|
-
flat.push({
|
|
430
|
-
type: "collection",
|
|
431
|
-
logicalPath: createLogicalPath(normalizedFull),
|
|
432
|
-
name: collection.name,
|
|
433
|
-
label: collection.label,
|
|
434
|
-
description: collection.description,
|
|
435
|
-
contentId: collection.contentId,
|
|
436
|
-
parentPath: parentPath ? createLogicalPath(parentPath) : void 0,
|
|
437
|
-
entries: collection.entries,
|
|
438
|
-
collections: collection.collections,
|
|
439
|
-
order: collection.order
|
|
440
|
-
});
|
|
441
|
-
if (collection.entries) {
|
|
442
|
-
for (const entryType of collection.entries) {
|
|
443
|
-
const entryTypePath = join(normalizedFull, entryType.name);
|
|
444
|
-
flat.push({
|
|
445
|
-
type: "entry-type",
|
|
446
|
-
logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
|
|
447
|
-
name: entryType.name,
|
|
448
|
-
label: entryType.label,
|
|
449
|
-
description: entryType.description,
|
|
450
|
-
parentPath: createLogicalPath(normalizedFull),
|
|
451
|
-
format: entryType.format,
|
|
452
|
-
schema: entryType.schema,
|
|
453
|
-
schemaRef: entryType.schemaRef,
|
|
454
|
-
default: entryType.default,
|
|
455
|
-
maxItems: entryType.maxItems
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
if (collection.collections) {
|
|
460
|
-
for (const child of collection.collections) {
|
|
461
|
-
walkCollection(child, normalizedFull);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
};
|
|
465
|
-
if (base) {
|
|
466
|
-
flat.push({
|
|
467
|
-
type: "collection",
|
|
468
|
-
logicalPath: createLogicalPath(base),
|
|
469
|
-
name: base,
|
|
470
|
-
// Use base path as the name (e.g., 'content')
|
|
471
|
-
label: void 0,
|
|
472
|
-
// Root collection has no label
|
|
473
|
-
contentId: ROOT_COLLECTION_ID,
|
|
474
|
-
// Sentinel — root dir has no embedded ID
|
|
475
|
-
parentPath: void 0,
|
|
476
|
-
// No parent - this is the root
|
|
477
|
-
entries: root.entries,
|
|
478
|
-
collections: root.collections,
|
|
479
|
-
order: root.order
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
if (root.entries) {
|
|
483
|
-
for (const entryType of root.entries) {
|
|
484
|
-
const entryTypePath = base ? join(base, entryType.name) : entryType.name;
|
|
485
|
-
flat.push({
|
|
486
|
-
type: "entry-type",
|
|
487
|
-
logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
|
|
488
|
-
name: entryType.name,
|
|
489
|
-
label: entryType.label,
|
|
490
|
-
description: entryType.description,
|
|
491
|
-
parentPath: base ? createLogicalPath(base) : createLogicalPath(""),
|
|
492
|
-
// Now references the root collection (e.g., 'content')
|
|
493
|
-
format: entryType.format,
|
|
494
|
-
schema: entryType.schema,
|
|
495
|
-
schemaRef: entryType.schemaRef,
|
|
496
|
-
default: entryType.default,
|
|
497
|
-
maxItems: entryType.maxItems
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
if (root.collections) {
|
|
502
|
-
for (const collection of root.collections) {
|
|
503
|
-
walkCollection(collection, base || "");
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
return flat;
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
});
|
|
510
|
-
|
|
511
511
|
// dist/config/validation.js
|
|
512
512
|
var init_validation = __esm({
|
|
513
513
|
"dist/config/validation.js"() {
|
|
@@ -529,7 +529,7 @@ var init_helpers = __esm({
|
|
|
529
529
|
var init_config2 = __esm({
|
|
530
530
|
"dist/config/index.js"() {
|
|
531
531
|
"use strict";
|
|
532
|
-
|
|
532
|
+
init_types2();
|
|
533
533
|
init_config();
|
|
534
534
|
init_field();
|
|
535
535
|
init_collection();
|
|
@@ -542,7 +542,7 @@ var init_config2 = __esm({
|
|
|
542
542
|
});
|
|
543
543
|
|
|
544
544
|
// dist/operating-mode/client-unsafe-strategy.js
|
|
545
|
-
import
|
|
545
|
+
import path6 from "node:path";
|
|
546
546
|
function operatingStrategy(mode) {
|
|
547
547
|
const cached = strategyCache.get(mode);
|
|
548
548
|
if (cached)
|
|
@@ -579,32 +579,32 @@ var init_client_unsafe_strategy = __esm({
|
|
|
579
579
|
// - shouldCommit(), shouldPush()
|
|
580
580
|
// Add client-unsafe methods (use Node.js APIs)
|
|
581
581
|
getWorkspaceRoot(_sourceRoot) {
|
|
582
|
-
return
|
|
582
|
+
return path6.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE);
|
|
583
583
|
}
|
|
584
584
|
getContentRoot(sourceRoot) {
|
|
585
|
-
return
|
|
585
|
+
return path6.resolve(sourceRoot ?? process.cwd(), "content");
|
|
586
586
|
}
|
|
587
587
|
getContentBranchesRoot(sourceRoot) {
|
|
588
|
-
return
|
|
588
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
|
|
589
589
|
}
|
|
590
590
|
getContentBranchRoot(branchName, sourceRoot) {
|
|
591
|
-
return
|
|
591
|
+
return path6.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
|
|
592
592
|
}
|
|
593
593
|
getGitExcludePattern() {
|
|
594
594
|
return ".canopy-meta/";
|
|
595
595
|
}
|
|
596
596
|
getPermissionsFilePath(root) {
|
|
597
|
-
return
|
|
597
|
+
return path6.join(root, this.getPermissionsFileName());
|
|
598
598
|
}
|
|
599
599
|
getGroupsFilePath(root) {
|
|
600
|
-
return
|
|
600
|
+
return path6.join(root, this.getGroupsFileName());
|
|
601
601
|
}
|
|
602
602
|
getRemoteUrlConfig() {
|
|
603
603
|
return {
|
|
604
604
|
shouldAutoInitLocal: false,
|
|
605
605
|
defaultRemotePath: "",
|
|
606
606
|
envVarName: "CANOPYCMS_REMOTE_URL",
|
|
607
|
-
autoDetectRemotePath:
|
|
607
|
+
autoDetectRemotePath: path6.join(this.getWorkspaceRoot(), "remote.git")
|
|
608
608
|
};
|
|
609
609
|
}
|
|
610
610
|
requiresExistingRepo() {
|
|
@@ -617,7 +617,7 @@ var init_client_unsafe_strategy = __esm({
|
|
|
617
617
|
return `canopycms-settings-${deploymentName}`;
|
|
618
618
|
}
|
|
619
619
|
getSettingsRoot(sourceRoot) {
|
|
620
|
-
return
|
|
620
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "settings");
|
|
621
621
|
}
|
|
622
622
|
usesSeparateSettingsBranch() {
|
|
623
623
|
return true;
|
|
@@ -634,25 +634,25 @@ var init_client_unsafe_strategy = __esm({
|
|
|
634
634
|
DevStrategy = class extends DevClientSafeStrategy {
|
|
635
635
|
// Inherits client-safe methods from DevClientSafeStrategy
|
|
636
636
|
getWorkspaceRoot(sourceRoot) {
|
|
637
|
-
return
|
|
637
|
+
return path6.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
|
|
638
638
|
}
|
|
639
639
|
getContentRoot(sourceRoot) {
|
|
640
|
-
return
|
|
640
|
+
return path6.resolve(sourceRoot ?? process.cwd(), "content");
|
|
641
641
|
}
|
|
642
642
|
getContentBranchesRoot(sourceRoot) {
|
|
643
|
-
return
|
|
643
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
|
|
644
644
|
}
|
|
645
645
|
getContentBranchRoot(branchName, sourceRoot) {
|
|
646
|
-
return
|
|
646
|
+
return path6.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
|
|
647
647
|
}
|
|
648
648
|
getGitExcludePattern() {
|
|
649
649
|
return ".canopy-meta/";
|
|
650
650
|
}
|
|
651
651
|
getPermissionsFilePath(root) {
|
|
652
|
-
return
|
|
652
|
+
return path6.join(root, this.getPermissionsFileName());
|
|
653
653
|
}
|
|
654
654
|
getGroupsFilePath(root) {
|
|
655
|
-
return
|
|
655
|
+
return path6.join(root, this.getGroupsFileName());
|
|
656
656
|
}
|
|
657
657
|
getRemoteUrlConfig() {
|
|
658
658
|
return {
|
|
@@ -671,7 +671,7 @@ var init_client_unsafe_strategy = __esm({
|
|
|
671
671
|
return `canopycms-settings-${deploymentName}`;
|
|
672
672
|
}
|
|
673
673
|
getSettingsRoot(sourceRoot) {
|
|
674
|
-
return
|
|
674
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "settings");
|
|
675
675
|
}
|
|
676
676
|
usesSeparateSettingsBranch() {
|
|
677
677
|
return true;
|
|
@@ -703,17 +703,17 @@ var init_operating_mode = __esm({
|
|
|
703
703
|
});
|
|
704
704
|
|
|
705
705
|
// dist/cli/generate-ai-content.js
|
|
706
|
-
import
|
|
706
|
+
import path18 from "node:path";
|
|
707
707
|
import { createJiti } from "jiti";
|
|
708
708
|
|
|
709
709
|
// dist/build/generate-ai-content.js
|
|
710
|
-
import
|
|
711
|
-
import
|
|
710
|
+
import fs16 from "node:fs/promises";
|
|
711
|
+
import path17 from "node:path";
|
|
712
712
|
|
|
713
713
|
// dist/content-store.js
|
|
714
|
-
import
|
|
715
|
-
import
|
|
716
|
-
import matter from "gray-matter";
|
|
714
|
+
import fs8 from "node:fs/promises";
|
|
715
|
+
import path8 from "node:path";
|
|
716
|
+
import matter from "gray-matter";
|
|
717
717
|
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
718
718
|
|
|
719
719
|
// dist/utils/atomic-write.js
|
|
@@ -1077,13 +1077,13 @@ function extractIdFromFilename(filename) {
|
|
|
1077
1077
|
return null;
|
|
1078
1078
|
}
|
|
1079
1079
|
async function resolveCollectionPath(root, logicalPath) {
|
|
1080
|
-
const
|
|
1081
|
-
const
|
|
1080
|
+
const fs17 = await import("node:fs/promises");
|
|
1081
|
+
const path19 = await import("node:path");
|
|
1082
1082
|
const segments = logicalPath.split("/").filter(Boolean);
|
|
1083
1083
|
let currentPath = root;
|
|
1084
1084
|
for (const segment of segments) {
|
|
1085
1085
|
try {
|
|
1086
|
-
const entries = await
|
|
1086
|
+
const entries = await fs17.readdir(currentPath, { withFileTypes: true });
|
|
1087
1087
|
const matchingDir = entries.find((entry) => {
|
|
1088
1088
|
if (!entry.isDirectory())
|
|
1089
1089
|
return false;
|
|
@@ -1091,7 +1091,7 @@ async function resolveCollectionPath(root, logicalPath) {
|
|
|
1091
1091
|
return logicalName === segment.toLowerCase();
|
|
1092
1092
|
});
|
|
1093
1093
|
if (matchingDir) {
|
|
1094
|
-
currentPath =
|
|
1094
|
+
currentPath = path19.join(currentPath, matchingDir.name);
|
|
1095
1095
|
} else {
|
|
1096
1096
|
return null;
|
|
1097
1097
|
}
|
|
@@ -1182,7 +1182,7 @@ function invalidateContentIndexesForRoot(root) {
|
|
|
1182
1182
|
}
|
|
1183
1183
|
}
|
|
1184
1184
|
|
|
1185
|
-
// dist/
|
|
1185
|
+
// dist/resource-generation.js
|
|
1186
1186
|
import fs3 from "node:fs/promises";
|
|
1187
1187
|
import path4 from "node:path";
|
|
1188
1188
|
import { randomUUID } from "node:crypto";
|
|
@@ -1278,177 +1278,595 @@ var testLogger = createDebugLogger({
|
|
|
1278
1278
|
throwOnError: false
|
|
1279
1279
|
});
|
|
1280
1280
|
|
|
1281
|
-
// dist/
|
|
1282
|
-
var log = createDebugLogger({ prefix: "
|
|
1281
|
+
// dist/resource-generation.js
|
|
1282
|
+
var log = createDebugLogger({ prefix: "ResourceGeneration" });
|
|
1283
1283
|
var META_DIR = ".canopy-meta";
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
return path4.join(path4.resolve(root), META_DIR, GENERATION_FILE);
|
|
1284
|
+
function resourceGenerationPath(root, resource) {
|
|
1285
|
+
return path4.resolve(root, META_DIR, `${resource}.generation`);
|
|
1287
1286
|
}
|
|
1288
|
-
async function
|
|
1287
|
+
async function bumpResourceGeneration(root, resource, options) {
|
|
1289
1288
|
const token = randomUUID();
|
|
1290
1289
|
try {
|
|
1291
|
-
await atomicWriteFile(
|
|
1290
|
+
await atomicWriteFile(resourceGenerationPath(root, resource), token);
|
|
1292
1291
|
return token;
|
|
1293
1292
|
} catch (err) {
|
|
1294
|
-
log.warn("generation", `Failed to bump
|
|
1293
|
+
log.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
|
|
1295
1294
|
error: getErrorMessage(err)
|
|
1296
1295
|
});
|
|
1296
|
+
if (options?.mustSucceed)
|
|
1297
|
+
throw err;
|
|
1297
1298
|
return null;
|
|
1298
1299
|
}
|
|
1299
1300
|
}
|
|
1300
|
-
async function
|
|
1301
|
+
async function readResourceGeneration(root, resource) {
|
|
1301
1302
|
try {
|
|
1302
|
-
|
|
1303
|
+
const token = await fs3.readFile(resourceGenerationPath(root, resource), "utf-8");
|
|
1304
|
+
return { ok: true, token };
|
|
1303
1305
|
} catch (err) {
|
|
1304
1306
|
if (isNodeError(err) && err.code === "ENOENT")
|
|
1305
|
-
return null;
|
|
1306
|
-
log.warn("generation", `Failed to read
|
|
1307
|
+
return { ok: true, token: null };
|
|
1308
|
+
log.warn("generation", `Failed to read ${resource} generation for ${root}`, {
|
|
1307
1309
|
error: getErrorMessage(err)
|
|
1308
1310
|
});
|
|
1309
|
-
return
|
|
1311
|
+
return { ok: false };
|
|
1310
1312
|
}
|
|
1311
1313
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
invalidateContentIndexesForRoot(root);
|
|
1314
|
+
function isGenerationCurrent(snapshotToken, read) {
|
|
1315
|
+
return read.ok && read.token === snapshotToken;
|
|
1315
1316
|
}
|
|
1316
1317
|
|
|
1317
|
-
// dist/
|
|
1318
|
-
import
|
|
1319
|
-
|
|
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 = [];
|
|
1320
1358
|
try {
|
|
1321
|
-
await fs4.
|
|
1322
|
-
|
|
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;
|
|
1323
1392
|
} catch (err) {
|
|
1324
|
-
if (
|
|
1325
|
-
return
|
|
1393
|
+
if (err.code === "ENOENT") {
|
|
1394
|
+
return [];
|
|
1395
|
+
}
|
|
1326
1396
|
throw err;
|
|
1327
1397
|
}
|
|
1328
1398
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
const
|
|
1351
|
-
|
|
1352
|
-
|
|
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
|
+
}
|
|
1409
|
+
}
|
|
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
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
const collections = await scanForCollectionMeta(contentRoot);
|
|
1421
|
+
return { root, collections };
|
|
1422
|
+
}
|
|
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
|
+
}
|
|
1353
1429
|
return {
|
|
1354
|
-
|
|
1355
|
-
|
|
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
|
|
1356
1437
|
};
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
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;
|
|
1359
1446
|
return {
|
|
1360
|
-
|
|
1361
|
-
|
|
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 }
|
|
1362
1454
|
};
|
|
1363
1455
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
import path6 from "node:path";
|
|
1369
|
-
var BranchPathError = class extends Error {
|
|
1370
|
-
};
|
|
1371
|
-
function sanitizeBranchName(branchName) {
|
|
1372
|
-
const replaced = branchName.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
1373
|
-
const squashed = replaced.replace(/-+/g, "-");
|
|
1374
|
-
const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
|
|
1375
|
-
return trimmedDots || "branch";
|
|
1376
|
-
}
|
|
1377
|
-
var resolveContentBranchesRoot = (mode, override) => {
|
|
1378
|
-
return operatingStrategy(mode).getContentBranchesRoot(override);
|
|
1379
|
-
};
|
|
1380
|
-
function resolveBranchPath(options) {
|
|
1381
|
-
if (options.branchName.includes("..")) {
|
|
1382
|
-
throw new BranchPathError("Branch name cannot contain traversal segments");
|
|
1456
|
+
function resolveCollectionReferences(metaFiles, entrySchemaRegistry) {
|
|
1457
|
+
const result = {};
|
|
1458
|
+
if (metaFiles.root?.label) {
|
|
1459
|
+
result.label = metaFiles.root.label;
|
|
1383
1460
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
const
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
};
|
|
1394
|
-
if (!withinBase(branchRoot)) {
|
|
1395
|
-
throw new BranchPathError("Branch path resolves outside the base root");
|
|
1461
|
+
if (metaFiles.root?.entries && metaFiles.root.entries.length > 0) {
|
|
1462
|
+
result.entries = resolveEntryTypes(metaFiles.root.entries, entrySchemaRegistry, "root collection");
|
|
1463
|
+
}
|
|
1464
|
+
if (metaFiles.root?.order) {
|
|
1465
|
+
result.order = metaFiles.root.order;
|
|
1466
|
+
}
|
|
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));
|
|
1396
1470
|
}
|
|
1397
|
-
return { branchRoot, baseRoot: normalizedBase, branchName: safeBranch };
|
|
1398
|
-
}
|
|
1399
|
-
async function ensureBranchRoot(options) {
|
|
1400
|
-
const result = resolveBranchPath(options);
|
|
1401
|
-
await fs5.mkdir(result.branchRoot, { recursive: true });
|
|
1402
1471
|
return result;
|
|
1403
1472
|
}
|
|
1404
1473
|
|
|
1405
|
-
// dist/
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
+
});
|
|
1410
1484
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1485
|
+
for (const collection of metaFiles.collections) {
|
|
1486
|
+
sources.push({
|
|
1487
|
+
path: `${collection.path}/.collection.json`,
|
|
1488
|
+
type: "collection",
|
|
1489
|
+
collections: [collection.name]
|
|
1490
|
+
});
|
|
1416
1491
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
if (!entries || entries.length === 0)
|
|
1420
|
-
return void 0;
|
|
1421
|
-
return entries.find((e) => e.default) || entries[0];
|
|
1492
|
+
const schema = resolveCollectionReferences(metaFiles, entrySchemaRegistry);
|
|
1493
|
+
return { schema, sources };
|
|
1422
1494
|
}
|
|
1423
|
-
function
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
if (slug.includes("\\")) {
|
|
1428
|
-
throw new ContentStoreError("Slugs cannot contain backslashes. Use nested collections instead.", "VALIDATION");
|
|
1429
|
-
}
|
|
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;
|
|
1430
1499
|
}
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return false;
|
|
1547
|
+
}
|
|
1548
|
+
var BranchSchemaCache = class {
|
|
1549
|
+
constructor(mode = "prod") {
|
|
1550
|
+
this.lastMtimeCheck = /* @__PURE__ */ new Map();
|
|
1551
|
+
this.devMode = mode === "dev";
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
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.
|
|
1561
|
+
*/
|
|
1562
|
+
skipDiskCache(branchRoot) {
|
|
1563
|
+
return isBuildMode() || path5.resolve(branchRoot) === path5.resolve(process.cwd());
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
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
|
|
1572
|
+
*/
|
|
1573
|
+
async getSchema(branchRoot, entrySchemaRegistry, contentRootName = "content") {
|
|
1574
|
+
return this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName);
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
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.
|
|
1581
|
+
*/
|
|
1582
|
+
async resolveFresh(contentRoot, entrySchemaRegistry) {
|
|
1583
|
+
return resolveSchema(contentRoot, entrySchemaRegistry);
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Load schema from cache or resolve fresh if cache is missing or stale.
|
|
1587
|
+
*/
|
|
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;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
return this.resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, {
|
|
1622
|
+
skipDiskCache
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
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}.
|
|
1630
|
+
*/
|
|
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.`);
|
|
1638
|
+
}
|
|
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
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
return { schema: result.schema, flatSchema };
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
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.
|
|
1696
|
+
*
|
|
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).
|
|
1700
|
+
*
|
|
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.
|
|
1705
|
+
*
|
|
1706
|
+
* @param branchRoot - Root directory of the branch
|
|
1707
|
+
*/
|
|
1708
|
+
async invalidate(branchRoot) {
|
|
1709
|
+
if (this.skipDiskCache(branchRoot))
|
|
1710
|
+
return;
|
|
1711
|
+
await bumpResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE, { mustSucceed: true });
|
|
1712
|
+
}
|
|
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;
|
|
1740
|
+
}
|
|
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
|
+
};
|
|
1770
|
+
}
|
|
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");
|
|
1796
|
+
}
|
|
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
|
|
1452
1870
|
* working tree underneath this store — git checkout/merge/rebase, the worker's
|
|
1453
1871
|
* rebase loop, and sync-core's content replacement. Without this, ID→path lookups
|
|
1454
1872
|
* keep resolving to pre-mutation paths and saves can target moved/deleted files.
|
|
@@ -1554,7 +1972,7 @@ var ContentStore = class {
|
|
|
1554
1972
|
async findEntryPathById(dir, id) {
|
|
1555
1973
|
let entries;
|
|
1556
1974
|
try {
|
|
1557
|
-
entries = await
|
|
1975
|
+
entries = await fs8.readdir(dir, { withFileTypes: true });
|
|
1558
1976
|
} catch (err) {
|
|
1559
1977
|
if (isNodeError(err) && err.code === "ENOENT")
|
|
1560
1978
|
return null;
|
|
@@ -1564,7 +1982,7 @@ var ContentStore = class {
|
|
|
1564
1982
|
if (entry.isDirectory())
|
|
1565
1983
|
continue;
|
|
1566
1984
|
if (extractIdFromFilename(entry.name) === id) {
|
|
1567
|
-
return
|
|
1985
|
+
return path8.relative(this.root, path8.join(dir, entry.name));
|
|
1568
1986
|
}
|
|
1569
1987
|
}
|
|
1570
1988
|
return null;
|
|
@@ -1576,11 +1994,11 @@ var ContentStore = class {
|
|
|
1576
1994
|
getSchemaItems() {
|
|
1577
1995
|
return this.schemaIndex.values();
|
|
1578
1996
|
}
|
|
1579
|
-
assertSchemaItem(
|
|
1580
|
-
const normalized = normalizeFilesystemPath(
|
|
1997
|
+
assertSchemaItem(path19) {
|
|
1998
|
+
const normalized = normalizeFilesystemPath(path19);
|
|
1581
1999
|
const item = this.schemaIndex.get(normalized);
|
|
1582
2000
|
if (!item) {
|
|
1583
|
-
throw new ContentStoreError(`Unknown schema item: ${
|
|
2001
|
+
throw new ContentStoreError(`Unknown schema item: ${path19}`, "NO_SCHEMA_ITEM");
|
|
1584
2002
|
}
|
|
1585
2003
|
return item;
|
|
1586
2004
|
}
|
|
@@ -1591,6 +2009,62 @@ var ContentStore = class {
|
|
|
1591
2009
|
}
|
|
1592
2010
|
return item;
|
|
1593
2011
|
}
|
|
2012
|
+
/**
|
|
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).
|
|
2022
|
+
*/
|
|
2023
|
+
idLockKey(id) {
|
|
2024
|
+
return `${this.root}:id:${id}`;
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
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).
|
|
2032
|
+
*/
|
|
2033
|
+
collectionSlugKey(schemaItem, slug) {
|
|
2034
|
+
if (schemaItem.type === "entry-type") {
|
|
2035
|
+
return `${schemaItem.parentPath}/${slug || schemaItem.name}`;
|
|
2036
|
+
}
|
|
2037
|
+
return `${schemaItem.logicalPath}/${slug}`;
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
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.
|
|
2050
|
+
*/
|
|
2051
|
+
createLockKey(schemaItem, slug) {
|
|
2052
|
+
return `${this.root}:create:${this.collectionSlugKey(schemaItem, slug)}`;
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
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()).
|
|
2062
|
+
*/
|
|
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
|
+
}
|
|
1594
2068
|
/**
|
|
1595
2069
|
* Build absolute and relative paths with security validation.
|
|
1596
2070
|
* All entries use the unified filename pattern: {type}.{slug}.{id}.{ext}
|
|
@@ -1607,7 +2081,7 @@ var ContentStore = class {
|
|
|
1607
2081
|
* @param options.entryTypeName - For collections with multiple entry types, specify which one to use. Defaults to the default entry type.
|
|
1608
2082
|
*/
|
|
1609
2083
|
async buildPaths(schemaItem, slug, options = {}) {
|
|
1610
|
-
const rootWithSep = this.root.endsWith(
|
|
2084
|
+
const rootWithSep = this.root.endsWith(path8.sep) ? this.root : `${this.root}${path8.sep}`;
|
|
1611
2085
|
if (schemaItem.type === "entry-type") {
|
|
1612
2086
|
const parentPath = schemaItem.parentPath || "";
|
|
1613
2087
|
const parentCollection = this.schemaIndex.get(parentPath);
|
|
@@ -1640,7 +2114,7 @@ var ContentStore = class {
|
|
|
1640
2114
|
const entryTypeName = entryTypeConfig?.name || "entry";
|
|
1641
2115
|
let collectionRoot = await resolveCollectionPath(this.root, schemaItem.logicalPath);
|
|
1642
2116
|
if (!collectionRoot) {
|
|
1643
|
-
collectionRoot =
|
|
2117
|
+
collectionRoot = path8.resolve(this.root, schemaItem.logicalPath);
|
|
1644
2118
|
}
|
|
1645
2119
|
if (!collectionRoot.startsWith(rootWithSep)) {
|
|
1646
2120
|
throw new ContentStoreError("Path traversal detected", "VALIDATION");
|
|
@@ -1648,8 +2122,9 @@ var ContentStore = class {
|
|
|
1648
2122
|
let id = options.existingId;
|
|
1649
2123
|
let existingFilename;
|
|
1650
2124
|
let existingEntryType;
|
|
2125
|
+
let foundExisting = false;
|
|
1651
2126
|
if (!id) {
|
|
1652
|
-
const entries = await
|
|
2127
|
+
const entries = await fs8.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
1653
2128
|
const existingFile = entries.find((entry) => {
|
|
1654
2129
|
if (entry.isDirectory())
|
|
1655
2130
|
return false;
|
|
@@ -1661,8 +2136,10 @@ var ContentStore = class {
|
|
|
1661
2136
|
id = extractIdFromFilename(existingFile.name) || void 0;
|
|
1662
2137
|
existingFilename = existingFile.name;
|
|
1663
2138
|
existingEntryType = extractEntryTypeFromFilename(existingFile.name) || void 0;
|
|
2139
|
+
foundExisting = true;
|
|
1664
2140
|
}
|
|
1665
2141
|
}
|
|
2142
|
+
const existed = foundExisting || Boolean(options.existingId);
|
|
1666
2143
|
const finalEntryTypeName = existingEntryType || entryTypeName;
|
|
1667
2144
|
let filename;
|
|
1668
2145
|
if (existingFilename) {
|
|
@@ -1673,16 +2150,17 @@ var ContentStore = class {
|
|
|
1673
2150
|
}
|
|
1674
2151
|
filename = `${finalEntryTypeName}.${safeSlug}.${id}${ext}`;
|
|
1675
2152
|
}
|
|
1676
|
-
const resolved =
|
|
1677
|
-
const collectionRootWithSep = collectionRoot.endsWith(
|
|
2153
|
+
const resolved = path8.resolve(collectionRoot, filename);
|
|
2154
|
+
const collectionRootWithSep = collectionRoot.endsWith(path8.sep) ? collectionRoot : `${collectionRoot}${path8.sep}`;
|
|
1678
2155
|
if (!resolved.startsWith(collectionRootWithSep)) {
|
|
1679
2156
|
throw new ContentStoreError("Path traversal detected", "VALIDATION");
|
|
1680
2157
|
}
|
|
1681
2158
|
return {
|
|
1682
2159
|
absolutePath: resolved,
|
|
1683
|
-
relativePath:
|
|
2160
|
+
relativePath: path8.relative(this.root, resolved),
|
|
1684
2161
|
id,
|
|
1685
|
-
entryTypeName: finalEntryTypeName
|
|
2162
|
+
entryTypeName: finalEntryTypeName,
|
|
2163
|
+
existed
|
|
1686
2164
|
};
|
|
1687
2165
|
}
|
|
1688
2166
|
throw new ContentStoreError("Invalid schema item type", "VALIDATION");
|
|
@@ -1715,8 +2193,8 @@ var ContentStore = class {
|
|
|
1715
2193
|
async read(collectionPath, slug = "", options = {}) {
|
|
1716
2194
|
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
1717
2195
|
const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName } = await this.buildPaths(schemaItem, slug);
|
|
1718
|
-
const stat = await
|
|
1719
|
-
const raw = await
|
|
2196
|
+
const stat = await fs8.stat(absolutePath);
|
|
2197
|
+
const raw = await fs8.readFile(absolutePath, "utf8");
|
|
1720
2198
|
let doc;
|
|
1721
2199
|
let format;
|
|
1722
2200
|
let fields;
|
|
@@ -1797,91 +2275,105 @@ var ContentStore = class {
|
|
|
1797
2275
|
if (expectedFormat !== input.format) {
|
|
1798
2276
|
throw new ContentStoreError(`Format mismatch: expects ${expectedFormat}, got ${input.format}`, "VALIDATION");
|
|
1799
2277
|
}
|
|
1800
|
-
const
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
+
}
|
|
1818
2307
|
}
|
|
1819
2308
|
}
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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
|
+
}
|
|
1826
2315
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
content = `${JSON.stringify(input.data ?? {}, null, 2)}
|
|
2316
|
+
let content;
|
|
2317
|
+
if (input.format === "json") {
|
|
2318
|
+
content = `${JSON.stringify(input.data ?? {}, null, 2)}
|
|
1831
2319
|
`;
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
} else {
|
|
1835
|
-
content = matter.stringify(input.body, input.data ?? {});
|
|
1836
|
-
}
|
|
1837
|
-
await atomicWriteFile(absolutePath, content);
|
|
1838
|
-
const liveIndex = this._idIndex;
|
|
1839
|
-
let staleOldAbsPath = null;
|
|
1840
|
-
if (id) {
|
|
1841
|
-
const existing = liveIndex.findById(id);
|
|
1842
|
-
if (existing) {
|
|
1843
|
-
if (existing.relativePath !== relativePath) {
|
|
1844
|
-
staleOldAbsPath = path7.join(this.root, existing.relativePath);
|
|
1845
|
-
liveIndex.updatePath(existing.id, relativePath);
|
|
1846
|
-
}
|
|
2320
|
+
} else if (input.format === "yaml") {
|
|
2321
|
+
content = yamlStringify(input.data ?? {});
|
|
1847
2322
|
} else {
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
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;
|
|
1853
2348
|
});
|
|
1854
2349
|
}
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
bodyFieldName: findBodyFieldName(fields)
|
|
1883
|
-
};
|
|
1884
|
-
});
|
|
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;
|
|
2375
|
+
}
|
|
2376
|
+
throw new ContentConflictError();
|
|
1885
2377
|
}
|
|
1886
2378
|
/**
|
|
1887
2379
|
* Read an entry by its ID (UUID).
|
|
@@ -1933,7 +2425,7 @@ var ContentStore = class {
|
|
|
1933
2425
|
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
1934
2426
|
const { absolutePath } = await this.buildPaths(schemaItem, slug);
|
|
1935
2427
|
try {
|
|
1936
|
-
await
|
|
2428
|
+
await fs8.access(absolutePath);
|
|
1937
2429
|
return true;
|
|
1938
2430
|
} catch (err) {
|
|
1939
2431
|
if (isNodeError(err) && err.code === "ENOENT")
|
|
@@ -1972,25 +2464,58 @@ var ContentStore = class {
|
|
|
1972
2464
|
const collectionRoot = await resolveCollectionPath(this.root, collectionPath);
|
|
1973
2465
|
if (!collectionRoot)
|
|
1974
2466
|
return 0;
|
|
1975
|
-
const entries = await
|
|
2467
|
+
const entries = await fs8.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
1976
2468
|
return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
|
|
1977
2469
|
}
|
|
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() {
|
|
2482
|
+
}
|
|
1978
2483
|
/**
|
|
1979
2484
|
* Delete an entry and remove it from the index.
|
|
1980
2485
|
*/
|
|
1981
2486
|
async delete(collectionPath, slug) {
|
|
1982
2487
|
await this.idIndex();
|
|
1983
2488
|
const collection = this.assertCollection(collectionPath);
|
|
1984
|
-
const
|
|
1985
|
-
|
|
1986
|
-
await
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
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();
|
|
1994
2519
|
}
|
|
1995
2520
|
/**
|
|
1996
2521
|
* Rename an entry by changing its slug (middle segment of filename).
|
|
@@ -2013,56 +2538,78 @@ var ContentStore = class {
|
|
|
2013
2538
|
if (currentSlug === safeNewSlug) {
|
|
2014
2539
|
return { newPath: `${collectionPath}/${currentSlug}` };
|
|
2015
2540
|
}
|
|
2016
|
-
const
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
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
|
+
}
|
|
2042
2586
|
}
|
|
2587
|
+
} catch (err) {
|
|
2588
|
+
if (err instanceof ContentStoreError)
|
|
2589
|
+
throw err;
|
|
2043
2590
|
}
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
if (isNodeError(err) && err.code === "EEXIST") {
|
|
2052
|
-
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
|
|
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;
|
|
2053
2598
|
}
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
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();
|
|
2066
2613
|
}
|
|
2067
2614
|
/**
|
|
2068
2615
|
* List all entries in a collection tree (including subcollections).
|
|
@@ -2145,389 +2692,58 @@ var ContentStore = class {
|
|
|
2145
2692
|
...b,
|
|
2146
2693
|
value: await this.resolveReferencesInData(b.value, template.fields)
|
|
2147
2694
|
};
|
|
2148
|
-
}));
|
|
2149
|
-
}
|
|
2150
|
-
}
|
|
2151
|
-
return resolved;
|
|
2152
|
-
}
|
|
2153
|
-
/**
|
|
2154
|
-
* Resolve a single reference ID to full entry data.
|
|
2155
|
-
* Returns null if the reference is invalid or missing.
|
|
2156
|
-
* Includes id, slug, and collection fields for debugging.
|
|
2157
|
-
*
|
|
2158
|
-
* Suspicious results (ID missing from the index, or an index hit whose file
|
|
2159
|
-
* is gone) trigger one forced index refresh and a retry — self-healing for
|
|
2160
|
-
* mutations by other processes inside the marker's residual windows. The
|
|
2161
|
-
* forced refresh itself is throttled (see refreshIndexForSuspiciousLookup),
|
|
2162
|
-
* but the retry against the live index always runs regardless of whether
|
|
2163
|
-
* this call won the throttle race: when a `list: true` reference array is
|
|
2164
|
-
* resolved via Promise.all, every miss shares the same stale snapshot, and
|
|
2165
|
-
* idIndex() dedupes concurrent builds — so a sibling call that wins the
|
|
2166
|
-
* throttle and rebuilds heals every other miss in the same batch, not just
|
|
2167
|
-
* the first.
|
|
2168
|
-
*/
|
|
2169
|
-
async resolveSingleReference(id, idIndex) {
|
|
2170
|
-
const first = await this.resolveSingleReferenceOnce(id, idIndex);
|
|
2171
|
-
if (first !== STALE_LOOKUP)
|
|
2172
|
-
return first;
|
|
2173
|
-
await this.refreshIndexForSuspiciousLookup();
|
|
2174
|
-
const second = await this.resolveSingleReferenceOnce(id, await this.idIndex());
|
|
2175
|
-
return second === STALE_LOOKUP ? null : second;
|
|
2176
|
-
}
|
|
2177
|
-
async resolveSingleReferenceOnce(id, idIndex) {
|
|
2178
|
-
try {
|
|
2179
|
-
const location = idIndex.findById(id);
|
|
2180
|
-
if (!location)
|
|
2181
|
-
return STALE_LOOKUP;
|
|
2182
|
-
if (location.type !== "entry" || !location.collection || !location.slug) {
|
|
2183
|
-
return null;
|
|
2184
|
-
}
|
|
2185
|
-
const doc = await this.read(location.collection, location.slug, {
|
|
2186
|
-
resolveReferences: false
|
|
2187
|
-
});
|
|
2188
|
-
return {
|
|
2189
|
-
id,
|
|
2190
|
-
slug: location.slug,
|
|
2191
|
-
collection: location.collection,
|
|
2192
|
-
...doc.data
|
|
2193
|
-
};
|
|
2194
|
-
} catch (error) {
|
|
2195
|
-
if (isNodeError(error) && error.code === "ENOENT")
|
|
2196
|
-
return STALE_LOOKUP;
|
|
2197
|
-
console.error(`Failed to resolve reference ${id}:`, error);
|
|
2198
|
-
return null;
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
};
|
|
2202
|
-
|
|
2203
|
-
// dist/branch-schema-cache.js
|
|
2204
|
-
import fs8 from "node:fs/promises";
|
|
2205
|
-
import path8 from "node:path";
|
|
2206
|
-
|
|
2207
|
-
// dist/schema/meta-loader.js
|
|
2208
|
-
import { promises as fs7 } from "fs";
|
|
2209
|
-
import { join as join2 } from "pathe";
|
|
2210
|
-
import { z as z6 } from "zod";
|
|
2211
|
-
import chokidar from "chokidar";
|
|
2212
|
-
var entryTypeMetaSchema = z6.object({
|
|
2213
|
-
name: z6.string().min(1),
|
|
2214
|
-
format: z6.enum(["md", "mdx", "json", "yaml"]),
|
|
2215
|
-
schema: z6.string().min(1),
|
|
2216
|
-
// Entry schema registry key (validated at resolution time)
|
|
2217
|
-
label: z6.string().optional(),
|
|
2218
|
-
description: z6.string().optional(),
|
|
2219
|
-
default: z6.boolean().optional(),
|
|
2220
|
-
maxItems: z6.number().int().positive().optional()
|
|
2221
|
-
});
|
|
2222
|
-
var collectionMetaSchema = z6.object({
|
|
2223
|
-
name: z6.string().min(1),
|
|
2224
|
-
label: z6.string().optional(),
|
|
2225
|
-
description: z6.string().optional(),
|
|
2226
|
-
entries: z6.array(entryTypeMetaSchema).optional(),
|
|
2227
|
-
order: z6.array(z6.string()).default([])
|
|
2228
|
-
// Embedded IDs for ordering; optional in the file, [] = alphabetical
|
|
2229
|
-
}).refine((data) => data.entries && data.entries.length > 0, {
|
|
2230
|
-
message: "Collection must have at least one entry type"
|
|
2231
|
-
});
|
|
2232
|
-
var rootCollectionMetaSchema = z6.object({
|
|
2233
|
-
label: z6.string().optional(),
|
|
2234
|
-
entries: z6.array(entryTypeMetaSchema).optional(),
|
|
2235
|
-
order: z6.array(z6.string()).optional()
|
|
2236
|
-
// Embedded IDs for ordering items
|
|
2237
|
-
});
|
|
2238
|
-
function stripEmbeddedIdFromName(name) {
|
|
2239
|
-
return extractSlugFromFilename(name);
|
|
2240
|
-
}
|
|
2241
|
-
async function scanForCollectionMeta(baseDir, relativePath = "") {
|
|
2242
|
-
const collections = [];
|
|
2243
|
-
try {
|
|
2244
|
-
const entries = await fs7.readdir(baseDir, { withFileTypes: true });
|
|
2245
|
-
for (const entry of entries) {
|
|
2246
|
-
if (!entry.isDirectory())
|
|
2247
|
-
continue;
|
|
2248
|
-
const folderName = entry.name;
|
|
2249
|
-
const logicalName = stripEmbeddedIdFromName(folderName);
|
|
2250
|
-
const collectionContentId = extractIdFromFilename(folderName) ?? void 0;
|
|
2251
|
-
const folderPath = relativePath ? `${relativePath}/${logicalName}` : logicalName;
|
|
2252
|
-
const absolutePath = join2(baseDir, folderName);
|
|
2253
|
-
const metaPath = join2(absolutePath, ".collection.json");
|
|
2254
|
-
try {
|
|
2255
|
-
await fs7.access(metaPath);
|
|
2256
|
-
const content = await fs7.readFile(metaPath, "utf-8");
|
|
2257
|
-
const parsed = JSON.parse(content);
|
|
2258
|
-
const meta = collectionMetaSchema.parse(parsed);
|
|
2259
|
-
collections.push({
|
|
2260
|
-
...meta,
|
|
2261
|
-
path: folderPath,
|
|
2262
|
-
// Path derived from folder name
|
|
2263
|
-
contentId: collectionContentId
|
|
2264
|
-
});
|
|
2265
|
-
const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
|
|
2266
|
-
collections.push(...nestedCollections);
|
|
2267
|
-
} catch (err) {
|
|
2268
|
-
if (err.code !== "ENOENT") {
|
|
2269
|
-
console.error(`Error loading ${metaPath}:`, err);
|
|
2270
|
-
throw new Error(`Invalid .collection.json in ${folderPath}: ${err.message}`);
|
|
2271
|
-
}
|
|
2272
|
-
const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
|
|
2273
|
-
collections.push(...nestedCollections);
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
return collections;
|
|
2277
|
-
} catch (err) {
|
|
2278
|
-
if (err.code === "ENOENT") {
|
|
2279
|
-
return [];
|
|
2280
|
-
}
|
|
2281
|
-
throw err;
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
async function loadCollectionMetaFiles(contentRoot) {
|
|
2285
|
-
let root = null;
|
|
2286
|
-
const rootMetaPath = join2(contentRoot, ".collection.json");
|
|
2287
|
-
try {
|
|
2288
|
-
await fs7.access(rootMetaPath);
|
|
2289
|
-
} catch (err) {
|
|
2290
|
-
if (err.code === "ENOENT") {
|
|
2291
|
-
} else {
|
|
2292
|
-
throw err;
|
|
2293
|
-
}
|
|
2294
|
-
}
|
|
2295
|
-
try {
|
|
2296
|
-
const content = await fs7.readFile(rootMetaPath, "utf-8");
|
|
2297
|
-
const parsed = JSON.parse(content);
|
|
2298
|
-
root = rootCollectionMetaSchema.parse(parsed);
|
|
2299
|
-
} catch (err) {
|
|
2300
|
-
const errno = err.code;
|
|
2301
|
-
if (errno !== "ENOENT") {
|
|
2302
|
-
throw new Error(`Invalid root .collection.json`);
|
|
2303
|
-
}
|
|
2304
|
-
}
|
|
2305
|
-
const collections = await scanForCollectionMeta(contentRoot);
|
|
2306
|
-
return { root, collections };
|
|
2307
|
-
}
|
|
2308
|
-
function resolveEntryTypes(entryTypes, entrySchemaRegistry, contextName) {
|
|
2309
|
-
return entryTypes.map((entryType) => {
|
|
2310
|
-
const resolvedSchema = entrySchemaRegistry[entryType.schema];
|
|
2311
|
-
if (!resolvedSchema) {
|
|
2312
|
-
throw new Error(`Schema reference "${entryType.schema}" in entry type "${entryType.name}" (${contextName}) not found in registry. Available schemas: ${Object.keys(entrySchemaRegistry).join(", ")}`);
|
|
2313
|
-
}
|
|
2314
|
-
return {
|
|
2315
|
-
name: entryType.name,
|
|
2316
|
-
label: entryType.label,
|
|
2317
|
-
format: entryType.format,
|
|
2318
|
-
schema: resolvedSchema,
|
|
2319
|
-
schemaRef: entryType.schema,
|
|
2320
|
-
default: entryType.default,
|
|
2321
|
-
maxItems: entryType.maxItems
|
|
2322
|
-
};
|
|
2323
|
-
});
|
|
2324
|
-
}
|
|
2325
|
-
function resolveCollectionMeta(meta, entrySchemaRegistry, allCollections) {
|
|
2326
|
-
const entries = meta.entries && meta.entries.length > 0 ? resolveEntryTypes(meta.entries, entrySchemaRegistry, `collection "${meta.name}"`) : void 0;
|
|
2327
|
-
const nestedCollections = allCollections.filter((col) => {
|
|
2328
|
-
return col.path.startsWith(`${meta.path}/`) && col.path.split("/").length === meta.path.split("/").length + 1;
|
|
2329
|
-
});
|
|
2330
|
-
const collections = nestedCollections.length > 0 ? nestedCollections.map((nestedMeta) => resolveCollectionMeta(nestedMeta, entrySchemaRegistry, allCollections)) : void 0;
|
|
2331
|
-
return {
|
|
2332
|
-
name: meta.name,
|
|
2333
|
-
label: meta.label,
|
|
2334
|
-
path: meta.path,
|
|
2335
|
-
contentId: meta.contentId,
|
|
2336
|
-
...entries && { entries },
|
|
2337
|
-
...meta.order && { order: meta.order },
|
|
2338
|
-
...collections && { collections }
|
|
2339
|
-
};
|
|
2340
|
-
}
|
|
2341
|
-
function resolveCollectionReferences(metaFiles, entrySchemaRegistry) {
|
|
2342
|
-
const result = {};
|
|
2343
|
-
if (metaFiles.root?.label) {
|
|
2344
|
-
result.label = metaFiles.root.label;
|
|
2345
|
-
}
|
|
2346
|
-
if (metaFiles.root?.entries && metaFiles.root.entries.length > 0) {
|
|
2347
|
-
result.entries = resolveEntryTypes(metaFiles.root.entries, entrySchemaRegistry, "root collection");
|
|
2348
|
-
}
|
|
2349
|
-
if (metaFiles.root?.order) {
|
|
2350
|
-
result.order = metaFiles.root.order;
|
|
2351
|
-
}
|
|
2352
|
-
const topLevelCollections = metaFiles.collections.filter((meta) => !meta.path.includes("/"));
|
|
2353
|
-
if (topLevelCollections.length > 0) {
|
|
2354
|
-
result.collections = topLevelCollections.map((meta) => resolveCollectionMeta(meta, entrySchemaRegistry, metaFiles.collections));
|
|
2355
|
-
}
|
|
2356
|
-
return result;
|
|
2357
|
-
}
|
|
2358
|
-
|
|
2359
|
-
// dist/schema/resolver.js
|
|
2360
|
-
async function resolveSchema(contentRoot, entrySchemaRegistry) {
|
|
2361
|
-
const metaFiles = await loadCollectionMetaFiles(contentRoot);
|
|
2362
|
-
const sources = [];
|
|
2363
|
-
if (metaFiles.root) {
|
|
2364
|
-
sources.push({
|
|
2365
|
-
path: ".collection.json",
|
|
2366
|
-
type: "root",
|
|
2367
|
-
collections: []
|
|
2368
|
-
});
|
|
2369
|
-
}
|
|
2370
|
-
for (const collection of metaFiles.collections) {
|
|
2371
|
-
sources.push({
|
|
2372
|
-
path: `${collection.path}/.collection.json`,
|
|
2373
|
-
type: "collection",
|
|
2374
|
-
collections: [collection.name]
|
|
2375
|
-
});
|
|
2376
|
-
}
|
|
2377
|
-
const schema = resolveCollectionReferences(metaFiles, entrySchemaRegistry);
|
|
2378
|
-
return { schema, sources };
|
|
2379
|
-
}
|
|
2380
|
-
function isValidSchema(schema) {
|
|
2381
|
-
const hasEntries = !!(schema.entries && schema.entries.length > 0);
|
|
2382
|
-
const hasCollections = !!(schema.collections && schema.collections.length > 0);
|
|
2383
|
-
return hasEntries || hasCollections;
|
|
2384
|
-
}
|
|
2385
|
-
|
|
2386
|
-
// dist/branch-schema-cache.js
|
|
2387
|
-
init_flatten();
|
|
2388
|
-
|
|
2389
|
-
// dist/build-mode.js
|
|
2390
|
-
var isDeployedStatic = (config) => {
|
|
2391
|
-
return config.deployedAs === "static";
|
|
2392
|
-
};
|
|
2393
|
-
var isBuildMode = () => {
|
|
2394
|
-
if (process.env.NEXT_PHASE === "phase-production-build")
|
|
2395
|
-
return true;
|
|
2396
|
-
if (process.env.CANOPY_BUILD_MODE === "true")
|
|
2397
|
-
return true;
|
|
2398
|
-
return false;
|
|
2399
|
-
};
|
|
2400
|
-
var STATIC_DEPLOY_USER = Object.freeze({
|
|
2401
|
-
type: "authenticated",
|
|
2402
|
-
userId: "__static_deploy__",
|
|
2403
|
-
groups: ["Admins"],
|
|
2404
|
-
email: "static-deploy@canopycms",
|
|
2405
|
-
name: "Static Deploy"
|
|
2406
|
-
});
|
|
2407
|
-
|
|
2408
|
-
// dist/branch-schema-cache.js
|
|
2409
|
-
var SCHEMA_CACHE_VERSION = 2;
|
|
2410
|
-
var MTIME_CHECK_DEBOUNCE_MS = 1e3;
|
|
2411
|
-
async function isStaleByMtime(dir, cachedAt) {
|
|
2412
|
-
let entries;
|
|
2413
|
-
try {
|
|
2414
|
-
entries = await fs8.readdir(dir, { recursive: true, encoding: "utf-8" });
|
|
2415
|
-
} catch {
|
|
2416
|
-
return true;
|
|
2417
|
-
}
|
|
2418
|
-
for (const entry of entries) {
|
|
2419
|
-
if (!entry.endsWith(".collection.json"))
|
|
2420
|
-
continue;
|
|
2421
|
-
const full = path8.join(dir, entry);
|
|
2422
|
-
try {
|
|
2423
|
-
const stat = await fs8.stat(full);
|
|
2424
|
-
if (stat.mtimeMs > cachedAt.getTime())
|
|
2425
|
-
return true;
|
|
2426
|
-
} catch {
|
|
2427
|
-
return true;
|
|
2695
|
+
}));
|
|
2696
|
+
}
|
|
2428
2697
|
}
|
|
2429
|
-
|
|
2430
|
-
return false;
|
|
2431
|
-
}
|
|
2432
|
-
var BranchSchemaCache = class {
|
|
2433
|
-
constructor(mode = "prod") {
|
|
2434
|
-
this.lastMtimeCheck = /* @__PURE__ */ new Map();
|
|
2435
|
-
this.devMode = mode === "dev";
|
|
2436
|
-
}
|
|
2437
|
-
/**
|
|
2438
|
-
* Whether to skip the on-disk cache for this branchRoot.
|
|
2439
|
-
*
|
|
2440
|
-
* branchRoot equals the project root (process.cwd()) only in the synthetic
|
|
2441
|
-
* contexts used by static deployments and build phases — real branch roots are
|
|
2442
|
-
* always nested under the workspace (.canopy-dev/content-branches/<name> in dev,
|
|
2443
|
-
* <workspaceRoot>/content-branches/<name> in prod). Never write .canopy-meta/ at
|
|
2444
|
-
* the project root, regardless of which entrypoint produced the cwd branchRoot.
|
|
2445
|
-
*/
|
|
2446
|
-
skipDiskCache(branchRoot) {
|
|
2447
|
-
return isBuildMode() || path8.resolve(branchRoot) === path8.resolve(process.cwd());
|
|
2698
|
+
return resolved;
|
|
2448
2699
|
}
|
|
2449
2700
|
/**
|
|
2450
|
-
*
|
|
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.
|
|
2451
2704
|
*
|
|
2452
|
-
*
|
|
2453
|
-
*
|
|
2454
|
-
*
|
|
2455
|
-
*
|
|
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.
|
|
2456
2715
|
*/
|
|
2457
|
-
async
|
|
2458
|
-
|
|
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;
|
|
2459
2723
|
}
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
const cacheDir = path8.join(branchRoot, ".canopy-meta");
|
|
2468
|
-
const cachePath = path8.join(cacheDir, "schema-cache.json");
|
|
2469
|
-
const stalePath = path8.join(cacheDir, "schema-cache.stale");
|
|
2470
|
-
let cacheData = null;
|
|
2471
|
-
try {
|
|
2472
|
-
const staleExists = await fs8.access(stalePath).then(() => true).catch(() => false);
|
|
2473
|
-
if (!staleExists) {
|
|
2474
|
-
const cacheContent = await fs8.readFile(cachePath, "utf-8");
|
|
2475
|
-
cacheData = JSON.parse(cacheContent);
|
|
2476
|
-
}
|
|
2477
|
-
} catch {
|
|
2478
|
-
cacheData = null;
|
|
2479
|
-
}
|
|
2480
|
-
if (cacheData && cacheData.version === SCHEMA_CACHE_VERSION) {
|
|
2481
|
-
const now = Date.now();
|
|
2482
|
-
const lastCheck = this.lastMtimeCheck.get(contentRoot) ?? 0;
|
|
2483
|
-
if (this.devMode && now - lastCheck >= MTIME_CHECK_DEBOUNCE_MS && await isStaleByMtime(contentRoot, new Date(cacheData.cachedAt))) {
|
|
2484
|
-
this.lastMtimeCheck.set(contentRoot, now);
|
|
2485
|
-
cacheData = null;
|
|
2486
|
-
} else {
|
|
2487
|
-
if (this.devMode)
|
|
2488
|
-
this.lastMtimeCheck.set(contentRoot, now);
|
|
2489
|
-
return { schema: cacheData.schema, flatSchema: cacheData.flatSchema };
|
|
2490
|
-
}
|
|
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;
|
|
2491
2731
|
}
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
const cachePath = path8.join(cacheDir, "schema-cache.json");
|
|
2501
|
-
const stalePath = path8.join(cacheDir, "schema-cache.stale");
|
|
2502
|
-
await fs8.mkdir(cacheDir, { recursive: true });
|
|
2503
|
-
const newCache = {
|
|
2504
|
-
version: SCHEMA_CACHE_VERSION,
|
|
2505
|
-
schema: result.schema,
|
|
2506
|
-
flatSchema,
|
|
2507
|
-
cachedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
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
|
|
2508
2740
|
};
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
} catch {
|
|
2515
|
-
}
|
|
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;
|
|
2516
2746
|
}
|
|
2517
|
-
return { schema: result.schema, flatSchema };
|
|
2518
|
-
}
|
|
2519
|
-
/**
|
|
2520
|
-
* Invalidate cache for a branch (creates .stale marker).
|
|
2521
|
-
*
|
|
2522
|
-
* @param branchRoot - Root directory of the branch
|
|
2523
|
-
*/
|
|
2524
|
-
async invalidate(branchRoot) {
|
|
2525
|
-
if (this.skipDiskCache(branchRoot))
|
|
2526
|
-
return;
|
|
2527
|
-
const cacheDir = path8.join(branchRoot, ".canopy-meta");
|
|
2528
|
-
const stalePath = path8.join(cacheDir, "schema-cache.stale");
|
|
2529
|
-
await fs8.mkdir(cacheDir, { recursive: true });
|
|
2530
|
-
await fs8.writeFile(stalePath, "", "utf-8");
|
|
2531
2747
|
}
|
|
2532
2748
|
};
|
|
2533
2749
|
|
|
@@ -3053,8 +3269,8 @@ function computeEntryUrl(collection, slug, contentRoot) {
|
|
|
3053
3269
|
if (slug && slug !== "index") {
|
|
3054
3270
|
segments.push(slug);
|
|
3055
3271
|
}
|
|
3056
|
-
const
|
|
3057
|
-
return
|
|
3272
|
+
const path19 = segments.length > 0 ? `/${segments.join("/")}` : "/";
|
|
3273
|
+
return path19.toLowerCase();
|
|
3058
3274
|
}
|
|
3059
3275
|
|
|
3060
3276
|
// dist/entry-link-resolver.js
|
|
@@ -3427,77 +3643,139 @@ function matchesBundleFilter(entry, filter, contentRoot) {
|
|
|
3427
3643
|
}
|
|
3428
3644
|
|
|
3429
3645
|
// dist/branch-workspace.js
|
|
3430
|
-
import
|
|
3646
|
+
import path15 from "node:path";
|
|
3431
3647
|
|
|
3432
3648
|
// dist/branch-metadata.js
|
|
3433
|
-
import
|
|
3434
|
-
import
|
|
3435
|
-
import path11 from "node:path";
|
|
3649
|
+
import fs12 from "node:fs/promises";
|
|
3650
|
+
import path12 from "node:path";
|
|
3436
3651
|
|
|
3437
3652
|
// dist/branch-registry.js
|
|
3438
3653
|
import fs10 from "node:fs/promises";
|
|
3439
3654
|
import path10 from "node:path";
|
|
3655
|
+
var log3 = createDebugLogger({ prefix: "BranchRegistry" });
|
|
3440
3656
|
var REGISTRY_FILE = "branches.json";
|
|
3441
3657
|
var REGISTRY_STALE_FILE = "branches.stale.json";
|
|
3442
3658
|
var REGISTRY_TEMP_FILE = "branches.tmp.json";
|
|
3443
|
-
var REGISTRY_VERSION =
|
|
3659
|
+
var REGISTRY_VERSION = 2;
|
|
3660
|
+
var RESOURCE2 = "branch-registry";
|
|
3661
|
+
var GET_MISS_REFRESH_MIN_INTERVAL_MS = 5e3;
|
|
3444
3662
|
var BranchRegistry = class {
|
|
3445
3663
|
constructor(root) {
|
|
3664
|
+
this.regenInFlight = null;
|
|
3665
|
+
this.lastForcedRefreshMs = 0;
|
|
3446
3666
|
this.root = path10.resolve(root);
|
|
3447
3667
|
this.registryPath = path10.join(this.root, REGISTRY_FILE);
|
|
3448
3668
|
this.stalePath = path10.join(this.root, REGISTRY_STALE_FILE);
|
|
3449
3669
|
this.tempPath = path10.join(this.root, REGISTRY_TEMP_FILE);
|
|
3450
3670
|
}
|
|
3451
3671
|
/**
|
|
3452
|
-
* Returns all branches. Uses
|
|
3672
|
+
* Returns all branches. Uses the cached snapshot if its embedded generation
|
|
3673
|
+
* token matches the live marker, regenerates otherwise.
|
|
3453
3674
|
*/
|
|
3454
3675
|
async list() {
|
|
3676
|
+
let parsed;
|
|
3455
3677
|
try {
|
|
3456
3678
|
const raw = await fs10.readFile(this.registryPath, "utf8");
|
|
3457
|
-
|
|
3458
|
-
if (!parsed.version || !Array.isArray(parsed.branches)) {
|
|
3459
|
-
return await this.regenerate();
|
|
3460
|
-
}
|
|
3461
|
-
return parsed.branches;
|
|
3679
|
+
parsed = JSON.parse(raw);
|
|
3462
3680
|
} catch (err) {
|
|
3463
3681
|
if (isNotFoundError(err)) {
|
|
3464
3682
|
return await this.regenerate();
|
|
3465
3683
|
}
|
|
3466
3684
|
throw err;
|
|
3467
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();
|
|
3468
3694
|
}
|
|
3469
3695
|
/**
|
|
3470
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.
|
|
3471
3705
|
*/
|
|
3472
3706
|
async get(name) {
|
|
3473
3707
|
const branches = await this.list();
|
|
3474
|
-
|
|
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;
|
|
3475
3722
|
}
|
|
3476
3723
|
/**
|
|
3477
|
-
* Marks the cache as stale
|
|
3478
|
-
*
|
|
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.
|
|
3479
3732
|
*/
|
|
3480
3733
|
async invalidate() {
|
|
3734
|
+
await bumpResourceGeneration(this.root, RESOURCE2, { mustSucceed: true });
|
|
3481
3735
|
try {
|
|
3482
|
-
|
|
3736
|
+
if (this.regenInFlight)
|
|
3737
|
+
await this.regenInFlight.catch(() => {
|
|
3738
|
+
});
|
|
3739
|
+
await this.regenerate();
|
|
3483
3740
|
} catch (err) {
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
}
|
|
3741
|
+
log3.warn("invalidate", "Eager regeneration after invalidate() failed", {
|
|
3742
|
+
error: getErrorMessage(err)
|
|
3743
|
+
});
|
|
3487
3744
|
}
|
|
3488
3745
|
}
|
|
3489
3746
|
/**
|
|
3490
|
-
* Scans branch directories and rebuilds the cache
|
|
3491
|
-
*
|
|
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.
|
|
3492
3752
|
*/
|
|
3493
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);
|
|
3494
3766
|
const branches = await this.scanBranchDirectories();
|
|
3495
|
-
|
|
3496
|
-
|
|
3767
|
+
await fs10.unlink(this.stalePath).catch(() => {
|
|
3768
|
+
});
|
|
3769
|
+
if (!read.ok) {
|
|
3770
|
+
return branches;
|
|
3771
|
+
}
|
|
3497
3772
|
const snapshot = {
|
|
3498
3773
|
version: REGISTRY_VERSION,
|
|
3499
|
-
branches
|
|
3774
|
+
branches,
|
|
3775
|
+
generation: read.token
|
|
3500
3776
|
};
|
|
3777
|
+
const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
3778
|
+
await fs10.mkdir(this.root, { recursive: true });
|
|
3501
3779
|
await fs10.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
|
3502
3780
|
try {
|
|
3503
3781
|
await fs10.rename(uniqueTempPath, this.registryPath);
|
|
@@ -3506,12 +3784,12 @@ var BranchRegistry = class {
|
|
|
3506
3784
|
});
|
|
3507
3785
|
throw err;
|
|
3508
3786
|
}
|
|
3509
|
-
await fs10.unlink(this.stalePath).catch(() => {
|
|
3510
|
-
});
|
|
3511
3787
|
return branches;
|
|
3512
3788
|
}
|
|
3513
3789
|
/**
|
|
3514
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.
|
|
3515
3793
|
*/
|
|
3516
3794
|
async scanBranchDirectories() {
|
|
3517
3795
|
const branches = [];
|
|
@@ -3541,30 +3819,156 @@ var BranchRegistry = class {
|
|
|
3541
3819
|
}
|
|
3542
3820
|
};
|
|
3543
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
|
+
|
|
3544
3947
|
// dist/branch-metadata.js
|
|
3545
3948
|
var BRANCH_META_DIR = ".canopy-meta";
|
|
3546
3949
|
var BRANCH_META_FILE = "branch.json";
|
|
3547
3950
|
var CURRENT_SCHEMA_VERSION = 1;
|
|
3548
3951
|
var BranchMetadataConflictError = class extends Error {
|
|
3549
|
-
constructor() {
|
|
3550
|
-
super(
|
|
3952
|
+
constructor(message = "Concurrent modification detected in branch metadata") {
|
|
3953
|
+
super(message);
|
|
3551
3954
|
this.name = "BranchMetadataConflictError";
|
|
3552
3955
|
}
|
|
3553
3956
|
};
|
|
3554
3957
|
var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
3555
|
-
constructor(branchRoot, baseRoot) {
|
|
3556
|
-
this.branchRoot =
|
|
3557
|
-
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);
|
|
3558
3961
|
this.baseRoot = baseRoot;
|
|
3962
|
+
this.settleMs = options?.settleMs;
|
|
3559
3963
|
}
|
|
3560
3964
|
/**
|
|
3561
3965
|
* Load branch metadata without requiring baseRoot.
|
|
3562
3966
|
* Use this for read-only access (e.g., in registry scanning or loadBranchContext).
|
|
3563
3967
|
*/
|
|
3564
3968
|
static async loadOnly(branchRoot) {
|
|
3565
|
-
const filePath =
|
|
3969
|
+
const filePath = path12.join(path12.resolve(branchRoot), BRANCH_META_DIR, BRANCH_META_FILE);
|
|
3566
3970
|
try {
|
|
3567
|
-
const raw = await
|
|
3971
|
+
const raw = await fs12.readFile(filePath, "utf8");
|
|
3568
3972
|
return JSON.parse(raw);
|
|
3569
3973
|
} catch (err) {
|
|
3570
3974
|
if (isNotFoundError(err)) {
|
|
@@ -3577,12 +3981,12 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
3577
3981
|
* Get a BranchMetadataFileManager instance configured for registry invalidation.
|
|
3578
3982
|
* Use this in API handlers to ensure registry cache is invalidated on updates.
|
|
3579
3983
|
*/
|
|
3580
|
-
static get(branchRoot, baseRoot) {
|
|
3581
|
-
return new _BranchMetadataFileManager(branchRoot, baseRoot);
|
|
3984
|
+
static get(branchRoot, baseRoot, options) {
|
|
3985
|
+
return new _BranchMetadataFileManager(branchRoot, baseRoot, options);
|
|
3582
3986
|
}
|
|
3583
3987
|
async load() {
|
|
3584
3988
|
try {
|
|
3585
|
-
const raw = await
|
|
3989
|
+
const raw = await fs12.readFile(this.filePath, "utf8");
|
|
3586
3990
|
const parsed = JSON.parse(raw);
|
|
3587
3991
|
const version = parsed.version ?? 0;
|
|
3588
3992
|
return { meta: parsed, version };
|
|
@@ -3594,114 +3998,107 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
3594
3998
|
}
|
|
3595
3999
|
}
|
|
3596
4000
|
/**
|
|
3597
|
-
*
|
|
3598
|
-
*
|
|
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.
|
|
3599
4013
|
*/
|
|
3600
4014
|
async write(meta, expectedVersion) {
|
|
3601
|
-
const newVersion = expectedVersion === null ? 1 : expectedVersion + 1;
|
|
3602
|
-
const writeId = randomUUID2();
|
|
3603
4015
|
const payload = {
|
|
3604
4016
|
...meta,
|
|
3605
|
-
schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION
|
|
3606
|
-
version: newVersion,
|
|
3607
|
-
writeId
|
|
4017
|
+
schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION
|
|
3608
4018
|
};
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
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");
|
|
3623
4055
|
}
|
|
3624
|
-
|
|
3625
|
-
});
|
|
3626
|
-
return { version: newVersion, writeId };
|
|
4056
|
+
throw err;
|
|
3627
4057
|
}
|
|
3628
|
-
|
|
3629
|
-
await fs11.writeFile(tempPath, content, "utf-8");
|
|
4058
|
+
let saved;
|
|
3630
4059
|
try {
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
const
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
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) {
|
|
3644
4096
|
throw new BranchMetadataConflictError();
|
|
3645
4097
|
}
|
|
3646
|
-
} catch (err) {
|
|
3647
|
-
await fs11.unlink(tempPath).catch(() => {
|
|
3648
|
-
});
|
|
3649
4098
|
throw err;
|
|
3650
4099
|
}
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
async withRetry(operation, maxAttempts = 5) {
|
|
3654
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3655
|
-
try {
|
|
3656
|
-
return await operation();
|
|
3657
|
-
} catch (err) {
|
|
3658
|
-
if (err instanceof BranchMetadataConflictError && attempt < maxAttempts) {
|
|
3659
|
-
const baseDelay = Math.min(10 * Math.pow(2, attempt - 1), 100);
|
|
3660
|
-
const jitter = Math.random() * baseDelay;
|
|
3661
|
-
await new Promise((resolve2) => setTimeout(resolve2, baseDelay + jitter));
|
|
3662
|
-
continue;
|
|
3663
|
-
}
|
|
3664
|
-
throw err;
|
|
3665
|
-
}
|
|
3666
|
-
}
|
|
3667
|
-
throw new Error("Unreachable");
|
|
3668
|
-
}
|
|
3669
|
-
async save(incoming) {
|
|
3670
|
-
return withLock(this.filePath, () => this.withRetry(async () => {
|
|
3671
|
-
const { meta: existing, version } = await this.load();
|
|
3672
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3673
|
-
const defaults = {
|
|
3674
|
-
name: "unknown",
|
|
3675
|
-
status: "editing",
|
|
3676
|
-
access: {},
|
|
3677
|
-
createdBy: "unknown",
|
|
3678
|
-
createdAt: now,
|
|
3679
|
-
updatedAt: now
|
|
3680
|
-
};
|
|
3681
|
-
const merged = {
|
|
3682
|
-
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
3683
|
-
version: version ?? 0,
|
|
3684
|
-
branch: {
|
|
3685
|
-
...defaults,
|
|
3686
|
-
...existing?.branch,
|
|
3687
|
-
...incoming.branch,
|
|
3688
|
-
access: {
|
|
3689
|
-
...existing?.branch?.access,
|
|
3690
|
-
...incoming.branch?.access
|
|
3691
|
-
},
|
|
3692
|
-
// Immutable after creation
|
|
3693
|
-
createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
|
|
3694
|
-
createdAt: existing?.branch.createdAt ?? defaults.createdAt,
|
|
3695
|
-
// Fork point is recorded once at creation; later saves must not move it
|
|
3696
|
-
baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch
|
|
3697
|
-
}
|
|
3698
|
-
};
|
|
3699
|
-
const written = await this.write(merged, version);
|
|
3700
|
-
merged.version = written.version;
|
|
3701
|
-
merged.writeId = written.writeId;
|
|
3702
|
-
await this.invalidateRegistry();
|
|
3703
|
-
return merged;
|
|
3704
|
-
}));
|
|
4100
|
+
await this.invalidateRegistry();
|
|
4101
|
+
return saved;
|
|
3705
4102
|
}
|
|
3706
4103
|
/**
|
|
3707
4104
|
* Invalidates the registry cache so next list() call regenerates from branch.json files.
|
|
@@ -3711,8 +4108,8 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
3711
4108
|
await registry2.invalidate();
|
|
3712
4109
|
}
|
|
3713
4110
|
};
|
|
3714
|
-
var getBranchMetadataFileManager = (branchRoot, baseRoot) => {
|
|
3715
|
-
return BranchMetadataFileManager.get(branchRoot, baseRoot);
|
|
4111
|
+
var getBranchMetadataFileManager = (branchRoot, baseRoot, options) => {
|
|
4112
|
+
return BranchMetadataFileManager.get(branchRoot, baseRoot, options);
|
|
3716
4113
|
};
|
|
3717
4114
|
var loadBranchContext = async (options) => {
|
|
3718
4115
|
const { branchRoot, baseRoot } = resolveBranchPath({
|
|
@@ -3735,8 +4132,8 @@ var loadBranchContext = async (options) => {
|
|
|
3735
4132
|
init_operating_mode();
|
|
3736
4133
|
|
|
3737
4134
|
// dist/git-manager.js
|
|
3738
|
-
import
|
|
3739
|
-
import
|
|
4135
|
+
import fs14 from "node:fs/promises";
|
|
4136
|
+
import path14 from "node:path";
|
|
3740
4137
|
import { simpleGit as simpleGit2 } from "simple-git";
|
|
3741
4138
|
|
|
3742
4139
|
// dist/utils/git.js
|
|
@@ -3760,40 +4157,40 @@ async function resolveBaseBranch(options) {
|
|
|
3760
4157
|
}
|
|
3761
4158
|
|
|
3762
4159
|
// dist/utils/provisioning-lock.js
|
|
3763
|
-
import
|
|
3764
|
-
import
|
|
3765
|
-
import
|
|
4160
|
+
import fs13 from "node:fs/promises";
|
|
4161
|
+
import path13 from "node:path";
|
|
4162
|
+
import lockfile2 from "proper-lockfile";
|
|
3766
4163
|
async function acquireProvisioningLock(lockTargetDir, lockName) {
|
|
3767
|
-
await
|
|
3768
|
-
return
|
|
3769
|
-
lockfilePath:
|
|
4164
|
+
await fs13.mkdir(lockTargetDir, { recursive: true });
|
|
4165
|
+
return lockfile2.lock(lockTargetDir, {
|
|
4166
|
+
lockfilePath: path13.join(lockTargetDir, lockName),
|
|
3770
4167
|
retries: { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true },
|
|
3771
4168
|
stale: 3e4
|
|
3772
4169
|
});
|
|
3773
4170
|
}
|
|
3774
4171
|
|
|
3775
4172
|
// dist/git-manager.js
|
|
3776
|
-
var
|
|
4173
|
+
var log5 = createDebugLogger({ prefix: "GitManager" });
|
|
3777
4174
|
var remoteInitLocks = /* @__PURE__ */ new Map();
|
|
3778
4175
|
async function ensureGitExcludePattern(repoPath, pattern) {
|
|
3779
|
-
const excludePath =
|
|
3780
|
-
await
|
|
4176
|
+
const excludePath = path14.join(repoPath, ".git", "info", "exclude");
|
|
4177
|
+
await fs14.mkdir(path14.dirname(excludePath), { recursive: true });
|
|
3781
4178
|
let content = "";
|
|
3782
4179
|
try {
|
|
3783
|
-
content = await
|
|
4180
|
+
content = await fs14.readFile(excludePath, "utf-8");
|
|
3784
4181
|
} catch (err) {
|
|
3785
4182
|
if (!isNotFoundError(err))
|
|
3786
4183
|
throw err;
|
|
3787
4184
|
}
|
|
3788
4185
|
const lines = content.split("\n");
|
|
3789
4186
|
if (lines.some((line) => line.trim() === pattern)) {
|
|
3790
|
-
|
|
4187
|
+
log5.debug("git", "Pattern already in .git/info/exclude", { pattern });
|
|
3791
4188
|
return;
|
|
3792
4189
|
}
|
|
3793
4190
|
const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
|
|
3794
4191
|
const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
|
|
3795
|
-
await
|
|
3796
|
-
|
|
4192
|
+
await fs14.writeFile(excludePath, newContent, "utf-8");
|
|
4193
|
+
log5.debug("git", "Added pattern to .git/info/exclude", { pattern });
|
|
3797
4194
|
}
|
|
3798
4195
|
var GitConflictError = class extends Error {
|
|
3799
4196
|
constructor(conflictedFiles) {
|
|
@@ -3804,22 +4201,22 @@ var GitConflictError = class extends Error {
|
|
|
3804
4201
|
};
|
|
3805
4202
|
var GitManager = class _GitManager {
|
|
3806
4203
|
constructor(options, gitOptions) {
|
|
3807
|
-
this.repoPath =
|
|
4204
|
+
this.repoPath = path14.resolve(options.repoPath);
|
|
3808
4205
|
this.baseBranch = options.baseBranch ?? "main";
|
|
3809
4206
|
this.remote = options.remote ?? "origin";
|
|
3810
4207
|
this.skipIndexMarker = options.skipIndexMarker ?? false;
|
|
3811
4208
|
this.git = simpleGit2({ baseDir: this.repoPath, ...gitOptions });
|
|
3812
|
-
this.git.env("GIT_CEILING_DIRECTORIES",
|
|
4209
|
+
this.git.env("GIT_CEILING_DIRECTORIES", path14.dirname(this.repoPath));
|
|
3813
4210
|
}
|
|
3814
4211
|
static async cloneRepo(remoteUrl, targetPath, baseBranch = "main") {
|
|
3815
|
-
|
|
4212
|
+
log5.debug("git", "Cloning repository", {
|
|
3816
4213
|
remoteUrl,
|
|
3817
4214
|
targetPath,
|
|
3818
4215
|
baseBranch
|
|
3819
4216
|
});
|
|
3820
4217
|
const git = simpleGit2();
|
|
3821
4218
|
await git.clone(remoteUrl, targetPath, ["--branch", baseBranch, "--single-branch"]);
|
|
3822
|
-
|
|
4219
|
+
log5.debug("git", "Clone complete");
|
|
3823
4220
|
}
|
|
3824
4221
|
/**
|
|
3825
4222
|
* Initializes a local bare git repository to simulate a remote for dev mode.
|
|
@@ -3839,30 +4236,30 @@ var GitManager = class _GitManager {
|
|
|
3839
4236
|
static async ensureLocalSimulatedRemote(options) {
|
|
3840
4237
|
const existingLock = remoteInitLocks.get(options.remotePath);
|
|
3841
4238
|
if (existingLock) {
|
|
3842
|
-
|
|
4239
|
+
log5.debug("git", "Waiting for existing remote initialization", {
|
|
3843
4240
|
remotePath: options.remotePath
|
|
3844
4241
|
});
|
|
3845
4242
|
await existingLock;
|
|
3846
4243
|
}
|
|
3847
|
-
const lockPromise =
|
|
4244
|
+
const lockPromise = log5.timed("git", "ensureLocalSimulatedRemote", async () => {
|
|
3848
4245
|
let releaseLock;
|
|
3849
4246
|
try {
|
|
3850
|
-
|
|
4247
|
+
log5.debug("git", "Initializing local simulated remote", {
|
|
3851
4248
|
remotePath: options.remotePath,
|
|
3852
4249
|
baseBranch: options.baseBranch
|
|
3853
4250
|
});
|
|
3854
|
-
const remoteParent =
|
|
4251
|
+
const remoteParent = path14.dirname(options.remotePath);
|
|
3855
4252
|
releaseLock = await acquireProvisioningLock(remoteParent, ".remote-init.lock");
|
|
3856
4253
|
let remoteExists = false;
|
|
3857
4254
|
try {
|
|
3858
|
-
const stat = await
|
|
4255
|
+
const stat = await fs14.stat(options.remotePath);
|
|
3859
4256
|
remoteExists = stat.isDirectory();
|
|
3860
4257
|
} catch (err) {
|
|
3861
4258
|
if (!isNotFoundError(err))
|
|
3862
4259
|
throw err;
|
|
3863
4260
|
}
|
|
3864
4261
|
if (remoteExists && await _GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch)) {
|
|
3865
|
-
|
|
4262
|
+
log5.debug("git", "Remote already has base branch, skipping");
|
|
3866
4263
|
return;
|
|
3867
4264
|
}
|
|
3868
4265
|
let gitRoot = options.sourcePath;
|
|
@@ -3881,8 +4278,8 @@ var GitManager = class _GitManager {
|
|
|
3881
4278
|
}
|
|
3882
4279
|
let hasCommits = false;
|
|
3883
4280
|
try {
|
|
3884
|
-
const
|
|
3885
|
-
hasCommits =
|
|
4281
|
+
const log8 = await sourceGit.log(["-1"]);
|
|
4282
|
+
hasCommits = log8.total > 0;
|
|
3886
4283
|
} catch {
|
|
3887
4284
|
hasCommits = false;
|
|
3888
4285
|
}
|
|
@@ -3894,12 +4291,12 @@ var GitManager = class _GitManager {
|
|
|
3894
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.`);
|
|
3895
4292
|
}
|
|
3896
4293
|
if (remoteExists) {
|
|
3897
|
-
|
|
4294
|
+
log5.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
|
|
3898
4295
|
remotePath: options.remotePath,
|
|
3899
4296
|
baseBranch: options.baseBranch
|
|
3900
4297
|
});
|
|
3901
4298
|
} else {
|
|
3902
|
-
|
|
4299
|
+
log5.debug("git", "Creating bare remote repository");
|
|
3903
4300
|
await simpleGit2().raw([
|
|
3904
4301
|
"init",
|
|
3905
4302
|
"--bare",
|
|
@@ -3913,13 +4310,13 @@ var GitManager = class _GitManager {
|
|
|
3913
4310
|
baseBranch: options.baseBranch,
|
|
3914
4311
|
subdirectory: options.subdirectory
|
|
3915
4312
|
});
|
|
3916
|
-
|
|
4313
|
+
log5.debug("git", "Remote initialization complete");
|
|
3917
4314
|
} finally {
|
|
3918
4315
|
if (releaseLock) {
|
|
3919
4316
|
try {
|
|
3920
4317
|
await releaseLock();
|
|
3921
4318
|
} catch (err) {
|
|
3922
|
-
|
|
4319
|
+
log5.debug("git", "Failed to release remote-init lock", { err });
|
|
3923
4320
|
}
|
|
3924
4321
|
}
|
|
3925
4322
|
remoteInitLocks.delete(options.remotePath);
|
|
@@ -4018,7 +4415,7 @@ var GitManager = class _GitManager {
|
|
|
4018
4415
|
*/
|
|
4019
4416
|
static async validateGitRepoExists(repoPath) {
|
|
4020
4417
|
try {
|
|
4021
|
-
const stat = await
|
|
4418
|
+
const stat = await fs14.stat(path14.join(repoPath, ".git"));
|
|
4022
4419
|
if (!stat.isDirectory()) {
|
|
4023
4420
|
throw new Error(`Expected git repo at ${repoPath}`);
|
|
4024
4421
|
}
|
|
@@ -4056,9 +4453,9 @@ var GitManager = class _GitManager {
|
|
|
4056
4453
|
return process.env[config.envVarName];
|
|
4057
4454
|
if (config.autoDetectRemotePath) {
|
|
4058
4455
|
try {
|
|
4059
|
-
const stat = await
|
|
4456
|
+
const stat = await fs14.stat(config.autoDetectRemotePath);
|
|
4060
4457
|
if (stat.isDirectory()) {
|
|
4061
|
-
|
|
4458
|
+
log5.debug("git", "Auto-detected local remote", {
|
|
4062
4459
|
path: config.autoDetectRemotePath
|
|
4063
4460
|
});
|
|
4064
4461
|
return config.autoDetectRemotePath;
|
|
@@ -4069,8 +4466,8 @@ var GitManager = class _GitManager {
|
|
|
4069
4466
|
if (config.shouldAutoInitLocal) {
|
|
4070
4467
|
const gitRoot = await this.findGitRoot();
|
|
4071
4468
|
const sourceRoot = options.sourceRoot;
|
|
4072
|
-
const sourcePath = sourceRoot ?
|
|
4073
|
-
const localRemotePath =
|
|
4469
|
+
const sourcePath = sourceRoot ? path14.resolve(gitRoot, sourceRoot) : gitRoot;
|
|
4470
|
+
const localRemotePath = path14.join(sourcePath, config.defaultRemotePath);
|
|
4074
4471
|
await this.ensureLocalSimulatedRemote({
|
|
4075
4472
|
remotePath: localRemotePath,
|
|
4076
4473
|
sourcePath: gitRoot,
|
|
@@ -4096,24 +4493,24 @@ var GitManager = class _GitManager {
|
|
|
4096
4493
|
const baseBranch = await resolveBaseBranch({
|
|
4097
4494
|
defaultBaseBranch: options.baseBranch,
|
|
4098
4495
|
mode: options.mode,
|
|
4099
|
-
detectFrom: options.sourceRoot ?
|
|
4496
|
+
detectFrom: options.sourceRoot ? path14.resolve(process.cwd(), options.sourceRoot) : process.cwd()
|
|
4100
4497
|
});
|
|
4101
4498
|
const remoteName = options.remoteName ?? "origin";
|
|
4102
4499
|
let repoExists = false;
|
|
4103
4500
|
try {
|
|
4104
4501
|
const checkGit = simpleGit2({ baseDir: options.workspacePath });
|
|
4105
|
-
checkGit.env("GIT_CEILING_DIRECTORIES",
|
|
4502
|
+
checkGit.env("GIT_CEILING_DIRECTORIES", path14.dirname(options.workspacePath));
|
|
4106
4503
|
await checkGit.raw(["rev-parse", "--git-dir"]);
|
|
4107
4504
|
repoExists = true;
|
|
4108
4505
|
} catch {
|
|
4109
|
-
const gitPath =
|
|
4506
|
+
const gitPath = path14.join(options.workspacePath, ".git");
|
|
4110
4507
|
try {
|
|
4111
|
-
const stat = await
|
|
4508
|
+
const stat = await fs14.stat(gitPath);
|
|
4112
4509
|
if (stat.isDirectory()) {
|
|
4113
|
-
|
|
4510
|
+
log5.debug("git", "Removing corrupt .git directory", {
|
|
4114
4511
|
workspacePath: options.workspacePath
|
|
4115
4512
|
});
|
|
4116
|
-
await
|
|
4513
|
+
await fs14.rm(gitPath, { recursive: true });
|
|
4117
4514
|
}
|
|
4118
4515
|
} catch (cleanupErr) {
|
|
4119
4516
|
if (!isNotFoundError(cleanupErr))
|
|
@@ -4139,7 +4536,7 @@ var GitManager = class _GitManager {
|
|
|
4139
4536
|
}
|
|
4140
4537
|
justCloned = true;
|
|
4141
4538
|
const freshGit = simpleGit2({ baseDir: options.workspacePath });
|
|
4142
|
-
freshGit.env("GIT_CEILING_DIRECTORIES",
|
|
4539
|
+
freshGit.env("GIT_CEILING_DIRECTORIES", path14.dirname(options.workspacePath));
|
|
4143
4540
|
await freshGit.addConfig("canopycms.managed", "true");
|
|
4144
4541
|
await freshGit.addConfig("user.name", options.gitBotAuthorName);
|
|
4145
4542
|
await freshGit.addConfig("user.email", options.gitBotAuthorEmail);
|
|
@@ -4153,7 +4550,7 @@ var GitManager = class _GitManager {
|
|
|
4153
4550
|
await git.git.addConfig("canopycms.managed", "true");
|
|
4154
4551
|
await git.git.addConfig("user.name", options.gitBotAuthorName);
|
|
4155
4552
|
await git.git.addConfig("user.email", options.gitBotAuthorEmail);
|
|
4156
|
-
|
|
4553
|
+
log5.debug("git", "Marked workspace as CanopyCMS-managed", {
|
|
4157
4554
|
workspacePath: options.workspacePath
|
|
4158
4555
|
});
|
|
4159
4556
|
if (!justCloned) {
|
|
@@ -4189,23 +4586,28 @@ var GitManager = class _GitManager {
|
|
|
4189
4586
|
};
|
|
4190
4587
|
}
|
|
4191
4588
|
/**
|
|
4192
|
-
* Mark ContentStore ID indexes rooted at (or
|
|
4193
|
-
* Called after operations that mutate the
|
|
4194
|
-
* ID→path lookups don't keep
|
|
4195
|
-
*
|
|
4196
|
-
*
|
|
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.
|
|
4197
4597
|
*
|
|
4198
|
-
* Covers both scopes: in-process stores via
|
|
4199
|
-
* processes sharing the filesystem (worker vs Lambda on
|
|
4200
|
-
* generation
|
|
4201
|
-
* (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).
|
|
4202
4604
|
*/
|
|
4203
4605
|
async invalidateContentIndexes() {
|
|
4204
4606
|
if (this.skipIndexMarker) {
|
|
4205
4607
|
invalidateContentIndexesForRoot(this.repoPath);
|
|
4206
4608
|
return;
|
|
4207
4609
|
}
|
|
4208
|
-
await
|
|
4610
|
+
await invalidateBranchContentCaches(this.repoPath);
|
|
4209
4611
|
}
|
|
4210
4612
|
async checkoutBranch(branch) {
|
|
4211
4613
|
try {
|
|
@@ -4431,10 +4833,10 @@ var GitManager = class _GitManager {
|
|
|
4431
4833
|
}
|
|
4432
4834
|
}
|
|
4433
4835
|
async createOrphanSettingsBranchInner(branchName, initialFiles) {
|
|
4434
|
-
|
|
4836
|
+
log5.debug("git", "Creating orphan settings branch", { branchName });
|
|
4435
4837
|
const branches = await this.git.branch();
|
|
4436
4838
|
if (branches.all.includes(branchName)) {
|
|
4437
|
-
|
|
4839
|
+
log5.debug("git", "Orphan branch already exists", { branchName });
|
|
4438
4840
|
await this.git.checkout(branchName);
|
|
4439
4841
|
return;
|
|
4440
4842
|
}
|
|
@@ -4444,25 +4846,25 @@ var GitManager = class _GitManager {
|
|
|
4444
4846
|
} catch {
|
|
4445
4847
|
}
|
|
4446
4848
|
for (const [filePath, content] of Object.entries(initialFiles)) {
|
|
4447
|
-
const absolutePath =
|
|
4448
|
-
await
|
|
4449
|
-
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");
|
|
4450
4852
|
await this.git.add(filePath);
|
|
4451
4853
|
}
|
|
4452
4854
|
await this.git.commit("Initialize settings branch", ["--allow-empty"]);
|
|
4453
|
-
|
|
4855
|
+
log5.debug("git", "Orphan settings branch created", { branchName });
|
|
4454
4856
|
}
|
|
4455
4857
|
};
|
|
4456
4858
|
|
|
4457
4859
|
// dist/branch-workspace.js
|
|
4458
|
-
var
|
|
4860
|
+
var log6 = createDebugLogger({ prefix: "BranchWorkspace" });
|
|
4459
4861
|
var workspaceInitLocks = /* @__PURE__ */ new Map();
|
|
4460
4862
|
var BranchWorkspaceManager = class {
|
|
4461
4863
|
constructor(config) {
|
|
4462
4864
|
this.config = config;
|
|
4463
4865
|
}
|
|
4464
4866
|
async ensureGitWorkspace(options) {
|
|
4465
|
-
return
|
|
4867
|
+
return log6.timed("workspace", "ensureGitWorkspace", async () => {
|
|
4466
4868
|
const existingLock = workspaceInitLocks.get(options.branchRoot);
|
|
4467
4869
|
if (existingLock) {
|
|
4468
4870
|
await existingLock;
|
|
@@ -4471,11 +4873,11 @@ var BranchWorkspaceManager = class {
|
|
|
4471
4873
|
const lockPromise = (async () => {
|
|
4472
4874
|
let releaseLock;
|
|
4473
4875
|
try {
|
|
4474
|
-
|
|
4876
|
+
log6.debug("workspace", "Ensuring git workspace", {
|
|
4475
4877
|
branchName: options.branchName,
|
|
4476
4878
|
mode: options.mode
|
|
4477
4879
|
});
|
|
4478
|
-
releaseLock = await acquireProvisioningLock(
|
|
4880
|
+
releaseLock = await acquireProvisioningLock(path15.dirname(options.branchRoot), `.${path15.basename(options.branchRoot)}.init.lock`);
|
|
4479
4881
|
await GitManager.initializeWorkspace({
|
|
4480
4882
|
workspacePath: options.branchRoot,
|
|
4481
4883
|
branchName: options.branchName,
|
|
@@ -4495,7 +4897,7 @@ var BranchWorkspaceManager = class {
|
|
|
4495
4897
|
try {
|
|
4496
4898
|
await releaseLock();
|
|
4497
4899
|
} catch (err) {
|
|
4498
|
-
|
|
4900
|
+
log6.debug("workspace", "Failed to release workspace-init lock", { err });
|
|
4499
4901
|
}
|
|
4500
4902
|
}
|
|
4501
4903
|
workspaceInitLocks.delete(options.branchRoot);
|
|
@@ -4515,7 +4917,7 @@ var BranchWorkspaceManager = class {
|
|
|
4515
4917
|
const baseBranch = await resolveBaseBranch({
|
|
4516
4918
|
defaultBaseBranch: this.config.defaultBaseBranch,
|
|
4517
4919
|
mode,
|
|
4518
|
-
detectFrom: this.config.sourceRoot ?
|
|
4920
|
+
detectFrom: this.config.sourceRoot ? path15.resolve(process.cwd(), this.config.sourceRoot) : void 0
|
|
4519
4921
|
});
|
|
4520
4922
|
await this.ensureGitWorkspace({
|
|
4521
4923
|
branchRoot,
|
|
@@ -4599,11 +5001,11 @@ async function resolveBranchRoot(config) {
|
|
|
4599
5001
|
}
|
|
4600
5002
|
|
|
4601
5003
|
// dist/content-listing.js
|
|
4602
|
-
import
|
|
4603
|
-
import
|
|
5004
|
+
import fs15 from "node:fs/promises";
|
|
5005
|
+
import path16 from "node:path";
|
|
4604
5006
|
import matter2 from "gray-matter";
|
|
4605
5007
|
import { parse as yamlParse2 } from "yaml";
|
|
4606
|
-
var
|
|
5008
|
+
var log7 = createDebugLogger({ prefix: "ContentListing" });
|
|
4607
5009
|
var normalizePath = (root, target) => {
|
|
4608
5010
|
const result = validateAndNormalizePath(root, target);
|
|
4609
5011
|
if (!result.valid) {
|
|
@@ -4613,7 +5015,7 @@ var normalizePath = (root, target) => {
|
|
|
4613
5015
|
};
|
|
4614
5016
|
var readEntryData = async (filePath, format, bodyFieldName = "body") => {
|
|
4615
5017
|
try {
|
|
4616
|
-
const raw = await
|
|
5018
|
+
const raw = await fs15.readFile(filePath, "utf8");
|
|
4617
5019
|
if (format === "json") {
|
|
4618
5020
|
return asRecord(JSON.parse(raw));
|
|
4619
5021
|
}
|
|
@@ -4629,7 +5031,7 @@ var readEntryData = async (filePath, format, bodyFieldName = "body") => {
|
|
|
4629
5031
|
} catch (err) {
|
|
4630
5032
|
if (isNotFoundError(err))
|
|
4631
5033
|
return {};
|
|
4632
|
-
|
|
5034
|
+
log7.warn("readEntryData", `Failed to read entry data from ${filePath}: ${getErrorMessage(err)}`);
|
|
4633
5035
|
return {};
|
|
4634
5036
|
}
|
|
4635
5037
|
};
|
|
@@ -4726,7 +5128,7 @@ var listCollectionEntries = async (root, collection) => {
|
|
|
4726
5128
|
normalizePath(root, collectionRoot);
|
|
4727
5129
|
let dirents;
|
|
4728
5130
|
try {
|
|
4729
|
-
dirents = await
|
|
5131
|
+
dirents = await fs15.readdir(collectionRoot, { withFileTypes: true });
|
|
4730
5132
|
} catch (err) {
|
|
4731
5133
|
if (isNotFoundError(err))
|
|
4732
5134
|
return [];
|
|
@@ -4734,11 +5136,11 @@ var listCollectionEntries = async (root, collection) => {
|
|
|
4734
5136
|
}
|
|
4735
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));
|
|
4736
5138
|
const entries = await Promise.all(files.map(async (file) => {
|
|
4737
|
-
const absolutePath =
|
|
5139
|
+
const absolutePath = path16.join(collectionRoot, file.name);
|
|
4738
5140
|
const relativePath = normalizePath(root, absolutePath);
|
|
4739
5141
|
const parsed = parseTypedFilename(file.name, entryTypes);
|
|
4740
5142
|
if (!parsed) {
|
|
4741
|
-
|
|
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)`);
|
|
4742
5144
|
return null;
|
|
4743
5145
|
}
|
|
4744
5146
|
const { type: entryTypeName, slug, id: contentId } = parsed;
|
|
@@ -4746,7 +5148,7 @@ var listCollectionEntries = async (root, collection) => {
|
|
|
4746
5148
|
const format = entryType?.format || "json";
|
|
4747
5149
|
const bodyField = entryType?.schema ? findBodyFieldName(entryType.schema) : "body";
|
|
4748
5150
|
const [stats, data] = await Promise.all([
|
|
4749
|
-
|
|
5151
|
+
fs15.stat(absolutePath),
|
|
4750
5152
|
readEntryData(absolutePath, format, bodyField)
|
|
4751
5153
|
]);
|
|
4752
5154
|
const item = {
|
|
@@ -4798,42 +5200,42 @@ function referenceValueId(value) {
|
|
|
4798
5200
|
return void 0;
|
|
4799
5201
|
}
|
|
4800
5202
|
var selectOptionValues = (field) => (field.options ?? []).map((opt) => typeof opt === "string" ? opt : opt.value);
|
|
4801
|
-
function validateScalar(field, value,
|
|
5203
|
+
function validateScalar(field, value, path19) {
|
|
4802
5204
|
switch (field.type) {
|
|
4803
5205
|
case "number":
|
|
4804
5206
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
4805
|
-
return [{ fieldPath:
|
|
5207
|
+
return [{ fieldPath: path19, message: "Expected a number" }];
|
|
4806
5208
|
}
|
|
4807
5209
|
return [];
|
|
4808
5210
|
case "boolean":
|
|
4809
5211
|
if (typeof value !== "boolean") {
|
|
4810
|
-
return [{ fieldPath:
|
|
5212
|
+
return [{ fieldPath: path19, message: "Expected true or false" }];
|
|
4811
5213
|
}
|
|
4812
5214
|
return [];
|
|
4813
5215
|
case "datetime":
|
|
4814
5216
|
if (typeof value !== "string" || value !== "" && Number.isNaN(Date.parse(value))) {
|
|
4815
|
-
return [{ fieldPath:
|
|
5217
|
+
return [{ fieldPath: path19, message: "Expected a valid date/time string" }];
|
|
4816
5218
|
}
|
|
4817
5219
|
return [];
|
|
4818
5220
|
case "select": {
|
|
4819
5221
|
if (typeof value !== "string") {
|
|
4820
|
-
return [{ fieldPath:
|
|
5222
|
+
return [{ fieldPath: path19, message: "Expected a selection" }];
|
|
4821
5223
|
}
|
|
4822
5224
|
const options = selectOptionValues(field);
|
|
4823
5225
|
if (value !== "" && !options.includes(value)) {
|
|
4824
|
-
return [{ fieldPath:
|
|
5226
|
+
return [{ fieldPath: path19, message: `Must be one of: ${options.join(", ")}` }];
|
|
4825
5227
|
}
|
|
4826
5228
|
return [];
|
|
4827
5229
|
}
|
|
4828
5230
|
case "reference":
|
|
4829
5231
|
if (referenceValueId(value) === void 0) {
|
|
4830
|
-
return [{ fieldPath:
|
|
5232
|
+
return [{ fieldPath: path19, message: "Expected a reference id" }];
|
|
4831
5233
|
}
|
|
4832
5234
|
return [];
|
|
4833
5235
|
default:
|
|
4834
5236
|
if (STRING_FIELD_TYPES.has(field.type)) {
|
|
4835
5237
|
if (typeof value !== "string") {
|
|
4836
|
-
return [{ fieldPath:
|
|
5238
|
+
return [{ fieldPath: path19, message: "Expected text" }];
|
|
4837
5239
|
}
|
|
4838
5240
|
}
|
|
4839
5241
|
return [];
|
|
@@ -4856,12 +5258,12 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
4856
5258
|
errors.push(...validateEntryData(field.fields, data, pathPrefix));
|
|
4857
5259
|
continue;
|
|
4858
5260
|
}
|
|
4859
|
-
const
|
|
5261
|
+
const path19 = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
|
|
4860
5262
|
const value = data[field.name];
|
|
4861
5263
|
const isRequired = "required" in field && field.required === true;
|
|
4862
5264
|
if (isRequired && isEmptyForRequired(field, value)) {
|
|
4863
5265
|
const message = Array.isArray(value) ? "At least one item is required" : "This field is required";
|
|
4864
|
-
errors.push({ fieldPath:
|
|
5266
|
+
errors.push({ fieldPath: path19, message });
|
|
4865
5267
|
continue;
|
|
4866
5268
|
}
|
|
4867
5269
|
if (value === void 0 || value === null)
|
|
@@ -4869,11 +5271,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
4869
5271
|
if (field.type === "block") {
|
|
4870
5272
|
const blockField = field;
|
|
4871
5273
|
if (!Array.isArray(value)) {
|
|
4872
|
-
errors.push({ fieldPath:
|
|
5274
|
+
errors.push({ fieldPath: path19, message: "Expected a list of blocks" });
|
|
4873
5275
|
continue;
|
|
4874
5276
|
}
|
|
4875
5277
|
value.forEach((item, index) => {
|
|
4876
|
-
const itemPath = `${
|
|
5278
|
+
const itemPath = `${path19}[${index}]`;
|
|
4877
5279
|
if (!isPlainRecord2(item)) {
|
|
4878
5280
|
errors.push({ fieldPath: itemPath, message: "Expected a block object" });
|
|
4879
5281
|
return;
|
|
@@ -4895,11 +5297,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
4895
5297
|
const objectField = field;
|
|
4896
5298
|
if (objectField.list) {
|
|
4897
5299
|
if (!Array.isArray(value)) {
|
|
4898
|
-
errors.push({ fieldPath:
|
|
5300
|
+
errors.push({ fieldPath: path19, message: "Expected a list of items" });
|
|
4899
5301
|
continue;
|
|
4900
5302
|
}
|
|
4901
5303
|
value.forEach((item, index) => {
|
|
4902
|
-
const itemPath = `${
|
|
5304
|
+
const itemPath = `${path19}[${index}]`;
|
|
4903
5305
|
if (!isPlainRecord2(item)) {
|
|
4904
5306
|
errors.push({ fieldPath: itemPath, message: "Expected an object" });
|
|
4905
5307
|
return;
|
|
@@ -4909,27 +5311,27 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
4909
5311
|
}
|
|
4910
5312
|
});
|
|
4911
5313
|
} else if (!isPlainRecord2(value)) {
|
|
4912
|
-
errors.push({ fieldPath:
|
|
5314
|
+
errors.push({ fieldPath: path19, message: "Expected an object" });
|
|
4913
5315
|
} else if (objectField.fields) {
|
|
4914
|
-
errors.push(...validateEntryData(objectField.fields, value,
|
|
5316
|
+
errors.push(...validateEntryData(objectField.fields, value, path19));
|
|
4915
5317
|
}
|
|
4916
5318
|
continue;
|
|
4917
5319
|
}
|
|
4918
5320
|
if ("list" in field && field.list) {
|
|
4919
5321
|
if (!Array.isArray(value)) {
|
|
4920
|
-
errors.push({ fieldPath:
|
|
5322
|
+
errors.push({ fieldPath: path19, message: "Expected a list of values" });
|
|
4921
5323
|
continue;
|
|
4922
5324
|
}
|
|
4923
5325
|
value.forEach((item, index) => {
|
|
4924
|
-
errors.push(...validateScalar(field, item, `${
|
|
5326
|
+
errors.push(...validateScalar(field, item, `${path19}[${index}]`));
|
|
4925
5327
|
});
|
|
4926
5328
|
continue;
|
|
4927
5329
|
}
|
|
4928
5330
|
if (Array.isArray(value) && KNOWN_FIELD_TYPES.has(field.type)) {
|
|
4929
|
-
errors.push({ fieldPath:
|
|
5331
|
+
errors.push({ fieldPath: path19, message: "Expected a single value, not a list" });
|
|
4930
5332
|
continue;
|
|
4931
5333
|
}
|
|
4932
|
-
errors.push(...validateScalar(field, value,
|
|
5334
|
+
errors.push(...validateScalar(field, value, path19));
|
|
4933
5335
|
}
|
|
4934
5336
|
return errors;
|
|
4935
5337
|
}
|
|
@@ -4999,15 +5401,15 @@ async function generateAIContentFiles(options) {
|
|
|
4999
5401
|
config: aiConfig,
|
|
5000
5402
|
entryLinkUrl: config.entryLinkUrl
|
|
5001
5403
|
});
|
|
5002
|
-
const absoluteOutputDir =
|
|
5404
|
+
const absoluteOutputDir = path17.resolve(outputDir) + path17.sep;
|
|
5003
5405
|
let fileCount = 0;
|
|
5004
5406
|
for (const [filePath, content] of result.files) {
|
|
5005
|
-
const absolutePath =
|
|
5407
|
+
const absolutePath = path17.resolve(path17.join(absoluteOutputDir, filePath));
|
|
5006
5408
|
if (!absolutePath.startsWith(absoluteOutputDir)) {
|
|
5007
5409
|
throw new Error(`Path traversal detected in AI content output: ${filePath}`);
|
|
5008
5410
|
}
|
|
5009
|
-
await
|
|
5010
|
-
await
|
|
5411
|
+
await fs16.mkdir(path17.dirname(absolutePath), { recursive: true });
|
|
5412
|
+
await fs16.writeFile(absolutePath, content, "utf-8");
|
|
5011
5413
|
fileCount++;
|
|
5012
5414
|
}
|
|
5013
5415
|
return { fileCount, outputDir: absoluteOutputDir };
|
|
@@ -5018,7 +5420,7 @@ var jiti = createJiti(import.meta.url);
|
|
|
5018
5420
|
async function generateAIContentCLI(options) {
|
|
5019
5421
|
const { projectDir, outputDir = "public/ai", configPath, appDir = "app" } = options;
|
|
5020
5422
|
console.log("\nCanopyCMS generate-ai-content\n");
|
|
5021
|
-
const canopyConfigPath =
|
|
5423
|
+
const canopyConfigPath = path18.join(projectDir, "canopycms.config.ts");
|
|
5022
5424
|
let canopyConfigModule;
|
|
5023
5425
|
try {
|
|
5024
5426
|
canopyConfigModule = await jiti.import(canopyConfigPath);
|
|
@@ -5029,7 +5431,7 @@ async function generateAIContentCLI(options) {
|
|
|
5029
5431
|
}
|
|
5030
5432
|
const configExport = canopyConfigModule.default ?? canopyConfigModule.config ?? canopyConfigModule;
|
|
5031
5433
|
const serverConfig = typeof configExport === "object" && configExport !== null && "server" in configExport ? configExport.server : configExport;
|
|
5032
|
-
const schemasPath =
|
|
5434
|
+
const schemasPath = path18.join(projectDir, appDir, "schemas.ts");
|
|
5033
5435
|
let entrySchemaRegistry = {};
|
|
5034
5436
|
try {
|
|
5035
5437
|
const schemasModule = await jiti.import(schemasPath);
|
|
@@ -5040,7 +5442,7 @@ async function generateAIContentCLI(options) {
|
|
|
5040
5442
|
let aiConfig;
|
|
5041
5443
|
if (configPath) {
|
|
5042
5444
|
try {
|
|
5043
|
-
const aiConfigModule = await jiti.import(
|
|
5445
|
+
const aiConfigModule = await jiti.import(path18.resolve(configPath));
|
|
5044
5446
|
aiConfig = aiConfigModule.aiContentConfig ?? aiConfigModule.default ?? aiConfigModule.config;
|
|
5045
5447
|
} catch (err) {
|
|
5046
5448
|
console.error(`Could not load AI config from ${configPath}`);
|
|
@@ -5057,7 +5459,7 @@ async function generateAIContentCLI(options) {
|
|
|
5057
5459
|
console.error("Make sure canopycms.config.ts uses defineCanopyConfig().");
|
|
5058
5460
|
process.exit(1);
|
|
5059
5461
|
}
|
|
5060
|
-
const resolvedOutput =
|
|
5462
|
+
const resolvedOutput = path18.resolve(projectDir, outputDir);
|
|
5061
5463
|
console.log(` Output: ${resolvedOutput}`);
|
|
5062
5464
|
console.log(` Mode: ${serverConfig.mode ?? "dev"}`);
|
|
5063
5465
|
const result = await generateAIContentFiles({
|