emdash 0.33.0 → 0.35.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 (916) hide show
  1. package/dist/{adapters-BMyNSSZi.d.mts → adapters-Bb1vG41D.d.mts} +33 -2
  2. package/dist/adapters-Bb1vG41D.d.mts.map +1 -0
  3. package/dist/{allowed-origins-CG1pACtS.mjs → allowed-origins-DW7sYyqg.mjs} +2 -2
  4. package/dist/{allowed-origins-CG1pACtS.mjs.map → allowed-origins-DW7sYyqg.mjs.map} +1 -1
  5. package/dist/api/route-utils.d.mts +3 -3
  6. package/dist/api/route-utils.mjs +22 -21
  7. package/dist/api/route-utils.mjs.map +1 -1
  8. package/dist/api/schemas/index.d.mts +2 -2
  9. package/dist/api/schemas/index.mjs +6 -5
  10. package/dist/{api-D2hunekQ.mjs → api-BeBglVxU.mjs} +98 -407
  11. package/dist/api-BeBglVxU.mjs.map +1 -0
  12. package/dist/{api-tokens-CSND0i1z.mjs → api-tokens-KO24G1Fd.mjs} +2 -2
  13. package/dist/{api-tokens-CSND0i1z.mjs.map → api-tokens-KO24G1Fd.mjs.map} +1 -1
  14. package/dist/{apply-kwNEJW2l.mjs → apply-kUDt80-G.mjs} +131 -107
  15. package/dist/apply-kUDt80-G.mjs.map +1 -0
  16. package/dist/astro/index.d.mts +12 -12
  17. package/dist/astro/index.d.mts.map +1 -1
  18. package/dist/astro/index.mjs +163 -49
  19. package/dist/astro/index.mjs.map +1 -1
  20. package/dist/astro/middleware/auth.d.mts +10 -10
  21. package/dist/astro/middleware/auth.mjs +8 -8
  22. package/dist/astro/middleware/media-usage-write-fence.d.mts +7 -0
  23. package/dist/astro/middleware/media-usage-write-fence.d.mts.map +1 -0
  24. package/dist/astro/middleware/media-usage-write-fence.mjs +35 -0
  25. package/dist/astro/middleware/media-usage-write-fence.mjs.map +1 -0
  26. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  27. package/dist/astro/middleware/redirect.mjs +10 -7
  28. package/dist/astro/middleware/redirect.mjs.map +1 -1
  29. package/dist/astro/middleware/request-context.mjs +3 -3
  30. package/dist/astro/middleware/setup.mjs +1 -1
  31. package/dist/astro/middleware.d.mts +12 -11
  32. package/dist/astro/middleware.d.mts.map +1 -1
  33. package/dist/astro/middleware.mjs +1763 -173
  34. package/dist/astro/middleware.mjs.map +1 -1
  35. package/dist/astro/routes/api/admin/allowed-domains/_domain_.mjs +9 -8
  36. package/dist/astro/routes/api/admin/allowed-domains/_domain_.mjs.map +1 -1
  37. package/dist/astro/routes/api/admin/allowed-domains/index.mjs +9 -8
  38. package/dist/astro/routes/api/admin/allowed-domains/index.mjs.map +1 -1
  39. package/dist/astro/routes/api/admin/api-tokens/_id_.mjs +5 -5
  40. package/dist/astro/routes/api/admin/api-tokens/index.mjs +6 -6
  41. package/dist/astro/routes/api/admin/byline-fields/_slug_/usage.mjs +6 -6
  42. package/dist/astro/routes/api/admin/byline-fields/_slug_.mjs +12 -11
  43. package/dist/astro/routes/api/admin/byline-fields/_slug_.mjs.map +1 -1
  44. package/dist/astro/routes/api/admin/byline-fields/index.mjs +12 -11
  45. package/dist/astro/routes/api/admin/byline-fields/index.mjs.map +1 -1
  46. package/dist/astro/routes/api/admin/byline-fields/reorder.mjs +12 -11
  47. package/dist/astro/routes/api/admin/byline-fields/reorder.mjs.map +1 -1
  48. package/dist/astro/routes/api/admin/bylines/_id_/index.mjs +17 -17
  49. package/dist/astro/routes/api/admin/bylines/_id_/translations.mjs +17 -17
  50. package/dist/astro/routes/api/admin/bylines/index.mjs +17 -17
  51. package/dist/astro/routes/api/admin/comments/_id_/status.mjs +14 -13
  52. package/dist/astro/routes/api/admin/comments/_id_/status.mjs.map +1 -1
  53. package/dist/astro/routes/api/admin/comments/_id_.mjs +6 -6
  54. package/dist/astro/routes/api/admin/comments/bulk.mjs +12 -11
  55. package/dist/astro/routes/api/admin/comments/bulk.mjs.map +1 -1
  56. package/dist/astro/routes/api/admin/comments/counts.mjs +6 -6
  57. package/dist/astro/routes/api/admin/comments/index.mjs +12 -11
  58. package/dist/astro/routes/api/admin/comments/index.mjs.map +1 -1
  59. package/dist/astro/routes/api/admin/hooks/exclusive/_hookName_.mjs +5 -5
  60. package/dist/astro/routes/api/admin/hooks/exclusive/index.mjs +4 -4
  61. package/dist/astro/routes/api/admin/media-usage/collection-deletions/index.d.mts +8 -0
  62. package/dist/astro/routes/api/admin/media-usage/collection-deletions/index.d.mts.map +1 -0
  63. package/dist/astro/routes/api/admin/media-usage/collection-deletions/index.mjs +36 -0
  64. package/dist/astro/routes/api/admin/media-usage/collection-deletions/index.mjs.map +1 -0
  65. package/dist/astro/routes/api/admin/media-usage/collection-deletions/retry.d.mts +8 -0
  66. package/dist/astro/routes/api/admin/media-usage/collection-deletions/retry.d.mts.map +1 -0
  67. package/dist/astro/routes/api/admin/media-usage/collection-deletions/retry.mjs +36 -0
  68. package/dist/astro/routes/api/admin/media-usage/collection-deletions/retry.mjs.map +1 -0
  69. package/dist/astro/routes/api/admin/media-usage/repair.mjs +15 -14
  70. package/dist/astro/routes/api/admin/media-usage/repair.mjs.map +1 -1
  71. package/dist/astro/routes/api/admin/media-usage/work/index.d.mts +8 -0
  72. package/dist/astro/routes/api/admin/media-usage/work/index.d.mts.map +1 -0
  73. package/dist/astro/routes/api/admin/media-usage/work/index.mjs +37 -0
  74. package/dist/astro/routes/api/admin/media-usage/work/index.mjs.map +1 -0
  75. package/dist/astro/routes/api/admin/media-usage/work/retry.d.mts +8 -0
  76. package/dist/astro/routes/api/admin/media-usage/work/retry.d.mts.map +1 -0
  77. package/dist/astro/routes/api/admin/media-usage/work/retry.mjs +37 -0
  78. package/dist/astro/routes/api/admin/media-usage/work/retry.mjs.map +1 -0
  79. package/dist/astro/routes/api/admin/oauth-clients/_id_.mjs +5 -5
  80. package/dist/astro/routes/api/admin/oauth-clients/index.mjs +5 -5
  81. package/dist/astro/routes/api/admin/plugins/_id_/disable.d.mts.map +1 -1
  82. package/dist/astro/routes/api/admin/plugins/_id_/disable.mjs +51 -43
  83. package/dist/astro/routes/api/admin/plugins/_id_/disable.mjs.map +1 -1
  84. package/dist/astro/routes/api/admin/plugins/_id_/enable.d.mts.map +1 -1
  85. package/dist/astro/routes/api/admin/plugins/_id_/enable.mjs +51 -43
  86. package/dist/astro/routes/api/admin/plugins/_id_/enable.mjs.map +1 -1
  87. package/dist/astro/routes/api/admin/plugins/_id_/index.mjs +47 -42
  88. package/dist/astro/routes/api/admin/plugins/_id_/index.mjs.map +1 -1
  89. package/dist/astro/routes/api/admin/plugins/_id_/mcp.mjs +6 -6
  90. package/dist/astro/routes/api/admin/plugins/_id_/settings.mjs +47 -42
  91. package/dist/astro/routes/api/admin/plugins/_id_/settings.mjs.map +1 -1
  92. package/dist/astro/routes/api/admin/plugins/_id_/uninstall.d.mts.map +1 -1
  93. package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs +50 -42
  94. package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs.map +1 -1
  95. package/dist/astro/routes/api/admin/plugins/_id_/update.d.mts.map +1 -1
  96. package/dist/astro/routes/api/admin/plugins/_id_/update.mjs +50 -42
  97. package/dist/astro/routes/api/admin/plugins/_id_/update.mjs.map +1 -1
  98. package/dist/astro/routes/api/admin/plugins/index.mjs +47 -42
  99. package/dist/astro/routes/api/admin/plugins/index.mjs.map +1 -1
  100. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/icon.mjs +4 -4
  101. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs +47 -42
  102. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs.map +1 -1
  103. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.d.mts.map +1 -1
  104. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs +50 -42
  105. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs.map +1 -1
  106. package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs +47 -42
  107. package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs.map +1 -1
  108. package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.d.mts.map +1 -1
  109. package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs +50 -42
  110. package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs.map +1 -1
  111. package/dist/astro/routes/api/admin/plugins/registry/_id_/update.d.mts.map +1 -1
  112. package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs +51 -43
  113. package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs.map +1 -1
  114. package/dist/astro/routes/api/admin/plugins/registry/artifact.mjs +47 -42
  115. package/dist/astro/routes/api/admin/plugins/registry/artifact.mjs.map +1 -1
  116. package/dist/astro/routes/api/admin/plugins/registry/install.d.mts.map +1 -1
  117. package/dist/astro/routes/api/admin/plugins/registry/install.mjs +51 -43
  118. package/dist/astro/routes/api/admin/plugins/registry/install.mjs.map +1 -1
  119. package/dist/astro/routes/api/admin/plugins/updates.mjs +47 -42
  120. package/dist/astro/routes/api/admin/plugins/updates.mjs.map +1 -1
  121. package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs +47 -42
  122. package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs.map +1 -1
  123. package/dist/astro/routes/api/admin/themes/marketplace/_id_/thumbnail.mjs +4 -4
  124. package/dist/astro/routes/api/admin/themes/marketplace/index.mjs +47 -42
  125. package/dist/astro/routes/api/admin/themes/marketplace/index.mjs.map +1 -1
  126. package/dist/astro/routes/api/admin/users/_id_/disable.mjs +3 -3
  127. package/dist/astro/routes/api/admin/users/_id_/enable.mjs +3 -3
  128. package/dist/astro/routes/api/admin/users/_id_/index.mjs +9 -8
  129. package/dist/astro/routes/api/admin/users/_id_/index.mjs.map +1 -1
  130. package/dist/astro/routes/api/admin/users/_id_/send-recovery.mjs +4 -4
  131. package/dist/astro/routes/api/admin/users/index.mjs +9 -8
  132. package/dist/astro/routes/api/admin/users/index.mjs.map +1 -1
  133. package/dist/astro/routes/api/auth/dev-bypass.d.mts.map +1 -1
  134. package/dist/astro/routes/api/auth/dev-bypass.mjs +5 -9
  135. package/dist/astro/routes/api/auth/dev-bypass.mjs.map +1 -1
  136. package/dist/astro/routes/api/auth/invite/accept.mjs +3 -3
  137. package/dist/astro/routes/api/auth/invite/complete.mjs +13 -12
  138. package/dist/astro/routes/api/auth/invite/complete.mjs.map +1 -1
  139. package/dist/astro/routes/api/auth/invite/index.mjs +10 -9
  140. package/dist/astro/routes/api/auth/invite/index.mjs.map +1 -1
  141. package/dist/astro/routes/api/auth/invite/register-options.mjs +12 -11
  142. package/dist/astro/routes/api/auth/invite/register-options.mjs.map +1 -1
  143. package/dist/astro/routes/api/auth/logout.mjs +4 -4
  144. package/dist/astro/routes/api/auth/magic-link/send.mjs +12 -11
  145. package/dist/astro/routes/api/auth/magic-link/send.mjs.map +1 -1
  146. package/dist/astro/routes/api/auth/magic-link/verify.mjs +4 -4
  147. package/dist/astro/routes/api/auth/me.mjs +10 -9
  148. package/dist/astro/routes/api/auth/me.mjs.map +1 -1
  149. package/dist/astro/routes/api/auth/mode.mjs +4 -4
  150. package/dist/astro/routes/api/auth/oauth/_provider_/callback.mjs +3 -3
  151. package/dist/astro/routes/api/auth/oauth/_provider_.mjs +2 -2
  152. package/dist/astro/routes/api/auth/passkey/_id_.mjs +9 -8
  153. package/dist/astro/routes/api/auth/passkey/_id_.mjs.map +1 -1
  154. package/dist/astro/routes/api/auth/passkey/index.mjs +3 -3
  155. package/dist/astro/routes/api/auth/passkey/options.mjs +14 -13
  156. package/dist/astro/routes/api/auth/passkey/options.mjs.map +1 -1
  157. package/dist/astro/routes/api/auth/passkey/register/options.mjs +12 -11
  158. package/dist/astro/routes/api/auth/passkey/register/options.mjs.map +1 -1
  159. package/dist/astro/routes/api/auth/passkey/register/verify.mjs +13 -12
  160. package/dist/astro/routes/api/auth/passkey/register/verify.mjs.map +1 -1
  161. package/dist/astro/routes/api/auth/passkey/verify.mjs +13 -12
  162. package/dist/astro/routes/api/auth/passkey/verify.mjs.map +1 -1
  163. package/dist/astro/routes/api/auth/signup/complete.mjs +13 -12
  164. package/dist/astro/routes/api/auth/signup/complete.mjs.map +1 -1
  165. package/dist/astro/routes/api/auth/signup/request.mjs +12 -11
  166. package/dist/astro/routes/api/auth/signup/request.mjs.map +1 -1
  167. package/dist/astro/routes/api/auth/signup/verify.mjs +3 -3
  168. package/dist/astro/routes/api/comments/_collection_/_contentId_/index.mjs +15 -14
  169. package/dist/astro/routes/api/comments/_collection_/_contentId_/index.mjs.map +1 -1
  170. package/dist/astro/routes/api/comments/_collection_/_contentId_/reactions.mjs +14 -13
  171. package/dist/astro/routes/api/comments/_collection_/_contentId_/reactions.mjs.map +1 -1
  172. package/dist/astro/routes/api/content/_collection_/_id_/compare.mjs +4 -4
  173. package/dist/astro/routes/api/content/_collection_/_id_/discard-draft.mjs +5 -5
  174. package/dist/astro/routes/api/content/_collection_/_id_/duplicate.mjs +5 -5
  175. package/dist/astro/routes/api/content/_collection_/_id_/permanent.mjs +4 -4
  176. package/dist/astro/routes/api/content/_collection_/_id_/preview-url.mjs +13 -13
  177. package/dist/astro/routes/api/content/_collection_/_id_/publish.mjs +11 -10
  178. package/dist/astro/routes/api/content/_collection_/_id_/publish.mjs.map +1 -1
  179. package/dist/astro/routes/api/content/_collection_/_id_/references/_relation_/children.mjs +18 -17
  180. package/dist/astro/routes/api/content/_collection_/_id_/references/_relation_/children.mjs.map +1 -1
  181. package/dist/astro/routes/api/content/_collection_/_id_/references/_relation_/parents.mjs +18 -17
  182. package/dist/astro/routes/api/content/_collection_/_id_/references/_relation_/parents.mjs.map +1 -1
  183. package/dist/astro/routes/api/content/_collection_/_id_/restore.mjs +5 -5
  184. package/dist/astro/routes/api/content/_collection_/_id_/revisions.mjs +4 -4
  185. package/dist/astro/routes/api/content/_collection_/_id_/schedule.mjs +11 -10
  186. package/dist/astro/routes/api/content/_collection_/_id_/schedule.mjs.map +1 -1
  187. package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.d.mts.map +1 -1
  188. package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.mjs +44 -31
  189. package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.mjs.map +1 -1
  190. package/dist/astro/routes/api/content/_collection_/_id_/translations.mjs +4 -4
  191. package/dist/astro/routes/api/content/_collection_/_id_/unpublish.mjs +5 -5
  192. package/dist/astro/routes/api/content/_collection_/_id_.mjs +11 -10
  193. package/dist/astro/routes/api/content/_collection_/_id_.mjs.map +1 -1
  194. package/dist/astro/routes/api/content/_collection_/authors.mjs +4 -4
  195. package/dist/astro/routes/api/content/_collection_/index.mjs +11 -10
  196. package/dist/astro/routes/api/content/_collection_/index.mjs.map +1 -1
  197. package/dist/astro/routes/api/content/_collection_/trash.mjs +10 -9
  198. package/dist/astro/routes/api/content/_collection_/trash.mjs.map +1 -1
  199. package/dist/astro/routes/api/dashboard.mjs +10 -8
  200. package/dist/astro/routes/api/dashboard.mjs.map +1 -1
  201. package/dist/astro/routes/api/dev/emails.mjs +4 -4
  202. package/dist/astro/routes/api/import/probe.d.mts +3 -3
  203. package/dist/astro/routes/api/import/probe.mjs +18 -16
  204. package/dist/astro/routes/api/import/probe.mjs.map +1 -1
  205. package/dist/astro/routes/api/import/wordpress/analyze.mjs +5 -5
  206. package/dist/astro/routes/api/import/wordpress/execute.d.mts +11 -11
  207. package/dist/astro/routes/api/import/wordpress/execute.mjs +15 -15
  208. package/dist/astro/routes/api/import/wordpress/execute.mjs.map +1 -1
  209. package/dist/astro/routes/api/import/wordpress/media.d.mts +10 -10
  210. package/dist/astro/routes/api/import/wordpress/media.mjs +14 -12
  211. package/dist/astro/routes/api/import/wordpress/media.mjs.map +1 -1
  212. package/dist/astro/routes/api/import/wordpress/prepare.mjs +13 -12
  213. package/dist/astro/routes/api/import/wordpress/prepare.mjs.map +1 -1
  214. package/dist/astro/routes/api/import/wordpress/rewrite-urls.d.mts +11 -11
  215. package/dist/astro/routes/api/import/wordpress/rewrite-urls.mjs +14 -14
  216. package/dist/astro/routes/api/import/wordpress-plugin/analyze.d.mts +1 -1
  217. package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs +18 -16
  218. package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs.map +1 -1
  219. package/dist/astro/routes/api/import/wordpress-plugin/callback.mjs +1 -1
  220. package/dist/astro/routes/api/import/wordpress-plugin/execute.d.mts +11 -11
  221. package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs +28 -27
  222. package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs.map +1 -1
  223. package/dist/astro/routes/api/manifest.mjs +5 -5
  224. package/dist/astro/routes/api/mcp.d.mts.map +1 -1
  225. package/dist/astro/routes/api/mcp.mjs +174 -72
  226. package/dist/astro/routes/api/mcp.mjs.map +1 -1
  227. package/dist/astro/routes/api/media/_id_/confirm.mjs +13 -11
  228. package/dist/astro/routes/api/media/_id_/confirm.mjs.map +1 -1
  229. package/dist/astro/routes/api/media/_id_/upload.mjs +6 -6
  230. package/dist/astro/routes/api/media/_id_/usage.mjs +17 -16
  231. package/dist/astro/routes/api/media/_id_/usage.mjs.map +1 -1
  232. package/dist/astro/routes/api/media/_id_.mjs +16 -15
  233. package/dist/astro/routes/api/media/_id_.mjs.map +1 -1
  234. package/dist/astro/routes/api/media/file/_...key_.mjs +3 -3
  235. package/dist/astro/routes/api/media/providers/_providerId_/_itemId_.mjs +4 -4
  236. package/dist/astro/routes/api/media/providers/_providerId_/index.mjs +4 -4
  237. package/dist/astro/routes/api/media/providers/index.mjs +4 -4
  238. package/dist/astro/routes/api/media/upload-url.mjs +11 -10
  239. package/dist/astro/routes/api/media/upload-url.mjs.map +1 -1
  240. package/dist/astro/routes/api/media.mjs +18 -15
  241. package/dist/astro/routes/api/media.mjs.map +1 -1
  242. package/dist/astro/routes/api/menus/_name_/items/_id_.mjs +11 -11
  243. package/dist/astro/routes/api/menus/_name_/items.mjs +11 -11
  244. package/dist/astro/routes/api/menus/_name_/reorder.mjs +11 -11
  245. package/dist/astro/routes/api/menus/_name_/translations.mjs +11 -11
  246. package/dist/astro/routes/api/menus/_name_.mjs +11 -11
  247. package/dist/astro/routes/api/menus/index.mjs +11 -11
  248. package/dist/astro/routes/api/oauth/authorize.mjs +6 -6
  249. package/dist/astro/routes/api/oauth/device/authorize.mjs +7 -7
  250. package/dist/astro/routes/api/oauth/device/code.mjs +10 -10
  251. package/dist/astro/routes/api/oauth/device/token.mjs +9 -9
  252. package/dist/astro/routes/api/oauth/register.mjs +4 -4
  253. package/dist/astro/routes/api/oauth/token/refresh.mjs +7 -7
  254. package/dist/astro/routes/api/oauth/token/revoke.mjs +7 -7
  255. package/dist/astro/routes/api/oauth/token.mjs +7 -7
  256. package/dist/astro/routes/api/openapi.json.mjs +152 -6
  257. package/dist/astro/routes/api/openapi.json.mjs.map +1 -1
  258. package/dist/astro/routes/api/plugins/_pluginId_/_...path_.d.mts.map +1 -1
  259. package/dist/astro/routes/api/plugins/_pluginId_/_...path_.mjs +8 -7
  260. package/dist/astro/routes/api/plugins/_pluginId_/_...path_.mjs.map +1 -1
  261. package/dist/astro/routes/api/redirects/404s/index.mjs +12 -11
  262. package/dist/astro/routes/api/redirects/404s/index.mjs.map +1 -1
  263. package/dist/astro/routes/api/redirects/404s/summary.mjs +12 -11
  264. package/dist/astro/routes/api/redirects/404s/summary.mjs.map +1 -1
  265. package/dist/astro/routes/api/redirects/_id_.mjs +13 -12
  266. package/dist/astro/routes/api/redirects/_id_.mjs.map +1 -1
  267. package/dist/astro/routes/api/redirects/index.mjs +13 -12
  268. package/dist/astro/routes/api/redirects/index.mjs.map +1 -1
  269. package/dist/astro/routes/api/relations/_id_/index.mjs +18 -17
  270. package/dist/astro/routes/api/relations/_id_/index.mjs.map +1 -1
  271. package/dist/astro/routes/api/relations/_id_/translations.mjs +13 -12
  272. package/dist/astro/routes/api/relations/_id_/translations.mjs.map +1 -1
  273. package/dist/astro/routes/api/relations/index.mjs +18 -17
  274. package/dist/astro/routes/api/relations/index.mjs.map +1 -1
  275. package/dist/astro/routes/api/revisions/_revisionId_/index.mjs +4 -4
  276. package/dist/astro/routes/api/revisions/_revisionId_/restore.mjs +5 -5
  277. package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs +47 -42
  278. package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs.map +1 -1
  279. package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs +47 -42
  280. package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs.map +1 -1
  281. package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs +47 -42
  282. package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs.map +1 -1
  283. package/dist/astro/routes/api/schema/collections/_slug_/index.d.mts.map +1 -1
  284. package/dist/astro/routes/api/schema/collections/_slug_/index.mjs +47 -42
  285. package/dist/astro/routes/api/schema/collections/_slug_/index.mjs.map +1 -1
  286. package/dist/astro/routes/api/schema/collections/index.mjs +47 -42
  287. package/dist/astro/routes/api/schema/collections/index.mjs.map +1 -1
  288. package/dist/astro/routes/api/schema/collections/reorder.mjs +47 -42
  289. package/dist/astro/routes/api/schema/collections/reorder.mjs.map +1 -1
  290. package/dist/astro/routes/api/schema/index.mjs +10 -9
  291. package/dist/astro/routes/api/schema/index.mjs.map +1 -1
  292. package/dist/astro/routes/api/schema/orphans/_slug_.mjs +47 -42
  293. package/dist/astro/routes/api/schema/orphans/_slug_.mjs.map +1 -1
  294. package/dist/astro/routes/api/schema/orphans/index.mjs +47 -42
  295. package/dist/astro/routes/api/schema/orphans/index.mjs.map +1 -1
  296. package/dist/astro/routes/api/search/enable.mjs +13 -13
  297. package/dist/astro/routes/api/search/index.mjs +12 -12
  298. package/dist/astro/routes/api/search/rebuild.mjs +13 -13
  299. package/dist/astro/routes/api/search/stats.mjs +8 -8
  300. package/dist/astro/routes/api/search/suggest.mjs +12 -12
  301. package/dist/astro/routes/api/sections/_slug_.mjs +12 -11
  302. package/dist/astro/routes/api/sections/_slug_.mjs.map +1 -1
  303. package/dist/astro/routes/api/sections/index.mjs +12 -11
  304. package/dist/astro/routes/api/sections/index.mjs.map +1 -1
  305. package/dist/astro/routes/api/settings/backups/archives/_name_.mjs +7 -7
  306. package/dist/astro/routes/api/settings/backups/archives/index.mjs +6 -6
  307. package/dist/astro/routes/api/settings/backups/export.mjs +7 -7
  308. package/dist/astro/routes/api/settings/backups/index.mjs +8 -8
  309. package/dist/astro/routes/api/settings/email.mjs +5 -5
  310. package/dist/astro/routes/api/settings.mjs +14 -13
  311. package/dist/astro/routes/api/settings.mjs.map +1 -1
  312. package/dist/astro/routes/api/setup/admin-verify.mjs +14 -13
  313. package/dist/astro/routes/api/setup/admin-verify.mjs.map +1 -1
  314. package/dist/astro/routes/api/setup/admin.mjs +13 -12
  315. package/dist/astro/routes/api/setup/admin.mjs.map +1 -1
  316. package/dist/astro/routes/api/setup/dev-bypass.d.mts.map +1 -1
  317. package/dist/astro/routes/api/setup/dev-bypass.mjs +30 -31
  318. package/dist/astro/routes/api/setup/dev-bypass.mjs.map +1 -1
  319. package/dist/astro/routes/api/setup/dev-reset.mjs +3 -3
  320. package/dist/astro/routes/api/setup/index.d.mts.map +1 -1
  321. package/dist/astro/routes/api/setup/index.mjs +31 -35
  322. package/dist/astro/routes/api/setup/index.mjs.map +1 -1
  323. package/dist/astro/routes/api/setup/status.mjs +5 -5
  324. package/dist/astro/routes/api/snapshot.mjs +8 -8
  325. package/dist/astro/routes/api/taxonomies/_name_/reorder.mjs +16 -15
  326. package/dist/astro/routes/api/taxonomies/_name_/reorder.mjs.map +1 -1
  327. package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_/translations.mjs +16 -15
  328. package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_/translations.mjs.map +1 -1
  329. package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_.mjs +16 -15
  330. package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_.mjs.map +1 -1
  331. package/dist/astro/routes/api/taxonomies/_name_/terms/index.d.mts.map +1 -1
  332. package/dist/astro/routes/api/taxonomies/_name_/terms/index.mjs +18 -16
  333. package/dist/astro/routes/api/taxonomies/_name_/terms/index.mjs.map +1 -1
  334. package/dist/astro/routes/api/taxonomies/_name_/translations.d.mts +8 -0
  335. package/dist/astro/routes/api/taxonomies/_name_/translations.d.mts.map +1 -0
  336. package/dist/astro/routes/api/taxonomies/_name_/translations.mjs +46 -0
  337. package/dist/astro/routes/api/taxonomies/_name_/translations.mjs.map +1 -0
  338. package/dist/astro/routes/api/taxonomies/_name_.d.mts +19 -0
  339. package/dist/astro/routes/api/taxonomies/_name_.d.mts.map +1 -0
  340. package/dist/astro/routes/api/taxonomies/_name_.mjs +88 -0
  341. package/dist/astro/routes/api/taxonomies/_name_.mjs.map +1 -0
  342. package/dist/astro/routes/api/taxonomies/index.mjs +16 -15
  343. package/dist/astro/routes/api/taxonomies/index.mjs.map +1 -1
  344. package/dist/astro/routes/api/themes/preview.mjs +6 -6
  345. package/dist/astro/routes/api/typegen.mjs +6 -6
  346. package/dist/astro/routes/api/well-known/auth.mjs +1 -1
  347. package/dist/astro/routes/api/well-known/oauth-authorization-server.mjs +2 -2
  348. package/dist/astro/routes/api/well-known/oauth-protected-resource.mjs +2 -2
  349. package/dist/astro/routes/api/widget-areas/_name_/reorder.mjs +10 -9
  350. package/dist/astro/routes/api/widget-areas/_name_/reorder.mjs.map +1 -1
  351. package/dist/astro/routes/api/widget-areas/_name_/widgets/_id_.mjs +12 -11
  352. package/dist/astro/routes/api/widget-areas/_name_/widgets/_id_.mjs.map +1 -1
  353. package/dist/astro/routes/api/widget-areas/_name_/widgets.mjs +12 -11
  354. package/dist/astro/routes/api/widget-areas/_name_/widgets.mjs.map +1 -1
  355. package/dist/astro/routes/api/widget-areas/_name_.mjs +7 -6
  356. package/dist/astro/routes/api/widget-areas/_name_.mjs.map +1 -1
  357. package/dist/astro/routes/api/widget-areas/index.mjs +12 -11
  358. package/dist/astro/routes/api/widget-areas/index.mjs.map +1 -1
  359. package/dist/astro/routes/api/widget-components.mjs +4 -4
  360. package/dist/astro/routes/robots.txt.mjs +7 -6
  361. package/dist/astro/routes/robots.txt.mjs.map +1 -1
  362. package/dist/astro/routes/sitemap-_collection_.xml.mjs +10 -10
  363. package/dist/astro/routes/sitemap.xml.mjs +8 -7
  364. package/dist/astro/routes/sitemap.xml.mjs.map +1 -1
  365. package/dist/astro/types.d.mts +35 -17
  366. package/dist/astro/types.d.mts.map +1 -1
  367. package/dist/auth/providers/github.d.mts +1 -1
  368. package/dist/auth/providers/google.d.mts +1 -1
  369. package/dist/{authorize-qRhWQnel.mjs → authorize-DUElxgOQ.mjs} +2 -2
  370. package/dist/{authorize-qRhWQnel.mjs.map → authorize-DUElxgOQ.mjs.map} +1 -1
  371. package/dist/{backup-CtA6LDNw.mjs → backup-DWYpJlLj.mjs} +4 -4
  372. package/dist/{backup-CtA6LDNw.mjs.map → backup-DWYpJlLj.mjs.map} +1 -1
  373. package/dist/{base64-B-PsqheR.mjs → base64-CvLhUKQv.mjs} +1 -1
  374. package/dist/{base64-B-PsqheR.mjs.map → base64-CvLhUKQv.mjs.map} +1 -1
  375. package/dist/bundle-vn56sLos.mjs +663 -0
  376. package/dist/bundle-vn56sLos.mjs.map +1 -0
  377. package/dist/{byline-fields-CNc0Yq5i.mjs → byline-fields-txaLGoME.mjs} +2 -2
  378. package/dist/{byline-fields-CNc0Yq5i.mjs.map → byline-fields-txaLGoME.mjs.map} +1 -1
  379. package/dist/{byline-registry-ZkPKqEwC.mjs → byline-registry-DDun8sxh.mjs} +2 -2
  380. package/dist/{byline-registry-ZkPKqEwC.mjs.map → byline-registry-DDun8sxh.mjs.map} +1 -1
  381. package/dist/{byline-Cp6csL84.mjs → byline-yjvWq4ip.mjs} +5 -5
  382. package/dist/{byline-Cp6csL84.mjs.map → byline-yjvWq4ip.mjs.map} +1 -1
  383. package/dist/{bylines-cf-C9lmF.mjs → bylines-Bu7pchQ4.mjs} +5 -5
  384. package/dist/{bylines-cf-C9lmF.mjs.map → bylines-Bu7pchQ4.mjs.map} +1 -1
  385. package/dist/{bylines-CEj5CdT4.mjs → bylines-DjXS5p0P.mjs} +7 -7
  386. package/dist/{bylines-CEj5CdT4.mjs.map → bylines-DjXS5p0P.mjs.map} +1 -1
  387. package/dist/{cache-C9FptTFR.mjs → cache-C46-3Irl.mjs} +3 -3
  388. package/dist/{cache-C9FptTFR.mjs.map → cache-C46-3Irl.mjs.map} +1 -1
  389. package/dist/{challenge-store-D9VNKELo.mjs → challenge-store-SIL5DsF2.mjs} +1 -1
  390. package/dist/{challenge-store-D9VNKELo.mjs.map → challenge-store-SIL5DsF2.mjs.map} +1 -1
  391. package/dist/{chunks-DbYzib_k.mjs → chunks-fiZ8G9RJ.mjs} +2 -2
  392. package/dist/{chunks-DbYzib_k.mjs.map → chunks-fiZ8G9RJ.mjs.map} +1 -1
  393. package/dist/cli/index.mjs +655 -677
  394. package/dist/cli/index.mjs.map +1 -1
  395. package/dist/client/cf-access.d.mts +1 -1
  396. package/dist/client/index.d.mts +74 -4
  397. package/dist/client/index.d.mts.map +1 -1
  398. package/dist/client/index.mjs +25 -2
  399. package/dist/client/index.mjs.map +1 -1
  400. package/dist/collection-deletion-DQPvMD8R.mjs +779 -0
  401. package/dist/collection-deletion-DQPvMD8R.mjs.map +1 -0
  402. package/dist/{comment-BIkJ6Fil.mjs → comment-Dxlep6cE.mjs} +2 -2
  403. package/dist/{comment-BIkJ6Fil.mjs.map → comment-Dxlep6cE.mjs.map} +1 -1
  404. package/dist/{comment-reaction-D-laEceb.mjs → comment-reaction-D-2J84UE.mjs} +2 -2
  405. package/dist/{comment-reaction-D-laEceb.mjs.map → comment-reaction-D-2J84UE.mjs.map} +1 -1
  406. package/dist/{comments-0_w6KChA.mjs → comments-CTfZtLmM.mjs} +3 -3
  407. package/dist/{comments-0_w6KChA.mjs.map → comments-CTfZtLmM.mjs.map} +1 -1
  408. package/dist/{components-BPxrRjpE.mjs → components-CQy3lh9x.mjs} +1 -1
  409. package/dist/{components-BPxrRjpE.mjs.map → components-CQy3lh9x.mjs.map} +1 -1
  410. package/dist/{config-DyF3OKg_.mjs → config-C3EBstHi.mjs} +18 -2
  411. package/dist/config-C3EBstHi.mjs.map +1 -0
  412. package/dist/config-COQzTe5Q.d.mts +34 -0
  413. package/dist/config-COQzTe5Q.d.mts.map +1 -0
  414. package/dist/{content-CJ94NEx5.mjs → content-ZHCc1ngN.mjs} +509 -78
  415. package/dist/content-ZHCc1ngN.mjs.map +1 -0
  416. package/dist/content-list-query-wJ2WN8Vp.d.mts +26 -0
  417. package/dist/content-list-query-wJ2WN8Vp.d.mts.map +1 -0
  418. package/dist/content-refresh-B_8SJzuU.mjs +2560 -0
  419. package/dist/content-refresh-B_8SJzuU.mjs.map +1 -0
  420. package/dist/{context-C_1nrqnW.mjs → context-ZldbZcle.mjs} +25 -17
  421. package/dist/context-ZldbZcle.mjs.map +1 -0
  422. package/dist/{cron-DRV-UC4K.mjs → cron-Y9aLtgrA.mjs} +1 -1
  423. package/dist/{cron-DRV-UC4K.mjs.map → cron-Y9aLtgrA.mjs.map} +1 -1
  424. package/dist/{dashboard-DINnNVVU.mjs → dashboard-DvHbk_cv.mjs} +44 -10
  425. package/dist/dashboard-DvHbk_cv.mjs.map +1 -0
  426. package/dist/db/index.d.mts +4 -4
  427. package/dist/db/index.mjs +31 -8
  428. package/dist/db/index.mjs.map +1 -1
  429. package/dist/db/libsql-migrations.d.mts +11 -0
  430. package/dist/db/libsql-migrations.d.mts.map +1 -0
  431. package/dist/db/libsql-migrations.mjs +57 -0
  432. package/dist/db/libsql-migrations.mjs.map +1 -0
  433. package/dist/db/libsql.d.mts +1 -1
  434. package/dist/db/postgres-migrations.d.mts +10 -0
  435. package/dist/db/postgres-migrations.d.mts.map +1 -0
  436. package/dist/db/postgres-migrations.mjs +47 -0
  437. package/dist/db/postgres-migrations.mjs.map +1 -0
  438. package/dist/db/postgres.d.mts +1 -1
  439. package/dist/db/sqlite-migrations.d.mts +10 -0
  440. package/dist/db/sqlite-migrations.d.mts.map +1 -0
  441. package/dist/db/sqlite-migrations.mjs +28 -0
  442. package/dist/db/sqlite-migrations.mjs.map +1 -0
  443. package/dist/db/sqlite.d.mts +1 -1
  444. package/dist/{db-errors-CcWLaRiR.mjs → db-errors-Cl-sKYTm.mjs} +1 -1
  445. package/dist/{db-errors-CcWLaRiR.mjs.map → db-errors-Cl-sKYTm.mjs.map} +1 -1
  446. package/dist/{default-BD2FG0xV.mjs → default-DdDl3fxv.mjs} +1 -1
  447. package/dist/{default-BD2FG0xV.mjs.map → default-DdDl3fxv.mjs.map} +1 -1
  448. package/dist/{deferred-tasks-C-WRCcKo.d.mts → deferred-tasks-SDWxrgzJ.d.mts} +1 -1
  449. package/dist/{deferred-tasks-C-WRCcKo.d.mts.map → deferred-tasks-SDWxrgzJ.d.mts.map} +1 -1
  450. package/dist/{device-flow-CTqd8mud.mjs → device-flow-C0GdD_z8.mjs} +4 -4
  451. package/dist/{device-flow-CTqd8mud.mjs.map → device-flow-C0GdD_z8.mjs.map} +1 -1
  452. package/dist/direct-executor-CDXeJCSs.mjs +85 -0
  453. package/dist/direct-executor-CDXeJCSs.mjs.map +1 -0
  454. package/dist/dist-D_6CKR8z.mjs +810 -0
  455. package/dist/dist-D_6CKR8z.mjs.map +1 -0
  456. package/dist/{email-console-C6WdcWbL.mjs → email-console-BHNjIwiB.mjs} +1 -1
  457. package/dist/{email-console-C6WdcWbL.mjs.map → email-console-BHNjIwiB.mjs.map} +1 -1
  458. package/dist/{enrich-CFJJgxs_.mjs → enrich-_ySMEvEE.mjs} +2 -2
  459. package/dist/{enrich-CFJJgxs_.mjs.map → enrich-_ySMEvEE.mjs.map} +1 -1
  460. package/dist/{error-oh_9i6bR.mjs → error-DupyJZE2.mjs} +3 -3
  461. package/dist/{error-oh_9i6bR.mjs.map → error-DupyJZE2.mjs.map} +1 -1
  462. package/dist/{errors-BucQ8VYz.mjs → errors-CLUPm7aD.mjs} +14 -2
  463. package/dist/errors-CLUPm7aD.mjs.map +1 -0
  464. package/dist/{escape-CXps5F_v.mjs → escape-DUY2r2Zs.mjs} +1 -1
  465. package/dist/{escape-CXps5F_v.mjs.map → escape-DUY2r2Zs.mjs.map} +1 -1
  466. package/dist/{field-defs-cache-WD82dMQq.mjs → field-defs-cache-BzwBlJrR.mjs} +4 -4
  467. package/dist/{field-defs-cache-WD82dMQq.mjs.map → field-defs-cache-BzwBlJrR.mjs.map} +1 -1
  468. package/dist/{fts-manager-BITakW_R.mjs → fts-manager-DyZEohZC.mjs} +76 -50
  469. package/dist/fts-manager-DyZEohZC.mjs.map +1 -0
  470. package/dist/{hreflang-BdZuVBhL.mjs → hreflang-BBe83kKa.mjs} +7 -7
  471. package/dist/{hreflang-BdZuVBhL.mjs.map → hreflang-BBe83kKa.mjs.map} +1 -1
  472. package/dist/{hreflang-BaLCNcDx.d.mts → hreflang-DwT2E0Sa.d.mts} +2 -2
  473. package/dist/{hreflang-BaLCNcDx.d.mts.map → hreflang-DwT2E0Sa.d.mts.map} +1 -1
  474. package/dist/identity-Ys6Dlc9C.mjs +35 -0
  475. package/dist/identity-Ys6Dlc9C.mjs.map +1 -0
  476. package/dist/{import-C-9uqyT3.mjs → import-DPkLmeXO.mjs} +5 -5
  477. package/dist/{import-C-9uqyT3.mjs.map → import-DPkLmeXO.mjs.map} +1 -1
  478. package/dist/{index-M37iz29I.d.mts → index-BVBZj4-7.d.mts} +236 -104
  479. package/dist/index-BVBZj4-7.d.mts.map +1 -0
  480. package/dist/{index-MGw47uWq.d.mts → index-C9KuQyO-.d.mts} +5 -10
  481. package/dist/index-C9KuQyO-.d.mts.map +1 -0
  482. package/dist/index.d.mts +21 -19
  483. package/dist/index.mjs +73 -67
  484. package/dist/{init-lock-DJkX6Hto.mjs → init-lock-6b309ZrF.mjs} +1 -1
  485. package/dist/{init-lock-DJkX6Hto.mjs.map → init-lock-6b309ZrF.mjs.map} +1 -1
  486. package/dist/{load-DV1DZuUJ.mjs → load-YJ2xl3ra.mjs} +2 -2
  487. package/dist/{load-DV1DZuUJ.mjs.map → load-YJ2xl3ra.mjs.map} +1 -1
  488. package/dist/{loader-CGugY7Zl.mjs → loader-CdG_Ci3H.mjs} +95 -53
  489. package/dist/loader-CdG_Ci3H.mjs.map +1 -0
  490. package/dist/manifest-builder-B252yQUf.mjs +189 -0
  491. package/dist/manifest-builder-B252yQUf.mjs.map +1 -0
  492. package/dist/{manifest-schema-Da7kj23y.mjs → manifest-schema-BXbUAHod.mjs} +1 -1
  493. package/dist/{manifest-schema-Da7kj23y.mjs.map → manifest-schema-BXbUAHod.mjs.map} +1 -1
  494. package/dist/media/image-endpoint.mjs +1 -1
  495. package/dist/media/index.d.mts +1 -1
  496. package/dist/media/index.mjs +3 -2
  497. package/dist/media/index.mjs.map +1 -1
  498. package/dist/media/local-runtime.d.mts +12 -12
  499. package/dist/media/local-runtime.mjs +6 -5
  500. package/dist/media/local-runtime.mjs.map +1 -1
  501. package/dist/{media-OhYL-7Io.mjs → media-BWWWb8kg.mjs} +92 -3
  502. package/dist/media-BWWWb8kg.mjs.map +1 -0
  503. package/dist/{media-BP7S-nHT.mjs → media-CzhAn2--.mjs} +2 -2
  504. package/dist/{media-BP7S-nHT.mjs.map → media-CzhAn2--.mjs.map} +1 -1
  505. package/dist/{media-allowlist-D-peH1H5.mjs → media-allowlist-D6x-Us02.mjs} +14 -3
  506. package/dist/media-allowlist-D6x-Us02.mjs.map +1 -0
  507. package/dist/{media-upload-DtrwU2B0.mjs → media-upload-CHBVn6m-.mjs} +10 -8
  508. package/dist/{media-upload-DtrwU2B0.mjs.map → media-upload-CHBVn6m-.mjs.map} +1 -1
  509. package/dist/{media-url-DYs_YLOx.mjs → media-url-BvoX88FA.mjs} +1 -1
  510. package/dist/{media-url-DYs_YLOx.mjs.map → media-url-BvoX88FA.mjs.map} +1 -1
  511. package/dist/{media-usage-BlxfTkN0.mjs → media-usage-BZCydfvu.mjs} +137 -60
  512. package/dist/media-usage-BZCydfvu.mjs.map +1 -0
  513. package/dist/media-usage-work-CLo_6tct.mjs +152 -0
  514. package/dist/media-usage-work-CLo_6tct.mjs.map +1 -0
  515. package/dist/media-usage-work-DrCbqBcl.mjs +424 -0
  516. package/dist/media-usage-work-DrCbqBcl.mjs.map +1 -0
  517. package/dist/media-usage-write-fence-aHEgN1zB.mjs +42 -0
  518. package/dist/media-usage-write-fence-aHEgN1zB.mjs.map +1 -0
  519. package/dist/{menus-Y54RDPIN.mjs → menus-DSo5QeW0.mjs} +351 -95
  520. package/dist/menus-DSo5QeW0.mjs.map +1 -0
  521. package/dist/{menus-BcQWxeXu.mjs → menus-TCNSd2vp.mjs} +3 -3
  522. package/dist/{menus-BcQWxeXu.mjs.map → menus-TCNSd2vp.mjs.map} +1 -1
  523. package/dist/migrations/index.d.mts +22 -0
  524. package/dist/migrations/index.d.mts.map +1 -0
  525. package/dist/migrations/index.mjs +7 -0
  526. package/dist/{mode-BWyTBe3I.mjs → mode-BDoH_WcF.mjs} +1 -1
  527. package/dist/{mode-BWyTBe3I.mjs.map → mode-BDoH_WcF.mjs.map} +1 -1
  528. package/dist/{normalize-C-SHXmra.mjs → normalize-Bc-s_r-a.mjs} +1 -1
  529. package/dist/{normalize-C-SHXmra.mjs.map → normalize-Bc-s_r-a.mjs.map} +1 -1
  530. package/dist/{oauth-authorization-ClvktmrL.mjs → oauth-authorization-iOikmrJd.mjs} +4 -4
  531. package/dist/{oauth-authorization-ClvktmrL.mjs.map → oauth-authorization-iOikmrJd.mjs.map} +1 -1
  532. package/dist/{oauth-clients-8ao-U66l.mjs → oauth-clients-CvI0nHMJ.mjs} +1 -1
  533. package/dist/{oauth-clients-8ao-U66l.mjs.map → oauth-clients-CvI0nHMJ.mjs.map} +1 -1
  534. package/dist/{oauth-state-store-B1ofdTiQ.mjs → oauth-state-store-Ba8KZlwW.mjs} +1 -1
  535. package/dist/{oauth-state-store-B1ofdTiQ.mjs.map → oauth-state-store-Ba8KZlwW.mjs.map} +1 -1
  536. package/dist/{oauth-user-lookup-CscEuIiR.mjs → oauth-user-lookup-C5SQwGYC.mjs} +1 -1
  537. package/dist/{oauth-user-lookup-CscEuIiR.mjs.map → oauth-user-lookup-C5SQwGYC.mjs.map} +1 -1
  538. package/dist/object-cache/memory.d.mts +1 -1
  539. package/dist/{options-CA4eVX6P.d.mts → options--hhz-jZ1.d.mts} +4 -3
  540. package/dist/options--hhz-jZ1.d.mts.map +1 -0
  541. package/dist/page/index.d.mts +2 -2
  542. package/dist/{parse-CJKNuewM.mjs → parse-DFbuc-Fh.mjs} +2 -2
  543. package/dist/{parse-CJKNuewM.mjs.map → parse-DFbuc-Fh.mjs.map} +1 -1
  544. package/dist/{passkey-config-D-zrzMJI.mjs → passkey-config-DFAmUyuE.mjs} +1 -1
  545. package/dist/{passkey-config-D-zrzMJI.mjs.map → passkey-config-DFAmUyuE.mjs.map} +1 -1
  546. package/dist/{placeholder-DaeNS-VG.d.mts → placeholder-Dp0K3RkD.d.mts} +1 -1
  547. package/dist/{placeholder-DaeNS-VG.d.mts.map → placeholder-Dp0K3RkD.d.mts.map} +1 -1
  548. package/dist/plugin-types.d.mts +8 -1
  549. package/dist/plugin-types.d.mts.map +1 -1
  550. package/dist/plugin-utils.d.mts +10 -10
  551. package/dist/plugins/adapt-sandbox-entry.d.mts +10 -10
  552. package/dist/plugins/adapt-sandbox-entry.mjs +7 -4
  553. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
  554. package/dist/policy-TluUIxuu.mjs +45 -0
  555. package/dist/policy-TluUIxuu.mjs.map +1 -0
  556. package/dist/{portable-text-B_kqBmXB.mjs → portable-text-imBW5cHK.mjs} +1 -1
  557. package/dist/{portable-text-B_kqBmXB.mjs.map → portable-text-imBW5cHK.mjs.map} +1 -1
  558. package/dist/{preview-BU6PaLpM.mjs → preview-PW-nOnq4.mjs} +2 -2
  559. package/dist/{preview-BU6PaLpM.mjs.map → preview-PW-nOnq4.mjs.map} +1 -1
  560. package/dist/protocol-DjsJHTlK.d.mts +52 -0
  561. package/dist/protocol-DjsJHTlK.d.mts.map +1 -0
  562. package/dist/{public-url-Ox9oOlgp.mjs → public-url-CWoFhQT8.mjs} +1 -1
  563. package/dist/{public-url-Ox9oOlgp.mjs.map → public-url-CWoFhQT8.mjs.map} +1 -1
  564. package/dist/{query-rC9HKv1N.mjs → query-BWs7O5Il.mjs} +3 -3
  565. package/dist/{query-rC9HKv1N.mjs.map → query-BWs7O5Il.mjs.map} +1 -1
  566. package/dist/{query-C5w7PsRQ.mjs → query-zTVAR1Sl.mjs} +31 -49
  567. package/dist/query-zTVAR1Sl.mjs.map +1 -0
  568. package/dist/{rate-limit-kug6q99V.mjs → rate-limit-Bx4g9m3p.mjs} +2 -2
  569. package/dist/{rate-limit-kug6q99V.mjs.map → rate-limit-Bx4g9m3p.mjs.map} +1 -1
  570. package/dist/{redirect-Egf2R9yP.mjs → redirect-BgAOGHDq.mjs} +1 -1
  571. package/dist/{redirect-Egf2R9yP.mjs.map → redirect-BgAOGHDq.mjs.map} +1 -1
  572. package/dist/{redirect-DIJxXKOG.mjs → redirect-COsS6SWJ.mjs} +2 -2
  573. package/dist/{redirect-DIJxXKOG.mjs.map → redirect-COsS6SWJ.mjs.map} +1 -1
  574. package/dist/{redirects-G8wAhWAf.mjs → redirects-DTDHWH31.mjs} +133 -14
  575. package/dist/redirects-DTDHWH31.mjs.map +1 -0
  576. package/dist/{redirects-Bzm812bY.mjs → redirects-DyChwDkG.mjs} +4 -4
  577. package/dist/{redirects-Bzm812bY.mjs.map → redirects-DyChwDkG.mjs.map} +1 -1
  578. package/dist/registry-Cqa2I749.mjs +1275 -0
  579. package/dist/registry-Cqa2I749.mjs.map +1 -0
  580. package/dist/{relation-Cbe3Sd3m.mjs → relation-BnHB0T4J.mjs} +3 -3
  581. package/dist/{relation-Cbe3Sd3m.mjs.map → relation-BnHB0T4J.mjs.map} +1 -1
  582. package/dist/{relations-Bw_E1TXo.mjs → relations-BBApI4e2.mjs} +3 -3
  583. package/dist/{relations-Bw_E1TXo.mjs.map → relations-BBApI4e2.mjs.map} +1 -1
  584. package/dist/{relations-G2ta7GIl.d.mts → relations-BHo5A8Zf.d.mts} +367 -33
  585. package/dist/relations-BHo5A8Zf.d.mts.map +1 -0
  586. package/dist/{relations-qHwpUe66.mjs → relations-D5sM5fW0.mjs} +6 -6
  587. package/dist/{relations-qHwpUe66.mjs.map → relations-D5sM5fW0.mjs.map} +1 -1
  588. package/dist/request-context.d.mts +1 -1
  589. package/dist/{request-meta-Df-jFbAd.mjs → request-meta-DgZJh3zG.mjs} +2 -2
  590. package/dist/{request-meta-Df-jFbAd.mjs.map → request-meta-DgZJh3zG.mjs.map} +1 -1
  591. package/dist/{resolve-DZzG4HqM.mjs → resolve-DOhVEvty.mjs} +2 -2
  592. package/dist/{resolve-DZzG4HqM.mjs.map → resolve-DOhVEvty.mjs.map} +1 -1
  593. package/dist/{runner-BaADli8l.d.mts → runner-BYHZn3Jy.d.mts} +16 -3
  594. package/dist/runner-BYHZn3Jy.d.mts.map +1 -0
  595. package/dist/{runner-BFTrkYhu.mjs → runner-C1T11CfC.mjs} +1190 -257
  596. package/dist/runner-C1T11CfC.mjs.map +1 -0
  597. package/dist/runtime.d.mts +11 -11
  598. package/dist/runtime.mjs +4 -3
  599. package/dist/runtime.mjs.map +1 -1
  600. package/dist/schema-2AOsSn2v.mjs +390 -0
  601. package/dist/schema-2AOsSn2v.mjs.map +1 -0
  602. package/dist/{schema-CiQXr6_F.mjs → schema-DZ5YVVZj.mjs} +3 -3
  603. package/dist/{schema-CiQXr6_F.mjs.map → schema-DZ5YVVZj.mjs.map} +1 -1
  604. package/dist/{scopes-CNJVT5i5.mjs → scopes-8ClPF2bC.mjs} +3 -3
  605. package/dist/{scopes-CNJVT5i5.mjs.map → scopes-8ClPF2bC.mjs.map} +1 -1
  606. package/dist/{search-FZhx5dDU.mjs → search-u1QYdOCG.mjs} +25 -15
  607. package/dist/search-u1QYdOCG.mjs.map +1 -0
  608. package/dist/{secrets-DLd8RXee.mjs → secrets-BEgsAQEU.mjs} +3 -3
  609. package/dist/{secrets-DLd8RXee.mjs.map → secrets-BEgsAQEU.mjs.map} +1 -1
  610. package/dist/{sections-BrIrMUCn.mjs → sections-DmHviGyM.mjs} +3 -3
  611. package/dist/{sections-BrIrMUCn.mjs.map → sections-DmHviGyM.mjs.map} +1 -1
  612. package/dist/seed/index.d.mts +2 -2
  613. package/dist/seed/index.mjs +24 -22
  614. package/dist/seo/index.d.mts +3 -3
  615. package/dist/seo/index.mjs +16 -15
  616. package/dist/seo/index.mjs.map +1 -1
  617. package/dist/{seo-CobNfOvb.mjs → seo-AFpVvTqr.mjs} +5 -3
  618. package/dist/seo-AFpVvTqr.mjs.map +1 -0
  619. package/dist/{seo-DTHRntNC.mjs → seo-Dq4G-RrO.mjs} +2 -2
  620. package/dist/{seo-DTHRntNC.mjs.map → seo-Dq4G-RrO.mjs.map} +1 -1
  621. package/dist/{service-B-pYGPgR.mjs → service-BpArayPr.mjs} +3 -3
  622. package/dist/{service-B-pYGPgR.mjs.map → service-BpArayPr.mjs.map} +1 -1
  623. package/dist/{session-user-BvM9-zNl.mjs → session-user-BEorKVT-.mjs} +1 -1
  624. package/dist/{session-user-BvM9-zNl.mjs.map → session-user-BEorKVT-.mjs.map} +1 -1
  625. package/dist/{settings-DPHau-WH.mjs → settings-DE9xtzqd.mjs} +3 -3
  626. package/dist/{settings-DPHau-WH.mjs.map → settings-DE9xtzqd.mjs.map} +1 -1
  627. package/dist/{settings-kJhGiQ4z.mjs → settings-xRdoUZr0.mjs} +4 -4
  628. package/dist/{settings-kJhGiQ4z.mjs.map → settings-xRdoUZr0.mjs.map} +1 -1
  629. package/dist/{setup-complete-u50Qm8rY.mjs → setup-complete-BnT8pq_C.mjs} +1 -1
  630. package/dist/{setup-complete-u50Qm8rY.mjs.map → setup-complete-BnT8pq_C.mjs.map} +1 -1
  631. package/dist/{setup-nonce-DCjiiOD2.mjs → setup-nonce-CBitf_oQ.mjs} +1 -1
  632. package/dist/{setup-nonce-DCjiiOD2.mjs.map → setup-nonce-CBitf_oQ.mjs.map} +1 -1
  633. package/dist/{single-flight-cache-DW3w11Pu.mjs → single-flight-cache-B0eLKS6K.mjs} +2 -2
  634. package/dist/{single-flight-cache-DW3w11Pu.mjs.map → single-flight-cache-B0eLKS6K.mjs.map} +1 -1
  635. package/dist/{site-url-I-lJ5kma.mjs → site-url-DjpIXMXJ.mjs} +2 -2
  636. package/dist/{site-url-I-lJ5kma.mjs.map → site-url-DjpIXMXJ.mjs.map} +1 -1
  637. package/dist/slugify-B994zkXC.mjs +17 -0
  638. package/dist/slugify-B994zkXC.mjs.map +1 -0
  639. package/dist/{snapshot-I_fjLDaD.mjs → snapshot-BBlGkb2q.mjs} +1 -1
  640. package/dist/{snapshot-I_fjLDaD.mjs.map → snapshot-BBlGkb2q.mjs.map} +1 -1
  641. package/dist/{ssrf-CTa6v21i.mjs → ssrf-Cpvg_n3A.mjs} +1 -1
  642. package/dist/{ssrf-CTa6v21i.mjs.map → ssrf-Cpvg_n3A.mjs.map} +1 -1
  643. package/dist/{state-CQvoefYP.mjs → state-DYwPue40.mjs} +1 -1
  644. package/dist/{state-CQvoefYP.mjs.map → state-DYwPue40.mjs.map} +1 -1
  645. package/dist/{status-CIsmlC1E.mjs → status-CWVYpbD8.mjs} +1 -1
  646. package/dist/{status-CIsmlC1E.mjs.map → status-CWVYpbD8.mjs.map} +1 -1
  647. package/dist/storage/local.d.mts +1 -1
  648. package/dist/storage/local.mjs +3 -3
  649. package/dist/storage/s3.d.mts +1 -1
  650. package/dist/storage/s3.mjs +1 -1
  651. package/dist/target-DixqqV5i.mjs +27 -0
  652. package/dist/target-DixqqV5i.mjs.map +1 -0
  653. package/dist/{taxonomies-CLN1N92E.mjs → taxonomies-BlrncHi9.mjs} +75 -64
  654. package/dist/taxonomies-BlrncHi9.mjs.map +1 -0
  655. package/dist/{taxonomies-CKuaZER5.mjs → taxonomies-OHUIiIpI.mjs} +280 -56
  656. package/dist/taxonomies-OHUIiIpI.mjs.map +1 -0
  657. package/dist/{taxonomy-pOrB2kZe.mjs → taxonomy-BqCZ1TdU.mjs} +131 -78
  658. package/dist/taxonomy-BqCZ1TdU.mjs.map +1 -0
  659. package/dist/{tokens-DagaJGK8.mjs → tokens-C39klX8R.mjs} +2 -2
  660. package/dist/{tokens-DagaJGK8.mjs.map → tokens-C39klX8R.mjs.map} +1 -1
  661. package/dist/{transport-DVsa5YYo.mjs → transport-DBJwa-Si.mjs} +1 -1
  662. package/dist/{transport-DVsa5YYo.mjs.map → transport-DBJwa-Si.mjs.map} +1 -1
  663. package/dist/{transport-CAwTaTp_.d.mts → transport-RQE36APG.d.mts} +1 -1
  664. package/dist/{transport-CAwTaTp_.d.mts.map → transport-RQE36APG.d.mts.map} +1 -1
  665. package/dist/{trusted-proxy-D20_7fyX.mjs → trusted-proxy-CEVekpby.mjs} +1 -1
  666. package/dist/{trusted-proxy-D20_7fyX.mjs.map → trusted-proxy-CEVekpby.mjs.map} +1 -1
  667. package/dist/{types-NBI3rF-_.mjs → types-75KIqzL9.mjs} +18 -2
  668. package/dist/types-75KIqzL9.mjs.map +1 -0
  669. package/dist/{types-BjxTKxrZ.mjs → types-BW1syrlw.mjs} +1 -1
  670. package/dist/{types-BjxTKxrZ.mjs.map → types-BW1syrlw.mjs.map} +1 -1
  671. package/dist/{types-DLG4IFEQ.d.mts → types-BcqdcT-r.d.mts} +1 -1
  672. package/dist/{types-DLG4IFEQ.d.mts.map → types-BcqdcT-r.d.mts.map} +1 -1
  673. package/dist/{types-LoagAGzH.d.mts → types-BntycPTF.d.mts} +1 -1
  674. package/dist/{types-LoagAGzH.d.mts.map → types-BntycPTF.d.mts.map} +1 -1
  675. package/dist/{types-BnFP1M3J.d.mts → types-C2pMvO62.d.mts} +32 -10
  676. package/dist/types-C2pMvO62.d.mts.map +1 -0
  677. package/dist/{types-Cd8iuS1J.mjs → types-CQPWhPxE.mjs} +9 -12
  678. package/dist/types-CQPWhPxE.mjs.map +1 -0
  679. package/dist/{types-C8oz_ayJ.d.mts → types-CXnOzDe1.d.mts} +1 -1
  680. package/dist/{types-C8oz_ayJ.d.mts.map → types-CXnOzDe1.d.mts.map} +1 -1
  681. package/dist/types-CtzytVXn.mjs.map +1 -1
  682. package/dist/{types-BGWPMiBO.d.mts → types-DN64_zzZ.d.mts} +44 -5
  683. package/dist/types-DN64_zzZ.d.mts.map +1 -0
  684. package/dist/{types-Dc4ntQjQ.d.mts → types-DQIDRKF2.d.mts} +124 -1
  685. package/dist/types-DQIDRKF2.d.mts.map +1 -0
  686. package/dist/{types-CbUcuxxc.d.mts → types-DWRhk9xZ.d.mts} +1 -1
  687. package/dist/{types-CbUcuxxc.d.mts.map → types-DWRhk9xZ.d.mts.map} +1 -1
  688. package/dist/{types-BK6U01Ro.d.mts → types-Dfk8KI5R.d.mts} +1 -1
  689. package/dist/{types-BK6U01Ro.d.mts.map → types-Dfk8KI5R.d.mts.map} +1 -1
  690. package/dist/{types-Cz8hNch2.d.mts → types-DrJrVCT1.d.mts} +25 -5
  691. package/dist/types-DrJrVCT1.d.mts.map +1 -0
  692. package/dist/{upload-attempts-D8QAFLUJ.mjs → upload-attempts-D4FS8LT6.mjs} +1 -1
  693. package/dist/{upload-attempts-D8QAFLUJ.mjs.map → upload-attempts-D4FS8LT6.mjs.map} +1 -1
  694. package/dist/url-pattern-BHS0V5Ng.mjs +33 -0
  695. package/dist/url-pattern-BHS0V5Ng.mjs.map +1 -0
  696. package/dist/{user-DBrq4UVq.mjs → user-DJZfUJVE.mjs} +3 -3
  697. package/dist/{user-DBrq4UVq.mjs.map → user-DJZfUJVE.mjs.map} +1 -1
  698. package/dist/{utils-hLzi_2Fn.mjs → utils-D4vD_Y98.mjs} +3 -3
  699. package/dist/{utils-hLzi_2Fn.mjs.map → utils-D4vD_Y98.mjs.map} +1 -1
  700. package/dist/{validate-mKo3RxBM.mjs → validate-BMLBvxOC.mjs} +24 -5
  701. package/dist/validate-BMLBvxOC.mjs.map +1 -0
  702. package/dist/{validate-C3oSmA7F.d.mts → validate-CTqt5nuE.d.mts} +15 -7
  703. package/dist/validate-CTqt5nuE.d.mts.map +1 -0
  704. package/dist/{validation-BgpucsvF.mjs → validation-npvENUCB.mjs} +11 -10
  705. package/dist/{validation-BgpucsvF.mjs.map → validation-npvENUCB.mjs.map} +1 -1
  706. package/dist/version-DB_fqCSI.mjs +7 -0
  707. package/dist/{version-C4PnpmoV.mjs.map → version-DB_fqCSI.mjs.map} +1 -1
  708. package/dist/{widgets-CJJtkiKh.mjs → widgets-BD3ho7Rq.mjs} +3 -3
  709. package/dist/{widgets-CJJtkiKh.mjs.map → widgets-BD3ho7Rq.mjs.map} +1 -1
  710. package/dist/{wxr-taxonomies-9PPSy8X6.mjs → wxr-taxonomies-B1R3vBoD.mjs} +13 -32
  711. package/dist/{wxr-taxonomies-9PPSy8X6.mjs.map → wxr-taxonomies-B1R3vBoD.mjs.map} +1 -1
  712. package/dist/{wxr-taxonomies-Bkpp_2tK.d.mts → wxr-taxonomies-Ca68766D.d.mts} +1 -1
  713. package/dist/{wxr-taxonomies-Bkpp_2tK.d.mts.map → wxr-taxonomies-Ca68766D.d.mts.map} +1 -1
  714. package/dist/{zod-generator-CG64Hnbh.mjs → zod-generator-LC-on_Vb.mjs} +52 -5
  715. package/dist/zod-generator-LC-on_Vb.mjs.map +1 -0
  716. package/package.json +32 -8
  717. package/src/api/errors.ts +12 -0
  718. package/src/api/handlers/content.ts +127 -29
  719. package/src/api/handlers/dashboard.ts +12 -2
  720. package/src/api/handlers/index.ts +16 -0
  721. package/src/api/handlers/manifest.ts +2 -0
  722. package/src/api/handlers/media-allowlist.ts +13 -2
  723. package/src/api/handlers/media-usage-work.ts +188 -0
  724. package/src/api/handlers/media-usage.ts +5 -3
  725. package/src/api/handlers/schema.ts +9 -3
  726. package/src/api/handlers/seo.ts +5 -2
  727. package/src/api/handlers/taxonomies.ts +345 -82
  728. package/src/api/media-usage-write-fence.ts +64 -0
  729. package/src/api/openapi/document.ts +199 -0
  730. package/src/api/schemas/common.ts +3 -1
  731. package/src/api/schemas/content.ts +122 -1
  732. package/src/api/schemas/media-usage.ts +133 -0
  733. package/src/api/schemas/schema.ts +78 -3
  734. package/src/api/schemas/taxonomies.ts +40 -8
  735. package/src/api/types.ts +1 -0
  736. package/src/astro/integration/index.ts +101 -56
  737. package/src/astro/integration/routes.ts +34 -4
  738. package/src/astro/integration/runtime.ts +18 -0
  739. package/src/astro/integration/virtual-modules.ts +25 -1
  740. package/src/astro/integration/vite-config.ts +16 -1
  741. package/src/astro/middleware/media-usage-write-fence.ts +30 -0
  742. package/src/astro/middleware/redirect.ts +12 -2
  743. package/src/astro/middleware/scoped-db.ts +21 -0
  744. package/src/astro/middleware.ts +140 -16
  745. package/src/astro/routes/api/admin/media-usage/collection-deletions/index.ts +22 -0
  746. package/src/astro/routes/api/admin/media-usage/collection-deletions/retry.ts +22 -0
  747. package/src/astro/routes/api/admin/media-usage/work/index.ts +26 -0
  748. package/src/astro/routes/api/admin/media-usage/work/retry.ts +26 -0
  749. package/src/astro/routes/api/admin/plugins/[id]/disable.ts +4 -0
  750. package/src/astro/routes/api/admin/plugins/[id]/enable.ts +4 -0
  751. package/src/astro/routes/api/admin/plugins/[id]/uninstall.ts +4 -0
  752. package/src/astro/routes/api/admin/plugins/[id]/update.ts +4 -0
  753. package/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +4 -0
  754. package/src/astro/routes/api/admin/plugins/registry/[id]/uninstall.ts +4 -0
  755. package/src/astro/routes/api/admin/plugins/registry/[id]/update.ts +4 -0
  756. package/src/astro/routes/api/admin/plugins/registry/install.ts +4 -0
  757. package/src/astro/routes/api/auth/dev-bypass.ts +0 -4
  758. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +67 -31
  759. package/src/astro/routes/api/import/wordpress/execute.ts +3 -10
  760. package/src/astro/routes/api/mcp.ts +1 -0
  761. package/src/astro/routes/api/plugins/[pluginId]/[...path].ts +7 -1
  762. package/src/astro/routes/api/schema/collections/[slug]/index.ts +1 -7
  763. package/src/astro/routes/api/setup/dev-bypass.ts +2 -7
  764. package/src/astro/routes/api/setup/index.ts +5 -13
  765. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +1 -0
  766. package/src/astro/routes/api/taxonomies/[name]/translations.ts +48 -0
  767. package/src/astro/routes/api/taxonomies/[name].ts +102 -0
  768. package/src/astro/types.ts +25 -4
  769. package/src/cleanup.ts +6 -12
  770. package/src/cli/commands/doctor.ts +193 -6
  771. package/src/cli/commands/export-seed.ts +4 -1
  772. package/src/cli/commands/migrate.ts +593 -0
  773. package/src/cli/index.ts +2 -23
  774. package/src/client/index.ts +119 -0
  775. package/src/components/Block.astro +12 -0
  776. package/src/components/InlinePortableTextEditor.tsx +89 -1
  777. package/src/content/converters/mark-safety.ts +120 -0
  778. package/src/content/converters/portable-text-to-prosemirror.ts +25 -16
  779. package/src/content/converters/prosemirror-to-portable-text.ts +12 -2
  780. package/src/content-list-query.ts +29 -0
  781. package/src/database/index.ts +8 -2
  782. package/src/database/migrations/060_collection_admin_config.ts +16 -0
  783. package/src/database/migrations/061_media_usage_cleanup.ts +77 -0
  784. package/src/database/migrations/062_media_usage_cleanup_fence.ts +295 -0
  785. package/src/database/migrations/063_media_usage_incremental_work.ts +397 -0
  786. package/src/database/migrations/064_fts_plain_text.ts +253 -0
  787. package/src/database/migrations/065_media_usage_collection_deletion.ts +85 -0
  788. package/src/database/migrations/066_media_usage_reconciliation.ts +115 -0
  789. package/src/database/migrations/067_indexed_content_fields.ts +39 -0
  790. package/src/database/migrations/068_content_taxonomy_entry_groups.ts +61 -0
  791. package/src/database/migrations/069_collection_title_date_fields.ts +45 -0
  792. package/src/database/migrations/070_collection_routable.ts +18 -0
  793. package/src/database/migrations/policy.ts +70 -0
  794. package/src/database/migrations/runner.ts +71 -3
  795. package/src/database/repositories/content.ts +861 -96
  796. package/src/database/repositories/media-usage-work.ts +848 -0
  797. package/src/database/repositories/media-usage.ts +1502 -138
  798. package/src/database/repositories/revision.ts +17 -6
  799. package/src/database/repositories/taxonomy.ts +234 -103
  800. package/src/database/repositories/types.ts +51 -2
  801. package/src/database/types.ts +134 -16
  802. package/src/db/adapters.ts +78 -2
  803. package/src/db/index.ts +6 -1
  804. package/src/db/libsql-migrations.ts +72 -0
  805. package/src/db/postgres-migrations.ts +59 -0
  806. package/src/db/sqlite-migrations.ts +37 -0
  807. package/src/emdash-runtime.ts +271 -35
  808. package/src/fields/file.ts +7 -4
  809. package/src/fields/image.ts +8 -1
  810. package/src/fields/types.ts +18 -5
  811. package/src/i18n/config.ts +32 -0
  812. package/src/i18n/normalize.ts +31 -0
  813. package/src/i18n/repair-locale-casing.ts +0 -14
  814. package/src/i18n/taxonomy-locale-diagnostic.ts +47 -0
  815. package/src/import/wxr-taxonomies.ts +7 -47
  816. package/src/index.ts +32 -3
  817. package/src/loader.ts +119 -65
  818. package/src/mcp/server.ts +242 -34
  819. package/src/media/usage/activation.ts +626 -0
  820. package/src/media/usage/capture-triggers.ts +543 -0
  821. package/src/media/usage/cleanup.ts +305 -0
  822. package/src/media/usage/collection-deletion-processor.ts +356 -0
  823. package/src/media/usage/collection-deletion.ts +836 -0
  824. package/src/media/usage/content-fields.ts +40 -3
  825. package/src/media/usage/content-refresh.ts +356 -47
  826. package/src/media/usage/content-repair.ts +180 -61
  827. package/src/media/usage/content-snapshots.ts +85 -105
  828. package/src/media/usage/projection-fingerprint.ts +129 -0
  829. package/src/media/usage/reconciliation-processor.ts +372 -0
  830. package/src/media/usage/reconciliation.ts +1117 -0
  831. package/src/media/usage/source-key.ts +4 -0
  832. package/src/media/usage/types.ts +2 -0
  833. package/src/media/usage/work-processor.ts +248 -0
  834. package/src/menus/index.ts +246 -141
  835. package/src/migrations/config-loader.ts +205 -0
  836. package/src/migrations/direct-executor.ts +141 -0
  837. package/src/migrations/identity.ts +46 -0
  838. package/src/migrations/index.ts +19 -0
  839. package/src/migrations/integration-metadata.ts +67 -0
  840. package/src/migrations/manifest-builder.ts +55 -0
  841. package/src/migrations/manifest-writer.ts +63 -0
  842. package/src/migrations/manifest.ts +197 -0
  843. package/src/migrations/protocol.ts +59 -0
  844. package/src/migrations/target.ts +35 -0
  845. package/src/plugin-types.ts +8 -0
  846. package/src/plugins/adapt-sandbox-entry.ts +24 -5
  847. package/src/plugins/context.ts +24 -7
  848. package/src/plugins/index.ts +9 -0
  849. package/src/plugins/routes.ts +43 -1
  850. package/src/plugins/sandbox/index.ts +10 -1
  851. package/src/plugins/sandbox/types.ts +78 -1
  852. package/src/plugins/scheduler/node.ts +13 -1
  853. package/src/plugins/scheduler/types.ts +2 -0
  854. package/src/plugins/types.ts +39 -2
  855. package/src/query.ts +29 -37
  856. package/src/scheduler-health.ts +46 -0
  857. package/src/schema/registry.ts +777 -255
  858. package/src/schema/types.ts +50 -8
  859. package/src/schema/url-pattern.ts +33 -0
  860. package/src/schema/zod-generator.ts +77 -5
  861. package/src/search/fts-manager.ts +117 -76
  862. package/src/search/query.ts +32 -11
  863. package/src/search/types.ts +2 -0
  864. package/src/seed/apply.ts +68 -15
  865. package/src/seed/types.ts +11 -3
  866. package/src/seed/validate.ts +52 -2
  867. package/src/taxonomies/index.ts +77 -75
  868. package/src/taxonomies/term-counts.ts +21 -8
  869. package/src/utils/slugify.ts +1 -29
  870. package/src/virtual-modules.d.ts +24 -0
  871. package/dist/adapters-BMyNSSZi.d.mts.map +0 -1
  872. package/dist/api-D2hunekQ.mjs.map +0 -1
  873. package/dist/apply-kwNEJW2l.mjs.map +0 -1
  874. package/dist/config-DyF3OKg_.mjs.map +0 -1
  875. package/dist/content-CJ94NEx5.mjs.map +0 -1
  876. package/dist/content-refresh-Xw7aKmou.mjs +0 -1629
  877. package/dist/content-refresh-Xw7aKmou.mjs.map +0 -1
  878. package/dist/context-C_1nrqnW.mjs.map +0 -1
  879. package/dist/dashboard-DINnNVVU.mjs.map +0 -1
  880. package/dist/errors-BucQ8VYz.mjs.map +0 -1
  881. package/dist/fts-manager-BITakW_R.mjs.map +0 -1
  882. package/dist/index-M37iz29I.d.mts.map +0 -1
  883. package/dist/index-MGw47uWq.d.mts.map +0 -1
  884. package/dist/loader-CGugY7Zl.mjs.map +0 -1
  885. package/dist/media-OhYL-7Io.mjs.map +0 -1
  886. package/dist/media-allowlist-D-peH1H5.mjs.map +0 -1
  887. package/dist/media-usage-BlxfTkN0.mjs.map +0 -1
  888. package/dist/menus-Y54RDPIN.mjs.map +0 -1
  889. package/dist/options-CA4eVX6P.d.mts.map +0 -1
  890. package/dist/query-C5w7PsRQ.mjs.map +0 -1
  891. package/dist/redirects-G8wAhWAf.mjs.map +0 -1
  892. package/dist/registry-Dbodb4Uo.mjs +0 -840
  893. package/dist/registry-Dbodb4Uo.mjs.map +0 -1
  894. package/dist/relations-G2ta7GIl.d.mts.map +0 -1
  895. package/dist/runner-BFTrkYhu.mjs.map +0 -1
  896. package/dist/runner-BaADli8l.d.mts.map +0 -1
  897. package/dist/search-FZhx5dDU.mjs.map +0 -1
  898. package/dist/seo-CobNfOvb.mjs.map +0 -1
  899. package/dist/slugify-C_tqlU4G.mjs +0 -30
  900. package/dist/slugify-C_tqlU4G.mjs.map +0 -1
  901. package/dist/taxonomies-CKuaZER5.mjs.map +0 -1
  902. package/dist/taxonomies-CLN1N92E.mjs.map +0 -1
  903. package/dist/taxonomy-pOrB2kZe.mjs.map +0 -1
  904. package/dist/types-BGWPMiBO.d.mts.map +0 -1
  905. package/dist/types-BnFP1M3J.d.mts.map +0 -1
  906. package/dist/types-Cd8iuS1J.mjs.map +0 -1
  907. package/dist/types-Cz8hNch2.d.mts.map +0 -1
  908. package/dist/types-Dc4ntQjQ.d.mts.map +0 -1
  909. package/dist/types-NBI3rF-_.mjs.map +0 -1
  910. package/dist/validate-C3oSmA7F.d.mts.map +0 -1
  911. package/dist/validate-mKo3RxBM.mjs.map +0 -1
  912. package/dist/version-C4PnpmoV.mjs +0 -7
  913. package/dist/zod-generator-CG64Hnbh.mjs.map +0 -1
  914. /package/dist/{api-tokens-Dai9dND0.mjs → api-tokens-rIXC6LdE.mjs} +0 -0
  915. /package/dist/{ssrf-Cj568Dgs.mjs → ssrf-DpXgKsna.mjs} +0 -0
  916. /package/dist/{types-DKuWPYV5.mjs → types-CP1CflWf.mjs} +0 -0
