canopycms 0.0.67-int.90 → 0.0.67
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/README.md +30 -3
- package/dist/ai/generate.js +1 -27
- package/dist/ai/handler.d.ts +0 -4
- package/dist/ai/handler.js +0 -11
- package/dist/ai/index.d.ts +1 -8
- package/dist/ai/index.js +1 -8
- package/dist/ai/json-to-markdown.js +15 -58
- package/dist/ai/resolve-branch.d.ts +2 -7
- package/dist/ai/resolve-branch.js +2 -8
- package/dist/ai/strip-mdx.d.ts +5 -13
- package/dist/ai/strip-mdx.js +5 -17
- package/dist/ai/to-plain-text.d.ts +10 -28
- package/dist/ai/to-plain-text.js +10 -28
- package/dist/ai/transform-components.d.ts +2 -4
- package/dist/ai/transform-components.js +8 -19
- package/dist/ai/types.d.ts +5 -10
- package/dist/api/admin-branch-health.d.ts +4 -12
- package/dist/api/admin-branch-health.js +26 -66
- package/dist/api/admin.d.ts +4 -12
- package/dist/api/admin.js +10 -26
- package/dist/api/assets.d.ts +0 -25
- package/dist/api/assets.js +50 -91
- package/dist/api/branch-merge.d.ts +1 -1
- package/dist/api/branch-merge.js +0 -2
- package/dist/api/branch-review.d.ts +0 -8
- package/dist/api/branch-review.js +0 -13
- package/dist/api/branch-status.d.ts +0 -3
- package/dist/api/branch-status.js +8 -33
- package/dist/api/branch-withdraw.d.ts +0 -1
- package/dist/api/branch-withdraw.js +6 -9
- package/dist/api/branch.d.ts +7 -12
- package/dist/api/branch.js +7 -44
- package/dist/api/client.d.ts +71 -224
- package/dist/api/client.js +82 -233
- package/dist/api/comments.d.ts +2 -4
- package/dist/api/comments.js +0 -17
- package/dist/api/content.d.ts +0 -6
- package/dist/api/content.js +66 -142
- package/dist/api/entries-constants.d.ts +5 -9
- package/dist/api/entries-constants.js +5 -9
- package/dist/api/entries.d.ts +8 -13
- package/dist/api/entries.js +20 -51
- package/dist/api/github-sync.d.ts +2 -4
- package/dist/api/github-sync.js +4 -8
- package/dist/api/groups.d.ts +3 -0
- package/dist/api/groups.js +8 -21
- package/dist/api/guards.d.ts +4 -16
- package/dist/api/guards.js +3 -22
- package/dist/api/index.d.ts +4 -4
- package/dist/api/permissions.d.ts +2 -0
- package/dist/api/permissions.js +18 -40
- package/dist/api/reference-options.d.ts +0 -1
- package/dist/api/reference-options.js +3 -11
- package/dist/api/request-body-hash.d.ts +12 -23
- package/dist/api/request-body-hash.js +12 -23
- package/dist/api/resolve-references.d.ts +0 -1
- package/dist/api/resolve-references.js +13 -28
- package/dist/api/route-builder.d.ts +3 -7
- package/dist/api/route-builder.js +1 -1
- package/dist/api/routes.d.ts +12 -0
- package/dist/api/routes.js +47 -0
- package/dist/api/schema.d.ts +23 -33
- package/dist/api/schema.js +20 -74
- package/dist/api/settings-helpers.js +4 -5
- package/dist/api/types.d.ts +0 -5
- package/dist/api/user.js +0 -5
- package/dist/api/validators.d.ts +14 -52
- package/dist/api/validators.js +14 -52
- package/dist/assets/asset-prefixes.d.ts +4 -8
- package/dist/assets/asset-prefixes.js +4 -8
- package/dist/assets/asset-src.d.ts +5 -22
- package/dist/assets/asset-src.js +5 -21
- package/dist/assets/factory.d.ts +1 -2
- package/dist/assets/factory.js +1 -2
- package/dist/assets/finalize.d.ts +3 -1
- package/dist/assets/finalize.js +3 -1
- package/dist/assets/index.d.ts +3 -3
- package/dist/assets/index.js +1 -1
- package/dist/assets/keys.d.ts +5 -3
- package/dist/assets/keys.js +6 -3
- package/dist/assets/pipeline.d.ts +3 -2
- package/dist/assets/pipeline.js +22 -56
- package/dist/assets/sharp-loader.d.ts +2 -10
- package/dist/assets/sharp-loader.js +2 -10
- package/dist/assets/store-local.d.ts +1 -2
- package/dist/assets/store-local.js +1 -2
- package/dist/assets/store-s3.d.ts +3 -4
- package/dist/assets/svg-sanitizer.d.ts +6 -14
- package/dist/assets/svg-sanitizer.js +6 -14
- package/dist/assets/transform-directives.d.ts +3 -4
- package/dist/assets/transform-directives.js +1 -1
- package/dist/assets/transform.d.ts +19 -30
- package/dist/assets/transform.js +18 -31
- package/dist/auth/cache.d.ts +2 -3
- package/dist/auth/cache.js +2 -3
- package/dist/auth/caching-auth-plugin.d.ts +11 -22
- package/dist/auth/caching-auth-plugin.js +12 -14
- package/dist/auth/context-helpers.d.ts +6 -20
- package/dist/auth/context-helpers.js +5 -17
- package/dist/auth/file-based-auth-cache.d.ts +10 -21
- package/dist/auth/file-based-auth-cache.js +23 -51
- package/dist/auth/plugin.d.ts +29 -61
- package/dist/auth/plugin.js +9 -13
- package/dist/auth/types.d.ts +11 -20
- package/dist/authorization/branch.d.ts +30 -49
- package/dist/authorization/branch.js +16 -42
- package/dist/authorization/content.d.ts +11 -40
- package/dist/authorization/content.js +13 -45
- package/dist/authorization/groups/index.d.ts +0 -3
- package/dist/authorization/groups/index.js +0 -3
- package/dist/authorization/groups/loader.d.ts +14 -28
- package/dist/authorization/groups/loader.js +14 -38
- package/dist/authorization/groups/schema.d.ts +0 -6
- package/dist/authorization/groups/schema.js +4 -7
- package/dist/authorization/helpers.d.ts +9 -28
- package/dist/authorization/helpers.js +9 -28
- package/dist/authorization/index.d.ts +3 -25
- package/dist/authorization/index.js +3 -35
- package/dist/authorization/path.d.ts +6 -13
- package/dist/authorization/path.js +8 -25
- package/dist/authorization/permissions/index.d.ts +0 -3
- package/dist/authorization/permissions/index.js +0 -3
- package/dist/authorization/permissions/loader.d.ts +14 -27
- package/dist/authorization/permissions/loader.js +14 -32
- package/dist/authorization/permissions/schema.d.ts +1 -6
- package/dist/authorization/permissions/schema.js +6 -13
- package/dist/authorization/protected-branch.d.ts +36 -65
- package/dist/authorization/protected-branch.js +29 -41
- package/dist/authorization/settings-file-store.d.ts +38 -81
- package/dist/authorization/settings-file-store.js +38 -79
- package/dist/authorization/types.d.ts +4 -26
- package/dist/authorization/types.js +0 -6
- package/dist/authorization/validation.d.ts +1 -23
- package/dist/authorization/validation.js +1 -24
- package/dist/branch-health.d.ts +39 -67
- package/dist/branch-health.js +27 -43
- package/dist/branch-metadata-file.d.ts +16 -32
- package/dist/branch-metadata-file.js +13 -26
- package/dist/branch-metadata.d.ts +49 -80
- package/dist/branch-metadata.js +57 -87
- package/dist/branch-registry.d.ts +26 -64
- package/dist/branch-registry.js +44 -93
- package/dist/branch-schema-cache.d.ts +34 -124
- package/dist/branch-schema-cache.js +49 -155
- package/dist/branch-workspace.d.ts +4 -4
- package/dist/branch-workspace.js +15 -19
- package/dist/build/generate-ai-content.d.ts +1 -0
- package/dist/build/generate-ai-content.js +12 -25
- package/dist/build-canopy.d.ts +17 -27
- package/dist/build-canopy.js +16 -23
- package/dist/build-mode.d.ts +25 -27
- package/dist/build-mode.js +25 -29
- package/dist/cli/cli.d.ts +15 -26
- package/dist/cli/cli.js +601 -978
- package/dist/cli/generate-ai-content.js +351 -628
- package/dist/cli/github-app-manifest.d.ts +84 -0
- package/dist/cli/github-app-manifest.js +161 -0
- package/dist/cli/init-github-app.d.ts +110 -444
- package/dist/cli/init-github-app.js +236 -773
- package/dist/cli/init.js +39 -80
- package/dist/cli/migrate.d.ts +13 -10
- package/dist/cli/migrate.js +22 -30
- package/dist/cli/project-detect.d.ts +17 -31
- package/dist/cli/project-detect.js +31 -42
- package/dist/cli/project-root.d.ts +4 -11
- package/dist/cli/project-root.js +4 -11
- package/dist/cli/prompt.d.ts +10 -0
- package/dist/cli/prompt.js +67 -0
- package/dist/cli/sync.d.ts +0 -8
- package/dist/cli/sync.js +8 -28
- package/dist/cli/template-files/middleware.ts.template +15 -29
- package/dist/cli/templates.d.ts +2 -12
- package/dist/cli/templates.js +4 -4
- package/dist/client.d.ts +1 -1
- package/dist/comment-store.d.ts +32 -53
- package/dist/comment-store.js +32 -52
- package/dist/config/flatten.d.ts +0 -3
- package/dist/config/flatten.js +2 -12
- package/dist/config/helpers.js +2 -2
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +1 -4
- package/dist/config/schemas/collection.d.ts +0 -8
- package/dist/config/schemas/collection.js +0 -10
- package/dist/config/schemas/config.d.ts +6 -78
- package/dist/config/schemas/config.js +38 -54
- package/dist/config/schemas/field.d.ts +2 -242
- package/dist/config/schemas/field.js +9 -20
- package/dist/config/schemas/media.js +1 -1
- package/dist/config/schemas/permissions.js +0 -2
- package/dist/config/schemas/url.d.ts +5 -6
- package/dist/config/schemas/url.js +5 -6
- package/dist/config/types.d.ts +45 -33
- package/dist/config/types.js +0 -1
- package/dist/config.d.ts +1 -8
- package/dist/config.js +1 -9
- package/dist/content-id-index.d.ts +76 -188
- package/dist/content-id-index.js +79 -230
- package/dist/content-index-generation.d.ts +27 -48
- package/dist/content-index-generation.js +35 -84
- package/dist/content-index-registry.d.ts +15 -24
- package/dist/content-index-registry.js +5 -9
- package/dist/content-listing.d.ts +64 -107
- package/dist/content-listing.js +68 -118
- package/dist/content-reader.d.ts +20 -29
- package/dist/content-reader.js +10 -18
- package/dist/content-store.d.ts +210 -336
- package/dist/content-store.js +413 -657
- package/dist/content-tree.d.ts +44 -86
- package/dist/content-tree.js +26 -54
- package/dist/context.d.ts +67 -94
- package/dist/context.js +53 -72
- package/dist/dev-content-watcher.d.ts +18 -18
- package/dist/dev-content-watcher.js +41 -44
- package/dist/editor/BranchManager.d.ts +3 -6
- package/dist/editor/BranchManager.js +24 -44
- package/dist/editor/CanopyEditor.js +5 -2
- package/dist/editor/CommentsPanel.js +0 -2
- package/dist/editor/Editor.js +19 -76
- package/dist/editor/EditorAuthGate.d.ts +27 -0
- package/dist/editor/EditorAuthGate.js +140 -0
- package/dist/editor/EntryNavigator.js +0 -20
- package/dist/editor/FormRenderer.d.ts +1 -2
- package/dist/editor/FormRenderer.js +8 -23
- package/dist/editor/admin/SystemHealthPanel.js +20 -37
- package/dist/editor/admin/useSystemHealth.d.ts +2 -3
- package/dist/editor/admin/useSystemHealth.js +10 -12
- package/dist/editor/client-reference-resolver.d.ts +2 -5
- package/dist/editor/client-reference-resolver.js +2 -19
- package/dist/editor/comments/BranchComments.js +0 -4
- package/dist/editor/comments/EntryComments.js +0 -4
- package/dist/editor/comments/FieldWrapper.js +0 -4
- package/dist/editor/comments/InlineCommentThread.js +0 -2
- package/dist/editor/comments/ThreadCarousel.d.ts +0 -58
- package/dist/editor/comments/ThreadCarousel.js +0 -12
- package/dist/editor/components/EditorFooter.d.ts +0 -8
- package/dist/editor/components/EditorFooter.js +0 -5
- package/dist/editor/components/EditorHeader.d.ts +0 -31
- package/dist/editor/components/EditorHeader.js +21 -71
- package/dist/editor/components/EditorSidebar.d.ts +0 -19
- package/dist/editor/components/EditorSidebar.js +0 -16
- package/dist/editor/components/EntryCreateModal.d.ts +4 -4
- package/dist/editor/components/EntryCreateModal.js +7 -22
- package/dist/editor/components/RenameEntryModal.js +0 -4
- package/dist/editor/components/UserBadge.js +0 -10
- package/dist/editor/components/index.js +0 -2
- package/dist/editor/context/ApiClientContext.d.ts +8 -7
- package/dist/editor/context/ApiClientContext.js +39 -11
- package/dist/editor/context/AssetContext.d.ts +7 -17
- package/dist/editor/context/AssetContext.js +7 -17
- package/dist/editor/context/EditorIdentityContext.d.ts +13 -0
- package/dist/editor/context/EditorIdentityContext.js +7 -0
- package/dist/editor/context/EditorStateContext.d.ts +10 -41
- package/dist/editor/context/EditorStateContext.js +7 -23
- package/dist/editor/context/SWRProvider.d.ts +3 -6
- package/dist/editor/context/index.d.ts +1 -20
- package/dist/editor/context/index.js +1 -20
- package/dist/editor/editor-utils.d.ts +8 -47
- package/dist/editor/editor-utils.js +19 -72
- package/dist/editor/fields/BlockField.d.ts +2 -2
- package/dist/editor/fields/BlockField.js +1 -2
- package/dist/editor/fields/CodeField.d.ts +0 -1
- package/dist/editor/fields/CodeField.js +0 -1
- package/dist/editor/fields/DateTimeField.d.ts +15 -22
- package/dist/editor/fields/DateTimeField.js +17 -26
- package/dist/editor/fields/ImageField.d.ts +2 -2
- package/dist/editor/fields/ImageField.js +8 -14
- package/dist/editor/fields/InlineGroupField.d.ts +0 -1
- package/dist/editor/fields/InlineGroupField.js +0 -1
- package/dist/editor/fields/MarkdownField.d.ts +0 -1
- package/dist/editor/fields/MarkdownField.js +6 -11
- package/dist/editor/fields/MdxImageDialog.d.ts +1 -1
- package/dist/editor/fields/MdxImageDialog.js +0 -1
- package/dist/editor/fields/NumberField.d.ts +9 -19
- package/dist/editor/fields/NumberField.js +11 -22
- package/dist/editor/fields/NumberListField.d.ts +7 -10
- package/dist/editor/fields/NumberListField.js +7 -10
- package/dist/editor/fields/ObjectField.d.ts +5 -8
- package/dist/editor/fields/ObjectField.js +0 -1
- package/dist/editor/fields/ReferenceField.d.ts +1 -1
- package/dist/editor/fields/ReferenceField.js +11 -16
- package/dist/editor/fields/SelectField.d.ts +2 -2
- package/dist/editor/fields/SelectField.js +0 -1
- package/dist/editor/fields/StringListField.d.ts +0 -4
- package/dist/editor/fields/StringListField.js +4 -9
- package/dist/editor/fields/TextField.d.ts +0 -1
- package/dist/editor/fields/TextField.js +0 -1
- package/dist/editor/fields/ToggleField.d.ts +0 -1
- package/dist/editor/fields/ToggleField.js +0 -1
- package/dist/editor/fields/entry-link/EntryLinkContext.js +2 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +0 -3
- package/dist/editor/fields/entry-link/InsertEntryLink.js +0 -4
- package/dist/editor/group-manager/ExternalGroupsTab.d.ts +0 -3
- package/dist/editor/group-manager/ExternalGroupsTab.js +0 -1
- package/dist/editor/group-manager/GroupCard.d.ts +0 -3
- package/dist/editor/group-manager/GroupForm.d.ts +0 -3
- package/dist/editor/group-manager/InternalGroupsTab.d.ts +0 -3
- package/dist/editor/group-manager/InternalGroupsTab.js +0 -2
- package/dist/editor/group-manager/MemberList.d.ts +0 -3
- package/dist/editor/group-manager/hooks/useExternalGroupSearch.d.ts +0 -3
- package/dist/editor/group-manager/hooks/useExternalGroupSearch.js +0 -3
- package/dist/editor/group-manager/hooks/useGroupState.d.ts +0 -3
- package/dist/editor/group-manager/hooks/useGroupState.js +0 -5
- package/dist/editor/group-manager/hooks/useUserSearch.d.ts +0 -3
- package/dist/editor/group-manager/hooks/useUserSearch.js +0 -3
- package/dist/editor/group-manager/index.d.ts +0 -6
- package/dist/editor/group-manager/index.js +0 -10
- package/dist/editor/group-manager/types.d.ts +2 -5
- package/dist/editor/group-manager/types.js +0 -3
- package/dist/editor/hooks/index.d.ts +6 -11
- package/dist/editor/hooks/index.js +6 -12
- package/dist/editor/hooks/useBranchActions.d.ts +0 -14
- package/dist/editor/hooks/useBranchActions.js +0 -22
- package/dist/editor/hooks/useBranchManager.d.ts +2 -48
- package/dist/editor/hooks/useBranchManager.js +4 -64
- package/dist/editor/hooks/useBranchesData.d.ts +7 -6
- package/dist/editor/hooks/useBranchesData.js +7 -6
- package/dist/editor/hooks/useCommentSystem.d.ts +0 -49
- package/dist/editor/hooks/useCommentSystem.js +1 -36
- package/dist/editor/hooks/useDraftManager.d.ts +2 -35
- package/dist/editor/hooks/useDraftManager.js +23 -64
- package/dist/editor/hooks/useEditorLayout.d.ts +0 -20
- package/dist/editor/hooks/useEditorLayout.js +0 -23
- package/dist/editor/hooks/useEntriesData.d.ts +1 -0
- package/dist/editor/hooks/useEntriesData.js +2 -3
- package/dist/editor/hooks/useEntryLinkResolution.d.ts +2 -2
- package/dist/editor/hooks/useEntryLinkResolution.js +2 -2
- package/dist/editor/hooks/useEntryManager.d.ts +2 -27
- package/dist/editor/hooks/useEntryManager.js +29 -78
- package/dist/editor/hooks/useGroupManager.d.ts +0 -21
- package/dist/editor/hooks/useGroupManager.js +0 -21
- package/dist/editor/hooks/usePermissionManager.d.ts +0 -20
- package/dist/editor/hooks/usePermissionManager.js +0 -20
- package/dist/editor/hooks/useReferenceResolution.d.ts +2 -33
- package/dist/editor/hooks/useReferenceResolution.js +1 -48
- package/dist/editor/hooks/useSchemaManager.d.ts +8 -27
- package/dist/editor/hooks/useSchemaManager.js +0 -19
- package/dist/editor/hooks/useUserContext.d.ts +2 -9
- package/dist/editor/hooks/useUserContext.js +10 -10
- package/dist/editor/hooks/useUserMetadata.d.ts +0 -2
- package/dist/editor/hooks/useUserMetadata.js +0 -3
- package/dist/editor/media/AssetCard.d.ts +0 -1
- package/dist/editor/media/AssetCard.js +1 -2
- package/dist/editor/media/CropStep.d.ts +0 -1
- package/dist/editor/media/CropStep.js +0 -1
- package/dist/editor/media/MediaLibrary.d.ts +1 -2
- package/dist/editor/media/MediaLibrary.js +1 -2
- package/dist/editor/media/MediaLibraryBody.d.ts +0 -1
- package/dist/editor/media/MediaLibraryBody.js +0 -1
- package/dist/editor/media/crop-math.d.ts +1 -1
- package/dist/editor/media/crop-math.js +1 -1
- package/dist/editor/permission-manager/GroupSelector.d.ts +0 -3
- package/dist/editor/permission-manager/PermissionEditor.d.ts +0 -4
- package/dist/editor/permission-manager/PermissionEditor.js +0 -1
- package/dist/editor/permission-manager/PermissionLevelBadge.d.ts +0 -7
- package/dist/editor/permission-manager/PermissionLevelBadge.js +0 -4
- package/dist/editor/permission-manager/PermissionTree.d.ts +0 -4
- package/dist/editor/permission-manager/UserSelector.d.ts +0 -3
- package/dist/editor/permission-manager/constants.d.ts +0 -3
- package/dist/editor/permission-manager/hooks/useGroupsAndUsers.d.ts +0 -3
- package/dist/editor/permission-manager/hooks/useGroupsAndUsers.js +0 -10
- package/dist/editor/permission-manager/hooks/usePermissionTree.d.ts +0 -14
- package/dist/editor/permission-manager/hooks/usePermissionTree.js +0 -11
- package/dist/editor/permission-manager/index.d.ts +0 -7
- package/dist/editor/permission-manager/index.js +0 -16
- package/dist/editor/permission-manager/types.d.ts +2 -12
- package/dist/editor/permission-manager/types.js +0 -3
- package/dist/editor/permission-manager/utils.d.ts +0 -28
- package/dist/editor/permission-manager/utils.js +9 -23
- package/dist/editor/preview-bridge.d.ts +5 -1
- package/dist/editor/preview-bridge.js +4 -4
- package/dist/editor/schema-editor/CollectionEditor.d.ts +0 -5
- package/dist/editor/schema-editor/CollectionEditor.js +9 -31
- package/dist/editor/schema-editor/EntryTypeEditor.d.ts +0 -6
- package/dist/editor/schema-editor/EntryTypeEditor.js +3 -24
- package/dist/editor/schema-editor/index.d.ts +0 -7
- package/dist/editor/schema-editor/index.js +0 -7
- package/dist/editor/utils/env.d.ts +3 -18
- package/dist/editor/utils/env.js +6 -25
- package/dist/entry-link-resolver.d.ts +13 -35
- package/dist/entry-link-resolver.js +20 -53
- package/dist/entry-schema-registry.d.ts +20 -60
- package/dist/entry-schema-registry.js +20 -69
- package/dist/entry-schema.d.ts +118 -223
- package/dist/entry-schema.js +46 -91
- package/dist/git-manager.d.ts +101 -200
- package/dist/git-manager.js +211 -384
- package/dist/github-service.d.ts +34 -75
- package/dist/github-service.js +37 -91
- package/dist/http/handler.d.ts +6 -37
- package/dist/http/handler.js +65 -120
- package/dist/http/router.d.ts +15 -32
- package/dist/http/router.js +34 -99
- package/dist/http/types.d.ts +15 -40
- package/dist/http/types.js +3 -7
- package/dist/id.d.ts +4 -10
- package/dist/id.js +4 -10
- package/dist/index.js +6 -9
- package/dist/operating-mode/client-safe-strategy.d.ts +7 -14
- package/dist/operating-mode/client-safe-strategy.js +8 -28
- package/dist/operating-mode/client-unsafe-strategy.d.ts +6 -16
- package/dist/operating-mode/client-unsafe-strategy.js +9 -34
- package/dist/operating-mode/client.d.ts +3 -10
- package/dist/operating-mode/client.js +3 -8
- package/dist/operating-mode/deployment-name-fixtures.d.ts +14 -18
- package/dist/operating-mode/deployment-name-fixtures.js +14 -18
- package/dist/operating-mode/deployment-name.d.ts +19 -29
- package/dist/operating-mode/deployment-name.js +29 -47
- package/dist/operating-mode/index.d.ts +4 -25
- package/dist/operating-mode/index.js +9 -31
- package/dist/operating-mode/mode-env.d.ts +32 -54
- package/dist/operating-mode/mode-env.js +36 -58
- package/dist/operating-mode/types.d.ts +24 -72
- package/dist/operating-mode/types.js +1 -7
- package/dist/paths/branch-name.d.ts +30 -40
- package/dist/paths/branch-name.js +30 -40
- package/dist/paths/branch.d.ts +4 -18
- package/dist/paths/branch.js +6 -22
- package/dist/paths/index.d.ts +2 -13
- package/dist/paths/index.js +3 -23
- package/dist/paths/normalize-server.d.ts +3 -15
- package/dist/paths/normalize-server.js +3 -15
- package/dist/paths/normalize.d.ts +13 -33
- package/dist/paths/normalize.js +13 -33
- package/dist/paths/resolve.d.ts +4 -17
- package/dist/paths/resolve.js +4 -22
- package/dist/paths/types.d.ts +12 -34
- package/dist/paths/types.js +5 -7
- package/dist/paths/validation.d.ts +22 -119
- package/dist/paths/validation.js +30 -148
- package/dist/reference-resolver.d.ts +0 -13
- package/dist/reference-resolver.js +0 -18
- package/dist/resolve-canopy-user.d.ts +16 -18
- package/dist/resolve-canopy-user.js +17 -26
- package/dist/resource-generation.d.ts +16 -25
- package/dist/resource-generation.js +34 -97
- package/dist/schema/meta-loader.d.ts +2 -14
- package/dist/schema/meta-loader.js +15 -64
- package/dist/schema/resolver.d.ts +1 -4
- package/dist/schema/resolver.js +1 -6
- package/dist/schema/schema-store-types.d.ts +0 -12
- package/dist/schema/schema-store.d.ts +61 -138
- package/dist/schema/schema-store.js +85 -282
- package/dist/schema/types.d.ts +0 -4
- package/dist/server.d.ts +62 -93
- package/dist/server.js +63 -94
- package/dist/services.d.ts +23 -47
- package/dist/services.js +55 -98
- package/dist/settings-workspace.d.ts +17 -30
- package/dist/settings-workspace.js +51 -81
- package/dist/static/index.d.ts +26 -52
- package/dist/static/index.js +33 -67
- package/dist/{worker/task-queue.d.ts → task-queue/cms-task-queue.d.ts} +7 -9
- package/dist/task-queue/cms-task-queue.js +13 -0
- package/dist/task-queue/task-queue-config.d.ts +3 -0
- package/dist/{worker → task-queue}/task-queue-config.js +1 -6
- package/dist/task-queue/task-queue.d.ts +16 -22
- package/dist/task-queue/task-queue.js +20 -44
- package/dist/task-queue/types.d.ts +0 -1
- package/dist/task-queue/worker-status.d.ts +35 -0
- package/dist/task-queue/worker-status.js +40 -0
- package/dist/types.d.ts +40 -51
- package/dist/url-collision.d.ts +26 -40
- package/dist/url-collision.js +47 -63
- package/dist/url-exclusivity-fixtures.d.ts +15 -20
- package/dist/url-exclusivity-fixtures.js +14 -19
- package/dist/url-path-resolver.d.ts +8 -16
- package/dist/url-path-resolver.js +19 -29
- package/dist/user.d.ts +8 -21
- package/dist/user.js +10 -19
- package/dist/utils/async-mutex.js +4 -6
- package/dist/utils/atomic-write.d.ts +2 -11
- package/dist/utils/atomic-write.js +2 -11
- package/dist/utils/body-field.d.ts +8 -15
- package/dist/utils/body-field.js +8 -15
- package/dist/utils/content-serialize.d.ts +18 -24
- package/dist/utils/content-serialize.js +109 -160
- package/dist/utils/content-write-lock.d.ts +51 -106
- package/dist/utils/content-write-lock.js +66 -111
- package/dist/utils/debug.d.ts +7 -23
- package/dist/utils/debug.js +7 -23
- package/dist/utils/entry-url.d.ts +11 -23
- package/dist/utils/entry-url.js +11 -26
- package/dist/utils/error.d.ts +25 -90
- package/dist/utils/error.js +60 -147
- package/dist/utils/flatten-group-fields.d.ts +5 -7
- package/dist/utils/flatten-group-fields.js +5 -7
- package/dist/utils/format.d.ts +1 -5
- package/dist/utils/format.js +1 -5
- package/dist/utils/fs.d.ts +3 -4
- package/dist/utils/fs.js +3 -4
- package/dist/utils/git.d.ts +52 -83
- package/dist/utils/git.js +79 -125
- package/dist/utils/logger.d.ts +35 -50
- package/dist/utils/logger.js +35 -52
- package/dist/utils/provisioning-lock.d.ts +23 -35
- package/dist/utils/provisioning-lock.js +49 -69
- package/dist/utils/sanitize-href.d.ts +5 -10
- package/dist/utils/sanitize-href.js +8 -18
- package/dist/utils/title-field.d.ts +17 -20
- package/dist/utils/title-field.js +18 -27
- package/dist/utils/typed-filename.d.ts +20 -48
- package/dist/utils/typed-filename.js +25 -61
- package/dist/utils/url-prefix.d.ts +3 -3
- package/dist/utils/url-prefix.js +3 -3
- package/dist/validation/block-structural-keys.d.ts +16 -31
- package/dist/validation/block-structural-keys.js +19 -29
- package/dist/validation/deletion-checker.d.ts +0 -34
- package/dist/validation/deletion-checker.js +1 -43
- package/dist/validation/entry-link-validator.d.ts +6 -13
- package/dist/validation/entry-link-validator.js +4 -13
- package/dist/validation/entry-type-reference-validator.d.ts +13 -18
- package/dist/validation/entry-type-reference-validator.js +13 -18
- package/dist/validation/entry-validator.d.ts +13 -25
- package/dist/validation/entry-validator.js +15 -32
- package/dist/validation/field-traversal.d.ts +3 -30
- package/dist/validation/field-traversal.js +1 -32
- package/dist/validation/reference-validator.d.ts +5 -24
- package/dist/validation/reference-validator.js +5 -30
- package/dist/worker/cms-worker.d.ts +172 -232
- package/dist/worker/cms-worker.js +241 -333
- package/dist/worker/git-sync.d.ts +32 -133
- package/dist/worker/git-sync.js +129 -191
- package/dist/worker/github-auth.d.ts +13 -19
- package/dist/worker/github-auth.js +13 -19
- package/dist/worker/history-rewrite.d.ts +53 -66
- package/dist/worker/history-rewrite.js +54 -60
- package/dist/worker/log.d.ts +24 -41
- package/dist/worker/log.js +25 -42
- package/dist/worker/rebase.d.ts +20 -110
- package/dist/worker/rebase.js +279 -354
- package/dist/worker/task-runner.d.ts +32 -66
- package/dist/worker/task-runner.js +126 -162
- package/dist/worker/worker-context.d.ts +41 -81
- package/package.json +4 -4
- package/dist/cli/template-files/middleware-clerk.ts.template +0 -37
- package/dist/worker/task-queue-config.d.ts +0 -8
- package/dist/worker/task-queue.js +0 -19
- package/dist/worker/worker-status.d.ts +0 -47
- package/dist/worker/worker-status.js +0 -52
package/dist/content-store.js
CHANGED
|
@@ -1,31 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ContentStore — the authoritative read/write boundary for entry content.
|
|
2
|
+
* ContentStore — the authoritative read/write boundary for entry content. Everything that
|
|
3
|
+
* creates, reads, updates, renames or deletes an entry file goes through here;
|
|
4
|
+
* `api/content.ts` is its HTTP front door.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
+
* Neighbours, since picking the wrong one is easy: content-reader.ts (branch-aware read facade
|
|
7
|
+
* over this store), content-listing.ts (batch `listEntries`), content-tree.ts (navigable
|
|
8
|
+
* tree), content-id-index.ts (the id -> path index this store consults),
|
|
9
|
+
* content-index-registry.ts (IN-PROCESS cache invalidation) and content-index-generation.ts
|
|
10
|
+
* (ON-DISK generation marker) — the last two are near-homonyms with unrelated jobs.
|
|
6
11
|
*
|
|
7
|
-
*
|
|
8
|
-
* one costs more time than reading this comment:
|
|
9
|
-
*
|
|
10
|
-
* content-store.ts (this file) the write boundary, plus path-and-id resolution
|
|
11
|
-
* content-reader.ts branch-aware read facade over this store
|
|
12
|
-
* content-listing.ts batch listing (`listEntries`) for adopters
|
|
13
|
-
* content-tree.ts the adopter-facing navigable tree
|
|
14
|
-
* content-id-index.ts the id -> path index this store consults
|
|
15
|
-
* content-index-registry.ts IN-PROCESS cache invalidation registry
|
|
16
|
-
* content-index-generation.ts ON-DISK generation marker <- near-homonym of the line above,
|
|
17
|
-
* unrelated job; check which one you want
|
|
18
|
-
*
|
|
19
|
-
* SHAPE — the CRUD/path/lock core is genuinely interwoven (`buildPaths` alone has
|
|
20
|
-
* ~25 call sites in this file), but two clusters are only loosely attached and are the ones
|
|
21
|
-
* to read in isolation: reference resolution (`resolveReferences` and friends, at
|
|
22
|
-
* the end of the file) and ID-index coherency (`idIndex`, `recordOwnMutation`,
|
|
23
|
-
* `refreshIndexForSuspiciousLookup`).
|
|
24
|
-
*
|
|
25
|
-
* The load-bearing rules are documented at the point of the rule, not here — see
|
|
26
|
-
* in particular `ContentStoreOptions.contentRootName`, the `[SLUG]` guard in
|
|
27
|
-
* `write()`, and the read-path note on `parseSlug`. Those comments are
|
|
28
|
-
* authoritative. Module map: ./AGENTS.md.
|
|
12
|
+
* Load-bearing rules live at the point of the rule. Module map: ./AGENTS.md.
|
|
29
13
|
*/
|
|
30
14
|
import fs from 'node:fs/promises';
|
|
31
15
|
import path from 'node:path';
|
|
@@ -48,16 +32,11 @@ import { filePathExists, readFileIfExists } from './utils/fs.js';
|
|
|
48
32
|
import { asRecord, getFormatExtension } from './utils/format.js';
|
|
49
33
|
import { normalizeFilesystemPath, parseSlug, } from './paths/index.js';
|
|
50
34
|
/**
|
|
51
|
-
* Acquire
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* the destination slug's create-lock -- see ContentStore.createLockKey()).
|
|
57
|
-
* The `id:` and `create:` keyspaces never share a literal string (disjoint
|
|
58
|
-
* prefixes), so two renameEntry() calls can never contend for the exact same
|
|
59
|
-
* pair of keys in reversed roles -- but sorting costs nothing and is cheap
|
|
60
|
-
* insurance against that changing later.
|
|
35
|
+
* Acquire `keys` in canonical (sorted) order before running `fn`, ruling out AB-BA
|
|
36
|
+
* deadlocks between callers with overlapping key sets. Only renameEntry() needs two
|
|
37
|
+
* (the source entry's ID lock plus the destination slug's create-lock, see
|
|
38
|
+
* ContentStore.createLockKey()), and the `id:`/`create:` keyspaces are disjoint, so
|
|
39
|
+
* sorting is insurance against that changing rather than a live requirement.
|
|
61
40
|
*/
|
|
62
41
|
async function withLocks(keys, fn) {
|
|
63
42
|
const sorted = Array.from(new Set(keys)).sort();
|
|
@@ -82,15 +61,13 @@ export class ContentConflictError extends Error {
|
|
|
82
61
|
}
|
|
83
62
|
/**
|
|
84
63
|
* [SYNC-C1] Thrown when a mutation could not take the branch's cross-host
|
|
85
|
-
* content-write lock within its bounded wait --
|
|
86
|
-
* mid-rebase on this branch's working tree
|
|
64
|
+
* content-write lock within its bounded wait (utils/content-write-lock.ts) --
|
|
65
|
+
* usually the worker mid-rebase on this branch's working tree, though
|
|
66
|
+
* writer-vs-writer contention raises it too (see ContentWriteLockBusyError).
|
|
87
67
|
*
|
|
88
|
-
* A `ContentConflictError` subclass so every
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* another editor", which would be actively misleading. The default wording
|
|
92
|
-
* covers writer-vs-writer contention too, which this lock also produces --
|
|
93
|
-
* see ContentWriteLockBusyError.
|
|
68
|
+
* A `ContentConflictError` subclass so every 409 mapping keeps working; the
|
|
69
|
+
* distinct type lets the API surface THIS message ("the branch is busy, retry")
|
|
70
|
+
* instead of the misleading generic "modified by another editor".
|
|
94
71
|
*/
|
|
95
72
|
export class BranchSyncingError extends ContentConflictError {
|
|
96
73
|
constructor(message) {
|
|
@@ -99,37 +76,27 @@ export class BranchSyncingError extends ContentConflictError {
|
|
|
99
76
|
}
|
|
100
77
|
}
|
|
101
78
|
/**
|
|
102
|
-
* [F1] Thrown when a save's content ID is carried by MORE THAN ONE file in
|
|
103
|
-
*
|
|
104
|
-
* quarantines (see its "Duplicate-ID quarantine" section).
|
|
79
|
+
* [F1] Thrown when a save's content ID is carried by MORE THAN ONE file in the branch's
|
|
80
|
+
* content tree -- the duplicate-ID state `ContentIdIndex` quarantines.
|
|
105
81
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* that is invisible to every ID-based lookup (reads-by-id, references,
|
|
112
|
-
* listings all resolve to the OTHER copy) and that the repair action later
|
|
113
|
-
* archives away, so the editor's work would appear to evaporate with no error
|
|
114
|
-
* anywhere. Refusing mutates nothing under any interleaving, and says what is
|
|
115
|
-
* wrong and who can fix it.
|
|
82
|
+
* Refuse rather than write: with two files sharing one ID "this entry" is ambiguous and every
|
|
83
|
+
* way of proceeding is worse. Following the index would DELETE (via the slug-change cleanup) a
|
|
84
|
+
* file the caller never addressed; writing only the addressed file would succeed into a file
|
|
85
|
+
* invisible to every ID-based lookup and later archived by the repair action, so the editor's
|
|
86
|
+
* work evaporates with no error anywhere. Refusing mutates nothing under any interleaving.
|
|
116
87
|
*
|
|
117
|
-
* A `ContentConflictError` subclass so every
|
|
118
|
-
*
|
|
119
|
-
* rather than the generic "modified by another editor", which would send the
|
|
88
|
+
* A `ContentConflictError` subclass so every 409 mapping keeps working; the distinct type lets
|
|
89
|
+
* the API surface THIS message rather than "modified by another editor", which would send the
|
|
120
90
|
* editor into a reload-and-retry loop that cannot succeed.
|
|
121
91
|
*/
|
|
122
92
|
export class DuplicateContentIdError extends ContentConflictError {
|
|
123
93
|
constructor(contentId, paths) {
|
|
124
94
|
const sorted = Array.from(new Set(paths)).sort();
|
|
125
95
|
super(
|
|
126
|
-
// Names the STATE, not an action
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// admin panel's read-only duplicate list (SystemHealthPanel) is the
|
|
131
|
-
// diagnosis half, and the repair UI is tracked in
|
|
132
|
-
// .claude/future-tasks/duplicate-content-id-repair-ui.md.
|
|
96
|
+
// Names the STATE, not an action: the repair-content-duplicates endpoint
|
|
97
|
+
// exists but nothing in the editor renders it, and the admin panel's
|
|
98
|
+
// duplicate list (SystemHealthPanel) is the diagnosis half only. The repair
|
|
99
|
+
// UI is tracked in .claude/future-tasks/duplicate-content-id-repair-ui.md.
|
|
133
100
|
`Content ID ${contentId} is on more than one file (${sorted
|
|
134
101
|
.map((p) => `"${p}"`)
|
|
135
102
|
.join(' and ')}), so this save was refused rather than risk overwriting or ` +
|
|
@@ -142,17 +109,15 @@ export class DuplicateContentIdError extends ContentConflictError {
|
|
|
142
109
|
}
|
|
143
110
|
/**
|
|
144
111
|
* Thrown when a create or rename would give a SECOND entry a `urlPath` another entry already
|
|
145
|
-
* holds -- the write-boundary half of the invariant `assertNoDuplicateUrlPaths` enforces at
|
|
146
|
-
* time (
|
|
112
|
+
* holds -- the write-boundary half of the invariant `assertNoDuplicateUrlPaths` enforces at
|
|
113
|
+
* build time (url-collision.ts says which shapes count and which deliberately do not).
|
|
147
114
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* rather than by the author, not necessarily the page they were editing.
|
|
115
|
+
* Refuse rather than write: only one of the two can be served at that URL, so the other
|
|
116
|
+
* silently has no route anywhere -- and the loser is picked by resolver precedence rather than
|
|
117
|
+
* by the author, so not necessarily the page they were editing.
|
|
152
118
|
*
|
|
153
|
-
* A `ContentConflictError` subclass so every
|
|
154
|
-
*
|
|
155
|
-
* editor", which would send the editor into a reload-and-retry loop that cannot succeed.
|
|
119
|
+
* A `ContentConflictError` subclass so every 409 mapping keeps working; the distinct type lets
|
|
120
|
+
* the API surface THIS message rather than the generic "modified by another editor".
|
|
156
121
|
*/
|
|
157
122
|
export class UrlPathConflictError extends ContentConflictError {
|
|
158
123
|
constructor(message, conflictingPath) {
|
|
@@ -161,25 +126,21 @@ export class UrlPathConflictError extends ContentConflictError {
|
|
|
161
126
|
this.conflictingPath = conflictingPath;
|
|
162
127
|
}
|
|
163
128
|
}
|
|
164
|
-
/**
|
|
165
|
-
* Get the default entry type from a collection's entries array.
|
|
166
|
-
* Returns the entry marked as default, or the first one, or undefined if no entries.
|
|
167
|
-
*/
|
|
129
|
+
/** The entry type marked `default`, else the first, else undefined. */
|
|
168
130
|
export function getDefaultEntryType(entries) {
|
|
169
131
|
if (!entries || entries.length === 0)
|
|
170
132
|
return undefined;
|
|
171
133
|
return entries.find((e) => e.default) || entries[0];
|
|
172
134
|
}
|
|
173
135
|
/**
|
|
174
|
-
*
|
|
175
|
-
* Slugs must be simple filenames (last path segment only).
|
|
136
|
+
* Rejects slashes and backslashes -- the path-traversal guard: a slug is one filename segment.
|
|
176
137
|
*
|
|
177
|
-
* Deliberately WEAKER than `parseSlug`,
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
138
|
+
* Deliberately WEAKER than `parseSlug`, because this runs on every resolution, reads included
|
|
139
|
+
* (`buildPaths`), so it can only require what must be true of content already on disk: content
|
|
140
|
+
* predating CanopyCMS, hand-authored, or arrived over git has to stay readable. `parseSlug`'s
|
|
141
|
+
* stricter URL-addressable rule is enforced only where a NEW filename is minted (the [SLUG]
|
|
142
|
+
* guards in `write()` and `renameEntry()`); applying it here would turn a build-time failure
|
|
143
|
+
* into unreachable data.
|
|
183
144
|
*/
|
|
184
145
|
function validateSlug(slug) {
|
|
185
146
|
if (slug.includes('/')) {
|
|
@@ -227,20 +188,16 @@ export class ContentStore {
|
|
|
227
188
|
}
|
|
228
189
|
/**
|
|
229
190
|
* [SYNC-C1] Run a working-tree mutation under the branch's cross-host
|
|
230
|
-
* content-write lock (utils/content-write-lock.ts), on top of the
|
|
231
|
-
*
|
|
191
|
+
* content-write lock (utils/content-write-lock.ts), on top of the in-process
|
|
192
|
+
* locks the callee takes for itself. The in-process mutex serializes writers
|
|
193
|
+
* inside ONE process and says nothing about the EC2 worker rebasing this same
|
|
194
|
+
* tree on shared EFS, which destroys an in-flight save.
|
|
232
195
|
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* destroys an in-flight save (`checkout --theirs` overwrites it and the
|
|
236
|
-
* rebase then reports success; `rebase --abort` hard-resets it). This is the
|
|
237
|
-
* layer that actually excludes the two.
|
|
196
|
+
* Reads deliberately do NOT take it: an extra EFS round-trip per read is not an
|
|
197
|
+
* acceptable cost, and a rebase cannot destroy a read.
|
|
238
198
|
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
* Acquisition order is always content lock -> `withLock`, never the reverse,
|
|
243
|
-
* so the two cannot deadlock.
|
|
199
|
+
* Acquisition order is always content lock -> `withLock`, never the reverse, so
|
|
200
|
+
* the two cannot deadlock.
|
|
244
201
|
*/
|
|
245
202
|
async withContentWriteExclusion(fn) {
|
|
246
203
|
try {
|
|
@@ -259,26 +216,23 @@ export class ContentStore {
|
|
|
259
216
|
*
|
|
260
217
|
* Called (via content-index-registry) after in-process operations that change the
|
|
261
218
|
* working tree underneath this store — git checkout/merge/rebase, the worker's
|
|
262
|
-
* rebase loop,
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* Cheap: only bumps a generation counter; the rebuild happens lazily.
|
|
219
|
+
* rebase loop, sync-core's content replacement. Without it, ID→path lookups keep
|
|
220
|
+
* resolving to pre-mutation paths and saves can target moved/deleted files.
|
|
221
|
+
* Cheap: bumps a generation counter, the rebuild is lazy.
|
|
266
222
|
*/
|
|
267
223
|
invalidateIndex() {
|
|
268
224
|
this.indexGeneration++;
|
|
269
225
|
}
|
|
270
226
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* mutations made by OTHER processes sharing this root (e.g. on EFS).
|
|
227
|
+
* The ID index, rebuilt first if stale. Loads lazily, rebuilds after
|
|
228
|
+
* invalidateIndex(), and otherwise reuses the built index apart from a throttled
|
|
229
|
+
* probe of the on-disk generation marker, which detects mutations by OTHER
|
|
230
|
+
* processes sharing this root (e.g. on EFS).
|
|
276
231
|
*/
|
|
277
232
|
async idIndex() {
|
|
278
|
-
// Cross-process freshness probe:
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// rebuild below captures the marker itself.
|
|
233
|
+
// Cross-process freshness probe: with the in-memory generations already matching,
|
|
234
|
+
// cheaply check whether another process bumped the on-disk marker. Skipped while a
|
|
235
|
+
// rebuild is due anyway — the rebuild below captures the marker itself.
|
|
282
236
|
if (this.loadedIndexGeneration === this.indexGeneration && this.shouldProbeDiskGeneration()) {
|
|
283
237
|
const diskToken = await readContentIndexGeneration(this.root);
|
|
284
238
|
if (diskToken !== this.loadedDiskGeneration) {
|
|
@@ -337,14 +291,12 @@ export class ContentStore {
|
|
|
337
291
|
return true;
|
|
338
292
|
}
|
|
339
293
|
/**
|
|
340
|
-
* After one of this store's own successful mutations: publish
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
* we leave the recorded token older and let the next probe observe our bump
|
|
347
|
-
* and trigger the healing rebuild.
|
|
294
|
+
* After one of this store's own successful mutations: publish it to other
|
|
295
|
+
* processes (bump the on-disk marker) and adopt the written token, since the
|
|
296
|
+
* in-memory index was already updated incrementally. Adoption is skipped unless
|
|
297
|
+
* the index is quiescent and `updatedIndex` is still the live instance: a rebuild
|
|
298
|
+
* that raced the mutation may have scanned before our file change, so leaving the
|
|
299
|
+
* recorded token older lets the next probe observe our bump and heal.
|
|
348
300
|
*/
|
|
349
301
|
async recordOwnMutation(updatedIndex) {
|
|
350
302
|
const token = await bumpContentIndexGeneration(this.root);
|
|
@@ -356,16 +308,14 @@ export class ContentStore {
|
|
|
356
308
|
}
|
|
357
309
|
}
|
|
358
310
|
/**
|
|
359
|
-
* Backstop for the residual staleness windows of the cross-process marker
|
|
360
|
-
*
|
|
361
|
-
* content-index-generation.ts): force one rebuild in response to a
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
* the race rebuilds the index for everyone, and callers should still retry
|
|
368
|
-
* their lookup against the live index regardless of this return value.
|
|
311
|
+
* Backstop for the residual staleness windows of the cross-process marker (NFS
|
|
312
|
+
* attribute caching, probe throttle, self-adoption — see
|
|
313
|
+
* content-index-generation.ts): force one rebuild in response to a suspicious
|
|
314
|
+
* lookup (an ID miss, or an index hit whose file is gone), throttled so a
|
|
315
|
+
* genuinely dangling ID costs at most one rescan per window. Returns true only if
|
|
316
|
+
* THIS call refreshed; a caller that loses the throttle race must still retry its
|
|
317
|
+
* lookup, since idIndex() dedupes concurrent builds and the winner rebuilds for
|
|
318
|
+
* everyone.
|
|
369
319
|
*/
|
|
370
320
|
async refreshIndexForSuspiciousLookup() {
|
|
371
321
|
const now = Date.now();
|
|
@@ -377,13 +327,12 @@ export class ContentStore {
|
|
|
377
327
|
return true;
|
|
378
328
|
}
|
|
379
329
|
/**
|
|
380
|
-
* Every file in `dir` whose filename embeds `id`, as root-relative paths
|
|
381
|
-
*
|
|
330
|
+
* Every file in `dir` whose filename embeds `id`, as root-relative paths (empty
|
|
331
|
+
* when there is no such file, or no such dir).
|
|
382
332
|
*
|
|
383
|
-
* [F1] Returns ALL matches, not the first: more than one match is a
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
* happened to yield first.
|
|
333
|
+
* [F1] Returns ALL matches, not the first: more than one match is a duplicate-ID
|
|
334
|
+
* pair, and callers must be able to tell that apart from a clean single hit rather
|
|
335
|
+
* than silently picking whichever one `readdir()` happened to yield first.
|
|
387
336
|
*/
|
|
388
337
|
async findEntryPathsById(dir, id) {
|
|
389
338
|
let entries;
|
|
@@ -405,10 +354,6 @@ export class ContentStore {
|
|
|
405
354
|
}
|
|
406
355
|
return matches.sort();
|
|
407
356
|
}
|
|
408
|
-
/**
|
|
409
|
-
* Get all schema items for iteration.
|
|
410
|
-
* Used internally by ReferenceResolver for path matching.
|
|
411
|
-
*/
|
|
412
357
|
getSchemaItems() {
|
|
413
358
|
return this.schemaIndex.values();
|
|
414
359
|
}
|
|
@@ -424,17 +369,16 @@ export class ContentStore {
|
|
|
424
369
|
* Is this path a COLLECTION schema item?
|
|
425
370
|
*
|
|
426
371
|
* The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
|
|
427
|
-
* point
|
|
428
|
-
* Map is last-wins
|
|
429
|
-
*
|
|
430
|
-
* first-wins and would not.
|
|
372
|
+
* point: a caller that gates on this cannot disagree with what `buildPaths` will then do,
|
|
373
|
+
* because the Map is last-wins where a subcollection's path collides with a parent's
|
|
374
|
+
* entry-type name. A `find` over the flat schema LIST is first-wins and would not.
|
|
431
375
|
*
|
|
432
|
-
* Type-only, deliberately
|
|
433
|
-
* with subcollections and no entries of its own is legal, and
|
|
434
|
-
*
|
|
376
|
+
* Type-only, deliberately: `resolvePath` additionally requires `entries`, but a collection
|
|
377
|
+
* with subcollections and no entries of its own is legal, and testing for that here would
|
|
378
|
+
* reject something `buildPaths` accepts.
|
|
435
379
|
*
|
|
436
380
|
* Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
|
|
437
|
-
* `urlAddressableOnly
|
|
381
|
+
* `urlAddressableOnly`.
|
|
438
382
|
*/
|
|
439
383
|
isCollectionPath(collectionPath) {
|
|
440
384
|
return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === 'collection';
|
|
@@ -442,23 +386,20 @@ export class ContentStore {
|
|
|
442
386
|
/**
|
|
443
387
|
* Does this collection declare `entryTypeName` in its `entries` config?
|
|
444
388
|
*
|
|
445
|
-
* Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries`
|
|
446
|
-
* whether a file on disk is one of the collection's entries at all. `buildPaths`'
|
|
447
|
-
* scan deliberately does NOT check this -- it matches on slug alone, so
|
|
448
|
-
* was renamed out of the schema stays findable and therefore
|
|
449
|
-
* deletable. Only URL resolution consults this, so what enumeration hides is
|
|
389
|
+
* Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries`
|
|
390
|
+
* decides whether a file on disk is one of the collection's entries at all. `buildPaths`'
|
|
391
|
+
* own directory scan deliberately does NOT check this -- it matches on slug alone, so an
|
|
392
|
+
* entry whose type was renamed out of the schema stays findable and therefore editable,
|
|
393
|
+
* renameable and deletable. Only URL resolution consults this, so what enumeration hides is
|
|
394
|
+
* not served.
|
|
450
395
|
*
|
|
451
|
-
* Returns FALSE when the collection declares no `entries` at all
|
|
452
|
-
* `parseTypedFilename`'s own
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
* nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
|
|
456
|
-
* advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
|
|
457
|
-
* been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
|
|
458
|
-
* or by retrofit, and it is invisible to the sitemap and to static params either way.
|
|
396
|
+
* Returns FALSE when the collection declares no `entries` at all -- stricter than
|
|
397
|
+
* `parseTypedFilename`'s own guard, and deliberately so: the enumerating surface is
|
|
398
|
+
* `listCollectionEntries`, which returns `[]` outright for such a collection, so a URL read
|
|
399
|
+
* resolving a file sitting in one would answer where nothing is advertised.
|
|
459
400
|
*
|
|
460
|
-
* Returns true for a path that is not a collection at all, because that is rule 1's
|
|
461
|
-
*
|
|
401
|
+
* Returns true for a path that is not a collection at all, because that is rule 1's
|
|
402
|
+
* question, and under `urlAddressableOnly` rule 1 has already rejected it.
|
|
462
403
|
*/
|
|
463
404
|
declaresEntryType(collectionPath, entryTypeName) {
|
|
464
405
|
const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
|
|
@@ -476,25 +417,20 @@ export class ContentStore {
|
|
|
476
417
|
return item;
|
|
477
418
|
}
|
|
478
419
|
/**
|
|
479
|
-
* Lock key for an existing entry, addressed by its permanent content ID
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
* the keyspace scoped per-store. It also keeps this ID keyspace disjoint
|
|
486
|
-
* from other modules' raw-path-keyed locks sharing the same withLock()
|
|
487
|
-
* map (comment-store, branch-metadata, etc).
|
|
420
|
+
* Lock key for an existing entry, addressed by its permanent content ID --
|
|
421
|
+
* stable across renames, which is the whole point of this locking scheme.
|
|
422
|
+
* Namespaced by store root: the same content ID exists in every clone of a
|
|
423
|
+
* branch and one process can hold stores on several clones at once, so the
|
|
424
|
+
* prefix scopes the keyspace per store and keeps it disjoint from other
|
|
425
|
+
* modules' raw-path-keyed locks sharing the same withLock() map.
|
|
488
426
|
*/
|
|
489
427
|
idLockKey(id) {
|
|
490
428
|
return `${this.root}:id:${id}`;
|
|
491
429
|
}
|
|
492
430
|
/**
|
|
493
|
-
* Stable collection+slug identifier for lock-key namespacing. Mirrors
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
* defaulting to the entry type's own name (see buildPaths()'s
|
|
497
|
-
* `schemaItem.type === 'entry-type'` branch).
|
|
431
|
+
* Stable collection+slug identifier for lock-key namespacing. Mirrors
|
|
432
|
+
* buildPaths()' entry-type delegation: an entry-type item delegates to its
|
|
433
|
+
* parent collection with the slug defaulting to the entry type's own name.
|
|
498
434
|
*/
|
|
499
435
|
collectionSlugKey(schemaItem, slug) {
|
|
500
436
|
if (schemaItem.type === 'entry-type') {
|
|
@@ -504,27 +440,24 @@ export class ContentStore {
|
|
|
504
440
|
}
|
|
505
441
|
/**
|
|
506
442
|
* Lock key for a not-yet-existing entry (a create). Serializes concurrent
|
|
507
|
-
* same-slug creates WITHIN this process: the second call's in-lock
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* DIFFERENT process is NOT covered by this in-process
|
|
511
|
-
*
|
|
512
|
-
* writes
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
* that would poison index rebuilds.
|
|
443
|
+
* same-slug creates WITHIN this process: the second call's in-lock buildPaths()
|
|
444
|
+
* re-resolution finds the first call's just-written file and folds in as an edit.
|
|
445
|
+
*
|
|
446
|
+
* A create racing from a DIFFERENT process is NOT covered by this in-process
|
|
447
|
+
* mutex, and that is accepted: each writer mints its own fresh ID and filename, so
|
|
448
|
+
* both writes succeed and the result is two same-slug files with different IDs (a
|
|
449
|
+
* slug-uniqueness violation surfaced on the next listing/lookup), never a
|
|
450
|
+
* duplicate-ID collision that would poison index rebuilds.
|
|
516
451
|
*/
|
|
517
452
|
createLockKey(schemaItem, slug) {
|
|
518
453
|
return `${this.root}:create:${this.collectionSlugKey(schemaItem, slug)}`;
|
|
519
454
|
}
|
|
520
455
|
/**
|
|
521
|
-
* Lock key for a write()/delete() pre-pass classification
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
* touch
|
|
525
|
-
*
|
|
526
|
-
* this store's original locking behavior for that narrow case. Not-yet-
|
|
527
|
-
* existing entries lock on a per-slug create-key (createLockKey()).
|
|
456
|
+
* Lock key for a write()/delete() pre-pass classification: an existing entry
|
|
457
|
+
* locks on its content ID (idLockKey()), a not-yet-existing one on a per-slug
|
|
458
|
+
* create-key (createLockKey()). An entry with no embedded ID falls back to its
|
|
459
|
+
* physical path -- renameEntry() refuses to touch those (its four-part filename
|
|
460
|
+
* check), so they have no rename race to guard against.
|
|
528
461
|
*/
|
|
529
462
|
entryLockKey(schemaItem, slug, prePass) {
|
|
530
463
|
if (!prePass.existed)
|
|
@@ -535,9 +468,6 @@ export class ContentStore {
|
|
|
535
468
|
* Refuse a create/rename that would give a second entry a `urlPath` another entry already
|
|
536
469
|
* holds. See url-collision.ts for the two shapes that count, and the legitimate
|
|
537
470
|
* landing-page-beside-a-collection shape that deliberately does not.
|
|
538
|
-
*
|
|
539
|
-
* @param collectionDir Absolute physical directory the entry will live in.
|
|
540
|
-
* @param slug The entry's slug, as it will be written.
|
|
541
471
|
*/
|
|
542
472
|
async assertUrlPathAvailable(collectionDir, slug) {
|
|
543
473
|
const claimant = await findUrlPathClaimant({
|
|
@@ -558,45 +488,36 @@ export class ContentStore {
|
|
|
558
488
|
throw new UrlPathConflictError(message, claimant.physicalPath);
|
|
559
489
|
}
|
|
560
490
|
/**
|
|
561
|
-
* Build absolute and relative paths
|
|
562
|
-
*
|
|
491
|
+
* Build absolute and relative paths for an entry, filename pattern
|
|
492
|
+
* `{type}.{slug}.{id}.{ext}`. `options.existingId` addresses a known entry; without it a
|
|
493
|
+
* new ID is generated. `options.entryTypeName` picks among a collection's entry types,
|
|
494
|
+
* defaulting to its default one.
|
|
563
495
|
*
|
|
564
|
-
* SECURITY BOUNDARY:
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
* This validation is performed BEFORE file I/O in resolveDocumentPath(),
|
|
570
|
-
* ensuring permission checks happen before any file system access.
|
|
571
|
-
*
|
|
572
|
-
* @param options.existingId - Optional ID to use (for edits). If not provided, generates new ID.
|
|
573
|
-
* @param options.entryTypeName - For collections with multiple entry types, specify which one to use. Defaults to the default entry type.
|
|
496
|
+
* SECURITY BOUNDARY against path traversal: resolved paths must stay inside the content
|
|
497
|
+
* root, slugs go through `validateSlug`, and everything is `path.resolve`d before those
|
|
498
|
+
* checks. All of it runs BEFORE any file I/O in resolveDocumentPath(), so permission checks
|
|
499
|
+
* happen before filesystem access.
|
|
574
500
|
*/
|
|
575
501
|
async buildPaths(schemaItem, slug, options = {}) {
|
|
576
502
|
const rootWithSep = this.root.endsWith(path.sep) ? this.root : `${this.root}${path.sep}`;
|
|
577
|
-
// Entry-type items
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
// type's own name. The API layer resolves paths via resolvePath(), which returns the parent
|
|
583
|
-
// collection directly and so never lands here.
|
|
503
|
+
// Entry-type items delegate to their parent collection, using the same
|
|
504
|
+
// {type}.{slug}.{id}.{ext} pattern, with the slug defaulting to the entry type's own
|
|
505
|
+
// name. That is DIRECT ContentStore usage -- store.read('content/home', '') and the
|
|
506
|
+
// read({ entryPath: 'content/home' }) API built on it. The API layer resolves paths via
|
|
507
|
+
// resolvePath(), which returns the parent collection directly and so never lands here.
|
|
584
508
|
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
// ITSELF was the wrong fix -- write()/renameEntry()/delete() resolve through here as well,
|
|
592
|
-
// and the by-URL rule has no business constraining them.
|
|
509
|
+
// readByUrlPath must not reach this branch: `resolveUrlPathCandidates` produces
|
|
510
|
+
// `content/<typeName>` for the URL `/<typeName>`, which the delegation would answer with
|
|
511
|
+
// the parent collection's index entry -- a URL no forward surface publishes. That is
|
|
512
|
+
// enforced on the read path (every candidate's entryPath must be a collection, see
|
|
513
|
+
// `isCollectionPath` and `ReadContentInput.urlAddressableOnly`) rather than by narrowing
|
|
514
|
+
// the delegation itself, which write()/renameEntry()/delete() also resolve through.
|
|
593
515
|
if (schemaItem.type === 'entry-type') {
|
|
594
516
|
const parentPath = schemaItem.parentPath || '';
|
|
595
517
|
const parentCollection = this.schemaIndex.get(parentPath);
|
|
596
518
|
if (!parentCollection || parentCollection.type !== 'collection') {
|
|
597
519
|
throw new ContentStoreError(`Parent collection not found for entry type: ${schemaItem.name}`, 'NO_SCHEMA_ITEM');
|
|
598
520
|
}
|
|
599
|
-
// Use provided slug, falling back to entry type name
|
|
600
521
|
const effectiveSlug = slug || schemaItem.name;
|
|
601
522
|
return this.buildPaths(parentCollection, effectiveSlug, {
|
|
602
523
|
...options,
|
|
@@ -611,17 +532,14 @@ export class ContentStore {
|
|
|
611
532
|
}
|
|
612
533
|
// Security: Validate slug format (prevents ../../../etc/passwd)
|
|
613
534
|
validateSlug(safeSlug);
|
|
614
|
-
// Determine which entry type to use
|
|
615
535
|
let entryTypeConfig;
|
|
616
536
|
if (options.entryTypeName) {
|
|
617
|
-
// Use specified entry type
|
|
618
537
|
entryTypeConfig = schemaItem.entries?.find((e) => e.name === options.entryTypeName);
|
|
619
538
|
if (!entryTypeConfig) {
|
|
620
539
|
throw new ContentStoreError(`Entry type '${options.entryTypeName}' not found in collection`, 'NO_SCHEMA_ITEM');
|
|
621
540
|
}
|
|
622
541
|
}
|
|
623
542
|
else {
|
|
624
|
-
// Use default entry type
|
|
625
543
|
entryTypeConfig = getDefaultEntryType(schemaItem.entries);
|
|
626
544
|
}
|
|
627
545
|
const format = entryTypeConfig?.format || 'json';
|
|
@@ -631,26 +549,22 @@ export class ContentStore {
|
|
|
631
549
|
// e.g., "content/docs/api" → "content/docs.bChqT78gcaLd/api.meiuwxTSo7UN"
|
|
632
550
|
let collectionRoot = await resolveCollectionPath(this.root, schemaItem.logicalPath);
|
|
633
551
|
if (!collectionRoot) {
|
|
634
|
-
//
|
|
635
|
-
// (Directory will be created on write if needed)
|
|
552
|
+
// No collection directory yet: use the logical path, write() creates it.
|
|
636
553
|
collectionRoot = path.resolve(this.root, schemaItem.logicalPath);
|
|
637
554
|
}
|
|
638
555
|
// Security: Prevent path traversal at collection level
|
|
639
556
|
if (!collectionRoot.startsWith(rootWithSep)) {
|
|
640
557
|
throw new ContentStoreError('Path traversal detected', 'VALIDATION');
|
|
641
558
|
}
|
|
642
|
-
// Check if file already exists (editing case)
|
|
643
559
|
let id = options.existingId;
|
|
644
560
|
let existingFilename;
|
|
645
561
|
let existingEntryType;
|
|
646
562
|
let foundExisting = false;
|
|
647
563
|
if (!id) {
|
|
648
|
-
// Try to find existing file with this slug
|
|
649
564
|
const entries = await fs.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
650
565
|
const existingFile = entries.find((entry) => {
|
|
651
566
|
if (entry.isDirectory())
|
|
652
567
|
return false;
|
|
653
|
-
// Extract entry type from filename to check slug properly
|
|
654
568
|
const fileEntryType = extractEntryTypeFromFilename(entry.name);
|
|
655
569
|
const existingSlug = extractSlugFromFilename(entry.name, fileEntryType || undefined);
|
|
656
570
|
return existingSlug === safeSlug;
|
|
@@ -664,27 +578,17 @@ export class ContentStore {
|
|
|
664
578
|
foundExisting = true;
|
|
665
579
|
}
|
|
666
580
|
}
|
|
667
|
-
// An entry "existed" if the directory scan above found it, OR the
|
|
668
|
-
// caller asserted an existingId (a presumed edit -- see buildPaths()'s
|
|
669
|
-
// doc comment on options.existingId). Not the same as `id` being
|
|
670
|
-
// truthy: a brand-new entry gets a freshly generated id below too.
|
|
671
581
|
const existed = foundExisting || Boolean(options.existingId);
|
|
672
|
-
// For existing entries, preserve the entry type (immutable after creation)
|
|
673
|
-
// For new entries, use the specified entry type
|
|
674
582
|
const finalEntryTypeName = existingEntryType || entryTypeName;
|
|
675
|
-
// Build filename: use existing filename if found, or generate new one with ID
|
|
676
583
|
let filename;
|
|
677
584
|
if (existingFilename) {
|
|
678
585
|
// Existing file found - use its original filename to preserve on-disk casing
|
|
679
586
|
filename = existingFilename;
|
|
680
587
|
}
|
|
681
588
|
else {
|
|
682
|
-
// Generate new ID if needed
|
|
683
589
|
if (!id) {
|
|
684
590
|
id = generateId();
|
|
685
591
|
}
|
|
686
|
-
// Build filename with embedded ID: type.slug.id.ext
|
|
687
|
-
// Use finalEntryTypeName to preserve entry type for existing entries
|
|
688
592
|
filename = `${finalEntryTypeName}.${safeSlug}.${id}${ext}`;
|
|
689
593
|
}
|
|
690
594
|
const resolved = path.resolve(collectionRoot, filename);
|
|
@@ -705,16 +609,12 @@ export class ContentStore {
|
|
|
705
609
|
}
|
|
706
610
|
throw new ContentStoreError('Invalid schema item type', 'VALIDATION');
|
|
707
611
|
}
|
|
708
|
-
/**
|
|
709
|
-
* Path resolution: resolves a URL path to a schema item
|
|
710
|
-
* - Try as collection + slug (last segment = slug)
|
|
711
|
-
*/
|
|
612
|
+
/** Resolve path segments to a collection schema item plus the trailing slug. */
|
|
712
613
|
resolvePath(pathSegments) {
|
|
713
614
|
if (pathSegments.length === 0) {
|
|
714
615
|
throw new ContentStoreError('Empty path', 'VALIDATION');
|
|
715
616
|
}
|
|
716
617
|
const logicalPath = pathSegments.join('/');
|
|
717
|
-
// Try as collection + slug
|
|
718
618
|
// Last segment of an API-validated LogicalPath; normalize to lowercase
|
|
719
619
|
const slug = pathSegments[pathSegments.length - 1].toLowerCase();
|
|
720
620
|
const collectionPath = pathSegments.slice(0, -1).join('/');
|
|
@@ -735,18 +635,16 @@ export class ContentStore {
|
|
|
735
635
|
async read(collectionPath, slug = '', options = {}) {
|
|
736
636
|
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
737
637
|
const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName, } = await this.buildPaths(schemaItem, slug);
|
|
738
|
-
// stat BEFORE readFile: conservative version token
|
|
739
|
-
// conflicts
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
// overwrite of a concurrent write (data loss).
|
|
638
|
+
// stat BEFORE readFile: a conservative version token can only produce false-positive
|
|
639
|
+
// conflicts. A write landing between stat and readFile gives the client newer content with
|
|
640
|
+
// an older token, so their next save 409s (safe); stat-after or a parallel stat+read risks
|
|
641
|
+
// the opposite, old content with a new token -- a silent overwrite of a concurrent write.
|
|
743
642
|
const stat = await fs.stat(absolutePath);
|
|
744
643
|
const raw = await fs.readFile(absolutePath, 'utf8');
|
|
745
644
|
let doc;
|
|
746
645
|
let format;
|
|
747
646
|
let fields;
|
|
748
647
|
if (schemaItem.type === 'entry-type') {
|
|
749
|
-
// Entry type from unified model
|
|
750
648
|
format = schemaItem.format;
|
|
751
649
|
fields = schemaItem.schema;
|
|
752
650
|
}
|
|
@@ -791,15 +689,11 @@ export class ContentStore {
|
|
|
791
689
|
collection: schemaItem.logicalPath,
|
|
792
690
|
collectionName: schemaItem.name,
|
|
793
691
|
format: format,
|
|
794
|
-
// Copy, never the object gray-matter hands back: its cache is
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
// shared object got replaced a few lines down. But that only happens when
|
|
800
|
-
// resolution runs: with `resolveReferences: false` the caller was handed
|
|
801
|
-
// the shared instance itself, and one mutation would poison every later
|
|
802
|
-
// parse of those bytes.
|
|
692
|
+
// Copy, never the object gray-matter hands back: its cache is process-global and
|
|
693
|
+
// keyed by file content, so every caller parsing the same bytes gets the SAME `data`
|
|
694
|
+
// instance (the hazard content-listing.ts documents at length). With
|
|
695
|
+
// `resolveReferences: false` the caller would otherwise be handed that shared
|
|
696
|
+
// instance, and one mutation would poison every later parse of those bytes.
|
|
803
697
|
data: { ...(parsed.data ?? {}) },
|
|
804
698
|
body: parsed.content,
|
|
805
699
|
bodyFieldName: findBodyFieldName(fields),
|
|
@@ -807,7 +701,6 @@ export class ContentStore {
|
|
|
807
701
|
absolutePath,
|
|
808
702
|
};
|
|
809
703
|
}
|
|
810
|
-
// Automatic reference resolution (defaults to true)
|
|
811
704
|
if (options.resolveReferences !== false) {
|
|
812
705
|
doc.data = await this.resolveReferencesInData(doc.data, fields);
|
|
813
706
|
}
|
|
@@ -826,7 +719,6 @@ export class ContentStore {
|
|
|
826
719
|
fields = schemaItem.schema;
|
|
827
720
|
}
|
|
828
721
|
else {
|
|
829
|
-
// For collections, determine format from specified or default entry type
|
|
830
722
|
let entryTypeConfig;
|
|
831
723
|
if (entryTypeName) {
|
|
832
724
|
entryTypeConfig = schemaItem.entries?.find((e) => e.name === entryTypeName);
|
|
@@ -843,38 +735,29 @@ export class ContentStore {
|
|
|
843
735
|
if (expectedFormat !== input.format) {
|
|
844
736
|
throw new ContentStoreError(`Format mismatch: expects ${expectedFormat}, got ${input.format}`, 'VALIDATION');
|
|
845
737
|
}
|
|
846
|
-
// Pre-pass: resolve paths OUTSIDE the lock, but ONLY to classify
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
// check above still runs unlocked
|
|
851
|
-
// path by directory-scanning for the slug, and that resolution can go
|
|
852
|
-
// stale the instant a concurrent renameEntry() completes, so it must be
|
|
853
|
-
// re-resolved (ground truth) after the lock is held -- see
|
|
854
|
-
// .claude/future-tasks/resolved/content-store-lock-key.md and entryLockKey().
|
|
738
|
+
// Pre-pass: resolve paths OUTSIDE the lock, but ONLY to classify existing-vs-new and pick
|
|
739
|
+
// a stable lock key -- never for the actual write. buildPaths() resolves the physical path
|
|
740
|
+
// by directory-scanning for the slug, and that resolution goes stale the instant a
|
|
741
|
+
// concurrent renameEntry() completes, so it must be re-resolved as ground truth once the
|
|
742
|
+
// lock is held (see entryLockKey()). The format check above still runs unlocked.
|
|
855
743
|
const prePass = await this.buildPaths(schemaItem, slug, { entryTypeName, existingId });
|
|
856
744
|
let lockKey = this.entryLockKey(schemaItem, slug, prePass);
|
|
857
|
-
// Reclassification loop: the pre-pass
|
|
858
|
-
//
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
// key
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
// scheduling.
|
|
868
|
-
// [SYNC-C1] Cross-host exclusion against the worker's rebase loop wraps
|
|
869
|
-
// the WHOLE reclassification loop: one acquisition per call, and no
|
|
870
|
-
// window between attempts where a rebase could start.
|
|
745
|
+
// Reclassification loop: the world can change between the pre-pass and lock acquisition (a
|
|
746
|
+
// concurrent renameEntry() freeing this slug flips existing->new; a concurrent create
|
|
747
|
+
// landing flips new->existing). Writing under the WRONG kind of key bypasses serialization
|
|
748
|
+
// against writers holding the right one -- e.g. a reclassified-to-create write under an
|
|
749
|
+
// id-key racing a create-key holder on the same slug, producing two same-slug files. So
|
|
750
|
+
// after the in-lock re-resolution, re-derive the key and, on mismatch, release and
|
|
751
|
+
// re-acquire under the current one. Bounded: each flip requires another mutator to have
|
|
752
|
+
// completed in the gap, so the cap only guards pathological scheduling.
|
|
753
|
+
// [SYNC-C1] Cross-host exclusion against the worker's rebase loop wraps the WHOLE loop:
|
|
754
|
+
// one acquisition per call, and no window between attempts where a rebase could start.
|
|
871
755
|
return this.withContentWriteExclusion(async () => {
|
|
872
756
|
const RETRY_KEY = Symbol('retry-with-new-lock-key');
|
|
873
757
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
874
758
|
const outcome = await withLock(lockKey, async () => {
|
|
875
|
-
// Re-resolve inside the lock: ground truth after acquisition
|
|
876
|
-
//
|
|
877
|
-
// pre-pass above and acquiring this lock.
|
|
759
|
+
// Re-resolve inside the lock: ground truth after acquisition, since a concurrent
|
|
760
|
+
// renameEntry() may have moved this entry since the pre-pass.
|
|
878
761
|
const inLock = await this.buildPaths(schemaItem, slug, {
|
|
879
762
|
entryTypeName,
|
|
880
763
|
existingId,
|
|
@@ -885,35 +768,29 @@ export class ContentStore {
|
|
|
885
768
|
return RETRY_KEY;
|
|
886
769
|
}
|
|
887
770
|
const { absolutePath, relativePath, id } = inLock;
|
|
888
|
-
// [F1] Duplicate-ID guard. INVARIANT: a write must never remove or
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
// save to it used to resolve the index to the OTHER file and unlink
|
|
898
|
-
// that one: a different document, silently deleted, with the write
|
|
899
|
-
// reporting success. Refuse instead (see DuplicateContentIdError).
|
|
771
|
+
// [F1] Duplicate-ID guard. INVARIANT: a write must never remove or modify a file it
|
|
772
|
+
// did not address. The post-write index-repair step below deletes the path the index
|
|
773
|
+
// holds for this ID when it differs from the one being written ("the slug changed"),
|
|
774
|
+
// which is sound only while an ID identifies exactly one file. A
|
|
775
|
+
// quarantined duplicate (ContentIdIndex's quarantine, from rename-crash debris or a
|
|
776
|
+
// merge) is still addressable by collection+slug -- buildPaths() resolves slugs by
|
|
777
|
+
// directory scan and knows nothing about the quarantine -- so a save to it would
|
|
778
|
+
// unlink a different document while reporting success. Refuse instead (see
|
|
779
|
+
// DuplicateContentIdError).
|
|
900
780
|
//
|
|
901
|
-
// Runs before any mutation, so a refusal leaves the tree exactly as
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
// non-duplicated entry: there the indexed path exists but the
|
|
915
|
-
// target does not (it is about to be created), so the cleanup
|
|
916
|
-
// below still removes exactly the file the caller relocated.
|
|
781
|
+
// Runs before any mutation, so a refusal leaves the tree exactly as it was. Two
|
|
782
|
+
// independent detections, because neither alone covers everything:
|
|
783
|
+
// 1. the index's own quarantine record -- catches a duplicate whose other copy
|
|
784
|
+
// lives in a different directory, and the ID-addressed (existingId) shape where
|
|
785
|
+
// the target is a fresh third path; needs the index to have scanned the
|
|
786
|
+
// duplicate.
|
|
787
|
+
// 2. disk-verified ambiguity -- the file we are about to write AND the indexed
|
|
788
|
+
// location both exist right now. Independent of index freshness, which is what
|
|
789
|
+
// makes the common slug-addressed save safe even against an index built before
|
|
790
|
+
// the duplicate landed.
|
|
791
|
+
// Neither fires for the ordinary slug-change save of a non-duplicated entry: there
|
|
792
|
+
// the indexed path exists but the target does not (it is about to be created), so
|
|
793
|
+
// the cleanup below still removes exactly the file the caller relocated.
|
|
917
794
|
if (id) {
|
|
918
795
|
const guardIndex = this._idIndex;
|
|
919
796
|
const indexedPath = guardIndex.findById(id)?.relativePath ?? null;
|
|
@@ -932,46 +809,40 @@ export class ContentStore {
|
|
|
932
809
|
}
|
|
933
810
|
}
|
|
934
811
|
// [URL] Contested-URL guard, create only: an ordinary save cannot contest a URL it
|
|
935
|
-
// already holds, and blocking it would trap the author in an entry they can no
|
|
936
|
-
// edit.
|
|
812
|
+
// already holds, and blocking it would trap the author in an entry they can no
|
|
813
|
+
// longer edit.
|
|
937
814
|
//
|
|
938
|
-
// Keyed on whether the target file actually EXISTS, not on `inLock.existed
|
|
939
|
-
// is
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
// while in fact creating one.
|
|
815
|
+
// Keyed on whether the target file actually EXISTS, not on `inLock.existed`, whose
|
|
816
|
+
// `existingId` half is a caller ASSERTION rather than disk truth -- so an
|
|
817
|
+
// id-addressed write recreating an entry deleted out from under it (git, another
|
|
818
|
+
// process) would skip the guard while in fact creating one.
|
|
943
819
|
//
|
|
944
820
|
// The slug is read back off the filename `buildPaths` actually chose, never the
|
|
945
|
-
// caller's raw argument
|
|
946
|
-
// the
|
|
947
|
-
//
|
|
821
|
+
// caller's raw argument, which it strips leading slashes from and lowercases:
|
|
822
|
+
// checking the raw value let `write(collection, '/guides', ...)` sail past a
|
|
823
|
+
// `guides` claimant.
|
|
948
824
|
//
|
|
949
825
|
// Serialization comes from `withContentWriteExclusion` (the branch-wide, cross-host
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
// different entry-lock keys. The branch lock is what makes check-then-write atomic
|
|
953
|
-
// against the other half landing concurrently.
|
|
826
|
+
// lock wrapping this whole loop), NOT the per-entry lock: the two halves of a
|
|
827
|
+
// contested pair live in DIFFERENT collections and take different entry-lock keys.
|
|
954
828
|
if (!(await filePathExists(absolutePath))) {
|
|
955
829
|
const chosenSlug = extractSlugFromFilename(path.basename(absolutePath));
|
|
956
|
-
// [SLUG] Routability guard, create only. `validateSlug`
|
|
957
|
-
// path-traversal check
|
|
958
|
-
// because it also runs on every READ. So the filename grammar accepts slugs
|
|
959
|
-
// `parseSlug`
|
|
960
|
-
// runs every URL candidate through `parseSlug`
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
// (static/index.ts)
|
|
830
|
+
// [SLUG] Routability guard, create only. `validateSlug` (in buildPaths) is the
|
|
831
|
+
// path-traversal check and rejects only `/` and `\`, and has to stay that weak
|
|
832
|
+
// because it also runs on every READ. So the filename grammar accepts slugs
|
|
833
|
+
// `parseSlug` rejects (a dot, an underscore, a leading hyphen), while
|
|
834
|
+
// `readByUrlPath` runs every URL candidate through `parseSlug` -- such an entry
|
|
835
|
+
// writes fine, builds fine, gets a `generateStaticParams` entry and a sitemap
|
|
836
|
+
// `<loc>`, then 404s on every actual visit. `assertRoutableSlugs`
|
|
837
|
+
// (static/index.ts) fails the whole production build over it.
|
|
964
838
|
//
|
|
965
|
-
// Refuse at the moment the
|
|
966
|
-
//
|
|
967
|
-
// target file not existing, for
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
// Checked against the slug `buildPaths` actually chose, not the caller's raw
|
|
973
|
-
// argument: buildPaths lowercases, strips leading slashes, and substitutes the entry
|
|
974
|
-
// type's name for an empty slug (the singleton shape, e.g. write('content/home', '')).
|
|
839
|
+
// Refuse at the moment the entry is CREATED, so the failure lands on the caller
|
|
840
|
+
// that caused it rather than on whoever builds next. Create-only, keyed on the
|
|
841
|
+
// target file not existing, for the same reason as the [URL] guard: content that
|
|
842
|
+
// already has a non-conforming slug must stay editable, and renaming is the only
|
|
843
|
+
// way out. Checked against the slug `buildPaths` chose (lowercased, leading
|
|
844
|
+
// slashes stripped, entry-type name substituted for an empty slug), not the
|
|
845
|
+
// caller's raw argument.
|
|
975
846
|
const routable = parseSlug(chosenSlug);
|
|
976
847
|
if (!routable.ok) {
|
|
977
848
|
throw new ContentStoreError(`Cannot create entry "${chosenSlug}": ${routable.error}. An entry whose slug is ` +
|
|
@@ -980,15 +851,13 @@ export class ContentStore {
|
|
|
980
851
|
await this.assertUrlPathAvailable(path.dirname(absolutePath), chosenSlug);
|
|
981
852
|
}
|
|
982
853
|
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
983
|
-
// OCC: undefined means no opinion (skip entirely,
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
// it holds even if a caller's own pre-write existence check (e.g. the
|
|
991
|
-
// API layer's `documentExists`) went stale under concurrency.
|
|
854
|
+
// OCC: undefined means no opinion (skip entirely, a blind write). A number means
|
|
855
|
+
// "must match this mtime" (stale-write rejection). `null` means "must NOT exist
|
|
856
|
+
// yet" — the create-intent guard, without which a create request against a slug
|
|
857
|
+
// that already has content falls through to an ordinary blind overwrite. This is
|
|
858
|
+
// the authoritative check: it runs inside the per-entry lock against a fresh stat,
|
|
859
|
+
// so it holds even if a caller's own pre-write existence check (e.g. the API
|
|
860
|
+
// layer's `documentExists`) went stale under concurrency.
|
|
992
861
|
if (input.expectedVersion !== undefined) {
|
|
993
862
|
try {
|
|
994
863
|
const existing = await fs.stat(absolutePath);
|
|
@@ -1003,46 +872,39 @@ export class ContentStore {
|
|
|
1003
872
|
if (err instanceof ContentConflictError)
|
|
1004
873
|
throw err;
|
|
1005
874
|
if (isNodeError(err) && err.code === 'ENOENT') {
|
|
1006
|
-
//
|
|
1007
|
-
// the
|
|
1008
|
-
//
|
|
1009
|
-
// case (create-only correctly finds no collision) — either way,
|
|
1010
|
-
// proceed with the write.
|
|
875
|
+
// No file yet: for a numeric expectedVersion this is the "first write,
|
|
876
|
+
// skip the version check" back-compat behavior; for null it is create-only
|
|
877
|
+
// correctly seeing no collision. Either way, proceed with the write.
|
|
1011
878
|
}
|
|
1012
879
|
else {
|
|
1013
880
|
throw err;
|
|
1014
881
|
}
|
|
1015
882
|
}
|
|
1016
883
|
}
|
|
1017
|
-
// Existence guard (cross-process): the caller asserts this entry
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
// renamed
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
// pass: the ID is nowhere on disk, so recreating is last-writer-wins.)
|
|
884
|
+
// Existence guard (cross-process): the caller asserts this entry exists
|
|
885
|
+
// (existingId), so a missing file at the path about to be written means this store's
|
|
886
|
+
// index may be stale -- another process may have renamed the entry. Recreating a
|
|
887
|
+
// renamed entry's old path would leave two files with the same embedded ID and
|
|
888
|
+
// poison every subsequent index rebuild. The directory listing is authoritative on
|
|
889
|
+
// this host: if the ID's actual on-disk location differs from what the index
|
|
890
|
+
// believes, fail with a conflict so the caller reloads fresh state. (An intentional
|
|
891
|
+
// slug-change save passes: index and directory agree on the entry's current,
|
|
892
|
+
// old-slug path. External deletes also pass: the ID is nowhere on disk, so
|
|
893
|
+
// recreating is last-writer-wins.)
|
|
1028
894
|
//
|
|
1029
|
-
// indexedRelPath reads the LIVE index synchronously — NOT idIndex()
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
// indexedRelPath can only turn agree->disagree (spurious conflict,
|
|
1036
|
-
// which the caller already handles by reloading), never
|
|
1037
|
-
// disagree->agree. Fail-closed, never fail-open.
|
|
895
|
+
// indexedRelPath reads the LIVE index synchronously — NOT idIndex(), which would
|
|
896
|
+
// run a full rescan while holding the entry lock if invalidateIndex() fired since
|
|
897
|
+
// the pre-lock warm-up. A stale live index only errs toward throwing: actualRelPath
|
|
898
|
+
// comes from the fresh in-lock directory scan (ground truth), so staleness can turn
|
|
899
|
+
// agree->disagree (a spurious conflict the caller already handles by reloading),
|
|
900
|
+
// never disagree->agree. Fail-closed, never fail-open.
|
|
1038
901
|
if (existingId && !(await filePathExists(absolutePath))) {
|
|
1039
902
|
const actualRelPaths = await this.findEntryPathsById(path.dirname(absolutePath), existingId);
|
|
1040
|
-
// [F1] Two files in the target directory carry this ID -- a
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
// one of two indistinguishable documents. Refuse deterministically.
|
|
903
|
+
// [F1] Two files in the target directory carry this ID -- a duplicate the index
|
|
904
|
+
// has not scanned yet (the guard above asks the index; this asks the directory).
|
|
905
|
+
// Which one a single-match scan returns is readdir-order-dependent, so it would
|
|
906
|
+
// 409 or pass at random, and passing meant unlinking one of two indistinguishable
|
|
907
|
+
// documents. Refuse deterministically.
|
|
1046
908
|
if (actualRelPaths.length > 1) {
|
|
1047
909
|
throw new DuplicateContentIdError(existingId, actualRelPaths);
|
|
1048
910
|
}
|
|
@@ -1052,27 +914,25 @@ export class ContentStore {
|
|
|
1052
914
|
throw new ContentConflictError();
|
|
1053
915
|
}
|
|
1054
916
|
}
|
|
1055
|
-
// Comment preservation: re-serialise onto the file's OWN parsed document rather
|
|
1056
|
-
// a fresh one, so nodes the payload did not change -- and
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
// read). See utils/content-serialize.ts.
|
|
917
|
+
// Comment preservation: re-serialise onto the file's OWN parsed document rather
|
|
918
|
+
// than a fresh one, so nodes the payload did not change -- and their attached
|
|
919
|
+
// comments -- survive the write. JSON has no comment syntax and skips the read. See
|
|
920
|
+
// utils/content-serialize.ts.
|
|
1060
921
|
//
|
|
1061
|
-
// This makes the write a genuine read-modify-write of the content file, so WHERE
|
|
1062
|
-
// read happens matters:
|
|
1063
|
-
// withContentWriteExclusion ([SYNC-C1]), and after the expectedVersion stat
|
|
1064
|
-
//
|
|
922
|
+
// This makes the write a genuine read-modify-write of the content file, so WHERE
|
|
923
|
+
// the read happens matters: inside withLock(lockKey), inside
|
|
924
|
+
// withContentWriteExclusion ([SYNC-C1]), and after the expectedVersion stat -- so
|
|
925
|
+
// the bytes read here are the bytes that OCC check validated, and no rebase can be
|
|
1065
926
|
// running against this branch. Do not hoist it out of the critical section.
|
|
1066
927
|
//
|
|
1067
928
|
// INVARIANT, and the one case that does NOT preserve comments: this reads the path
|
|
1068
|
-
// being WRITTEN. A relocating write --
|
|
1069
|
-
//
|
|
1070
|
-
//
|
|
929
|
+
// being WRITTEN. A relocating write -- the ID resolves to a different relativePath,
|
|
930
|
+
// so the block below unlinks `staleOldAbsPath` -- finds nothing at the new path and
|
|
931
|
+
// falls back to a plain stringify, losing the old file's comments. Unreachable
|
|
1071
932
|
// today: the editor renames through renameEntry(), which link()s the bytes across
|
|
1072
|
-
// intact, and no caller passes `existingId`. If a relocating write
|
|
933
|
+
// intact, and no caller passes `existingId`. If a relocating write becomes
|
|
1073
934
|
// reachable, read the ID's current path here instead of `absolutePath`.
|
|
1074
935
|
const existingRaw = input.format === 'json' ? undefined : await readFileIfExists(absolutePath);
|
|
1075
|
-
// Serialize content string
|
|
1076
936
|
let content;
|
|
1077
937
|
if (input.format === 'json') {
|
|
1078
938
|
content = `${JSON.stringify(input.data ?? {}, null, 2)}\n`;
|
|
@@ -1084,10 +944,9 @@ export class ContentStore {
|
|
|
1084
944
|
content = serializeFrontmatter(input.body, input.data ?? {}, existingRaw);
|
|
1085
945
|
}
|
|
1086
946
|
await atomicWriteFile(absolutePath, content);
|
|
1087
|
-
// Update the ID index after a successful write. Look up and mutate the
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
// pre-write snapshot, and updates must land where future lookups go.
|
|
947
|
+
// Update the ID index after a successful write. Look up and mutate the LIVE index
|
|
948
|
+
// in one synchronous window (no awaits in between): a concurrent rebuild may have
|
|
949
|
+
// swapped in a fresh instance, and updates must land where future lookups go.
|
|
1091
950
|
const liveIndex = this._idIndex;
|
|
1092
951
|
let staleOldAbsPath = null;
|
|
1093
952
|
if (id) {
|
|
@@ -1145,8 +1004,8 @@ export class ContentStore {
|
|
|
1145
1004
|
if (typeof outcome !== 'symbol')
|
|
1146
1005
|
return outcome;
|
|
1147
1006
|
}
|
|
1148
|
-
// Ten
|
|
1149
|
-
//
|
|
1007
|
+
// Ten foreign mutations in our acquisition gaps in a row: contention, so let the caller
|
|
1008
|
+
// reload and retry.
|
|
1150
1009
|
throw new ContentConflictError();
|
|
1151
1010
|
});
|
|
1152
1011
|
}
|
|
@@ -1186,7 +1045,6 @@ export class ContentStore {
|
|
|
1186
1045
|
}
|
|
1187
1046
|
}
|
|
1188
1047
|
/**
|
|
1189
|
-
* Get the ID for an entry given its collection and slug.
|
|
1190
1048
|
* Returns null if no ID exists yet.
|
|
1191
1049
|
*/
|
|
1192
1050
|
async getIdForEntry(collectionPath, slug) {
|
|
@@ -1213,20 +1071,16 @@ export class ContentStore {
|
|
|
1213
1071
|
}
|
|
1214
1072
|
}
|
|
1215
1073
|
/**
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
* (
|
|
1219
|
-
* existing-file lookup in buildPaths(), which this mirrors. Returns
|
|
1220
|
-
* undefined if no entry exists yet at this slug.
|
|
1074
|
+
* The on-disk entry type of an existing collection entry, read from its filename
|
|
1075
|
+
* (`{type}.{slug}.{id}.{ext}`), which is immutable after creation -- mirroring the
|
|
1076
|
+
* existing-file lookup in buildPaths(). Undefined if no entry exists yet at this slug.
|
|
1221
1077
|
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1224
|
-
*
|
|
1225
|
-
*
|
|
1226
|
-
* with what will actually be written (post-review M2).
|
|
1078
|
+
* The API write boundary (api/content.ts) validates an existing entry's payload against ITS
|
|
1079
|
+
* real schema rather than a caller-supplied (or omitted, or spoofed) `entryType` param:
|
|
1080
|
+
* ContentStore.write() preserves the on-disk type regardless of what is requested, so
|
|
1081
|
+
* validation must agree with what will actually be written.
|
|
1227
1082
|
*
|
|
1228
|
-
* Cheap: one buildPaths() resolution plus a stat, no file content is read
|
|
1229
|
-
* same cost class as documentExists().
|
|
1083
|
+
* Cheap: one buildPaths() resolution plus a stat, no file content is read.
|
|
1230
1084
|
*/
|
|
1231
1085
|
async getExistingEntryType(collectionPath, slug = '') {
|
|
1232
1086
|
const schemaItem = this.assertCollection(collectionPath);
|
|
@@ -1234,9 +1088,9 @@ export class ContentStore {
|
|
|
1234
1088
|
return (await filePathExists(absolutePath)) ? entryTypeName : undefined;
|
|
1235
1089
|
}
|
|
1236
1090
|
/**
|
|
1237
|
-
* Count existing entries of a given entry type in a collection, by filename
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1091
|
+
* Count existing entries of a given entry type in a collection, by filename (entry
|
|
1092
|
+
* filenames embed their type: `{type}.{slug}.{id}.{ext}`). Enforces
|
|
1093
|
+
* `EntryTypeConfig.maxItems` server-side at the create boundary.
|
|
1240
1094
|
*/
|
|
1241
1095
|
async countEntriesOfType(collectionPath, entryTypeName) {
|
|
1242
1096
|
this.assertCollection(collectionPath);
|
|
@@ -1249,56 +1103,40 @@ export class ContentStore {
|
|
|
1249
1103
|
return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
|
|
1250
1104
|
}
|
|
1251
1105
|
/**
|
|
1252
|
-
* Test-only seam: awaited right after the pre-pass buildPaths() resolves
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1257
|
-
* not-yet-acquired lock) to completion before this call proceeds to
|
|
1258
|
-
* acquire its (possibly now-stale) lock key -- see the "Deterministic
|
|
1259
|
-
* interleavings" testing pattern in docs/concurrency.md and
|
|
1260
|
-
* branch-registry.test.ts's `BlockingRegistry` for the same idiom.
|
|
1106
|
+
* Test-only seam: awaited right after the pre-pass buildPaths() resolves in delete() and
|
|
1107
|
+
* renameEntry(), while neither has yet acquired a lock. No-op in production. A test subclass
|
|
1108
|
+
* overrides it to pause in that exact window and run OTHER mutations to completion, making
|
|
1109
|
+
* the stale-lock-key path deterministic -- see the "Deterministic interleavings" testing
|
|
1110
|
+
* pattern in docs/concurrency.md and branch-registry.test.ts's `BlockingRegistry`.
|
|
1261
1111
|
*/
|
|
1262
1112
|
async afterPrePassForTesting() { }
|
|
1263
|
-
/**
|
|
1264
|
-
* Delete an entry and remove it from the index.
|
|
1265
|
-
*/
|
|
1113
|
+
/** Delete an entry and remove it from the index. */
|
|
1266
1114
|
async delete(collectionPath, slug) {
|
|
1267
1115
|
// Warm the index outside the lock (a full scan must not hold the entry lock)
|
|
1268
1116
|
await this.idIndex();
|
|
1269
1117
|
const collection = this.assertCollection(collectionPath);
|
|
1270
|
-
// Pre-pass: classify existing-vs-already-gone
|
|
1271
|
-
//
|
|
1272
|
-
// exactly the way this locking scheme guards against (a rename this
|
|
1273
|
-
// store hasn't observed yet).
|
|
1118
|
+
// Pre-pass: classify existing-vs-already-gone by directory scan (local ground truth), not
|
|
1119
|
+
// this._idIndex, which can be stale in exactly the way this locking scheme guards against.
|
|
1274
1120
|
const prePass = await this.buildPaths(collection, slug);
|
|
1275
1121
|
if (!prePass.existed) {
|
|
1276
|
-
// Nothing on disk for this slug -- no shared resource to lock on, and
|
|
1277
|
-
//
|
|
1278
|
-
// ENOENT on the pre-pass's freshly generated, necessarily-nonexistent
|
|
1279
|
-
// path).
|
|
1122
|
+
// Nothing on disk for this slug -- no shared resource to lock on, and fs.unlink on the
|
|
1123
|
+
// freshly generated (necessarily nonexistent) path throws ENOENT, as callers expect.
|
|
1280
1124
|
await fs.unlink(prePass.absolutePath);
|
|
1281
1125
|
return;
|
|
1282
1126
|
}
|
|
1283
1127
|
let lockKey = this.entryLockKey(collection, slug, prePass);
|
|
1284
1128
|
await this.afterPrePassForTesting();
|
|
1285
|
-
// Reclassification loop, mirroring write()'s (see its doc comment):
|
|
1286
|
-
//
|
|
1287
|
-
//
|
|
1288
|
-
// the key this slug would resolve to. Re-derive the key from the in-lock
|
|
1289
|
-
// ground truth and retry under the corrected key on mismatch, bounded to
|
|
1290
|
-
// rule out only pathological scheduling.
|
|
1129
|
+
// Reclassification loop, mirroring write()'s (see its doc comment): a concurrent
|
|
1130
|
+
// renameEntry() moving this slug's entry away flips the key this slug resolves to, so
|
|
1131
|
+
// re-derive the key from in-lock ground truth and retry under the corrected one.
|
|
1291
1132
|
// [SYNC-C1] See write()'s call to withContentWriteExclusion.
|
|
1292
1133
|
return this.withContentWriteExclusion(async () => {
|
|
1293
1134
|
const RETRY_KEY = Symbol('retry-with-new-lock-key');
|
|
1294
1135
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
1295
1136
|
const outcome = await withLock(lockKey, async () => {
|
|
1296
|
-
// Re-resolve inside the lock: ground truth after acquisition. If a
|
|
1297
|
-
//
|
|
1298
|
-
//
|
|
1299
|
-
// the entry genuinely isn't at this slug anymore, so we fall through
|
|
1300
|
-
// to the unlink below on a freshly generated (nonexistent) path,
|
|
1301
|
-
// preserving the original ENOENT-throwing behavior.
|
|
1137
|
+
// Re-resolve inside the lock: ground truth after acquisition. If a concurrent
|
|
1138
|
+
// renameEntry() moved this slug away, buildPaths() no longer finds it here, so we
|
|
1139
|
+
// fall through to unlink a freshly generated (nonexistent) path and throw ENOENT.
|
|
1302
1140
|
const inLock = await this.buildPaths(collection, slug);
|
|
1303
1141
|
const currentKey = this.entryLockKey(collection, slug, inLock);
|
|
1304
1142
|
if (currentKey !== lockKey) {
|
|
@@ -1306,7 +1144,6 @@ export class ContentStore {
|
|
|
1306
1144
|
return RETRY_KEY;
|
|
1307
1145
|
}
|
|
1308
1146
|
const { absolutePath, relativePath } = inLock;
|
|
1309
|
-
// Delete file
|
|
1310
1147
|
await fs.unlink(absolutePath);
|
|
1311
1148
|
// Remove from the LIVE index — lookup and mutation in one synchronous
|
|
1312
1149
|
// window, since a concurrent rebuild may swap instances across awaits.
|
|
@@ -1321,20 +1158,14 @@ export class ContentStore {
|
|
|
1321
1158
|
if (outcome !== RETRY_KEY)
|
|
1322
1159
|
return;
|
|
1323
1160
|
}
|
|
1324
|
-
// Ten
|
|
1325
|
-
//
|
|
1161
|
+
// Ten foreign mutations in our acquisition gaps in a row: contention, so let the caller
|
|
1162
|
+
// reload and retry.
|
|
1326
1163
|
throw new ContentConflictError();
|
|
1327
1164
|
});
|
|
1328
1165
|
}
|
|
1329
1166
|
/**
|
|
1330
1167
|
* Rename an entry by changing its slug (middle segment of filename).
|
|
1331
1168
|
* Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
|
|
1332
|
-
*
|
|
1333
|
-
* @param collectionPath - Logical path to the collection
|
|
1334
|
-
* @param currentSlug - Current slug of the entry
|
|
1335
|
-
* @param newSlug - New slug (must be unique within collection)
|
|
1336
|
-
* @returns Object with new logical path
|
|
1337
|
-
* @throws ContentStoreError if entry doesn't exist, new slug conflicts, or validation fails
|
|
1338
1169
|
*/
|
|
1339
1170
|
async renameEntry(collectionPath, currentSlug, newSlug) {
|
|
1340
1171
|
// Warm the index outside the lock (a full scan must not hold the entry lock)
|
|
@@ -1346,20 +1177,18 @@ export class ContentStore {
|
|
|
1346
1177
|
if (!safeNewSlug) {
|
|
1347
1178
|
throw new ContentStoreError('New slug cannot be empty', 'VALIDATION');
|
|
1348
1179
|
}
|
|
1349
|
-
// If slugs are the same, no-op
|
|
1350
1180
|
if (currentSlug === safeNewSlug) {
|
|
1351
1181
|
return { newPath: `${collectionPath}/${currentSlug}` };
|
|
1352
1182
|
}
|
|
1353
|
-
// [SLUG] Routability check, run here rather than trusted from the caller
|
|
1354
|
-
// type does
|
|
1355
|
-
// with only `.toLowerCase()`, and tests/callers reach for `unsafeAsSlug
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
1358
|
-
// unroutable entry just as a create can. See write()'s [SLUG] guard for what goes wrong.
|
|
1183
|
+
// [SLUG] Routability check, run here rather than trusted from the caller: the `Slug`
|
|
1184
|
+
// branded type does not guarantee it (`ContentStore.resolvePath` casts a raw path segment
|
|
1185
|
+
// with only `.toLowerCase()`, and tests/callers reach for `unsafeAsSlug`), and a rename
|
|
1186
|
+
// mints a new filename, so it can create an unroutable entry just as a create can. See
|
|
1187
|
+
// write()'s [SLUG] guard for what goes wrong.
|
|
1359
1188
|
//
|
|
1360
1189
|
// Deliberately AFTER the no-op short-circuit above: a rename to the slug the entry already
|
|
1361
|
-
// has mints nothing, so refusing it would only
|
|
1362
|
-
//
|
|
1190
|
+
// has mints nothing, so refusing it would only make one more operation on an existing
|
|
1191
|
+
// non-conforming entry fail, for no gain.
|
|
1363
1192
|
const routable = parseSlug(safeNewSlug);
|
|
1364
1193
|
if (!routable.ok) {
|
|
1365
1194
|
throw new ContentStoreError(`Cannot rename to "${safeNewSlug}": ${routable.error}. An entry whose slug is not ` +
|
|
@@ -1373,32 +1202,22 @@ export class ContentStore {
|
|
|
1373
1202
|
}
|
|
1374
1203
|
let sourceLockKey = this.entryLockKey(collection, currentSlug, prePass);
|
|
1375
1204
|
let sourceId = prePass.id;
|
|
1376
|
-
// Also lock the destination create-key. Content ID is rename-invariant,
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
// in
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
// a file that's actually a different (renamed-in) entry. Acquiring both
|
|
1384
|
-
// keys in canonical (sorted) order rules out AB-BA deadlocks; in
|
|
1385
|
-
// practice the id: and create: keyspaces never share a literal key
|
|
1386
|
-
// string (disjoint prefixes), so two renameEntry() calls can never
|
|
1387
|
-
// contend for the exact same pair of keys in reversed roles, but sorting
|
|
1388
|
-
// costs nothing.
|
|
1205
|
+
// Also lock the destination create-key. Content ID is rename-invariant, so the SOURCE side
|
|
1206
|
+
// only ever needs one lock -- but the DESTINATION slug can simultaneously be the target of
|
|
1207
|
+
// a concurrent write() creating a brand-new entry there. Without this second lock, that
|
|
1208
|
+
// write()'s in-lock re-resolution could observe the just-linked destination file and
|
|
1209
|
+
// silently fold in as an "edit" of the renamed entry, applying the create's requested
|
|
1210
|
+
// entry type/schema to what is actually a different entry. Both keys are acquired in
|
|
1211
|
+
// canonical order (see withLocks).
|
|
1389
1212
|
let destLockKey = this.createLockKey(collection, safeNewSlug);
|
|
1390
1213
|
await this.afterPrePassForTesting();
|
|
1391
|
-
// Reclassification loop, mirroring write()'s (see its doc comment): the
|
|
1392
|
-
//
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1396
|
-
// file
|
|
1397
|
-
//
|
|
1398
|
-
// "same file" from "a different legacy file now occupies this slug").
|
|
1399
|
-
// On either mismatch, release and retry under the corrected keys rather
|
|
1400
|
-
// than renaming the wrong entry under the stale lock -- bounded to rule
|
|
1401
|
-
// out only pathological scheduling.
|
|
1214
|
+
// Reclassification loop, mirroring write()'s (see its doc comment): the pre-pass's source
|
|
1215
|
+
// key can go stale -- this entry may have been renamed away and a brand-new, different-ID
|
|
1216
|
+
// entry landed at this same slug in the gap. Re-derive the source key from in-lock ground
|
|
1217
|
+
// truth AND verify the resolved file's embedded ID still matches the pre-pass ID, since a
|
|
1218
|
+
// path-based fallback key (legacy no-ID filenames) cannot otherwise tell "same file" from
|
|
1219
|
+
// "a different legacy file now occupying this slug". On either mismatch, release and retry
|
|
1220
|
+
// under the corrected keys rather than renaming the wrong entry under the stale lock.
|
|
1402
1221
|
// [SYNC-C1] See write()'s call to withContentWriteExclusion.
|
|
1403
1222
|
return this.withContentWriteExclusion(async () => {
|
|
1404
1223
|
const RETRY_KEY = Symbol('retry-with-new-lock-key');
|
|
@@ -1407,14 +1226,12 @@ export class ContentStore {
|
|
|
1407
1226
|
// Re-resolve inside the lock: ground truth after acquisition.
|
|
1408
1227
|
const inLock = await this.buildPaths(collection, currentSlug);
|
|
1409
1228
|
if (!inLock.existed) {
|
|
1410
|
-
//
|
|
1411
|
-
// matches the original access()-based NOT_FOUND behavior.
|
|
1229
|
+
// The entry genuinely isn't at this slug anymore (e.g. deleted).
|
|
1412
1230
|
throw new ContentStoreError(`Entry not found: ${currentSlug}`, 'NOT_FOUND');
|
|
1413
1231
|
}
|
|
1414
1232
|
const currentSourceKey = this.entryLockKey(collection, currentSlug, inLock);
|
|
1415
|
-
// The dest create-key is purely slug-derived and
|
|
1416
|
-
//
|
|
1417
|
-
// source side.
|
|
1233
|
+
// The dest create-key is purely slug-derived and cannot change across attempts;
|
|
1234
|
+
// recomputed for uniformity with the source side.
|
|
1418
1235
|
const currentDestKey = this.createLockKey(collection, safeNewSlug);
|
|
1419
1236
|
if (currentSourceKey !== sourceLockKey || inLock.id !== sourceId) {
|
|
1420
1237
|
sourceLockKey = currentSourceKey;
|
|
@@ -1424,7 +1241,6 @@ export class ContentStore {
|
|
|
1424
1241
|
}
|
|
1425
1242
|
destLockKey = currentDestKey;
|
|
1426
1243
|
const { absolutePath: currentPath, relativePath: currentRelPath } = inLock;
|
|
1427
|
-
// Extract entry type name and extension from current filename
|
|
1428
1244
|
const currentFilename = path.basename(currentPath);
|
|
1429
1245
|
const parts = currentFilename.split('.');
|
|
1430
1246
|
if (parts.length < 4) {
|
|
@@ -1433,7 +1249,6 @@ export class ContentStore {
|
|
|
1433
1249
|
const entryTypeName = parts[0];
|
|
1434
1250
|
const contentId = parts[parts.length - 2];
|
|
1435
1251
|
const ext = `.${parts[parts.length - 1]}`;
|
|
1436
|
-
// Build new filename with new slug
|
|
1437
1252
|
const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
|
|
1438
1253
|
const parentDir = path.dirname(currentPath);
|
|
1439
1254
|
const newPath = path.join(parentDir, newFilename);
|
|
@@ -1456,29 +1271,25 @@ export class ContentStore {
|
|
|
1456
1271
|
// Ignore filesystem errors (e.g. ENOENT if parent dir doesn't exist)
|
|
1457
1272
|
}
|
|
1458
1273
|
// [URL] Contested-URL guard. A rename moves the entry to a NEW url, so the same
|
|
1459
|
-
// check a create gets applies here -- including the index direction, since
|
|
1460
|
-
// an entry TO `index` is how
|
|
1274
|
+
// check a create gets applies here -- including the index direction, since
|
|
1275
|
+
// renaming an entry TO `index` is how a collection acquires a landing page.
|
|
1461
1276
|
//
|
|
1462
|
-
// Runs AFTER the same-slug scan above, deliberately
|
|
1463
|
-
// both refusals apply
|
|
1464
|
-
//
|
|
1465
|
-
// the other claimant, only to hit the same-slug refusal on the retry. Still before
|
|
1466
|
-
// link(), so either refusal leaves the tree untouched.
|
|
1277
|
+
// Runs AFTER the same-slug scan above, deliberately: in an already-contested tree
|
|
1278
|
+
// both refusals apply and the same-slug one is the more immediate and actionable.
|
|
1279
|
+
// Still before link(), so either refusal leaves the tree untouched.
|
|
1467
1280
|
await this.assertUrlPathAvailable(parentDir, safeNewSlug);
|
|
1468
|
-
//
|
|
1469
|
-
// exact same destination path fails with EEXIST
|
|
1281
|
+
// link()+unlink() rather than rename(), so a concurrent cross-process rename to
|
|
1282
|
+
// the exact same destination path fails with EEXIST instead of silently
|
|
1283
|
+
// overwriting.
|
|
1470
1284
|
//
|
|
1471
|
-
// Tradeoff:
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
// delete() targeting this same entry is fully serialized against this rename and
|
|
1480
|
-
// cannot observe this partial-failure window; only a genuinely separate process
|
|
1481
|
-
// acting directly on the filesystem without going through this store could.
|
|
1285
|
+
// Tradeoff: two steps, not one atomic syscall. If unlink() fails after a
|
|
1286
|
+
// successful link() (a transient EFS error), both slug files exist pointing at one
|
|
1287
|
+
// inode; the index holds the new path so reads work, and the orphaned source
|
|
1288
|
+
// persists until the next rename or deletion of that entry. Acceptable: the EEXIST
|
|
1289
|
+
// protection prevents silent data loss on concurrent renames, and the partial
|
|
1290
|
+
// failure is detectable and recoverable. write()/delete()/renameEntry() all lock
|
|
1291
|
+
// on content ID, so only a process acting on the filesystem outside this store can
|
|
1292
|
+
// observe that window.
|
|
1482
1293
|
try {
|
|
1483
1294
|
await fs.link(currentPath, newPath);
|
|
1484
1295
|
}
|
|
@@ -1498,45 +1309,32 @@ export class ContentStore {
|
|
|
1498
1309
|
liveIndex.updatePath(entryId, newRelativePath);
|
|
1499
1310
|
}
|
|
1500
1311
|
await this.recordOwnMutation(liveIndex);
|
|
1501
|
-
// Return new logical path
|
|
1502
1312
|
return { newPath: `${collectionPath}/${safeNewSlug}` };
|
|
1503
1313
|
});
|
|
1504
1314
|
if (typeof outcome !== 'symbol')
|
|
1505
1315
|
return outcome;
|
|
1506
1316
|
}
|
|
1507
|
-
// Ten
|
|
1508
|
-
//
|
|
1317
|
+
// Ten foreign mutations in our acquisition gaps in a row: contention, so let the caller
|
|
1318
|
+
// reload and retry.
|
|
1509
1319
|
throw new ContentConflictError();
|
|
1510
1320
|
});
|
|
1511
1321
|
}
|
|
1512
|
-
/**
|
|
1513
|
-
* List all entries in a collection tree (including subcollections).
|
|
1514
|
-
* For example, passing 'content/data-catalog' returns entries from
|
|
1515
|
-
* 'content/data-catalog', 'content/data-catalog/partner-a', etc.
|
|
1516
|
-
* Returns array of entry metadata (relativePath, collection, slug).
|
|
1517
|
-
* Returns empty array if the collection doesn't exist.
|
|
1518
|
-
*/
|
|
1519
1322
|
/**
|
|
1520
1323
|
* Resolve a schema collection referenced by name or logical path.
|
|
1521
1324
|
*
|
|
1522
|
-
* Accepts
|
|
1523
|
-
*
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
1526
|
-
* the dropdown and the write boundary can never disagree.
|
|
1325
|
+
* Accepts a full logical path ("content/authors") or a bare collection name ("authors") --
|
|
1326
|
+
* the contract reference fields use for `collections: [...]` (see README). The single
|
|
1327
|
+
* normalization point shared by reference-option loading and reference validation, so the
|
|
1328
|
+
* dropdown and the write boundary can never disagree.
|
|
1527
1329
|
*
|
|
1528
|
-
* CAVEAT: the bare-name fallback matches on the LAST path segment and
|
|
1529
|
-
*
|
|
1530
|
-
*
|
|
1531
|
-
*
|
|
1532
|
-
* same-named collections.
|
|
1330
|
+
* CAVEAT: the bare-name fallback matches on the LAST path segment and returns the first hit
|
|
1331
|
+
* in schema order, so with two collections sharing a leaf name (content/blog/posts,
|
|
1332
|
+
* content/news/posts) a bare 'posts' is ambiguous. Use the full logical path in schemas that
|
|
1333
|
+
* nest same-named collections.
|
|
1533
1334
|
*/
|
|
1534
1335
|
resolveCollectionItem(collectionPath) {
|
|
1535
|
-
// The schema index uses normalized logical paths like "content/authors"
|
|
1536
1336
|
const normalized = normalizeFilesystemPath(collectionPath);
|
|
1537
1337
|
let item = this.schemaIndex.get(normalized);
|
|
1538
|
-
// If not found by full path, try matching the last segment
|
|
1539
|
-
// (handles cases where caller passes "posts" instead of "content/posts")
|
|
1540
1338
|
if (!item) {
|
|
1541
1339
|
for (const schemaItem of this.schemaIndex.values()) {
|
|
1542
1340
|
if (schemaItem.type === 'collection') {
|
|
@@ -1550,16 +1348,18 @@ export class ContentStore {
|
|
|
1550
1348
|
}
|
|
1551
1349
|
return item && item.type === 'collection' ? item : undefined;
|
|
1552
1350
|
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Every entry in a collection tree, including subcollections: 'content/data-catalog'
|
|
1353
|
+
* returns entries from it and from 'content/data-catalog/partner-a' and so on. Empty when
|
|
1354
|
+
* the collection does not exist.
|
|
1355
|
+
*/
|
|
1553
1356
|
async getCollectionEntryPaths(collectionPath) {
|
|
1554
1357
|
const idIndex = await this.idIndex();
|
|
1555
|
-
// Return empty array if collection doesn't exist or isn't a collection
|
|
1556
1358
|
const collection = this.resolveCollectionItem(collectionPath);
|
|
1557
1359
|
if (!collection) {
|
|
1558
1360
|
return [];
|
|
1559
1361
|
}
|
|
1560
|
-
// Get entries from this collection and all subcollections via tree traversal
|
|
1561
1362
|
const treeEntries = idIndex.getEntriesInCollectionTree(collection.logicalPath);
|
|
1562
|
-
// Filter and map to required format
|
|
1563
1363
|
const entries = [];
|
|
1564
1364
|
for (const location of treeEntries) {
|
|
1565
1365
|
if (location.type === 'entry' && location.slug && location.collection) {
|
|
@@ -1575,29 +1375,21 @@ export class ContentStore {
|
|
|
1575
1375
|
/**
|
|
1576
1376
|
* Resolve every `reference` field in `data` against `fields`, returning a copy.
|
|
1577
1377
|
*
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1581
|
-
*
|
|
1582
|
-
*
|
|
1378
|
+
* What `read()` applies automatically (unless `resolveReferences: false`), exposed so BATCH
|
|
1379
|
+
* surfaces can opt into the same resolution without duplicating the walk: the listing
|
|
1380
|
+
* primitives in content-listing.ts and content-tree.ts read entry files straight off disk
|
|
1381
|
+
* and never touch this store. Pass one {@link ReferenceResolveCache} across a whole batch so
|
|
1382
|
+
* a target referenced by many entries is read once; omit `cache` for exactly `read()`'s
|
|
1383
|
+
* unmemoized behavior.
|
|
1583
1384
|
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
* **Path ACLs are not consulted for the targets.** Resolution goes through this store's
|
|
1589
|
-
* own `read()`, below the per-entry permission check in content-reader.ts — so a resolved
|
|
1590
|
-
* target may be an entry the caller could not `read()` directly. That is pre-existing
|
|
1591
|
-
* `read()` behavior, matched here on purpose so one rule covers both; see the
|
|
1592
|
-
* `resolveReferences` option in content-listing.ts for the note adopters see.
|
|
1385
|
+
* **Path ACLs are not consulted for the targets.** Resolution goes through this store's own
|
|
1386
|
+
* `read()`, below the per-entry permission check in content-reader.ts, so a resolved target
|
|
1387
|
+
* may be an entry the caller could not `read()` directly -- pre-existing `read()` behavior,
|
|
1388
|
+
* matched here on purpose so one rule covers both.
|
|
1593
1389
|
*/
|
|
1594
1390
|
async resolveReferences(data, fields, cache) {
|
|
1595
1391
|
return this.resolveReferencesInData(data, fields, cache);
|
|
1596
1392
|
}
|
|
1597
|
-
/**
|
|
1598
|
-
* Recursively resolve reference fields in data.
|
|
1599
|
-
* This traverses objects, arrays, and blocks to find and resolve all reference fields.
|
|
1600
|
-
*/
|
|
1601
1393
|
async resolveReferencesInData(data, fields, cache) {
|
|
1602
1394
|
const resolved = { ...data };
|
|
1603
1395
|
const idIndex = await this.idIndex();
|
|
@@ -1614,18 +1406,15 @@ export class ContentStore {
|
|
|
1614
1406
|
// to it is a property of the field, declared once in the schema -- not of the call,
|
|
1615
1407
|
// which routinely contains both kinds at once. See ReferenceFieldConfig.includeBody.
|
|
1616
1408
|
const includeBody = field.includeBody === true;
|
|
1617
|
-
// Single reference
|
|
1618
1409
|
if (typeof value === 'string' && value) {
|
|
1619
1410
|
resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache);
|
|
1620
1411
|
}
|
|
1621
|
-
// Array of references (list: true)
|
|
1622
1412
|
else if (field.list && Array.isArray(value)) {
|
|
1623
1413
|
resolved[field.name] = await Promise.all(value.map((id) => typeof id === 'string'
|
|
1624
1414
|
? this.resolveSingleReference(id, idIndex, includeBody, cache)
|
|
1625
1415
|
: null));
|
|
1626
1416
|
}
|
|
1627
1417
|
}
|
|
1628
|
-
// Recursively handle nested objects
|
|
1629
1418
|
else if (field.type === 'object' && value) {
|
|
1630
1419
|
const objectField = field;
|
|
1631
1420
|
if (!objectField.fields)
|
|
@@ -1639,7 +1428,6 @@ export class ContentStore {
|
|
|
1639
1428
|
resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache);
|
|
1640
1429
|
}
|
|
1641
1430
|
}
|
|
1642
|
-
// Recursively handle blocks
|
|
1643
1431
|
else if (field.type === 'block' && Array.isArray(value)) {
|
|
1644
1432
|
const blockField = field;
|
|
1645
1433
|
resolved[field.name] = await Promise.all(value.map(async (block) => {
|
|
@@ -1659,95 +1447,69 @@ export class ContentStore {
|
|
|
1659
1447
|
return resolved;
|
|
1660
1448
|
}
|
|
1661
1449
|
/**
|
|
1662
|
-
* Resolve a single reference ID to full entry data, memoized when a batch cache is
|
|
1663
|
-
*
|
|
1664
|
-
* `read()` behaves exactly as it always has.
|
|
1450
|
+
* Resolve a single reference ID to full entry data, memoized when a batch cache is supplied.
|
|
1451
|
+
* Without a cache this is a straight pass-through, so `read()` behaves as it always has.
|
|
1665
1452
|
*
|
|
1666
|
-
* Every occurrence gets its OWN deep copy, even on a cache hit. Without that, the memo
|
|
1667
|
-
*
|
|
1668
|
-
*
|
|
1669
|
-
*
|
|
1670
|
-
*
|
|
1671
|
-
* instead of a semantic change.
|
|
1453
|
+
* Every occurrence gets its OWN deep copy, even on a cache hit. Without that, the memo would
|
|
1454
|
+
* hand one shared object to all 40 entries referencing the same block -- a mutation in one
|
|
1455
|
+
* caller's `extract` would silently rewrite it for every sibling, and both `list: true`
|
|
1456
|
+
* elements of `[id, id]` would be the same instance. The copy is what keeps the cache a pure
|
|
1457
|
+
* performance optimization instead of a semantic change.
|
|
1672
1458
|
*
|
|
1673
|
-
*
|
|
1674
|
-
*
|
|
1675
|
-
*
|
|
1676
|
-
*
|
|
1677
|
-
*
|
|
1678
|
-
* touching it would change `read()` — but it means the cached path is the safer of the two,
|
|
1679
|
-
* not a relaxation of a guarantee the uncached one provides. See
|
|
1680
|
-
* `.claude/future-tasks/graymatter-cache-shared-frontmatter.md`.
|
|
1459
|
+
* The uncached path is NOT the clean baseline it looks like: for md/mdx, gray-matter serves
|
|
1460
|
+
* `data` from a process-global content-keyed cache, so `resolveSingleReferenceOnce`'s
|
|
1461
|
+
* top-level spread severs exactly one level and NESTED frontmatter objects alias across
|
|
1462
|
+
* occurrences, calls and requests. Pre-existing `read()` behavior, deliberately out of scope
|
|
1463
|
+
* here; see `.claude/future-tasks/graymatter-cache-shared-frontmatter.md`.
|
|
1681
1464
|
*
|
|
1682
|
-
*
|
|
1683
|
-
*
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
1687
|
-
*
|
|
1688
|
-
*
|
|
1689
|
-
* `resolveSingleReferenceOnce` spreads in only for an embedding field), so by default body
|
|
1690
|
-
* size is irrelevant no matter how long the document and only a genuinely huge JSON/YAML
|
|
1691
|
-
* target reaches the wash. **`includeBody: true` is the case that CAN reach it on markdown**:
|
|
1692
|
-
* the body then sits inside the memoized object and is cloned once per referencing entry, so
|
|
1693
|
-
* a long document embedded by many pages pays that repeatedly — the reason `includeBody`
|
|
1694
|
-
* exists as an opt-in per field rather than as resolution's default. And in the deployment this
|
|
1695
|
-
* targets, content lives on EFS/NFS where the syscall the memo removes dominates parse and
|
|
1696
|
-
* clone alike, which the local-disk numbers above understate badly. Correctness is the
|
|
1697
|
-
* reason for the copy regardless; the numbers are here so nobody has to re-derive them
|
|
1698
|
-
* before touching this.
|
|
1465
|
+
* Measured (2000 occurrences of one target, local disk, the friendliest case for NOT
|
|
1466
|
+
* caching): cloning a snippet-sized target is ~63x cheaper than re-reading and parsing it, a
|
|
1467
|
+
* 265KB JSON target ~0.8x -- a wash at the pathological end, never a blow-up, and on the
|
|
1468
|
+
* EFS/NFS this targets the syscall the memo removes dominates both. An md/mdx target
|
|
1469
|
+
* resolves to its FRONTMATTER unless the field sets `includeBody`, which is why
|
|
1470
|
+
* `includeBody` is opt-in per field: an embedded body sits inside the memoized object and is
|
|
1471
|
+
* cloned once per referencing entry.
|
|
1699
1472
|
*/
|
|
1700
1473
|
resolveSingleReference(id, idIndex, includeBody, cache) {
|
|
1701
1474
|
if (!cache)
|
|
1702
1475
|
return this.resolveSingleReferenceUncached(id, idIndex, includeBody);
|
|
1703
1476
|
// The key carries `includeBody`, not just the id: two fields can reference the SAME target
|
|
1704
1477
|
// with different settings, and sharing one entry between them would make the shape depend
|
|
1705
|
-
// on which field the walk reached first
|
|
1706
|
-
// per-occurrence copy already exists to prevent.
|
|
1478
|
+
// on which field the walk reached first.
|
|
1707
1479
|
const key = includeBody ? `${id}:body` : id;
|
|
1708
1480
|
let pending = cache.get(key);
|
|
1709
1481
|
if (!pending) {
|
|
1710
|
-
// Store the in-flight promise
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1713
|
-
// repeated — see ReferenceResolveCache for why misses are cached too.
|
|
1482
|
+
// Store the in-flight promise with no `await` in between, so concurrent lookups from
|
|
1483
|
+
// one Promise.all batch find it and collapse onto a single read. Memoizing the promise
|
|
1484
|
+
// also shares the self-healing retry below rather than repeating it.
|
|
1714
1485
|
pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody);
|
|
1715
1486
|
cache.set(key, pending);
|
|
1716
1487
|
}
|
|
1717
1488
|
// The cached promise always has this handler attached, so it is never an unhandled
|
|
1718
|
-
// rejection; entry data is plain parsed JSON/YAML/frontmatter
|
|
1719
|
-
//
|
|
1720
|
-
// preserved exactly is a `!!binary` Buffer, which clones to a plain Uint8Array).
|
|
1489
|
+
// rejection; entry data is plain parsed JSON/YAML/frontmatter and so always cloneable (a
|
|
1490
|
+
// `!!binary` Buffer is the sole shape not preserved exactly: it clones to a Uint8Array).
|
|
1721
1491
|
return pending.then((resolved) => (resolved === null ? null : structuredClone(resolved)));
|
|
1722
1492
|
}
|
|
1723
1493
|
/**
|
|
1724
|
-
* Returns null if the reference is invalid or missing.
|
|
1725
|
-
* Includes id, slug, and collection fields for debugging.
|
|
1494
|
+
* Returns null if the reference is invalid or missing. Includes id, slug and collection.
|
|
1726
1495
|
*
|
|
1727
|
-
*
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1731
|
-
*
|
|
1732
|
-
*
|
|
1733
|
-
* resolved via Promise.all, every miss shares the same stale snapshot, and
|
|
1734
|
-
* idIndex() dedupes concurrent builds — so a sibling call that wins the
|
|
1735
|
-
* throttle and rebuilds heals every other miss in the same batch, not just
|
|
1736
|
-
* the first.
|
|
1496
|
+
* A suspicious result (ID missing from the index, or an index hit whose file is gone)
|
|
1497
|
+
* triggers one forced index refresh and a retry — self-healing for mutations by other
|
|
1498
|
+
* processes inside the marker's residual windows. The forced refresh is itself throttled
|
|
1499
|
+
* (see refreshIndexForSuspiciousLookup) but the retry against the live index always runs:
|
|
1500
|
+
* every miss in a `list: true` Promise.all shares one stale snapshot, and idIndex() dedupes
|
|
1501
|
+
* concurrent builds, so a sibling that wins the throttle heals the whole batch.
|
|
1737
1502
|
*
|
|
1738
|
-
* A {@link ReferenceResolveCache} sits ABOVE this, never inside it, so every
|
|
1739
|
-
*
|
|
1740
|
-
* is only that repeats of the SAME id in one batch share that one attempt's
|
|
1741
|
-
* outcome instead of each getting an independent throw of the dice.
|
|
1503
|
+
* A {@link ReferenceResolveCache} sits ABOVE this, never inside it, so every distinct id
|
|
1504
|
+
* still runs the full refresh-and-retry; a cache only makes repeats of the SAME id share it.
|
|
1742
1505
|
*/
|
|
1743
1506
|
async resolveSingleReferenceUncached(id, idIndex, includeBody) {
|
|
1744
1507
|
const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody);
|
|
1745
1508
|
if (first !== STALE_LOOKUP)
|
|
1746
1509
|
return first;
|
|
1747
|
-
// Force a rebuild (throttled). Even when this caller loses the throttle,
|
|
1748
|
-
//
|
|
1749
|
-
//
|
|
1750
|
-
// every miss in a Promise.all batch heals, not just the first.
|
|
1510
|
+
// Force a rebuild (throttled). Even when this caller loses the throttle, retry against the
|
|
1511
|
+
// live index: a sibling lookup in the same batch may have won it and rebuilt (idIndex()
|
|
1512
|
+
// dedupes in-flight builds), so every miss in a Promise.all batch heals.
|
|
1751
1513
|
await this.refreshIndexForSuspiciousLookup();
|
|
1752
1514
|
const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody);
|
|
1753
1515
|
return second === STALE_LOOKUP ? null : second;
|
|
@@ -1764,30 +1526,24 @@ export class ContentStore {
|
|
|
1764
1526
|
const doc = await this.read(location.collection, location.slug, {
|
|
1765
1527
|
resolveReferences: false,
|
|
1766
1528
|
});
|
|
1767
|
-
// `urlPath` is what makes a resolved reference linkable
|
|
1768
|
-
// "see also: <Target>" as a real anchor
|
|
1769
|
-
//
|
|
1770
|
-
//
|
|
1771
|
-
// false` on pages where paying for both was worse than hand-rolling it.
|
|
1529
|
+
// `urlPath` is what makes a resolved reference linkable: without it a page rendering
|
|
1530
|
+
// "see also: <Target>" as a real anchor has no href from the resolution it already paid
|
|
1531
|
+
// for, and had to run a SECOND full listing over the tree to build a contentId -> url
|
|
1532
|
+
// table.
|
|
1772
1533
|
//
|
|
1773
1534
|
// Deliberately `computeEntryUrl` (utils/entry-url.ts), the same forward
|
|
1774
1535
|
// collection+slug -> url rule `listEntries` publishes as `item.urlPath` and
|
|
1775
|
-
// `entry-link-resolver.ts`
|
|
1776
|
-
//
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
1779
|
-
// .claude/future-tasks/resolved/url-resolver-index-entry-extra-url.md), but the
|
|
1780
|
-
// direction still matters: this is the surface that DEFINES an entry's URL, and
|
|
1781
|
-
// sourcing it from the resolver that consumes that definition would invert the
|
|
1782
|
-
// dependency and let any future divergence propagate into every resolved reference.
|
|
1536
|
+
// `entry-link-resolver.ts` uses for `entry:ID` links -- NOT the reverse url -> entry
|
|
1537
|
+
// resolver in url-path-resolver.ts. They agree today, but direction matters: this is the
|
|
1538
|
+
// surface that DEFINES an entry's URL, so sourcing it from the resolver that consumes
|
|
1539
|
+
// that definition would invert the dependency.
|
|
1783
1540
|
//
|
|
1784
|
-
// The assembly
|
|
1785
|
-
// `buildResolvedReference`'s job
|
|
1786
|
-
// live
|
|
1787
|
-
// had already drifted. That doc comment carries the reasoning for the ordering.
|
|
1541
|
+
// The assembly -- data, then the embedded body, then the reserved metadata -- is
|
|
1542
|
+
// `buildResolvedReference`'s job, because api/resolve-references.ts builds the same
|
|
1543
|
+
// object for live preview and the two had drifted; its doc comment carries the ordering.
|
|
1788
1544
|
//
|
|
1789
|
-
// `'body' in doc`
|
|
1790
|
-
// `doc.body`/`doc.bodyFieldName` are reachable at all
|
|
1545
|
+
// `'body' in doc` narrows the ContentDocument union to its markdown variant, so
|
|
1546
|
+
// `doc.body`/`doc.bodyFieldName` are reachable at all -- a type guard, not a redundant
|
|
1791
1547
|
// runtime check. Only a field that asked to EMBED its target passes a body at all.
|
|
1792
1548
|
const bodyForEmbed = includeBody && 'body' in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : undefined;
|
|
1793
1549
|
return buildResolvedReference(doc.data, {
|