@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,1008 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RelationLinkService = exports.RELATION_PAGE_SIZE = void 0;
|
|
4
|
+
exports.assertSameLocale = assertSameLocale;
|
|
5
|
+
const tslib_1 = require("tslib");
|
|
6
|
+
const common_1 = require("@nestjs/common");
|
|
7
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
8
|
+
const database_1 = require("@orthacms/database");
|
|
9
|
+
const content_type_1 = require("../../../types/content-type");
|
|
10
|
+
const fields_1 = require("../../../types/fields");
|
|
11
|
+
const entry_row_1 = require("./entry-row");
|
|
12
|
+
/**
|
|
13
|
+
* Reject any target row that lives in a different locale from the source.
|
|
14
|
+
*
|
|
15
|
+
* Two **i18n** types must link inside one locale. A cross-locale link is a
|
|
16
|
+
* broken model rather than a preference: the English article would render the
|
|
17
|
+
* German tag, and since links are per-row rather than synced across a
|
|
18
|
+
* translation group, nothing later repairs it. The admin has always enforced
|
|
19
|
+
* this by only offering same-locale candidates in its relation picker; this is
|
|
20
|
+
* the same rule at the layer a direct API call cannot skip.
|
|
21
|
+
*
|
|
22
|
+
* A no-op unless BOTH sides are localized — `sourceLocale` is `undefined` for a
|
|
23
|
+
* non-i18n owner, and a non-i18n target has no locale to disagree about.
|
|
24
|
+
*
|
|
25
|
+
* Exported so `EntryWriterService` applies the identical rule to the owning
|
|
26
|
+
* **single** relations it validates itself (a `<field>_id` FK never reaches the
|
|
27
|
+
* join-table paths), instead of the two growing their own versions of it.
|
|
28
|
+
*/
|
|
29
|
+
function assertSameLocale(rows, target, field, sourceLocale) {
|
|
30
|
+
if (!sourceLocale || !target.i18n)
|
|
31
|
+
return;
|
|
32
|
+
const foreign = rows.filter((row) => row['locale'] !== sourceLocale);
|
|
33
|
+
if (!foreign.length)
|
|
34
|
+
return;
|
|
35
|
+
throw new common_1.UnprocessableEntityException({
|
|
36
|
+
message: 'Entry validation failed',
|
|
37
|
+
issues: foreign.map((row) => ({
|
|
38
|
+
field,
|
|
39
|
+
message: `must reference a "${sourceLocale}" entry — ` +
|
|
40
|
+
`"${row['id']}" is "${row['locale']}". ` +
|
|
41
|
+
'Link the translation of that record in this entry’s locale.'
|
|
42
|
+
}))
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Default links per page when the caller doesn't specify one. */
|
|
46
|
+
exports.RELATION_PAGE_SIZE = 20;
|
|
47
|
+
/**
|
|
48
|
+
* How many relation fields are resolved at once — by the page preview and by
|
|
49
|
+
* the per-entry {@link RelationLinkService.readAll}. `relationFields` is
|
|
50
|
+
* bounded in bytes but not in count, so a type with many relation columns could
|
|
51
|
+
* otherwise fan out one concurrent query per field — and each field takes a
|
|
52
|
+
* connection twice (its window query, then `refsFor`). The pool is small and
|
|
53
|
+
* app-wide, so cap the fan-out and let the remainder queue in-process rather
|
|
54
|
+
* than in the pool, where it would block unrelated requests.
|
|
55
|
+
*/
|
|
56
|
+
const RELATION_FIELD_CONCURRENCY = 3;
|
|
57
|
+
/**
|
|
58
|
+
* A private advisory-lock class (the first `pg_advisory_xact_lock` key)
|
|
59
|
+
* namespacing the per-source append locks, so their hashed `<table>:<sourceId>`
|
|
60
|
+
* keys can't collide with any other advisory lock the app takes (e.g. identity's
|
|
61
|
+
* per-workspace lock). Arbitrary but fixed.
|
|
62
|
+
*/
|
|
63
|
+
const RELATION_APPEND_LOCK_CLASS = 0x524c; // 'RL'
|
|
64
|
+
/**
|
|
65
|
+
* Reads and writes the relation **links** an entry owns beyond its own columns:
|
|
66
|
+
* many-to-many join rows and the inverse (back-reference) side of a two-way
|
|
67
|
+
* relation. Owning **single** relations are plain `<field>_id` FK columns —
|
|
68
|
+
* written by `toColumns`, read off the row — so they need no join plumbing.
|
|
69
|
+
*
|
|
70
|
+
* Everything is **paginated**: a relation can hold thousands of links, so reads
|
|
71
|
+
* return one ordered page + a `total` (never every id), and writes apply an
|
|
72
|
+
* incremental {@link RelationDelta} (link / unlink / reorder) inside a
|
|
73
|
+
* transaction — so a huge relation never has to be sent or held in full.
|
|
74
|
+
* Order is persisted in the join table's `position` column, owned by the
|
|
75
|
+
* **source** side; the inverse reads by it (stable) but can't reorder it.
|
|
76
|
+
*/
|
|
77
|
+
let RelationLinkService = class RelationLinkService {
|
|
78
|
+
db;
|
|
79
|
+
constructor(db) {
|
|
80
|
+
this.db = db;
|
|
81
|
+
}
|
|
82
|
+
// ---- reads -------------------------------------------------------------
|
|
83
|
+
/**
|
|
84
|
+
* First page (+ total) of **every** relation field of `type` for one entry,
|
|
85
|
+
* keyed by field name — what the editor loads on open. Each field is read
|
|
86
|
+
* independently (paginated), so a relation with many links contributes only
|
|
87
|
+
* its first page, not every id.
|
|
88
|
+
*
|
|
89
|
+
* Admin-only, hence no {@link RelationTargetVisibility}: an editor must see
|
|
90
|
+
* the draft targets it may be about to publish. The public API reads links
|
|
91
|
+
* one field at a time through {@link readField}, which takes the flag.
|
|
92
|
+
*/
|
|
93
|
+
async readAll(type, row, workspaceId, pageSize = exports.RELATION_PAGE_SIZE) {
|
|
94
|
+
// Each relation field is an independent read, so fan them out
|
|
95
|
+
// concurrently rather than awaiting one before starting the next — a
|
|
96
|
+
// type with several relations pays one field's latency, not their sum.
|
|
97
|
+
//
|
|
98
|
+
// Bounded by the same cap as `previewForEntries`, and for the same
|
|
99
|
+
// reason: the pool is small (10 by default) and app-wide, while each
|
|
100
|
+
// field takes a connection for its page + count and then again for
|
|
101
|
+
// `refsFor`. Unbounded, a type with several relation fields could let a
|
|
102
|
+
// couple of concurrent editor opens occupy every connection and queue
|
|
103
|
+
// unrelated traffic behind them — the exact hazard the preview path
|
|
104
|
+
// already guards, which this read simply hadn't caught up with.
|
|
105
|
+
const fields = Object.entries(type.fields).filter(([, spec]) => spec.type === fields_1.CONTENT_FIELD_TYPE.Relation && spec.relation);
|
|
106
|
+
const views = [];
|
|
107
|
+
for (let i = 0; i < fields.length; i += RELATION_FIELD_CONCURRENCY) {
|
|
108
|
+
views.push(...(await Promise.all(fields
|
|
109
|
+
.slice(i, i + RELATION_FIELD_CONCURRENCY)
|
|
110
|
+
.map(([name, spec]) => this.readField(type, row, name, spec, 1, pageSize, workspaceId)))));
|
|
111
|
+
}
|
|
112
|
+
const out = {};
|
|
113
|
+
fields.forEach(([name], i) => {
|
|
114
|
+
out[name] = views[i];
|
|
115
|
+
});
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A capped relation preview for a whole **page** of entries — what the
|
|
120
|
+
* records table's relation columns render. Returns, per entry id, a
|
|
121
|
+
* `{ items, total }` view for each requested field.
|
|
122
|
+
*
|
|
123
|
+
* The batching is the point: this issues a constant number of queries **per
|
|
124
|
+
* relation field**, spanning every row on the page (`inArray(ownCol, ids)`),
|
|
125
|
+
* never one query per row. Contrast {@link readAll}, which is scoped to a
|
|
126
|
+
* single entry (`eq(ownCol, row.id)`) because the editor opens one record —
|
|
127
|
+
* calling it in a loop over a page would be a textbook N+1 (a 50-row page
|
|
128
|
+
* with two relation columns would cost 300 queries; this costs a handful).
|
|
129
|
+
*
|
|
130
|
+
* Each field contributes at most `pageSize` refs plus its true `total`, so a
|
|
131
|
+
* record holding thousands of links reads exactly one page here — the
|
|
132
|
+
* dropdown pages through the rest via {@link readField}. Only `fields` are
|
|
133
|
+
* resolved (the table's visible columns), so a hidden relation column costs
|
|
134
|
+
* nothing.
|
|
135
|
+
*/
|
|
136
|
+
async previewForEntries(type, fields, rows, workspaceId, pageSize = exports.RELATION_PAGE_SIZE, visibility) {
|
|
137
|
+
const out = new Map();
|
|
138
|
+
const sourceIds = rows
|
|
139
|
+
.map((row) => row['id'])
|
|
140
|
+
.filter((id) => typeof id === 'string' && !!id);
|
|
141
|
+
const specs = fields
|
|
142
|
+
.map((name) => [name, type.fields[name]])
|
|
143
|
+
.filter((entry) => entry[1]?.type === fields_1.CONTENT_FIELD_TYPE.Relation &&
|
|
144
|
+
!!entry[1].relation);
|
|
145
|
+
if (!sourceIds.length || !specs.length)
|
|
146
|
+
return out;
|
|
147
|
+
for (const id of sourceIds)
|
|
148
|
+
out.set(id, {});
|
|
149
|
+
// Each relation field is an independent read, so fan them out
|
|
150
|
+
// concurrently rather than awaiting one before starting the next — a
|
|
151
|
+
// table with several relation columns pays one field's latency, not
|
|
152
|
+
// their sum (the same reasoning as `readAll`).
|
|
153
|
+
//
|
|
154
|
+
// Bounded, though: the pool is small and shared app-wide, and each
|
|
155
|
+
// field holds a connection for its window query and then another for
|
|
156
|
+
// `refsFor`. A type with many relation columns would otherwise let a
|
|
157
|
+
// couple of list requests occupy every connection, queueing unrelated
|
|
158
|
+
// traffic behind a records table's previews.
|
|
159
|
+
const views = [];
|
|
160
|
+
for (let i = 0; i < specs.length; i += RELATION_FIELD_CONCURRENCY) {
|
|
161
|
+
const batch = specs.slice(i, i + RELATION_FIELD_CONCURRENCY);
|
|
162
|
+
views.push(...(await Promise.all(batch.map(([name, spec]) => this.previewField(type, name, spec, rows, sourceIds, workspaceId, pageSize, visibility)))));
|
|
163
|
+
}
|
|
164
|
+
specs.forEach(([name], index) => {
|
|
165
|
+
for (const [sourceId, view] of views[index]) {
|
|
166
|
+
const bucket = out.get(sourceId);
|
|
167
|
+
if (bucket)
|
|
168
|
+
bucket[name] = view;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Preview one relation field across the page, dispatched by storage form —
|
|
175
|
+
* the same three shapes {@link readField} handles: an owning single relation
|
|
176
|
+
* (FK on the row), a join-backed relation (owning many-to-many or the
|
|
177
|
+
* inverse of one), and an inverse-of-single (the owner's FK points back).
|
|
178
|
+
*/
|
|
179
|
+
previewField(type, field, spec, rows, sourceIds, workspaceId, pageSize, visibility) {
|
|
180
|
+
const relation = spec.relation;
|
|
181
|
+
if (!relation)
|
|
182
|
+
return Promise.resolve(new Map());
|
|
183
|
+
if (!relation.many && !relation.inverse)
|
|
184
|
+
return this.previewSingle(relation.to(), field, rows, workspaceId, visibility);
|
|
185
|
+
const join = this.joinPlanFor(type, field, spec);
|
|
186
|
+
if (join)
|
|
187
|
+
return this.previewJoin(join, sourceIds, workspaceId, pageSize, visibility);
|
|
188
|
+
const inverse = this.inversePlanFor(spec);
|
|
189
|
+
if (inverse)
|
|
190
|
+
return this.previewInverse(inverse, sourceIds, workspaceId, pageSize, visibility);
|
|
191
|
+
return Promise.resolve(new Map());
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Owning **single** relations: the FK already rides each page row, so this
|
|
195
|
+
* is one batched {@link refsFor} for every row's target at once — which also
|
|
196
|
+
* de-duplicates, so 50 posts sharing one author resolve that title once
|
|
197
|
+
* instead of 50 times.
|
|
198
|
+
*/
|
|
199
|
+
async previewSingle(target, field, rows, workspaceId, visibility) {
|
|
200
|
+
const fkBySource = new Map();
|
|
201
|
+
for (const row of rows) {
|
|
202
|
+
const fk = row[field];
|
|
203
|
+
const id = row['id'];
|
|
204
|
+
if (typeof fk === 'string' && fk && typeof id === 'string')
|
|
205
|
+
fkBySource.set(id, fk);
|
|
206
|
+
}
|
|
207
|
+
const out = new Map();
|
|
208
|
+
if (!fkBySource.size)
|
|
209
|
+
return out;
|
|
210
|
+
const refs = await this.refsFor(target, [...fkBySource.values()], workspaceId, visibility);
|
|
211
|
+
const refById = new Map(refs.map((ref) => [ref.id, ref]));
|
|
212
|
+
for (const [sourceId, fk] of fkBySource) {
|
|
213
|
+
// Under a visibility restriction an unresolvable target is one the
|
|
214
|
+
// caller may not see, so the link is reported as absent rather than
|
|
215
|
+
// as a `missing` ref — a public consumer must not learn that a
|
|
216
|
+
// hidden record is linked here, and `total` must not count it.
|
|
217
|
+
if (visibility?.publishedOnly && !refById.has(fk)) {
|
|
218
|
+
out.set(sourceId, { items: [], total: 0 });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// `refsFor` is total — it yields a ref for every id, id-only and
|
|
222
|
+
// flagged `missing` when the target is gone. So the link is always
|
|
223
|
+
// represented (`total` counts the link, not whether its target
|
|
224
|
+
// resolved, exactly as `readField` reports it), and it is the flag,
|
|
225
|
+
// not an absent item, that tells the UI the target is unavailable.
|
|
226
|
+
const ref = refById.get(fk) ?? {
|
|
227
|
+
id: fk,
|
|
228
|
+
title: fk,
|
|
229
|
+
missing: true
|
|
230
|
+
};
|
|
231
|
+
out.set(sourceId, { items: [ref], total: 1 });
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Join-backed relations (owning many-to-many, or the inverse of one), ranked
|
|
237
|
+
* in a single windowed pass: `row_number()` applies the per-source page cap
|
|
238
|
+
* and `count(*)` the true total, both partitioned by the owning id. That is
|
|
239
|
+
* what keeps a 600-link record to `pageSize` rows read instead of 600.
|
|
240
|
+
*/
|
|
241
|
+
async previewJoin(join, sourceIds, workspaceId, pageSize, visibility) {
|
|
242
|
+
const cols = join.table;
|
|
243
|
+
const own = cols[join.ownCol];
|
|
244
|
+
const ref = cols[join.refCol];
|
|
245
|
+
const position = cols['position'];
|
|
246
|
+
const ranked = this.db
|
|
247
|
+
.select({
|
|
248
|
+
own,
|
|
249
|
+
ref,
|
|
250
|
+
rn: (0, drizzle_orm_1.sql) `row_number() over (partition by ${own} order by ${position} asc, ${ref} asc)`.as('rn'),
|
|
251
|
+
total: (0, drizzle_orm_1.sql) `count(*) over (partition by ${own})`.as('total')
|
|
252
|
+
})
|
|
253
|
+
.from(join.table)
|
|
254
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(own, sourceIds),
|
|
255
|
+
// Applied INSIDE the window, not after it: `count(*) over`
|
|
256
|
+
// is computed here, so filtering later would page over
|
|
257
|
+
// hidden links and report a total the caller can't reach.
|
|
258
|
+
this.visibleTargetIdsPredicate(ref, join.target, workspaceId, visibility)))
|
|
259
|
+
.as('ranked');
|
|
260
|
+
// The window aliases (`rn`/`own`) carry no column type through the
|
|
261
|
+
// subquery, so compare and order them as raw fragments rather than via
|
|
262
|
+
// `lte`/`asc`, whose overloads can't resolve an untyped alias.
|
|
263
|
+
const rows = (await this.db
|
|
264
|
+
.select()
|
|
265
|
+
.from(ranked)
|
|
266
|
+
.where((0, drizzle_orm_1.sql) `${ranked.rn} <= ${pageSize}`)
|
|
267
|
+
.orderBy((0, drizzle_orm_1.sql) `${ranked.own} asc, ${ranked.rn} asc`));
|
|
268
|
+
return this.groupPreview(rows, 'ref', await this.refsFor(join.target, rows.map((row) => row['ref']), workspaceId, visibility));
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Inverse-of-single (one-to-many): the links are the owner rows whose FK
|
|
272
|
+
* points back at each page row. Windowed the same way, and scoped to the
|
|
273
|
+
* workspace with the soft-delete guard the inverse read side already applies.
|
|
274
|
+
*/
|
|
275
|
+
async previewInverse(inverse, sourceIds, workspaceId, pageSize, visibility) {
|
|
276
|
+
const cols = inverse.table;
|
|
277
|
+
const fk = cols[inverse.fkCol];
|
|
278
|
+
const ranked = this.db
|
|
279
|
+
.select({
|
|
280
|
+
own: fk,
|
|
281
|
+
ref: cols['id'],
|
|
282
|
+
rn: (0, drizzle_orm_1.sql) `row_number() over (partition by ${fk} order by ${cols['createdAt']} asc, ${cols['id']} asc)`.as('rn'),
|
|
283
|
+
total: (0, drizzle_orm_1.sql) `count(*) over (partition by ${fk})`.as('total')
|
|
284
|
+
})
|
|
285
|
+
.from(inverse.table)
|
|
286
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(fk, sourceIds),
|
|
287
|
+
// The window reads the TARGET table here (the owners whose
|
|
288
|
+
// FK points back), so the restriction is a plain column
|
|
289
|
+
// predicate rather than a sub-select.
|
|
290
|
+
this.targetVisibleWhere(inverse.target, workspaceId, visibility)))
|
|
291
|
+
.as('ranked');
|
|
292
|
+
const rows = (await this.db
|
|
293
|
+
.select()
|
|
294
|
+
.from(ranked)
|
|
295
|
+
.where((0, drizzle_orm_1.sql) `${ranked.rn} <= ${pageSize}`)
|
|
296
|
+
.orderBy((0, drizzle_orm_1.sql) `${ranked.own} asc, ${ranked.rn} asc`));
|
|
297
|
+
return this.groupPreview(rows, 'ref', await this.refsFor(inverse.target, rows.map((row) => row['ref']), workspaceId, visibility));
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Fold ranked window rows (already ordered by owner, then rank) into one
|
|
301
|
+
* `{ items, total }` view per owning id, resolving each link through the
|
|
302
|
+
* batched `refs`. Shared by the join-backed and inverse previews.
|
|
303
|
+
*/
|
|
304
|
+
groupPreview(rows, refKey, refs) {
|
|
305
|
+
const refById = new Map(refs.map((ref) => [ref.id, ref]));
|
|
306
|
+
const out = new Map();
|
|
307
|
+
for (const row of rows) {
|
|
308
|
+
const sourceId = row['own'];
|
|
309
|
+
let view = out.get(sourceId);
|
|
310
|
+
if (!view) {
|
|
311
|
+
view = { items: [], total: Number(row['total']) };
|
|
312
|
+
out.set(sourceId, view);
|
|
313
|
+
}
|
|
314
|
+
// `refsFor` resolved every ranked id, so there is always a ref —
|
|
315
|
+
// id-only and flagged `missing` where the target is soft-deleted or
|
|
316
|
+
// out of workspace. Skipping it here would make `items` shorter
|
|
317
|
+
// than `total` and leave the cell showing a phantom `+N`.
|
|
318
|
+
const id = row[refKey];
|
|
319
|
+
view.items.push(refById.get(id) ?? { id, title: id, missing: true });
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* One page of a single relation field's links, ordered by `position`
|
|
325
|
+
* (owning) and resolved to display-ready refs (id + title). `row` is the
|
|
326
|
+
* editing entry (its FK columns back a single relation); pass it to avoid a
|
|
327
|
+
* re-read. Returns `{ items, total }`.
|
|
328
|
+
*/
|
|
329
|
+
async readField(type, row, field, spec, page, pageSize, workspaceId, visibility) {
|
|
330
|
+
if (spec.type !== fields_1.CONTENT_FIELD_TYPE.Relation || !spec.relation)
|
|
331
|
+
return { items: [], total: 0 };
|
|
332
|
+
const offset = (page - 1) * pageSize;
|
|
333
|
+
// Owning single relation: the id is the row's FK value (0 or 1 link).
|
|
334
|
+
if (!spec.relation.many && !spec.relation.inverse) {
|
|
335
|
+
const fk = row[field];
|
|
336
|
+
const ids = typeof fk === 'string' && fk ? [fk] : [];
|
|
337
|
+
const items = page === 1
|
|
338
|
+
? await this.refsFor(spec.relation.to(), ids, workspaceId, visibility)
|
|
339
|
+
: [];
|
|
340
|
+
// Under a visibility restriction an unresolved target is hidden,
|
|
341
|
+
// not `missing` — the link is reported as absent (see
|
|
342
|
+
// `previewSingle` for why `total` must not count it).
|
|
343
|
+
if (visibility?.publishedOnly && !items.some((ref) => !ref.missing))
|
|
344
|
+
return { items: [], total: 0 };
|
|
345
|
+
return { items, total: ids.length };
|
|
346
|
+
}
|
|
347
|
+
const join = this.joinPlanFor(type, field, spec);
|
|
348
|
+
if (join) {
|
|
349
|
+
const cols = join.table;
|
|
350
|
+
const selectable = join.table;
|
|
351
|
+
// One predicate for the page and the count, so a restricted read
|
|
352
|
+
// can't report a total its pages never reach.
|
|
353
|
+
const linkWhere = (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(cols[join.ownCol], row['id']), this.visibleTargetIdsPredicate(selectable[join.refCol], join.target, workspaceId, visibility));
|
|
354
|
+
const [rows, [{ total }]] = await Promise.all([
|
|
355
|
+
this.db
|
|
356
|
+
.select()
|
|
357
|
+
.from(join.table)
|
|
358
|
+
.where(linkWhere)
|
|
359
|
+
.orderBy((0, drizzle_orm_1.asc)(cols['position']), (0, drizzle_orm_1.asc)(cols[join.refCol]))
|
|
360
|
+
.limit(pageSize)
|
|
361
|
+
.offset(offset),
|
|
362
|
+
this.db
|
|
363
|
+
.select({ total: (0, drizzle_orm_1.count)() })
|
|
364
|
+
.from(join.table)
|
|
365
|
+
.where(linkWhere)
|
|
366
|
+
]);
|
|
367
|
+
const ids = rows.map((r) => r[join.refCol]);
|
|
368
|
+
return {
|
|
369
|
+
items: await this.refsFor(join.target, ids, workspaceId, visibility),
|
|
370
|
+
total: Number(total)
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const inverse = this.inversePlanFor(spec);
|
|
374
|
+
if (inverse) {
|
|
375
|
+
const cols = inverse.table;
|
|
376
|
+
const where = (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(cols[inverse.fkCol], row['id']), this.targetVisibleWhere(inverse.target, workspaceId, visibility));
|
|
377
|
+
const [rows, [{ total }]] = await Promise.all([
|
|
378
|
+
this.db
|
|
379
|
+
.select()
|
|
380
|
+
.from(inverse.table)
|
|
381
|
+
.where(where)
|
|
382
|
+
.orderBy((0, drizzle_orm_1.asc)(cols['createdAt']), (0, drizzle_orm_1.asc)(cols['id']))
|
|
383
|
+
.limit(pageSize)
|
|
384
|
+
.offset(offset),
|
|
385
|
+
this.db
|
|
386
|
+
.select({ total: (0, drizzle_orm_1.count)() })
|
|
387
|
+
.from(inverse.table)
|
|
388
|
+
.where(where)
|
|
389
|
+
]);
|
|
390
|
+
return {
|
|
391
|
+
items: rows.map((r) => this.rowToRef(inverse.target, r)),
|
|
392
|
+
total: Number(total)
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return { items: [], total: 0 };
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The **full** ordered target-id list of every join-backed relation field
|
|
399
|
+
* (owning many-to-many and the inverse of one) for one entry, keyed by field
|
|
400
|
+
* name — the relation half of a revision snapshot. Unlike {@link readAll}
|
|
401
|
+
* (one page + a `total`, for the editor), this reads every link, since a
|
|
402
|
+
* snapshot must reconstruct the whole set. Runs on the passed executor (the
|
|
403
|
+
* save transaction), so it captures exactly what committed. Owning single
|
|
404
|
+
* relations aren't included — their FK already rides the entry's `values`.
|
|
405
|
+
* The join rows belong to the owning entry (scoped by `row.id`), so no
|
|
406
|
+
* workspace filter is needed here — the same as the join read in
|
|
407
|
+
* {@link readField}.
|
|
408
|
+
*/
|
|
409
|
+
async snapshotLinks(exec, type, row) {
|
|
410
|
+
const out = {};
|
|
411
|
+
for (const [field, spec] of Object.entries(type.fields)) {
|
|
412
|
+
const join = this.joinPlanFor(type, field, spec);
|
|
413
|
+
if (!join)
|
|
414
|
+
continue; // single FK / inverse-of-single: not join-backed
|
|
415
|
+
const cols = join.table;
|
|
416
|
+
const rows = (await exec
|
|
417
|
+
.select()
|
|
418
|
+
.from(join.table)
|
|
419
|
+
.where((0, drizzle_orm_1.eq)(cols[join.ownCol], row['id']))
|
|
420
|
+
.orderBy((0, drizzle_orm_1.asc)(cols['position']), (0, drizzle_orm_1.asc)(cols[join.refCol])));
|
|
421
|
+
out[field] = rows.map((r) => r[join.refCol]);
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
// ---- writes ------------------------------------------------------------
|
|
426
|
+
/**
|
|
427
|
+
* Apply an incremental {@link RelationDelta} to one many/inverse relation
|
|
428
|
+
* field inside `tx`: validate the linked targets are in the workspace, unlink
|
|
429
|
+
* the removed pairs, append the new ones (`position = max+1` for the source,
|
|
430
|
+
* `ON CONFLICT DO NOTHING`), then renumber to `order` (owning side only).
|
|
431
|
+
* A single relation or an inverse-of-single owns no delta-writable link, so
|
|
432
|
+
* it's a no-op. Returns nothing — read the field back for the new state.
|
|
433
|
+
*/
|
|
434
|
+
async applyDelta(tx, type, sourceId, field, delta, workspaceId, sourceLocale) {
|
|
435
|
+
const spec = type.fields[field];
|
|
436
|
+
const join = spec ? this.joinPlanFor(type, field, spec) : null;
|
|
437
|
+
if (!join)
|
|
438
|
+
return; // single / inverse-of-single: nothing to write here
|
|
439
|
+
// `by: 'localeGroup'` names translation groups rather than rows; resolve
|
|
440
|
+
// them to this source's own locale before anything touches the join.
|
|
441
|
+
const resolved = delta.by === 'localeGroup'
|
|
442
|
+
? await this.resolveByLocaleGroup(tx, join.target, delta, workspaceId, field, sourceLocale)
|
|
443
|
+
: delta;
|
|
444
|
+
await this.assertTargets(tx, join.target, resolved.link ?? [], workspaceId, field, sourceLocale);
|
|
445
|
+
const cols = join.table;
|
|
446
|
+
const own = cols[join.ownCol];
|
|
447
|
+
const ref = cols[join.refCol];
|
|
448
|
+
if (resolved.unlink?.length) {
|
|
449
|
+
await tx
|
|
450
|
+
.delete(join.table)
|
|
451
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(own, sourceId), (0, drizzle_orm_1.inArray)(ref, resolved.unlink)));
|
|
452
|
+
}
|
|
453
|
+
// Append each new link at the end of its **source's** ordered list.
|
|
454
|
+
const links = dedupe(resolved.link);
|
|
455
|
+
if (links.length && join.ownCol === 'sourceId') {
|
|
456
|
+
// Owning relation: every new link shares this entry as its source, so
|
|
457
|
+
// read the append base once and insert them all in a single
|
|
458
|
+
// statement (position = base + index) instead of a SELECT+INSERT per
|
|
459
|
+
// link inside the locked transaction. Take the source's append lock
|
|
460
|
+
// first so a concurrent writer to the same list can't read the same
|
|
461
|
+
// `max(position)` and produce a duplicate position (the inverse side
|
|
462
|
+
// and a whole-set write both target this same physical list).
|
|
463
|
+
await this.lockSource(tx, join.table, sourceId);
|
|
464
|
+
const base = await this.nextPosition(tx, join.table, sourceId);
|
|
465
|
+
await tx
|
|
466
|
+
.insert(join.table)
|
|
467
|
+
.values(links.map((targetId, i) => ({
|
|
468
|
+
[join.ownCol]: sourceId,
|
|
469
|
+
[join.refCol]: targetId,
|
|
470
|
+
position: base + i
|
|
471
|
+
})))
|
|
472
|
+
.onConflictDoNothing();
|
|
473
|
+
}
|
|
474
|
+
else if (links.length) {
|
|
475
|
+
// Inverse relation: each link appends to a **different** owner row's
|
|
476
|
+
// list (its physical source is the linked owner, not this entry), so
|
|
477
|
+
// each needs its own append base — and its own append lock. Lock (and
|
|
478
|
+
// append) in a stable sorted order so two transactions touching an
|
|
479
|
+
// overlapping owner set can't deadlock by taking the locks in
|
|
480
|
+
// opposite orders.
|
|
481
|
+
for (const targetId of [...links].sort()) {
|
|
482
|
+
await this.lockSource(tx, join.table, targetId);
|
|
483
|
+
const position = await this.nextPosition(tx, join.table, targetId);
|
|
484
|
+
await tx
|
|
485
|
+
.insert(join.table)
|
|
486
|
+
.values({
|
|
487
|
+
[join.ownCol]: sourceId,
|
|
488
|
+
[join.refCol]: targetId,
|
|
489
|
+
position
|
|
490
|
+
})
|
|
491
|
+
.onConflictDoNothing();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// Reorder is the owning side's prerogative (its `source_id` axis); the
|
|
495
|
+
// inverse reuses the same rows and can't renumber them without corrupting
|
|
496
|
+
// the owner's order, so `order` is ignored there. One `CASE` update
|
|
497
|
+
// renumbers every listed target instead of a statement per id.
|
|
498
|
+
if (resolved.order?.length && join.ownCol === 'sourceId') {
|
|
499
|
+
// Renumber the whole list in one UPDATE — position = its index in
|
|
500
|
+
// `order`, via a CASE keyed on the ref id — instead of an UPDATE per
|
|
501
|
+
// id inside the locked transaction. The WHERE bounds it to this
|
|
502
|
+
// source's rows named in `order`; the `else` keeps any unmatched row
|
|
503
|
+
// untouched.
|
|
504
|
+
const order = resolved.order;
|
|
505
|
+
const cases = order.map((targetId, i) => (0, drizzle_orm_1.sql) `when ${ref} = ${targetId} then ${i}`);
|
|
506
|
+
await tx
|
|
507
|
+
.update(join.table)
|
|
508
|
+
.set({
|
|
509
|
+
position: (0, drizzle_orm_1.sql) `case ${drizzle_orm_1.sql.join(cases, (0, drizzle_orm_1.sql) ` `)} else ${cols['position']} end`
|
|
510
|
+
})
|
|
511
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(own, sourceId), (0, drizzle_orm_1.inArray)(ref, order)));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Replace a many-relation's links with exactly `values[field]` (array),
|
|
516
|
+
* position = array index — the **whole-set** write used only when a caller
|
|
517
|
+
* genuinely submits a relation **array** in the entry body (legacy / bulk).
|
|
518
|
+
* The editor uses {@link applyDelta} instead. Runs inside the entry's
|
|
519
|
+
* transaction. **Only an array counts**: a field that's absent or `null`
|
|
520
|
+
* (`coerceValues` stamps `null` onto every unset field) is left untouched, so
|
|
521
|
+
* a save that manages a relation purely through deltas can never have its
|
|
522
|
+
* links cleared here.
|
|
523
|
+
*/
|
|
524
|
+
async writeLinks(tx, type, sourceId, values, workspaceId, sourceLocale) {
|
|
525
|
+
for (const [name, spec] of Object.entries(type.fields)) {
|
|
526
|
+
// Only a genuinely submitted **array** is a whole-set write. Anything
|
|
527
|
+
// else — key absent, or `null` (which `coerceValues` stamps onto
|
|
528
|
+
// every unset field) — means "not managing this relation via the
|
|
529
|
+
// whole-set path"; skip it so the incremental delta path owns it and
|
|
530
|
+
// a null can't be read as "clear all links".
|
|
531
|
+
if (!Array.isArray(values[name]))
|
|
532
|
+
continue;
|
|
533
|
+
const join = this.joinPlanFor(type, name, spec);
|
|
534
|
+
if (!join || join.ownCol !== 'sourceId')
|
|
535
|
+
continue; // owning many only
|
|
536
|
+
const ids = dedupe(values[name]);
|
|
537
|
+
await this.assertTargets(tx, join.target, ids, workspaceId, name, sourceLocale);
|
|
538
|
+
const cols = join.table;
|
|
539
|
+
const own = cols[join.ownCol];
|
|
540
|
+
const ref = cols[join.refCol];
|
|
541
|
+
await tx
|
|
542
|
+
.delete(join.table)
|
|
543
|
+
.where(ids.length
|
|
544
|
+
? (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(own, sourceId), (0, drizzle_orm_1.notInArray)(ref, ids))
|
|
545
|
+
: (0, drizzle_orm_1.eq)(own, sourceId));
|
|
546
|
+
if (ids.length) {
|
|
547
|
+
await tx
|
|
548
|
+
.insert(join.table)
|
|
549
|
+
.values(ids.map((id, i) => ({
|
|
550
|
+
[join.ownCol]: sourceId,
|
|
551
|
+
[join.refCol]: id,
|
|
552
|
+
position: i
|
|
553
|
+
})))
|
|
554
|
+
.onConflictDoNothing();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Count the links of one join-backed relation field for an entry — the
|
|
560
|
+
* `total` a read would report, without fetching or resolving any page. Runs
|
|
561
|
+
* on the passed executor (the write transaction when validating a just-saved
|
|
562
|
+
* entry, so it sees the delta's uncommitted rows). Returns 0 for a relation
|
|
563
|
+
* that owns no writable links from this side (single FK / inverse-of-single).
|
|
564
|
+
*/
|
|
565
|
+
async countLinks(exec, type, row, field, spec, workspaceId) {
|
|
566
|
+
const join = this.joinPlanFor(type, field, spec);
|
|
567
|
+
if (join) {
|
|
568
|
+
const cols = join.table;
|
|
569
|
+
const [{ total }] = await exec
|
|
570
|
+
.select({ total: (0, drizzle_orm_1.count)() })
|
|
571
|
+
.from(join.table)
|
|
572
|
+
.where((0, drizzle_orm_1.eq)(cols[join.ownCol], row['id']));
|
|
573
|
+
return Number(total);
|
|
574
|
+
}
|
|
575
|
+
const inverse = this.inversePlanFor(spec);
|
|
576
|
+
if (inverse) {
|
|
577
|
+
const cols = inverse.table;
|
|
578
|
+
const [{ total }] = await exec
|
|
579
|
+
.select({ total: (0, drizzle_orm_1.count)() })
|
|
580
|
+
.from(inverse.table)
|
|
581
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(cols[inverse.fkCol], row['id']), (0, drizzle_orm_1.eq)(cols['workspaceId'], workspaceId), cols['deletedAt']
|
|
582
|
+
? (0, drizzle_orm_1.isNull)(cols['deletedAt'])
|
|
583
|
+
: undefined));
|
|
584
|
+
return Number(total);
|
|
585
|
+
}
|
|
586
|
+
return 0;
|
|
587
|
+
}
|
|
588
|
+
// ---- helpers -----------------------------------------------------------
|
|
589
|
+
/**
|
|
590
|
+
* Take the transaction-scoped advisory lock for one physical source list
|
|
591
|
+
* (`<join table>:<sourceId>`), serializing every appender to that list so no
|
|
592
|
+
* two concurrent writes read the same `max(position)`. Auto-releases at
|
|
593
|
+
* commit/rollback. Keyed on the table name too, so distinct join tables that
|
|
594
|
+
* happen to share a source id don't falsely contend.
|
|
595
|
+
*/
|
|
596
|
+
async lockSource(tx, table, sourceId) {
|
|
597
|
+
const key = `${(0, drizzle_orm_1.getTableName)(table)}:${sourceId}`;
|
|
598
|
+
await tx.execute((0, drizzle_orm_1.sql) `select pg_advisory_xact_lock(${RELATION_APPEND_LOCK_CLASS}, hashtext(${key}))`);
|
|
599
|
+
}
|
|
600
|
+
/** Next append position for a source: `max(position) + 1`, else 0. */
|
|
601
|
+
async nextPosition(tx, table, sourceId) {
|
|
602
|
+
const cols = table;
|
|
603
|
+
const [row] = await tx
|
|
604
|
+
.select({ max: (0, drizzle_orm_1.max)(cols['position']) })
|
|
605
|
+
.from(table)
|
|
606
|
+
.where((0, drizzle_orm_1.eq)(cols['sourceId'], sourceId));
|
|
607
|
+
const current = row?.max;
|
|
608
|
+
return current == null ? 0 : Number(current) + 1;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Public resolver for callers that hold **raw ids** rather than a live link
|
|
612
|
+
* set — the revision preview, which reads a historical snapshot's relation id
|
|
613
|
+
* lists and needs their display titles. Resolves the first `cap` ids (order
|
|
614
|
+
* preserved) via the same batched lookup as {@link refsFor}; the caller
|
|
615
|
+
* carries the true count separately and shows a "+N more" past the cap, so a
|
|
616
|
+
* relation with thousands of links is never resolved whole. A soft-deleted /
|
|
617
|
+
* foreign id yields a `missing` ref, never a title leak.
|
|
618
|
+
*/
|
|
619
|
+
async resolveRefs(target, ids, workspaceId, cap) {
|
|
620
|
+
return this.refsFor(target, ids.slice(0, cap), workspaceId);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Resolve `ids` (in order) to display-ready refs via one lookup on the target
|
|
624
|
+
* type, scoped to the workspace. Ids with no **live** row drop to an id-only
|
|
625
|
+
* ref — a soft-deleted (paranoid) target is excluded here just as it is on
|
|
626
|
+
* the inverse read side, so a trashed record never surfaces its title on the
|
|
627
|
+
* owning side while it's hidden on the other.
|
|
628
|
+
*/
|
|
629
|
+
async refsFor(target, ids, workspaceId, visibility) {
|
|
630
|
+
if (!ids.length)
|
|
631
|
+
return [];
|
|
632
|
+
const cols = target.table;
|
|
633
|
+
const rows = (await this.db
|
|
634
|
+
.select()
|
|
635
|
+
.from(target.table)
|
|
636
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(cols['id'], [...new Set(ids)]), this.targetVisibleWhere(target, workspaceId, visibility))));
|
|
637
|
+
const byId = new Map(rows.map((row) => [row['id'], this.rowToRef(target, row)]));
|
|
638
|
+
// A ref is produced for *every* id, so a link is never silently
|
|
639
|
+
// dropped — `total` and `items` stay consistent. An id with no live row
|
|
640
|
+
// is flagged `missing` rather than passed off as a titled record: its
|
|
641
|
+
// `title` is only the raw id standing in, which the UI must not print.
|
|
642
|
+
return ids.map((id) => byId.get(id) ?? { id, title: id, missing: true });
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* The visibility predicate applied to a relation's target rows: the
|
|
646
|
+
* workspace scope, the soft-delete guard, and — only when the caller asks
|
|
647
|
+
* for it — the published-only restriction. One definition, so the row read
|
|
648
|
+
* (`refsFor`) and the windowed link reads that must *count* correctly can't
|
|
649
|
+
* disagree on what a visible target is.
|
|
650
|
+
*/
|
|
651
|
+
targetVisibleWhere(target, workspaceId, visibility) {
|
|
652
|
+
const cols = target.table;
|
|
653
|
+
return (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(cols['workspaceId'], workspaceId), cols['deletedAt'] ? (0, drizzle_orm_1.isNull)(cols['deletedAt']) : undefined, visibility?.publishedOnly && target.publishable
|
|
654
|
+
? (0, drizzle_orm_1.eq)(cols['status'], content_type_1.ENTRY_STATUS.Published)
|
|
655
|
+
: undefined);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* The id sub-select of a target's visible rows — how a link read that reads
|
|
659
|
+
* only the JOIN table (so has no target columns of its own) restricts to
|
|
660
|
+
* visible targets *inside* its window, keeping `total` honest. `undefined`
|
|
661
|
+
* when no extra restriction applies, so the predicate collapses away.
|
|
662
|
+
*/
|
|
663
|
+
visibleTargetIds(target, workspaceId, visibility) {
|
|
664
|
+
if (!visibility?.publishedOnly)
|
|
665
|
+
return undefined;
|
|
666
|
+
const cols = target.table;
|
|
667
|
+
return this.db
|
|
668
|
+
.select({ id: cols['id'] })
|
|
669
|
+
.from(target.table)
|
|
670
|
+
.where(this.targetVisibleWhere(target, workspaceId, visibility));
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* `ref IN (visible target ids)`, or `undefined` when no restriction
|
|
674
|
+
* applies. Wraps {@link visibleTargetIds} for the join-table reads, whose
|
|
675
|
+
* only handle on the target is the id column in the join row.
|
|
676
|
+
*/
|
|
677
|
+
visibleTargetIdsPredicate(refColumn, target, workspaceId, visibility) {
|
|
678
|
+
const visible = this.visibleTargetIds(target, workspaceId, visibility);
|
|
679
|
+
return visible ? (0, drizzle_orm_1.inArray)(refColumn, visible) : undefined;
|
|
680
|
+
}
|
|
681
|
+
/** Build a display ref from a full target row. */
|
|
682
|
+
rowToRef(target, row) {
|
|
683
|
+
const ref = {
|
|
684
|
+
id: row['id'],
|
|
685
|
+
title: (0, entry_row_1.entryTitle)(target, row)
|
|
686
|
+
};
|
|
687
|
+
const slug = (0, entry_row_1.entrySlug)(target, row);
|
|
688
|
+
if (slug)
|
|
689
|
+
ref.slug = slug;
|
|
690
|
+
if (target.publishable)
|
|
691
|
+
ref.status = row['status'];
|
|
692
|
+
return ref;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Rewrite a delta's translation-group ids into entry ids, picking each
|
|
696
|
+
* group's row in the **source entry's own locale**.
|
|
697
|
+
*
|
|
698
|
+
* This is the half of the cross-locale rule that makes it usable rather than
|
|
699
|
+
* merely strict. A client that thinks in stories holds one group id per
|
|
700
|
+
* story, not one entry id per language; without this it would have to keep a
|
|
701
|
+
* per-locale id map and pick the right entry for every write — and picking
|
|
702
|
+
* wrong is exactly what the rule then rejects. Here the server picks, and it
|
|
703
|
+
* is the only party that knows the source row's locale for certain.
|
|
704
|
+
*
|
|
705
|
+
* A group with no row in this locale is a **422** naming the field: the
|
|
706
|
+
* caller asked to link a story that has not been translated into this
|
|
707
|
+
* language yet, which is a real content gap rather than a bad request.
|
|
708
|
+
*/
|
|
709
|
+
async resolveLocaleGroups(runner, target, groupIds, workspaceId, field, sourceLocale) {
|
|
710
|
+
if (!target.i18n || !sourceLocale) {
|
|
711
|
+
throw new common_1.BadRequestException(`Relation "${field}" cannot be addressed by locale group — ` +
|
|
712
|
+
(target.i18n
|
|
713
|
+
? 'this entry is not localized, so there is no locale to resolve into.'
|
|
714
|
+
: `"${target.name}" is not a localized content type.`));
|
|
715
|
+
}
|
|
716
|
+
const unique = [...new Set(groupIds.filter(Boolean))];
|
|
717
|
+
if (!unique.length)
|
|
718
|
+
return new Map();
|
|
719
|
+
const cols = target.table;
|
|
720
|
+
const rows = (await runner
|
|
721
|
+
.select()
|
|
722
|
+
.from(target.table)
|
|
723
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(cols['localeGroupId'], unique), (0, drizzle_orm_1.eq)(cols['workspaceId'], workspaceId), (0, drizzle_orm_1.eq)(cols['locale'], sourceLocale), target.paranoid ? (0, drizzle_orm_1.isNull)(cols['deletedAt']) : undefined)));
|
|
724
|
+
const byGroup = new Map(rows.map((row) => [
|
|
725
|
+
row['localeGroupId'],
|
|
726
|
+
row['id']
|
|
727
|
+
]));
|
|
728
|
+
const missing = unique.filter((id) => !byGroup.has(id));
|
|
729
|
+
if (missing.length) {
|
|
730
|
+
throw new common_1.UnprocessableEntityException({
|
|
731
|
+
message: 'Entry validation failed',
|
|
732
|
+
issues: missing.map((groupId) => ({
|
|
733
|
+
field,
|
|
734
|
+
message: `no "${sourceLocale}" entry exists in translation group ` +
|
|
735
|
+
`"${groupId}" on "${target.name}" — translate that record ` +
|
|
736
|
+
'into this locale before linking it.'
|
|
737
|
+
}))
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
return byGroup;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* The **lenient** counterpart of {@link resolveLocaleGroups}: given target
|
|
744
|
+
* ids held by one row, find the equivalent id in each of `locales` — the
|
|
745
|
+
* row of the same translation group, in that language.
|
|
746
|
+
*
|
|
747
|
+
* Returns `locale → (given id → that locale's id)`. A pair with no row in a
|
|
748
|
+
* locale is simply **absent** rather than an error, which is the whole
|
|
749
|
+
* difference. `resolveLocaleGroups` answers an explicit API call naming a
|
|
750
|
+
* group, where a missing translation is a request to reject; this answers
|
|
751
|
+
* the implicit sibling sync, where it is a content gap on the *target* and
|
|
752
|
+
* failing would mean an English save could not be saved until somebody
|
|
753
|
+
* translated a tag.
|
|
754
|
+
*
|
|
755
|
+
* Two queries regardless of how many ids or locales are asked for: one to
|
|
756
|
+
* read the given ids' groups, one to read those groups' rows in the wanted
|
|
757
|
+
* locales. Soft-deleted rows are excluded from the **destination** side —
|
|
758
|
+
* required for determinism, since the `(locale_group_id, locale)` unique
|
|
759
|
+
* index is partial (`WHERE deleted_at IS NULL`) on a paranoid type, so
|
|
760
|
+
* trashed rows can legitimately repeat a pair. The source lookup is not
|
|
761
|
+
* filtered: a link to a trashed row still has a group, and dropping it
|
|
762
|
+
* would silently unlink siblings whenever a target went to the bin.
|
|
763
|
+
*/
|
|
764
|
+
async equivalentIdsByLocale(exec, target, ids, locales, workspaceId) {
|
|
765
|
+
const out = new Map();
|
|
766
|
+
for (const locale of locales)
|
|
767
|
+
out.set(locale, new Map());
|
|
768
|
+
const unique = [...new Set(ids)].filter(Boolean);
|
|
769
|
+
if (!target.i18n || !unique.length || !locales.length)
|
|
770
|
+
return out;
|
|
771
|
+
const cols = target.table;
|
|
772
|
+
const sourceRows = (await exec
|
|
773
|
+
.select()
|
|
774
|
+
.from(target.table)
|
|
775
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(cols['id'], unique), (0, drizzle_orm_1.eq)(cols['workspaceId'], workspaceId))));
|
|
776
|
+
const groupOf = new Map(sourceRows.map((row) => [
|
|
777
|
+
row['id'],
|
|
778
|
+
row['localeGroupId']
|
|
779
|
+
]));
|
|
780
|
+
const groups = [...new Set(groupOf.values())].filter(Boolean);
|
|
781
|
+
if (!groups.length)
|
|
782
|
+
return out;
|
|
783
|
+
const siblingRows = (await exec
|
|
784
|
+
.select()
|
|
785
|
+
.from(target.table)
|
|
786
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(cols['localeGroupId'], groups), (0, drizzle_orm_1.inArray)(cols['locale'], [...locales]), (0, drizzle_orm_1.eq)(cols['workspaceId'], workspaceId), target.paranoid ? (0, drizzle_orm_1.isNull)(cols['deletedAt']) : undefined)));
|
|
787
|
+
// Keyed by group AND locale, since one group contributes at most one
|
|
788
|
+
// row per language and the pair is what a sibling asks for.
|
|
789
|
+
const byGroupLocale = new Map();
|
|
790
|
+
for (const row of siblingRows) {
|
|
791
|
+
byGroupLocale.set(groupLocaleKey(row['localeGroupId'], row['locale']), row['id']);
|
|
792
|
+
}
|
|
793
|
+
for (const locale of locales) {
|
|
794
|
+
const bucket = out.get(locale);
|
|
795
|
+
for (const id of unique) {
|
|
796
|
+
const group = groupOf.get(id);
|
|
797
|
+
if (!group)
|
|
798
|
+
continue;
|
|
799
|
+
const equivalent = byGroupLocale.get(groupLocaleKey(group, locale));
|
|
800
|
+
if (equivalent)
|
|
801
|
+
bucket.set(id, equivalent);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return out;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Replace one join-backed relation field's links for `sourceId` with
|
|
808
|
+
* exactly `targetIds`, in order — the primitive the locale sibling sync
|
|
809
|
+
* writes through. Returns whether anything actually changed, so a caller
|
|
810
|
+
* can leave an already-correct sibling untouched (and unversioned).
|
|
811
|
+
*
|
|
812
|
+
* Deliberately **not** {@link applyDelta}: that is an incremental edit from
|
|
813
|
+
* a client that knows what it changed, while a sync knows only the desired
|
|
814
|
+
* end state. Deliberately not {@link writeLinks} either — that reads its ids
|
|
815
|
+
* out of a values bag and re-validates targets the caller may not have.
|
|
816
|
+
* Here the ids came from a sibling row that already passed those checks.
|
|
817
|
+
*/
|
|
818
|
+
async replaceLinks(tx, type, sourceId, field, targetIds) {
|
|
819
|
+
const spec = type.fields[field];
|
|
820
|
+
const join = spec ? this.joinPlanFor(type, field, spec) : null;
|
|
821
|
+
if (!join || join.ownCol !== 'sourceId')
|
|
822
|
+
return false;
|
|
823
|
+
const cols = join.table;
|
|
824
|
+
const own = cols[join.ownCol];
|
|
825
|
+
const ref = cols[join.refCol];
|
|
826
|
+
const desired = dedupe([...targetIds]);
|
|
827
|
+
// Compare before writing: an unchanged sibling must not be rewritten,
|
|
828
|
+
// or every save would re-version the whole translation group.
|
|
829
|
+
const currentRows = (await tx
|
|
830
|
+
.select()
|
|
831
|
+
.from(join.table)
|
|
832
|
+
.where((0, drizzle_orm_1.eq)(own, sourceId))
|
|
833
|
+
.orderBy((0, drizzle_orm_1.asc)(cols['position']), (0, drizzle_orm_1.asc)(ref)));
|
|
834
|
+
const current = currentRows.map((row) => row[join.refCol]);
|
|
835
|
+
if (current.length === desired.length &&
|
|
836
|
+
current.every((id, index) => id === desired[index])) {
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
await tx.delete(join.table).where((0, drizzle_orm_1.eq)(own, sourceId));
|
|
840
|
+
if (desired.length) {
|
|
841
|
+
await tx.insert(join.table).values(desired.map((targetId, index) => ({
|
|
842
|
+
[join.ownCol]: sourceId,
|
|
843
|
+
[join.refCol]: targetId,
|
|
844
|
+
position: index
|
|
845
|
+
})));
|
|
846
|
+
}
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* The ordered link ids of **one** join-backed relation field, read on the
|
|
851
|
+
* passed executor. {@link snapshotLinks} reads every field at once for a
|
|
852
|
+
* revision; the sync needs one field at a time, and only the ones that
|
|
853
|
+
* actually propagate.
|
|
854
|
+
*/
|
|
855
|
+
async linkIdsOf(exec, type, sourceId, field) {
|
|
856
|
+
const spec = type.fields[field];
|
|
857
|
+
const join = spec ? this.joinPlanFor(type, field, spec) : null;
|
|
858
|
+
if (!join)
|
|
859
|
+
return [];
|
|
860
|
+
const cols = join.table;
|
|
861
|
+
const rows = (await exec
|
|
862
|
+
.select()
|
|
863
|
+
.from(join.table)
|
|
864
|
+
.where((0, drizzle_orm_1.eq)(cols[join.ownCol], sourceId))
|
|
865
|
+
.orderBy((0, drizzle_orm_1.asc)(cols['position']), (0, drizzle_orm_1.asc)(cols[join.refCol])));
|
|
866
|
+
return rows.map((row) => row[join.refCol]);
|
|
867
|
+
}
|
|
868
|
+
/** {@link resolveLocaleGroups} applied to a delta's three id arrays. */
|
|
869
|
+
async resolveByLocaleGroup(tx, target, delta, workspaceId, field, sourceLocale) {
|
|
870
|
+
const byGroup = await this.resolveLocaleGroups(tx, target, [
|
|
871
|
+
...(delta.link ?? []),
|
|
872
|
+
...(delta.unlink ?? []),
|
|
873
|
+
...(delta.order ?? [])
|
|
874
|
+
], workspaceId, field, sourceLocale);
|
|
875
|
+
// `unlink` maps through the same table: a group that resolves to a row
|
|
876
|
+
// which was never linked is simply a no-op delete, as it is by id.
|
|
877
|
+
const map = (ids) => ids?.map((groupId) => byGroup.get(groupId));
|
|
878
|
+
return {
|
|
879
|
+
...(map(delta.link) ? { link: map(delta.link) } : {}),
|
|
880
|
+
...(map(delta.unlink) ? { unlink: map(delta.unlink) } : {}),
|
|
881
|
+
...(map(delta.order) ? { order: map(delta.order) } : {})
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Verify every id to be linked exists **in the same workspace** (the join FK
|
|
886
|
+
* has no workspace constraint of its own). A missing or cross-workspace id is
|
|
887
|
+
* a uniform 422 — indistinguishable from an invalid id, so no enumeration
|
|
888
|
+
* signal.
|
|
889
|
+
*
|
|
890
|
+
* When `sourceLocale` is given and the target type is localized, the link is
|
|
891
|
+
* additionally required to stay **inside one locale**. Two i18n types linked
|
|
892
|
+
* across locales is a broken model, not a preference: the English article
|
|
893
|
+
* would render the German tag, and because join links are per-row rather
|
|
894
|
+
* than synced across a translation group, nothing would ever repair it. The
|
|
895
|
+
* admin has always enforced this in its picker (which offers same-locale
|
|
896
|
+
* candidates only) — this is the same rule where it cannot be bypassed by
|
|
897
|
+
* talking to the API directly.
|
|
898
|
+
*
|
|
899
|
+
* The caller passes the **source row's own** locale, so the rule reads off
|
|
900
|
+
* the row being written rather than off a request parameter that may name a
|
|
901
|
+
* different one. `undefined` (a non-i18n owner) disables the check.
|
|
902
|
+
*/
|
|
903
|
+
async assertTargets(tx, target, ids, workspaceId, field, sourceLocale) {
|
|
904
|
+
const unique = [...new Set(ids)].filter((id) => !!id);
|
|
905
|
+
if (!unique.length)
|
|
906
|
+
return;
|
|
907
|
+
const cols = target.table;
|
|
908
|
+
// Read on the write transaction's own connection (not `this.db`, a
|
|
909
|
+
// separate pooled connection), so the existence check shares the txn's
|
|
910
|
+
// snapshot and the workspace advisory lock the write path holds.
|
|
911
|
+
const rows = (await tx
|
|
912
|
+
.select()
|
|
913
|
+
.from(target.table)
|
|
914
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(cols['id'], unique), (0, drizzle_orm_1.eq)(cols['workspaceId'], workspaceId))));
|
|
915
|
+
const present = new Set(rows.map((r) => r['id']));
|
|
916
|
+
const missing = unique.filter((id) => !present.has(id));
|
|
917
|
+
if (missing.length) {
|
|
918
|
+
throw new common_1.UnprocessableEntityException({
|
|
919
|
+
message: 'Entry validation failed',
|
|
920
|
+
issues: missing.map(() => ({
|
|
921
|
+
field,
|
|
922
|
+
message: 'must reference an existing entry'
|
|
923
|
+
}))
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
assertSameLocale(rows, target, field, sourceLocale);
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* The join-table plan for a relation field, or `null` when it owns no join
|
|
930
|
+
* table (an owning single relation, or an inverse-of-single). An owning
|
|
931
|
+
* many-relation uses its own join table (`source→target`); an inverse of a
|
|
932
|
+
* many-to-many reuses the owning side's join table with the roles swapped.
|
|
933
|
+
*/
|
|
934
|
+
joinPlanFor(type, field, spec) {
|
|
935
|
+
if (spec.type !== fields_1.CONTENT_FIELD_TYPE.Relation || !spec.relation)
|
|
936
|
+
return null;
|
|
937
|
+
const relation = spec.relation;
|
|
938
|
+
if (relation.inverse) {
|
|
939
|
+
const owner = relation.to();
|
|
940
|
+
const owningField = owner.fields[relation.inverse.field];
|
|
941
|
+
if (!owningField?.relation?.many)
|
|
942
|
+
return null; // inverse-of-single
|
|
943
|
+
return {
|
|
944
|
+
table: owner.joinTables[relation.inverse.field],
|
|
945
|
+
ownCol: 'targetId',
|
|
946
|
+
refCol: 'sourceId',
|
|
947
|
+
target: relation.to()
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
if (relation.many) {
|
|
951
|
+
return {
|
|
952
|
+
table: type.joinTables[field],
|
|
953
|
+
ownCol: 'sourceId',
|
|
954
|
+
refCol: 'targetId',
|
|
955
|
+
target: relation.to()
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* The inverse-of-single (one-to-many) plan for a relation field, or `null`:
|
|
962
|
+
* it reads the owner rows whose FK points back at the editing record, and
|
|
963
|
+
* owns no writable link from this side.
|
|
964
|
+
*/
|
|
965
|
+
inversePlanFor(spec) {
|
|
966
|
+
if (spec.type !== fields_1.CONTENT_FIELD_TYPE.Relation ||
|
|
967
|
+
!spec.relation?.inverse)
|
|
968
|
+
return null;
|
|
969
|
+
const owner = spec.relation.to();
|
|
970
|
+
const owningField = owner.fields[spec.relation.inverse.field];
|
|
971
|
+
if (owningField?.relation?.many)
|
|
972
|
+
return null; // handled as a join plan
|
|
973
|
+
return {
|
|
974
|
+
table: owner.table,
|
|
975
|
+
fkCol: spec.relation.inverse.field,
|
|
976
|
+
target: owner
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
exports.RelationLinkService = RelationLinkService;
|
|
981
|
+
exports.RelationLinkService = RelationLinkService = tslib_1.__decorate([
|
|
982
|
+
(0, common_1.Injectable)(),
|
|
983
|
+
tslib_1.__param(0, (0, database_1.InjectDatabase)()),
|
|
984
|
+
tslib_1.__metadata("design:paramtypes", [Object])
|
|
985
|
+
], RelationLinkService);
|
|
986
|
+
/**
|
|
987
|
+
* The composite map key for "this translation group, in this locale".
|
|
988
|
+
*
|
|
989
|
+
* A named helper rather than an inline template so the two call sites cannot
|
|
990
|
+
* drift apart on the separator — and so the separator is a visible character.
|
|
991
|
+
* `|` cannot appear in either half: a `locale_group_id` is a uuid, and a locale
|
|
992
|
+
* slug is `^[a-z]{2,3}(-[a-z0-9]+)*$`, so the pair is unambiguous.
|
|
993
|
+
*/
|
|
994
|
+
function groupLocaleKey(localeGroupId, locale) {
|
|
995
|
+
return `${localeGroupId}|${locale}`;
|
|
996
|
+
}
|
|
997
|
+
/** De-duplicate a list of ids, preserving order and dropping empties. */
|
|
998
|
+
function dedupe(value) {
|
|
999
|
+
const seen = new Set();
|
|
1000
|
+
const out = [];
|
|
1001
|
+
for (const item of value ?? []) {
|
|
1002
|
+
if (typeof item === 'string' && item && !seen.has(item)) {
|
|
1003
|
+
seen.add(item);
|
|
1004
|
+
out.push(item);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return out;
|
|
1008
|
+
}
|