@@ -1,17 +1,19 @@
1
1
  import {
2
2
  sql,
3
3
  type ExpressionBuilder,
4
- type Insertable,
5
4
  type Kysely,
5
+ type RawBuilder,
6
6
  type Selectable,
7
7
  type Transaction,
8
8
  type Updateable,
9
9
  } from "kysely";
10
10
  import { ulid } from "ulidx";
11
11
 
12
+ import { isMediaUsageProjectionFingerprint } from "../../media/usage/projection-fingerprint.js";
12
13
  import type { MediaUsageContentSourceVariant } from "../../media/usage/source-key.js";
13
14
  import type { MediaKind, MediaUsageReferenceType } from "../../media/usage/types.js";
14
15
  import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
16
+ import { isPostgres } from "../dialect-helpers.js";
15
17
  import { withTransaction } from "../transaction.js";
16
18
  import type {
17
19
  Database,
@@ -24,18 +26,35 @@ import { decodeCursor, encodeCursor, InvalidCursorError, type FindManyResult } f
24
26
 
25
27
  type DatabaseExecutor = Kysely<Database> | Transaction<Database>;
26
28
  type MediaUsageSourceNullableStringColumn =
29
+ | "collection_id"
27
30
  | "source_fingerprint"
28
31
  | "source_updated_at"
29
32
  | "revision_id"
30
33
  | "updated_at"
31
34
  | "last_attempted_at"
32
35
  | "last_error_code";
33
-
34
36
  const OCCURRENCE_BIND_COLUMNS = 13;
37
+ export const MEDIA_USAGE_GENERATION_WRITE_LEASE_MS = 60 * 60 * 1000;
35
38
  const OCCURRENCE_INSERT_BATCH_SIZE = Math.max(
36
39
  1,
37
40
  Math.floor(SQL_BATCH_SIZE / OCCURRENCE_BIND_COLUMNS),
38
41
  );
42
+
43
+ function cleanupDeleteBatchSize(cleanupLease: MediaUsageCleanupLease | undefined): number {
44
+ return cleanupLease ? SQL_BATCH_SIZE - 3 : SQL_BATCH_SIZE;
45
+ }
46
+
47
+ function canIssueCleanupStatement(canIssueStatement: (() => boolean) | undefined): boolean {
48
+ return canIssueStatement?.() ?? true;
49
+ }
50
+
51
+ function cleanupDurationSeconds(value: number): number {
52
+ if (!Number.isSafeInteger(value) || value < 0) {
53
+ throw new Error("Media usage cleanup duration must be a non-negative whole number of seconds");
54
+ }
55
+ return value;
56
+ }
57
+
39
58
  const CONTENT_SOURCE_ELIGIBILITY = sql<boolean>`(
40
59
  s.source_variant = 'draft_overlay'
41
60
  OR (
@@ -49,14 +68,37 @@ const CONTENT_SOURCE_ELIGIBILITY = sql<boolean>`(
49
68
  AND overlay.collection_slug = s.collection_slug
50
69
  AND overlay.content_id = s.content_id
51
70
  AND overlay.source_variant = 'draft_overlay'
71
+ AND ${contentSourceMatchesActiveCollection("overlay", "s.collection_id")}
52
72
  )
53
73
  )
