@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,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
+ }