canopycms 0.0.55 → 0.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/branch.d.ts +6 -0
- package/dist/api/branch.d.ts.map +1 -1
- package/dist/api/branch.js +63 -19
- package/dist/api/branch.js.map +1 -1
- package/dist/api/content.d.ts +2 -2
- package/dist/api/entries.d.ts.map +1 -1
- package/dist/api/entries.js +10 -2
- package/dist/api/entries.js.map +1 -1
- package/dist/api/github-sync.d.ts.map +1 -1
- package/dist/api/github-sync.js +28 -7
- package/dist/api/github-sync.js.map +1 -1
- package/dist/auth/caching-auth-plugin.d.ts +8 -1
- package/dist/auth/caching-auth-plugin.d.ts.map +1 -1
- package/dist/auth/caching-auth-plugin.js +6 -1
- package/dist/auth/caching-auth-plugin.js.map +1 -1
- package/dist/auth/plugin.d.ts +19 -10
- package/dist/auth/plugin.d.ts.map +1 -1
- package/dist/auth/plugin.js +12 -10
- package/dist/auth/plugin.js.map +1 -1
- package/dist/branch-metadata.d.ts +71 -6
- package/dist/branch-metadata.d.ts.map +1 -1
- package/dist/branch-metadata.js +132 -116
- package/dist/branch-metadata.js.map +1 -1
- package/dist/branch-registry.d.ts +78 -9
- package/dist/branch-registry.d.ts.map +1 -1
- package/dist/branch-registry.js +155 -29
- package/dist/branch-registry.js.map +1 -1
- package/dist/branch-schema-cache.d.ts +126 -11
- package/dist/branch-schema-cache.d.ts.map +1 -1
- package/dist/branch-schema-cache.js +208 -58
- package/dist/branch-schema-cache.js.map +1 -1
- package/dist/build/generate-ai-content.d.ts.map +1 -1
- package/dist/build/generate-ai-content.js +7 -0
- package/dist/build/generate-ai-content.js.map +1 -1
- package/dist/cli/generate-ai-content.js +2358 -1556
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +16 -8
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +25 -0
- package/dist/cli/migrate.js.map +1 -1
- package/dist/cli/sync.js +5 -5
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/templates.d.ts.map +1 -1
- package/dist/cli/templates.js +6 -3
- package/dist/cli/templates.js.map +1 -1
- package/dist/comment-store.d.ts +45 -15
- package/dist/comment-store.d.ts.map +1 -1
- package/dist/comment-store.js +80 -101
- package/dist/comment-store.js.map +1 -1
- package/dist/config/helpers.d.ts.map +1 -1
- package/dist/config/helpers.js +3 -0
- package/dist/config/helpers.js.map +1 -1
- package/dist/config/schemas/config.d.ts +3 -4
- package/dist/config/schemas/config.d.ts.map +1 -1
- package/dist/config/schemas/config.js +6 -3
- package/dist/config/schemas/config.js.map +1 -1
- package/dist/config/types.d.ts +6 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config-test.d.ts +5 -3
- package/dist/config-test.d.ts.map +1 -1
- package/dist/config-test.js +9 -1
- package/dist/config-test.js.map +1 -1
- package/dist/content-index-generation.d.ts +40 -4
- package/dist/content-index-generation.d.ts.map +1 -1
- package/dist/content-index-generation.js +70 -73
- package/dist/content-index-generation.js.map +1 -1
- package/dist/content-listing.d.ts +3 -1
- package/dist/content-listing.d.ts.map +1 -1
- package/dist/content-listing.js +1 -0
- package/dist/content-listing.js.map +1 -1
- package/dist/content-store.d.ts +63 -0
- package/dist/content-store.d.ts.map +1 -1
- package/dist/content-store.js +421 -184
- package/dist/content-store.js.map +1 -1
- package/dist/editor/Editor.d.ts.map +1 -1
- package/dist/editor/Editor.js +19 -15
- package/dist/editor/Editor.js.map +1 -1
- package/dist/editor/hooks/useDraftManager.d.ts +7 -0
- package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
- package/dist/editor/hooks/useDraftManager.js +69 -31
- package/dist/editor/hooks/useDraftManager.js.map +1 -1
- package/dist/editor/hooks/useSchemaManager.d.ts +34 -10
- package/dist/editor/hooks/useSchemaManager.d.ts.map +1 -1
- package/dist/editor/hooks/useSchemaManager.js +20 -24
- package/dist/editor/hooks/useSchemaManager.js.map +1 -1
- package/dist/editor/schema-editor/CollectionEditor.d.ts +6 -5
- package/dist/editor/schema-editor/CollectionEditor.d.ts.map +1 -1
- package/dist/editor/schema-editor/CollectionEditor.js +9 -4
- package/dist/editor/schema-editor/CollectionEditor.js.map +1 -1
- package/dist/git-manager.d.ts +15 -10
- package/dist/git-manager.d.ts.map +1 -1
- package/dist/git-manager.js +16 -11
- package/dist/git-manager.js.map +1 -1
- package/dist/github-service.d.ts +63 -7
- package/dist/github-service.d.ts.map +1 -1
- package/dist/github-service.js +147 -39
- package/dist/github-service.js.map +1 -1
- package/dist/resource-generation.d.ts +54 -0
- package/dist/resource-generation.d.ts.map +1 -0
- package/dist/resource-generation.js +156 -0
- package/dist/resource-generation.js.map +1 -0
- package/dist/schema/schema-store.d.ts +29 -2
- package/dist/schema/schema-store.d.ts.map +1 -1
- package/dist/schema/schema-store.js +47 -8
- package/dist/schema/schema-store.js.map +1 -1
- package/dist/services.js +4 -0
- package/dist/services.js.map +1 -1
- package/dist/static/index.d.ts +32 -0
- package/dist/static/index.d.ts.map +1 -1
- package/dist/static/index.js +90 -0
- package/dist/static/index.js.map +1 -1
- package/dist/sync-core.js +2 -2
- package/dist/sync-core.js.map +1 -1
- package/dist/utils/occ-json-write.d.ts +100 -0
- package/dist/utils/occ-json-write.d.ts.map +1 -0
- package/dist/utils/occ-json-write.js +234 -0
- package/dist/utils/occ-json-write.js.map +1 -0
- package/dist/worker/cms-worker.d.ts +6 -3
- package/dist/worker/cms-worker.d.ts.map +1 -1
- package/dist/worker/cms-worker.js +42 -41
- package/dist/worker/cms-worker.js.map +1 -1
- package/package.json +2 -1
package/dist/content-store.js
CHANGED
|
@@ -13,6 +13,23 @@ import { isNodeError } from './utils/error.js';
|
|
|
13
13
|
import { filePathExists } from './utils/fs.js';
|
|
14
14
|
import { asRecord, getFormatExtension } from './utils/format.js';
|
|
15
15
|
import { normalizeFilesystemPath, } from './paths/index.js';
|
|
16
|
+
/**
|
|
17
|
+
* Acquire multiple lock keys (via withLock) in a canonical (sorted) order
|
|
18
|
+
* before running `fn`, to rule out AB-BA deadlocks between callers that need
|
|
19
|
+
* overlapping key sets.
|
|
20
|
+
*
|
|
21
|
+
* Only renameEntry() needs two keys today (the source entry's ID lock, plus
|
|
22
|
+
* the destination slug's create-lock -- see ContentStore.createLockKey()).
|
|
23
|
+
* The `id:` and `create:` keyspaces never share a literal string (disjoint
|
|
24
|
+
* prefixes), so two renameEntry() calls can never contend for the exact same
|
|
25
|
+
* pair of keys in reversed roles -- but sorting costs nothing and is cheap
|
|
26
|
+
* insurance against that changing later.
|
|
27
|
+
*/
|
|
28
|
+
async function withLocks(keys, fn) {
|
|
29
|
+
const sorted = Array.from(new Set(keys)).sort();
|
|
30
|
+
const acquireFrom = (index) => index === sorted.length ? fn() : withLock(sorted[index], () => acquireFrom(index + 1));
|
|
31
|
+
return acquireFrom(0);
|
|
32
|
+
}
|
|
16
33
|
export class ContentStoreError extends Error {
|
|
17
34
|
constructor(message, code) {
|
|
18
35
|
super(message);
|
|
@@ -248,6 +265,62 @@ export class ContentStore {
|
|
|
248
265
|
}
|
|
249
266
|
return item;
|
|
250
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Lock key for an existing entry, addressed by its permanent content ID
|
|
270
|
+
* (stable across renames -- the whole point of this locking scheme; see
|
|
271
|
+
* .claude/future-tasks/resolved/content-store-lock-key.md). Namespaced by store
|
|
272
|
+
* root: the same content ID exists in every clone of a branch, and one
|
|
273
|
+
* process can hold ContentStore instances on several clones (dev
|
|
274
|
+
* workspace roots, prod branch clones) at once, so the root prefix keeps
|
|
275
|
+
* the keyspace scoped per-store. It also keeps this ID keyspace disjoint
|
|
276
|
+
* from other modules' raw-path-keyed locks sharing the same withLock()
|
|
277
|
+
* map (comment-store, branch-metadata, etc).
|
|
278
|
+
*/
|
|
279
|
+
idLockKey(id) {
|
|
280
|
+
return `${this.root}:id:${id}`;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Stable collection+slug identifier for lock-key namespacing. Mirrors the
|
|
284
|
+
* schemaItem resolution buildPaths() performs for entry-type delegation:
|
|
285
|
+
* an entry-type item delegates to its parent collection with the slug
|
|
286
|
+
* defaulting to the entry type's own name (see buildPaths()'s
|
|
287
|
+
* `schemaItem.type === 'entry-type'` branch).
|
|
288
|
+
*/
|
|
289
|
+
collectionSlugKey(schemaItem, slug) {
|
|
290
|
+
if (schemaItem.type === 'entry-type') {
|
|
291
|
+
return `${schemaItem.parentPath}/${slug || schemaItem.name}`;
|
|
292
|
+
}
|
|
293
|
+
return `${schemaItem.logicalPath}/${slug}`;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Lock key for a not-yet-existing entry (a create). Serializes concurrent
|
|
297
|
+
* same-slug creates WITHIN this process: the second call's in-lock
|
|
298
|
+
* buildPaths() re-resolution will find the first call's just-written file
|
|
299
|
+
* and fold in as an edit (see write()). A concurrent create racing from a
|
|
300
|
+
* DIFFERENT process is NOT covered by this in-process mutex -- accepted
|
|
301
|
+
* per the epic's design review: each writer mints its own fresh ID and
|
|
302
|
+
* writes its own distinct filename, so both writes succeed and the result
|
|
303
|
+
* is two same-slug files with different IDs (a slug-uniqueness violation
|
|
304
|
+
* surfaced on the next listing/lookup), never a duplicate-ID collision
|
|
305
|
+
* that would poison index rebuilds.
|
|
306
|
+
*/
|
|
307
|
+
createLockKey(schemaItem, slug) {
|
|
308
|
+
return `${this.root}:create:${this.collectionSlugKey(schemaItem, slug)}`;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Lock key for a write()/delete() pre-pass classification. Existing
|
|
312
|
+
* entries lock on their content ID (idLockKey()). Legacy entries with no
|
|
313
|
+
* embedded ID (pre-ID-era filenames -- renameEntry() already refuses to
|
|
314
|
+
* touch these, via its four-part filename check, so there is no rename
|
|
315
|
+
* race to guard against for them) fall back to the physical path, matching
|
|
316
|
+
* this store's original locking behavior for that narrow case. Not-yet-
|
|
317
|
+
* existing entries lock on a per-slug create-key (createLockKey()).
|
|
318
|
+
*/
|
|
319
|
+
entryLockKey(schemaItem, slug, prePass) {
|
|
320
|
+
if (!prePass.existed)
|
|
321
|
+
return this.createLockKey(schemaItem, slug);
|
|
322
|
+
return prePass.id ? this.idLockKey(prePass.id) : prePass.absolutePath;
|
|
323
|
+
}
|
|
251
324
|
/**
|
|
252
325
|
* Build absolute and relative paths with security validation.
|
|
253
326
|
* All entries use the unified filename pattern: {type}.{slug}.{id}.{ext}
|
|
@@ -323,6 +396,7 @@ export class ContentStore {
|
|
|
323
396
|
let id = options.existingId;
|
|
324
397
|
let existingFilename;
|
|
325
398
|
let existingEntryType;
|
|
399
|
+
let foundExisting = false;
|
|
326
400
|
if (!id) {
|
|
327
401
|
// Try to find existing file with this slug
|
|
328
402
|
const entries = await fs.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
@@ -340,8 +414,14 @@ export class ContentStore {
|
|
|
340
414
|
existingFilename = existingFile.name;
|
|
341
415
|
// Extract and preserve entry type from existing file (immutable after creation)
|
|
342
416
|
existingEntryType = extractEntryTypeFromFilename(existingFile.name) || undefined;
|
|
417
|
+
foundExisting = true;
|
|
343
418
|
}
|
|
344
419
|
}
|
|
420
|
+
// An entry "existed" if the directory scan above found it, OR the
|
|
421
|
+
// caller asserted an existingId (a presumed edit -- see buildPaths()'s
|
|
422
|
+
// doc comment on options.existingId). Not the same as `id` being
|
|
423
|
+
// truthy: a brand-new entry gets a freshly generated id below too.
|
|
424
|
+
const existed = foundExisting || Boolean(options.existingId);
|
|
345
425
|
// For existing entries, preserve the entry type (immutable after creation)
|
|
346
426
|
// For new entries, use the specified entry type
|
|
347
427
|
const finalEntryTypeName = existingEntryType || entryTypeName;
|
|
@@ -373,6 +453,7 @@ export class ContentStore {
|
|
|
373
453
|
relativePath: path.relative(this.root, resolved),
|
|
374
454
|
id,
|
|
375
455
|
entryTypeName: finalEntryTypeName,
|
|
456
|
+
existed,
|
|
376
457
|
};
|
|
377
458
|
}
|
|
378
459
|
throw new ContentStoreError('Invalid schema item type', 'VALIDATION');
|
|
@@ -505,115 +586,163 @@ export class ContentStore {
|
|
|
505
586
|
if (expectedFormat !== input.format) {
|
|
506
587
|
throw new ContentStoreError(`Format mismatch: expects ${expectedFormat}, got ${input.format}`, 'VALIDATION');
|
|
507
588
|
}
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
589
|
+
// Pre-pass: resolve paths OUTSIDE the lock, but ONLY to classify
|
|
590
|
+
// existing-vs-new and pick a stable lock key -- never used for the
|
|
591
|
+
// actual write. This trades away the old "validation errors surface
|
|
592
|
+
// before the lock" property for buildPaths() specifically (the format
|
|
593
|
+
// check above still runs unlocked): buildPaths() resolves the physical
|
|
594
|
+
// path by directory-scanning for the slug, and that resolution can go
|
|
595
|
+
// stale the instant a concurrent renameEntry() completes, so it must be
|
|
596
|
+
// re-resolved (ground truth) after the lock is held -- see
|
|
597
|
+
// .claude/future-tasks/resolved/content-store-lock-key.md and entryLockKey().
|
|
598
|
+
const prePass = await this.buildPaths(schemaItem, slug, { entryTypeName, existingId });
|
|
599
|
+
let lockKey = this.entryLockKey(schemaItem, slug, prePass);
|
|
600
|
+
// Reclassification loop: the pre-pass picked the lock key, but the world
|
|
601
|
+
// can change between the pre-pass and lock acquisition (a concurrent
|
|
602
|
+
// renameEntry() freeing this slug flips existing->new; a concurrent
|
|
603
|
+
// create landing flips new->existing). Writing under the WRONG kind of
|
|
604
|
+
// key would bypass serialization against the writers holding the right
|
|
605
|
+
// one (e.g. a reclassified-to-create write under an id-key racing a
|
|
606
|
+
// create-key holder on the same slug -> two same-slug files). So after
|
|
607
|
+
// the in-lock re-resolution, re-derive the key; on mismatch, release and
|
|
608
|
+
// re-acquire under the current key. Bounded: each flip requires another
|
|
609
|
+
// mutator to have completed in the gap; the cap only guards pathological
|
|
610
|
+
// scheduling.
|
|
611
|
+
const RETRY_KEY = Symbol('retry-with-new-lock-key');
|
|
612
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
613
|
+
const outcome = await withLock(lockKey, async () => {
|
|
614
|
+
// Re-resolve inside the lock: ground truth after acquisition. A
|
|
615
|
+
// concurrent renameEntry() may have moved this entry between the
|
|
616
|
+
// pre-pass above and acquiring this lock.
|
|
617
|
+
const inLock = await this.buildPaths(schemaItem, slug, {
|
|
618
|
+
entryTypeName,
|
|
619
|
+
existingId,
|
|
620
|
+
});
|
|
621
|
+
const currentKey = this.entryLockKey(schemaItem, slug, inLock);
|
|
622
|
+
if (currentKey !== lockKey) {
|
|
623
|
+
lockKey = currentKey;
|
|
624
|
+
return RETRY_KEY;
|
|
625
|
+
}
|
|
626
|
+
const { absolutePath, relativePath, id } = inLock;
|
|
627
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
628
|
+
// OCC: if caller supplied a version token, reject stale writes
|
|
629
|
+
if (input.expectedVersion !== undefined) {
|
|
630
|
+
try {
|
|
631
|
+
const existing = await fs.stat(absolutePath);
|
|
632
|
+
if (existing.mtimeMs !== input.expectedVersion) {
|
|
633
|
+
throw new ContentConflictError();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
catch (err) {
|
|
637
|
+
if (err instanceof ContentConflictError)
|
|
638
|
+
throw err;
|
|
639
|
+
if (isNodeError(err) && err.code === 'ENOENT') {
|
|
640
|
+
// File doesn't exist yet — first write, skip version check
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
throw err;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Existence guard (cross-process): the caller asserts this entry already
|
|
648
|
+
// exists (existingId), so if no file is at the path we are about to
|
|
649
|
+
// write, this store's index may be stale — another process may have
|
|
650
|
+
// renamed the entry. Recreating a renamed entry's old path would leave
|
|
651
|
+
// two files with the same embedded ID and poison every subsequent index
|
|
652
|
+
// rebuild (ID collision). The directory listing is authoritative on this
|
|
653
|
+
// host: if the ID's actual on-disk location differs from what our index
|
|
654
|
+
// believes, fail with a conflict so the caller reloads fresh state.
|
|
655
|
+
// (An intentional slug-change save passes: the index and the directory
|
|
656
|
+
// agree on the entry's current — old-slug — path. External deletes also
|
|
657
|
+
// pass: the ID is nowhere on disk, so recreating is last-writer-wins.)
|
|
658
|
+
//
|
|
659
|
+
// indexedRelPath reads the LIVE index synchronously — NOT idIndex() —
|
|
660
|
+
// because idIndex() would run a full rescan while holding the entry
|
|
661
|
+
// lock if invalidateIndex() fired between the pre-lock warm-up above
|
|
662
|
+
// and here. The live index may then be stale, but staleness only errs
|
|
663
|
+
// toward throwing ContentConflictError: actualRelPath comes from the
|
|
664
|
+
// fresh in-lock directory scan just below (ground truth), so a stale
|
|
665
|
+
// indexedRelPath can only turn agree->disagree (spurious conflict,
|
|
666
|
+
// which the caller already handles by reloading), never
|
|
667
|
+
// disagree->agree. Fail-closed, never fail-open.
|
|
668
|
+
if (existingId && !(await filePathExists(absolutePath))) {
|
|
669
|
+
const actualRelPath = await this.findEntryPathById(path.dirname(absolutePath), existingId);
|
|
670
|
+
const indexedRelPath = this._idIndex.findById(existingId)?.relativePath ?? null;
|
|
671
|
+
if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
|
|
520
672
|
throw new ContentConflictError();
|
|
521
673
|
}
|
|
522
674
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
675
|
+
// Serialize content string
|
|
676
|
+
let content;
|
|
677
|
+
if (input.format === 'json') {
|
|
678
|
+
content = `${JSON.stringify(input.data ?? {}, null, 2)}\n`;
|
|
679
|
+
}
|
|
680
|
+
else if (input.format === 'yaml') {
|
|
681
|
+
content = yamlStringify(input.data ?? {});
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
content = matter.stringify(input.body, input.data ?? {});
|
|
685
|
+
}
|
|
686
|
+
await atomicWriteFile(absolutePath, content);
|
|
687
|
+
// Update the ID index after a successful write. Look up and mutate the
|
|
688
|
+
// LIVE index in one synchronous window (no awaits in between): a
|
|
689
|
+
// concurrent rebuild may have swapped in a fresh instance since the
|
|
690
|
+
// pre-write snapshot, and updates must land where future lookups go.
|
|
691
|
+
const liveIndex = this._idIndex;
|
|
692
|
+
let staleOldAbsPath = null;
|
|
693
|
+
if (id) {
|
|
694
|
+
const existing = liveIndex.findById(id);
|
|
695
|
+
if (existing) {
|
|
696
|
+
if (existing.relativePath !== relativePath) {
|
|
697
|
+
// Slug changed — remember the orphaned old path to delete below
|
|
698
|
+
staleOldAbsPath = path.join(this.root, existing.relativePath);
|
|
699
|
+
liveIndex.updatePath(existing.id, relativePath);
|
|
700
|
+
}
|
|
528
701
|
}
|
|
529
702
|
else {
|
|
530
|
-
|
|
703
|
+
liveIndex.add({
|
|
704
|
+
type: 'entry',
|
|
705
|
+
relativePath,
|
|
706
|
+
collection: collectionPath,
|
|
707
|
+
slug: slug || undefined,
|
|
708
|
+
});
|
|
531
709
|
}
|
|
532
710
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
// two files with the same embedded ID and poison every subsequent index
|
|
539
|
-
// rebuild (ID collision). The directory listing is authoritative on this
|
|
540
|
-
// host: if the ID's actual on-disk location differs from what our index
|
|
541
|
-
// believes, fail with a conflict so the caller reloads fresh state.
|
|
542
|
-
// (An intentional slug-change save passes: the index and the directory
|
|
543
|
-
// agree on the entry's current — old-slug — path. External deletes also
|
|
544
|
-
// pass: the ID is nowhere on disk, so recreating is last-writer-wins.)
|
|
545
|
-
if (existingId && !(await filePathExists(absolutePath))) {
|
|
546
|
-
const actualRelPath = await this.findEntryPathById(path.dirname(absolutePath), existingId);
|
|
547
|
-
const indexedRelPath = (await this.idIndex()).findById(existingId)?.relativePath ?? null;
|
|
548
|
-
if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
|
|
549
|
-
throw new ContentConflictError();
|
|
711
|
+
if (staleOldAbsPath) {
|
|
712
|
+
await fs.unlink(staleOldAbsPath).catch((err) => {
|
|
713
|
+
if (!isNodeError(err) || err.code !== 'ENOENT')
|
|
714
|
+
throw err;
|
|
715
|
+
});
|
|
550
716
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
await atomicWriteFile(absolutePath, content);
|
|
564
|
-
// Update the ID index after a successful write. Look up and mutate the
|
|
565
|
-
// LIVE index in one synchronous window (no awaits in between): a
|
|
566
|
-
// concurrent rebuild may have swapped in a fresh instance since the
|
|
567
|
-
// pre-write snapshot, and updates must land where future lookups go.
|
|
568
|
-
const liveIndex = this._idIndex;
|
|
569
|
-
let staleOldAbsPath = null;
|
|
570
|
-
if (id) {
|
|
571
|
-
const existing = liveIndex.findById(id);
|
|
572
|
-
if (existing) {
|
|
573
|
-
if (existing.relativePath !== relativePath) {
|
|
574
|
-
// Slug changed — remember the orphaned old path to delete below
|
|
575
|
-
staleOldAbsPath = path.join(this.root, existing.relativePath);
|
|
576
|
-
liveIndex.updatePath(existing.id, relativePath);
|
|
577
|
-
}
|
|
717
|
+
await this.recordOwnMutation(liveIndex);
|
|
718
|
+
const afterStat = await fs.stat(absolutePath);
|
|
719
|
+
const base = {
|
|
720
|
+
collection: schemaItem.logicalPath,
|
|
721
|
+
collectionName: schemaItem.name,
|
|
722
|
+
relativePath,
|
|
723
|
+
absolutePath,
|
|
724
|
+
version: afterStat.mtimeMs,
|
|
725
|
+
};
|
|
726
|
+
if (input.format === 'json') {
|
|
727
|
+
return { ...base, format: 'json', data: input.data ?? {} };
|
|
578
728
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
type: 'entry',
|
|
582
|
-
relativePath,
|
|
583
|
-
collection: collectionPath,
|
|
584
|
-
slug: slug || undefined,
|
|
585
|
-
});
|
|
729
|
+
if (input.format === 'yaml') {
|
|
730
|
+
return { ...base, format: 'yaml', data: input.data ?? {} };
|
|
586
731
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
version: afterStat.mtimeMs,
|
|
602
|
-
};
|
|
603
|
-
if (input.format === 'json') {
|
|
604
|
-
return { ...base, format: 'json', data: input.data ?? {} };
|
|
605
|
-
}
|
|
606
|
-
if (input.format === 'yaml') {
|
|
607
|
-
return { ...base, format: 'yaml', data: input.data ?? {} };
|
|
608
|
-
}
|
|
609
|
-
return {
|
|
610
|
-
...base,
|
|
611
|
-
format: input.format,
|
|
612
|
-
data: input.data ?? {},
|
|
613
|
-
body: input.body,
|
|
614
|
-
bodyFieldName: findBodyFieldName(fields),
|
|
615
|
-
};
|
|
616
|
-
});
|
|
732
|
+
return {
|
|
733
|
+
...base,
|
|
734
|
+
format: input.format,
|
|
735
|
+
data: input.data ?? {},
|
|
736
|
+
body: input.body,
|
|
737
|
+
bodyFieldName: findBodyFieldName(fields),
|
|
738
|
+
};
|
|
739
|
+
});
|
|
740
|
+
if (typeof outcome !== 'symbol')
|
|
741
|
+
return outcome;
|
|
742
|
+
}
|
|
743
|
+
// Ten completed foreign mutations landed in our acquisition gaps in a
|
|
744
|
+
// row — treat as contention and let the caller reload + retry.
|
|
745
|
+
throw new ContentConflictError();
|
|
617
746
|
}
|
|
618
747
|
/**
|
|
619
748
|
* Read an entry by its ID (UUID).
|
|
@@ -713,6 +842,18 @@ export class ContentStore {
|
|
|
713
842
|
.catch(() => []);
|
|
714
843
|
return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
|
|
715
844
|
}
|
|
845
|
+
/**
|
|
846
|
+
* Test-only seam: awaited right after the pre-pass buildPaths() resolves
|
|
847
|
+
* in delete() and renameEntry(), before the lock key is used to acquire
|
|
848
|
+
* anything. No-op in production. A test subclass can override this to
|
|
849
|
+
* inject a controlled pause in that exact window, letting a test
|
|
850
|
+
* deterministically run OTHER mutations (which don't hold this call's
|
|
851
|
+
* not-yet-acquired lock) to completion before this call proceeds to
|
|
852
|
+
* acquire its (possibly now-stale) lock key -- see the "Deterministic
|
|
853
|
+
* interleavings" testing pattern in docs/concurrency.md and
|
|
854
|
+
* branch-registry.test.ts's `BlockingRegistry` for the same idiom.
|
|
855
|
+
*/
|
|
856
|
+
async afterPrePassForTesting() { }
|
|
716
857
|
/**
|
|
717
858
|
* Delete an entry and remove it from the index.
|
|
718
859
|
*/
|
|
@@ -720,19 +861,61 @@ export class ContentStore {
|
|
|
720
861
|
// Warm the index outside the lock (a full scan must not hold the entry lock)
|
|
721
862
|
await this.idIndex();
|
|
722
863
|
const collection = this.assertCollection(collectionPath);
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
}
|
|
864
|
+
// Pre-pass: classify existing-vs-already-gone via a directory scan
|
|
865
|
+
// (local ground truth) -- not this._idIndex, which can be stale in
|
|
866
|
+
// exactly the way this locking scheme guards against (a rename this
|
|
867
|
+
// store hasn't observed yet).
|
|
868
|
+
const prePass = await this.buildPaths(collection, slug);
|
|
869
|
+
if (!prePass.existed) {
|
|
870
|
+
// Nothing on disk for this slug -- no shared resource to lock on, and
|
|
871
|
+
// this matches the store's original behavior (fs.unlink throwing
|
|
872
|
+
// ENOENT on the pre-pass's freshly generated, necessarily-nonexistent
|
|
873
|
+
// path).
|
|
874
|
+
await fs.unlink(prePass.absolutePath);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
let lockKey = this.entryLockKey(collection, slug, prePass);
|
|
878
|
+
await this.afterPrePassForTesting();
|
|
879
|
+
// Reclassification loop, mirroring write()'s (see its doc comment): the
|
|
880
|
+
// pre-pass's lock key can go stale between resolution and acquisition --
|
|
881
|
+
// e.g. a concurrent renameEntry() moving this slug's entry away flips
|
|
882
|
+
// the key this slug would resolve to. Re-derive the key from the in-lock
|
|
883
|
+
// ground truth and retry under the corrected key on mismatch, bounded to
|
|
884
|
+
// rule out only pathological scheduling.
|
|
885
|
+
const RETRY_KEY = Symbol('retry-with-new-lock-key');
|
|
886
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
887
|
+
const outcome = await withLock(lockKey, async () => {
|
|
888
|
+
// Re-resolve inside the lock: ground truth after acquisition. If a
|
|
889
|
+
// concurrent renameEntry() moved this slug away in the meantime,
|
|
890
|
+
// buildPaths() no longer finds it here (inLock.existed is false) --
|
|
891
|
+
// the entry genuinely isn't at this slug anymore, so we fall through
|
|
892
|
+
// to the unlink below on a freshly generated (nonexistent) path,
|
|
893
|
+
// preserving the original ENOENT-throwing behavior.
|
|
894
|
+
const inLock = await this.buildPaths(collection, slug);
|
|
895
|
+
const currentKey = this.entryLockKey(collection, slug, inLock);
|
|
896
|
+
if (currentKey !== lockKey) {
|
|
897
|
+
lockKey = currentKey;
|
|
898
|
+
return RETRY_KEY;
|
|
899
|
+
}
|
|
900
|
+
const { absolutePath, relativePath } = inLock;
|
|
901
|
+
// Delete file
|
|
902
|
+
await fs.unlink(absolutePath);
|
|
903
|
+
// Remove from the LIVE index — lookup and mutation in one synchronous
|
|
904
|
+
// window, since a concurrent rebuild may swap instances across awaits.
|
|
905
|
+
const liveIndex = this._idIndex;
|
|
906
|
+
const id = liveIndex.findByPath(relativePath);
|
|
907
|
+
if (id) {
|
|
908
|
+
liveIndex.remove(id);
|
|
909
|
+
}
|
|
910
|
+
await this.recordOwnMutation(liveIndex);
|
|
911
|
+
return undefined;
|
|
912
|
+
});
|
|
913
|
+
if (outcome !== RETRY_KEY)
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
// Ten completed foreign mutations landed in our acquisition gaps in a
|
|
917
|
+
// row — treat as contention and let the caller reload + retry.
|
|
918
|
+
throw new ContentConflictError();
|
|
736
919
|
}
|
|
737
920
|
/**
|
|
738
921
|
* Rename an entry by changing its slug (middle segment of filename).
|
|
@@ -758,82 +941,136 @@ export class ContentStore {
|
|
|
758
941
|
if (currentSlug === safeNewSlug) {
|
|
759
942
|
return { newPath: `${collectionPath}/${currentSlug}` };
|
|
760
943
|
}
|
|
761
|
-
//
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
944
|
+
// Pre-pass: classify via a directory scan (local ground truth, not the
|
|
945
|
+
// index) and pick the source entry's stable lock key.
|
|
946
|
+
const prePass = await this.buildPaths(collection, currentSlug);
|
|
947
|
+
if (!prePass.existed) {
|
|
948
|
+
throw new ContentStoreError(`Entry not found: ${currentSlug}`, 'NOT_FOUND');
|
|
949
|
+
}
|
|
950
|
+
let sourceLockKey = this.entryLockKey(collection, currentSlug, prePass);
|
|
951
|
+
let sourceId = prePass.id;
|
|
952
|
+
// Also lock the destination create-key. Content ID is rename-invariant,
|
|
953
|
+
// so the SOURCE side only ever needs one lock -- but the DESTINATION
|
|
954
|
+
// slug can simultaneously be the target of a concurrent write() creating
|
|
955
|
+
// a brand-new entry there. Without this second lock, that write()'s
|
|
956
|
+
// in-lock buildPaths() re-resolution could observe the just-linked
|
|
957
|
+
// destination file and silently fold in as an "edit" of the renamed
|
|
958
|
+
// entry -- using the create's caller-requested entry type/schema against
|
|
959
|
+
// a file that's actually a different (renamed-in) entry. Acquiring both
|
|
960
|
+
// keys in canonical (sorted) order rules out AB-BA deadlocks; in
|
|
961
|
+
// practice the id: and create: keyspaces never share a literal key
|
|
962
|
+
// string (disjoint prefixes), so two renameEntry() calls can never
|
|
963
|
+
// contend for the exact same pair of keys in reversed roles, but sorting
|
|
964
|
+
// costs nothing.
|
|
965
|
+
let destLockKey = this.createLockKey(collection, safeNewSlug);
|
|
966
|
+
await this.afterPrePassForTesting();
|
|
967
|
+
// Reclassification loop, mirroring write()'s (see its doc comment): the
|
|
968
|
+
// pre-pass's source key can go stale between resolution and acquisition
|
|
969
|
+
// -- e.g. this entry was renamed away by another writer and a brand-new,
|
|
970
|
+
// different-ID entry landed at this same slug in the gap. Re-derive the
|
|
971
|
+
// source key from the in-lock ground truth AND verify the resolved
|
|
972
|
+
// file's embedded ID still matches the pre-pass ID (a path-based
|
|
973
|
+
// fallback key for legacy no-ID filenames can't otherwise distinguish
|
|
974
|
+
// "same file" from "a different legacy file now occupies this slug").
|
|
975
|
+
// On either mismatch, release and retry under the corrected keys rather
|
|
976
|
+
// than renaming the wrong entry under the stale lock -- bounded to rule
|
|
977
|
+
// out only pathological scheduling.
|
|
978
|
+
const RETRY_KEY = Symbol('retry-with-new-lock-key');
|
|
979
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
980
|
+
const outcome = await withLocks([sourceLockKey, destLockKey], async () => {
|
|
981
|
+
// Re-resolve inside the lock: ground truth after acquisition.
|
|
982
|
+
const inLock = await this.buildPaths(collection, currentSlug);
|
|
983
|
+
if (!inLock.existed) {
|
|
984
|
+
// Entry genuinely isn't at this slug anymore (e.g. deleted) --
|
|
985
|
+
// matches the original access()-based NOT_FOUND behavior.
|
|
986
|
+
throw new ContentStoreError(`Entry not found: ${currentSlug}`, 'NOT_FOUND');
|
|
987
|
+
}
|
|
988
|
+
const currentSourceKey = this.entryLockKey(collection, currentSlug, inLock);
|
|
989
|
+
// The dest create-key is purely slug-derived and can't actually
|
|
990
|
+
// change across attempts, but recompute for uniformity with the
|
|
991
|
+
// source side.
|
|
992
|
+
const currentDestKey = this.createLockKey(collection, safeNewSlug);
|
|
993
|
+
if (currentSourceKey !== sourceLockKey || inLock.id !== sourceId) {
|
|
994
|
+
sourceLockKey = currentSourceKey;
|
|
995
|
+
destLockKey = currentDestKey;
|
|
996
|
+
sourceId = inLock.id;
|
|
997
|
+
return RETRY_KEY;
|
|
998
|
+
}
|
|
999
|
+
destLockKey = currentDestKey;
|
|
1000
|
+
const { absolutePath: currentPath, relativePath: currentRelPath } = inLock;
|
|
1001
|
+
// Extract entry type name and extension from current filename
|
|
1002
|
+
const currentFilename = path.basename(currentPath);
|
|
1003
|
+
const parts = currentFilename.split('.');
|
|
1004
|
+
if (parts.length < 4) {
|
|
1005
|
+
throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, 'VALIDATION');
|
|
1006
|
+
}
|
|
1007
|
+
const entryTypeName = parts[0];
|
|
1008
|
+
const contentId = parts[parts.length - 2];
|
|
1009
|
+
const ext = `.${parts[parts.length - 1]}`;
|
|
1010
|
+
// Build new filename with new slug
|
|
1011
|
+
const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
|
|
1012
|
+
const parentDir = path.dirname(currentPath);
|
|
1013
|
+
const newPath = path.join(parentDir, newFilename);
|
|
1014
|
+
// Check if any file with the new slug already exists (regardless of ID)
|
|
1015
|
+
// This catches same-slug-different-ID conflicts that link() alone cannot prevent
|
|
1016
|
+
try {
|
|
1017
|
+
const entries = await fs.readdir(parentDir, { withFileTypes: true });
|
|
1018
|
+
for (const entry of entries) {
|
|
1019
|
+
if (entry.isDirectory())
|
|
1020
|
+
continue;
|
|
1021
|
+
const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
|
|
1022
|
+
if (existingSlug === safeNewSlug) {
|
|
1023
|
+
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, 'VALIDATION');
|
|
1024
|
+
}
|
|
794
1025
|
}
|
|
795
1026
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1027
|
+
catch (err) {
|
|
1028
|
+
if (err instanceof ContentStoreError)
|
|
1029
|
+
throw err;
|
|
1030
|
+
// Ignore filesystem errors (e.g. ENOENT if parent dir doesn't exist)
|
|
1031
|
+
}
|
|
1032
|
+
// Use link()+unlink() instead of rename() so a concurrent cross-process rename to the
|
|
1033
|
+
// exact same destination path fails with EEXIST rather than silently overwriting.
|
|
1034
|
+
//
|
|
1035
|
+
// Tradeoff: this is a two-step operation, not a single atomic syscall. If unlink()
|
|
1036
|
+
// fails after a successful link() (e.g. a transient EFS error), both the old and new
|
|
1037
|
+
// slug files will exist pointing at the same inode. The ID index will reflect the new
|
|
1038
|
+
// path, so subsequent reads work, but the orphaned source file will persist until the
|
|
1039
|
+
// next rename or deletion of that entry. This is an acceptable tradeoff: the EEXIST
|
|
1040
|
+
// protection on link() prevents silent data loss on concurrent renames, and the
|
|
1041
|
+
// partial-failure case is detectable and recoverable. Note: write()/delete()/
|
|
1042
|
+
// renameEntry() now lock on content ID (see idLockKey()), so a concurrent write() or
|
|
1043
|
+
// delete() targeting this same entry is fully serialized against this rename and
|
|
1044
|
+
// cannot observe this partial-failure window; only a genuinely separate process
|
|
1045
|
+
// acting directly on the filesystem without going through this store could.
|
|
1046
|
+
try {
|
|
1047
|
+
await fs.link(currentPath, newPath);
|
|
1048
|
+
}
|
|
1049
|
+
catch (err) {
|
|
1050
|
+
if (isNodeError(err) && err.code === 'EEXIST') {
|
|
1051
|
+
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, 'VALIDATION');
|
|
1052
|
+
}
|
|
799
1053
|
throw err;
|
|
800
|
-
// Ignore filesystem errors (e.g. ENOENT if parent dir doesn't exist)
|
|
801
|
-
}
|
|
802
|
-
// Use link()+unlink() instead of rename() so a concurrent cross-process rename to the
|
|
803
|
-
// exact same destination path fails with EEXIST rather than silently overwriting.
|
|
804
|
-
//
|
|
805
|
-
// Tradeoff: this is a two-step operation, not a single atomic syscall. If unlink()
|
|
806
|
-
// fails after a successful link() (e.g. a transient EFS error), both the old and new
|
|
807
|
-
// slug files will exist pointing at the same inode. The ID index will reflect the new
|
|
808
|
-
// path, so subsequent reads work, but the orphaned source file will persist until the
|
|
809
|
-
// next rename or deletion of that entry. This is an acceptable tradeoff: the EEXIST
|
|
810
|
-
// protection on link() prevents silent data loss on concurrent renames, and the
|
|
811
|
-
// partial-failure case is detectable and recoverable. See also:
|
|
812
|
-
// .claude/future-tasks/content-store-lock-key.md — when lock keys migrate to content
|
|
813
|
-
// IDs, rename and write on the same entry will be fully serialized, further reducing
|
|
814
|
-
// this window.
|
|
815
|
-
try {
|
|
816
|
-
await fs.link(currentPath, newPath);
|
|
817
|
-
}
|
|
818
|
-
catch (err) {
|
|
819
|
-
if (isNodeError(err) && err.code === 'EEXIST') {
|
|
820
|
-
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, 'VALIDATION');
|
|
821
1054
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1055
|
+
await fs.unlink(currentPath);
|
|
1056
|
+
// Update the LIVE index — lookup and mutation in one synchronous window,
|
|
1057
|
+
// since a concurrent rebuild may swap instances across awaits.
|
|
1058
|
+
const newRelativePath = path.relative(this.root, newPath);
|
|
1059
|
+
const liveIndex = this._idIndex;
|
|
1060
|
+
const entryId = liveIndex.findByPath(currentRelPath);
|
|
1061
|
+
if (entryId) {
|
|
1062
|
+
liveIndex.updatePath(entryId, newRelativePath);
|
|
1063
|
+
}
|
|
1064
|
+
await this.recordOwnMutation(liveIndex);
|
|
1065
|
+
// Return new logical path
|
|
1066
|
+
return { newPath: `${collectionPath}/${safeNewSlug}` };
|
|
1067
|
+
});
|
|
1068
|
+
if (typeof outcome !== 'symbol')
|
|
1069
|
+
return outcome;
|
|
1070
|
+
}
|
|
1071
|
+
// Ten completed foreign mutations landed in our acquisition gaps in a
|
|
1072
|
+
// row — treat as contention and let the caller reload + retry.
|
|
1073
|
+
throw new ContentConflictError();
|
|
837
1074
|
}
|
|
838
1075
|
/**
|
|
839
1076
|
* List all entries in a collection tree (including subcollections).
|