54
74
  )
55
75
  )`;
56
76
 
77
+ type ContentSourceAlias = "deleted_source" | "overlay" | "s" | "state";
78
+ type CurrentCollectionIdReference = "collection.id" | "page.collection_id" | "s.collection_id";
79
+
80
+ function contentSourceMatchesActiveCollection(
81
+ source: ContentSourceAlias,
82
+ currentCollectionId: CurrentCollectionIdReference,
83
+ ): RawBuilder<boolean> {
84
+ return sql<boolean>`(
85
+ NOT EXISTS (
86
+ SELECT 1
87
+ FROM _emdash_media_usage_activation AS activation
88
+ WHERE activation.task_key = 'incremental_capture'
89
+ AND activation.state = 'active'
90
+ )
91
+ OR (
92
+ ${sql.ref(`${source}.collection_id`)} = ${sql.ref(currentCollectionId)}
93
+ AND ${sql.ref(`${source}.identity_version`)} = 1
94
+ )
95
+ )`;
96
+ }
97
+
57
98
  export interface MediaUsageSourceInput {
58
99
  sourceKey: string;
59
100
  sourceType: string;
101
+ collectionId?: string | null;
60
102
  collectionSlug?: string | null;
61
103
  contentId?: string | null;
62
104
  sourceVariant: MediaUsageContentSourceVariant;
@@ -72,6 +114,7 @@ export interface MediaUsageSourceInput {
72
114
  sourceUpdatedAt?: string | null;
73
115
  sourceVersion?: number | null;
74
116
  sourceFingerprint?: string | null;
117
+ identityVersion?: number | null;
75
118
  sourceCompleteness?: MediaUsageSourceCompleteness;
76
119
  lastAttemptedAt?: string | null;
77
120
  lastErrorCode?: string | null;
@@ -92,6 +135,7 @@ export interface MediaUsageOccurrenceInput {
92
135
  export interface MediaUsageSource {
93
136
  sourceKey: string;
94
137
  sourceType: string;
138
+ collectionId: string | null;
95
139
  collectionSlug: string | null;
96
140
  contentId: string | null;
97
141
  sourceVariant: string;
@@ -108,6 +152,7 @@ export interface MediaUsageSource {
108
152
  sourceUpdatedAt: string | null;
109
153
  sourceVersion: number | null;
110
154
  sourceFingerprint: string | null;
155
+ identityVersion: number | null;
111
156
  sourceCompleteness: string;
112
157
  lastAttemptedAt: string | null;
113
158
  lastErrorCode: string | null;
@@ -118,6 +163,7 @@ export interface MediaUsageSource {
118
163
 
119
164
  export interface MediaUsageGuardedReplaceResult {
120
165
  replaced: boolean;
166
+ unchanged: boolean;
121
167
  /** Populated only when a guarded replacement did not win the current source row. */
122
168
  source: MediaUsageSource | null;
123
169
  }
@@ -137,6 +183,59 @@ export interface MediaUsageGuardedAttemptResult {
137
183
  source: MediaUsageSource | null;
138
184
  }
139
185
 
186
+ export interface MediaUsageSourceGenerationDeletionMeasurement {
187
+ occurrenceCount: number;
188
+ occurrenceBytes: number;
189
+ exceedsOccurrenceLimit: boolean;
190
+ }
191
+
192
+ export interface MediaUsageCleanupCursor {
193
+ createdAt: string;
194
+ id: string;
195
+ }
196
+
197
+ export interface MediaUsageCleanupClaim {
198
+ leaseToken: string;
199
+ cursor: MediaUsageCleanupCursor | null;
200
+ claimedAt: string;
201
+ scanBeforeAt: string;
202
+ consecutiveFailures: number;
203
+ }
204
+
205
+ export interface MediaUsageCleanupCandidate {
206
+ id: string;
207
+ sourceKey: string;
208
+ generation: string;
209
+ createdAt: string;
210
+ currentGeneration: string | null;
211
+ indexedAt: string | null;
212
+ writeLeaseExpiresAt: string | null;
213
+ }
214
+
215
+ export interface MediaUsageCleanupLease {
216
+ leaseToken: string;
217
+ }
218
+
219
+ export interface MediaUsageCleanupDeleteOptions {
220
+ candidateIds?: readonly string[];
221
+ cleanupLease?: MediaUsageCleanupLease;
222
+ canIssueStatement?: () => boolean;
223
+ }
224
+
225
+ export interface MediaUsageCleanupCompletion {
226
+ leaseToken: string;
227
+ nextCursor: MediaUsageCleanupCursor | null;
228
+ sweepComplete: boolean;
229
+ candidateCount: number;
230
+ deletedOrphans: number;
231
+ deletedStale: number;
232
+ deletedAbandoned: number;
233
+ deletedWriteLeases: number;
234
+ backlogLowerBound: number;
235
+ scanHasMore: boolean;
236
+ durationMs: number;
237
+ }
238
+
140
239
  export interface MediaUsageIndexStatusRepairInput extends MediaUsageIndexStatusIdentity {
141
240
  runToken: string;
142
241
  schemaVersion?: number;
@@ -155,6 +254,30 @@ export interface MediaUsageIndexStatusFinalizeInput extends MediaUsageIndexStatu
155
254
  updatedAt?: string;
156
255
  }
157
256
 
257
+ export interface MediaUsageIndexStatusEpochRepairInput extends MediaUsageIndexStatusIdentity {
258
+ collectionId: string;
259
+ runToken: string;
260
+ schemaVersion: number;
261
+ }
262
+
263
+ export interface MediaUsageIndexStatusEpochRepairRun {
264
+ changeEpoch: number | string;
265
+ startedAt: string;
266
+ }
267
+
268
+ export interface MediaUsageIndexStatusEpochFinalizeInput extends MediaUsageIndexStatusEpochRepairInput {
269
+ startingEpoch: number | string;
270
+ status: Exclude<MediaUsageIndexStatusValue, "never" | "running" | "stale">;
271
+ indexedSourceCount: number;
272
+ failedSourceCount: number;
273
+ lastErrorCode: string | null;
274
+ }
275
+
276
+ export interface MediaUsageIncrementalStatusIdentity {
277
+ collectionId: string;
278
+ collectionSlug: string;
279
+ }
280
+
158
281
  export interface MediaUsageGuardedIndexStatusResult {
159
282
  finalized: boolean;
160
283
  status: MediaUsageIndexStatus | null;
@@ -214,6 +337,7 @@ export interface MediaUsageCollectionIndexStatusScope {
214
337
  collectionSlug: string;
215
338
  status: string | null;
216
339
  schemaVersion: number | null;
340
+ reconciliationRequired: boolean;
217
341
  }
218
342
 
219
343
  export interface MediaUsageEntrySource {
@@ -231,6 +355,7 @@ export interface MediaUsageEntryGroup {
231
355
  interface MediaUsageSourceRow {
232
356
  source_key: string;
233
357
  source_type: string;
358
+ collection_id: string | null;
234
359
  collection_slug: string | null;
235
360
  content_id: string | null;
236
361
  source_variant: string;
@@ -247,6 +372,7 @@ interface MediaUsageSourceRow {
247
372
  source_updated_at: string | null;
248
373
  source_version: number | null;
249
374
  source_fingerprint: string | null;
375
+ identity_version: number | null;
250
376
  source_completeness: string;
251
377
  last_attempted_at: string | null;
252
378
  last_error_code: string | null;
@@ -279,6 +405,7 @@ export interface MediaUsageRecord {
279
405
  interface JoinedUsageRow {
280
406
  source_key: string;
281
407
  source_type: string;
408
+ collection_id: string | null;
282
409
  collection_slug: string | null;
283
410
  content_id: string | null;
284
411
  source_variant: string;
@@ -295,6 +422,7 @@ interface JoinedUsageRow {
295
422
  source_updated_at: string | null;
296
423
  source_version: number | null;
297
424
  source_fingerprint: string | null;
425
+ identity_version: number | null;
298
426
  source_completeness: string;
299
427
  last_attempted_at: string | null;
300
428
  last_error_code: string | null;
@@ -329,12 +457,26 @@ export class MediaUsageRepository {
329
457
  occurrences: readonly MediaUsageOccurrenceInput[],
330
458
  ): Promise<MediaUsageSource> {
331
459
  const generation = ulid();
332
- const now = new Date().toISOString();
333
460
 
334
- await withTransaction(this.db, async (trx) => {
335
- await this.insertOccurrences(trx, source.sourceKey, generation, occurrences, now);
336
- await this.upsertSource(trx, source, generation, now);
337
- });
461
+ const admitted = await this.withGenerationWriteLease(
462
+ source,
463
+ generation,
464
+ async (leaseToken, now) => {
465
+ await withTransaction(this.db, async (trx) => {
466
+ if (!(await this.lockCanonicalSourceCollection(trx, source))) {
467
+ throw new Error(`Media usage collection is no longer current for ${source.sourceKey}`);
468
+ }
469
+ await this.insertOccurrences(trx, source.sourceKey, generation, occurrences, now);
470
+ const promoted = await this.upsertSource(trx, source, generation, now, leaseToken);
471
+ if (!promoted) {
472
+ throw new Error(`Media usage generation lease expired for ${source.sourceKey}`);
473
+ }
474
+ });
475
+ },
476
+ );
477
+ if (!admitted) {
478
+ throw new Error(`Media usage collection is no longer current for ${source.sourceKey}`);
479
+ }
338
480
 
339
481
  const replaced = await this.findSource(source.sourceKey);
340
482
  if (!replaced) {
@@ -347,22 +489,36 @@ export class MediaUsageRepository {
347
489
  occurrences: readonly MediaUsageOccurrenceInput[],
348
490
  expectedCurrentGeneration: string | null,
349
491
  ): Promise<MediaUsageGuardedReplaceResult> {
492
+ if (
493
+ expectedCurrentGeneration !== null &&
494
+ (await this.projectionMatchesCurrentGeneration(source, expectedCurrentGeneration))
495
+ ) {
496
+ return { replaced: false, unchanged: true, source: null };
497
+ }
350
498
  const generation = ulid();
351
- const now = new Date().toISOString();
352
- const row = this.buildSourceRow(source, generation, now);
353
499
  let replaced = false;
354
500
 
355
- await withTransaction(this.db, async (trx) => {
356
- await this.insertOccurrences(trx, source.sourceKey, generation, occurrences, now);
357
- if (expectedCurrentGeneration === null) {
358
- replaced = await this.insertSourceIfAbsent(trx, row);
359
- return;
360
- }
361
- replaced = await this.updateSourceIfGeneration(trx, row, expectedCurrentGeneration);
501
+ await this.withGenerationWriteLease(source, generation, async (leaseToken, now) => {
502
+ const row = this.buildSourceRow(source, generation, now);
503
+ await withTransaction(this.db, async (trx) => {
504
+ if (!(await this.lockCanonicalSourceCollection(trx, source))) return;
505
+ await this.insertOccurrences(trx, source.sourceKey, generation, occurrences, now);
506
+ if (expectedCurrentGeneration === null) {
507
+ replaced = await this.insertSourceIfAbsent(trx, row, leaseToken);
508
+ return;
509
+ }
510
+ replaced = await this.updateSourceIfGeneration(
511
+ trx,
512
+ row,
513
+ expectedCurrentGeneration,
514
+ leaseToken,
515
+ );
516
+ });
362
517
  });
363
518
 
364
519
  return {
365
520
  replaced,
521
+ unchanged: false,
366
522
  source: replaced ? null : await this.findSource(source.sourceKey),
367
523
  };
368
524
  }
@@ -397,41 +553,99 @@ export class MediaUsageRepository {
397
553
  return sources;
398
554
  }
399
555
 
556
+ async measureSourceGenerationDeletion(
557
+ sourceKey: string,
558
+ generation: string,
559
+ maxOccurrences: number,
560
+ ): Promise<MediaUsageSourceGenerationDeletionMeasurement> {
561
+ if (!Number.isSafeInteger(maxOccurrences) || maxOccurrences < 0) {
562
+ throw new Error("Media usage deletion measurement requires a non-negative row limit");
563
+ }
564
+ const payload = sql<string>`
565
+ COALESCE(field_slug, '') || COALESCE(field_path, '') ||
566
+ COALESCE(reference_type, '') || COALESCE(media_id, '') ||
567
+ COALESCE(provider, '') || COALESCE(provider_asset_id, '') ||
568
+ COALESCE(media_kind, '') || COALESCE(mime_type, '')
569
+ `;
570
+ const occurrenceBytes = isPostgres(this.db)
571
+ ? sql<number>`octet_length(${payload})`
572
+ : sql<number>`length(CAST(${payload} AS BLOB))`;
573
+ const rows = await this.db
574
+ .selectFrom("_emdash_media_usage")
575
+ .select(occurrenceBytes.as("occurrence_bytes"))
576
+ .where("source_key", "=", sourceKey)
577
+ .where("generation", "=", generation)
578
+ .limit(maxOccurrences + 1)
579
+ .execute();
580
+
581
+ return {
582
+ occurrenceCount: rows.length,
583
+ occurrenceBytes: rows.reduce((total, row) => total + Number(row.occurrence_bytes), 0),
584
+ exceedsOccurrenceLimit: rows.length > maxOccurrences,
585
+ };
586
+ }
587
+
400
588
  async replaceSourceIfMatching(
401
589
  source: MediaUsageSourceInput,
402
590
  occurrences: readonly MediaUsageOccurrenceInput[],
403
591
  expectedSource: MediaUsageSource | null,
404
592
  ): Promise<MediaUsageGuardedReplaceResult> {
593
+ if (
594
+ expectedSource !== null &&
595
+ (await this.projectionMatchesExpectedSource(source, expectedSource))
596
+ ) {
597
+ return { replaced: false, unchanged: true, source: null };
598
+ }
405
599
  const generation = ulid();
406
- const now = new Date().toISOString();
407
- const row = this.buildSourceRow(source, generation, now);
408
600
  let replaced = false;
409
601
 
410
- await withTransaction(this.db, async (trx) => {
411
- await this.insertOccurrences(trx, source.sourceKey, generation, occurrences, now);
412
- if (expectedSource === null) {
413
- replaced = await this.insertSourceIfAbsent(trx, row);
414
- return;
415
- }
416
- replaced = await this.updateSourceIfMatching(trx, row, expectedSource);
602
+ await this.withGenerationWriteLease(source, generation, async (leaseToken, now) => {
603
+ const row = this.buildSourceRow(source, generation, now);
604
+ await withTransaction(this.db, async (trx) => {
605
+ if (!(await this.lockCanonicalSourceCollection(trx, source))) return;
606
+ await this.insertOccurrences(trx, source.sourceKey, generation, occurrences, now);
607
+ if (expectedSource === null) {
608
+ replaced = await this.insertSourceIfAbsent(trx, row, leaseToken);
609
+ return;
610
+ }
611
+ replaced = await this.updateSourceIfMatching(trx, row, expectedSource, leaseToken);
612
+ });
417
613
  });
418
614
 
419
615
  return {
420
616
  replaced,
617
+ unchanged: false,
421
618
  source: replaced ? null : await this.findSource(source.sourceKey),
422
619
  };
423
620
  }
424
621
 
425
622
  async markSourceAttempted(source: MediaUsageSourceInput): Promise<MediaUsageSource> {
426
- const now = new Date().toISOString();
427
- const row = this.buildAttemptedSourceRow(source, now);
428
- const updates = this.attemptedSourceUpdateSet(source, row);
623
+ if (source.collectionId !== undefined && source.collectionId !== null) {
624
+ const expectedSource = await this.findSource(source.sourceKey);
625
+ const result = await this.markSourceAttemptedIfMatching(source, expectedSource);
626
+ if (!result.attempted) {
627
+ throw new Error(`Canonical media usage source ${source.sourceKey} is no longer current`);
628
+ }
629
+ const attempted = await this.findSource(source.sourceKey);
630
+ if (!attempted) {
631
+ throw new Error(`Media usage source ${source.sourceKey} was not persisted`);
632
+ }
633
+ return attempted;
634
+ }
429
635
 
430
- await this.db
431
- .insertInto("_emdash_media_usage_sources")
432
- .values(row)
433
- .onConflict((oc) => oc.column("source_key").doUpdateSet(updates))
434
- .execute();
636
+ const generation = ulid();
637
+ await this.withGenerationWriteLease(source, generation, async (leaseToken, now) => {
638
+ const row = this.buildAttemptedSourceRow(source, generation, now);
639
+ const updates = this.attemptedSourceUpdateSet(source, row);
640
+ const result = await this.db
641
+ .insertInto("_emdash_media_usage_sources")
642
+ .values(row)
643
+ .onConflict((oc) => oc.column("source_key").doUpdateSet(updates))
644
+ .executeTakeFirst();
645
+ if ((result.numInsertedOrUpdatedRows ?? 0n) <= 0n) {
646
+ throw new Error(`Media usage generation lease expired for ${source.sourceKey}`);
647
+ }
648
+ });
435
649
 
436
650
  const attempted = await this.findSource(source.sourceKey);
437
651
  if (!attempted) {
@@ -444,14 +658,28 @@ export class MediaUsageRepository {
444
658
  source: MediaUsageSourceInput,
445
659
  expectedSource: MediaUsageSource | null,
446
660
  ): Promise<MediaUsageGuardedAttemptResult> {
447
- const now = new Date().toISOString();
448
- const row = this.buildAttemptedSourceRow(source, now);
661
+ const generation = ulid();
449
662
  let attempted = false;
450
663
 
451
664
  if (expectedSource === null) {
452
- attempted = await this.insertSourceIfAbsent(this.db, row);
665
+ await this.withGenerationWriteLease(source, generation, async (leaseToken, now) => {
666
+ const row = this.buildAttemptedSourceRow(source, generation, now);
667
+ await withTransaction(this.db, async (trx) => {
668
+ if (!(await this.lockCanonicalSourceCollection(trx, source))) return;
669
+ attempted = await this.persistSourceIfWriteLease(
670
+ trx,
671
+ row,
672
+ leaseToken,
673
+ sql`ON CONFLICT (source_key) DO NOTHING`,
674
+ );
675
+ });
676
+ });
453
677
  } else {
454
- attempted = await this.updateAttemptedSourceIfMatching(this.db, source, row, expectedSource);
678
+ const row = this.buildAttemptedSourceRow(source, generation, new Date().toISOString());
679
+ await withTransaction(this.db, async (trx) => {
680
+ if (!(await this.lockCanonicalSourceCollection(trx, source))) return;
681
+ attempted = await this.updateAttemptedSourceIfMatching(trx, source, row, expectedSource);
682
+ });
455
683
  }
456
684
 
457
685
  return {
@@ -482,6 +710,7 @@ export class MediaUsageRepository {
482
710
  .whereRef("deleted_source.collection_slug", "=", "s.collection_slug")
483
711
  .whereRef("deleted_source.content_id", "=", "s.content_id")
484
712
  .where("deleted_source.source_variant", "in", ["columns", "draft_overlay"])
713
+ .where(contentSourceMatchesActiveCollection("deleted_source", "collection.id"))
485
714
  .where("deleted_source.content_deleted_at", "is not", null),
486
715
  ),
487
716
  ),
@@ -519,6 +748,7 @@ export class MediaUsageRepository {
519
748
  "collection.slug as collection_slug",
520
749
  "status.status as status",
521
750
  "status.schema_version as schema_version",
751
+ "status.reconciliation_required as reconciliation_required",
522
752
  ])
523
753
  .orderBy("collection.slug", "asc")
524
754
  .execute();
@@ -527,6 +757,8 @@ export class MediaUsageRepository {
527
757
  collectionSlug: row.collection_slug,
528
758
  status: row.status,
529
759
  schemaVersion: row.schema_version === null ? null : Number(row.schema_version),
760
+ reconciliationRequired:
761
+ row.reconciliation_required !== null && Number(row.reconciliation_required) !== 0,
530
762
  }));
531
763
  }
532
764
 
@@ -541,7 +773,11 @@ export class MediaUsageRepository {
541
773
  throw new InvalidCursorError(options.cursor ?? "");
542
774
  }
543
775
  let matchedGroups = this.currentContentMediaUsageBaseQuery()
544
- .select(["s.collection_slug as collection_slug", "s.content_id as content_id"])
776
+ .select([
777
+ "collection.id as collection_id",
778
+ "s.collection_slug as collection_slug",
779
+ "s.content_id as content_id",
780
+ ])
545
781
  .where("u.media_id", "=", mediaId)
546
782
  .distinct();
547
783
  if (cursor) {
@@ -574,7 +810,7 @@ export class MediaUsageRepository {
574
810
  db
575
811
  .selectFrom("page_groups as page")
576
812
  .crossJoin("_emdash_media_usage_sources as state")
577
- .select(["page.collection_slug", "page.content_id"])
813
+ .select(["page.collection_id", "page.collection_slug", "page.content_id"])
578
814
  .select((eb) =>
579
815
  eb.fn.max<string | null>("state.content_deleted_at").as("entry_deleted_at"),
580
816
  )
@@ -582,13 +818,15 @@ export class MediaUsageRepository {
582
818
  .whereRef("page.content_id", "=", "state.content_id")
583
819
  .where("state.source_type", "=", "content")
584
820
  .where("state.source_variant", "in", ["columns", "draft_overlay"])
585
- .groupBy(["page.collection_slug", "page.content_id"]),
821
+ .where(contentSourceMatchesActiveCollection("state", "page.collection_id"))
822
+ .groupBy(["page.collection_id", "page.collection_slug", "page.content_id"]),
586
823
  )
587
824
  .selectFrom("entry_state as page")
588
825
  .crossJoin("_emdash_media_usage_sources as s")
589
826
  .crossJoin("_emdash_media_usage as u")
590
827
  .whereRef("page.collection_slug", "=", "s.collection_slug")
591
828
  .whereRef("page.content_id", "=", "s.content_id")
829
+ .where(contentSourceMatchesActiveCollection("s", "page.collection_id"))
592
830
  .whereRef("s.source_key", "=", "u.source_key")
593
831
  .whereRef("s.current_generation", "=", "u.generation")
594
832
  .select(currentUsageSelect)
@@ -692,6 +930,7 @@ export class MediaUsageRepository {
692
930
  ): Promise<MediaUsageGuardedDeleteResult> {
693
931
  let deleted = false;
694
932
  await withTransaction(this.db, async (trx) => {
933
+ await this.lockCleanupBeforeSourceDelete(trx);
695
934
  const result = await trx
696
935
  .deleteFrom("_emdash_media_usage_sources")
697
936
  .where("source_key", "=", sourceKey)
@@ -714,10 +953,14 @@ export class MediaUsageRepository {
714
953
  ): Promise<MediaUsageGuardedDeleteResult> {
715
954
  let deleted = false;
716
955
  await withTransaction(this.db, async (trx) => {
956
+ await this.lockCleanupBeforeSourceDelete(trx);
717
957
  const result = await trx
718
958
  .deleteFrom("_emdash_media_usage_sources")
719
959
  .where("source_key", "=", sourceKey)
720
960
  .where(this.sourceMatchExpression(expectedSource))
961
+ .where(
962
+ this.currentCollectionExists(expectedSource.collectionId, expectedSource.collectionSlug),
963
+ )
721
964
  .executeTakeFirst();
722
965
  deleted = Number(result.numDeletedRows ?? 0) > 0;
723
966
  if (!deleted) return;
@@ -744,10 +987,14 @@ export class MediaUsageRepository {
744
987
  const tableName = `ec_${collectionSlug}`;
745
988
  let deleted = false;
746
989
  await withTransaction(this.db, async (trx) => {
990
+ await this.lockCleanupBeforeSourceDelete(trx);
747
991
  const result = await trx
748
992
  .deleteFrom("_emdash_media_usage_sources")
749
993
  .where("source_key", "=", sourceKey)
750
994
  .where(this.sourceMatchExpression(expectedSource))
995
+ .where(
996
+ this.currentCollectionExists(expectedSource.collectionId, expectedSource.collectionSlug),
997
+ )
751
998
  .where(
752
999
  sql<boolean>`NOT EXISTS (SELECT 1 FROM ${sql.ref(tableName)} WHERE id = ${contentId})`,
753
1000
  )
@@ -803,136 +1050,360 @@ export class MediaUsageRepository {
803
1050
  return deleted;
804
1051
  }
805
1052
 
806
- async findCollectionContentSources(collectionSlug: string): Promise<MediaUsageSource[]> {
807
- const rows = await this.db
1053
+ async findCollectionContentSources(
1054
+ collectionSlug: string,
1055
+ collectionId?: string,
1056
+ ): Promise<MediaUsageSource[]> {
1057
+ let query = this.db
808
1058
  .selectFrom("_emdash_media_usage_sources")
809
1059
  .selectAll()
810
1060
  .where("source_type", "=", "content")
811
1061
  .where("collection_slug", "=", collectionSlug)
812
- .orderBy("source_key", "asc")
813
- .execute();
1062
+ .orderBy("source_key", "asc");
1063
+ if (collectionId !== undefined) query = query.where("collection_id", "=", collectionId);
1064
+ const rows = await query.execute();
814
1065
  return rows.map((row) => rowToSource(row));
815
1066
  }
816
1067
 
817
- async deleteOrphanOccurrencesOlderThan(cutoff: string, limit: number): Promise<number> {
1068
+ async claimMediaUsageCleanup(input: {
1069
+ leaseToken: string;
1070
+ leaseDurationSeconds: number;
1071
+ nextEligibleDelaySeconds: number;
1072
+ sweepSafetyWindowSeconds: number;
1073
+ }): Promise<MediaUsageCleanupClaim | null> {
1074
+ const leaseDurationSeconds = cleanupDurationSeconds(input.leaseDurationSeconds);
1075
+ const nextEligibleDelaySeconds = cleanupDurationSeconds(input.nextEligibleDelaySeconds);
1076
+ const sweepSafetyWindowSeconds = cleanupDurationSeconds(input.sweepSafetyWindowSeconds);
1077
+ const claimedAt = this.cleanupTimestampOffset(0);
1078
+ const leaseExpiresAt = this.cleanupTimestampOffset(leaseDurationSeconds);
1079
+ const nextEligibleAt = this.cleanupTimestampOffset(nextEligibleDelaySeconds);
1080
+ const sweepBeforeAt = this.cleanupTimestampOffset(-sweepSafetyWindowSeconds);
1081
+ const row = await this.db
1082
+ .updateTable("_emdash_media_usage_cleanup")
1083
+ .set({
1084
+ lease_token: input.leaseToken,
1085
+ lease_expires_at: leaseExpiresAt,
1086
+ next_eligible_at: nextEligibleAt,
1087
+ last_started_at: claimedAt,
1088
+ updated_at: claimedAt,
1089
+ scan_before_at: sql<string>`CASE
1090
+ WHEN scan_before_at IS NULL THEN ${sweepBeforeAt}
1091
+ ELSE scan_before_at
1092
+ END`,
1093
+ })
1094
+ .where("task_key", "=", "projection_gc")
1095
+ .where(this.cleanupTimestampIsDue("next_eligible_at"))
1096
+ .where((eb) =>
1097
+ eb.or([eb("lease_token", "is", null), this.cleanupTimestampIsDue("lease_expires_at")]),
1098
+ )
1099
+ .returning([
1100
+ "cursor_created_at",
1101
+ "cursor_id",
1102
+ "last_started_at",
1103
+ "scan_before_at",
1104
+ "consecutive_failures",
1105
+ ])
1106
+ .executeTakeFirst();
1107
+ if (!row) return null;
1108
+ if (!row.last_started_at || !row.scan_before_at) {
1109
+ throw new Error("Media usage cleanup claim did not persist its database timestamps");
1110
+ }
1111
+ return {
1112
+ leaseToken: input.leaseToken,
1113
+ cursor:
1114
+ row.cursor_created_at && row.cursor_id
1115
+ ? { createdAt: row.cursor_created_at, id: row.cursor_id }
1116
+ : null,
1117
+ claimedAt: row.last_started_at,
1118
+ scanBeforeAt: row.scan_before_at,
1119
+ consecutiveFailures: row.consecutive_failures,
1120
+ };
1121
+ }
1122
+
1123
+ async findMediaUsageCleanupCandidates(input: {
1124
+ cutoff: string;
1125
+ cursor: MediaUsageCleanupCursor | null;
1126
+ limit: number;
1127
+ cleanupLease?: MediaUsageCleanupLease;
1128
+ }): Promise<MediaUsageCleanupCandidate[]> {
1129
+ let query = this.db
1130
+ .selectFrom("_emdash_media_usage as u")
1131
+ .leftJoin("_emdash_media_usage_sources as s", "s.source_key", "u.source_key")
1132
+ .leftJoin("_emdash_media_usage_generation_writes as writer", (join) =>
1133
+ join
1134
+ .onRef("writer.source_key", "=", "u.source_key")
1135
+ .onRef("writer.generation", "=", "u.generation"),
1136
+ )
1137
+ .select([
1138
+ "u.id as id",
1139
+ "u.source_key as source_key",
1140
+ "u.generation as generation",
1141
+ "u.created_at as created_at",
1142
+ "s.current_generation as current_generation",
1143
+ "s.indexed_at as indexed_at",
1144
+ "writer.expires_at as write_lease_expires_at",
1145
+ ])
1146
+ .where("u.created_at", "<", input.cutoff)
1147
+ .orderBy("u.created_at", "asc")
1148
+ .orderBy("u.id", "asc")
1149
+ .limit(Math.max(0, Math.floor(input.limit)));
1150
+ if (input.cleanupLease) {
1151
+ query = query.where(this.activeCleanupLeaseExpression(input.cleanupLease));
1152
+ }
1153
+
1154
+ if (input.cursor) {
1155
+ query = query.where((eb) =>
1156
+ eb.or([
1157
+ eb("u.created_at", ">", input.cursor!.createdAt),
1158
+ eb.and([
1159
+ eb("u.created_at", "=", input.cursor!.createdAt),
1160
+ eb("u.id", ">", input.cursor!.id),
1161
+ ]),
1162
+ ]),
1163
+ );
1164
+ }
1165
+
1166
+ const rows = await query.execute();
1167
+ return rows.map((row) => ({
1168
+ id: row.id,
1169
+ sourceKey: row.source_key,
1170
+ generation: row.generation,
1171
+ createdAt: row.created_at,
1172
+ currentGeneration: row.current_generation,
1173
+ indexedAt: row.indexed_at,
1174
+ writeLeaseExpiresAt: row.write_lease_expires_at,
1175
+ }));
1176
+ }
1177
+
1178
+ async completeMediaUsageCleanup(input: MediaUsageCleanupCompletion): Promise<boolean> {
1179
+ const updates = {
1180
+ lease_token: null,
1181
+ lease_expires_at: null,
1182
+ cursor_created_at: input.sweepComplete ? null : (input.nextCursor?.createdAt ?? null),
1183
+ cursor_id: input.sweepComplete ? null : (input.nextCursor?.id ?? null),
1184
+ ...(input.sweepComplete ? { scan_before_at: null } : {}),
1185
+ consecutive_failures: 0,
1186
+ last_completed_at: this.cleanupTimestampOffset(0),
1187
+ last_candidate_count: input.candidateCount,
1188
+ last_deleted_orphans: input.deletedOrphans,
1189
+ last_deleted_stale: input.deletedStale,
1190
+ last_deleted_abandoned: input.deletedAbandoned,
1191
+ last_deleted_write_leases: input.deletedWriteLeases,
1192
+ last_backlog_lower_bound: input.backlogLowerBound,
1193
+ last_scan_has_more: input.scanHasMore ? 1 : 0,
1194
+ last_duration_ms: input.durationMs,
1195
+ last_error_code: null,
1196
+ updated_at: this.cleanupTimestampOffset(0),
1197
+ };
1198
+ const result = await this.db
1199
+ .updateTable("_emdash_media_usage_cleanup")
1200
+ .set(updates)
1201
+ .where("task_key", "=", "projection_gc")
1202
+ .where("lease_token", "=", input.leaseToken)
1203
+ .where(this.cleanupLeaseExpiryIsInFuture("_emdash_media_usage_cleanup.lease_expires_at"))
1204
+ .executeTakeFirst();
1205
+ return Number(result.numUpdatedRows ?? 0) > 0;
1206
+ }
1207
+
1208
+ async failMediaUsageCleanup(input: {
1209
+ leaseToken: string;
1210
+ retryDelaySeconds: number;
1211
+ consecutiveFailures: number;
1212
+ durationMs: number;
1213
+ errorCode: string;
1214
+ }): Promise<boolean> {
1215
+ const retryDelaySeconds = cleanupDurationSeconds(input.retryDelaySeconds);
1216
+ const result = await this.db
1217
+ .updateTable("_emdash_media_usage_cleanup")
1218
+ .set({
1219
+ lease_token: null,
1220
+ lease_expires_at: null,
1221
+ next_eligible_at: this.cleanupTimestampOffset(retryDelaySeconds),
1222
+ consecutive_failures: input.consecutiveFailures,
1223
+ last_completed_at: this.cleanupTimestampOffset(0),
1224
+ last_duration_ms: input.durationMs,
1225
+ last_error_code: input.errorCode,
1226
+ updated_at: this.cleanupTimestampOffset(0),
1227
+ })
1228
+ .where("task_key", "=", "projection_gc")
1229
+ .where("lease_token", "=", input.leaseToken)
1230
+ .where(this.cleanupLeaseExpiryIsInFuture("_emdash_media_usage_cleanup.lease_expires_at"))
1231
+ .executeTakeFirst();
1232
+ return Number(result.numUpdatedRows ?? 0) > 0;
1233
+ }
1234
+
1235
+ async deleteOrphanOccurrencesOlderThan(
1236
+ cutoff: string,
1237
+ limit: number,
1238
+ options: MediaUsageCleanupDeleteOptions = {},
1239
+ ): Promise<number> {
818
1240
  const batchLimit = Math.floor(limit);
819
1241
  if (batchLimit <= 0) return 0;
1242
+ if (options.candidateIds) {
1243
+ return this.deleteOrphanCandidateIds(
1244
+ options.candidateIds.slice(0, batchLimit),
1245
+ cutoff,
1246
+ options.cleanupLease,
1247
+ options.canIssueStatement,
1248
+ );
1249
+ }
1250
+ if (!canIssueCleanupStatement(options.canIssueStatement)) return 0;
820
1251
 
821
- const rows = await this.db
1252
+ let query = this.db
822
1253
  .selectFrom("_emdash_media_usage as u")
823
1254
  .leftJoin("_emdash_media_usage_sources as s", (join) =>
824
1255
  join.onRef("s.source_key", "=", "u.source_key"),
825
1256
  )
1257
+ .leftJoin("_emdash_media_usage_generation_writes as writer", (join) =>
1258
+ join
1259
+ .onRef("writer.source_key", "=", "u.source_key")
1260
+ .onRef("writer.generation", "=", "u.generation"),
1261
+ )
826
1262
  .select("u.id")
827
1263
  .where("s.source_key", "is", null)
828
1264
  .where("u.created_at", "<", cutoff)
1265
+ .where(this.noActiveGenerationWriteExpression("u"))
829
1266
  .orderBy("u.created_at", "asc")
830
1267
  .orderBy("u.id", "asc")
831
- .limit(batchLimit)
832
- .execute();
1268
+ .limit(batchLimit);
1269
+ if (options.cleanupLease) {
1270
+ query = query.where(this.activeCleanupLeaseExpression(options.cleanupLease));
1271
+ }
1272
+ const rows = await query.execute();
833
1273
 
834
- let deleted = 0;
835
- for (const idBatch of chunks(
1274
+ return this.deleteOrphanCandidateIds(
836
1275
  rows.map((row) => row.id),
837
- SQL_BATCH_SIZE,
838
- )) {
839
- const result = await this.db
840
- .deleteFrom("_emdash_media_usage")
841
- .where("id", "in", idBatch)
842
- .where("created_at", "<", cutoff)
843
- .where(
844
- sql<boolean>`NOT EXISTS (SELECT 1 FROM _emdash_media_usage_sources s WHERE s.source_key = _emdash_media_usage.source_key)`,
845
- )
846
- .executeTakeFirst();
847
- deleted += Number(result.numDeletedRows ?? 0);
848
- }
849
- return deleted;
1276
+ cutoff,
1277
+ options.cleanupLease,
1278
+ options.canIssueStatement,
1279
+ );
850
1280
  }
851
1281
 
852
- async deleteStaleGenerationsOlderThan(cutoff: string, limit: number): Promise<number> {
1282
+ async deleteStaleGenerationsOlderThan(
1283
+ cutoff: string,
1284
+ limit: number,
1285
+ options: MediaUsageCleanupDeleteOptions = {},
1286
+ ): Promise<number> {
853
1287
  const batchLimit = Math.floor(limit);
854
1288
  if (batchLimit <= 0) return 0;
1289
+ if (options.candidateIds) {
1290
+ return this.deleteStaleCandidateIds(
1291
+ options.candidateIds.slice(0, batchLimit),
1292
+ cutoff,
1293
+ options.cleanupLease,
1294
+ options.canIssueStatement,
1295
+ );
1296
+ }
1297
+ if (!canIssueCleanupStatement(options.canIssueStatement)) return 0;
855
1298
 
856
- const rows = await this.db
1299
+ let query = this.db
857
1300
  .selectFrom("_emdash_media_usage as u")
858
1301
  .innerJoin("_emdash_media_usage_sources as s", (join) =>
859
1302
  join.onRef("s.source_key", "=", "u.source_key"),
860
1303
  )
1304
+ .leftJoin("_emdash_media_usage_generation_writes as writer", (join) =>
1305
+ join
1306
+ .onRef("writer.source_key", "=", "u.source_key")
1307
+ .onRef("writer.generation", "=", "u.generation"),
1308
+ )
861
1309
  .select("u.id")
862
1310
  .where("u.created_at", "<", cutoff)
863
1311
  .whereRef("u.generation", "!=", "s.current_generation")
864
1312
  .whereRef("u.created_at", "<", "s.indexed_at")
1313
+ .where(this.noActiveGenerationWriteExpression("u"))
865
1314
  .orderBy("u.created_at", "asc")
866
1315
  .orderBy("u.id", "asc")
867
- .limit(batchLimit)
868
- .execute();
869
-
870
- const ids = rows.map((row) => row.id);
871
- if (ids.length === 0) return 0;
872
-
873
- let deleted = 0;
874
- for (const idBatch of chunks(ids, SQL_BATCH_SIZE)) {
875
- const result = await this.db
876
- .deleteFrom("_emdash_media_usage")
877
- .where("id", "in", idBatch)
878
- .where("created_at", "<", cutoff)
879
- .where((eb) =>
880
- eb.exists(
881
- eb
882
- .selectFrom("_emdash_media_usage_sources as s")
883
- .select("s.source_key")
884
- .whereRef("s.source_key", "=", "_emdash_media_usage.source_key")
885
- .whereRef("s.current_generation", "!=", "_emdash_media_usage.generation")
886
- .whereRef("_emdash_media_usage.created_at", "<", "s.indexed_at"),
887
- ),
888
- )
889
- .executeTakeFirst();
890
- deleted += Number(result.numDeletedRows ?? 0);
1316
+ .limit(batchLimit);
1317
+ if (options.cleanupLease) {
1318
+ query = query.where(this.activeCleanupLeaseExpression(options.cleanupLease));
891
1319
  }
892
- return deleted;
1320
+ const rows = await query.execute();
1321
+
1322
+ return this.deleteStaleCandidateIds(
1323
+ rows.map((row) => row.id),
1324
+ cutoff,
1325
+ options.cleanupLease,
1326
+ options.canIssueStatement,
1327
+ );
893
1328
  }
894
1329
 
895
- async deleteAbandonedGenerationsOlderThan(cutoff: string, limit: number): Promise<number> {
1330
+ async deleteAbandonedGenerationsOlderThan(
1331
+ cutoff: string,
1332
+ limit: number,
1333
+ options: MediaUsageCleanupDeleteOptions = {},
1334
+ ): Promise<number> {
896
1335
  const batchLimit = Math.floor(limit);
897
1336
  if (batchLimit <= 0) return 0;
1337
+ if (options.candidateIds) {
1338
+ return this.deleteAbandonedCandidateIds(
1339
+ options.candidateIds.slice(0, batchLimit),
1340
+ cutoff,
1341
+ options.cleanupLease,
1342
+ options.canIssueStatement,
1343
+ );
1344
+ }
1345
+ if (!canIssueCleanupStatement(options.canIssueStatement)) return 0;
898
1346
 
899
- const rows = await this.db
1347
+ let query = this.db
900
1348
  .selectFrom("_emdash_media_usage as u")
901
1349
  .innerJoin("_emdash_media_usage_sources as s", (join) =>
902
1350
  join.onRef("s.source_key", "=", "u.source_key"),
903
1351
  )
1352
+ .leftJoin("_emdash_media_usage_generation_writes as writer", (join) =>
1353
+ join
1354
+ .onRef("writer.source_key", "=", "u.source_key")
1355
+ .onRef("writer.generation", "=", "u.generation"),
1356
+ )
904
1357
  .select("u.id")
905
1358
  .where("u.created_at", "<", cutoff)
906
1359
  .whereRef("u.generation", "!=", "s.current_generation")
907
1360
  .whereRef("u.created_at", ">=", "s.indexed_at")
1361
+ .where(this.noActiveGenerationWriteExpression("u"))
908
1362
  .orderBy("u.created_at", "asc")
909
1363
  .orderBy("u.id", "asc")
910
- .limit(batchLimit)
911
- .execute();
1364
+ .limit(batchLimit);
1365
+ if (options.cleanupLease) {
1366
+ query = query.where(this.activeCleanupLeaseExpression(options.cleanupLease));
1367
+ }
1368
+ const rows = await query.execute();
912
1369
 
913
- let deleted = 0;
914
- for (const idBatch of chunks(
1370
+ return this.deleteAbandonedCandidateIds(
915
1371
  rows.map((row) => row.id),
916
- SQL_BATCH_SIZE,
917
- )) {
918
- const result = await this.db
919
- .deleteFrom("_emdash_media_usage")
920
- .where("id", "in", idBatch)
921
- .where("created_at", "<", cutoff)
922
- .where((eb) =>
923
- eb.exists(
924
- eb
925
- .selectFrom("_emdash_media_usage_sources as s")
926
- .select("s.source_key")
927
- .whereRef("s.source_key", "=", "_emdash_media_usage.source_key")
928
- .whereRef("s.current_generation", "!=", "_emdash_media_usage.generation")
929
- .whereRef("_emdash_media_usage.created_at", ">=", "s.indexed_at"),
930
- ),
931
- )
932
- .executeTakeFirst();
933
- deleted += Number(result.numDeletedRows ?? 0);
934
- }
935
- return deleted;
1372
+ cutoff,
1373
+ options.cleanupLease,
1374
+ options.canIssueStatement,
1375
+ );
1376
+ }
1377
+
1378
+ async deleteExpiredGenerationWriteLeases(
1379
+ limit: number,
1380
+ cleanupLease?: MediaUsageCleanupLease,
1381
+ canIssueStatement?: () => boolean,
1382
+ ): Promise<number> {
1383
+ const batchLimit = Math.floor(limit);
1384
+ if (batchLimit <= 0 || !canIssueCleanupStatement(canIssueStatement)) return 0;
1385
+ let query = this.db
1386
+ .selectFrom("_emdash_media_usage_generation_writes")
1387
+ .select("lease_token")
1388
+ .where(this.generationWriteLeaseHasExpired("expires_at"))
1389
+ .orderBy("expires_at", "asc")
1390
+ .orderBy("lease_token", "asc")
1391
+ .limit(batchLimit);
1392
+ if (cleanupLease) query = query.where(this.activeCleanupLeaseExpression(cleanupLease));
1393
+ const rows = await query.execute();
1394
+ if (rows.length === 0 || !canIssueCleanupStatement(canIssueStatement)) return 0;
1395
+ let deleteQuery = this.db
1396
+ .deleteFrom("_emdash_media_usage_generation_writes")
1397
+ .where(
1398
+ "lease_token",
1399
+ "in",
1400
+ rows.map((row) => row.lease_token),
1401
+ )
1402
+ .where(this.generationWriteLeaseHasExpired("expires_at"));
1403
+ if (cleanupLease)
1404
+ deleteQuery = deleteQuery.where(this.activeCleanupLeaseExpression(cleanupLease));
1405
+ const result = await deleteQuery.executeTakeFirst();
1406
+ return Number(result.numDeletedRows ?? 0);
936
1407
  }
937
1408
 
938
1409
  async upsertIndexStatus(input: MediaUsageIndexStatusInput): Promise<MediaUsageIndexStatus> {
@@ -979,6 +1450,43 @@ export class MediaUsageRepository {
979
1450
  return status;
980
1451
  }
981
1452
 
1453
+ async invalidateIndexStatusForSchemaChange(collectionSlug: string): Promise<boolean> {
1454
+ const result = await this.db
1455
+ .updateTable("_emdash_media_usage_index_status as status")
1456
+ .set({
1457
+ change_epoch: sql<number>`change_epoch + 1`,
1458
+ status: "stale",
1459
+ completed_at: null,
1460
+ cursor: null,
1461
+ last_error_code: "CONTENT_USAGE_STALE",
1462
+ reconciliation_required: 1,
1463
+ updated_at: this.sortableUtcTimestamp(),
1464
+ })
1465
+ .where("status.adapter_id", "=", "content-media")
1466
+ .where("status.scope_type", "=", "collection")
1467
+ .where("status.scope_key", "=", collectionSlug)
1468
+ .where("status.capture_state", "=", "active")
1469
+ .where((eb) =>
1470
+ eb.exists(
1471
+ eb
1472
+ .selectFrom("_emdash_collections as collection")
1473
+ .select("collection.id")
1474
+ .whereRef("collection.id", "=", "status.collection_id")
1475
+ .whereRef("collection.slug", "=", "status.scope_key"),
1476
+ ),
1477
+ )
1478
+ .where(
1479
+ sql<boolean>`EXISTS (
1480
+ SELECT 1
1481
+ FROM _emdash_media_usage_activation AS activation
1482
+ WHERE activation.task_key = 'incremental_capture'
1483
+ AND activation.state = 'active'
1484
+ )`,
1485
+ )
1486
+ .executeTakeFirst();
1487
+ return Number(result.numUpdatedRows ?? 0) === 1;
1488
+ }
1489
+
982
1490
  async beginIndexStatusRepair(
983
1491
  input: MediaUsageIndexStatusRepairInput,
984
1492
  ): Promise<MediaUsageIndexStatus> {
@@ -1029,6 +1537,247 @@ export class MediaUsageRepository {
1029
1537
  };
1030
1538
  }
1031
1539
 
1540
+ async beginIndexStatusRepairAtCurrentEpoch(
1541
+ input: MediaUsageIndexStatusEpochRepairInput,
1542
+ ): Promise<MediaUsageIndexStatusEpochRepairRun | null> {
1543
+ const now = this.sortableUtcTimestamp();
1544
+ const row = await this.db
1545
+ .updateTable("_emdash_media_usage_index_status")
1546
+ .set({
1547
+ status: "running",
1548
+ schema_version: input.schemaVersion,
1549
+ started_at: now,
1550
+ completed_at: null,
1551
+ cursor: input.runToken,
1552
+ indexed_source_count: 0,
1553
+ failed_source_count: 0,
1554
+ last_error_code: null,
1555
+ reconciliation_required: 1,
1556
+ updated_at: now,
1557
+ })
1558
+ .where("adapter_id", "=", input.adapterId)
1559
+ .where("scope_type", "=", input.scopeType)
1560
+ .where("scope_key", "=", input.scopeKey)
1561
+ .where("collection_id", "=", input.collectionId)
1562
+ .where("capture_state", "=", "active")
1563
+ .where(
1564
+ sql<boolean>`EXISTS (
1565
+ SELECT 1
1566
+ FROM _emdash_collections AS collection
1567
+ WHERE collection.id = ${input.collectionId}
1568
+ AND collection.slug = ${input.scopeKey}
1569
+ )`,
1570
+ )
1571
+ .where(
1572
+ sql<boolean>`EXISTS (
1573
+ SELECT 1
1574
+ FROM _emdash_media_usage_activation AS activation
1575
+ WHERE activation.task_key = 'incremental_capture'
1576
+ AND activation.state = 'active'
1577
+ )`,
1578
+ )
1579
+ .returning(["change_epoch", "started_at"])
1580
+ .executeTakeFirst();
1581
+ if (!row?.started_at) return null;
1582
+ return { changeEpoch: row.change_epoch, startedAt: row.started_at };
1583
+ }
1584
+
1585
+ async finalizeIndexStatusRepairAtEpoch(
1586
+ input: MediaUsageIndexStatusEpochFinalizeInput,
1587
+ ): Promise<MediaUsageGuardedIndexStatusResult> {
1588
+ const now = this.sortableUtcTimestamp();
1589
+ const updates = {
1590
+ status: input.status,
1591
+ schema_version: input.schemaVersion,
1592
+ completed_at: now,
1593
+ cursor: null,
1594
+ indexed_source_count: input.indexedSourceCount,
1595
+ failed_source_count: input.failedSourceCount,
1596
+ last_error_code: input.lastErrorCode,
1597
+ reconciliation_required: input.status === "complete" ? 0 : 1,
1598
+ updated_at: now,
1599
+ };
1600
+
1601
+ let query = this.db
1602
+ .updateTable("_emdash_media_usage_index_status")
1603
+ .set(updates)
1604
+ .where("adapter_id", "=", input.adapterId)
1605
+ .where("scope_type", "=", input.scopeType)
1606
+ .where("scope_key", "=", input.scopeKey)
1607
+ .where("collection_id", "=", input.collectionId)
1608
+ .where("status", "=", "running")
1609
+ .where("cursor", "=", input.runToken)
1610
+ .where("change_epoch", "=", input.startingEpoch)
1611
+ .where(
1612
+ sql<boolean>`EXISTS (
1613
+ SELECT 1
1614
+ FROM _emdash_collections AS collection
1615
+ WHERE collection.id = ${input.collectionId}
1616
+ AND collection.slug = ${input.scopeKey}
1617
+ )`,
1618
+ );
1619
+ if (input.status === "complete") {
1620
+ query = query.where(
1621
+ sql<boolean>`NOT EXISTS (
1622
+ SELECT 1
1623
+ FROM _emdash_media_usage_work AS work
1624
+ WHERE work.collection_id = ${input.collectionId}
1625
+ )`,
1626
+ );
1627
+ }
1628
+ const result = await query.executeTakeFirst();
1629
+ const finalized = Number(result.numUpdatedRows ?? 0) > 0;
1630
+
1631
+ if (!finalized) {
1632
+ await this.db
1633
+ .updateTable("_emdash_media_usage_index_status")
1634
+ .set({
1635
+ status: "stale",
1636
+ completed_at: null,
1637
+ cursor: null,
1638
+ last_error_code: "CONTENT_USAGE_REPAIR_CONFLICT",
1639
+ reconciliation_required: 1,
1640
+ updated_at: this.sortableUtcTimestamp(),
1641
+ })
1642
+ .where("adapter_id", "=", input.adapterId)
1643
+ .where("scope_type", "=", input.scopeType)
1644
+ .where("scope_key", "=", input.scopeKey)
1645
+ .where("collection_id", "=", input.collectionId)
1646
+ .where("status", "=", "running")
1647
+ .where("cursor", "=", input.runToken)
1648
+ .execute();
1649
+ }
1650
+
1651
+ return {
1652
+ finalized,
1653
+ status: await this.findIndexStatusForCollection(input, input.collectionId),
1654
+ };
1655
+ }
1656
+
1657
+ async recordIncrementalSuccess(input: MediaUsageIncrementalStatusIdentity): Promise<boolean> {
1658
+ const observed = await this.db
1659
+ .selectFrom("_emdash_media_usage_index_status")
1660
+ .select("change_epoch")
1661
+ .where("adapter_id", "=", "content-media")
1662
+ .where("scope_type", "=", "collection")
1663
+ .where("scope_key", "=", input.collectionSlug)
1664
+ .where("collection_id", "=", input.collectionId)
1665
+ .where("capture_state", "=", "active")
1666
+ .where(
1667
+ sql<boolean>`EXISTS (
1668
+ SELECT 1
1669
+ FROM _emdash_collections AS collection
1670
+ WHERE collection.id = ${input.collectionId}
1671
+ AND collection.slug = ${input.collectionSlug}
1672
+ )`,
1673
+ )
1674
+ .executeTakeFirst();
1675
+ if (!observed) return false;
1676
+
1677
+ const canComplete = sql<boolean>`(
1678
+ reconciliation_required = 0
1679
+ AND status IN ('complete', 'stale', 'partial')
1680
+ AND NOT EXISTS (
1681
+ SELECT 1
1682
+ FROM _emdash_media_usage_work AS work
1683
+ WHERE work.collection_id = ${input.collectionId}
1684
+ )
1685
+ )`;
1686
+ const now = this.sortableUtcTimestamp();
1687
+ const result = await this.db
1688
+ .updateTable("_emdash_media_usage_index_status")
1689
+ .set({
1690
+ status: sql<string>`CASE WHEN ${canComplete} THEN 'complete' ELSE status END`,
1691
+ completed_at: sql<
1692
+ string | null
1693
+ >`CASE WHEN ${canComplete} THEN ${now} ELSE completed_at END`,
1694
+ last_error_code: sql<
1695
+ string | null
1696
+ >`CASE WHEN ${canComplete} THEN NULL ELSE last_error_code END`,
1697
+ last_incremental_success_at: now,
1698
+ updated_at: now,
1699
+ })
1700
+ .where("adapter_id", "=", "content-media")
1701
+ .where("scope_type", "=", "collection")
1702
+ .where("scope_key", "=", input.collectionSlug)
1703
+ .where("collection_id", "=", input.collectionId)
1704
+ .where("change_epoch", "=", observed.change_epoch)
1705
+ .where("capture_state", "=", "active")
1706
+ .where(
1707
+ sql<boolean>`EXISTS (
1708
+ SELECT 1
1709
+ FROM _emdash_collections AS collection
1710
+ WHERE collection.id = ${input.collectionId}
1711
+ AND collection.slug = ${input.collectionSlug}
1712
+ )`,
1713
+ )
1714
+ .executeTakeFirst();
1715
+ return Number(result.numUpdatedRows ?? 0) > 0;
1716
+ }
1717
+
1718
+ async recordIncrementalFailure(
1719
+ input: MediaUsageIncrementalStatusIdentity & {
1720
+ contentId: string;
1721
+ workVersion: number | string;
1722
+ errorCode: string;
1723
+ },
1724
+ ): Promise<boolean> {
1725
+ const now = this.sortableUtcTimestamp();
1726
+ const automaticRunOwnsCoverage = sql<boolean>`EXISTS (
1727
+ SELECT 1
1728
+ FROM _emdash_media_usage_reconciliations AS reconciliation
1729
+ WHERE reconciliation.collection_id = ${input.collectionId}
1730
+ AND reconciliation.run_token = cursor
1731
+ )`;
1732
+ const result = await this.db
1733
+ .updateTable("_emdash_media_usage_index_status")
1734
+ .set({
1735
+ status: sql<string>`CASE
1736
+ WHEN ${automaticRunOwnsCoverage} THEN status
1737
+ WHEN reconciliation_required = 0 THEN 'partial'
1738
+ WHEN status = 'running' THEN 'stale'
1739
+ ELSE status
1740
+ END`,
1741
+ completed_at: sql<string | null>`CASE
1742
+ WHEN ${automaticRunOwnsCoverage} THEN completed_at
1743
+ WHEN reconciliation_required = 0 OR status = 'running' THEN NULL
1744
+ ELSE completed_at
1745
+ END`,
1746
+ cursor: sql<string | null>`CASE
1747
+ WHEN ${automaticRunOwnsCoverage} THEN cursor
1748
+ WHEN status = 'running' THEN NULL
1749
+ ELSE cursor
1750
+ END`,
1751
+ last_error_code: input.errorCode,
1752
+ updated_at: now,
1753
+ })
1754
+ .where("adapter_id", "=", "content-media")
1755
+ .where("scope_type", "=", "collection")
1756
+ .where("scope_key", "=", input.collectionSlug)
1757
+ .where("collection_id", "=", input.collectionId)
1758
+ .where(
1759
+ sql<boolean>`EXISTS (
1760
+ SELECT 1
1761
+ FROM _emdash_media_usage_work AS work
1762
+ WHERE work.collection_id = ${input.collectionId}
1763
+ AND work.content_id = ${input.contentId}
1764
+ AND work.work_version = ${input.workVersion}
1765
+ AND work.state = 'failed'
1766
+ AND work.last_error_code = ${input.errorCode}
1767
+ )`,
1768
+ )
1769
+ .where(
1770
+ sql<boolean>`EXISTS (
1771
+ SELECT 1
1772
+ FROM _emdash_collections AS collection
1773
+ WHERE collection.id = ${input.collectionId}
1774
+ AND collection.slug = ${input.collectionSlug}
1775
+ )`,
1776
+ )
1777
+ .executeTakeFirst();
1778
+ return Number(result.numUpdatedRows ?? 0) > 0;
1779
+ }
1780
+
1032
1781
  async findIndexStatus(
1033
1782
  identity: MediaUsageIndexStatusIdentity,
1034
1783
  ): Promise<MediaUsageIndexStatus | null> {
@@ -1043,16 +1792,41 @@ export class MediaUsageRepository {
1043
1792
  return row ? rowToIndexStatus(row) : null;
1044
1793
  }
1045
1794
 
1046
- async deleteIndexStatus(identity: MediaUsageIndexStatusIdentity): Promise<number> {
1047
- const result = await this.db
1048
- .deleteFrom("_emdash_media_usage_index_status")
1795
+ private async findIndexStatusForCollection(
1796
+ identity: MediaUsageIndexStatusIdentity,
1797
+ collectionId: string,
1798
+ ): Promise<MediaUsageIndexStatus | null> {
1799
+ const row = await this.db
1800
+ .selectFrom("_emdash_media_usage_index_status")
1801
+ .selectAll()
1049
1802
  .where("adapter_id", "=", identity.adapterId)
1050
1803
  .where("scope_type", "=", identity.scopeType)
1051
1804
  .where("scope_key", "=", identity.scopeKey)
1805
+ .where("collection_id", "=", collectionId)
1052
1806
  .executeTakeFirst();
1807
+ return row ? rowToIndexStatus(row) : null;
1808
+ }
1809
+
1810
+ async deleteIndexStatus(
1811
+ identity: MediaUsageIndexStatusIdentity,
1812
+ collectionId?: string,
1813
+ ): Promise<number> {
1814
+ let query = this.db
1815
+ .deleteFrom("_emdash_media_usage_index_status")
1816
+ .where("adapter_id", "=", identity.adapterId)
1817
+ .where("scope_type", "=", identity.scopeType)
1818
+ .where("scope_key", "=", identity.scopeKey);
1819
+ if (collectionId !== undefined) query = query.where("collection_id", "=", collectionId);
1820
+ const result = await query.executeTakeFirst();
1053
1821
  return Number(result.numDeletedRows ?? 0);
1054
1822
  }
1055
1823
 
1824
+ private sortableUtcTimestamp(): RawBuilder<string> {
1825
+ return isPostgres(this.db)
1826
+ ? sql<string>`to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`
1827
+ : sql<string>`strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`;
1828
+ }
1829
+
1056
1830
  private async findCurrentUsagePage(
1057
1831
  applyFilter: (
1058
1832
  query: ReturnType<MediaUsageRepository["currentUsageBaseQuery"]>,
@@ -1103,14 +1877,253 @@ export class MediaUsageRepository {
1103
1877
  .where("s.collection_slug", "is not", null)
1104
1878
  .where("s.content_id", "is not", null)
1105
1879
  .where("s.source_variant", "in", ["columns", "draft_overlay"])
1880
+ .where(contentSourceMatchesActiveCollection("s", "collection.id"))
1106
1881
  .where(CONTENT_SOURCE_ELIGIBILITY);
1107
1882
  }
1108
1883
 
1884
+ private async deleteOrphanCandidateIds(
1885
+ ids: readonly string[],
1886
+ cutoff: string,
1887
+ cleanupLease?: MediaUsageCleanupLease,
1888
+ canIssueStatement?: () => boolean,
1889
+ ): Promise<number> {
1890
+ let deleted = 0;
1891
+ for (const idBatch of chunks([...ids], cleanupDeleteBatchSize(cleanupLease))) {
1892
+ if (!canIssueCleanupStatement(canIssueStatement)) break;
1893
+ if (cleanupLease) {
1894
+ await this.markOrphanCandidatesForCleanup(idBatch, cutoff, cleanupLease);
1895
+ if (!canIssueCleanupStatement(canIssueStatement)) break;
1896
+ }
1897
+ let query = this.db
1898
+ .deleteFrom("_emdash_media_usage")
1899
+ .where("id", "in", idBatch)
1900
+ .where("created_at", "<", cutoff)
1901
+ .where(
1902
+ sql<boolean>`NOT EXISTS (SELECT 1 FROM _emdash_media_usage_sources source WHERE source.source_key = _emdash_media_usage.source_key)`,
1903
+ )
1904
+ .where(this.noActiveGenerationWriteExpression());
1905
+ if (cleanupLease) {
1906
+ query = query
1907
+ .where("cleanup_lease_token", "=", cleanupLease.leaseToken)
1908
+ .where(this.activeCleanupLeaseExpression(cleanupLease));
1909
+ }
1910
+ const result = await query.executeTakeFirst();
1911
+ deleted += Number(result.numDeletedRows ?? 0);
1912
+ }
1913
+ return deleted;
1914
+ }
1915
+
1916
+ private async deleteStaleCandidateIds(
1917
+ ids: readonly string[],
1918
+ cutoff: string,
1919
+ cleanupLease?: MediaUsageCleanupLease,
1920
+ canIssueStatement?: () => boolean,
1921
+ ): Promise<number> {
1922
+ let deleted = 0;
1923
+ for (const idBatch of chunks([...ids], cleanupDeleteBatchSize(cleanupLease))) {
1924
+ if (!canIssueCleanupStatement(canIssueStatement)) break;
1925
+ if (cleanupLease) {
1926
+ await this.markStaleCandidatesForCleanup(idBatch, cutoff, cleanupLease);
1927
+ if (!canIssueCleanupStatement(canIssueStatement)) break;
1928
+ }
1929
+ let query = this.db
1930
+ .deleteFrom("_emdash_media_usage")
1931
+ .where("id", "in", idBatch)
1932
+ .where("created_at", "<", cutoff)
1933
+ .where((eb) =>
1934
+ eb.exists(
1935
+ eb
1936
+ .selectFrom("_emdash_media_usage_sources as source")
1937
+ .select("source.source_key")
1938
+ .whereRef("source.source_key", "=", "_emdash_media_usage.source_key")
1939
+ .whereRef("source.current_generation", "!=", "_emdash_media_usage.generation")
1940
+ .whereRef("_emdash_media_usage.created_at", "<", "source.indexed_at"),
1941
+ ),
1942
+ )
1943
+ .where(this.noActiveGenerationWriteExpression());
1944
+ if (cleanupLease) {
1945
+ query = query
1946
+ .where("cleanup_lease_token", "=", cleanupLease.leaseToken)
1947
+ .where(this.activeCleanupLeaseExpression(cleanupLease));
1948
+ }
1949
+ const result = await query.executeTakeFirst();
1950
+ deleted += Number(result.numDeletedRows ?? 0);
1951
+ }
1952
+ return deleted;
1953
+ }
1954
+
1955
+ private async deleteAbandonedCandidateIds(
1956
+ ids: readonly string[],
1957
+ cutoff: string,
1958
+ cleanupLease?: MediaUsageCleanupLease,
1959
+ canIssueStatement?: () => boolean,
1960
+ ): Promise<number> {
1961
+ let deleted = 0;
1962
+ for (const idBatch of chunks([...ids], cleanupDeleteBatchSize(cleanupLease))) {
1963
+ if (!canIssueCleanupStatement(canIssueStatement)) break;
1964
+ if (cleanupLease) {
1965
+ await this.markAbandonedCandidatesForCleanup(idBatch, cutoff, cleanupLease);
1966
+ if (!canIssueCleanupStatement(canIssueStatement)) break;
1967
+ }
1968
+ let query = this.db
1969
+ .deleteFrom("_emdash_media_usage")
1970
+ .where("id", "in", idBatch)
1971
+ .where("created_at", "<", cutoff)
1972
+ .where((eb) =>
1973
+ eb.exists(
1974
+ eb
1975
+ .selectFrom("_emdash_media_usage_sources as source")
1976
+ .select("source.source_key")
1977
+ .whereRef("source.source_key", "=", "_emdash_media_usage.source_key")
1978
+ .whereRef("source.current_generation", "!=", "_emdash_media_usage.generation")
1979
+ .whereRef("_emdash_media_usage.created_at", ">=", "source.indexed_at"),
1980
+ ),
1981
+ )
1982
+ .where(this.noActiveGenerationWriteExpression());
1983
+ if (cleanupLease) {
1984
+ query = query
1985
+ .where("cleanup_lease_token", "=", cleanupLease.leaseToken)
1986
+ .where(this.activeCleanupLeaseExpression(cleanupLease));
1987
+ }
1988
+ const result = await query.executeTakeFirst();
1989
+ deleted += Number(result.numDeletedRows ?? 0);
1990
+ }
1991
+ return deleted;
1992
+ }
1993
+
1994
+ private async markOrphanCandidatesForCleanup(
1995
+ ids: readonly string[],
1996
+ cutoff: string,
1997
+ cleanupLease: MediaUsageCleanupLease,
1998
+ ): Promise<void> {
1999
+ await this.db
2000
+ .updateTable("_emdash_media_usage")
2001
+ .set({ cleanup_lease_token: cleanupLease.leaseToken })
2002
+ .where("id", "in", ids)
2003
+ .where("created_at", "<", cutoff)
2004
+ .where(
2005
+ sql<boolean>`NOT EXISTS (SELECT 1 FROM _emdash_media_usage_sources source WHERE source.source_key = _emdash_media_usage.source_key)`,
2006
+ )
2007
+ .where(this.noActiveGenerationWriteExpression())
2008
+ .where(this.activeCleanupLeaseExpression(cleanupLease))
2009
+ .execute();
2010
+ }
2011
+
2012
+ private async markStaleCandidatesForCleanup(
2013
+ ids: readonly string[],
2014
+ cutoff: string,
2015
+ cleanupLease: MediaUsageCleanupLease,
2016
+ ): Promise<void> {
2017
+ await this.db
2018
+ .updateTable("_emdash_media_usage")
2019
+ .set({ cleanup_lease_token: cleanupLease.leaseToken })
2020
+ .where("id", "in", ids)
2021
+ .where("created_at", "<", cutoff)
2022
+ .where((eb) =>
2023
+ eb.exists(
2024
+ eb
2025
+ .selectFrom("_emdash_media_usage_sources as source")
2026
+ .select("source.source_key")
2027
+ .whereRef("source.source_key", "=", "_emdash_media_usage.source_key")
2028
+ .whereRef("source.current_generation", "!=", "_emdash_media_usage.generation")
2029
+ .whereRef("_emdash_media_usage.created_at", "<", "source.indexed_at"),
2030
+ ),
2031
+ )
2032
+ .where(this.noActiveGenerationWriteExpression())
2033
+ .where(this.activeCleanupLeaseExpression(cleanupLease))
2034
+ .execute();
2035
+ }
2036
+
2037
+ private async markAbandonedCandidatesForCleanup(
2038
+ ids: readonly string[],
2039
+ cutoff: string,
2040
+ cleanupLease: MediaUsageCleanupLease,
2041
+ ): Promise<void> {
2042
+ await this.db
2043
+ .updateTable("_emdash_media_usage")
2044
+ .set({ cleanup_lease_token: cleanupLease.leaseToken })
2045
+ .where("id", "in", ids)
2046
+ .where("created_at", "<", cutoff)
2047
+ .where((eb) =>
2048
+ eb.exists(
2049
+ eb
2050
+ .selectFrom("_emdash_media_usage_sources as source")
2051
+ .select("source.source_key")
2052
+ .whereRef("source.source_key", "=", "_emdash_media_usage.source_key")
2053
+ .whereRef("source.current_generation", "!=", "_emdash_media_usage.generation")
2054
+ .whereRef("_emdash_media_usage.created_at", ">=", "source.indexed_at"),
2055
+ ),
2056
+ )
2057
+ .where(this.noActiveGenerationWriteExpression())
2058
+ .where(this.activeCleanupLeaseExpression(cleanupLease))
2059
+ .execute();
2060
+ }
2061
+
2062
+ private noActiveGenerationWriteExpression(usageTable = "_emdash_media_usage") {
2063
+ const sourceKey = sql.ref(`${usageTable}.source_key`);
2064
+ const generation = sql.ref(`${usageTable}.generation`);
2065
+ return sql<boolean>`NOT EXISTS (
2066
+ SELECT 1
2067
+ FROM _emdash_media_usage_generation_writes AS writer
2068
+ WHERE writer.source_key = ${sourceKey}
2069
+ AND writer.generation = ${generation}
2070
+ AND ${this.generationWriteLeaseExpiryIsInFuture("writer.expires_at")}
2071
+ )`;
2072
+ }
2073
+
2074
+ private activeCleanupLeaseExpression(cleanupLease: MediaUsageCleanupLease) {
2075
+ const rowLock = isPostgres(this.db) ? sql` FOR UPDATE` : sql``;
2076
+ return sql<boolean>`EXISTS (
2077
+ SELECT 1
2078
+ FROM _emdash_media_usage_cleanup AS cleanup
2079
+ WHERE cleanup.task_key = 'projection_gc'
2080
+ AND cleanup.lease_token = ${cleanupLease.leaseToken}
2081
+ AND ${this.cleanupLeaseExpiryIsInFuture("cleanup.lease_expires_at")}
2082
+ ${rowLock}
2083
+ )`;
2084
+ }
2085
+
2086
+ private cleanupLeaseExpiryIsInFuture(column: string) {
2087
+ const leaseExpiresAt = sql.ref(column);
2088
+ return isPostgres(this.db)
2089
+ ? sql<boolean>`${leaseExpiresAt}::timestamptz > clock_timestamp()`
2090
+ : sql<boolean>`${leaseExpiresAt} > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`;
2091
+ }
2092
+
2093
+ private cleanupTimestampIsDue(column: string) {
2094
+ const timestamp = sql.ref(column);
2095
+ return isPostgres(this.db)
2096
+ ? sql<boolean>`${timestamp}::timestamptz <= clock_timestamp()`
2097
+ : sql<boolean>`${timestamp} <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`;
2098
+ }
2099
+
2100
+ private cleanupTimestampOffset(offsetSeconds: number): RawBuilder<string> {
2101
+ if (isPostgres(this.db)) {
2102
+ return sql<string>`to_char(
2103
+ (clock_timestamp() AT TIME ZONE 'UTC') + (${offsetSeconds} * INTERVAL '1 second'),
2104
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
2105
+ )`;
2106
+ }
2107
+ return sql<string>`strftime(
2108
+ '%Y-%m-%dT%H:%M:%fZ',
2109
+ 'now',
2110
+ ${`${offsetSeconds >= 0 ? "+" : ""}${offsetSeconds} seconds`}
2111
+ )`;
2112
+ }
2113
+
2114
+ private generationWriteLeaseHasExpired(column: string) {
2115
+ const leaseExpiresAt = sql.ref(column);
2116
+ return isPostgres(this.db)
2117
+ ? sql<boolean>`${leaseExpiresAt}::timestamptz <= clock_timestamp()`
2118
+ : sql<boolean>`${leaseExpiresAt} <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`;
2119
+ }
2120
+
1109
2121
  private async deleteSourceKeys(sourceKeys: readonly string[]): Promise<number> {
1110
2122
  const uniqueSourceKeys = [...new Set(sourceKeys)];
1111
2123
  if (uniqueSourceKeys.length === 0) return 0;
1112
2124
 
1113
2125
  return withTransaction(this.db, async (trx) => {
2126
+ await this.lockCleanupBeforeSourceDelete(trx);
1114
2127
  let deleted = 0;
1115
2128
  for (const sourceKeyBatch of chunks(uniqueSourceKeys, SQL_BATCH_SIZE)) {
1116
2129
  const result = await trx
@@ -1119,6 +2132,11 @@ export class MediaUsageRepository {
1119
2132
  .executeTakeFirst();
1120
2133
  deleted += Number(result.numDeletedRows ?? 0);
1121
2134
 
2135
+ await trx
2136
+ .updateTable("_emdash_media_usage")
2137
+ .set({ cleanup_lease_token: null })
2138
+ .where("source_key", "in", sourceKeyBatch)
2139
+ .execute();
1122
2140
  await trx
1123
2141
  .deleteFrom("_emdash_media_usage")
1124
2142
  .where("source_key", "in", sourceKeyBatch)
@@ -1133,8 +2151,12 @@ export class MediaUsageRepository {
1133
2151
  sourceKey: string,
1134
2152
  generation: string,
1135
2153
  ): Promise<void> {
1136
- // Guarded source deletes remove only the generation that won the source CAS;
1137
- // unmatched generations become invisible orphans reclaimed by age-gated cleanup.
2154
+ await db
2155
+ .updateTable("_emdash_media_usage")
2156
+ .set({ cleanup_lease_token: null })
2157
+ .where("source_key", "=", sourceKey)
2158
+ .where("generation", "=", generation)
2159
+ .execute();
1138
2160
  await db
1139
2161
  .deleteFrom("_emdash_media_usage")
1140
2162
  .where("source_key", "=", sourceKey)
@@ -1142,6 +2164,16 @@ export class MediaUsageRepository {
1142
2164
  .execute();
1143
2165
  }
1144
2166
 
2167
+ private async lockCleanupBeforeSourceDelete(db: DatabaseExecutor): Promise<void> {
2168
+ if (!isPostgres(this.db)) return;
2169
+ await sql`
2170
+ SELECT 1
2171
+ FROM _emdash_media_usage_cleanup
2172
+ WHERE task_key = 'projection_gc'
2173
+ FOR SHARE
2174
+ `.execute(db);
2175
+ }
2176
+
1145
2177
  private async insertOccurrences(
1146
2178
  db: DatabaseExecutor,
1147
2179
  sourceKey: string,
@@ -1172,43 +2204,252 @@ export class MediaUsageRepository {
1172
2204
  }
1173
2205
  }
1174
2206
 
2207
+ private async lockCanonicalSourceCollection(
2208
+ db: DatabaseExecutor,
2209
+ source: MediaUsageSourceInput,
2210
+ ): Promise<boolean> {
2211
+ if (source.collectionId === undefined || source.collectionId === null) return true;
2212
+ if (!source.collectionSlug) return false;
2213
+ if (!isPostgres(this.db)) return true;
2214
+ const collection = await db
2215
+ .selectFrom("_emdash_collections")
2216
+ .select("id")
2217
+ .where("id", "=", source.collectionId)
2218
+ .where("slug", "=", source.collectionSlug)
2219
+ .forKeyShare()
2220
+ .executeTakeFirst();
2221
+ return collection !== undefined;
2222
+ }
2223
+
1175
2224
  private async upsertSource(
1176
2225
  db: DatabaseExecutor,
1177
2226
  source: MediaUsageSourceInput,
1178
2227
  generation: string,
1179
2228
  now: string,
1180
- ): Promise<void> {
2229
+ leaseToken: string,
2230
+ ): Promise<boolean> {
1181
2231
  const row = this.buildSourceRow(source, generation, now);
1182
-
1183
- await db
1184
- .insertInto("_emdash_media_usage_sources")
1185
- .values(row)
1186
- .onConflict((oc) => oc.column("source_key").doUpdateSet(this.sourceUpdateSet(row)))
1187
- .execute();
2232
+ return this.persistSourceIfWriteLease(
2233
+ db,
2234
+ row,
2235
+ leaseToken,
2236
+ sql`
2237
+ ON CONFLICT (source_key) DO UPDATE SET
2238
+ source_type = excluded.source_type,
2239
+ collection_id = excluded.collection_id,
2240
+ collection_slug = excluded.collection_slug,
2241
+ content_id = excluded.content_id,
2242
+ source_variant = excluded.source_variant,
2243
+ locale = excluded.locale,
2244
+ translation_group = excluded.translation_group,
2245
+ content_slug = excluded.content_slug,
2246
+ content_title = excluded.content_title,
2247
+ content_status = excluded.content_status,
2248
+ content_scheduled_at = excluded.content_scheduled_at,
2249
+ content_deleted_at = excluded.content_deleted_at,
2250
+ revision_id = excluded.revision_id,
2251
+ current_generation = excluded.current_generation,
2252
+ schema_version = excluded.schema_version,
2253
+ source_updated_at = excluded.source_updated_at,
2254
+ source_version = excluded.source_version,
2255
+ source_fingerprint = excluded.source_fingerprint,
2256
+ identity_version = excluded.identity_version,
2257
+ source_completeness = excluded.source_completeness,
2258
+ last_attempted_at = excluded.last_attempted_at,
2259
+ last_error_code = excluded.last_error_code,
2260
+ indexed_at = excluded.indexed_at,
2261
+ updated_at = excluded.updated_at
2262
+ `,
2263
+ );
1188
2264
  }
1189
2265
 
1190
2266
  private async insertSourceIfAbsent(
1191
2267
  db: DatabaseExecutor,
1192
- row: Insertable<MediaUsageSourceTable>,
2268
+ row: ReturnType<MediaUsageRepository["buildSourceRow"]>,
2269
+ leaseToken: string,
1193
2270
  ): Promise<boolean> {
1194
- const result = await db
1195
- .insertInto("_emdash_media_usage_sources")
1196
- .values(row)
1197
- .onConflict((oc) => oc.column("source_key").doNothing())
1198
- .executeTakeFirst();
1199
- return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
2271
+ return this.persistSourceIfWriteLease(
2272
+ db,
2273
+ row,
2274
+ leaseToken,
2275
+ sql`ON CONFLICT (source_key) DO NOTHING`,
2276
+ );
2277
+ }
2278
+
2279
+ private async persistSourceIfWriteLease(
2280
+ db: DatabaseExecutor,
2281
+ row:
2282
+ | ReturnType<MediaUsageRepository["buildSourceRow"]>
2283
+ | ReturnType<MediaUsageRepository["buildAttemptedSourceRow"]>,
2284
+ leaseToken: string,
2285
+ conflict: RawBuilder<unknown>,
2286
+ ): Promise<boolean> {
2287
+ const result = await sql`
2288
+ INSERT INTO _emdash_media_usage_sources (
2289
+ source_key,
2290
+ source_type,
2291
+ collection_id,
2292
+ collection_slug,
2293
+ content_id,
2294
+ source_variant,
2295
+ locale,
2296
+ translation_group,
2297
+ content_slug,
2298
+ content_title,
2299
+ content_status,
2300
+ content_scheduled_at,
2301
+ content_deleted_at,
2302
+ revision_id,
2303
+ current_generation,
2304
+ schema_version,
2305
+ source_updated_at,
2306
+ source_version,
2307
+ source_fingerprint,
2308
+ identity_version,
2309
+ source_completeness,
2310
+ last_attempted_at,
2311
+ last_error_code,
2312
+ indexed_at,
2313
+ updated_at
2314
+ )
2315
+ SELECT
2316
+ ${row.source_key},
2317
+ ${row.source_type},
2318
+ ${row.collection_id},
2319
+ ${row.collection_slug},
2320
+ ${row.content_id},
2321
+ ${row.source_variant},
2322
+ ${row.locale},
2323
+ ${row.translation_group},
2324
+ ${row.content_slug},
2325
+ ${row.content_title},
2326
+ ${row.content_status},
2327
+ ${row.content_scheduled_at},
2328
+ ${row.content_deleted_at},
2329
+ ${row.revision_id},
2330
+ ${row.current_generation},
2331
+ ${row.schema_version},
2332
+ ${row.source_updated_at},
2333
+ ${row.source_version},
2334
+ ${row.source_fingerprint},
2335
+ ${row.identity_version},
2336
+ ${row.source_completeness},
2337
+ ${row.last_attempted_at},
2338
+ ${row.last_error_code},
2339
+ ${row.indexed_at},
2340
+ ${row.updated_at}
2341
+ WHERE EXISTS (
2342
+ SELECT 1
2343
+ FROM _emdash_media_usage_generation_writes
2344
+ WHERE source_key = ${row.source_key}
2345
+ AND generation = ${row.current_generation}
2346
+ AND lease_token = ${leaseToken}
2347
+ AND ${this.generationWriteLeaseExpiryIsInFuture("expires_at")}
2348
+ )
2349
+ AND ${this.currentCollectionExists(row.collection_id, row.collection_slug)}
2350
+ AND ${this.currentCanonicalContentExists(row)}
2351
+ ${conflict}
2352
+ `.execute(db);
2353
+ return Number(result.numAffectedRows ?? 0) > 0;
2354
+ }
2355
+
2356
+ private generationWriteLeaseExpression(
2357
+ row: ReturnType<MediaUsageRepository["buildSourceRow"]>,
2358
+ leaseToken: string,
2359
+ ) {
2360
+ return (eb: ExpressionBuilder<Database, "_emdash_media_usage_sources">) =>
2361
+ eb.exists(
2362
+ eb
2363
+ .selectFrom("_emdash_media_usage_generation_writes")
2364
+ .select("source_key")
2365
+ .where("source_key", "=", row.source_key)
2366
+ .where("generation", "=", row.current_generation)
2367
+ .where("lease_token", "=", leaseToken)
2368
+ .where(
2369
+ this.generationWriteLeaseExpiryIsInFuture(
2370
+ "_emdash_media_usage_generation_writes.expires_at",
2371
+ ),
2372
+ ),
2373
+ );
2374
+ }
2375
+
2376
+ private generationWriteLeaseExpiryIsInFuture(column: string) {
2377
+ const leaseExpiresAt = sql.ref(column);
2378
+ return isPostgres(this.db)
2379
+ ? sql<boolean>`${leaseExpiresAt}::timestamptz > clock_timestamp()`
2380
+ : sql<boolean>`${leaseExpiresAt} > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`;
2381
+ }
2382
+
2383
+ private async withGenerationWriteLease(
2384
+ source: Pick<MediaUsageSourceInput, "sourceKey" | "collectionId" | "collectionSlug">,
2385
+ generation: string,
2386
+ write: (leaseToken: string, startedAt: string) => Promise<void>,
2387
+ ): Promise<boolean> {
2388
+ const leaseToken = ulid();
2389
+ const lease = await sql<{ created_at: string }>`
2390
+ INSERT INTO _emdash_media_usage_generation_writes (
2391
+ source_key, generation, lease_token, expires_at, created_at
2392
+ )
2393
+ SELECT
2394
+ ${source.sourceKey},
2395
+ ${generation},
2396
+ ${leaseToken},
2397
+ ${this.generationWriteLeaseTimestampOffset(MEDIA_USAGE_GENERATION_WRITE_LEASE_MS / 1000)},
2398
+ ${this.generationWriteLeaseTimestampOffset(0)}
2399
+ WHERE ${this.currentCollectionExists(
2400
+ source.collectionId ?? null,
2401
+ source.collectionSlug ?? null,
2402
+ )}
2403
+ RETURNING created_at
2404
+ `.execute(this.db);
2405
+ const owner = lease.rows[0];
2406
+ if (!owner) return false;
2407
+
2408
+ try {
2409
+ await write(leaseToken, owner.created_at);
2410
+ return true;
2411
+ } finally {
2412
+ try {
2413
+ await this.db
2414
+ .deleteFrom("_emdash_media_usage_generation_writes")
2415
+ .where("source_key", "=", source.sourceKey)
2416
+ .where("generation", "=", generation)
2417
+ .where("lease_token", "=", leaseToken)
2418
+ .execute();
2419
+ } catch (error) {
2420
+ console.error("[media-usage] Failed to release generation write lease:", error);
2421
+ }
2422
+ }
2423
+ }
2424
+
2425
+ private generationWriteLeaseTimestampOffset(offsetSeconds: number): RawBuilder<string> {
2426
+ if (isPostgres(this.db)) {
2427
+ return sql<string>`to_char(
2428
+ (clock_timestamp() AT TIME ZONE 'UTC') + (${offsetSeconds} * INTERVAL '1 second'),
2429
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
2430
+ )`;
2431
+ }
2432
+ return sql<string>`strftime(
2433
+ '%Y-%m-%dT%H:%M:%fZ',
2434
+ 'now',
2435
+ ${`${offsetSeconds >= 0 ? "+" : ""}${offsetSeconds} seconds`}
2436
+ )`;
1200
2437
  }
1201
2438
 
1202
2439
  private async updateSourceIfGeneration(
1203
2440
  db: DatabaseExecutor,
1204
2441
  row: ReturnType<MediaUsageRepository["buildSourceRow"]>,
1205
2442
  expectedCurrentGeneration: string,
2443
+ leaseToken: string,
1206
2444
  ): Promise<boolean> {
1207
2445
  const result = await db
1208
2446
  .updateTable("_emdash_media_usage_sources")
1209
2447
  .set(this.sourceUpdateSet(row))
1210
2448
  .where("source_key", "=", row.source_key)
1211
2449
  .where("current_generation", "=", expectedCurrentGeneration)
2450
+ .where(this.generationWriteLeaseExpression(row, leaseToken))
2451
+ .where(this.currentCollectionExists(row.collection_id, row.collection_slug))
2452
+ .where(this.currentCanonicalContentExists(row))
1212
2453
  .executeTakeFirst();
1213
2454
  return Number(result.numUpdatedRows ?? 0) > 0;
1214
2455
  }
@@ -1217,12 +2458,16 @@ export class MediaUsageRepository {
1217
2458
  db: DatabaseExecutor,
1218
2459
  row: ReturnType<MediaUsageRepository["buildSourceRow"]>,
1219
2460
  expectedSource: MediaUsageSource,
2461
+ leaseToken: string,
1220
2462
  ): Promise<boolean> {
1221
2463
  const result = await db
1222
2464
  .updateTable("_emdash_media_usage_sources")
1223
2465
  .set(this.sourceUpdateSet(row))
1224
2466
  .where("source_key", "=", row.source_key)
1225
2467
  .where(this.sourceMatchExpression(expectedSource))
2468
+ .where(this.generationWriteLeaseExpression(row, leaseToken))
2469
+ .where(this.currentCollectionExists(row.collection_id, row.collection_slug))
2470
+ .where(this.currentCanonicalContentExists(row))
1226
2471
  .executeTakeFirst();
1227
2472
  return Number(result.numUpdatedRows ?? 0) > 0;
1228
2473
  }
@@ -1238,6 +2483,8 @@ export class MediaUsageRepository {
1238
2483
  .set(this.attemptedSourceUpdateSet(source, row))
1239
2484
  .where("source_key", "=", row.source_key)
1240
2485
  .where(this.sourceMatchExpression(expectedSource))
2486
+ .where(this.currentCollectionExists(row.collection_id, row.collection_slug))
2487
+ .where(this.currentCanonicalContentExists(row))
1241
2488
  .executeTakeFirst();
1242
2489
  return Number(result.numUpdatedRows ?? 0) > 0;
1243
2490
  }
@@ -1247,16 +2494,64 @@ export class MediaUsageRepository {
1247
2494
  eb.and([
1248
2495
  eb("current_generation", "=", expectedSource.currentGeneration),
1249
2496
  eb("source_completeness", "=", expectedSource.sourceCompleteness),
2497
+ this.nullableStringExpression(eb, "collection_id", expectedSource.collectionId),
1250
2498
  this.nullableStringExpression(eb, "updated_at", expectedSource.updatedAt),
1251
2499
  this.nullableStringExpression(eb, "source_fingerprint", expectedSource.sourceFingerprint),
1252
2500
  this.nullableStringExpression(eb, "source_updated_at", expectedSource.sourceUpdatedAt),
1253
2501
  this.nullableNumberExpression(eb, "source_version", expectedSource.sourceVersion),
2502
+ this.nullableNumberExpression(eb, "identity_version", expectedSource.identityVersion),
1254
2503
  this.nullableStringExpression(eb, "revision_id", expectedSource.revisionId),
1255
2504
  this.nullableStringExpression(eb, "last_attempted_at", expectedSource.lastAttemptedAt),
1256
2505
  this.nullableStringExpression(eb, "last_error_code", expectedSource.lastErrorCode),
1257
2506
  ]);
1258
2507
  }
1259
2508
 
2509
+ private async projectionMatchesCurrentGeneration(
2510
+ source: MediaUsageSourceInput,
2511
+ expectedCurrentGeneration: string,
2512
+ ): Promise<boolean> {
2513
+ const fingerprint = source.sourceFingerprint;
2514
+ if (!isMediaUsageProjectionFingerprint(fingerprint)) return false;
2515
+ const row = await this.db
2516
+ .selectFrom("_emdash_media_usage_sources")
2517
+ .select("source_key")
2518
+ .where("source_key", "=", source.sourceKey)
2519
+ .where("current_generation", "=", expectedCurrentGeneration)
2520
+ .where("source_fingerprint", "=", fingerprint!)
2521
+ .where("source_completeness", "=", source.sourceCompleteness ?? "complete")
2522
+ .where("last_error_code", "is", null)
2523
+ .where(
2524
+ this.currentCollectionExists(source.collectionId ?? null, source.collectionSlug ?? null),
2525
+ )
2526
+ .executeTakeFirst();
2527
+ return row !== undefined;
2528
+ }
2529
+
2530
+ async projectionMatchesExpectedSource(
2531
+ source: MediaUsageSourceInput,
2532
+ expectedSource: MediaUsageSource,
2533
+ ): Promise<boolean> {
2534
+ const fingerprint = source.sourceFingerprint;
2535
+ if (
2536
+ !isMediaUsageProjectionFingerprint(fingerprint) ||
2537
+ expectedSource.sourceFingerprint !== fingerprint ||
2538
+ expectedSource.sourceCompleteness !== (source.sourceCompleteness ?? "complete") ||
2539
+ expectedSource.lastErrorCode !== null
2540
+ ) {
2541
+ return false;
2542
+ }
2543
+ const row = await this.db
2544
+ .selectFrom("_emdash_media_usage_sources")
2545
+ .select("source_key")
2546
+ .where("source_key", "=", source.sourceKey)
2547
+ .where(this.sourceMatchExpression(expectedSource))
2548
+ .where(
2549
+ this.currentCollectionExists(source.collectionId ?? null, source.collectionSlug ?? null),
2550
+ )
2551
+ .executeTakeFirst();
2552
+ return row !== undefined;
2553
+ }
2554
+
1260
2555
  private nullableStringExpression(
1261
2556
  eb: ExpressionBuilder<Database, "_emdash_media_usage_sources">,
1262
2557
  column: MediaUsageSourceNullableStringColumn,
@@ -1265,9 +2560,63 @@ export class MediaUsageRepository {
1265
2560
  return value === null ? eb(column, "is", null) : eb(column, "=", value);
1266
2561
  }
1267
2562
 
2563
+ private currentCollectionExists(
2564
+ collectionId: string | null,
2565
+ collectionSlug: string | null,
2566
+ ): RawBuilder<boolean> {
2567
+ if (collectionId === null) return sql<boolean>`1 = 1`;
2568
+ return sql<boolean>`EXISTS (
2569
+ SELECT 1
2570
+ FROM _emdash_collections
2571
+ WHERE id = ${collectionId}
2572
+ AND slug = ${collectionSlug}
2573
+ )`;
2574
+ }
2575
+
2576
+ private currentCanonicalContentExists(
2577
+ row:
2578
+ | ReturnType<MediaUsageRepository["buildSourceRow"]>
2579
+ | ReturnType<MediaUsageRepository["buildAttemptedSourceRow"]>,
2580
+ ): RawBuilder<boolean> {
2581
+ if (row.collection_id === null || row.identity_version !== 1 || row.source_type !== "content") {
2582
+ return sql<boolean>`1 = 1`;
2583
+ }
2584
+ if (
2585
+ !row.collection_slug ||
2586
+ !row.content_id ||
2587
+ row.source_version === null ||
2588
+ row.source_updated_at === null
2589
+ ) {
2590
+ return sql<boolean>`1 = 0`;
2591
+ }
2592
+ validateIdentifier(row.collection_slug, "collection slug");
2593
+ const tableName = `ec_${row.collection_slug}`;
2594
+ validateIdentifier(tableName, "content table");
2595
+ const revisionColumn =
2596
+ row.source_variant === "columns"
2597
+ ? "live_revision_id"
2598
+ : row.source_variant === "draft_overlay"
2599
+ ? "draft_revision_id"
2600
+ : null;
2601
+ if (!revisionColumn) return sql<boolean>`1 = 0`;
2602
+ const revision = sql.ref(`content.${revisionColumn}`);
2603
+ const revisionMatches =
2604
+ row.revision_id === null
2605
+ ? sql<boolean>`${revision} IS NULL`
2606
+ : sql<boolean>`${revision} = ${row.revision_id}`;
2607
+ return sql<boolean>`EXISTS (
2608
+ SELECT 1
2609
+ FROM ${sql.ref(tableName)} AS content
2610
+ WHERE content.id = ${row.content_id}
2611
+ AND content.version = ${row.source_version}
2612
+ AND content.updated_at = ${row.source_updated_at}
2613
+ AND ${revisionMatches}
2614
+ )`;
2615
+ }
2616
+
1268
2617
  private nullableNumberExpression(
1269
2618
  eb: ExpressionBuilder<Database, "_emdash_media_usage_sources">,
1270
- column: "source_version",
2619
+ column: "source_version" | "identity_version",
1271
2620
  value: number | null,
1272
2621
  ) {
1273
2622
  return value === null ? eb(column, "is", null) : eb(column, "=", value);
@@ -1287,6 +2636,7 @@ export class MediaUsageRepository {
1287
2636
  return {
1288
2637
  source_key: source.sourceKey,
1289
2638
  source_type: source.sourceType,
2639
+ collection_id: source.collectionId ?? null,
1290
2640
  collection_slug: source.collectionSlug ?? null,
1291
2641
  content_id: source.contentId ?? null,
1292
2642
  source_variant: source.sourceVariant,
@@ -1303,6 +2653,7 @@ export class MediaUsageRepository {
1303
2653
  source_updated_at: source.sourceUpdatedAt ?? null,
1304
2654
  source_version: source.sourceVersion ?? null,
1305
2655
  source_fingerprint: source.sourceFingerprint ?? null,
2656
+ identity_version: source.identityVersion ?? null,
1306
2657
  // Complete means this source was fully refreshed for the extractor's current
1307
2658
  // schema/version coverage, not that every possible reference shape is known.
1308
2659
  source_completeness: source.sourceCompleteness ?? "complete",
@@ -1313,10 +2664,11 @@ export class MediaUsageRepository {
1313
2664
  };
1314
2665
  }
1315
2666
 
1316
- private buildAttemptedSourceRow(source: MediaUsageSourceInput, now: string) {
2667
+ private buildAttemptedSourceRow(source: MediaUsageSourceInput, generation: string, now: string) {
1317
2668
  return {
1318
2669
  source_key: source.sourceKey,
1319
2670
  source_type: source.sourceType,
2671
+ collection_id: source.collectionId ?? null,
1320
2672
  collection_slug: source.collectionSlug ?? null,
1321
2673
  content_id: source.contentId ?? null,
1322
2674
  source_variant: source.sourceVariant,
@@ -1328,11 +2680,12 @@ export class MediaUsageRepository {
1328
2680
  content_scheduled_at: source.contentScheduledAt ?? null,
1329
2681
  content_deleted_at: source.contentDeletedAt ?? null,
1330
2682
  revision_id: source.revisionId ?? null,
1331
- current_generation: ulid(),
2683
+ current_generation: generation,
1332
2684
  schema_version: source.schemaVersion ?? 1,
1333
2685
  source_updated_at: source.sourceUpdatedAt ?? null,
1334
2686
  source_version: source.sourceVersion ?? null,
1335
2687
  source_fingerprint: source.sourceFingerprint ?? null,
2688
+ identity_version: source.identityVersion ?? null,
1336
2689
  source_completeness:
1337
2690
  source.sourceCompleteness ?? (source.lastErrorCode ? "failed" : "unknown"),
1338
2691
  last_attempted_at: source.lastAttemptedAt ?? now,
@@ -1356,6 +2709,7 @@ export class MediaUsageRepository {
1356
2709
  };
1357
2710
 
1358
2711
  if (source.collectionSlug !== undefined) updates.collection_slug = row.collection_slug;
2712
+ if (source.collectionId !== undefined) updates.collection_id = row.collection_id;
1359
2713
  if (source.contentId !== undefined) updates.content_id = row.content_id;
1360
2714
  if (source.locale !== undefined) updates.locale = row.locale;
1361
2715
  if (source.translationGroup !== undefined) updates.translation_group = row.translation_group;
@@ -1373,6 +2727,7 @@ export class MediaUsageRepository {
1373
2727
  if (source.sourceFingerprint !== undefined) {
1374
2728
  updates.source_fingerprint = row.source_fingerprint;
1375
2729
  }
2730
+ if (source.identityVersion !== undefined) updates.identity_version = row.identity_version;
1376
2731
 
1377
2732
  return updates;
1378
2733
  }
@@ -1382,6 +2737,7 @@ export class MediaUsageRepository {
1382
2737
  ): Updateable<MediaUsageSourceTable> {
1383
2738
  return {
1384
2739
  source_type: row.source_type,
2740
+ collection_id: row.collection_id,
1385
2741
  collection_slug: row.collection_slug,
1386
2742
  content_id: row.content_id,
1387
2743
  source_variant: row.source_variant,
@@ -1398,6 +2754,7 @@ export class MediaUsageRepository {
1398
2754
  source_updated_at: row.source_updated_at,
1399
2755
  source_version: row.source_version,
1400
2756
  source_fingerprint: row.source_fingerprint,
2757
+ identity_version: row.identity_version,
1401
2758
  source_completeness: row.source_completeness,
1402
2759
  last_attempted_at: row.last_attempted_at,
1403
2760
  last_error_code: row.last_error_code,
@@ -1410,6 +2767,7 @@ export class MediaUsageRepository {
1410
2767
  const currentUsageSelect = [
1411
2768
  "s.source_key as source_key",
1412
2769
  "s.source_type as source_type",
2770
+ "s.collection_id as collection_id",
1413
2771
  "s.collection_slug as collection_slug",
1414
2772
  "s.content_id as content_id",
1415
2773
  "s.source_variant as source_variant",
@@ -1426,6 +2784,7 @@ const currentUsageSelect = [
1426
2784
  "s.source_updated_at as source_updated_at",
1427
2785
  "s.source_version as source_version",
1428
2786
  "s.source_fingerprint as source_fingerprint",
2787
+ "s.identity_version as identity_version",
1429
2788
  "s.source_completeness as source_completeness",
1430
2789
  "s.last_attempted_at as last_attempted_at",
1431
2790
  "s.last_error_code as last_error_code",
@@ -1482,6 +2841,7 @@ function rowToSource(row: MediaUsageSourceRow): MediaUsageSource {
1482
2841
  return {
1483
2842
  sourceKey: row.source_key,
1484
2843
  sourceType: row.source_type,
2844
+ collectionId: row.collection_id,
1485
2845
  collectionSlug: row.collection_slug,
1486
2846
  contentId: row.content_id,
1487
2847
  sourceVariant: row.source_variant,
@@ -1498,6 +2858,7 @@ function rowToSource(row: MediaUsageSourceRow): MediaUsageSource {
1498
2858
  sourceUpdatedAt: row.source_updated_at,
1499
2859
  sourceVersion: row.source_version === null ? null : Number(row.source_version),
1500
2860
  sourceFingerprint: row.source_fingerprint,
2861
+ identityVersion: row.identity_version === null ? null : Number(row.identity_version),
1501
2862
  sourceCompleteness: row.source_completeness,
1502
2863
  lastAttemptedAt: row.last_attempted_at,
1503
2864
  lastErrorCode: row.last_error_code,
@@ -1530,6 +2891,7 @@ function rowToUsageRecord(row: JoinedUsageRow): MediaUsageRecord {
1530
2891
  source: rowToSource({
1531
2892
  source_key: row.source_key,
1532
2893
  source_type: row.source_type,
2894
+ collection_id: row.collection_id,
1533
2895
  collection_slug: row.collection_slug,
1534
2896
  content_id: row.content_id,
1535
2897
  source_variant: row.source_variant,
@@ -1546,6 +2908,7 @@ function rowToUsageRecord(row: JoinedUsageRow): MediaUsageRecord {
1546
2908
  source_updated_at: row.source_updated_at,
1547
2909
  source_version: row.source_version,
1548
2910
  source_fingerprint: row.source_fingerprint,
2911
+ identity_version: row.identity_version,
1549
2912
  source_completeness: row.source_completeness,
1550
2913
  last_attempted_at: row.last_attempted_at,
1551
2914
  last_error_code: row.last_error_code,
@@ -1567,6 +2930,7 @@ function rowToUsageRecord(row: JoinedUsageRow): MediaUsageRecord {
1567
2930
  media_kind: row.media_kind,
1568
2931
  mime_type: row.mime_type,
1569
2932
  created_at: row.occurrence_created_at,
2933
+ cleanup_lease_token: null,
1570
2934
  }),
1571
2935
  };
1572
2936
  }