@orthacms/content-server 0.0.0-reserve.0
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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/define.d.ts +17 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.js +28 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/lib/collection/define.d.ts +35 -0
- package/dist/lib/collection/define.d.ts.map +1 -0
- package/dist/lib/collection/define.js +217 -0
- package/dist/lib/collection/table-builder.d.ts +28 -0
- package/dist/lib/collection/table-builder.d.ts.map +1 -0
- package/dist/lib/collection/table-builder.js +306 -0
- package/dist/lib/content-types/controllers/get-content-schema.controller.d.ts +24 -0
- package/dist/lib/content-types/controllers/get-content-schema.controller.d.ts.map +1 -0
- package/dist/lib/content-types/controllers/get-content-schema.controller.js +58 -0
- package/dist/lib/content-types/controllers/get-filter-fields.controller.d.ts +35 -0
- package/dist/lib/content-types/controllers/get-filter-fields.controller.d.ts.map +1 -0
- package/dist/lib/content-types/controllers/get-filter-fields.controller.js +74 -0
- package/dist/lib/content-types/controllers/list-content-schema.controller.d.ts +15 -0
- package/dist/lib/content-types/controllers/list-content-schema.controller.d.ts.map +1 -0
- package/dist/lib/content-types/controllers/list-content-schema.controller.js +38 -0
- package/dist/lib/content-types/queries/workspace-grants.query.d.ts +25 -0
- package/dist/lib/content-types/queries/workspace-grants.query.d.ts.map +1 -0
- package/dist/lib/content-types/queries/workspace-grants.query.js +45 -0
- package/dist/lib/content.module.d.ts +21 -0
- package/dist/lib/content.module.d.ts.map +1 -0
- package/dist/lib/content.module.js +264 -0
- package/dist/lib/content.tokens.d.ts +5 -0
- package/dist/lib/content.tokens.d.ts.map +1 -0
- package/dist/lib/content.tokens.js +9 -0
- package/dist/lib/copilot/content-tool.provider.d.ts +64 -0
- package/dist/lib/copilot/content-tool.provider.d.ts.map +1 -0
- package/dist/lib/copilot/content-tool.provider.js +309 -0
- package/dist/lib/copilot/diff-snapshots.d.ts +42 -0
- package/dist/lib/copilot/diff-snapshots.d.ts.map +1 -0
- package/dist/lib/copilot/diff-snapshots.js +77 -0
- package/dist/lib/copilot/entry-proposal.applier.d.ts +89 -0
- package/dist/lib/copilot/entry-proposal.applier.d.ts.map +1 -0
- package/dist/lib/copilot/entry-proposal.applier.js +245 -0
- package/dist/lib/copilot/entry-proposal.provider.d.ts +157 -0
- package/dist/lib/copilot/entry-proposal.provider.d.ts.map +1 -0
- package/dist/lib/copilot/entry-proposal.provider.js +619 -0
- package/dist/lib/copilot/filter-schema.d.ts +61 -0
- package/dist/lib/copilot/filter-schema.d.ts.map +1 -0
- package/dist/lib/copilot/filter-schema.js +139 -0
- package/dist/lib/copilot/project-entry.d.ts +23 -0
- package/dist/lib/copilot/project-entry.d.ts.map +1 -0
- package/dist/lib/copilot/project-entry.js +36 -0
- package/dist/lib/copilot/proposal-kinds.d.ts +30 -0
- package/dist/lib/copilot/proposal-kinds.d.ts.map +1 -0
- package/dist/lib/copilot/proposal-kinds.js +32 -0
- package/dist/lib/copilot/revision-tool.provider.d.ts +56 -0
- package/dist/lib/copilot/revision-tool.provider.d.ts.map +1 -0
- package/dist/lib/copilot/revision-tool.provider.js +215 -0
- package/dist/lib/docs/content-schemas.d.ts +34 -0
- package/dist/lib/docs/content-schemas.d.ts.map +1 -0
- package/dist/lib/docs/content-schemas.js +617 -0
- package/dist/lib/docs/describe-content-api.d.ts +22 -0
- package/dist/lib/docs/describe-content-api.d.ts.map +1 -0
- package/dist/lib/docs/describe-content-api.js +173 -0
- package/dist/lib/docs/field-schema.d.ts +28 -0
- package/dist/lib/docs/field-schema.d.ts.map +1 -0
- package/dist/lib/docs/field-schema.js +214 -0
- package/dist/lib/entries/application/use-cases/bulk-publish-entries.use-case.d.ts +23 -0
- package/dist/lib/entries/application/use-cases/bulk-publish-entries.use-case.d.ts.map +1 -0
- package/dist/lib/entries/application/use-cases/bulk-publish-entries.use-case.js +77 -0
- package/dist/lib/entries/application/use-cases/bulk-unpublish-entries.use-case.d.ts +23 -0
- package/dist/lib/entries/application/use-cases/bulk-unpublish-entries.use-case.d.ts.map +1 -0
- package/dist/lib/entries/application/use-cases/bulk-unpublish-entries.use-case.js +66 -0
- package/dist/lib/entries/application/use-cases/publish-entry.use-case.d.ts +43 -0
- package/dist/lib/entries/application/use-cases/publish-entry.use-case.d.ts.map +1 -0
- package/dist/lib/entries/application/use-cases/publish-entry.use-case.js +106 -0
- package/dist/lib/entries/application/use-cases/unpublish-entry.use-case.d.ts +30 -0
- package/dist/lib/entries/application/use-cases/unpublish-entry.use-case.d.ts.map +1 -0
- package/dist/lib/entries/application/use-cases/unpublish-entry.use-case.js +70 -0
- package/dist/lib/entries/controllers/bulk-entries.controller.d.ts +26 -0
- package/dist/lib/entries/controllers/bulk-entries.controller.d.ts.map +1 -0
- package/dist/lib/entries/controllers/create-entry.controller.d.ts +20 -0
- package/dist/lib/entries/controllers/create-entry.controller.d.ts.map +1 -0
- package/dist/lib/entries/controllers/delete-entry.controller.d.ts +24 -0
- package/dist/lib/entries/controllers/delete-entry.controller.d.ts.map +1 -0
- package/dist/lib/entries/controllers/get-entry.controller.d.ts +34 -0
- package/dist/lib/entries/controllers/get-entry.controller.d.ts.map +1 -0
- package/dist/lib/entries/controllers/list-entries.controller.d.ts +20 -0
- package/dist/lib/entries/controllers/list-entries.controller.d.ts.map +1 -0
- package/dist/lib/entries/controllers/publish-entry.controller.d.ts +19 -0
- package/dist/lib/entries/controllers/publish-entry.controller.d.ts.map +1 -0
- package/dist/lib/entries/controllers/resolve-type.d.ts +9 -0
- package/dist/lib/entries/controllers/resolve-type.d.ts.map +1 -0
- package/dist/lib/entries/controllers/update-entry.controller.d.ts +18 -0
- package/dist/lib/entries/controllers/update-entry.controller.d.ts.map +1 -0
- package/dist/lib/entries/domain/entry-publish-blocked.error.d.ts +13 -0
- package/dist/lib/entries/domain/entry-publish-blocked.error.d.ts.map +1 -0
- package/dist/lib/entries/domain/entry-publish-blocked.error.js +19 -0
- package/dist/lib/entries/domain/entry.d.ts +71 -0
- package/dist/lib/entries/domain/entry.d.ts.map +1 -0
- package/dist/lib/entries/domain/entry.js +90 -0
- package/dist/lib/entries/domain/events/entry-events.d.ts +67 -0
- package/dist/lib/entries/domain/events/entry-events.d.ts.map +1 -0
- package/dist/lib/entries/domain/events/entry-events.js +100 -0
- package/dist/lib/entries/dto/bulk-ids.dto.d.ts +11 -0
- package/dist/lib/entries/dto/bulk-ids.dto.d.ts.map +1 -0
- package/dist/lib/entries/dto/list-entries-query.dto.d.ts +46 -0
- package/dist/lib/entries/dto/list-entries-query.dto.d.ts.map +1 -0
- package/dist/lib/entries/dto/relation-delta-map.validator.d.ts +19 -0
- package/dist/lib/entries/dto/relation-delta-map.validator.d.ts.map +1 -0
- package/dist/lib/entries/dto/save-entry.dto.d.ts +46 -0
- package/dist/lib/entries/dto/save-entry.dto.d.ts.map +1 -0
- package/dist/lib/entries/entries.constants.d.ts +19 -0
- package/dist/lib/entries/entries.constants.d.ts.map +1 -0
- package/dist/lib/entries/entries.constants.js +21 -0
- package/dist/lib/entries/http/controllers/bulk-entries.controller.d.ts +33 -0
- package/dist/lib/entries/http/controllers/bulk-entries.controller.d.ts.map +1 -0
- package/dist/lib/entries/http/controllers/bulk-entries.controller.js +143 -0
- package/dist/lib/entries/http/controllers/create-entry.controller.d.ts +21 -0
- package/dist/lib/entries/http/controllers/create-entry.controller.d.ts.map +1 -0
- package/dist/lib/entries/http/controllers/create-entry.controller.js +52 -0
- package/dist/lib/entries/http/controllers/delete-entry.controller.d.ts +25 -0
- package/dist/lib/entries/http/controllers/delete-entry.controller.d.ts.map +1 -0
- package/dist/lib/entries/http/controllers/delete-entry.controller.js +84 -0
- package/dist/lib/entries/http/controllers/get-entry.controller.d.ts +44 -0
- package/dist/lib/entries/http/controllers/get-entry.controller.d.ts.map +1 -0
- package/dist/lib/entries/http/controllers/get-entry.controller.js +124 -0
- package/dist/lib/entries/http/controllers/list-entries.controller.d.ts +20 -0
- package/dist/lib/entries/http/controllers/list-entries.controller.d.ts.map +1 -0
- package/dist/lib/entries/http/controllers/list-entries.controller.js +52 -0
- package/dist/lib/entries/http/controllers/publish-entry.controller.d.ts +22 -0
- package/dist/lib/entries/http/controllers/publish-entry.controller.d.ts.map +1 -0
- package/dist/lib/entries/http/controllers/publish-entry.controller.js +68 -0
- package/dist/lib/entries/http/controllers/resolve-type.d.ts +9 -0
- package/dist/lib/entries/http/controllers/resolve-type.d.ts.map +1 -0
- package/dist/lib/entries/http/controllers/resolve-type.js +16 -0
- package/dist/lib/entries/http/controllers/to-actor.d.ts +13 -0
- package/dist/lib/entries/http/controllers/to-actor.d.ts.map +1 -0
- package/dist/lib/entries/http/controllers/to-actor.js +15 -0
- package/dist/lib/entries/http/controllers/update-entry.controller.d.ts +19 -0
- package/dist/lib/entries/http/controllers/update-entry.controller.d.ts.map +1 -0
- package/dist/lib/entries/http/controllers/update-entry.controller.js +51 -0
- package/dist/lib/entries/http/dto/bulk-ids.dto.d.ts +11 -0
- package/dist/lib/entries/http/dto/bulk-ids.dto.d.ts.map +1 -0
- package/dist/lib/entries/http/dto/bulk-ids.dto.js +33 -0
- package/dist/lib/entries/http/dto/list-entries-query.dto.d.ts +66 -0
- package/dist/lib/entries/http/dto/list-entries-query.dto.d.ts.map +1 -0
- package/dist/lib/entries/http/dto/list-entries-query.dto.js +192 -0
- package/dist/lib/entries/http/dto/relation-delta-map.validator.d.ts +30 -0
- package/dist/lib/entries/http/dto/relation-delta-map.validator.d.ts.map +1 -0
- package/dist/lib/entries/http/dto/relation-delta-map.validator.js +102 -0
- package/dist/lib/entries/http/dto/save-entry.dto.d.ts +48 -0
- package/dist/lib/entries/http/dto/save-entry.dto.d.ts.map +1 -0
- package/dist/lib/entries/http/dto/save-entry.dto.js +120 -0
- package/dist/lib/entries/http/guards/content-grant.guard.d.ts +37 -0
- package/dist/lib/entries/http/guards/content-grant.guard.d.ts.map +1 -0
- package/dist/lib/entries/http/guards/content-grant.guard.js +67 -0
- package/dist/lib/entries/infrastructure/persistence/bulk-publish-verdicts.d.ts +22 -0
- package/dist/lib/entries/infrastructure/persistence/bulk-publish-verdicts.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/persistence/bulk-publish-verdicts.js +81 -0
- package/dist/lib/entries/infrastructure/persistence/entry-counter.service.d.ts +42 -0
- package/dist/lib/entries/infrastructure/persistence/entry-counter.service.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/persistence/entry-counter.service.js +73 -0
- package/dist/lib/entries/infrastructure/persistence/entry-row.d.ts +56 -0
- package/dist/lib/entries/infrastructure/persistence/entry-row.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/persistence/entry-row.js +185 -0
- package/dist/lib/entries/infrastructure/persistence/entry-writer.service.d.ts +392 -0
- package/dist/lib/entries/infrastructure/persistence/entry-writer.service.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/persistence/entry-writer.service.js +1223 -0
- package/dist/lib/entries/infrastructure/persistence/field-selection.d.ts +24 -0
- package/dist/lib/entries/infrastructure/persistence/field-selection.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/persistence/media-accept.d.ts +23 -0
- package/dist/lib/entries/infrastructure/persistence/media-accept.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/persistence/media-accept.js +55 -0
- package/dist/lib/entries/infrastructure/persistence/relation-link.service.d.ts +327 -0
- package/dist/lib/entries/infrastructure/persistence/relation-link.service.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/persistence/relation-link.service.js +1008 -0
- package/dist/lib/entries/infrastructure/queries/bulk-publish-preview.query.d.ts +19 -0
- package/dist/lib/entries/infrastructure/queries/bulk-publish-preview.query.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/queries/bulk-publish-preview.query.js +39 -0
- package/dist/lib/entries/infrastructure/queries/entries.service.d.ts +64 -0
- package/dist/lib/entries/infrastructure/queries/entries.service.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/queries/entries.service.js +202 -0
- package/dist/lib/entries/infrastructure/queries/entry-filter-schema.d.ts +40 -0
- package/dist/lib/entries/infrastructure/queries/entry-filter-schema.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/queries/entry-filter-surface.d.ts +38 -0
- package/dist/lib/entries/infrastructure/queries/entry-filter-surface.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/queries/entry-filter-surface.js +310 -0
- package/dist/lib/entries/infrastructure/queries/entry-scalar-fields.d.ts +25 -0
- package/dist/lib/entries/infrastructure/queries/entry-scalar-fields.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/queries/entry-scalar-fields.js +51 -0
- package/dist/lib/entries/infrastructure/queries/entry-search.d.ts +19 -0
- package/dist/lib/entries/infrastructure/queries/entry-search.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/queries/entry-search.js +52 -0
- package/dist/lib/entries/infrastructure/queries/media-refs.query.d.ts +21 -0
- package/dist/lib/entries/infrastructure/queries/media-refs.query.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/queries/media-refs.query.js +117 -0
- package/dist/lib/entries/services/entries.service.d.ts +59 -0
- package/dist/lib/entries/services/entries.service.d.ts.map +1 -0
- package/dist/lib/entries/services/entry-counter.service.d.ts +31 -0
- package/dist/lib/entries/services/entry-counter.service.d.ts.map +1 -0
- package/dist/lib/entries/services/entry-filter-schema.d.ts +32 -0
- package/dist/lib/entries/services/entry-filter-schema.d.ts.map +1 -0
- package/dist/lib/entries/services/entry-row.d.ts +56 -0
- package/dist/lib/entries/services/entry-row.d.ts.map +1 -0
- package/dist/lib/entries/services/entry-writer.service.d.ts +193 -0
- package/dist/lib/entries/services/entry-writer.service.d.ts.map +1 -0
- package/dist/lib/entries/services/relation-link.service.d.ts +115 -0
- package/dist/lib/entries/services/relation-link.service.d.ts.map +1 -0
- package/dist/lib/entries/types/bulk-publish.d.ts +75 -0
- package/dist/lib/entries/types/bulk-publish.d.ts.map +1 -0
- package/dist/lib/entries/types/bulk-publish.js +22 -0
- package/dist/lib/entries/types/entry-list-view.d.ts +205 -0
- package/dist/lib/entries/types/entry-list-view.d.ts.map +1 -0
- package/dist/lib/entries/types/entry-list-view.js +5 -0
- package/dist/lib/entries/types/filter-surface.d.ts +35 -0
- package/dist/lib/entries/types/filter-surface.d.ts.map +1 -0
- package/dist/lib/entries/types/filter-surface.js +2 -0
- package/dist/lib/extension/entry-extension-boot-check.d.ts +16 -0
- package/dist/lib/extension/entry-extension-boot-check.d.ts.map +1 -0
- package/dist/lib/extension/entry-extension-boot-check.js +42 -0
- package/dist/lib/extension/entry-extension.d.ts +225 -0
- package/dist/lib/extension/entry-extension.d.ts.map +1 -0
- package/dist/lib/extension/entry-extension.js +27 -0
- package/dist/lib/extension/media-asset-resolver.d.ts +85 -0
- package/dist/lib/extension/media-asset-resolver.d.ts.map +1 -0
- package/dist/lib/extension/media-asset-resolver.js +26 -0
- package/dist/lib/extension/per-locale-relation.d.ts +18 -0
- package/dist/lib/extension/per-locale-relation.d.ts.map +1 -0
- package/dist/lib/extension/relation-locale-sync.d.ts +62 -0
- package/dist/lib/extension/relation-locale-sync.d.ts.map +1 -0
- package/dist/lib/extension/relation-locale-sync.js +88 -0
- package/dist/lib/fields/index.d.ts +94 -0
- package/dist/lib/fields/index.d.ts.map +1 -0
- package/dist/lib/fields/index.js +203 -0
- package/dist/lib/insights/http/controllers/content-pipeline.controller.d.ts +16 -0
- package/dist/lib/insights/http/controllers/content-pipeline.controller.d.ts.map +1 -0
- package/dist/lib/insights/http/controllers/content-pipeline.controller.js +39 -0
- package/dist/lib/insights/http/controllers/content-punchcard.controller.d.ts +18 -0
- package/dist/lib/insights/http/controllers/content-punchcard.controller.d.ts.map +1 -0
- package/dist/lib/insights/http/controllers/content-punchcard.controller.js +42 -0
- package/dist/lib/insights/http/controllers/content-stale.controller.d.ts +17 -0
- package/dist/lib/insights/http/controllers/content-stale.controller.d.ts.map +1 -0
- package/dist/lib/insights/http/controllers/content-stale.controller.js +40 -0
- package/dist/lib/insights/http/controllers/content-totals.controller.d.ts +18 -0
- package/dist/lib/insights/http/controllers/content-totals.controller.d.ts.map +1 -0
- package/dist/lib/insights/http/controllers/content-totals.controller.js +42 -0
- package/dist/lib/insights/http/controllers/content-unshipped.controller.d.ts +17 -0
- package/dist/lib/insights/http/controllers/content-unshipped.controller.d.ts.map +1 -0
- package/dist/lib/insights/http/controllers/content-unshipped.controller.js +40 -0
- package/dist/lib/insights/http/controllers/content-velocity.controller.d.ts +17 -0
- package/dist/lib/insights/http/controllers/content-velocity.controller.d.ts.map +1 -0
- package/dist/lib/insights/http/controllers/content-velocity.controller.js +41 -0
- package/dist/lib/insights/http/dto/insights-range-query.dto.d.ts +15 -0
- package/dist/lib/insights/http/dto/insights-range-query.dto.d.ts.map +1 -0
- package/dist/lib/insights/http/dto/insights-range-query.dto.js +38 -0
- package/dist/lib/insights/infrastructure/queries/content-insights.query.d.ts +77 -0
- package/dist/lib/insights/infrastructure/queries/content-insights.query.d.ts.map +1 -0
- package/dist/lib/insights/infrastructure/queries/content-insights.query.js +380 -0
- package/dist/lib/insights/types/content-insights-view.d.ts +134 -0
- package/dist/lib/insights/types/content-insights-view.d.ts.map +1 -0
- package/dist/lib/insights/types/content-insights-view.js +10 -0
- package/dist/lib/mcp/content-tools.provider.d.ts +83 -0
- package/dist/lib/mcp/content-tools.provider.d.ts.map +1 -0
- package/dist/lib/mcp/content-tools.provider.js +441 -0
- package/dist/lib/mcp/tool-input.d.ts +43 -0
- package/dist/lib/mcp/tool-input.d.ts.map +1 -0
- package/dist/lib/mcp/tool-input.js +117 -0
- package/dist/lib/mcp/tool-schemas.d.ts +34 -0
- package/dist/lib/mcp/tool-schemas.d.ts.map +1 -0
- package/dist/lib/mcp/tool-schemas.js +282 -0
- package/dist/lib/public/controllers/get-public-entry.controller.d.ts +26 -0
- package/dist/lib/public/controllers/get-public-entry.controller.d.ts.map +1 -0
- package/dist/lib/public/controllers/list-public-entries.controller.d.ts +24 -0
- package/dist/lib/public/controllers/list-public-entries.controller.d.ts.map +1 -0
- package/dist/lib/public/controllers/public-schema.controller.d.ts +18 -0
- package/dist/lib/public/controllers/public-schema.controller.d.ts.map +1 -0
- package/dist/lib/public-api/http/api-token-request.d.ts +41 -0
- package/dist/lib/public-api/http/api-token-request.d.ts.map +1 -0
- package/dist/lib/public-api/http/api-token-request.js +2 -0
- package/dist/lib/public-api/http/controllers/public-content-types.controller.d.ts +31 -0
- package/dist/lib/public-api/http/controllers/public-content-types.controller.d.ts.map +1 -0
- package/dist/lib/public-api/http/controllers/public-content-types.controller.js +93 -0
- package/dist/lib/public-api/http/controllers/public-entries.controller.d.ts +101 -0
- package/dist/lib/public-api/http/controllers/public-entries.controller.d.ts.map +1 -0
- package/dist/lib/public-api/http/controllers/public-entries.controller.js +307 -0
- package/dist/lib/public-api/http/controllers/public-entry-writes.controller.d.ts +76 -0
- package/dist/lib/public-api/http/controllers/public-entry-writes.controller.d.ts.map +1 -0
- package/dist/lib/public-api/http/controllers/public-entry-writes.controller.js +353 -0
- package/dist/lib/public-api/http/controllers/resolve-granted-type.d.ts +42 -0
- package/dist/lib/public-api/http/controllers/resolve-granted-type.d.ts.map +1 -0
- package/dist/lib/public-api/http/controllers/resolve-granted-type.js +25 -0
- package/dist/lib/public-api/http/decorators/current-api-token.decorator.d.ts +8 -0
- package/dist/lib/public-api/http/decorators/current-api-token.decorator.d.ts.map +1 -0
- package/dist/lib/public-api/http/decorators/current-api-token.decorator.js +17 -0
- package/dist/lib/public-api/http/dto/public-bulk.dto.d.ts +58 -0
- package/dist/lib/public-api/http/dto/public-bulk.dto.d.ts.map +1 -0
- package/dist/lib/public-api/http/dto/public-bulk.dto.js +118 -0
- package/dist/lib/public-api/http/dto/public-list-entries-query.dto.d.ts +106 -0
- package/dist/lib/public-api/http/dto/public-list-entries-query.dto.d.ts.map +1 -0
- package/dist/lib/public-api/http/dto/public-list-entries-query.dto.js +297 -0
- package/dist/lib/public-api/http/dto/public-save-entry.dto.d.ts +39 -0
- package/dist/lib/public-api/http/dto/public-save-entry.dto.d.ts.map +1 -0
- package/dist/lib/public-api/http/dto/public-save-entry.dto.js +112 -0
- package/dist/lib/public-api/http/guards/api-token-workspace.guard.d.ts +25 -0
- package/dist/lib/public-api/http/guards/api-token-workspace.guard.d.ts.map +1 -0
- package/dist/lib/public-api/http/guards/api-token-workspace.guard.js +58 -0
- package/dist/lib/public-api/http/guards/api-token.guard.d.ts +32 -0
- package/dist/lib/public-api/http/guards/api-token.guard.d.ts.map +1 -0
- package/dist/lib/public-api/http/guards/api-token.guard.js +92 -0
- package/dist/lib/public-api/http/guards/draft-visibility.guard.d.ts +25 -0
- package/dist/lib/public-api/http/guards/draft-visibility.guard.d.ts.map +1 -0
- package/dist/lib/public-api/http/guards/draft-visibility.guard.js +56 -0
- package/dist/lib/public-api/infrastructure/bulk-save-op.d.ts +55 -0
- package/dist/lib/public-api/infrastructure/bulk-save-op.d.ts.map +1 -0
- package/dist/lib/public-api/infrastructure/bulk-save-op.js +61 -0
- package/dist/lib/public-api/infrastructure/field-selection.d.ts +21 -0
- package/dist/lib/public-api/infrastructure/field-selection.d.ts.map +1 -0
- package/dist/lib/public-api/infrastructure/field-selection.js +72 -0
- package/dist/lib/public-api/infrastructure/public-entries.query.d.ts +248 -0
- package/dist/lib/public-api/infrastructure/public-entries.query.d.ts.map +1 -0
- package/dist/lib/public-api/infrastructure/public-entries.query.js +557 -0
- package/dist/lib/public-api/infrastructure/public-entry-row.d.ts +31 -0
- package/dist/lib/public-api/infrastructure/public-entry-row.d.ts.map +1 -0
- package/dist/lib/public-api/infrastructure/public-entry-row.js +76 -0
- package/dist/lib/public-api/infrastructure/public-entry-writes.service.d.ts +188 -0
- package/dist/lib/public-api/infrastructure/public-entry-writes.service.d.ts.map +1 -0
- package/dist/lib/public-api/infrastructure/public-entry-writes.service.js +321 -0
- package/dist/lib/public-api/infrastructure/public-expansion.query.d.ts +98 -0
- package/dist/lib/public-api/infrastructure/public-expansion.query.d.ts.map +1 -0
- package/dist/lib/public-api/infrastructure/public-expansion.query.js +384 -0
- package/dist/lib/public-api/types/public-bulk.d.ts +77 -0
- package/dist/lib/public-api/types/public-bulk.d.ts.map +1 -0
- package/dist/lib/public-api/types/public-bulk.js +23 -0
- package/dist/lib/public-api/types/public-entry.d.ts +97 -0
- package/dist/lib/public-api/types/public-entry.d.ts.map +1 -0
- package/dist/lib/public-api/types/public-entry.js +8 -0
- package/dist/lib/public-api/types/public-expansion.d.ts +92 -0
- package/dist/lib/public-api/types/public-expansion.d.ts.map +1 -0
- package/dist/lib/public-api/types/public-expansion.js +2 -0
- package/dist/lib/registry/content-type-registry.d.ts +103 -0
- package/dist/lib/registry/content-type-registry.d.ts.map +1 -0
- package/dist/lib/registry/content-type-registry.js +155 -0
- package/dist/lib/revisions/application/ports/revision-store.d.ts +71 -0
- package/dist/lib/revisions/application/ports/revision-store.d.ts.map +1 -0
- package/dist/lib/revisions/application/ports/revision-store.js +9 -0
- package/dist/lib/revisions/application/use-cases/publish-revision.use-case.d.ts +40 -0
- package/dist/lib/revisions/application/use-cases/publish-revision.use-case.d.ts.map +1 -0
- package/dist/lib/revisions/application/use-cases/publish-revision.use-case.js +66 -0
- package/dist/lib/revisions/application/use-cases/restore-revision.use-case.d.ts +36 -0
- package/dist/lib/revisions/application/use-cases/restore-revision.use-case.d.ts.map +1 -0
- package/dist/lib/revisions/application/use-cases/restore-revision.use-case.js +57 -0
- package/dist/lib/revisions/domain/revision-status.d.ts +18 -0
- package/dist/lib/revisions/domain/revision-status.d.ts.map +1 -0
- package/dist/lib/revisions/domain/revision-status.js +18 -0
- package/dist/lib/revisions/domain/revision.d.ts +80 -0
- package/dist/lib/revisions/domain/revision.d.ts.map +1 -0
- package/dist/lib/revisions/domain/revision.js +84 -0
- package/dist/lib/revisions/http/controllers/publish-revision.controller.d.ts +19 -0
- package/dist/lib/revisions/http/controllers/publish-revision.controller.d.ts.map +1 -0
- package/dist/lib/revisions/http/controllers/publish-revision.controller.js +51 -0
- package/dist/lib/revisions/http/controllers/restore-revision.controller.d.ts +17 -0
- package/dist/lib/revisions/http/controllers/restore-revision.controller.d.ts.map +1 -0
- package/dist/lib/revisions/http/controllers/restore-revision.controller.js +49 -0
- package/dist/lib/revisions/http/controllers/revisions.controller.d.ts +20 -0
- package/dist/lib/revisions/http/controllers/revisions.controller.d.ts.map +1 -0
- package/dist/lib/revisions/http/controllers/revisions.controller.js +76 -0
- package/dist/lib/revisions/infrastructure/persistence/drizzle-revision.store.d.ts +33 -0
- package/dist/lib/revisions/infrastructure/persistence/drizzle-revision.store.d.ts.map +1 -0
- package/dist/lib/revisions/infrastructure/persistence/drizzle-revision.store.js +174 -0
- package/dist/lib/revisions/infrastructure/persistence/revision-snapshot.d.ts +17 -0
- package/dist/lib/revisions/infrastructure/persistence/revision-snapshot.d.ts.map +1 -0
- package/dist/lib/revisions/infrastructure/persistence/revision-snapshot.js +20 -0
- package/dist/lib/revisions/infrastructure/persistence/revision-table.d.ts +228 -0
- package/dist/lib/revisions/infrastructure/persistence/revision-table.d.ts.map +1 -0
- package/dist/lib/revisions/infrastructure/persistence/revision-table.js +57 -0
- package/dist/lib/revisions/infrastructure/queries/revision-refs.query.d.ts +22 -0
- package/dist/lib/revisions/infrastructure/queries/revision-refs.query.d.ts.map +1 -0
- package/dist/lib/revisions/infrastructure/queries/revision-refs.query.js +66 -0
- package/dist/lib/revisions/revisions.constants.d.ts +10 -0
- package/dist/lib/revisions/revisions.constants.d.ts.map +1 -0
- package/dist/lib/revisions/revisions.constants.js +12 -0
- package/dist/lib/revisions/types/revision-view.d.ts +74 -0
- package/dist/lib/revisions/types/revision-view.d.ts.map +1 -0
- package/dist/lib/revisions/types/revision-view.js +2 -0
- package/dist/lib/types/content-type.d.ts +131 -0
- package/dist/lib/types/content-type.d.ts.map +1 -0
- package/dist/lib/types/content-type.js +28 -0
- package/dist/lib/types/fields.d.ts +303 -0
- package/dist/lib/types/fields.d.ts.map +1 -0
- package/dist/lib/types/fields.js +52 -0
- package/dist/lib/utils/content-plugin.d.ts +35 -0
- package/dist/lib/utils/content-plugin.d.ts.map +1 -0
- package/dist/lib/utils/content-plugin.js +35 -0
- package/dist/lib/validation/services/entry-validation.service.d.ts +24 -0
- package/dist/lib/validation/services/entry-validation.service.d.ts.map +1 -0
- package/dist/lib/validation/services/entry-validation.service.js +35 -0
- package/package.json +54 -0
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EntryWriterService = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const common_1 = require("@nestjs/common");
|
|
6
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
7
|
+
const database_1 = require("@orthacms/database");
|
|
8
|
+
const workspaces_server_1 = require("@orthacms/workspaces-server");
|
|
9
|
+
const utils_server_1 = require("@orthacms/utils-server");
|
|
10
|
+
const content_domain_1 = require("@orthacms/content-domain");
|
|
11
|
+
const entry_extension_1 = require("../../../extension/entry-extension");
|
|
12
|
+
const media_asset_resolver_1 = require("../../../extension/media-asset-resolver");
|
|
13
|
+
const media_accept_1 = require("./media-accept");
|
|
14
|
+
const content_type_1 = require("../../../types/content-type");
|
|
15
|
+
const fields_1 = require("../../../types/fields");
|
|
16
|
+
const entry_validation_service_1 = require("../../../validation/services/entry-validation.service");
|
|
17
|
+
const table_builder_1 = require("../../../collection/table-builder");
|
|
18
|
+
const entry_row_1 = require("./entry-row");
|
|
19
|
+
const relation_link_service_1 = require("./relation-link.service");
|
|
20
|
+
const revision_store_1 = require("../../../revisions/application/ports/revision-store");
|
|
21
|
+
const entry_events_1 = require("../../domain/events/entry-events");
|
|
22
|
+
const revision_1 = require("../../../revisions/domain/revision");
|
|
23
|
+
const revision_snapshot_1 = require("../../../revisions/infrastructure/persistence/revision-snapshot");
|
|
24
|
+
/**
|
|
25
|
+
* RFC-4122 uuid, matched case-insensitively.
|
|
26
|
+
*
|
|
27
|
+
* A relation id that isn't one must be rejected **before** it reaches the
|
|
28
|
+
* existence probe: `inArray(<uuid column>, ['not-a-uuid'])` is a Postgres cast
|
|
29
|
+
* error, i.e. a 500 on ordinary bad input. The relation-*delta* path has been
|
|
30
|
+
* shape-checked at the DTO since it was written; a single relation's FK arrives
|
|
31
|
+
* inside the free-form `values` bag, which no decorator can reach, so the guard
|
|
32
|
+
* has to live here.
|
|
33
|
+
*/
|
|
34
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
35
|
+
/**
|
|
36
|
+
* The infrastructure persistence engine for entry writes — create / read-one /
|
|
37
|
+
* update / delete / restore / purge, plus their bulk variants, plus the small
|
|
38
|
+
* status read/write primitives the **publish-lifecycle use-cases** compose
|
|
39
|
+
* (`findLive`, `markPublished`/`markDraft`, the bulk selectors, and
|
|
40
|
+
* `requiredRelationIssues`). Generic over the content type (like
|
|
41
|
+
* {@link EntriesService}): the physical table and its envelope columns are
|
|
42
|
+
* derived from `type` at request time, so one engine backs every collection.
|
|
43
|
+
*
|
|
44
|
+
* Layering note (ADR-0003): the entries engine is generic and registry-driven —
|
|
45
|
+
* one service backs every content type, with no per-aggregate table — so the
|
|
46
|
+
* heavy, battle-tested column/relation/extension persistence stays here as
|
|
47
|
+
* infrastructure rather than being forced into a row⇄aggregate mapper. The
|
|
48
|
+
* publish **lifecycle** (the part with real invariants) is modelled by the
|
|
49
|
+
* `Entry` domain object and driven by application use-cases, which call the
|
|
50
|
+
* status primitives below through the {@link UnitOfWork} so the write and its
|
|
51
|
+
* outbox events commit atomically. {@link EntryValidationService} remains the
|
|
52
|
+
* gate — nothing is written or published without passing it.
|
|
53
|
+
*/
|
|
54
|
+
let EntryWriterService = class EntryWriterService {
|
|
55
|
+
db;
|
|
56
|
+
uow;
|
|
57
|
+
outbox;
|
|
58
|
+
validation;
|
|
59
|
+
relations;
|
|
60
|
+
revisionStore;
|
|
61
|
+
extension;
|
|
62
|
+
mediaResolver;
|
|
63
|
+
constructor(db,
|
|
64
|
+
// The transaction boundary every write now runs inside, and the outbox
|
|
65
|
+
// that joins it. Together they are what makes an entry's domain event
|
|
66
|
+
// commit with — and never without — the row it describes.
|
|
67
|
+
uow, outbox, validation, relations, revisionStore, extension, mediaResolver) {
|
|
68
|
+
this.db = db;
|
|
69
|
+
this.uow = uow;
|
|
70
|
+
this.outbox = outbox;
|
|
71
|
+
this.validation = validation;
|
|
72
|
+
this.relations = relations;
|
|
73
|
+
this.revisionStore = revisionStore;
|
|
74
|
+
this.extension = extension;
|
|
75
|
+
this.mediaResolver = mediaResolver;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The active unit-of-work transaction, typed as the handle drizzle hands a
|
|
79
|
+
* `db.transaction` callback.
|
|
80
|
+
*
|
|
81
|
+
* `UnitOfWork` stores exactly that object and publishes it as `Database`,
|
|
82
|
+
* because most callers only need the query surface — while this file's
|
|
83
|
+
* helpers, `RelationLinkService`, and the `CONTENT_ENTRY_EXTENSION` port are
|
|
84
|
+
* all typed against the transaction handle. Only ever called from inside
|
|
85
|
+
* `uow.run(...)`, where the two are the same object.
|
|
86
|
+
*/
|
|
87
|
+
tx() {
|
|
88
|
+
return this.uow.current();
|
|
89
|
+
}
|
|
90
|
+
/** One event per affected row, built from each row's own id. */
|
|
91
|
+
eventsFor(rows, build) {
|
|
92
|
+
return rows.map((row) => build(row['id']));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Append `events` to the transactional outbox, stamped with the acting
|
|
96
|
+
* user, from inside the active unit of work.
|
|
97
|
+
*
|
|
98
|
+
* `actor` is `null` for a write made with an API token: revisions record a
|
|
99
|
+
* *user* id and a token is not one, and an audit row naming a person who
|
|
100
|
+
* did not do it is worse than one that says "System". Attributing a token's
|
|
101
|
+
* writes is a real gap that wants its own column, not a misused one.
|
|
102
|
+
*/
|
|
103
|
+
async emit(events, actor) {
|
|
104
|
+
if (events.length === 0)
|
|
105
|
+
return;
|
|
106
|
+
const stamped = [...events];
|
|
107
|
+
await this.outbox.append(actor ? (0, database_1.attachActor)(stamped, actor) : stamped);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The declared fields whose stored value differs between two rows — what
|
|
111
|
+
* `entry.updated` reports, and the test for whether it is raised at all.
|
|
112
|
+
*
|
|
113
|
+
* Compared by JSON shape rather than identity so a jsonb field (`json`,
|
|
114
|
+
* `multiselect`, a media array) is judged on content, and only over
|
|
115
|
+
* `type.fields`: the envelope columns a save always rewrites
|
|
116
|
+
* (`updated_at`, and `status` on a publishable type) are not editorial
|
|
117
|
+
* changes and would make every save look like one.
|
|
118
|
+
*/
|
|
119
|
+
changedFields(type, before, after) {
|
|
120
|
+
if (!before)
|
|
121
|
+
return Object.keys(type.fields);
|
|
122
|
+
return Object.keys(type.fields).filter((name) => JSON.stringify(before[name] ?? null) !==
|
|
123
|
+
JSON.stringify(after[name] ?? null));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Fold any `relations: { <singleField>: { set } }` entries into the values
|
|
127
|
+
* bag, resolving `by: "localeGroup"` on the way, and return the deltas with
|
|
128
|
+
* those fields removed.
|
|
129
|
+
*
|
|
130
|
+
* Done **before** the write rather than alongside the link deltas, because a
|
|
131
|
+
* single relation is a column on the row itself: routing it through `values`
|
|
132
|
+
* means the row is written once, and the resolved id then passes through
|
|
133
|
+
* `assertRelationTargets` — existence, workspace, and the same-locale rule —
|
|
134
|
+
* with no second implementation of any of them.
|
|
135
|
+
*
|
|
136
|
+
* The field must not appear in both bags. One of the two would have to win
|
|
137
|
+
* silently, and a caller who set it twice does not know which they meant.
|
|
138
|
+
* Reads the **raw** submitted values for that test — `coerceValues` stamps
|
|
139
|
+
* every declared field, so after coercion every field is "present".
|
|
140
|
+
*/
|
|
141
|
+
async foldSingleRelationSets(type, values, relations, workspaceId, sourceLocale) {
|
|
142
|
+
if (!relations)
|
|
143
|
+
return { values, relations };
|
|
144
|
+
const singles = Object.entries(relations).filter(([field]) => {
|
|
145
|
+
const rel = type.fields[field]?.relation;
|
|
146
|
+
return !!rel && !rel.many && !rel.inverse;
|
|
147
|
+
});
|
|
148
|
+
if (!singles.length)
|
|
149
|
+
return { values, relations };
|
|
150
|
+
const merged = { ...values };
|
|
151
|
+
const rest = { ...relations };
|
|
152
|
+
for (const [field, delta] of singles) {
|
|
153
|
+
if (delta.set === undefined) {
|
|
154
|
+
// Left to the caller's own error below: the arrays are
|
|
155
|
+
// meaningless on a single relation, and saying so beats a
|
|
156
|
+
// silent no-op.
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (Object.hasOwn(values, field)) {
|
|
160
|
+
throw new common_1.BadRequestException(`Relation "${type.name}.${field}" was set in both \`values\` and \`relations\` — ` +
|
|
161
|
+
'send it in one of them.');
|
|
162
|
+
}
|
|
163
|
+
const target = type.fields[field].relation?.to();
|
|
164
|
+
if (delta.set === null || !target) {
|
|
165
|
+
merged[field] = null;
|
|
166
|
+
}
|
|
167
|
+
else if (delta.by === 'localeGroup') {
|
|
168
|
+
const byGroup = await this.relations.resolveLocaleGroups(this.db, target, [delta.set], workspaceId, field, sourceLocale);
|
|
169
|
+
merged[field] = byGroup.get(delta.set) ?? null;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
merged[field] = delta.set;
|
|
173
|
+
}
|
|
174
|
+
delete rest[field];
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
values: merged,
|
|
178
|
+
relations: Object.keys(rest).length ? rest : undefined
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The locale of one live row, or `undefined` on a type that has none.
|
|
183
|
+
*
|
|
184
|
+
* A relation link may not cross locales, and an update's target checks run
|
|
185
|
+
* before the transaction opens — so the row's locale has to be read up
|
|
186
|
+
* front. Costs one indexed lookup, and only on localized types: a non-i18n
|
|
187
|
+
* type short-circuits without touching the database.
|
|
188
|
+
*/
|
|
189
|
+
async localeOf(type, id, workspaceId) {
|
|
190
|
+
if (!type.i18n)
|
|
191
|
+
return {};
|
|
192
|
+
// `PgColumn`, not the looser `AnyColumn` the predicate helpers use —
|
|
193
|
+
// drizzle's select builder accepts only the former.
|
|
194
|
+
const cols = type.table;
|
|
195
|
+
const [row] = (await this.db
|
|
196
|
+
.select({
|
|
197
|
+
locale: cols['locale'],
|
|
198
|
+
// Read alongside the locale rather than in a second probe: the
|
|
199
|
+
// one-to-one check needs the row's *record* identity, since the
|
|
200
|
+
// locale siblings of one record legitimately share a target.
|
|
201
|
+
localeGroupId: cols['localeGroupId']
|
|
202
|
+
})
|
|
203
|
+
.from(type.table)
|
|
204
|
+
.where(this.liveWhere(type, id, workspaceId))
|
|
205
|
+
.limit(1));
|
|
206
|
+
return { locale: row?.locale, localeGroupId: row?.localeGroupId };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Append an immutable **draft** revision for a just-saved row, on the save's
|
|
210
|
+
* own transaction so the snapshot commits atomically with it. Captures the
|
|
211
|
+
* whole document — the field `values` bag plus the full ordered link sets of
|
|
212
|
+
* every join-backed relation (read back inside `tx`, so it reflects the
|
|
213
|
+
* committed state). The entry's append lock serializes concurrent savers so
|
|
214
|
+
* each allocates a distinct version number.
|
|
215
|
+
*/
|
|
216
|
+
async appendRevision(tx, type, row, workspaceId, actorId) {
|
|
217
|
+
const id = row['id'];
|
|
218
|
+
const links = await this.relations.snapshotLinks(tx, type, row);
|
|
219
|
+
await this.revisionStore.lockEntry(tx, id);
|
|
220
|
+
const number = await this.revisionStore.nextNumber(tx, id);
|
|
221
|
+
const revision = revision_1.Revision.createDraft({
|
|
222
|
+
contentType: type.name,
|
|
223
|
+
entryId: id,
|
|
224
|
+
workspaceId,
|
|
225
|
+
localeGroupId: row['localeGroupId'] ?? null,
|
|
226
|
+
locale: row['locale'] ?? null,
|
|
227
|
+
number,
|
|
228
|
+
snapshot: (0, revision_snapshot_1.buildSnapshot)(type, row, links),
|
|
229
|
+
createdBy: actorId
|
|
230
|
+
});
|
|
231
|
+
await this.revisionStore.append(tx, revision);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Append a revision for each row an extension changed as a side-effect of
|
|
235
|
+
* this save — the locale siblings a shared (non-localized) field is synced
|
|
236
|
+
* to, today.
|
|
237
|
+
*
|
|
238
|
+
* Without this their stored values move while their timeline doesn't, so
|
|
239
|
+
* the history claims a change that never happened to them and hides one
|
|
240
|
+
* that did. Rows are sorted by id before appending: each takes a per-entry
|
|
241
|
+
* advisory lock, and a stable order across concurrent savers is what keeps
|
|
242
|
+
* two of them from deadlocking on the pair.
|
|
243
|
+
*/
|
|
244
|
+
async appendRevisionsFor(tx, type, rows, workspaceId, actorId) {
|
|
245
|
+
if (!rows?.length)
|
|
246
|
+
return;
|
|
247
|
+
const ordered = [...rows].sort((a, b) => String(a['id']).localeCompare(String(b['id'])));
|
|
248
|
+
for (const row of ordered) {
|
|
249
|
+
await this.appendRevision(tx, type, row, workspaceId, actorId);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Create an entry **owned by `workspaceId`** (stamped onto the row so the
|
|
254
|
+
* reader's workspace filter and every later scoped write see it). A
|
|
255
|
+
* publishable type starts as a **draft** and may be incomplete — validation
|
|
256
|
+
* is deferred to publish. A non-publishable type is always live, so its
|
|
257
|
+
* values must validate now.
|
|
258
|
+
*
|
|
259
|
+
* The insert runs in a transaction that first takes the workspace's *shared*
|
|
260
|
+
* content lock ({@link lockWorkspaceShared}): it coordinates with the
|
|
261
|
+
* workspace delete / content-revoke guards (which take the lock exclusively),
|
|
262
|
+
* so a new entry can't land between their emptiness check and their mutation
|
|
263
|
+
* and be orphaned. Shared mode keeps concurrent creates non-blocking.
|
|
264
|
+
*
|
|
265
|
+
* `locale` / `localeGroupId` are opaque, extension-owned params: the bound
|
|
266
|
+
* extension validates them and stamps the envelope columns (a
|
|
267
|
+
* `localeGroupId` makes the row a **sibling** in that translation group). A
|
|
268
|
+
* duplicate `(group, locale)` surfaces as a **409** via {@link uniqueGuarded}.
|
|
269
|
+
*/
|
|
270
|
+
async create(type, values, workspaceId, relations, locale, localeGroupId, actor) {
|
|
271
|
+
const actorId = actor?.id ?? null;
|
|
272
|
+
// `coerceValues` stamps every declared field onto the bag, so the
|
|
273
|
+
// "sent in both places" check below has to read the RAW body — after
|
|
274
|
+
// coercion every field looks present.
|
|
275
|
+
let coerced = (0, entry_row_1.coerceValues)(type, values);
|
|
276
|
+
// Extension-stamped envelope columns (e.g. the validated locale + group
|
|
277
|
+
// id). Resolved before the transaction so an invalid param — unknown
|
|
278
|
+
// locale, or a group id that names no group in the workspace — fails
|
|
279
|
+
// fast. May read the DB (the group check), hence awaited.
|
|
280
|
+
//
|
|
281
|
+
// Resolved **before** the relation checks because the locale it stamps is
|
|
282
|
+
// what those compare against: a link may not cross locales, and the
|
|
283
|
+
// locale this row will be written in is not known until the extension
|
|
284
|
+
// has defaulted and validated it.
|
|
285
|
+
const extensionColumns = (await this.extension?.createColumns(type, workspaceId, {
|
|
286
|
+
locale,
|
|
287
|
+
localeGroupId
|
|
288
|
+
})) ?? {};
|
|
289
|
+
const rowLocale = extensionColumns['locale'];
|
|
290
|
+
// A `{ set }` on an owning single relation becomes a plain value here, so
|
|
291
|
+
// everything below — existence, workspace, the same-locale rule — sees
|
|
292
|
+
// one shape and needs no second implementation.
|
|
293
|
+
const folded = await this.foldSingleRelationSets(type, values, relations, workspaceId, rowLocale);
|
|
294
|
+
coerced = (0, entry_row_1.coerceValues)(type, folded.values);
|
|
295
|
+
relations = folded.relations;
|
|
296
|
+
await this.assertRelationTargets(type, coerced, workspaceId, rowLocale);
|
|
297
|
+
await this.assertUniqueRelations(type, coerced, workspaceId, rowLocale, {
|
|
298
|
+
id: '',
|
|
299
|
+
// The group the new row joins, when it joins one. Absent means a
|
|
300
|
+
// fresh group, so every existing claimant is a different record.
|
|
301
|
+
localeGroupId: extensionColumns['localeGroupId']
|
|
302
|
+
});
|
|
303
|
+
await this.assertMediaTargets(type, coerced, workspaceId);
|
|
304
|
+
if (!type.publishable)
|
|
305
|
+
this.assertValid(type, coerced);
|
|
306
|
+
// One transaction: take the workspace's shared content lock (coordinates
|
|
307
|
+
// with the delete / content-revoke guards so a new entry can't be
|
|
308
|
+
// orphaned), then write the row, its whole-set join-table links (a
|
|
309
|
+
// many-relation submitted in `values`), and the staged relation deltas —
|
|
310
|
+
// all together, so a failed link write never leaves a half-linked entry.
|
|
311
|
+
// Wrapped in uniqueGuarded so a duplicate (group, locale) on an i18n
|
|
312
|
+
// type is a clean 409 rather than a 500.
|
|
313
|
+
const row = await this.uniqueGuarded(() => this.uow.run(async () => {
|
|
314
|
+
const tx = this.tx();
|
|
315
|
+
await (0, workspaces_server_1.lockWorkspaceShared)(tx, workspaceId);
|
|
316
|
+
// Same seam as the update path: the extension locks the
|
|
317
|
+
// set it will rewrite in `afterUpdate` before any row of
|
|
318
|
+
// that set is touched, so a create joining a group orders
|
|
319
|
+
// itself against concurrent saves on the group's members.
|
|
320
|
+
await this.extension?.beforeWrite?.(tx, type, {
|
|
321
|
+
localeGroupId: extensionColumns['localeGroupId']
|
|
322
|
+
}, workspaceId);
|
|
323
|
+
const [inserted] = await tx
|
|
324
|
+
.insert(type.table)
|
|
325
|
+
.values({
|
|
326
|
+
...(0, entry_row_1.toColumns)(type, coerced),
|
|
327
|
+
workspaceId,
|
|
328
|
+
...extensionColumns
|
|
329
|
+
})
|
|
330
|
+
.returning();
|
|
331
|
+
const id = inserted['id'];
|
|
332
|
+
await this.relations.writeLinks(tx, type, id, coerced, workspaceId, rowLocale);
|
|
333
|
+
await this.applyRelationDeltas(tx, type, id, relations, workspaceId, rowLocale);
|
|
334
|
+
// A non-publishable type is always live, so — like its scalar
|
|
335
|
+
// values — a required link-managed relation must be satisfied now.
|
|
336
|
+
// Checked after the links are written (inside the txn, so it sees
|
|
337
|
+
// them), so a 422 rolls the whole create back.
|
|
338
|
+
if (!type.publishable)
|
|
339
|
+
await this.assertRequiredRelations(tx, type, inserted, workspaceId);
|
|
340
|
+
// Same side-effects as an update (e.g. syncing shared fields
|
|
341
|
+
// to locale siblings): a sibling created into an existing
|
|
342
|
+
// group must land consistent with the group's shared values.
|
|
343
|
+
// A no-op for a fresh, sibling-less group.
|
|
344
|
+
const touched = await this.extension?.afterUpdate(tx, type, inserted, coerced, workspaceId, { created: true });
|
|
345
|
+
// Snapshot the just-created document as its first revision,
|
|
346
|
+
// inside this same transaction.
|
|
347
|
+
await this.appendRevision(tx, type, inserted, workspaceId, actorId ?? null);
|
|
348
|
+
// …and one for every sibling the extension rewrote, so a
|
|
349
|
+
// shared value landing on them is in their history too.
|
|
350
|
+
await this.appendRevisionsFor(tx, type, touched, workspaceId, actorId ?? null);
|
|
351
|
+
// Inside the transaction, so the fact and the row it
|
|
352
|
+
// describes commit together or not at all — the whole point
|
|
353
|
+
// of the outbox.
|
|
354
|
+
await this.emit([(0, entry_events_1.entryCreated)(id, type.name)], actor);
|
|
355
|
+
return inserted;
|
|
356
|
+
}), type);
|
|
357
|
+
return (0, entry_row_1.toRecord)(type, row);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* First page (+ total) of every relation field's links for one live entry,
|
|
361
|
+
* keyed by field name — what the editor loads on open. Each field is
|
|
362
|
+
* paginated, so a relation with many links contributes only its first page.
|
|
363
|
+
* 404 if the entry is missing (or soft-deleted) in this workspace.
|
|
364
|
+
*/
|
|
365
|
+
async getRelations(type, id, workspaceId) {
|
|
366
|
+
const row = await this.findLive(type, id, workspaceId);
|
|
367
|
+
if (!row)
|
|
368
|
+
throw this.notFound(type, id);
|
|
369
|
+
return this.relations.readAll(type, row, workspaceId);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* One page of a single relation field's links (infinite-scroll for a
|
|
373
|
+
* many/inverse relation). 404 if the entry is missing; 400 if `field` isn't a
|
|
374
|
+
* relation on the type.
|
|
375
|
+
*/
|
|
376
|
+
async getRelationField(type, id, field, page, pageSize, workspaceId) {
|
|
377
|
+
const spec = this.relationSpec(type, field);
|
|
378
|
+
const row = await this.findLive(type, id, workspaceId);
|
|
379
|
+
if (!row)
|
|
380
|
+
throw this.notFound(type, id);
|
|
381
|
+
return this.relations.readField(type, row, field, spec, page, pageSize, workspaceId);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Apply the staged per-field relation deltas of a save, inside the entry's
|
|
385
|
+
* transaction — so the row and every link change commit or roll back as one.
|
|
386
|
+
* Each field must be a **many/inverse** relation (a single relation is set
|
|
387
|
+
* through `values`); a single-relation or unknown key is a 400. `applyDelta`
|
|
388
|
+
* validates the linked ids against the workspace (422 on a bad target).
|
|
389
|
+
*/
|
|
390
|
+
async applyRelationDeltas(tx, type, id, relations, workspaceId, sourceLocale) {
|
|
391
|
+
if (!relations)
|
|
392
|
+
return;
|
|
393
|
+
for (const [field, delta] of Object.entries(relations)) {
|
|
394
|
+
const spec = this.relationSpec(type, field);
|
|
395
|
+
const rel = spec.relation;
|
|
396
|
+
// Only a join-backed relation this side owns can persist a delta: an
|
|
397
|
+
// owning many-to-many, or the inverse of a many-to-many (it reuses
|
|
398
|
+
// the owning join table). A single relation (set via `values`) — and,
|
|
399
|
+
// crucially, the inverse of a *single* relation (one-to-many), which
|
|
400
|
+
// owns no writable link from this side — must be rejected here, else
|
|
401
|
+
// `applyDelta` would silently no-op and the save would drop the edit.
|
|
402
|
+
const writable = rel?.inverse
|
|
403
|
+
? !!rel.to().fields[rel.inverse.field]?.relation?.many
|
|
404
|
+
: !!rel?.many;
|
|
405
|
+
if (!writable) {
|
|
406
|
+
throw new common_1.BadRequestException(`Relation "${type.name}.${field}" owns no writable links from this side — ` +
|
|
407
|
+
`set a single relation via the entry's values; the inverse of a single relation is read-only.`);
|
|
408
|
+
}
|
|
409
|
+
await this.relations.applyDelta(tx, type, id, field, delta, workspaceId, sourceLocale);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/** Resolve a relation field spec on the type, or 400 if it isn't one. */
|
|
413
|
+
relationSpec(type, field) {
|
|
414
|
+
const spec = type.fields[field];
|
|
415
|
+
if (spec?.type !== fields_1.CONTENT_FIELD_TYPE.Relation || !spec.relation) {
|
|
416
|
+
throw new common_1.BadRequestException(`"${field}" is not a relation field on content type "${type.name}".`);
|
|
417
|
+
}
|
|
418
|
+
return spec;
|
|
419
|
+
}
|
|
420
|
+
/** Read one live entry in the workspace, or 404. */
|
|
421
|
+
async getOne(type, id, workspaceId) {
|
|
422
|
+
const row = await this.findLive(type, id, workspaceId);
|
|
423
|
+
if (!row)
|
|
424
|
+
throw this.notFound(type, id);
|
|
425
|
+
return (0, entry_row_1.toRecord)(type, row);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Replace a live entry's values, or 404. As with {@link create}, a
|
|
429
|
+
* publishable type's values aren't validated here (a draft may be saved
|
|
430
|
+
* incomplete; publish enforces the rules) — a non-publishable, always-live
|
|
431
|
+
* type validates now.
|
|
432
|
+
*/
|
|
433
|
+
async update(type, id, values, workspaceId, relations, actor, options) {
|
|
434
|
+
const actorId = actor?.id ?? null;
|
|
435
|
+
let coerced = (0, entry_row_1.coerceValues)(type, values);
|
|
436
|
+
// The row's own locale is what a relation link must match. Read up front
|
|
437
|
+
// (one indexed lookup, and only on localized types) so the pre-transaction
|
|
438
|
+
// target checks can apply the same rule the in-transaction link writes do.
|
|
439
|
+
const { locale: rowLocale, localeGroupId: rowGroup } = await this.localeOf(type, id, workspaceId);
|
|
440
|
+
const folded = await this.foldSingleRelationSets(type, values, relations, workspaceId, rowLocale);
|
|
441
|
+
coerced = (0, entry_row_1.coerceValues)(type, folded.values);
|
|
442
|
+
relations = folded.relations;
|
|
443
|
+
await this.assertRelationTargets(type, coerced, workspaceId, rowLocale);
|
|
444
|
+
await this.assertUniqueRelations(type, coerced, workspaceId, rowLocale, { id, localeGroupId: rowGroup });
|
|
445
|
+
await this.assertMediaTargets(type, coerced, workspaceId);
|
|
446
|
+
// Whether this write must satisfy the type's required rules now (its
|
|
447
|
+
// scalar values up front, its link-managed relations after the links are
|
|
448
|
+
// written). A **publishable** type's save always produces a **draft**
|
|
449
|
+
// working copy: a draft may be incomplete, so it isn't eagerly validated,
|
|
450
|
+
// and editing an already-published entry moves it back to draft (its
|
|
451
|
+
// previously-published *version* stays live in history until the next
|
|
452
|
+
// publish). A non-publishable type is always live, so every write must
|
|
453
|
+
// validate now.
|
|
454
|
+
const enforceRequired = !type.publishable;
|
|
455
|
+
if (!type.publishable) {
|
|
456
|
+
this.assertValid(type, coerced);
|
|
457
|
+
}
|
|
458
|
+
// One transaction: replace the row's columns, re-sync a whole-set
|
|
459
|
+
// many-relation submitted in `values`, and apply the staged relation
|
|
460
|
+
// deltas — so a save is all-or-nothing.
|
|
461
|
+
const row = await this.uow.run(async () => {
|
|
462
|
+
const tx = this.tx();
|
|
463
|
+
// Read the stored row before the UPDATE rewrites it — inside the
|
|
464
|
+
// transaction, so it is the row this write actually replaces — so
|
|
465
|
+
// `entry.updated` can name the fields that changed instead of
|
|
466
|
+
// asserting only that a save happened.
|
|
467
|
+
const before = await this.findLive(type, id, workspaceId, tx);
|
|
468
|
+
// Before the row is locked by the UPDATE below: the extension's
|
|
469
|
+
// chance to take a deterministic lock over the wider set it will
|
|
470
|
+
// rewrite in `afterUpdate` (the i18n plugin locks the translation
|
|
471
|
+
// group). Taken first, so two concurrent saves on two members of
|
|
472
|
+
// one group queue instead of deadlocking on each other's row locks.
|
|
473
|
+
await this.extension?.beforeWrite?.(tx, type, { localeGroupId: rowGroup ?? undefined }, workspaceId);
|
|
474
|
+
const [updated] = await tx
|
|
475
|
+
.update(type.table)
|
|
476
|
+
.set({
|
|
477
|
+
...(0, entry_row_1.toColumns)(type, coerced),
|
|
478
|
+
// Editing a publishable entry produces a draft working copy —
|
|
479
|
+
// a published entry moves back to draft (its live version
|
|
480
|
+
// stays published in history until the next publish).
|
|
481
|
+
//
|
|
482
|
+
// `published_at` is deliberately **kept**: it records that
|
|
483
|
+
// this entry has a live published version, which an edit
|
|
484
|
+
// does not retract (only `unpublish` does, and `markDraft`
|
|
485
|
+
// clears it there). `draft` + a `published_at` is what the
|
|
486
|
+
// admin reads as **Modified** — unsaved-to-live changes on
|
|
487
|
+
// top of published content — versus a never-published
|
|
488
|
+
// `draft`.
|
|
489
|
+
...(type.publishable ? { status: content_type_1.ENTRY_STATUS.Draft } : {}),
|
|
490
|
+
updatedAt: new Date()
|
|
491
|
+
})
|
|
492
|
+
.where(this.liveWhere(type, id, workspaceId))
|
|
493
|
+
.returning();
|
|
494
|
+
if (!updated)
|
|
495
|
+
throw this.notFound(type, id);
|
|
496
|
+
await this.relations.writeLinks(tx, type, id, coerced, workspaceId, rowLocale);
|
|
497
|
+
await this.applyRelationDeltas(tx, type, id, relations, workspaceId, rowLocale);
|
|
498
|
+
// Enforce required link-managed relations against the post-delta
|
|
499
|
+
// link set (inside the txn, so it sees the just-written rows); a
|
|
500
|
+
// 422 rolls the whole update back.
|
|
501
|
+
if (enforceRequired)
|
|
502
|
+
await this.assertRequiredRelations(tx, type, updated, workspaceId);
|
|
503
|
+
// Extension side-effects of a save (e.g. syncing shared fields to
|
|
504
|
+
// locale siblings) run inside the same transaction — a failure
|
|
505
|
+
// rolls the whole save back.
|
|
506
|
+
const touched = await this.extension?.afterUpdate(tx, type, updated, coerced, workspaceId, { created: false });
|
|
507
|
+
// Snapshot the updated document as a new draft revision, inside this
|
|
508
|
+
// same transaction — unless the caller is re-applying a version that
|
|
509
|
+
// already exists in the timeline (publish-a-version).
|
|
510
|
+
if (options?.appendRevision ?? true) {
|
|
511
|
+
await this.appendRevision(tx, type, updated, workspaceId, actorId ?? null);
|
|
512
|
+
}
|
|
513
|
+
// …and one for every sibling the extension rewrote.
|
|
514
|
+
await this.appendRevisionsFor(tx, type, touched, workspaceId, actorId ?? null);
|
|
515
|
+
// A save that changed no field value raises nothing: it is a round
|
|
516
|
+
// trip, not an editorial change, and the log is read by people.
|
|
517
|
+
const fields = this.changedFields(type, before, updated);
|
|
518
|
+
if (fields.length > 0) {
|
|
519
|
+
await this.emit([(0, entry_events_1.entryUpdated)(id, type.name, fields)], actor);
|
|
520
|
+
}
|
|
521
|
+
return updated;
|
|
522
|
+
});
|
|
523
|
+
return (0, entry_row_1.toRecord)(type, row);
|
|
524
|
+
}
|
|
525
|
+
// ---- publish-lifecycle status primitives -------------------------------
|
|
526
|
+
// These are the SQL the publish/unpublish/bulk use-cases compose; they run
|
|
527
|
+
// on the executor the caller passes (the active unit-of-work transaction),
|
|
528
|
+
// so the status write and its outbox event commit atomically. The
|
|
529
|
+
// validation gate + status transition + event raising live in the `Entry`
|
|
530
|
+
// domain model and its use-cases, not here.
|
|
531
|
+
/**
|
|
532
|
+
* Stamp `status='published'` + `published_at`/`updated_at` on one live row,
|
|
533
|
+
* returning the updated row (or undefined if none matched, i.e. a 404).
|
|
534
|
+
*/
|
|
535
|
+
async markPublished(exec, type, id, workspaceId) {
|
|
536
|
+
const [row] = await exec
|
|
537
|
+
.update(type.table)
|
|
538
|
+
.set({
|
|
539
|
+
status: content_type_1.ENTRY_STATUS.Published,
|
|
540
|
+
publishedAt: new Date(),
|
|
541
|
+
updatedAt: new Date()
|
|
542
|
+
})
|
|
543
|
+
.where(this.liveWhere(type, id, workspaceId))
|
|
544
|
+
.returning();
|
|
545
|
+
return row;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Transition the entry's **revision history** to reflect a publish: one
|
|
549
|
+
* revision becomes the live (`published`) version and any prior published
|
|
550
|
+
* one is `superseded`. Runs on the publish transaction's executor (the
|
|
551
|
+
* active unit of work) so the row's status and its history commit as one.
|
|
552
|
+
* Delegates to the revision store — every revision is minted a draft, so
|
|
553
|
+
* this is what makes a published entry read as "Live" in the timeline.
|
|
554
|
+
*
|
|
555
|
+
* `revisionNumber` targets a **specific** version (publishing an earlier one
|
|
556
|
+
* in place); omitted, it targets the latest — the row that was just saved.
|
|
557
|
+
*/
|
|
558
|
+
async markRevisionPublished(exec, entryId, workspaceId, revisionNumber) {
|
|
559
|
+
await this.revisionStore.markPublished(exec, entryId, workspaceId, revisionNumber);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Revert the entry's published revision back to `draft` on `exec` — the
|
|
563
|
+
* unpublish counterpart to {@link markRevisionPublished}.
|
|
564
|
+
*/
|
|
565
|
+
async markRevisionUnpublished(exec, entryId, workspaceId) {
|
|
566
|
+
await this.revisionStore.markUnpublished(exec, entryId, workspaceId);
|
|
567
|
+
}
|
|
568
|
+
/** Revert one live row to `status='draft'` (clearing `published_at`) on `exec`. */
|
|
569
|
+
async markDraft(exec, type, id, workspaceId) {
|
|
570
|
+
const [row] = await exec
|
|
571
|
+
.update(type.table)
|
|
572
|
+
.set({
|
|
573
|
+
status: content_type_1.ENTRY_STATUS.Draft,
|
|
574
|
+
publishedAt: null,
|
|
575
|
+
updatedAt: new Date()
|
|
576
|
+
})
|
|
577
|
+
.where(this.liveWhere(type, id, workspaceId))
|
|
578
|
+
.returning();
|
|
579
|
+
return row;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Load the workspace's live rows for `ids` **`FOR UPDATE`** on `exec`, keyed
|
|
583
|
+
* by id — the locked candidate set a bulk publish re-validates inside its
|
|
584
|
+
* transaction (closing the preview→commit TOCTOU window the comments on the
|
|
585
|
+
* original single-transaction bulk publish defended).
|
|
586
|
+
*/
|
|
587
|
+
async loadLiveByIdsForUpdate(exec, type, ids, workspaceId) {
|
|
588
|
+
if (!ids.length)
|
|
589
|
+
return new Map();
|
|
590
|
+
const t = this.columns(type);
|
|
591
|
+
const deletedGuard = type.paranoid ? (0, drizzle_orm_1.isNull)(t['deletedAt']) : undefined;
|
|
592
|
+
const rows = (await exec
|
|
593
|
+
.select()
|
|
594
|
+
.from(type.table)
|
|
595
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(t['id'], ids), this.scope(type, workspaceId), deletedGuard))
|
|
596
|
+
.for('update'));
|
|
597
|
+
return new Map(rows.map((row) => [row['id'], row]));
|
|
598
|
+
}
|
|
599
|
+
/** Publish a set of ids in one statement on `exec` (the caller pre-filtered them). */
|
|
600
|
+
async markPublishedBulk(exec, type, ids, workspaceId) {
|
|
601
|
+
if (!ids.length)
|
|
602
|
+
return;
|
|
603
|
+
const t = this.columns(type);
|
|
604
|
+
const deletedGuard = type.paranoid ? (0, drizzle_orm_1.isNull)(t['deletedAt']) : undefined;
|
|
605
|
+
await exec
|
|
606
|
+
.update(type.table)
|
|
607
|
+
.set({
|
|
608
|
+
status: content_type_1.ENTRY_STATUS.Published,
|
|
609
|
+
publishedAt: new Date(),
|
|
610
|
+
updatedAt: new Date()
|
|
611
|
+
})
|
|
612
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(t['id'], ids), this.scope(type, workspaceId), deletedGuard));
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Which of `ids` are currently `published` and live in the workspace, on
|
|
616
|
+
* `exec` — the set a bulk unpublish actually transitions, so it raises one
|
|
617
|
+
* `entry.unpublished` per real change rather than per matched row.
|
|
618
|
+
*/
|
|
619
|
+
async publishedIdsAmong(exec, type, ids, workspaceId) {
|
|
620
|
+
if (!ids.length)
|
|
621
|
+
return [];
|
|
622
|
+
const t = this.columns(type);
|
|
623
|
+
const deletedGuard = type.paranoid ? (0, drizzle_orm_1.isNull)(t['deletedAt']) : undefined;
|
|
624
|
+
const rows = (await exec
|
|
625
|
+
.select()
|
|
626
|
+
.from(type.table)
|
|
627
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(t['id'], ids), this.scope(type, workspaceId), (0, drizzle_orm_1.eq)(t['status'], content_type_1.ENTRY_STATUS.Published), deletedGuard)));
|
|
628
|
+
return rows.map((row) => row['id']);
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Revert the **published** rows among `ids` to draft on `exec`, returning
|
|
632
|
+
* how many actually transitioned — the `{ count }` the bulk endpoint
|
|
633
|
+
* reports.
|
|
634
|
+
*
|
|
635
|
+
* The status predicate is the whole of it, and it was missing: without it
|
|
636
|
+
* the `UPDATE` matched every live row in the list, so unpublishing two ids
|
|
637
|
+
* of which one was already a draft answered `{ count: 2 }` — which is what
|
|
638
|
+
* the route's own OpenAPI description promises it never does ("an id that
|
|
639
|
+
* was not a live published row … is not counted"). It also stamped
|
|
640
|
+
* `updatedAt` on rows nothing happened to, moving an entry up a
|
|
641
|
+
* last-modified sort because somebody unpublished a *different* one.
|
|
642
|
+
*/
|
|
643
|
+
async markDraftBulk(exec, type, ids, workspaceId) {
|
|
644
|
+
if (!ids.length)
|
|
645
|
+
return 0;
|
|
646
|
+
const t = this.columns(type);
|
|
647
|
+
const rows = await exec
|
|
648
|
+
.update(type.table)
|
|
649
|
+
.set({
|
|
650
|
+
status: content_type_1.ENTRY_STATUS.Draft,
|
|
651
|
+
publishedAt: null,
|
|
652
|
+
updatedAt: new Date()
|
|
653
|
+
})
|
|
654
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(t['id'], ids), this.scope(type, workspaceId), (0, drizzle_orm_1.eq)(t['status'], content_type_1.ENTRY_STATUS.Published), type.paranoid ? (0, drizzle_orm_1.isNull)(t['deletedAt']) : undefined))
|
|
655
|
+
.returning();
|
|
656
|
+
return rows.length;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Delete an entry: soft (stamp `deleted_at`) for a paranoid type, hard
|
|
660
|
+
* `DELETE` otherwise. 404 if there's no live row to remove **in this
|
|
661
|
+
* workspace** — an id from another workspace is indistinguishable from a
|
|
662
|
+
* missing one.
|
|
663
|
+
*/
|
|
664
|
+
async remove(type, id, workspaceId, actor) {
|
|
665
|
+
const t = this.columns(type);
|
|
666
|
+
const scope = this.scope(type, workspaceId);
|
|
667
|
+
// In a unit of work purely so the `entry.deleted` event commits with
|
|
668
|
+
// the delete. Nothing else here needs a transaction — it is one
|
|
669
|
+
// statement — but an event that can outlive a rolled-back delete is the
|
|
670
|
+
// exact failure the outbox exists to prevent.
|
|
671
|
+
await this.uow.run(async () => {
|
|
672
|
+
const exec = this.uow.current();
|
|
673
|
+
const [row] = await this.restrictGuarded(() => type.paranoid
|
|
674
|
+
? exec
|
|
675
|
+
.update(type.table)
|
|
676
|
+
.set({
|
|
677
|
+
deletedAt: new Date(),
|
|
678
|
+
updatedAt: new Date()
|
|
679
|
+
})
|
|
680
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(t['id'], id), scope, (0, drizzle_orm_1.isNull)(t['deletedAt'])))
|
|
681
|
+
.returning()
|
|
682
|
+
: exec
|
|
683
|
+
.delete(type.table)
|
|
684
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(t['id'], id), scope))
|
|
685
|
+
.returning(), type);
|
|
686
|
+
if (!row)
|
|
687
|
+
throw this.notFound(type, id);
|
|
688
|
+
await this.emit([(0, entry_events_1.entryDeleted)(id, type.name, Boolean(type.paranoid))], actor);
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Clear a soft-deleted entry's tombstone (paranoid types only), or 404.
|
|
693
|
+
* On an i18n type the restore can collide with a row created in the same
|
|
694
|
+
* (locale group, locale) slot after the soft delete — the partial unique
|
|
695
|
+
* index rejects it, surfaced as a 409 rather than a 500.
|
|
696
|
+
*/
|
|
697
|
+
async restore(type, id, workspaceId, actor) {
|
|
698
|
+
this.assertParanoid(type);
|
|
699
|
+
const t = this.columns(type);
|
|
700
|
+
return this.uow.run(async () => {
|
|
701
|
+
const exec = this.uow.current();
|
|
702
|
+
const [row] = await this.uniqueGuarded(() => exec
|
|
703
|
+
.update(type.table)
|
|
704
|
+
.set({
|
|
705
|
+
deletedAt: null,
|
|
706
|
+
updatedAt: new Date()
|
|
707
|
+
})
|
|
708
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(t['id'], id), this.scope(type, workspaceId), (0, drizzle_orm_1.isNotNull)(t['deletedAt'])))
|
|
709
|
+
.returning(), type);
|
|
710
|
+
if (!row)
|
|
711
|
+
throw this.notFound(type, id);
|
|
712
|
+
await this.emit([(0, entry_events_1.entryRestored)(id, type.name)], actor);
|
|
713
|
+
return (0, entry_row_1.toRecord)(type, row);
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
/** Permanently delete a tombstoned entry (paranoid types only), or 404. */
|
|
717
|
+
async purge(type, id, workspaceId, actor) {
|
|
718
|
+
this.assertParanoid(type);
|
|
719
|
+
const t = this.columns(type);
|
|
720
|
+
await this.uow.run(async () => {
|
|
721
|
+
const exec = this.uow.current();
|
|
722
|
+
const [row] = await this.restrictGuarded(() => exec
|
|
723
|
+
.delete(type.table)
|
|
724
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(t['id'], id), this.scope(type, workspaceId), (0, drizzle_orm_1.isNotNull)(t['deletedAt'])))
|
|
725
|
+
.returning(), type);
|
|
726
|
+
if (!row)
|
|
727
|
+
throw this.notFound(type, id);
|
|
728
|
+
// The one content action that leaves nothing behind to inspect, so
|
|
729
|
+
// the event is the only remaining record that it happened.
|
|
730
|
+
await this.emit([(0, entry_events_1.entryPurged)(id, type.name)], actor);
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
/** Delete a set of entries: soft for paranoid types, hard otherwise. */
|
|
734
|
+
async bulkRemove(type, ids, workspaceId, actor) {
|
|
735
|
+
if (!ids.length)
|
|
736
|
+
return { count: 0 };
|
|
737
|
+
const t = this.columns(type);
|
|
738
|
+
const scope = this.scope(type, workspaceId);
|
|
739
|
+
return this.uow.run(async () => {
|
|
740
|
+
const exec = this.uow.current();
|
|
741
|
+
const rows = await this.restrictGuarded(() => type.paranoid
|
|
742
|
+
? exec
|
|
743
|
+
.update(type.table)
|
|
744
|
+
.set({
|
|
745
|
+
deletedAt: new Date(),
|
|
746
|
+
updatedAt: new Date()
|
|
747
|
+
})
|
|
748
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(t['id'], ids), scope, (0, drizzle_orm_1.isNull)(t['deletedAt'])))
|
|
749
|
+
.returning()
|
|
750
|
+
: exec
|
|
751
|
+
.delete(type.table)
|
|
752
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(t['id'], ids), scope))
|
|
753
|
+
.returning(), type);
|
|
754
|
+
// One event per row actually removed, not one per id asked for:
|
|
755
|
+
// batching is a way of asking, and the log records what happened.
|
|
756
|
+
await this.emit(this.eventsFor(rows, (id) => (0, entry_events_1.entryDeleted)(id, type.name, Boolean(type.paranoid))), actor);
|
|
757
|
+
return { count: rows.length };
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
/** Permanently delete a set of tombstoned entries (paranoid types only). */
|
|
761
|
+
async bulkPurge(type, ids, workspaceId, actor) {
|
|
762
|
+
this.assertParanoid(type);
|
|
763
|
+
if (!ids.length)
|
|
764
|
+
return { count: 0 };
|
|
765
|
+
const t = this.columns(type);
|
|
766
|
+
return this.uow.run(async () => {
|
|
767
|
+
const exec = this.uow.current();
|
|
768
|
+
const rows = await this.restrictGuarded(() => exec
|
|
769
|
+
.delete(type.table)
|
|
770
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(t['id'], ids), this.scope(type, workspaceId), (0, drizzle_orm_1.isNotNull)(t['deletedAt'])))
|
|
771
|
+
.returning(), type);
|
|
772
|
+
await this.emit(this.eventsFor(rows, (id) => (0, entry_events_1.entryPurged)(id, type.name)), actor);
|
|
773
|
+
return { count: rows.length };
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Restore a set of soft-deleted entries (paranoid types only). As with
|
|
778
|
+
* {@link restore}, a restored row colliding with a live sibling in its
|
|
779
|
+
* (locale group, locale) slot is a 409 — the whole batch rolls back
|
|
780
|
+
* (single statement), so the caller can retry without the conflicting id.
|
|
781
|
+
*/
|
|
782
|
+
async bulkRestore(type, ids, workspaceId, actor) {
|
|
783
|
+
this.assertParanoid(type);
|
|
784
|
+
if (!ids.length)
|
|
785
|
+
return { count: 0 };
|
|
786
|
+
const t = this.columns(type);
|
|
787
|
+
return this.uow.run(async () => {
|
|
788
|
+
const exec = this.uow.current();
|
|
789
|
+
const rows = await this.uniqueGuarded(() => exec
|
|
790
|
+
.update(type.table)
|
|
791
|
+
.set({
|
|
792
|
+
deletedAt: null,
|
|
793
|
+
updatedAt: new Date()
|
|
794
|
+
})
|
|
795
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(t['id'], ids), this.scope(type, workspaceId), (0, drizzle_orm_1.isNotNull)(t['deletedAt'])))
|
|
796
|
+
.returning(), type);
|
|
797
|
+
await this.emit(this.eventsFor(rows, (id) => (0, entry_events_1.entryRestored)(id, type.name)), actor);
|
|
798
|
+
return { count: rows.length };
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Run a write, translating a Postgres unique violation (23505) into a 409.
|
|
803
|
+
* Only i18n types carry a restore-relevant unique index — the partial
|
|
804
|
+
* `(locale_group_id, locale)` one — so the mapping is scoped to them and
|
|
805
|
+
* any other type's violation still surfaces as the bug it is.
|
|
806
|
+
*/
|
|
807
|
+
/**
|
|
808
|
+
* Run a delete and translate a Postgres **foreign-key** violation into a
|
|
809
|
+
* `409`.
|
|
810
|
+
*
|
|
811
|
+
* A required single relation declares `onDelete: 'restrict'` (a NOT NULL FK
|
|
812
|
+
* cannot be nulled on delete), so deleting a row something still points at
|
|
813
|
+
* is refused by the database. That refusal is legitimate and actionable —
|
|
814
|
+
* "detach the referring records first" — but it surfaced as a raw `500`,
|
|
815
|
+
* which tells the caller the server broke and gives them nothing to do. The
|
|
816
|
+
* symmetric counterpart of {@link uniqueGuarded}.
|
|
817
|
+
*
|
|
818
|
+
* Deliberately does not name the referring rows: finding them means a query
|
|
819
|
+
* per inbound relation across every type in the registry, and the caller
|
|
820
|
+
* cannot be told about records in workspaces they cannot see.
|
|
821
|
+
*/
|
|
822
|
+
async restrictGuarded(write, type) {
|
|
823
|
+
try {
|
|
824
|
+
return await write();
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
if (!(0, utils_server_1.isForeignKeyViolation)(error))
|
|
828
|
+
throw error;
|
|
829
|
+
throw new common_1.ConflictException(`This "${type.name}" entry is still referenced by other entries and cannot be deleted.`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
async uniqueGuarded(write, type) {
|
|
833
|
+
try {
|
|
834
|
+
return await write();
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
const constraint = (0, utils_server_1.violatedConstraint)(error);
|
|
838
|
+
if (constraint === undefined || !type.i18n)
|
|
839
|
+
throw error;
|
|
840
|
+
// A localized table carries more than one unique index, so the
|
|
841
|
+
// message has to follow the constraint that actually tripped.
|
|
842
|
+
// `<table>_<field>_locale_unique` is a one-to-one relation already
|
|
843
|
+
// claimed in this locale — a 422 about that field, not a 409 about
|
|
844
|
+
// the locale, which would send the user to fix the wrong thing.
|
|
845
|
+
// `assertUniqueRelations` normally catches this first; reaching here
|
|
846
|
+
// means a concurrent writer took the target in between, and the
|
|
847
|
+
// constraint is what settles the race.
|
|
848
|
+
if (constraint.endsWith('_locale_unique')) {
|
|
849
|
+
// Map the index name back to the field by re-deriving each
|
|
850
|
+
// field's own index name, rather than un-snake-casing the
|
|
851
|
+
// capture: `snakeCase` is not injective, so the reverse guess
|
|
852
|
+
// could name a field that doesn't exist.
|
|
853
|
+
const field = Object.keys(type.fields).find((name) => constraint ===
|
|
854
|
+
`${(0, drizzle_orm_1.getTableName)(type.table)}_${(0, table_builder_1.snakeCase)(name)}_locale_unique`);
|
|
855
|
+
if (field) {
|
|
856
|
+
throw new common_1.UnprocessableEntityException({
|
|
857
|
+
message: 'Entry validation failed',
|
|
858
|
+
issues: [
|
|
859
|
+
{
|
|
860
|
+
field,
|
|
861
|
+
message: 'is already linked to another entry in this locale'
|
|
862
|
+
}
|
|
863
|
+
]
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
throw new common_1.ConflictException(`An entry already occupies this locale in its translation group on "${type.name}".`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/** The type's generated table seen as a column bag (envelope + fields). */
|
|
871
|
+
columns(type) {
|
|
872
|
+
return type.table;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* `workspace_id = :workspaceId` — the ownership predicate every read and
|
|
876
|
+
* write AND-s in, so a request scoped to one workspace can neither see nor
|
|
877
|
+
* mutate another's rows.
|
|
878
|
+
*/
|
|
879
|
+
scope(type, workspaceId) {
|
|
880
|
+
return (0, drizzle_orm_1.eq)(this.columns(type)['workspaceId'], workspaceId);
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* `id = :id` AND the workspace scope AND (for paranoid types)
|
|
884
|
+
* `deleted_at IS NULL`.
|
|
885
|
+
*/
|
|
886
|
+
liveWhere(type, id, workspaceId) {
|
|
887
|
+
const t = this.columns(type);
|
|
888
|
+
return (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(t['id'], id), this.scope(type, workspaceId), type.paranoid ? (0, drizzle_orm_1.isNull)(t['deletedAt']) : undefined);
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Fetch one live row by id in the workspace, or undefined. Public + executor
|
|
892
|
+
* parameterized so the publish-lifecycle use-cases can read it under the
|
|
893
|
+
* active unit of work; internal callers use the default (base connection).
|
|
894
|
+
*/
|
|
895
|
+
async findLive(type, id, workspaceId, exec = this.db) {
|
|
896
|
+
const [row] = await exec
|
|
897
|
+
.select()
|
|
898
|
+
.from(type.table)
|
|
899
|
+
.where(this.liveWhere(type, id, workspaceId))
|
|
900
|
+
.limit(1);
|
|
901
|
+
return row;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Fetch the workspace's live rows for a set of ids, keyed by id — the
|
|
905
|
+
* bulk-publish **dry run** (no `FOR UPDATE`; the committed path uses
|
|
906
|
+
* {@link loadLiveByIdsForUpdate}).
|
|
907
|
+
*/
|
|
908
|
+
async loadLiveByIds(type, ids, workspaceId, exec = this.db) {
|
|
909
|
+
if (!ids.length)
|
|
910
|
+
return new Map();
|
|
911
|
+
const t = this.columns(type);
|
|
912
|
+
const rows = (await exec
|
|
913
|
+
.select()
|
|
914
|
+
.from(type.table)
|
|
915
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(t['id'], ids), this.scope(type, workspaceId), type.paranoid ? (0, drizzle_orm_1.isNull)(t['deletedAt']) : undefined)));
|
|
916
|
+
return new Map(rows.map((row) => [row['id'], row]));
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Verify every referenced relation target exists **in the same workspace** —
|
|
920
|
+
* single FKs, many-to-many links, and the inverse (back-reference) side of a
|
|
921
|
+
* two-way relation. Neither the FK column nor the join table has a workspace
|
|
922
|
+
* constraint of its own (the target table is workspace-scoped only at the app
|
|
923
|
+
* layer), so without this a caller could reference — or probe the existence
|
|
924
|
+
* of — an entry in another workspace. Batched one existence query per
|
|
925
|
+
* referenced target type; a missing or cross-workspace target surfaces as a
|
|
926
|
+
* uniform 422 validation issue (indistinguishable from a plain "invalid id",
|
|
927
|
+
* so no not-found-vs-forbidden enumeration signal). Runs on every
|
|
928
|
+
* create/update, draft or not, because links are written eagerly either way.
|
|
929
|
+
* An inverse-of-single (one-to-many) field owns no writable link from this
|
|
930
|
+
* side, so it's skipped.
|
|
931
|
+
*/
|
|
932
|
+
/**
|
|
933
|
+
* Reject a `unique: true` single relation whose target is already claimed
|
|
934
|
+
* — by a **different record**, in the locale being written.
|
|
935
|
+
*
|
|
936
|
+
* The database settles this too (a column-wide UNIQUE on a plain type, a
|
|
937
|
+
* per-locale `(<field>_id, locale)` index on a localized one), and it has to
|
|
938
|
+
* stay there: it is the only thing that holds under concurrency. This check
|
|
939
|
+
* exists for the message, not the guarantee. A raw 23505 surfaces as a 500
|
|
940
|
+
* with a constraint name in it; the caller needs to know *which field* is
|
|
941
|
+
* taken, in the same `{ field, message }` shape as every other entry
|
|
942
|
+
* validation error.
|
|
943
|
+
*
|
|
944
|
+
* "Different record" is the whole subtlety on a localized type. The English
|
|
945
|
+
* and German rows of one article are two rows and one record, and a relation
|
|
946
|
+
* synced across the group deliberately puts the same id in both — so this
|
|
947
|
+
* compares translation **groups**, not row ids. Excluding only `entryId`
|
|
948
|
+
* would make the second locale of a record look like a rival claimant to the
|
|
949
|
+
* target the record already owns.
|
|
950
|
+
*/
|
|
951
|
+
async assertUniqueRelations(type, values, workspaceId, locale, current) {
|
|
952
|
+
const unique = Object.entries(type.fields).filter(([, spec]) => spec.type === fields_1.CONTENT_FIELD_TYPE.Relation &&
|
|
953
|
+
spec.relation?.unique &&
|
|
954
|
+
!spec.relation.many &&
|
|
955
|
+
!spec.relation.inverse);
|
|
956
|
+
if (!unique.length)
|
|
957
|
+
return;
|
|
958
|
+
const cols = this.columns(type);
|
|
959
|
+
const issues = [];
|
|
960
|
+
for (const [field] of unique) {
|
|
961
|
+
const target = values[field];
|
|
962
|
+
if (typeof target !== 'string' || !UUID_RE.test(target))
|
|
963
|
+
continue;
|
|
964
|
+
const rows = (await this.db
|
|
965
|
+
.select()
|
|
966
|
+
.from(type.table)
|
|
967
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(cols[field], target), (0, drizzle_orm_1.eq)(cols['workspaceId'], workspaceId),
|
|
968
|
+
// Scoped to the locale being written on an i18n type,
|
|
969
|
+
// mirroring the index: another language's row holding
|
|
970
|
+
// this target is the same record's sibling, not a rival.
|
|
971
|
+
type.i18n && locale
|
|
972
|
+
? (0, drizzle_orm_1.eq)(cols['locale'], locale)
|
|
973
|
+
: undefined, type.paranoid ? (0, drizzle_orm_1.isNull)(cols['deletedAt']) : undefined))
|
|
974
|
+
.limit(2));
|
|
975
|
+
const claimedByOther = rows.some((row) => type.i18n && current?.localeGroupId
|
|
976
|
+
? row['localeGroupId'] !== current.localeGroupId
|
|
977
|
+
: row['id'] !== current?.id);
|
|
978
|
+
if (claimedByOther) {
|
|
979
|
+
issues.push({
|
|
980
|
+
field,
|
|
981
|
+
message: type.i18n
|
|
982
|
+
? 'is already linked to another entry in this locale'
|
|
983
|
+
: 'is already linked to another entry'
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (issues.length) {
|
|
988
|
+
throw new common_1.UnprocessableEntityException({
|
|
989
|
+
message: 'Entry validation failed',
|
|
990
|
+
issues
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
async assertRelationTargets(type, values, workspaceId, sourceLocale) {
|
|
995
|
+
// Group referenced ids by target content type so each type is probed once.
|
|
996
|
+
const byTarget = new Map();
|
|
997
|
+
for (const [name, spec] of Object.entries(type.fields)) {
|
|
998
|
+
if (spec.type !== fields_1.CONTENT_FIELD_TYPE.Relation || !spec.relation)
|
|
999
|
+
continue;
|
|
1000
|
+
const relation = spec.relation;
|
|
1001
|
+
// An owning many-to-many array submitted in `values` is re-validated
|
|
1002
|
+
// in-transaction by `writeLinks` (the same uniform 422), so skip it
|
|
1003
|
+
// here to avoid a duplicate existence probe. The inverse array and
|
|
1004
|
+
// owning single FKs aren't checked in-tx, so they stay validated here.
|
|
1005
|
+
if (relation.many && !relation.inverse)
|
|
1006
|
+
continue;
|
|
1007
|
+
if (relation.inverse) {
|
|
1008
|
+
// Inverse of a single relation writes nothing from this side;
|
|
1009
|
+
// only the inverse of a many-to-many links (its ids are owner
|
|
1010
|
+
// rows of the referenced type).
|
|
1011
|
+
const owningField = relation.to().fields[relation.inverse.field];
|
|
1012
|
+
if (!owningField?.relation?.many)
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
const ids = relation.many || relation.inverse
|
|
1016
|
+
? Array.isArray(values[name])
|
|
1017
|
+
? values[name]
|
|
1018
|
+
: []
|
|
1019
|
+
: [values[name]];
|
|
1020
|
+
const target = relation.to();
|
|
1021
|
+
const refs = byTarget.get(target) ?? [];
|
|
1022
|
+
for (const id of ids) {
|
|
1023
|
+
if (typeof id !== 'string' || !id)
|
|
1024
|
+
continue;
|
|
1025
|
+
refs.push({ field: name, id });
|
|
1026
|
+
}
|
|
1027
|
+
if (refs.length)
|
|
1028
|
+
byTarget.set(target, refs);
|
|
1029
|
+
}
|
|
1030
|
+
if (!byTarget.size)
|
|
1031
|
+
return;
|
|
1032
|
+
const issues = [];
|
|
1033
|
+
for (const [target, refs] of byTarget) {
|
|
1034
|
+
const t = target.table;
|
|
1035
|
+
// A malformed id is the same uniform 422 as a missing one, and never
|
|
1036
|
+
// reaches the query — see UUID_RE.
|
|
1037
|
+
const malformed = refs.filter((ref) => !UUID_RE.test(ref.id));
|
|
1038
|
+
for (const ref of malformed) {
|
|
1039
|
+
issues.push({
|
|
1040
|
+
field: ref.field,
|
|
1041
|
+
message: 'must reference an existing entry'
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
const ids = [
|
|
1045
|
+
...new Set(refs
|
|
1046
|
+
.filter((ref) => UUID_RE.test(ref.id))
|
|
1047
|
+
.map((ref) => ref.id))
|
|
1048
|
+
];
|
|
1049
|
+
if (!ids.length)
|
|
1050
|
+
continue;
|
|
1051
|
+
const rows = (await this.db
|
|
1052
|
+
.select()
|
|
1053
|
+
.from(target.table)
|
|
1054
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(t['id'], ids), (0, drizzle_orm_1.eq)(t['workspaceId'], workspaceId))));
|
|
1055
|
+
const present = new Set(rows.map((row) => row['id']));
|
|
1056
|
+
for (const ref of refs) {
|
|
1057
|
+
if (UUID_RE.test(ref.id) && !present.has(ref.id))
|
|
1058
|
+
issues.push({
|
|
1059
|
+
field: ref.field,
|
|
1060
|
+
message: 'must reference an existing entry'
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
// Same cross-locale rule the join-backed links get, applied to the
|
|
1064
|
+
// owning single FKs and inverse arrays that only pass through here.
|
|
1065
|
+
// Reported per field so a save touching two of them names both.
|
|
1066
|
+
for (const field of new Set(refs.map((ref) => ref.field))) {
|
|
1067
|
+
const ids = new Set(refs.filter((ref) => ref.field === field).map((r) => r.id));
|
|
1068
|
+
(0, relation_link_service_1.assertSameLocale)(rows.filter((row) => ids.has(row['id'])), target, field, sourceLocale);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
if (issues.length) {
|
|
1072
|
+
throw new common_1.UnprocessableEntityException({
|
|
1073
|
+
message: 'Entry validation failed',
|
|
1074
|
+
issues
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Validate every media field's asset ids: each must reference an asset that
|
|
1080
|
+
* **exists in the same workspace** and whose kind/MIME satisfies the field's
|
|
1081
|
+
* `accept` restriction. Mirrors {@link assertRelationTargets} — asset ids are
|
|
1082
|
+
* plain uuids with no FK (the assets live in the media plugin's schema), so
|
|
1083
|
+
* without this a caller could reference — or probe — an asset in another
|
|
1084
|
+
* workspace, or attach a disallowed file type. Batched: one resolver lookup
|
|
1085
|
+
* across every media id on the entry.
|
|
1086
|
+
*
|
|
1087
|
+
* A **missing** asset and a **cross-workspace** one both read as the same
|
|
1088
|
+
* uniform 422 (the resolver simply omits them from its map — no
|
|
1089
|
+
* not-found-vs-forbidden enumeration signal). When no resolver is bound (the
|
|
1090
|
+
* media plugin isn't registered) this is a no-op: media fields shape-validate
|
|
1091
|
+
* and store their ids, but existence/restriction aren't enforced.
|
|
1092
|
+
*/
|
|
1093
|
+
async assertMediaTargets(type, values, workspaceId) {
|
|
1094
|
+
if (!this.mediaResolver)
|
|
1095
|
+
return;
|
|
1096
|
+
// Collect every referenced asset id, remembering which field each came
|
|
1097
|
+
// from so a violation names the right field.
|
|
1098
|
+
const refs = [];
|
|
1099
|
+
for (const [name, spec] of Object.entries(type.fields)) {
|
|
1100
|
+
if (spec.type !== fields_1.CONTENT_FIELD_TYPE.Media)
|
|
1101
|
+
continue;
|
|
1102
|
+
// Through the kernel, because a media value is `{ id, alt?,
|
|
1103
|
+
// decorative? }` as well as a bare id since `ORT-83` — and a
|
|
1104
|
+
// hand-rolled `typeof value === 'string'` silently skipped the
|
|
1105
|
+
// object form, so an asset attached with alt text was never checked
|
|
1106
|
+
// for existence or against `accept`.
|
|
1107
|
+
for (const id of (0, content_domain_1.mediaValueIds)(values[name])) {
|
|
1108
|
+
if (id)
|
|
1109
|
+
refs.push({ field: name, id });
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (!refs.length)
|
|
1113
|
+
return;
|
|
1114
|
+
const resolved = await this.mediaResolver.resolve([...new Set(refs.map((ref) => ref.id))], workspaceId);
|
|
1115
|
+
const issues = [];
|
|
1116
|
+
for (const ref of refs) {
|
|
1117
|
+
const asset = resolved.get(ref.id);
|
|
1118
|
+
if (!asset) {
|
|
1119
|
+
issues.push({
|
|
1120
|
+
field: ref.field,
|
|
1121
|
+
message: 'must reference an existing asset'
|
|
1122
|
+
});
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
const spec = type.fields[ref.field];
|
|
1126
|
+
if (!(0, media_accept_1.acceptsAsset)(spec.accept, asset)) {
|
|
1127
|
+
issues.push({
|
|
1128
|
+
field: ref.field,
|
|
1129
|
+
message: `must be ${(0, media_accept_1.describeAccept)(spec.accept)}`
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
if (issues.length) {
|
|
1134
|
+
throw new common_1.UnprocessableEntityException({
|
|
1135
|
+
message: 'Entry validation failed',
|
|
1136
|
+
issues
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Enforce required **link-managed** relations (an owning many-to-many, or the
|
|
1142
|
+
* inverse of one) against the entry's actual link set — the check
|
|
1143
|
+
* {@link EntryValidationService} can't make, because those links never travel
|
|
1144
|
+
* in the `values` bag it sees. A required such relation with zero links is a
|
|
1145
|
+
* 422 `is required`, matching how a required scalar field reads. A single FK
|
|
1146
|
+
* relation is validated in `values` already; an inverse-of-single owns no
|
|
1147
|
+
* writable link from this side, so it's skipped (it can't be satisfied here).
|
|
1148
|
+
* `exec` is the write transaction on create/update (so it counts the
|
|
1149
|
+
* just-written, uncommitted rows) or the root client on publish (committed).
|
|
1150
|
+
*/
|
|
1151
|
+
async assertRequiredRelations(exec, type, row, workspaceId) {
|
|
1152
|
+
const issues = await this.requiredRelationIssues(exec, type, row, workspaceId);
|
|
1153
|
+
if (issues.length) {
|
|
1154
|
+
throw new common_1.UnprocessableEntityException({
|
|
1155
|
+
message: 'Entry validation failed',
|
|
1156
|
+
issues
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* The `is required` issues for required **link-managed** relations (an owning
|
|
1162
|
+
* many-to-many, or the inverse of one) whose link set is empty — the check
|
|
1163
|
+
* {@link EntryValidationService} can't make, since those links never travel
|
|
1164
|
+
* in the `values` bag. Returns the issues rather than throwing, so the
|
|
1165
|
+
* publish use-case can fold them into the `Entry` domain publish gate. `exec`
|
|
1166
|
+
* is the write transaction (counting just-written rows) on create/update, or
|
|
1167
|
+
* the unit-of-work transaction on publish.
|
|
1168
|
+
*/
|
|
1169
|
+
async requiredRelationIssues(exec, type, row, workspaceId) {
|
|
1170
|
+
const issues = [];
|
|
1171
|
+
for (const [name, spec] of Object.entries(type.fields)) {
|
|
1172
|
+
if (spec.type !== fields_1.CONTENT_FIELD_TYPE.Relation ||
|
|
1173
|
+
!spec.relation ||
|
|
1174
|
+
!spec.required)
|
|
1175
|
+
continue;
|
|
1176
|
+
const rel = spec.relation;
|
|
1177
|
+
// Only relations this side owns writable links for are counted:
|
|
1178
|
+
// an owning many-to-many, or the inverse of a many-to-many.
|
|
1179
|
+
const writable = rel.inverse
|
|
1180
|
+
? !!rel.to().fields[rel.inverse.field]?.relation?.many
|
|
1181
|
+
: !!rel.many;
|
|
1182
|
+
if (!writable)
|
|
1183
|
+
continue;
|
|
1184
|
+
const total = await this.relations.countLinks(exec, type, row, name, spec, workspaceId);
|
|
1185
|
+
if (total === 0)
|
|
1186
|
+
issues.push({ field: name, message: 'is required' });
|
|
1187
|
+
}
|
|
1188
|
+
return issues;
|
|
1189
|
+
}
|
|
1190
|
+
/** Throw 422 with the issue list when the values fail validation. */
|
|
1191
|
+
assertValid(type, values) {
|
|
1192
|
+
const result = this.validation.validate(type, values);
|
|
1193
|
+
if (!result.valid) {
|
|
1194
|
+
throw new common_1.UnprocessableEntityException({
|
|
1195
|
+
message: 'Entry validation failed',
|
|
1196
|
+
issues: result.issues
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
/** Reject restore/purge on a type without soft delete. */
|
|
1201
|
+
assertParanoid(type) {
|
|
1202
|
+
if (!type.paranoid) {
|
|
1203
|
+
throw new common_1.BadRequestException(`Content type "${type.name}" does not support trash/restore.`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
notFound(type, id) {
|
|
1207
|
+
return new common_1.NotFoundException(`No entry "${id}" on content type "${type.name}".`);
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
exports.EntryWriterService = EntryWriterService;
|
|
1211
|
+
exports.EntryWriterService = EntryWriterService = tslib_1.__decorate([
|
|
1212
|
+
(0, common_1.Injectable)(),
|
|
1213
|
+
tslib_1.__param(0, (0, database_1.InjectDatabase)()),
|
|
1214
|
+
tslib_1.__param(5, (0, revision_store_1.InjectRevisionStore)()),
|
|
1215
|
+
tslib_1.__param(6, (0, common_1.Optional)()),
|
|
1216
|
+
tslib_1.__param(6, (0, common_1.Inject)(entry_extension_1.CONTENT_ENTRY_EXTENSION)),
|
|
1217
|
+
tslib_1.__param(7, (0, common_1.Optional)()),
|
|
1218
|
+
tslib_1.__param(7, (0, media_asset_resolver_1.InjectMediaAssetResolver)()),
|
|
1219
|
+
tslib_1.__metadata("design:paramtypes", [Object, database_1.UnitOfWork,
|
|
1220
|
+
database_1.OutboxWriter,
|
|
1221
|
+
entry_validation_service_1.EntryValidationService,
|
|
1222
|
+
relation_link_service_1.RelationLinkService, Object, Object, Object])
|
|
1223
|
+
], EntryWriterService);
|