@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.
Files changed (396) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/define.d.ts +17 -0
  4. package/dist/define.d.ts.map +1 -0
  5. package/dist/define.js +28 -0
  6. package/dist/index.d.ts +48 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +109 -0
  9. package/dist/lib/collection/define.d.ts +35 -0
  10. package/dist/lib/collection/define.d.ts.map +1 -0
  11. package/dist/lib/collection/define.js +217 -0
  12. package/dist/lib/collection/table-builder.d.ts +28 -0
  13. package/dist/lib/collection/table-builder.d.ts.map +1 -0
  14. package/dist/lib/collection/table-builder.js +306 -0
  15. package/dist/lib/content-types/controllers/get-content-schema.controller.d.ts +24 -0
  16. package/dist/lib/content-types/controllers/get-content-schema.controller.d.ts.map +1 -0
  17. package/dist/lib/content-types/controllers/get-content-schema.controller.js +58 -0
  18. package/dist/lib/content-types/controllers/get-filter-fields.controller.d.ts +35 -0
  19. package/dist/lib/content-types/controllers/get-filter-fields.controller.d.ts.map +1 -0
  20. package/dist/lib/content-types/controllers/get-filter-fields.controller.js +74 -0
  21. package/dist/lib/content-types/controllers/list-content-schema.controller.d.ts +15 -0
  22. package/dist/lib/content-types/controllers/list-content-schema.controller.d.ts.map +1 -0
  23. package/dist/lib/content-types/controllers/list-content-schema.controller.js +38 -0
  24. package/dist/lib/content-types/queries/workspace-grants.query.d.ts +25 -0
  25. package/dist/lib/content-types/queries/workspace-grants.query.d.ts.map +1 -0
  26. package/dist/lib/content-types/queries/workspace-grants.query.js +45 -0
  27. package/dist/lib/content.module.d.ts +21 -0
  28. package/dist/lib/content.module.d.ts.map +1 -0
  29. package/dist/lib/content.module.js +264 -0
  30. package/dist/lib/content.tokens.d.ts +5 -0
  31. package/dist/lib/content.tokens.d.ts.map +1 -0
  32. package/dist/lib/content.tokens.js +9 -0
  33. package/dist/lib/copilot/content-tool.provider.d.ts +64 -0
  34. package/dist/lib/copilot/content-tool.provider.d.ts.map +1 -0
  35. package/dist/lib/copilot/content-tool.provider.js +309 -0
  36. package/dist/lib/copilot/diff-snapshots.d.ts +42 -0
  37. package/dist/lib/copilot/diff-snapshots.d.ts.map +1 -0
  38. package/dist/lib/copilot/diff-snapshots.js +77 -0
  39. package/dist/lib/copilot/entry-proposal.applier.d.ts +89 -0
  40. package/dist/lib/copilot/entry-proposal.applier.d.ts.map +1 -0
  41. package/dist/lib/copilot/entry-proposal.applier.js +245 -0
  42. package/dist/lib/copilot/entry-proposal.provider.d.ts +157 -0
  43. package/dist/lib/copilot/entry-proposal.provider.d.ts.map +1 -0
  44. package/dist/lib/copilot/entry-proposal.provider.js +619 -0
  45. package/dist/lib/copilot/filter-schema.d.ts +61 -0
  46. package/dist/lib/copilot/filter-schema.d.ts.map +1 -0
  47. package/dist/lib/copilot/filter-schema.js +139 -0
  48. package/dist/lib/copilot/project-entry.d.ts +23 -0
  49. package/dist/lib/copilot/project-entry.d.ts.map +1 -0
  50. package/dist/lib/copilot/project-entry.js +36 -0
  51. package/dist/lib/copilot/proposal-kinds.d.ts +30 -0
  52. package/dist/lib/copilot/proposal-kinds.d.ts.map +1 -0
  53. package/dist/lib/copilot/proposal-kinds.js +32 -0
  54. package/dist/lib/copilot/revision-tool.provider.d.ts +56 -0
  55. package/dist/lib/copilot/revision-tool.provider.d.ts.map +1 -0
  56. package/dist/lib/copilot/revision-tool.provider.js +215 -0
  57. package/dist/lib/docs/content-schemas.d.ts +34 -0
  58. package/dist/lib/docs/content-schemas.d.ts.map +1 -0
  59. package/dist/lib/docs/content-schemas.js +617 -0
  60. package/dist/lib/docs/describe-content-api.d.ts +22 -0
  61. package/dist/lib/docs/describe-content-api.d.ts.map +1 -0
  62. package/dist/lib/docs/describe-content-api.js +173 -0
  63. package/dist/lib/docs/field-schema.d.ts +28 -0
  64. package/dist/lib/docs/field-schema.d.ts.map +1 -0
  65. package/dist/lib/docs/field-schema.js +214 -0
  66. package/dist/lib/entries/application/use-cases/bulk-publish-entries.use-case.d.ts +23 -0
  67. package/dist/lib/entries/application/use-cases/bulk-publish-entries.use-case.d.ts.map +1 -0
  68. package/dist/lib/entries/application/use-cases/bulk-publish-entries.use-case.js +77 -0
  69. package/dist/lib/entries/application/use-cases/bulk-unpublish-entries.use-case.d.ts +23 -0
  70. package/dist/lib/entries/application/use-cases/bulk-unpublish-entries.use-case.d.ts.map +1 -0
  71. package/dist/lib/entries/application/use-cases/bulk-unpublish-entries.use-case.js +66 -0
  72. package/dist/lib/entries/application/use-cases/publish-entry.use-case.d.ts +43 -0
  73. package/dist/lib/entries/application/use-cases/publish-entry.use-case.d.ts.map +1 -0
  74. package/dist/lib/entries/application/use-cases/publish-entry.use-case.js +106 -0
  75. package/dist/lib/entries/application/use-cases/unpublish-entry.use-case.d.ts +30 -0
  76. package/dist/lib/entries/application/use-cases/unpublish-entry.use-case.d.ts.map +1 -0
  77. package/dist/lib/entries/application/use-cases/unpublish-entry.use-case.js +70 -0
  78. package/dist/lib/entries/controllers/bulk-entries.controller.d.ts +26 -0
  79. package/dist/lib/entries/controllers/bulk-entries.controller.d.ts.map +1 -0
  80. package/dist/lib/entries/controllers/create-entry.controller.d.ts +20 -0
  81. package/dist/lib/entries/controllers/create-entry.controller.d.ts.map +1 -0
  82. package/dist/lib/entries/controllers/delete-entry.controller.d.ts +24 -0
  83. package/dist/lib/entries/controllers/delete-entry.controller.d.ts.map +1 -0
  84. package/dist/lib/entries/controllers/get-entry.controller.d.ts +34 -0
  85. package/dist/lib/entries/controllers/get-entry.controller.d.ts.map +1 -0
  86. package/dist/lib/entries/controllers/list-entries.controller.d.ts +20 -0
  87. package/dist/lib/entries/controllers/list-entries.controller.d.ts.map +1 -0
  88. package/dist/lib/entries/controllers/publish-entry.controller.d.ts +19 -0
  89. package/dist/lib/entries/controllers/publish-entry.controller.d.ts.map +1 -0
  90. package/dist/lib/entries/controllers/resolve-type.d.ts +9 -0
  91. package/dist/lib/entries/controllers/resolve-type.d.ts.map +1 -0
  92. package/dist/lib/entries/controllers/update-entry.controller.d.ts +18 -0
  93. package/dist/lib/entries/controllers/update-entry.controller.d.ts.map +1 -0
  94. package/dist/lib/entries/domain/entry-publish-blocked.error.d.ts +13 -0
  95. package/dist/lib/entries/domain/entry-publish-blocked.error.d.ts.map +1 -0
  96. package/dist/lib/entries/domain/entry-publish-blocked.error.js +19 -0
  97. package/dist/lib/entries/domain/entry.d.ts +71 -0
  98. package/dist/lib/entries/domain/entry.d.ts.map +1 -0
  99. package/dist/lib/entries/domain/entry.js +90 -0
  100. package/dist/lib/entries/domain/events/entry-events.d.ts +67 -0
  101. package/dist/lib/entries/domain/events/entry-events.d.ts.map +1 -0
  102. package/dist/lib/entries/domain/events/entry-events.js +100 -0
  103. package/dist/lib/entries/dto/bulk-ids.dto.d.ts +11 -0
  104. package/dist/lib/entries/dto/bulk-ids.dto.d.ts.map +1 -0
  105. package/dist/lib/entries/dto/list-entries-query.dto.d.ts +46 -0
  106. package/dist/lib/entries/dto/list-entries-query.dto.d.ts.map +1 -0
  107. package/dist/lib/entries/dto/relation-delta-map.validator.d.ts +19 -0
  108. package/dist/lib/entries/dto/relation-delta-map.validator.d.ts.map +1 -0
  109. package/dist/lib/entries/dto/save-entry.dto.d.ts +46 -0
  110. package/dist/lib/entries/dto/save-entry.dto.d.ts.map +1 -0
  111. package/dist/lib/entries/entries.constants.d.ts +19 -0
  112. package/dist/lib/entries/entries.constants.d.ts.map +1 -0
  113. package/dist/lib/entries/entries.constants.js +21 -0
  114. package/dist/lib/entries/http/controllers/bulk-entries.controller.d.ts +33 -0
  115. package/dist/lib/entries/http/controllers/bulk-entries.controller.d.ts.map +1 -0
  116. package/dist/lib/entries/http/controllers/bulk-entries.controller.js +143 -0
  117. package/dist/lib/entries/http/controllers/create-entry.controller.d.ts +21 -0
  118. package/dist/lib/entries/http/controllers/create-entry.controller.d.ts.map +1 -0
  119. package/dist/lib/entries/http/controllers/create-entry.controller.js +52 -0
  120. package/dist/lib/entries/http/controllers/delete-entry.controller.d.ts +25 -0
  121. package/dist/lib/entries/http/controllers/delete-entry.controller.d.ts.map +1 -0
  122. package/dist/lib/entries/http/controllers/delete-entry.controller.js +84 -0
  123. package/dist/lib/entries/http/controllers/get-entry.controller.d.ts +44 -0
  124. package/dist/lib/entries/http/controllers/get-entry.controller.d.ts.map +1 -0
  125. package/dist/lib/entries/http/controllers/get-entry.controller.js +124 -0
  126. package/dist/lib/entries/http/controllers/list-entries.controller.d.ts +20 -0
  127. package/dist/lib/entries/http/controllers/list-entries.controller.d.ts.map +1 -0
  128. package/dist/lib/entries/http/controllers/list-entries.controller.js +52 -0
  129. package/dist/lib/entries/http/controllers/publish-entry.controller.d.ts +22 -0
  130. package/dist/lib/entries/http/controllers/publish-entry.controller.d.ts.map +1 -0
  131. package/dist/lib/entries/http/controllers/publish-entry.controller.js +68 -0
  132. package/dist/lib/entries/http/controllers/resolve-type.d.ts +9 -0
  133. package/dist/lib/entries/http/controllers/resolve-type.d.ts.map +1 -0
  134. package/dist/lib/entries/http/controllers/resolve-type.js +16 -0
  135. package/dist/lib/entries/http/controllers/to-actor.d.ts +13 -0
  136. package/dist/lib/entries/http/controllers/to-actor.d.ts.map +1 -0
  137. package/dist/lib/entries/http/controllers/to-actor.js +15 -0
  138. package/dist/lib/entries/http/controllers/update-entry.controller.d.ts +19 -0
  139. package/dist/lib/entries/http/controllers/update-entry.controller.d.ts.map +1 -0
  140. package/dist/lib/entries/http/controllers/update-entry.controller.js +51 -0
  141. package/dist/lib/entries/http/dto/bulk-ids.dto.d.ts +11 -0
  142. package/dist/lib/entries/http/dto/bulk-ids.dto.d.ts.map +1 -0
  143. package/dist/lib/entries/http/dto/bulk-ids.dto.js +33 -0
  144. package/dist/lib/entries/http/dto/list-entries-query.dto.d.ts +66 -0
  145. package/dist/lib/entries/http/dto/list-entries-query.dto.d.ts.map +1 -0
  146. package/dist/lib/entries/http/dto/list-entries-query.dto.js +192 -0
  147. package/dist/lib/entries/http/dto/relation-delta-map.validator.d.ts +30 -0
  148. package/dist/lib/entries/http/dto/relation-delta-map.validator.d.ts.map +1 -0
  149. package/dist/lib/entries/http/dto/relation-delta-map.validator.js +102 -0
  150. package/dist/lib/entries/http/dto/save-entry.dto.d.ts +48 -0
  151. package/dist/lib/entries/http/dto/save-entry.dto.d.ts.map +1 -0
  152. package/dist/lib/entries/http/dto/save-entry.dto.js +120 -0
  153. package/dist/lib/entries/http/guards/content-grant.guard.d.ts +37 -0
  154. package/dist/lib/entries/http/guards/content-grant.guard.d.ts.map +1 -0
  155. package/dist/lib/entries/http/guards/content-grant.guard.js +67 -0
  156. package/dist/lib/entries/infrastructure/persistence/bulk-publish-verdicts.d.ts +22 -0
  157. package/dist/lib/entries/infrastructure/persistence/bulk-publish-verdicts.d.ts.map +1 -0
  158. package/dist/lib/entries/infrastructure/persistence/bulk-publish-verdicts.js +81 -0
  159. package/dist/lib/entries/infrastructure/persistence/entry-counter.service.d.ts +42 -0
  160. package/dist/lib/entries/infrastructure/persistence/entry-counter.service.d.ts.map +1 -0
  161. package/dist/lib/entries/infrastructure/persistence/entry-counter.service.js +73 -0
  162. package/dist/lib/entries/infrastructure/persistence/entry-row.d.ts +56 -0
  163. package/dist/lib/entries/infrastructure/persistence/entry-row.d.ts.map +1 -0
  164. package/dist/lib/entries/infrastructure/persistence/entry-row.js +185 -0
  165. package/dist/lib/entries/infrastructure/persistence/entry-writer.service.d.ts +392 -0
  166. package/dist/lib/entries/infrastructure/persistence/entry-writer.service.d.ts.map +1 -0
  167. package/dist/lib/entries/infrastructure/persistence/entry-writer.service.js +1223 -0
  168. package/dist/lib/entries/infrastructure/persistence/field-selection.d.ts +24 -0
  169. package/dist/lib/entries/infrastructure/persistence/field-selection.d.ts.map +1 -0
  170. package/dist/lib/entries/infrastructure/persistence/media-accept.d.ts +23 -0
  171. package/dist/lib/entries/infrastructure/persistence/media-accept.d.ts.map +1 -0
  172. package/dist/lib/entries/infrastructure/persistence/media-accept.js +55 -0
  173. package/dist/lib/entries/infrastructure/persistence/relation-link.service.d.ts +327 -0
  174. package/dist/lib/entries/infrastructure/persistence/relation-link.service.d.ts.map +1 -0
  175. package/dist/lib/entries/infrastructure/persistence/relation-link.service.js +1008 -0
  176. package/dist/lib/entries/infrastructure/queries/bulk-publish-preview.query.d.ts +19 -0
  177. package/dist/lib/entries/infrastructure/queries/bulk-publish-preview.query.d.ts.map +1 -0
  178. package/dist/lib/entries/infrastructure/queries/bulk-publish-preview.query.js +39 -0
  179. package/dist/lib/entries/infrastructure/queries/entries.service.d.ts +64 -0
  180. package/dist/lib/entries/infrastructure/queries/entries.service.d.ts.map +1 -0
  181. package/dist/lib/entries/infrastructure/queries/entries.service.js +202 -0
  182. package/dist/lib/entries/infrastructure/queries/entry-filter-schema.d.ts +40 -0
  183. package/dist/lib/entries/infrastructure/queries/entry-filter-schema.d.ts.map +1 -0
  184. package/dist/lib/entries/infrastructure/queries/entry-filter-surface.d.ts +38 -0
  185. package/dist/lib/entries/infrastructure/queries/entry-filter-surface.d.ts.map +1 -0
  186. package/dist/lib/entries/infrastructure/queries/entry-filter-surface.js +310 -0
  187. package/dist/lib/entries/infrastructure/queries/entry-scalar-fields.d.ts +25 -0
  188. package/dist/lib/entries/infrastructure/queries/entry-scalar-fields.d.ts.map +1 -0
  189. package/dist/lib/entries/infrastructure/queries/entry-scalar-fields.js +51 -0
  190. package/dist/lib/entries/infrastructure/queries/entry-search.d.ts +19 -0
  191. package/dist/lib/entries/infrastructure/queries/entry-search.d.ts.map +1 -0
  192. package/dist/lib/entries/infrastructure/queries/entry-search.js +52 -0
  193. package/dist/lib/entries/infrastructure/queries/media-refs.query.d.ts +21 -0
  194. package/dist/lib/entries/infrastructure/queries/media-refs.query.d.ts.map +1 -0
  195. package/dist/lib/entries/infrastructure/queries/media-refs.query.js +117 -0
  196. package/dist/lib/entries/services/entries.service.d.ts +59 -0
  197. package/dist/lib/entries/services/entries.service.d.ts.map +1 -0
  198. package/dist/lib/entries/services/entry-counter.service.d.ts +31 -0
  199. package/dist/lib/entries/services/entry-counter.service.d.ts.map +1 -0
  200. package/dist/lib/entries/services/entry-filter-schema.d.ts +32 -0
  201. package/dist/lib/entries/services/entry-filter-schema.d.ts.map +1 -0
  202. package/dist/lib/entries/services/entry-row.d.ts +56 -0
  203. package/dist/lib/entries/services/entry-row.d.ts.map +1 -0
  204. package/dist/lib/entries/services/entry-writer.service.d.ts +193 -0
  205. package/dist/lib/entries/services/entry-writer.service.d.ts.map +1 -0
  206. package/dist/lib/entries/services/relation-link.service.d.ts +115 -0
  207. package/dist/lib/entries/services/relation-link.service.d.ts.map +1 -0
  208. package/dist/lib/entries/types/bulk-publish.d.ts +75 -0
  209. package/dist/lib/entries/types/bulk-publish.d.ts.map +1 -0
  210. package/dist/lib/entries/types/bulk-publish.js +22 -0
  211. package/dist/lib/entries/types/entry-list-view.d.ts +205 -0
  212. package/dist/lib/entries/types/entry-list-view.d.ts.map +1 -0
  213. package/dist/lib/entries/types/entry-list-view.js +5 -0
  214. package/dist/lib/entries/types/filter-surface.d.ts +35 -0
  215. package/dist/lib/entries/types/filter-surface.d.ts.map +1 -0
  216. package/dist/lib/entries/types/filter-surface.js +2 -0
  217. package/dist/lib/extension/entry-extension-boot-check.d.ts +16 -0
  218. package/dist/lib/extension/entry-extension-boot-check.d.ts.map +1 -0
  219. package/dist/lib/extension/entry-extension-boot-check.js +42 -0
  220. package/dist/lib/extension/entry-extension.d.ts +225 -0
  221. package/dist/lib/extension/entry-extension.d.ts.map +1 -0
  222. package/dist/lib/extension/entry-extension.js +27 -0
  223. package/dist/lib/extension/media-asset-resolver.d.ts +85 -0
  224. package/dist/lib/extension/media-asset-resolver.d.ts.map +1 -0
  225. package/dist/lib/extension/media-asset-resolver.js +26 -0
  226. package/dist/lib/extension/per-locale-relation.d.ts +18 -0
  227. package/dist/lib/extension/per-locale-relation.d.ts.map +1 -0
  228. package/dist/lib/extension/relation-locale-sync.d.ts +62 -0
  229. package/dist/lib/extension/relation-locale-sync.d.ts.map +1 -0
  230. package/dist/lib/extension/relation-locale-sync.js +88 -0
  231. package/dist/lib/fields/index.d.ts +94 -0
  232. package/dist/lib/fields/index.d.ts.map +1 -0
  233. package/dist/lib/fields/index.js +203 -0
  234. package/dist/lib/insights/http/controllers/content-pipeline.controller.d.ts +16 -0
  235. package/dist/lib/insights/http/controllers/content-pipeline.controller.d.ts.map +1 -0
  236. package/dist/lib/insights/http/controllers/content-pipeline.controller.js +39 -0
  237. package/dist/lib/insights/http/controllers/content-punchcard.controller.d.ts +18 -0
  238. package/dist/lib/insights/http/controllers/content-punchcard.controller.d.ts.map +1 -0
  239. package/dist/lib/insights/http/controllers/content-punchcard.controller.js +42 -0
  240. package/dist/lib/insights/http/controllers/content-stale.controller.d.ts +17 -0
  241. package/dist/lib/insights/http/controllers/content-stale.controller.d.ts.map +1 -0
  242. package/dist/lib/insights/http/controllers/content-stale.controller.js +40 -0
  243. package/dist/lib/insights/http/controllers/content-totals.controller.d.ts +18 -0
  244. package/dist/lib/insights/http/controllers/content-totals.controller.d.ts.map +1 -0
  245. package/dist/lib/insights/http/controllers/content-totals.controller.js +42 -0
  246. package/dist/lib/insights/http/controllers/content-unshipped.controller.d.ts +17 -0
  247. package/dist/lib/insights/http/controllers/content-unshipped.controller.d.ts.map +1 -0
  248. package/dist/lib/insights/http/controllers/content-unshipped.controller.js +40 -0
  249. package/dist/lib/insights/http/controllers/content-velocity.controller.d.ts +17 -0
  250. package/dist/lib/insights/http/controllers/content-velocity.controller.d.ts.map +1 -0
  251. package/dist/lib/insights/http/controllers/content-velocity.controller.js +41 -0
  252. package/dist/lib/insights/http/dto/insights-range-query.dto.d.ts +15 -0
  253. package/dist/lib/insights/http/dto/insights-range-query.dto.d.ts.map +1 -0
  254. package/dist/lib/insights/http/dto/insights-range-query.dto.js +38 -0
  255. package/dist/lib/insights/infrastructure/queries/content-insights.query.d.ts +77 -0
  256. package/dist/lib/insights/infrastructure/queries/content-insights.query.d.ts.map +1 -0
  257. package/dist/lib/insights/infrastructure/queries/content-insights.query.js +380 -0
  258. package/dist/lib/insights/types/content-insights-view.d.ts +134 -0
  259. package/dist/lib/insights/types/content-insights-view.d.ts.map +1 -0
  260. package/dist/lib/insights/types/content-insights-view.js +10 -0
  261. package/dist/lib/mcp/content-tools.provider.d.ts +83 -0
  262. package/dist/lib/mcp/content-tools.provider.d.ts.map +1 -0
  263. package/dist/lib/mcp/content-tools.provider.js +441 -0
  264. package/dist/lib/mcp/tool-input.d.ts +43 -0
  265. package/dist/lib/mcp/tool-input.d.ts.map +1 -0
  266. package/dist/lib/mcp/tool-input.js +117 -0
  267. package/dist/lib/mcp/tool-schemas.d.ts +34 -0
  268. package/dist/lib/mcp/tool-schemas.d.ts.map +1 -0
  269. package/dist/lib/mcp/tool-schemas.js +282 -0
  270. package/dist/lib/public/controllers/get-public-entry.controller.d.ts +26 -0
  271. package/dist/lib/public/controllers/get-public-entry.controller.d.ts.map +1 -0
  272. package/dist/lib/public/controllers/list-public-entries.controller.d.ts +24 -0
  273. package/dist/lib/public/controllers/list-public-entries.controller.d.ts.map +1 -0
  274. package/dist/lib/public/controllers/public-schema.controller.d.ts +18 -0
  275. package/dist/lib/public/controllers/public-schema.controller.d.ts.map +1 -0
  276. package/dist/lib/public-api/http/api-token-request.d.ts +41 -0
  277. package/dist/lib/public-api/http/api-token-request.d.ts.map +1 -0
  278. package/dist/lib/public-api/http/api-token-request.js +2 -0
  279. package/dist/lib/public-api/http/controllers/public-content-types.controller.d.ts +31 -0
  280. package/dist/lib/public-api/http/controllers/public-content-types.controller.d.ts.map +1 -0
  281. package/dist/lib/public-api/http/controllers/public-content-types.controller.js +93 -0
  282. package/dist/lib/public-api/http/controllers/public-entries.controller.d.ts +101 -0
  283. package/dist/lib/public-api/http/controllers/public-entries.controller.d.ts.map +1 -0
  284. package/dist/lib/public-api/http/controllers/public-entries.controller.js +307 -0
  285. package/dist/lib/public-api/http/controllers/public-entry-writes.controller.d.ts +76 -0
  286. package/dist/lib/public-api/http/controllers/public-entry-writes.controller.d.ts.map +1 -0
  287. package/dist/lib/public-api/http/controllers/public-entry-writes.controller.js +353 -0
  288. package/dist/lib/public-api/http/controllers/resolve-granted-type.d.ts +42 -0
  289. package/dist/lib/public-api/http/controllers/resolve-granted-type.d.ts.map +1 -0
  290. package/dist/lib/public-api/http/controllers/resolve-granted-type.js +25 -0
  291. package/dist/lib/public-api/http/decorators/current-api-token.decorator.d.ts +8 -0
  292. package/dist/lib/public-api/http/decorators/current-api-token.decorator.d.ts.map +1 -0
  293. package/dist/lib/public-api/http/decorators/current-api-token.decorator.js +17 -0
  294. package/dist/lib/public-api/http/dto/public-bulk.dto.d.ts +58 -0
  295. package/dist/lib/public-api/http/dto/public-bulk.dto.d.ts.map +1 -0
  296. package/dist/lib/public-api/http/dto/public-bulk.dto.js +118 -0
  297. package/dist/lib/public-api/http/dto/public-list-entries-query.dto.d.ts +106 -0
  298. package/dist/lib/public-api/http/dto/public-list-entries-query.dto.d.ts.map +1 -0
  299. package/dist/lib/public-api/http/dto/public-list-entries-query.dto.js +297 -0
  300. package/dist/lib/public-api/http/dto/public-save-entry.dto.d.ts +39 -0
  301. package/dist/lib/public-api/http/dto/public-save-entry.dto.d.ts.map +1 -0
  302. package/dist/lib/public-api/http/dto/public-save-entry.dto.js +112 -0
  303. package/dist/lib/public-api/http/guards/api-token-workspace.guard.d.ts +25 -0
  304. package/dist/lib/public-api/http/guards/api-token-workspace.guard.d.ts.map +1 -0
  305. package/dist/lib/public-api/http/guards/api-token-workspace.guard.js +58 -0
  306. package/dist/lib/public-api/http/guards/api-token.guard.d.ts +32 -0
  307. package/dist/lib/public-api/http/guards/api-token.guard.d.ts.map +1 -0
  308. package/dist/lib/public-api/http/guards/api-token.guard.js +92 -0
  309. package/dist/lib/public-api/http/guards/draft-visibility.guard.d.ts +25 -0
  310. package/dist/lib/public-api/http/guards/draft-visibility.guard.d.ts.map +1 -0
  311. package/dist/lib/public-api/http/guards/draft-visibility.guard.js +56 -0
  312. package/dist/lib/public-api/infrastructure/bulk-save-op.d.ts +55 -0
  313. package/dist/lib/public-api/infrastructure/bulk-save-op.d.ts.map +1 -0
  314. package/dist/lib/public-api/infrastructure/bulk-save-op.js +61 -0
  315. package/dist/lib/public-api/infrastructure/field-selection.d.ts +21 -0
  316. package/dist/lib/public-api/infrastructure/field-selection.d.ts.map +1 -0
  317. package/dist/lib/public-api/infrastructure/field-selection.js +72 -0
  318. package/dist/lib/public-api/infrastructure/public-entries.query.d.ts +248 -0
  319. package/dist/lib/public-api/infrastructure/public-entries.query.d.ts.map +1 -0
  320. package/dist/lib/public-api/infrastructure/public-entries.query.js +557 -0
  321. package/dist/lib/public-api/infrastructure/public-entry-row.d.ts +31 -0
  322. package/dist/lib/public-api/infrastructure/public-entry-row.d.ts.map +1 -0
  323. package/dist/lib/public-api/infrastructure/public-entry-row.js +76 -0
  324. package/dist/lib/public-api/infrastructure/public-entry-writes.service.d.ts +188 -0
  325. package/dist/lib/public-api/infrastructure/public-entry-writes.service.d.ts.map +1 -0
  326. package/dist/lib/public-api/infrastructure/public-entry-writes.service.js +321 -0
  327. package/dist/lib/public-api/infrastructure/public-expansion.query.d.ts +98 -0
  328. package/dist/lib/public-api/infrastructure/public-expansion.query.d.ts.map +1 -0
  329. package/dist/lib/public-api/infrastructure/public-expansion.query.js +384 -0
  330. package/dist/lib/public-api/types/public-bulk.d.ts +77 -0
  331. package/dist/lib/public-api/types/public-bulk.d.ts.map +1 -0
  332. package/dist/lib/public-api/types/public-bulk.js +23 -0
  333. package/dist/lib/public-api/types/public-entry.d.ts +97 -0
  334. package/dist/lib/public-api/types/public-entry.d.ts.map +1 -0
  335. package/dist/lib/public-api/types/public-entry.js +8 -0
  336. package/dist/lib/public-api/types/public-expansion.d.ts +92 -0
  337. package/dist/lib/public-api/types/public-expansion.d.ts.map +1 -0
  338. package/dist/lib/public-api/types/public-expansion.js +2 -0
  339. package/dist/lib/registry/content-type-registry.d.ts +103 -0
  340. package/dist/lib/registry/content-type-registry.d.ts.map +1 -0
  341. package/dist/lib/registry/content-type-registry.js +155 -0
  342. package/dist/lib/revisions/application/ports/revision-store.d.ts +71 -0
  343. package/dist/lib/revisions/application/ports/revision-store.d.ts.map +1 -0
  344. package/dist/lib/revisions/application/ports/revision-store.js +9 -0
  345. package/dist/lib/revisions/application/use-cases/publish-revision.use-case.d.ts +40 -0
  346. package/dist/lib/revisions/application/use-cases/publish-revision.use-case.d.ts.map +1 -0
  347. package/dist/lib/revisions/application/use-cases/publish-revision.use-case.js +66 -0
  348. package/dist/lib/revisions/application/use-cases/restore-revision.use-case.d.ts +36 -0
  349. package/dist/lib/revisions/application/use-cases/restore-revision.use-case.d.ts.map +1 -0
  350. package/dist/lib/revisions/application/use-cases/restore-revision.use-case.js +57 -0
  351. package/dist/lib/revisions/domain/revision-status.d.ts +18 -0
  352. package/dist/lib/revisions/domain/revision-status.d.ts.map +1 -0
  353. package/dist/lib/revisions/domain/revision-status.js +18 -0
  354. package/dist/lib/revisions/domain/revision.d.ts +80 -0
  355. package/dist/lib/revisions/domain/revision.d.ts.map +1 -0
  356. package/dist/lib/revisions/domain/revision.js +84 -0
  357. package/dist/lib/revisions/http/controllers/publish-revision.controller.d.ts +19 -0
  358. package/dist/lib/revisions/http/controllers/publish-revision.controller.d.ts.map +1 -0
  359. package/dist/lib/revisions/http/controllers/publish-revision.controller.js +51 -0
  360. package/dist/lib/revisions/http/controllers/restore-revision.controller.d.ts +17 -0
  361. package/dist/lib/revisions/http/controllers/restore-revision.controller.d.ts.map +1 -0
  362. package/dist/lib/revisions/http/controllers/restore-revision.controller.js +49 -0
  363. package/dist/lib/revisions/http/controllers/revisions.controller.d.ts +20 -0
  364. package/dist/lib/revisions/http/controllers/revisions.controller.d.ts.map +1 -0
  365. package/dist/lib/revisions/http/controllers/revisions.controller.js +76 -0
  366. package/dist/lib/revisions/infrastructure/persistence/drizzle-revision.store.d.ts +33 -0
  367. package/dist/lib/revisions/infrastructure/persistence/drizzle-revision.store.d.ts.map +1 -0
  368. package/dist/lib/revisions/infrastructure/persistence/drizzle-revision.store.js +174 -0
  369. package/dist/lib/revisions/infrastructure/persistence/revision-snapshot.d.ts +17 -0
  370. package/dist/lib/revisions/infrastructure/persistence/revision-snapshot.d.ts.map +1 -0
  371. package/dist/lib/revisions/infrastructure/persistence/revision-snapshot.js +20 -0
  372. package/dist/lib/revisions/infrastructure/persistence/revision-table.d.ts +228 -0
  373. package/dist/lib/revisions/infrastructure/persistence/revision-table.d.ts.map +1 -0
  374. package/dist/lib/revisions/infrastructure/persistence/revision-table.js +57 -0
  375. package/dist/lib/revisions/infrastructure/queries/revision-refs.query.d.ts +22 -0
  376. package/dist/lib/revisions/infrastructure/queries/revision-refs.query.d.ts.map +1 -0
  377. package/dist/lib/revisions/infrastructure/queries/revision-refs.query.js +66 -0
  378. package/dist/lib/revisions/revisions.constants.d.ts +10 -0
  379. package/dist/lib/revisions/revisions.constants.d.ts.map +1 -0
  380. package/dist/lib/revisions/revisions.constants.js +12 -0
  381. package/dist/lib/revisions/types/revision-view.d.ts +74 -0
  382. package/dist/lib/revisions/types/revision-view.d.ts.map +1 -0
  383. package/dist/lib/revisions/types/revision-view.js +2 -0
  384. package/dist/lib/types/content-type.d.ts +131 -0
  385. package/dist/lib/types/content-type.d.ts.map +1 -0
  386. package/dist/lib/types/content-type.js +28 -0
  387. package/dist/lib/types/fields.d.ts +303 -0
  388. package/dist/lib/types/fields.d.ts.map +1 -0
  389. package/dist/lib/types/fields.js +52 -0
  390. package/dist/lib/utils/content-plugin.d.ts +35 -0
  391. package/dist/lib/utils/content-plugin.d.ts.map +1 -0
  392. package/dist/lib/utils/content-plugin.js +35 -0
  393. package/dist/lib/validation/services/entry-validation.service.d.ts +24 -0
  394. package/dist/lib/validation/services/entry-validation.service.d.ts.map +1 -0
  395. package/dist/lib/validation/services/entry-validation.service.js +35 -0
  396. 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);