@jant/core 0.6.14 → 0.6.16

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 (350) hide show
  1. package/bin/commands/import-site.js +88 -13
  2. package/bin/commands/migrate.js +55 -0
  3. package/bin/commands/posts/rebuild-html.js +184 -0
  4. package/bin/commands/site/snapshot/import.js +14 -10
  5. package/bin/lib/d1-query.js +23 -5
  6. package/bin/lib/migration-runner.js +29 -7
  7. package/bin/lib/node-database.js +32 -0
  8. package/bin/lib/site-snapshot.js +347 -46
  9. package/bin/lib/sql-export.js +1 -1
  10. package/bin/lib/thread-collection-migration.js +326 -0
  11. package/dist/app-C75Zc8dD.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
  13. package/dist/client/.vite/manifest.json +8 -8
  14. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
  16. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  17. package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
  18. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  19. package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
  20. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  21. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  22. package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
  23. package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
  24. package/dist/index.js +5 -5
  25. package/dist/node.js +7 -7
  26. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  27. package/package.json +11 -10
  28. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  29. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  30. package/src/__tests__/export-hugo-build.test.ts +63 -4
  31. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  32. package/src/__tests__/export-service.test.ts +142 -9
  33. package/src/__tests__/helpers/app.ts +3 -0
  34. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  35. package/src/__tests__/import-site-command.test.ts +65 -0
  36. package/src/__tests__/mise-config.test.ts +22 -0
  37. package/src/__tests__/vite-config.test.ts +45 -0
  38. package/src/app.tsx +16 -3
  39. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  40. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  41. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  42. package/src/client/__tests__/compose-bridge.test.ts +316 -2
  43. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  44. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  45. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  46. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  47. package/src/client/__tests__/toast.test.ts +82 -1
  48. package/src/client/collection-created-notice.ts +66 -0
  49. package/src/client/collection-form-bridge.ts +17 -0
  50. package/src/client/collection-navigation.ts +44 -0
  51. package/src/client/collection-page-actions.ts +57 -1
  52. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  53. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  54. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  55. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
  56. package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
  57. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
  58. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  59. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  60. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  61. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  62. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  63. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  64. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  65. package/src/client/components/collection-manager-types.ts +9 -3
  66. package/src/client/components/compose-format-convert.ts +5 -1
  67. package/src/client/components/compose-types.ts +55 -3
  68. package/src/client/components/jant-collection-directory.ts +165 -30
  69. package/src/client/components/jant-command-palette.ts +14 -7
  70. package/src/client/components/jant-compose-dialog.ts +1542 -694
  71. package/src/client/components/jant-compose-editor.ts +237 -99
  72. package/src/client/components/jant-compose-fullscreen.ts +27 -9
  73. package/src/client/components/jant-config-editor.ts +722 -0
  74. package/src/client/components/jant-media-lightbox.ts +100 -18
  75. package/src/client/components/jant-nav-manager.ts +1283 -54
  76. package/src/client/components/jant-post-form.ts +3 -3
  77. package/src/client/components/jant-post-menu.ts +177 -95
  78. package/src/client/components/nav-manager-types.ts +51 -2
  79. package/src/client/components/post-form-types.ts +1 -1
  80. package/src/client/compose-bridge.ts +147 -123
  81. package/src/client/compose-launch.ts +13 -0
  82. package/src/client/compose-shortcuts.ts +5 -1
  83. package/src/client/feed-video-player.ts +62 -2
  84. package/src/client/footnote-rail.ts +478 -0
  85. package/src/client/hydrate-partial.ts +2 -0
  86. package/src/client/media-lightbox-events.ts +63 -0
  87. package/src/client/post-refresh.ts +135 -0
  88. package/src/client/site-header-fragment.ts +38 -0
  89. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  90. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  91. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  92. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  93. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  94. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  95. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  96. package/src/client/tiptap/bubble-menu.ts +88 -10
  97. package/src/client/tiptap/continuous-lists.ts +102 -0
  98. package/src/client/tiptap/create-editor.ts +3 -0
  99. package/src/client/tiptap/extensions.ts +14 -5
  100. package/src/client/tiptap/link-input-rules.ts +4 -4
  101. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  102. package/src/client/tiptap/slash-commands.ts +8 -1
  103. package/src/client/tiptap/structural-keymap.ts +211 -0
  104. package/src/client/tiptap/tab-indent.ts +8 -22
  105. package/src/client/tiptap/table-control-labels.ts +30 -0
  106. package/src/client/tiptap/table-controls.ts +537 -0
  107. package/src/client/tiptap/table-size-picker.ts +254 -0
  108. package/src/client/toast.ts +133 -39
  109. package/src/client-auth.ts +1 -0
  110. package/src/client.ts +1 -0
  111. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  112. package/src/db/__tests__/d1-query.test.ts +43 -1
  113. package/src/db/__tests__/migration-runner.test.ts +63 -0
  114. package/src/db/__tests__/migrations.test.ts +469 -0
  115. package/src/db/__tests__/thread-activity.test.ts +113 -0
  116. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  117. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  118. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  119. package/src/db/migrations/0028_superb_post.sql +2 -0
  120. package/src/db/migrations/0029_boring_magma.sql +1 -0
  121. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  122. package/src/db/migrations/0031_many_ego.sql +3 -0
  123. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  124. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  125. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  126. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  127. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  128. package/src/db/migrations/meta/_journal.json +35 -0
  129. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  130. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  131. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  132. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  133. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  134. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  135. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  136. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  137. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  138. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  139. package/src/db/migrations/pg/meta/_journal.json +35 -0
  140. package/src/db/pg/schema.ts +46 -20
  141. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  142. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  143. package/src/db/schema.ts +48 -20
  144. package/src/db/thread-activity.ts +101 -0
  145. package/src/i18n/__tests__/detect.test.ts +8 -2
  146. package/src/i18n/detect.ts +14 -5
  147. package/src/i18n/locales/public/en.po +222 -51
  148. package/src/i18n/locales/public/en.ts +1 -1
  149. package/src/i18n/locales/public/zh-Hans.po +216 -45
  150. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  151. package/src/i18n/locales/public/zh-Hant.po +216 -45
  152. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  153. package/src/i18n/locales/settings/en.po +513 -10
  154. package/src/i18n/locales/settings/en.ts +1 -1
  155. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  156. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  157. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  158. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  159. package/src/lib/__tests__/api-settings.test.ts +299 -0
  160. package/src/lib/__tests__/constants.test.ts +5 -0
  161. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  162. package/src/lib/__tests__/feed.test.ts +168 -0
  163. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  164. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  165. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  166. package/src/lib/__tests__/markdown.test.ts +24 -6
  167. package/src/lib/__tests__/navigation.test.ts +59 -2
  168. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  169. package/src/lib/__tests__/post-display.test.ts +64 -0
  170. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  171. package/src/lib/__tests__/slug.test.ts +18 -0
  172. package/src/lib/__tests__/summary.test.ts +82 -0
  173. package/src/lib/__tests__/timeline.test.ts +109 -70
  174. package/src/lib/__tests__/timezones.test.ts +31 -3
  175. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  176. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  177. package/src/lib/__tests__/translit.test.ts +87 -0
  178. package/src/lib/__tests__/url.test.ts +53 -0
  179. package/src/lib/__tests__/view.test.ts +53 -0
  180. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  181. package/src/lib/api-settings.ts +239 -12
  182. package/src/lib/collection-sort.ts +7 -5
  183. package/src/lib/constants.ts +2 -0
  184. package/src/lib/feed-path.ts +25 -0
  185. package/src/lib/feed-policy.ts +64 -0
  186. package/src/lib/feed.ts +113 -11
  187. package/src/lib/footnotes.ts +264 -0
  188. package/src/lib/github-sync-site-config.ts +2 -0
  189. package/src/lib/hugo-markdown.ts +15 -3
  190. package/src/lib/jant-branding.ts +1 -0
  191. package/src/lib/link-preview.ts +25 -0
  192. package/src/lib/markdown-manager.ts +193 -5
  193. package/src/lib/markdown.ts +10 -3
  194. package/src/lib/navigation.ts +16 -3
  195. package/src/lib/post-body-html.ts +131 -0
  196. package/src/lib/post-display.ts +47 -8
  197. package/src/lib/render.tsx +9 -2
  198. package/src/lib/resolve-config.ts +100 -16
  199. package/src/lib/schemas.ts +133 -1
  200. package/src/lib/settings-paths.ts +5 -0
  201. package/src/lib/site-skill.ts +78 -0
  202. package/src/lib/summary.ts +16 -5
  203. package/src/lib/timeline.ts +137 -131
  204. package/src/lib/timezones.ts +54 -14
  205. package/src/lib/tiptap-render.ts +359 -83
  206. package/src/lib/translit.ts +288 -0
  207. package/src/lib/url.ts +123 -12
  208. package/src/lib/view.ts +58 -20
  209. package/src/lib/worker-response-cache.ts +11 -18
  210. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  211. package/src/middleware/config.ts +2 -2
  212. package/src/middleware/public-content-access.ts +87 -0
  213. package/src/node/__tests__/cli-export.test.ts +14 -0
  214. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  215. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  216. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  217. package/src/node/index.ts +2 -1
  218. package/src/preset.css +16 -2
  219. package/src/routes/__tests__/compose.test.ts +51 -0
  220. package/src/routes/__tests__/site-skill.test.ts +112 -0
  221. package/src/routes/api/__tests__/collections.test.ts +91 -19
  222. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  223. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  224. package/src/routes/api/__tests__/palette.test.ts +44 -0
  225. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  226. package/src/routes/api/__tests__/settings.test.ts +217 -0
  227. package/src/routes/api/collections.ts +22 -26
  228. package/src/routes/api/export.ts +2 -0
  229. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  230. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  231. package/src/routes/api/internal/post-body-html.ts +43 -0
  232. package/src/routes/api/internal/sites.ts +24 -0
  233. package/src/routes/api/nav-items.ts +24 -1
  234. package/src/routes/api/posts.ts +14 -10
  235. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  236. package/src/routes/api/public/__tests__/posts.test.ts +246 -0
  237. package/src/routes/api/public/archive.ts +4 -0
  238. package/src/routes/api/public/posts.ts +48 -24
  239. package/src/routes/api/search.ts +3 -0
  240. package/src/routes/api/settings.ts +33 -0
  241. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  242. package/src/routes/compose.tsx +8 -3
  243. package/src/routes/dash/settings.tsx +45 -2
  244. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  245. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  246. package/src/routes/feed/feed.ts +23 -14
  247. package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
  248. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  249. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  250. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  251. package/src/routes/pages/__tests__/preview.test.ts +176 -0
  252. package/src/routes/pages/__tests__/search.test.ts +69 -0
  253. package/src/routes/pages/archive.tsx +90 -22
  254. package/src/routes/pages/collection.tsx +32 -19
  255. package/src/routes/pages/collections.tsx +6 -1
  256. package/src/routes/pages/page.tsx +86 -11
  257. package/src/routes/site-skill.ts +18 -0
  258. package/src/services/__tests__/auth.test.ts +7 -5
  259. package/src/services/__tests__/collection.test.ts +190 -44
  260. package/src/services/__tests__/navigation.test.ts +229 -21
  261. package/src/services/__tests__/post-timeline.test.ts +249 -39
  262. package/src/services/__tests__/post.test.ts +844 -5
  263. package/src/services/__tests__/search.test.ts +48 -1
  264. package/src/services/__tests__/settings.test.ts +8 -0
  265. package/src/services/__tests__/site-admin.test.ts +121 -1
  266. package/src/services/auth.ts +2 -2
  267. package/src/services/bootstrap.ts +3 -2
  268. package/src/services/collection.ts +224 -167
  269. package/src/services/export-theme/assets/client-site.css +1 -1
  270. package/src/services/export-theme/assets/client-site.js +46 -25
  271. package/src/services/export-theme/layouts/_default/list.html +18 -4
  272. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  273. package/src/services/export-theme/layouts/featured/list.html +5 -4
  274. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  275. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  276. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  277. package/src/services/export-theme/styles/main.css +30 -2
  278. package/src/services/export.ts +94 -85
  279. package/src/services/mcp.ts +37 -27
  280. package/src/services/media.ts +1 -1
  281. package/src/services/navigation.ts +185 -46
  282. package/src/services/path.ts +4 -1
  283. package/src/services/post.ts +1142 -342
  284. package/src/services/search.ts +17 -2
  285. package/src/services/settings.ts +2 -10
  286. package/src/services/site-admin.ts +63 -43
  287. package/src/style-cjk-jp.css +1 -1
  288. package/src/style-cjk-kr.css +1 -1
  289. package/src/style-cjk-tc.css +1 -1
  290. package/src/style-cjk.css +1 -1
  291. package/src/styles/components.css +540 -14
  292. package/src/styles/site-media.css +111 -19
  293. package/src/styles/tokens.css +76 -10
  294. package/src/styles/ui.css +2227 -1347
  295. package/src/types/bindings.ts +3 -0
  296. package/src/types/config.ts +243 -12
  297. package/src/types/constants.ts +22 -1
  298. package/src/types/entities.ts +14 -8
  299. package/src/types/operations.ts +18 -10
  300. package/src/types/props.ts +20 -2
  301. package/src/types/raw-assets.d.ts +5 -0
  302. package/src/types/views.ts +22 -4
  303. package/src/ui/__tests__/font-themes.test.ts +42 -30
  304. package/src/ui/compose/ComposeDialog.tsx +109 -19
  305. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  306. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  307. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  308. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  309. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  310. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  311. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  312. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  313. package/src/ui/feed/LinkCard.tsx +2 -12
  314. package/src/ui/feed/LinkPreview.tsx +2 -7
  315. package/src/ui/feed/NoteCard.tsx +10 -20
  316. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  317. package/src/ui/feed/QuoteCard.tsx +2 -12
  318. package/src/ui/feed/ThreadPreview.tsx +26 -34
  319. package/src/ui/feed/TimelineFeed.tsx +2 -2
  320. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  321. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  322. package/src/ui/feed/thread-preview-state.ts +5 -7
  323. package/src/ui/font-themes.ts +74 -26
  324. package/src/ui/layouts/BaseLayout.tsx +22 -8
  325. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  326. package/src/ui/pages/ArchivePage.tsx +131 -24
  327. package/src/ui/pages/CollectionPage.tsx +101 -22
  328. package/src/ui/pages/CollectionsPage.tsx +3 -1
  329. package/src/ui/pages/PostPage.tsx +39 -12
  330. package/src/ui/pages/SearchPage.tsx +6 -14
  331. package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
  332. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  333. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  334. package/src/ui/shared/CollectionsManager.tsx +17 -7
  335. package/src/ui/shared/DraftPreviewBar.tsx +49 -0
  336. package/src/ui/shared/HomePageBranding.tsx +2 -2
  337. package/src/ui/shared/MediaGallery.tsx +1 -0
  338. package/src/ui/shared/PostFooter.tsx +38 -13
  339. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  340. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  341. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  342. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  343. package/src/ui/shared/collection-management-labels.ts +33 -2
  344. package/src/ui/shared/custom-icons.ts +5 -0
  345. package/src/ui/shared/navigation-labels.ts +2 -4
  346. package/src/ui/shared/post-article-attributes.ts +52 -0
  347. package/dist/app-DZDlmh7C.js +0 -6
  348. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  349. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  350. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -20,6 +20,7 @@ import {
20
20
  asc,
21
21
  lte,
22
22
  gt,
23
+ getTableColumns,
23
24
  } from "drizzle-orm";
24
25
  import {
25
26
  type Database,
@@ -31,14 +32,25 @@ import {
31
32
  sqliteSchemaBundle,
32
33
  type DatabaseSchema,
33
34
  } from "../db/schema-bundle.js";
35
+ import {
36
+ buildRootActivityExpr,
37
+ rootActivityColumns,
38
+ } from "../db/thread-activity.js";
34
39
  import { createEntityId } from "../lib/ids.js";
35
40
  import { now } from "../lib/time.js";
36
- import { renderTiptapJson, trimTiptapBody } from "../lib/tiptap-render.js";
41
+ import { trimTiptapBody } from "../lib/tiptap-render.js";
42
+ import {
43
+ POST_BODY_HTML_VERSION,
44
+ renderPostBodyHtml,
45
+ resolvePostBodyHtml,
46
+ tryPreparePostBodyHtml,
47
+ } from "../lib/post-body-html.js";
37
48
  import { extractSummary, extractBodyText } from "../lib/summary.js";
38
49
  import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
39
50
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
40
51
  import { generatePostSlug } from "../lib/slug.js";
41
52
  import { getSlugValidationIssue } from "../lib/slug-format.js";
53
+ import { isReservedPath } from "../lib/constants.js";
42
54
  import { normalizePath, slugify } from "../lib/url.js";
43
55
  import type { StorageDriver } from "../lib/storage.js";
44
56
  import type { MediaService } from "./media.js";
@@ -73,6 +85,9 @@ import {
73
85
  } from "../lib/youtube.js";
74
86
  import { getPreviewStorageKey } from "../lib/upload.js";
75
87
  import { generateRandomId } from "../lib/nanoid.js";
88
+ import { resolveSummaryConfig } from "../lib/resolve-config.js";
89
+ import type { Bindings } from "../types/bindings.js";
90
+ import type { SettingsService } from "./settings.js";
76
91
 
77
92
  /** Dependencies for operations that coordinate with other services */
78
93
  export interface PostDeleteDeps {
@@ -80,6 +95,32 @@ export interface PostDeleteDeps {
80
95
  storage?: StorageDriver | null;
81
96
  }
82
97
 
98
+ export interface RebuildBodyHtmlOptions {
99
+ /** Batch size (1..100, default 50). */
100
+ limit?: number;
101
+ /** Exclusive post ID cursor. */
102
+ cursor?: string;
103
+ /** Compute and report changes without writing them. */
104
+ dryRun?: boolean;
105
+ /** Summary settings used only when a canonical legacy body is upgraded. */
106
+ summaryConfig?: SummaryConfig;
107
+ }
108
+
109
+ export interface RebuildBodyHtmlResult {
110
+ processed: number;
111
+ wouldRebuild: number;
112
+ rebuilt: number;
113
+ wouldUpgradeFootnotes: number;
114
+ upgradedFootnotes: number;
115
+ skipped: number;
116
+ conflicted: number;
117
+ failed: number;
118
+ failures: Array<{ postId: string; error: string }>;
119
+ nextCursor: string | null;
120
+ done: boolean;
121
+ targetVersion: number;
122
+ }
123
+
83
124
  export interface PostAttachmentDeps extends PostDeleteDeps {
84
125
  storageDriver: string;
85
126
  maxFileSizeMB: number;
@@ -104,6 +145,14 @@ export interface PostFilters {
104
145
  publishedAfter?: number;
105
146
  /** Unix timestamp (exclusive) — only posts published before this time */
106
147
  publishedBefore?: number;
148
+ /**
149
+ * Unix timestamp (inclusive) — range filter on whichever column `sortBy`
150
+ * selects. Kept separate from `publishedAfter` so a caller can pin a
151
+ * publication window (the RSS delay) and still filter on the sort axis.
152
+ */
153
+ axisAfter?: number;
154
+ /** Unix timestamp (exclusive) — upper bound on the `sortBy` column */
155
+ axisBefore?: number;
107
156
  /** Media kinds to filter by (OR logic: post has media of ANY selected kind). */
108
157
  mediaKinds?: MediaKind[];
109
158
  /** Filter by media presence */
@@ -119,7 +168,21 @@ export interface PostFilters {
119
168
  hasReplies?: boolean;
120
169
  /** Explicit result sort order */
121
170
  sortOrder?: SortOrder;
122
- /** Ignore global pinned ordering when sorting subscription/feed results. */
171
+ /**
172
+ * Time axis for chronological queries (defaults to announced activity).
173
+ *
174
+ * - `activity` — `lastActivityAt`: last announced post. Quiet replies do not
175
+ * move it. Latest and the feeds use this.
176
+ * - `thread_updated` — `threadUpdatedAt`: last post of any kind, quiet
177
+ * included. The archive's updated sort uses this.
178
+ * - `published` — when the Thread root itself was published.
179
+ *
180
+ * Selects the column used for sorting **and** for the year/month bucketing
181
+ * done by `countByYearMonth` and `getDistinctYears`, so a caller that groups
182
+ * its results cannot end up with buckets that disagree with the order.
183
+ */
184
+ sortBy?: "activity" | "thread_updated" | "published";
185
+ /** Ignore global pinned ordering when results must remain chronological. */
123
186
  ignorePinnedSort?: boolean;
124
187
  limit?: number;
125
188
  cursor?: string;
@@ -135,6 +198,13 @@ export interface SummaryConfig {
135
198
  interface ThreadRootPageOptions {
136
199
  status?: Status;
137
200
  excludePrivate?: boolean;
201
+ excludeLatestHidden?: boolean;
202
+ /** Exclude Posts published at or after this Unix timestamp. */
203
+ publishedBefore?: number;
204
+ /** Restrict by the Thread root's format without excluding Child Posts. */
205
+ rootFormat?: Format;
206
+ /** Restrict to Threads with at least one rated published post. */
207
+ hasRating?: true;
138
208
  limit?: number;
139
209
  offset?: number;
140
210
  }
@@ -146,6 +216,8 @@ interface CollectionFeedEntryOptions extends ThreadRootPageOptions {
146
216
 
147
217
  interface CollectionThreadRootPageOptions extends ThreadRootPageOptions {
148
218
  sortOrder?: CollectionSortOrder;
219
+ /** Root Post ID returned by the previous page. */
220
+ cursor?: string;
149
221
  }
150
222
 
151
223
  interface CursorSortKey {
@@ -159,6 +231,24 @@ export interface CollectionFeedEntry {
159
231
  collectedAt: number;
160
232
  }
161
233
 
234
+ export interface FeaturedThreadTimelinePost {
235
+ post: Post;
236
+ /** Zero-based position among the Thread's published Posts. */
237
+ position: number;
238
+ }
239
+
240
+ /**
241
+ * Bounded projection for one curated Featured Thread.
242
+ *
243
+ * `posts` contains only the Root, every Featured Post, and the final published
244
+ * Post, in Thread order. Positions preserve the hidden-gap counts without
245
+ * loading the omitted Posts or their related metadata.
246
+ */
247
+ export interface FeaturedThreadTimelineData {
248
+ posts: FeaturedThreadTimelinePost[];
249
+ featuredPostIds: string[];
250
+ }
251
+
162
252
  export interface PostBodyContent {
163
253
  id: string;
164
254
  type: "post";
@@ -223,7 +313,7 @@ export interface PostService {
223
313
  count(filters?: PostFilters): Promise<number>;
224
314
  /** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
225
315
  countUpTo(filters: PostFilters | undefined, limit: number): Promise<number>;
226
- /** Count posts grouped by published year-month (YYYY-MM) */
316
+ /** Count posts grouped by year-month (YYYY-MM) on the `sortBy` time axis */
227
317
  countByYearMonth(
228
318
  filters?: PostFilters,
229
319
  ): Promise<{ yearMonth: string; count: number }[]>;
@@ -237,6 +327,8 @@ export interface PostService {
237
327
  /**
238
328
  * Atomically create a thread of posts. The first item is the root; each
239
329
  * subsequent item is automatically chained as a reply to the previous one.
330
+ * When the first item quietly extends an existing thread, the quiet behavior
331
+ * applies to every new reply in the batch.
240
332
  * On failure, all already-created posts are rolled back.
241
333
  *
242
334
  * @param items - Ordered list of (data, attachments) pairs; at least 2 required
@@ -278,6 +370,21 @@ export interface PostService {
278
370
  */
279
371
  deleteThreadDraft(id: string, deps?: PostDeleteDeps): Promise<boolean>;
280
372
  getThread(rootId: string): Promise<Post[]>;
373
+ /**
374
+ * 1-based position of a Post in the reply chain running from its Thread root
375
+ * down to it: a root is 1, a reply to it is 2, a reply to that is 3.
376
+ *
377
+ * Threads are stored as a tree (`replyToId`) flattened under one `threadId`,
378
+ * so this is the Post's depth, not how many Posts the Thread holds — two
379
+ * replies to the same parent are both at the same position.
380
+ *
381
+ * @param postId - TypeID of the Post to locate
382
+ * @returns The 1-based position, or 0 when no such Post exists
383
+ * @example
384
+ * // Root -> reply -> reply, asked about the last one
385
+ * await posts.getThreadPosition(lastId); // => 3
386
+ */
387
+ getThreadPosition(postId: string): Promise<number>;
281
388
  updateThreadStatusAndVisibility(
282
389
  rootId: string,
283
390
  status: Status,
@@ -296,8 +403,12 @@ export interface PostService {
296
403
  ): Promise<Map<string, ThreadTimelineContext>>;
297
404
  /** Count distinct thread roots that contain featured published posts */
298
405
  countFeaturedThreadRoots(options?: ThreadRootPageOptions): Promise<number>;
299
- /** List featured thread root IDs ordered by the latest featured post in each thread */
406
+ /** List Featured Thread roots by the latest selected Post publication time. */
300
407
  listFeaturedThreadRootIds(options?: ThreadRootPageOptions): Promise<string[]>;
408
+ /** Load only the Posts rendered by the curated Featured Thread timeline. */
409
+ getFeaturedThreadTimelineData(
410
+ rootIds: string[],
411
+ ): Promise<Map<string, FeaturedThreadTimelineData>>;
301
412
  /** Count distinct thread roots that contain published posts in the given collection */
302
413
  countCollectionThreadRoots(
303
414
  collectionId: string,
@@ -308,6 +419,12 @@ export interface PostService {
308
419
  collectionIds: string[],
309
420
  options?: ThreadRootPageOptions,
310
421
  ): Promise<number>;
422
+ /** Count distinct matching collection Threads up to a fixed limit. */
423
+ countCollectionThreadRootsUpToForCollections(
424
+ collectionIds: string[],
425
+ options: ThreadRootPageOptions | undefined,
426
+ limit: number,
427
+ ): Promise<number>;
311
428
  /** List collection thread root IDs ordered by collected-at or rating semantics */
312
429
  listCollectionThreadRootIds(
313
430
  collectionId: string,
@@ -318,6 +435,11 @@ export interface PostService {
318
435
  collectionIds: string[],
319
436
  options?: CollectionThreadRootPageOptions,
320
437
  ): Promise<string[]>;
438
+ /** List and hydrate collection Thread roots using Thread-level ordering. */
439
+ listCollectionThreadRootsForCollections(
440
+ collectionIds: string[],
441
+ options?: CollectionThreadRootPageOptions,
442
+ ): Promise<Post[]>;
321
443
  /** List collection feed entries ordered by latest added-at timestamp */
322
444
  listCollectionFeedEntries(
323
445
  collectionId: string,
@@ -328,22 +450,36 @@ export interface PostService {
328
450
  collectionIds: string[],
329
451
  options?: CollectionFeedEntryOptions,
330
452
  ): Promise<CollectionFeedEntry[]>;
331
- /** Fetch all published, non-deleted posts for each requested thread root */
332
- getPublishedThreads(rootIds: string[]): Promise<Map<string, Post[]>>;
333
- /** For each thread, return post IDs that belong to the given collection */
334
- getCollectionPostIdsByThread(
335
- collectionId: string,
336
- threadIds: string[],
337
- ): Promise<Map<string, string[]>>;
338
- /** For each thread, return post IDs that belong to any of the given collections */
339
- getCollectionPostIdsByThreadForCollections(
340
- collectionIds: string[],
341
- threadIds: string[],
342
- ): Promise<Map<string, string[]>>;
343
- /** Get distinct years that have published posts */
453
+ /** Fetch published Posts for each requested Thread root. */
454
+ getPublishedThreads(
455
+ rootIds: string[],
456
+ options?: Pick<PostFilters, "publishedBefore">,
457
+ ): Promise<Map<string, Post[]>>;
458
+ /** Get distinct years with posts, bucketed on the `sortBy` time axis */
344
459
  getDistinctYears(filters?: PostFilters): Promise<number[]>;
345
- /** For each thread ID, return the ID of the last published, non-deleted post */
346
- getLastPostIdsByThread(threadIds: string[]): Promise<Map<string, string>>;
460
+ /**
461
+ * For each Thread ID, resolve the Post that currently ends the chain.
462
+ *
463
+ * Two callers need two different answers. Readers ask what the audience can
464
+ * see, so drafts must not count — the Reply affordance belongs on the last
465
+ * published Post. The reply guard asks what the chain physically ends with,
466
+ * and there an unpublished draft still owns the slot: attaching a second
467
+ * reply to the same parent would fork a structure that is meant to be linear.
468
+ *
469
+ * @param threadIds - Thread root IDs to resolve (duplicates are fine)
470
+ * @param options - `includeDrafts` counts unpublished members as the tail
471
+ * @returns Map of Thread root ID to its tail Post ID; threads with no
472
+ * matching member are absent from the map
473
+ * @example
474
+ * ```ts
475
+ * const tails = await posts.getThreadTailIds([root.id], { includeDrafts: true });
476
+ * const tailId = tails.get(root.id);
477
+ * ```
478
+ */
479
+ getThreadTailIds(
480
+ threadIds: string[],
481
+ options?: { includeDrafts?: boolean },
482
+ ): Promise<Map<string, string>>;
347
483
  /**
348
484
  * Rebuild `post.body_text` for a batch of non-deleted posts, cursor-paginated
349
485
  * by post id. For each row, recomputes the plain-text extraction via
@@ -366,6 +502,44 @@ export interface PostService {
366
502
  nextCursor: string | null;
367
503
  done: boolean;
368
504
  }>;
505
+ /**
506
+ * Upgrade recognized legacy footnotes and rebuild stored post body HTML for
507
+ * this site.
508
+ *
509
+ * Updates use compare-and-swap guards so concurrent edits win. Editorial
510
+ * timestamps are never touched. The operation is cursor-paginated,
511
+ * idempotent, and supports a read-only dry run.
512
+ */
513
+ rebuildBodyHtml(
514
+ options?: RebuildBodyHtmlOptions,
515
+ ): Promise<RebuildBodyHtmlResult>;
516
+ }
517
+
518
+ /**
519
+ * Rebuild body HTML using the site's current runtime summary settings.
520
+ *
521
+ * Internal maintenance routes do not run behind the normal app-config
522
+ * middleware, so this service-level orchestration preserves the same
523
+ * DB → environment → built-in resolution used by regular requests.
524
+ *
525
+ * @param services - Site-scoped post and settings services.
526
+ * @param env - Runtime environment bindings.
527
+ * @param options - Rebuild options supplied by the maintenance caller.
528
+ * @returns Rebuild progress and outcome counts.
529
+ */
530
+ export async function rebuildPostBodyHtmlWithRuntimeSettings(
531
+ services: Pick<
532
+ { posts: PostService; settings: SettingsService },
533
+ "posts" | "settings"
534
+ >,
535
+ env: Bindings,
536
+ options: Omit<RebuildBodyHtmlOptions, "summaryConfig"> = {},
537
+ ): Promise<RebuildBodyHtmlResult> {
538
+ const allSettings = await services.settings.getAll();
539
+ return services.posts.rebuildBodyHtml({
540
+ ...options,
541
+ summaryConfig: resolveSummaryConfig(env, allSettings),
542
+ });
369
543
  }
370
544
 
371
545
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
@@ -521,20 +695,32 @@ export function createPostService(
521
695
  databaseSchema: DatabaseSchema = sqliteSchemaBundle,
522
696
  ): PostService {
523
697
  const resolvedPaths = paths ?? createPathService(db, siteId, databaseSchema);
524
- const { pathRegistry, posts, postCollections } = databaseSchema;
698
+ const { navItems, pathRegistry, posts, sites, threadCollections } =
699
+ databaseSchema;
525
700
  const databaseDialect = config.databaseDialect ?? "sqlite";
526
701
  const usesBatchWrites = !supportsDrizzleTransaction(db, databaseDialect);
527
702
 
528
- function buildPublishedYearMonthExpr(): SQL<string> {
703
+ /**
704
+ * Column that carries the time axis selected by `PostFilters.sortBy`.
705
+ * Sorting, year/month bucketing, and date-range filters all read it through
706
+ * here so they can never drift onto different columns.
707
+ */
708
+ function timeAxisColumn(filters: Pick<PostFilters, "sortBy">) {
709
+ if (filters.sortBy === "activity") return posts.lastActivityAt;
710
+ if (filters.sortBy === "thread_updated") return posts.threadUpdatedAt;
711
+ return posts.publishedAt;
712
+ }
713
+
714
+ function buildYearMonthExpr(column: SQLWrapper): SQL<string> {
529
715
  return databaseDialect === "pg"
530
- ? sql<string>`to_char(timezone('UTC', to_timestamp(${posts.publishedAt})), 'YYYY-MM')`
531
- : sql<string>`strftime('%Y-%m', ${posts.publishedAt}, 'unixepoch')`;
716
+ ? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY-MM')`
717
+ : sql<string>`strftime('%Y-%m', ${column}, 'unixepoch')`;
532
718
  }
533
719
 
534
- function buildPublishedYearExpr(): SQL<string> {
720
+ function buildYearExpr(column: SQLWrapper): SQL<string> {
535
721
  return databaseDialect === "pg"
536
- ? sql<string>`to_char(timezone('UTC', to_timestamp(${posts.publishedAt})), 'YYYY')`
537
- : sql<string>`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`;
722
+ ? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY')`
723
+ : sql<string>`strftime('%Y', ${column}, 'unixepoch')`;
538
724
  }
539
725
 
540
726
  const effectiveVisibilityExpr = sql<string>`coalesce(
@@ -575,28 +761,58 @@ export function createPostService(
575
761
  return rows.length > 0;
576
762
  }
577
763
 
578
- async function recalculateThreadLastActivity(rootId: string): Promise<void> {
764
+ /**
765
+ * Recompute both Thread activity timestamps on the root from the Thread's
766
+ * rows.
767
+ *
768
+ * - `lastActivityAt` — newest published post excluding quiet replies. This
769
+ * is "last announced" and drives Latest and the feeds.
770
+ * - `threadUpdatedAt` — newest published post including quiet replies. This
771
+ * is "last changed" and drives the archive's updated sort.
772
+ *
773
+ * Both are derived, never accumulated, so a quiet reply stays quiet no
774
+ * matter what later edits or deletions trigger a recalculation.
775
+ *
776
+ * @param rootId - Thread root post ID
777
+ */
778
+ async function recalculateThreadActivity(rootId: string): Promise<void> {
579
779
  const rootRows = await db
580
780
  .select({
581
- latestPublishedAt: sql<number | null>`MAX(${posts.publishedAt})`.as(
582
- "latest_published_at",
781
+ announcedAt: sql<number | null>`MAX(
782
+ CASE
783
+ WHEN ${posts.quietReply} THEN NULL
784
+ ELSE ${posts.publishedAt}
785
+ END
786
+ )`.as("announced_at"),
787
+ updatedAt: sql<number | null>`MAX(${posts.publishedAt})`.as(
788
+ "thread_updated_at",
583
789
  ),
584
790
  })
585
791
  .from(posts)
586
- .where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId)));
792
+ .where(
793
+ and(
794
+ eq(posts.siteId, siteId),
795
+ eq(posts.threadId, rootId),
796
+ eq(posts.status, "published"),
797
+ ),
798
+ );
587
799
 
588
- const latestPublishedAt = rootRows[0]?.latestPublishedAt ?? null;
800
+ const announcedAt = rootRows[0]?.announcedAt ?? null;
801
+ const threadUpdatedAt = rootRows[0]?.updatedAt ?? null;
589
802
  const root = await db
590
803
  .select({ updatedAt: posts.updatedAt })
591
804
  .from(posts)
592
805
  .where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)))
593
806
  .limit(1);
594
807
 
595
- const lastActivityAt = latestPublishedAt ?? root[0]?.updatedAt ?? now();
808
+ const fallback = root[0]?.updatedAt ?? now();
596
809
 
597
810
  await db
598
811
  .update(posts)
599
- .set({ lastActivityAt })
812
+ .set({
813
+ lastActivityAt: announcedAt ?? fallback,
814
+ threadUpdatedAt: threadUpdatedAt ?? fallback,
815
+ })
600
816
  .where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)));
601
817
  }
602
818
 
@@ -695,11 +911,11 @@ export function createPostService(
695
911
  }
696
912
 
697
913
  return uniqueCollectionIds.length === 1
698
- ? eq(postCollections.collectionId, firstCollectionId)
699
- : inArray(postCollections.collectionId, uniqueCollectionIds);
914
+ ? eq(threadCollections.collectionId, firstCollectionId)
915
+ : inArray(threadCollections.collectionId, uniqueCollectionIds);
700
916
  }
701
917
 
702
- function buildPostCollectionSubqueryCondition(
918
+ function buildThreadCollectionSubqueryCondition(
703
919
  collectionIds: readonly string[],
704
920
  ): SQL<unknown> {
705
921
  const uniqueCollectionIds = normalizeCollectionIds(collectionIds);
@@ -710,9 +926,9 @@ export function createPostService(
710
926
  const placeholders = uniqueCollectionIds.map(
711
927
  (collectionId) => sql`${collectionId}`,
712
928
  );
713
- return sql`${posts.id} IN (
714
- SELECT post_id
715
- FROM post_collection
929
+ return sql`${posts.threadId} IN (
930
+ SELECT thread_id
931
+ FROM thread_collection
716
932
  WHERE site_id = ${siteId}
717
933
  AND collection_id IN (${sql.join(placeholders, sql`, `)})
718
934
  )`;
@@ -753,11 +969,11 @@ export function createPostService(
753
969
  }
754
970
  if (filters.collectionIds !== undefined) {
755
971
  conditions.push(
756
- buildPostCollectionSubqueryCondition(filters.collectionIds),
972
+ buildThreadCollectionSubqueryCondition(filters.collectionIds),
757
973
  );
758
974
  } else if (filters.collectionId !== undefined) {
759
975
  conditions.push(
760
- buildPostCollectionSubqueryCondition([filters.collectionId]),
976
+ buildThreadCollectionSubqueryCondition([filters.collectionId]),
761
977
  );
762
978
  }
763
979
  if (filters.threadId) {
@@ -772,6 +988,15 @@ export function createPostService(
772
988
  if (filters.publishedBefore !== undefined) {
773
989
  conditions.push(sql`${posts.publishedAt} < ${filters.publishedBefore}`);
774
990
  }
991
+ if (filters.axisAfter !== undefined || filters.axisBefore !== undefined) {
992
+ const axis = timeAxisColumn(filters);
993
+ if (filters.axisAfter !== undefined) {
994
+ conditions.push(sql`${axis} >= ${filters.axisAfter}`);
995
+ }
996
+ if (filters.axisBefore !== undefined) {
997
+ conditions.push(sql`${axis} < ${filters.axisBefore}`);
998
+ }
999
+ }
775
1000
  if (filters.mediaKinds && filters.mediaKinds.length > 0) {
776
1001
  const placeholders = filters.mediaKinds.map((k) => sql`${k}`);
777
1002
  conditions.push(
@@ -784,19 +1009,13 @@ export function createPostService(
784
1009
  );
785
1010
  }
786
1011
  if (filters.hasMedia !== undefined) {
787
- if (filters.hasMedia) {
788
- conditions.push(
789
- sql`${posts.id} IN (
790
- SELECT post_id FROM media WHERE site_id = ${siteId}
791
- )`,
792
- );
793
- } else {
794
- conditions.push(
795
- sql`${posts.id} NOT IN (
796
- SELECT post_id FROM media WHERE site_id = ${siteId}
797
- )`,
798
- );
799
- }
1012
+ const mediaExists = sql`EXISTS (
1013
+ SELECT 1
1014
+ FROM media
1015
+ WHERE site_id = ${siteId}
1016
+ AND post_id = ${posts.id}
1017
+ )`;
1018
+ conditions.push(filters.hasMedia ? mediaExists : sql`NOT ${mediaExists}`);
800
1019
  }
801
1020
  if (filters.hasTitle !== undefined) {
802
1021
  if (filters.hasTitle) {
@@ -831,21 +1050,38 @@ export function createPostService(
831
1050
  return conditions;
832
1051
  }
833
1052
 
834
- async function getLastLivePostIdInThread(
835
- threadId: string,
836
- ): Promise<string | null> {
837
- const rows = await db
838
- .select({ id: posts.id })
839
- .from(posts)
840
- .where(and(eq(posts.siteId, siteId), eq(posts.threadId, threadId)))
841
- .orderBy(desc(posts.createdAt), desc(posts.id))
842
- .limit(1);
843
-
844
- return rows[0]?.id ?? null;
1053
+ function getCursorSortTimestamp(
1054
+ row: typeof posts.$inferSelect,
1055
+ filters: PostFilters,
1056
+ ): number {
1057
+ if (filters.sortBy === "published") {
1058
+ return row.publishedAt ?? row.createdAt;
1059
+ }
1060
+ if (filters.sortBy === "thread_updated") {
1061
+ return row.threadUpdatedAt ?? -1;
1062
+ }
1063
+ return row.status === "draft" ? row.updatedAt : (row.lastActivityAt ?? -1);
845
1064
  }
846
1065
 
847
- function getCursorSortTimestamp(row: typeof posts.$inferSelect): number {
848
- return row.status === "draft" ? row.updatedAt : (row.lastActivityAt ?? -1);
1066
+ /**
1067
+ * Chronological sort key for `list()`.
1068
+ *
1069
+ * Shared by the ORDER BY and the keyset cursor comparison — they must read
1070
+ * the same expression or pagination silently skips or repeats rows.
1071
+ */
1072
+ function buildSortTimestampExpr(filters: PostFilters): SQLWrapper {
1073
+ if (filters.sortBy === "published") {
1074
+ return sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`;
1075
+ }
1076
+ if (filters.sortBy === "thread_updated") {
1077
+ return posts.threadUpdatedAt;
1078
+ }
1079
+ if (filters.status === "draft") return posts.updatedAt;
1080
+ if (filters.status === "published") return posts.lastActivityAt;
1081
+ return sql<number>`CASE
1082
+ WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
1083
+ ELSE ${posts.lastActivityAt}
1084
+ END`;
849
1085
  }
850
1086
 
851
1087
  function buildLexicographicCursorCondition(
@@ -887,17 +1123,11 @@ export function createPostService(
887
1123
  return null;
888
1124
  }
889
1125
 
890
- const sortTimestampExpr =
891
- filters.status === "draft"
892
- ? posts.updatedAt
893
- : filters.status === "published"
894
- ? posts.lastActivityAt
895
- : sql<number>`CASE
896
- WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
897
- ELSE ${posts.lastActivityAt}
898
- END`;
1126
+ const sortTimestampExpr = buildSortTimestampExpr(filters);
899
1127
  const pinnedSortExpr = sql<number>`coalesce(${posts.pinnedAt}, -1)`;
900
- const featuredSortExpr = sql<number>`coalesce(${posts.featuredAt}, -1)`;
1128
+ const featuredPublishedSortExpr = sql<number>`coalesce(
1129
+ ${posts.publishedAt}, ${posts.createdAt}, -1
1130
+ )`;
901
1131
  const sortTimestampSortExpr = sql<number>`coalesce(${sortTimestampExpr}, -1)`;
902
1132
  const ratingPresenceExpr = sql<number>`CASE
903
1133
  WHEN ${posts.rating} IS NULL THEN 0
@@ -905,8 +1135,9 @@ export function createPostService(
905
1135
  END`;
906
1136
  const ratingSortExpr = sql<number>`coalesce(${posts.rating}, -1)`;
907
1137
  const cursorPinnedAt = cursorPost.pinnedAt ?? -1;
908
- const cursorFeaturedAt = cursorPost.featuredAt ?? -1;
909
- const cursorSortTimestamp = getCursorSortTimestamp(cursorPost);
1138
+ const cursorFeaturedPublishedAt =
1139
+ cursorPost.publishedAt ?? cursorPost.createdAt;
1140
+ const cursorSortTimestamp = getCursorSortTimestamp(cursorPost, filters);
910
1141
  const cursorRatingPresence = cursorPost.rating === null ? 0 : 1;
911
1142
  const cursorRating = cursorPost.rating ?? -1;
912
1143
  const withPinnedSortKey = (
@@ -925,8 +1156,8 @@ export function createPostService(
925
1156
  withPinnedSortKey([
926
1157
  {
927
1158
  direction: "desc",
928
- expr: featuredSortExpr,
929
- value: cursorFeaturedAt,
1159
+ expr: featuredPublishedSortExpr,
1160
+ value: cursorFeaturedPublishedAt,
930
1161
  },
931
1162
  { direction: "desc", expr: posts.id, value: cursorPost.id },
932
1163
  ]),
@@ -1012,7 +1243,12 @@ export function createPostService(
1012
1243
  title: row.title,
1013
1244
  url: row.url,
1014
1245
  body: row.body,
1015
- bodyHtml: row.bodyHtml,
1246
+ bodyHtml: resolvePostBodyHtml({
1247
+ id: row.id,
1248
+ body: row.body,
1249
+ bodyHtml: row.bodyHtml,
1250
+ bodyHtmlVersion: row.bodyHtmlVersion,
1251
+ }),
1016
1252
  bodyText: row.bodyText,
1017
1253
  quoteText: row.quoteText,
1018
1254
  summary: row.summary,
@@ -1022,8 +1258,14 @@ export function createPostService(
1022
1258
  previewProvider: row.previewProvider,
1023
1259
  replyToId: row.replyToId,
1024
1260
  threadId: row.threadId,
1261
+ quietReply: row.quietReply,
1025
1262
  publishedAt: row.publishedAt,
1026
1263
  lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
1264
+ threadUpdatedAt:
1265
+ row.threadUpdatedAt ??
1266
+ row.lastActivityAt ??
1267
+ row.publishedAt ??
1268
+ row.updatedAt,
1027
1269
  createdAt: row.createdAt,
1028
1270
  updatedAt: row.updatedAt,
1029
1271
  };
@@ -1122,15 +1364,50 @@ export function createPostService(
1122
1364
  if (options?.excludePrivate) {
1123
1365
  conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
1124
1366
  }
1367
+ if (options?.excludeLatestHidden) {
1368
+ conditions.push(sql`${effectiveVisibilityExpr} != 'latest_hidden'`);
1369
+ }
1370
+ if (options?.publishedBefore !== undefined) {
1371
+ conditions.push(
1372
+ sql`${posts.publishedAt} < ${options.publishedBefore}`,
1373
+ sql`EXISTS (
1374
+ SELECT 1
1375
+ FROM post AS publication_root
1376
+ WHERE publication_root.site_id = ${siteId}
1377
+ AND publication_root.id = ${posts.threadId}
1378
+ AND publication_root.published_at < ${options.publishedBefore}
1379
+ )`,
1380
+ );
1381
+ }
1382
+ if (options?.rootFormat) {
1383
+ conditions.push(sql`EXISTS (
1384
+ SELECT 1
1385
+ FROM post AS collection_root
1386
+ WHERE collection_root.site_id = ${siteId}
1387
+ AND collection_root.id = ${posts.threadId}
1388
+ AND collection_root.format = ${options.rootFormat}
1389
+ )`);
1390
+ }
1391
+ if (options?.hasRating) {
1392
+ conditions.push(isNotNull(posts.rating));
1393
+ }
1125
1394
 
1126
1395
  return conditions;
1127
1396
  }
1128
1397
 
1398
+ /**
1399
+ * Thread activity timestamp used to order collection threads.
1400
+ *
1401
+ * Rows here are Thread *members* grouped by thread_id, so the definition is
1402
+ * reached through a subquery to the root. The outer MAX only collapses the
1403
+ * group — the subquery returns one value per thread. The outer COALESCE
1404
+ * covers a member whose root row is missing.
1405
+ */
1129
1406
  function buildCollectionThreadActivityExpr(alias: string) {
1130
1407
  return sql<number>`MAX(
1131
1408
  COALESCE(
1132
1409
  (
1133
- SELECT root.last_activity_at
1410
+ SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
1134
1411
  FROM post AS root
1135
1412
  WHERE root.site_id = ${siteId}
1136
1413
  AND root.id = ${posts.threadId}
@@ -1141,6 +1418,59 @@ export function createPostService(
1141
1418
  )`.as(alias);
1142
1419
  }
1143
1420
 
1421
+ function buildCollectionThreadSortQuery(
1422
+ collectionIds: string[],
1423
+ options: CollectionThreadRootPageOptions,
1424
+ ) {
1425
+ const conditions = [
1426
+ ...buildThreadRootPageConditions(options),
1427
+ buildCollectionMembershipCondition(collectionIds),
1428
+ ];
1429
+ const sortOrder = options.sortOrder ?? "newest";
1430
+ const publishedAt =
1431
+ sortOrder === "oldest"
1432
+ ? sql<number>`MIN(${posts.publishedAt})`.as("published_at")
1433
+ : sql<number>`MAX(${posts.publishedAt})`.as("published_at");
1434
+ const threadActivityAt =
1435
+ buildCollectionThreadActivityExpr("thread_activity_at");
1436
+ const ratingPresence = sql<number>`MAX(
1437
+ CASE
1438
+ WHEN ${posts.rating} IS NULL THEN 0
1439
+ ELSE 1
1440
+ END
1441
+ )`.as("rating_presence");
1442
+ const ratingValue = sql<number>`MAX(coalesce(${posts.rating}, -1))`.as(
1443
+ "rating_value",
1444
+ );
1445
+ const collectionPinnedAt =
1446
+ sql<number>`MAX(coalesce(${threadCollections.pinnedAt}, -1))`.as(
1447
+ "collection_pinned_at",
1448
+ );
1449
+
1450
+ const sortedThreads = db
1451
+ .select({
1452
+ threadId: posts.threadId,
1453
+ publishedAt,
1454
+ threadActivityAt,
1455
+ collectionPinnedAt,
1456
+ ratingPresence,
1457
+ ratingValue,
1458
+ })
1459
+ .from(posts)
1460
+ .innerJoin(
1461
+ threadCollections,
1462
+ and(
1463
+ eq(threadCollections.siteId, posts.siteId),
1464
+ eq(threadCollections.threadId, posts.threadId),
1465
+ ),
1466
+ )
1467
+ .where(and(...conditions))
1468
+ .groupBy(posts.threadId)
1469
+ .as("collection_thread_sort");
1470
+
1471
+ return { sortOrder, sortedThreads };
1472
+ }
1473
+
1144
1474
  function isMediaAttachmentInput(
1145
1475
  attachment: PostAttachmentInput,
1146
1476
  ): attachment is Extract<PostAttachmentInput, { type: "media" }> {
@@ -1210,36 +1540,50 @@ export function createPostService(
1210
1540
  await deps.media.deleteByIds(mediaIds, deps.storage).catch(() => undefined);
1211
1541
  }
1212
1542
 
1213
- async function getCollectionIdsForPost(postId: string): Promise<string[]> {
1543
+ async function getCollectionIdsForThread(
1544
+ threadId: string,
1545
+ ): Promise<string[]> {
1214
1546
  const rows = await db
1215
- .select({ collectionId: postCollections.collectionId })
1216
- .from(postCollections)
1547
+ .select({ collectionId: threadCollections.collectionId })
1548
+ .from(threadCollections)
1217
1549
  .where(
1218
1550
  and(
1219
- eq(postCollections.siteId, siteId),
1220
- eq(postCollections.postId, postId),
1551
+ eq(threadCollections.siteId, siteId),
1552
+ eq(threadCollections.threadId, threadId),
1221
1553
  ),
1222
1554
  );
1223
1555
 
1224
1556
  return rows.map((row) => row.collectionId);
1225
1557
  }
1226
1558
 
1227
- async function getPostCollectionTimestamps(
1228
- postId: string,
1229
- ): Promise<Map<string, number>> {
1559
+ async function getThreadCollectionEntries(threadId: string): Promise<
1560
+ Map<
1561
+ string,
1562
+ {
1563
+ createdAt: number;
1564
+ pinnedAt: number | null;
1565
+ }
1566
+ >
1567
+ > {
1230
1568
  const rows = await db
1231
1569
  .select({
1232
- collectionId: postCollections.collectionId,
1233
- createdAt: postCollections.createdAt,
1570
+ collectionId: threadCollections.collectionId,
1571
+ createdAt: threadCollections.createdAt,
1572
+ pinnedAt: threadCollections.pinnedAt,
1234
1573
  })
1235
- .from(postCollections)
1574
+ .from(threadCollections)
1236
1575
  .where(
1237
1576
  and(
1238
- eq(postCollections.siteId, siteId),
1239
- eq(postCollections.postId, postId),
1577
+ eq(threadCollections.siteId, siteId),
1578
+ eq(threadCollections.threadId, threadId),
1240
1579
  ),
1241
1580
  );
1242
- return new Map(rows.map((r) => [r.collectionId, r.createdAt]));
1581
+ return new Map(
1582
+ rows.map((row) => [
1583
+ row.collectionId,
1584
+ { createdAt: row.createdAt, pinnedAt: row.pinnedAt },
1585
+ ]),
1586
+ );
1243
1587
  }
1244
1588
 
1245
1589
  function buildRollbackUpdate(
@@ -1323,15 +1667,7 @@ export function createPostService(
1323
1667
  if (filters.cursor && !cursorCondition) {
1324
1668
  return [];
1325
1669
  }
1326
- const sortTimestamp =
1327
- filters.status === "draft"
1328
- ? posts.updatedAt
1329
- : filters.status === "published"
1330
- ? posts.lastActivityAt
1331
- : sql<number>`CASE
1332
- WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
1333
- ELSE ${posts.lastActivityAt}
1334
- END`;
1670
+ const sortTimestamp = buildSortTimestampExpr(filters);
1335
1671
 
1336
1672
  if (cursorCondition) {
1337
1673
  conditions.push(cursorCondition);
@@ -1344,10 +1680,13 @@ export function createPostService(
1344
1680
 
1345
1681
  // NULL-sort order differs between dialects: SQLite puts NULLs last for
1346
1682
  // DESC, Postgres puts them first. Wrap nullable sort keys in COALESCE
1347
- // so pinned/featured posts land on top under both engines. Mirrors the
1683
+ // so pinned and nullable timeline values sort identically under both
1684
+ // engines. Mirrors the
1348
1685
  // expressions used by `buildListCursorCondition` above.
1349
1686
  const pinnedSortExpr = sql<number>`coalesce(${posts.pinnedAt}, -1)`;
1350
- const featuredSortExpr = sql<number>`coalesce(${posts.featuredAt}, -1)`;
1687
+ const featuredPublishedSortExpr = sql<number>`coalesce(
1688
+ ${posts.publishedAt}, ${posts.createdAt}, -1
1689
+ )`;
1351
1690
  const sortTimestampSortExpr = sql<number>`coalesce(${sortTimestamp}, -1)`;
1352
1691
  const pinnedOrder = filters.ignorePinnedSort
1353
1692
  ? []
@@ -1364,7 +1703,7 @@ export function createPostService(
1364
1703
  ? baseQuery.orderBy(
1365
1704
  ...pinnedOrder,
1366
1705
  filters.featured
1367
- ? desc(featuredSortExpr)
1706
+ ? desc(featuredPublishedSortExpr)
1368
1707
  : desc(sortTimestampSortExpr),
1369
1708
  desc(posts.id),
1370
1709
  )
@@ -1410,10 +1749,30 @@ export function createPostService(
1410
1749
  conditions.push(sql`${posts.id} > ${afterId}`);
1411
1750
  }
1412
1751
 
1752
+ // `lastmod` describes the page, and a Thread root's page renders its
1753
+ // replies too — so an added or edited reply changes it even though the
1754
+ // root row is untouched. Unlike ordering, edits do count here: the page
1755
+ // really did change. Mirrors `articleModifiedTime` in lib/post-display.
1756
+ //
1757
+ // The outer columns are written out rather than interpolated: Drizzle
1758
+ // only qualifies projection columns when the query has a join, so
1759
+ // `${posts.id}` would render as a bare "id" here and bind to the
1760
+ // subquery's own alias instead of correlating to the outer row.
1761
+ const threadModifiedAt = sql<number>`COALESCE(
1762
+ (
1763
+ SELECT MAX(member.updated_at)
1764
+ FROM post AS member
1765
+ WHERE member.site_id = ${siteId}
1766
+ AND member.thread_id = "post"."id"
1767
+ AND member.status = 'published'
1768
+ ),
1769
+ "post"."updated_at"
1770
+ )`;
1771
+
1413
1772
  const rows = await db
1414
1773
  .select({
1415
1774
  id: posts.id,
1416
- updatedAt: posts.updatedAt,
1775
+ updatedAt: threadModifiedAt,
1417
1776
  featuredAt: posts.featuredAt,
1418
1777
  })
1419
1778
  .from(posts)
@@ -1503,11 +1862,9 @@ export function createPostService(
1503
1862
  },
1504
1863
 
1505
1864
  async countByYearMonth(filters = {}) {
1506
- const conditions = [
1507
- ...buildFilterConditions(filters),
1508
- isNotNull(posts.publishedAt),
1509
- ];
1510
- const publishedYearMonthExpr = buildPublishedYearMonthExpr();
1865
+ const axis = timeAxisColumn(filters);
1866
+ const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
1867
+ const publishedYearMonthExpr = buildYearMonthExpr(axis);
1511
1868
 
1512
1869
  return db
1513
1870
  .select({
@@ -1531,11 +1888,17 @@ export function createPostService(
1531
1888
  ? ensurePostVisibility(data.visibility)
1532
1889
  : undefined;
1533
1890
  const rating = ensurePostRating(data.rating);
1891
+ // Only replies can be quiet — a root has nothing to stay quiet about.
1892
+ const isQuietReply = Boolean(data.replyToId) && data.quietReply === true;
1534
1893
 
1535
1894
  const rawBody = data.bodyMarkdown
1536
1895
  ? markdownToTiptapJson(data.bodyMarkdown)
1537
1896
  : (data.body ?? null);
1538
- const body = rawBody ? trimTiptapBody(rawBody) : null;
1897
+ const trimmedBody = rawBody ? trimTiptapBody(rawBody) : null;
1898
+ const preparedBody = trimmedBody
1899
+ ? tryPreparePostBodyHtml(id, trimmedBody)
1900
+ : null;
1901
+ const body = preparedBody?.ok ? preparedBody.body : trimmedBody;
1539
1902
  const title = data.title?.trim() || null;
1540
1903
  const quoteText = data.quoteText?.trim() || null;
1541
1904
  const url = data.url?.trim() || null;
@@ -1551,7 +1914,11 @@ export function createPostService(
1551
1914
  : undefined,
1552
1915
  });
1553
1916
 
1554
- const bodyHtml = body ? renderTiptapJson(body) : null;
1917
+ const bodyHtml = preparedBody?.ok
1918
+ ? preparedBody.html
1919
+ : body
1920
+ ? renderPostBodyHtml(id, body)
1921
+ : null;
1555
1922
  const bodyText = body
1556
1923
  ? extractBodyText(body, { includeLinkHrefs: true })
1557
1924
  : null;
@@ -1603,10 +1970,19 @@ export function createPostService(
1603
1970
  );
1604
1971
  }
1605
1972
 
1606
- const lastLivePostId = await getLastLivePostIdInThread(parent.threadId);
1607
- if (lastLivePostId && lastLivePostId !== parent.id) {
1973
+ // Drafts count here: an unpublished reply already owns the tail slot,
1974
+ // and a sibling attached to the same parent would fork the chain.
1975
+ const tailId = (
1976
+ await this.getThreadTailIds([parent.threadId], {
1977
+ includeDrafts: true,
1978
+ })
1979
+ ).get(parent.threadId);
1980
+ if (tailId && tailId !== parent.id) {
1981
+ const tail = await this.getById(tailId);
1608
1982
  throw new ConflictError(
1609
- "This post is no longer the end of the thread. Reply to the latest post instead.",
1983
+ tail?.status === "draft"
1984
+ ? "This thread ends with an unpublished draft. Finish that draft or discard it, then reply."
1985
+ : "This post is no longer the end of the thread. Reply to the latest post instead.",
1610
1986
  );
1611
1987
  }
1612
1988
 
@@ -1617,12 +1993,29 @@ export function createPostService(
1617
1993
  parent.threadId === parent.id
1618
1994
  ? parent
1619
1995
  : await this.getById(parent.threadId);
1620
- if (root) {
1621
- if (data.status !== "draft") {
1622
- status = root.status;
1623
- }
1996
+ // A reply takes the root's status. A draft earlier in the chain does
1997
+ // not hold it back: readers never saw that post, so publishing past it
1998
+ // reads as continuous, and the draft stays parked until it is dealt
1999
+ // with on its own.
2000
+ if (root && data.status !== "draft") {
2001
+ status = root.status;
1624
2002
  }
1625
2003
  visibility = null;
2004
+
2005
+ if (
2006
+ (data.collectionIds?.length ?? 0) > 0 ||
2007
+ (data.collectionEntries?.length ?? 0) > 0
2008
+ ) {
2009
+ throw new ConflictError(
2010
+ "Cannot set Collections while creating a Thread reply. Set them on the Thread root instead.",
2011
+ );
2012
+ }
2013
+ }
2014
+
2015
+ // Featured surfaces only list published posts, so featuring a draft
2016
+ // writes a flag that does nothing and shows nowhere. Say so instead.
2017
+ if (status === "draft" && resolvedFeaturedAt !== null) {
2018
+ throw new ConflictError("Publish this post before featuring it.");
1626
2019
  }
1627
2020
 
1628
2021
  assertDraftPublishedAt(status, data.publishedAt);
@@ -1643,6 +2036,11 @@ export function createPostService(
1643
2036
 
1644
2037
  if (data.path) {
1645
2038
  const normalized = normalizePath(data.path);
2039
+ if (isReservedPath(normalized)) {
2040
+ throw new ValidationError(
2041
+ `Path "${normalized}" is reserved and cannot be used`,
2042
+ );
2043
+ }
1646
2044
  if (isValidSlug(normalized)) {
1647
2045
  // Path is a valid slug — use it directly
1648
2046
  slug = await generatePostSlug({
@@ -1674,7 +2072,8 @@ export function createPostService(
1674
2072
  });
1675
2073
  }
1676
2074
 
1677
- // When structured `collectionEntries` are provided (Hugo import path),
2075
+ // Thread collection membership is stored against the root ID. When
2076
+ // structured `collectionEntries` are provided (Hugo import path),
1678
2077
  // they win over the bare `collectionIds` slug list and carry
1679
2078
  // `createdAt` / `position` / `pinnedAt` per row. Otherwise fall back to
1680
2079
  // the simple list and derive those fields from sensible defaults.
@@ -1683,7 +2082,7 @@ export function createPostService(
1683
2082
  data.collectionEntries.length > 0;
1684
2083
  const collectionInsertRows: {
1685
2084
  siteId: string;
1686
- postId: string;
2085
+ threadId: string;
1687
2086
  collectionId: string;
1688
2087
  createdAt: number;
1689
2088
  position: number;
@@ -1693,7 +2092,7 @@ export function createPostService(
1693
2092
  const seen = new Set<string>();
1694
2093
  const rows: {
1695
2094
  siteId: string;
1696
- postId: string;
2095
+ threadId: string;
1697
2096
  collectionId: string;
1698
2097
  createdAt: number;
1699
2098
  position: number;
@@ -1705,7 +2104,7 @@ export function createPostService(
1705
2104
  seen.add(entry.collectionId);
1706
2105
  rows.push({
1707
2106
  siteId,
1708
- postId: id,
2107
+ threadId,
1709
2108
  collectionId: entry.collectionId,
1710
2109
  createdAt: entry.createdAt ?? timestamp,
1711
2110
  position: entry.position ?? fallbackPosition,
@@ -1717,7 +2116,7 @@ export function createPostService(
1717
2116
  })()
1718
2117
  : [...new Set(data.collectionIds ?? [])].map((collectionId, index) => ({
1719
2118
  siteId,
1720
- postId: id,
2119
+ threadId,
1721
2120
  collectionId,
1722
2121
  createdAt: timestamp,
1723
2122
  position: index,
@@ -1741,14 +2140,17 @@ export function createPostService(
1741
2140
  url,
1742
2141
  body,
1743
2142
  bodyHtml,
2143
+ bodyHtmlVersion: POST_BODY_HTML_VERSION,
1744
2144
  bodyText,
1745
2145
  quoteText,
1746
2146
  summary,
1747
2147
  rating,
1748
2148
  replyToId: data.replyToId ?? null,
1749
2149
  threadId,
2150
+ quietReply: isQuietReply,
1750
2151
  publishedAt,
1751
2152
  lastActivityAt: publishedAt ?? timestamp,
2153
+ threadUpdatedAt: publishedAt ?? timestamp,
1752
2154
  createdAt: timestamp,
1753
2155
  updatedAt: timestamp,
1754
2156
  }),
@@ -1788,7 +2190,12 @@ export function createPostService(
1788
2190
 
1789
2191
  if (collectionInsertRows.length > 0) {
1790
2192
  writeQueries.push(
1791
- db.insert(postCollections).values(collectionInsertRows),
2193
+ data.replyToId
2194
+ ? db
2195
+ .insert(threadCollections)
2196
+ .values(collectionInsertRows)
2197
+ .onConflictDoNothing()
2198
+ : db.insert(threadCollections).values(collectionInsertRows),
1792
2199
  );
1793
2200
  }
1794
2201
 
@@ -1812,14 +2219,17 @@ export function createPostService(
1812
2219
  url,
1813
2220
  body,
1814
2221
  bodyHtml,
2222
+ bodyHtmlVersion: POST_BODY_HTML_VERSION,
1815
2223
  bodyText,
1816
2224
  quoteText,
1817
2225
  summary,
1818
2226
  rating,
1819
2227
  replyToId: data.replyToId ?? null,
1820
2228
  threadId,
2229
+ quietReply: isQuietReply,
1821
2230
  publishedAt,
1822
2231
  lastActivityAt: publishedAt ?? timestamp,
2232
+ threadUpdatedAt: publishedAt ?? timestamp,
1823
2233
  createdAt: timestamp,
1824
2234
  updatedAt: timestamp,
1825
2235
  });
@@ -1853,7 +2263,14 @@ export function createPostService(
1853
2263
  }
1854
2264
 
1855
2265
  if (collectionInsertRows.length > 0) {
1856
- await tx.insert(postCollections).values(collectionInsertRows);
2266
+ const insertQuery = tx
2267
+ .insert(threadCollections)
2268
+ .values(collectionInsertRows);
2269
+ if (data.replyToId) {
2270
+ await insertQuery.onConflictDoNothing();
2271
+ } else {
2272
+ await insertQuery;
2273
+ }
1857
2274
  }
1858
2275
  });
1859
2276
  }
@@ -1872,9 +2289,11 @@ export function createPostService(
1872
2289
  throw new ConflictError(`Slug "${slug}" could not be resolved`);
1873
2290
  }
1874
2291
 
1875
- // Bump thread root's lastActivityAt when creating a published reply
1876
- if (data.replyToId && status === "published" && !data.quietReply) {
1877
- await recalculateThreadLastActivity(threadId);
2292
+ // A quiet reply still changes the Thread, it just isn't announced —
2293
+ // the recalculation reads the persisted quiet_reply flag and keeps
2294
+ // lastActivityAt where it was while moving threadUpdatedAt forward.
2295
+ if (data.replyToId && status === "published") {
2296
+ await recalculateThreadActivity(threadId);
1878
2297
  }
1879
2298
 
1880
2299
  return post;
@@ -1926,6 +2345,9 @@ export function createPostService(
1926
2345
  throw new ValidationError("A thread requires at least 2 posts.");
1927
2346
  }
1928
2347
 
2348
+ const extendsExistingThreadQuietly =
2349
+ items[0]?.data.replyToId !== undefined &&
2350
+ items[0].data.quietReply === true;
1929
2351
  const created: Post[] = [];
1930
2352
 
1931
2353
  for (let i = 0; i < items.length; i++) {
@@ -1933,10 +2355,19 @@ export function createPostService(
1933
2355
  if (!item) continue;
1934
2356
  const { data, attachments } = item;
1935
2357
  const prevPost = created[i - 1];
2358
+ // A reply with no date of its own belongs to the root's moment, not to
2359
+ // whenever the request happened to run. Without this, backdating the
2360
+ // root leaves its replies stamped today and the thread reads as if it
2361
+ // spanned years. Thread order is by `createdAt`/`id` (see the thread
2362
+ // queries), so sharing one `publishedAt` cannot reorder anything.
2363
+ const rootPublishedAt = created[0]?.publishedAt ?? undefined;
1936
2364
  const postData: CreatePost = {
1937
2365
  ...data,
1938
2366
  // Chain each post as a reply to the previous one (server-side chaining)
1939
2367
  replyToId: i === 0 ? data.replyToId : prevPost?.id,
2368
+ quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
2369
+ publishedAt:
2370
+ i === 0 ? data.publishedAt : (data.publishedAt ?? rootPublishedAt),
1940
2371
  };
1941
2372
 
1942
2373
  try {
@@ -1990,6 +2421,19 @@ export function createPostService(
1990
2421
 
1991
2422
  const nextTitle =
1992
2423
  data.title !== undefined ? data.title?.trim() || null : existing.title;
2424
+ const effectiveNextVisibility = nextVisibility ?? existing.visibility;
2425
+ const wasNavigationPageEligible =
2426
+ existing.format === "note" &&
2427
+ existing.status === "published" &&
2428
+ existing.visibility !== "private" &&
2429
+ !isThreadReply(existing) &&
2430
+ hasNonEmptyText(existing.title);
2431
+ const remainsNavigationPageEligible =
2432
+ nextFormat === "note" &&
2433
+ nextStatus === "published" &&
2434
+ effectiveNextVisibility !== "private" &&
2435
+ !isThreadReply(existing) &&
2436
+ hasNonEmptyText(nextTitle);
1993
2437
 
1994
2438
  assertPostFormatShape({
1995
2439
  format: nextFormat,
@@ -2045,18 +2489,32 @@ export function createPostService(
2045
2489
  } else if (data.featured !== undefined) {
2046
2490
  updates.featuredAt = data.featured ? timestamp : null;
2047
2491
  }
2492
+ // Same rule `create` enforces: nothing unpublished can be featured.
2493
+ // Only guards turning it on — an existing flag survives a trip back to
2494
+ // draft and takes effect again when the post is republished.
2495
+ if (nextStatus === "draft" && updates.featuredAt) {
2496
+ throw new ConflictError("Publish this post before featuring it.");
2497
+ }
2048
2498
 
2499
+ let updatedBody: string | null | undefined;
2049
2500
  if (data.body !== undefined || data.bodyMarkdown !== undefined) {
2050
2501
  const rawBody = data.bodyMarkdown
2051
2502
  ? markdownToTiptapJson(data.bodyMarkdown)
2052
2503
  : (data.body ?? null);
2053
2504
  const normalizedBody = rawBody ? trimTiptapBody(rawBody) : null;
2054
- updates.body = normalizedBody;
2055
- updates.bodyHtml = normalizedBody
2056
- ? renderTiptapJson(normalizedBody)
2505
+ const preparedBody = normalizedBody
2506
+ ? tryPreparePostBodyHtml(existing.id, normalizedBody)
2057
2507
  : null;
2058
- updates.bodyText = normalizedBody
2059
- ? extractBodyText(normalizedBody, { includeLinkHrefs: true })
2508
+ updatedBody = preparedBody?.ok ? preparedBody.body : normalizedBody;
2509
+ updates.body = updatedBody;
2510
+ updates.bodyHtml = preparedBody?.ok
2511
+ ? preparedBody.html
2512
+ : updatedBody
2513
+ ? renderPostBodyHtml(existing.id, updatedBody)
2514
+ : null;
2515
+ updates.bodyHtmlVersion = POST_BODY_HTML_VERSION;
2516
+ updates.bodyText = updatedBody
2517
+ ? extractBodyText(updatedBody, { includeLinkHrefs: true })
2060
2518
  : null;
2061
2519
  }
2062
2520
 
@@ -2064,14 +2522,7 @@ export function createPostService(
2064
2522
  if (summaryConfig) {
2065
2523
  const format = nextFormat;
2066
2524
  const title = nextTitle;
2067
- const body =
2068
- data.bodyMarkdown !== undefined
2069
- ? data.bodyMarkdown
2070
- ? markdownToTiptapJson(data.bodyMarkdown)
2071
- : null
2072
- : data.body !== undefined
2073
- ? data.body
2074
- : existing.body;
2525
+ const body = updatedBody !== undefined ? updatedBody : existing.body;
2075
2526
  if (format === "note" && title && body) {
2076
2527
  updates.summary = extractSummary(
2077
2528
  body,
@@ -2128,14 +2579,24 @@ export function createPostService(
2128
2579
  const needsCascade = statusChanged && !isThreadReply(existing);
2129
2580
  const needsReplyVisibilityCleanup =
2130
2581
  !isThreadReply(existing) && (statusChanged || visibilityChanged);
2131
- const needsCollectionSync = data.collectionIds !== undefined;
2132
- const nextCollectionIds = needsCollectionSync
2133
- ? [...new Set(data.collectionIds ?? [])]
2134
- : [];
2582
+ const needsCollectionSync =
2583
+ data.collectionIds !== undefined ||
2584
+ data.collectionEntries !== undefined;
2135
2585
  const needsThreadActivityRecalc =
2136
2586
  statusChanged || publishedAtChanged || existing.status === "draft";
2587
+ const needsPageNavDelete =
2588
+ wasNavigationPageEligible && !remainsNavigationPageEligible;
2589
+ const needsPageNavUrlUpdate =
2590
+ wasNavigationPageEligible &&
2591
+ remainsNavigationPageEligible &&
2592
+ slugChanged &&
2593
+ Boolean(data.slug);
2137
2594
  const hasExtraWrites =
2138
- needsCascade || needsReplyVisibilityCleanup || needsCollectionSync;
2595
+ needsCascade ||
2596
+ needsReplyVisibilityCleanup ||
2597
+ needsCollectionSync ||
2598
+ needsPageNavDelete ||
2599
+ needsPageNavUrlUpdate;
2139
2600
 
2140
2601
  if (!hasExtraWrites) {
2141
2602
  // Simple case: only the post update
@@ -2145,16 +2606,63 @@ export function createPostService(
2145
2606
  .where(and(eq(posts.siteId, siteId), eq(posts.id, id)))
2146
2607
  .returning();
2147
2608
  if (needsThreadActivityRecalc) {
2148
- await recalculateThreadLastActivity(existing.threadId);
2609
+ await recalculateThreadActivity(existing.threadId);
2149
2610
  return this.getById(id);
2150
2611
  }
2151
2612
  return hydratePost(result[0]);
2152
2613
  }
2153
2614
 
2154
- // Complex case: cascade + update + collection sync atomically
2155
- const existingCollectionTimestamps = needsCollectionSync
2156
- ? await getPostCollectionTimestamps(id)
2157
- : new Map<string, number>();
2615
+ // Complex case: cascade + update + Thread collection sync atomically.
2616
+ // Retained entries keep their collected/pinned timestamps when a normal
2617
+ // collectionIds update only changes selection or ordering.
2618
+ const existingThreadCollections = needsCollectionSync
2619
+ ? await getThreadCollectionEntries(existing.threadId)
2620
+ : new Map<string, { createdAt: number; pinnedAt: number | null }>();
2621
+ const collectionTimestamp = now();
2622
+ const nextThreadCollectionRows = (() => {
2623
+ if (!needsCollectionSync) return [];
2624
+
2625
+ if (data.collectionEntries !== undefined) {
2626
+ const seen = new Set<string>();
2627
+ return data.collectionEntries.flatMap((entry, index) => {
2628
+ if (seen.has(entry.collectionId)) return [];
2629
+ seen.add(entry.collectionId);
2630
+ const existingEntry = existingThreadCollections.get(
2631
+ entry.collectionId,
2632
+ );
2633
+ return [
2634
+ {
2635
+ siteId,
2636
+ threadId: existing.threadId,
2637
+ collectionId: entry.collectionId,
2638
+ createdAt:
2639
+ entry.createdAt ??
2640
+ existingEntry?.createdAt ??
2641
+ collectionTimestamp,
2642
+ position: entry.position ?? index,
2643
+ pinnedAt:
2644
+ entry.pinnedAt !== undefined
2645
+ ? entry.pinnedAt
2646
+ : (existingEntry?.pinnedAt ?? null),
2647
+ },
2648
+ ];
2649
+ });
2650
+ }
2651
+
2652
+ return [...new Set(data.collectionIds ?? [])].map(
2653
+ (collectionId, index) => {
2654
+ const existingEntry = existingThreadCollections.get(collectionId);
2655
+ return {
2656
+ siteId,
2657
+ threadId: existing.threadId,
2658
+ collectionId,
2659
+ createdAt: existingEntry?.createdAt ?? collectionTimestamp,
2660
+ position: index,
2661
+ pinnedAt: existingEntry?.pinnedAt ?? null,
2662
+ };
2663
+ },
2664
+ );
2665
+ })();
2158
2666
  let updateResult: (typeof posts.$inferSelect)[] | undefined;
2159
2667
 
2160
2668
  if (usesBatchWrites) {
@@ -2208,33 +2716,40 @@ export function createPostService(
2208
2716
  .returning(),
2209
2717
  );
2210
2718
 
2719
+ if (needsPageNavDelete) {
2720
+ writeQueries.push(
2721
+ db
2722
+ .delete(navItems)
2723
+ .where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id))),
2724
+ );
2725
+ } else if (needsPageNavUrlUpdate && data.slug) {
2726
+ writeQueries.push(
2727
+ db
2728
+ .update(navItems)
2729
+ .set({
2730
+ url: `/${normalizePath(data.slug)}`,
2731
+ updatedAt: timestamp,
2732
+ })
2733
+ .where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id))),
2734
+ );
2735
+ }
2736
+
2211
2737
  if (needsCollectionSync) {
2212
- // Delete all and re-insert to preserve user-specified ordering
2738
+ // Delete all and re-insert the one shared Thread membership set.
2213
2739
  writeQueries.push(
2214
2740
  db
2215
- .delete(postCollections)
2741
+ .delete(threadCollections)
2216
2742
  .where(
2217
2743
  and(
2218
- eq(postCollections.siteId, siteId),
2219
- eq(postCollections.postId, id),
2744
+ eq(threadCollections.siteId, siteId),
2745
+ eq(threadCollections.threadId, existing.threadId),
2220
2746
  ),
2221
2747
  ),
2222
2748
  );
2223
2749
 
2224
- if (nextCollectionIds.length > 0) {
2225
- const collectionTimestamp = now();
2750
+ if (nextThreadCollectionRows.length > 0) {
2226
2751
  writeQueries.push(
2227
- db.insert(postCollections).values(
2228
- nextCollectionIds.map((collectionId, index) => ({
2229
- siteId,
2230
- postId: id,
2231
- collectionId,
2232
- createdAt:
2233
- existingCollectionTimestamps.get(collectionId) ??
2234
- collectionTimestamp,
2235
- position: index,
2236
- })),
2237
- ),
2752
+ db.insert(threadCollections).values(nextThreadCollectionRows),
2238
2753
  );
2239
2754
  }
2240
2755
  }
@@ -2291,37 +2806,42 @@ export function createPostService(
2291
2806
  .where(and(eq(posts.siteId, siteId), eq(posts.id, id)))
2292
2807
  .returning();
2293
2808
 
2809
+ if (needsPageNavDelete) {
2810
+ await tx
2811
+ .delete(navItems)
2812
+ .where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id)));
2813
+ } else if (needsPageNavUrlUpdate && data.slug) {
2814
+ await tx
2815
+ .update(navItems)
2816
+ .set({
2817
+ url: `/${normalizePath(data.slug)}`,
2818
+ updatedAt: timestamp,
2819
+ })
2820
+ .where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id)));
2821
+ }
2822
+
2294
2823
  if (needsCollectionSync) {
2295
- // Delete all and re-insert to preserve user-specified ordering
2824
+ // Delete all and re-insert the one shared Thread membership set.
2296
2825
  await tx
2297
- .delete(postCollections)
2826
+ .delete(threadCollections)
2298
2827
  .where(
2299
2828
  and(
2300
- eq(postCollections.siteId, siteId),
2301
- eq(postCollections.postId, id),
2829
+ eq(threadCollections.siteId, siteId),
2830
+ eq(threadCollections.threadId, existing.threadId),
2302
2831
  ),
2303
2832
  );
2304
2833
 
2305
- if (nextCollectionIds.length > 0) {
2306
- const collectionTimestamp = now();
2307
- await tx.insert(postCollections).values(
2308
- nextCollectionIds.map((collectionId, index) => ({
2309
- siteId,
2310
- postId: id,
2311
- collectionId,
2312
- createdAt:
2313
- existingCollectionTimestamps.get(collectionId) ??
2314
- collectionTimestamp,
2315
- position: index,
2316
- })),
2317
- );
2834
+ if (nextThreadCollectionRows.length > 0) {
2835
+ await tx
2836
+ .insert(threadCollections)
2837
+ .values(nextThreadCollectionRows);
2318
2838
  }
2319
2839
  }
2320
2840
  });
2321
2841
  }
2322
2842
 
2323
2843
  if (needsThreadActivityRecalc) {
2324
- await recalculateThreadLastActivity(existing.threadId);
2844
+ await recalculateThreadActivity(existing.threadId);
2325
2845
  return this.getById(id);
2326
2846
  }
2327
2847
  return hydratePost(updateResult?.[0]);
@@ -2360,7 +2880,9 @@ export function createPostService(
2360
2880
  const existingPost = await this.getById(id);
2361
2881
  if (!existingPost) return null;
2362
2882
 
2363
- const existingCollectionIds = await getCollectionIdsForPost(id);
2883
+ const existingCollectionIds = await getCollectionIdsForThread(
2884
+ existingPost.threadId,
2885
+ );
2364
2886
  const rollbackData = buildRollbackUpdate(
2365
2887
  existingPost,
2366
2888
  existingCollectionIds,
@@ -2507,7 +3029,7 @@ export function createPostService(
2507
3029
  await db
2508
3030
  .delete(posts)
2509
3031
  .where(and(eq(posts.siteId, siteId), eq(posts.id, id)));
2510
- await recalculateThreadLastActivity(existing.threadId);
3032
+ await recalculateThreadActivity(existing.threadId);
2511
3033
  }
2512
3034
 
2513
3035
  return true;
@@ -2527,6 +3049,44 @@ export function createPostService(
2527
3049
  return hydratePosts(rows);
2528
3050
  },
2529
3051
 
3052
+ async getThreadPosition(postId) {
3053
+ const targetRows = await db
3054
+ .select({ replyToId: posts.replyToId, threadId: posts.threadId })
3055
+ .from(posts)
3056
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
3057
+ .limit(1);
3058
+
3059
+ const target = targetRows[0];
3060
+ if (!target) return 0;
3061
+ if (!target.replyToId) return 1;
3062
+
3063
+ // One read for the whole Thread, then walk up in memory: a chain of N
3064
+ // Posts would otherwise be N round trips.
3065
+ const threadRows = await db
3066
+ .select({ id: posts.id, replyToId: posts.replyToId })
3067
+ .from(posts)
3068
+ .where(
3069
+ and(eq(posts.siteId, siteId), eq(posts.threadId, target.threadId)),
3070
+ );
3071
+
3072
+ const parentOf = new Map(
3073
+ threadRows.map((row) => [row.id, row.replyToId] as const),
3074
+ );
3075
+
3076
+ let position = 1;
3077
+ let cursor: string | null = target.replyToId;
3078
+ // A cycle is impossible per the schema's CHECK constraints, but walking a
3079
+ // parent chain unguarded turns a bad row into a hung request.
3080
+ const visited = new Set<string>([postId]);
3081
+ while (cursor && !visited.has(cursor)) {
3082
+ visited.add(cursor);
3083
+ position += 1;
3084
+ cursor = parentOf.get(cursor) ?? null;
3085
+ }
3086
+
3087
+ return position;
3088
+ },
3089
+
2530
3090
  async updateThreadStatusAndVisibility(rootId, status, visibility) {
2531
3091
  const nextStatus = ensurePostStatus(status);
2532
3092
  const nextVisibility = ensurePostVisibility(visibility);
@@ -2591,7 +3151,7 @@ export function createPostService(
2591
3151
  );
2592
3152
  });
2593
3153
  }
2594
- await recalculateThreadLastActivity(rootId);
3154
+ await recalculateThreadActivity(rootId);
2595
3155
  },
2596
3156
 
2597
3157
  async getReplyCounts(postIds) {
@@ -2718,8 +3278,8 @@ export function createPostService(
2718
3278
  .from(rankedReplies)
2719
3279
  .where(
2720
3280
  or(
2721
- eq(rankedReplies.firstReplyRank, 1),
2722
- lte(rankedReplies.latestReplyRank, 2),
3281
+ lte(rankedReplies.firstReplyRank, 2),
3282
+ lte(rankedReplies.latestReplyRank, 3),
2723
3283
  ),
2724
3284
  );
2725
3285
 
@@ -2730,25 +3290,25 @@ export function createPostService(
2730
3290
  const contextByThreadId = new Map<
2731
3291
  string,
2732
3292
  {
2733
- secondReplyId: string | null;
2734
- penultimateReplyId: string | null;
3293
+ leadingReplyIds: Map<number, string>;
3294
+ trailingReplyIds: Map<number, string>;
2735
3295
  latestReplyId: string | null;
2736
3296
  totalReplyCount: number;
2737
3297
  }
2738
3298
  >();
2739
3299
  for (const row of contextRows) {
2740
3300
  const existing = contextByThreadId.get(row.threadId) ?? {
2741
- secondReplyId: null,
2742
- penultimateReplyId: null,
3301
+ leadingReplyIds: new Map<number, string>(),
3302
+ trailingReplyIds: new Map<number, string>(),
2743
3303
  latestReplyId: null,
2744
3304
  totalReplyCount: row.totalReplyCount,
2745
3305
  };
2746
3306
 
2747
- if (row.firstReplyRank === 1) {
2748
- existing.secondReplyId = row.id;
3307
+ if (row.firstReplyRank <= 2) {
3308
+ existing.leadingReplyIds.set(row.firstReplyRank, row.id);
2749
3309
  }
2750
- if (row.latestReplyRank === 2) {
2751
- existing.penultimateReplyId = row.id;
3310
+ if (row.latestReplyRank === 2 || row.latestReplyRank === 3) {
3311
+ existing.trailingReplyIds.set(row.latestReplyRank, row.id);
2752
3312
  }
2753
3313
  if (row.latestReplyRank === 1) {
2754
3314
  existing.latestReplyId = row.id;
@@ -2764,13 +3324,20 @@ export function createPostService(
2764
3324
  const latestReply = hydratedPosts.get(context.latestReplyId);
2765
3325
  if (!latestReply) continue;
2766
3326
 
3327
+ const hydrateReplyIds = (ids: Array<string | undefined>) =>
3328
+ ids
3329
+ .map((id) => (id ? hydratedPosts.get(id) : undefined))
3330
+ .filter((post): post is Post => post !== undefined);
3331
+
2767
3332
  result.set(threadId, {
2768
- secondReply: context.secondReplyId
2769
- ? (hydratedPosts.get(context.secondReplyId) ?? null)
2770
- : null,
2771
- penultimateReply: context.penultimateReplyId
2772
- ? (hydratedPosts.get(context.penultimateReplyId) ?? null)
2773
- : null,
3333
+ leadingReplies: hydrateReplyIds([
3334
+ context.leadingReplyIds.get(1),
3335
+ context.leadingReplyIds.get(2),
3336
+ ]),
3337
+ trailingReplies: hydrateReplyIds([
3338
+ context.trailingReplyIds.get(3),
3339
+ context.trailingReplyIds.get(2),
3340
+ ]),
2774
3341
  latestReply,
2775
3342
  totalReplyCount: context.totalReplyCount,
2776
3343
  });
@@ -2803,19 +3370,20 @@ export function createPostService(
2803
3370
  ...buildThreadRootPageConditions(options),
2804
3371
  isNotNull(posts.featuredAt),
2805
3372
  ];
2806
- const latestFeaturedAt = sql<number>`MAX(${posts.featuredAt})`.as(
2807
- "latest_featured_at",
2808
- );
3373
+ const latestFeaturedPublishedAt =
3374
+ sql<number>`MAX(${posts.publishedAt})`.as(
3375
+ "latest_featured_published_at",
3376
+ );
2809
3377
 
2810
3378
  let query = db
2811
3379
  .select({
2812
3380
  threadId: posts.threadId,
2813
- latestFeaturedAt,
3381
+ latestFeaturedPublishedAt,
2814
3382
  })
2815
3383
  .from(posts)
2816
3384
  .where(and(...conditions))
2817
3385
  .groupBy(posts.threadId)
2818
- .orderBy(desc(latestFeaturedAt), desc(posts.threadId));
3386
+ .orderBy(desc(latestFeaturedPublishedAt), desc(posts.threadId));
2819
3387
 
2820
3388
  if (options.limit !== undefined) {
2821
3389
  query = query.limit(options.limit) as typeof query;
@@ -2828,6 +3396,77 @@ export function createPostService(
2828
3396
  return rows.map((row) => row.threadId);
2829
3397
  },
2830
3398
 
3399
+ async getFeaturedThreadTimelineData(rootIds) {
3400
+ const result = new Map<string, FeaturedThreadTimelineData>();
3401
+ if (rootIds.length === 0) return result;
3402
+
3403
+ const uniqueRootIds = [...new Set(rootIds)];
3404
+ const threadPosition = sql<number>`CAST(
3405
+ row_number() OVER (
3406
+ PARTITION BY ${posts.threadId}
3407
+ ORDER BY ${posts.createdAt}, ${posts.id}
3408
+ ) - 1 AS INTEGER
3409
+ )`.as("thread_position");
3410
+ const threadPostCount = sql<number>`CAST(
3411
+ count(*) OVER (PARTITION BY ${posts.threadId}) AS INTEGER
3412
+ )`.as("thread_post_count");
3413
+
3414
+ const rankedPosts = db
3415
+ .select({
3416
+ ...getTableColumns(posts),
3417
+ threadPosition,
3418
+ threadPostCount,
3419
+ })
3420
+ .from(posts)
3421
+ .where(
3422
+ and(
3423
+ eq(posts.siteId, siteId),
3424
+ inArray(posts.threadId, uniqueRootIds),
3425
+ eq(posts.status, "published"),
3426
+ ),
3427
+ )
3428
+ .as("ranked_featured_thread_post");
3429
+
3430
+ const rows = await db
3431
+ .select()
3432
+ .from(rankedPosts)
3433
+ .where(
3434
+ or(
3435
+ eq(rankedPosts.id, rankedPosts.threadId),
3436
+ isNotNull(rankedPosts.featuredAt),
3437
+ sql`${rankedPosts.threadPosition} = ${rankedPosts.threadPostCount} - 1`,
3438
+ ),
3439
+ )
3440
+ .orderBy(rankedPosts.threadId, sql`${rankedPosts.threadPosition}`);
3441
+
3442
+ const hydratedPosts = await hydratePosts(
3443
+ rows.map(
3444
+ ({ threadPosition: _position, threadPostCount: _count, ...row }) =>
3445
+ row,
3446
+ ),
3447
+ );
3448
+ const hydratedById = new Map(
3449
+ hydratedPosts.map((post) => [post.id, post]),
3450
+ );
3451
+
3452
+ for (const row of rows) {
3453
+ const post = hydratedById.get(row.id);
3454
+ if (!post) continue;
3455
+
3456
+ const thread = result.get(row.threadId) ?? {
3457
+ posts: [],
3458
+ featuredPostIds: [],
3459
+ };
3460
+ thread.posts.push({ post, position: row.threadPosition });
3461
+ if (row.featuredAt !== null) {
3462
+ thread.featuredPostIds.push(row.id);
3463
+ }
3464
+ result.set(row.threadId, thread);
3465
+ }
3466
+
3467
+ return result;
3468
+ },
3469
+
2831
3470
  async countCollectionThreadRoots(collectionId, options = {}) {
2832
3471
  return this.countCollectionThreadRootsForCollections(
2833
3472
  [collectionId],
@@ -2853,10 +3492,10 @@ export function createPostService(
2853
3492
  })
2854
3493
  .from(posts)
2855
3494
  .innerJoin(
2856
- postCollections,
3495
+ threadCollections,
2857
3496
  and(
2858
- eq(postCollections.siteId, siteId),
2859
- eq(postCollections.postId, posts.id),
3497
+ eq(threadCollections.siteId, posts.siteId),
3498
+ eq(threadCollections.threadId, posts.threadId),
2860
3499
  ),
2861
3500
  )
2862
3501
  .where(and(...conditions));
@@ -2864,6 +3503,35 @@ export function createPostService(
2864
3503
  return rows[0]?.count ?? 0;
2865
3504
  },
2866
3505
 
3506
+ async countCollectionThreadRootsUpToForCollections(
3507
+ collectionIds,
3508
+ options = {},
3509
+ limit,
3510
+ ) {
3511
+ const normalizedLimit = Math.max(0, Math.trunc(limit));
3512
+ if (normalizedLimit === 0) return 0;
3513
+
3514
+ const conditions = [
3515
+ ...buildThreadRootPageConditions(options),
3516
+ buildCollectionMembershipCondition(collectionIds),
3517
+ ];
3518
+ const rows = await db
3519
+ .select({ threadId: posts.threadId })
3520
+ .from(posts)
3521
+ .innerJoin(
3522
+ threadCollections,
3523
+ and(
3524
+ eq(threadCollections.siteId, posts.siteId),
3525
+ eq(threadCollections.threadId, posts.threadId),
3526
+ ),
3527
+ )
3528
+ .where(and(...conditions))
3529
+ .groupBy(posts.threadId)
3530
+ .limit(normalizedLimit);
3531
+
3532
+ return rows.length;
3533
+ },
3534
+
2867
3535
  async listCollectionThreadRootIds(collectionId, options = {}) {
2868
3536
  return this.listCollectionThreadRootIdsForCollections(
2869
3537
  [collectionId],
@@ -2875,71 +3543,102 @@ export function createPostService(
2875
3543
  collectionIds,
2876
3544
  options = {},
2877
3545
  ) {
2878
- const conditions = [
2879
- ...buildThreadRootPageConditions(options),
2880
- buildCollectionMembershipCondition(collectionIds),
2881
- ];
2882
- const sortOrder = options.sortOrder ?? "newest";
2883
- const publishedAt =
2884
- sortOrder === "oldest"
2885
- ? sql<number>`MIN(${posts.publishedAt})`.as("published_at")
2886
- : sql<number>`MAX(${posts.publishedAt})`.as("published_at");
2887
- const threadActivityAt =
2888
- buildCollectionThreadActivityExpr("thread_activity_at");
2889
- const ratingPresence = sql<number>`MAX(
2890
- CASE
2891
- WHEN ${posts.rating} IS NULL THEN 0
2892
- ELSE 1
2893
- END
2894
- )`.as("rating_presence");
2895
- const ratingValue = sql<number | null>`MAX(${posts.rating})`.as(
2896
- "rating_value",
3546
+ const { sortOrder, sortedThreads } = buildCollectionThreadSortQuery(
3547
+ collectionIds,
3548
+ options,
2897
3549
  );
3550
+ let cursorCondition: SQL<unknown> | undefined;
3551
+
3552
+ if (options.cursor) {
3553
+ const cursorRows = await db
3554
+ .select()
3555
+ .from(sortedThreads)
3556
+ .where(eq(sortedThreads.threadId, options.cursor))
3557
+ .limit(1);
3558
+ const cursorRow = cursorRows[0];
3559
+ if (!cursorRow) return [];
3560
+
3561
+ const pinnedKey: CursorSortKey = {
3562
+ direction: "desc",
3563
+ expr: sortedThreads.collectionPinnedAt,
3564
+ value: cursorRow.collectionPinnedAt,
3565
+ };
2898
3566
 
2899
- const collectionPinnedAt =
2900
- sql<number>`MAX(coalesce(${postCollections.pinnedAt}, -1))`.as(
2901
- "collection_pinned_at",
2902
- );
3567
+ cursorCondition =
3568
+ sortOrder === "oldest"
3569
+ ? buildLexicographicCursorCondition([
3570
+ pinnedKey,
3571
+ {
3572
+ direction: "asc",
3573
+ expr: sortedThreads.publishedAt,
3574
+ value: cursorRow.publishedAt,
3575
+ },
3576
+ {
3577
+ direction: "asc",
3578
+ expr: sortedThreads.threadId,
3579
+ value: cursorRow.threadId,
3580
+ },
3581
+ ])
3582
+ : sortOrder === "rating_desc"
3583
+ ? buildLexicographicCursorCondition([
3584
+ pinnedKey,
3585
+ {
3586
+ direction: "desc",
3587
+ expr: sortedThreads.ratingPresence,
3588
+ value: cursorRow.ratingPresence,
3589
+ },
3590
+ {
3591
+ direction: "desc",
3592
+ expr: sortedThreads.ratingValue,
3593
+ value: cursorRow.ratingValue,
3594
+ },
3595
+ {
3596
+ direction: "desc",
3597
+ expr: sortedThreads.threadActivityAt,
3598
+ value: cursorRow.threadActivityAt,
3599
+ },
3600
+ {
3601
+ direction: "desc",
3602
+ expr: sortedThreads.threadId,
3603
+ value: cursorRow.threadId,
3604
+ },
3605
+ ])
3606
+ : buildLexicographicCursorCondition([
3607
+ pinnedKey,
3608
+ {
3609
+ direction: "desc",
3610
+ expr: sortedThreads.threadActivityAt,
3611
+ value: cursorRow.threadActivityAt,
3612
+ },
3613
+ {
3614
+ direction: "desc",
3615
+ expr: sortedThreads.threadId,
3616
+ value: cursorRow.threadId,
3617
+ },
3618
+ ]);
3619
+ }
2903
3620
 
2904
- const baseQuery = db
2905
- .select({
2906
- threadId: posts.threadId,
2907
- publishedAt,
2908
- threadActivityAt,
2909
- collectionPinnedAt,
2910
- ratingPresence,
2911
- ratingValue,
2912
- })
2913
- .from(posts)
2914
- .innerJoin(
2915
- postCollections,
2916
- and(
2917
- eq(postCollections.siteId, siteId),
2918
- eq(postCollections.postId, posts.id),
2919
- ),
2920
- )
2921
- .where(and(...conditions))
2922
- .groupBy(posts.threadId);
3621
+ const baseQuery = db.select().from(sortedThreads).where(cursorCondition);
2923
3622
 
2924
3623
  let query =
2925
3624
  sortOrder === "oldest"
2926
3625
  ? baseQuery.orderBy(
2927
- desc(collectionPinnedAt),
2928
- asc(publishedAt),
2929
- asc(posts.threadId),
3626
+ desc(sortedThreads.collectionPinnedAt),
3627
+ asc(sortedThreads.publishedAt),
3628
+ asc(sortedThreads.threadId),
2930
3629
  )
2931
3630
  : sortOrder === "rating_desc"
2932
3631
  ? baseQuery.orderBy(
2933
- desc(collectionPinnedAt),
2934
- desc(ratingPresence),
2935
- desc(ratingValue),
2936
- desc(threadActivityAt),
2937
- desc(posts.threadId),
3632
+ desc(sortedThreads.collectionPinnedAt),
3633
+ desc(sortedThreads.ratingPresence),
3634
+ desc(sortedThreads.ratingValue),
3635
+ desc(sortedThreads.threadActivityAt),
3636
+ desc(sortedThreads.threadId),
2938
3637
  )
2939
3638
  : baseQuery.orderBy(
2940
- desc(collectionPinnedAt),
2941
- desc(threadActivityAt),
2942
- desc(posts.threadId),
3639
+ desc(sortedThreads.collectionPinnedAt),
3640
+ desc(sortedThreads.threadActivityAt),
3641
+ desc(sortedThreads.threadId),
2943
3642
  );
2944
3643
 
2945
3644
  if (options.limit !== undefined) {
@@ -2953,6 +3652,18 @@ export function createPostService(
2953
3652
  return rows.map((row) => row.threadId);
2954
3653
  },
2955
3654
 
3655
+ async listCollectionThreadRootsForCollections(collectionIds, options = {}) {
3656
+ const rootIds = await this.listCollectionThreadRootIdsForCollections(
3657
+ collectionIds,
3658
+ options,
3659
+ );
3660
+ const rootsById = await hydratePostsById(rootIds);
3661
+ return rootIds.flatMap((rootId) => {
3662
+ const root = rootsById.get(rootId);
3663
+ return root ? [root] : [];
3664
+ });
3665
+ },
3666
+
2956
3667
  async listCollectionFeedEntries(collectionId, options = {}) {
2957
3668
  return this.listCollectionFeedEntriesForCollections(
2958
3669
  [collectionId],
@@ -2967,11 +3678,11 @@ export function createPostService(
2967
3678
  ];
2968
3679
  const threadActivityAt =
2969
3680
  buildCollectionThreadActivityExpr("thread_activity_at");
2970
- const collectedAt = sql<number>`MAX(${postCollections.createdAt})`.as(
3681
+ const collectedAt = sql<number>`MAX(${threadCollections.createdAt})`.as(
2971
3682
  "collected_at",
2972
3683
  );
2973
3684
  const collectionPinnedAt =
2974
- sql<number>`MAX(coalesce(${postCollections.pinnedAt}, -1))`.as(
3685
+ sql<number>`MAX(coalesce(${threadCollections.pinnedAt}, -1))`.as(
2975
3686
  "collection_pinned_at",
2976
3687
  );
2977
3688
  const pinnedOrder = options.ignoreCollectionPinnedSort
@@ -2987,10 +3698,10 @@ export function createPostService(
2987
3698
  })
2988
3699
  .from(posts)
2989
3700
  .innerJoin(
2990
- postCollections,
3701
+ threadCollections,
2991
3702
  and(
2992
- eq(postCollections.siteId, siteId),
2993
- eq(postCollections.postId, posts.id),
3703
+ eq(threadCollections.siteId, posts.siteId),
3704
+ eq(threadCollections.threadId, posts.threadId),
2994
3705
  ),
2995
3706
  )
2996
3707
  .where(and(...conditions))
@@ -3013,21 +3724,23 @@ export function createPostService(
3013
3724
  });
3014
3725
  },
3015
3726
 
3016
- async getPublishedThreads(rootIds) {
3727
+ async getPublishedThreads(rootIds, options = {}) {
3017
3728
  const result = new Map<string, Post[]>();
3018
3729
  if (rootIds.length === 0) return result;
3019
3730
 
3020
3731
  const unique = [...new Set(rootIds)];
3732
+ const conditions = [
3733
+ eq(posts.siteId, siteId),
3734
+ inArray(posts.threadId, unique),
3735
+ eq(posts.status, "published"),
3736
+ ];
3737
+ if (options.publishedBefore !== undefined) {
3738
+ conditions.push(sql`${posts.publishedAt} < ${options.publishedBefore}`);
3739
+ }
3021
3740
  const rows = await db
3022
3741
  .select()
3023
3742
  .from(posts)
3024
- .where(
3025
- and(
3026
- eq(posts.siteId, siteId),
3027
- inArray(posts.threadId, unique),
3028
- eq(posts.status, "published"),
3029
- ),
3030
- )
3743
+ .where(and(...conditions))
3031
3744
  .orderBy(posts.threadId, posts.createdAt, posts.id);
3032
3745
 
3033
3746
  for (const post of await hydratePosts(rows)) {
@@ -3042,57 +3755,7 @@ export function createPostService(
3042
3755
  return result;
3043
3756
  },
3044
3757
 
3045
- async getCollectionPostIdsByThread(collectionId, threadIds) {
3046
- return this.getCollectionPostIdsByThreadForCollections(
3047
- [collectionId],
3048
- threadIds,
3049
- );
3050
- },
3051
-
3052
- async getCollectionPostIdsByThreadForCollections(collectionIds, threadIds) {
3053
- const result = new Map<string, string[]>();
3054
- if (threadIds.length === 0) return result;
3055
-
3056
- const unique = [...new Set(threadIds)];
3057
- const rows = await batchQueryRows(unique, (chunk) =>
3058
- db
3059
- .select({
3060
- threadId: posts.threadId,
3061
- postId: posts.id,
3062
- })
3063
- .from(posts)
3064
- .innerJoin(
3065
- postCollections,
3066
- and(
3067
- eq(postCollections.siteId, siteId),
3068
- eq(postCollections.postId, posts.id),
3069
- ),
3070
- )
3071
- .where(
3072
- and(
3073
- eq(posts.siteId, siteId),
3074
- buildCollectionMembershipCondition(collectionIds),
3075
- inArray(posts.threadId, chunk),
3076
- eq(posts.status, "published"),
3077
- ),
3078
- )
3079
- .groupBy(posts.threadId, posts.id)
3080
- .orderBy(posts.threadId, posts.createdAt, posts.id),
3081
- );
3082
-
3083
- for (const row of rows) {
3084
- const list = result.get(row.threadId);
3085
- if (list) {
3086
- list.push(row.postId);
3087
- } else {
3088
- result.set(row.threadId, [row.postId]);
3089
- }
3090
- }
3091
-
3092
- return result;
3093
- },
3094
-
3095
- async getLastPostIdsByThread(threadIds) {
3758
+ async getThreadTailIds(threadIds, options) {
3096
3759
  const result = new Map<string, string>();
3097
3760
  if (threadIds.length === 0) return result;
3098
3761
 
@@ -3107,7 +3770,7 @@ export function createPostService(
3107
3770
  and(
3108
3771
  eq(posts.siteId, siteId),
3109
3772
  inArray(posts.threadId, unique),
3110
- eq(posts.status, "published"),
3773
+ ...(options?.includeDrafts ? [] : [eq(posts.status, "published")]),
3111
3774
  ),
3112
3775
  )
3113
3776
  .orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
@@ -3121,11 +3784,9 @@ export function createPostService(
3121
3784
  },
3122
3785
 
3123
3786
  async getDistinctYears(filters = {}) {
3124
- const conditions = [
3125
- ...buildFilterConditions(filters),
3126
- isNotNull(posts.publishedAt),
3127
- ];
3128
- const publishedYearExpr = buildPublishedYearExpr();
3787
+ const axis = timeAxisColumn(filters);
3788
+ const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
3789
+ const publishedYearExpr = buildYearExpr(axis);
3129
3790
 
3130
3791
  const rows = await db
3131
3792
  .select({
@@ -3191,5 +3852,144 @@ export function createPostService(
3191
3852
  done: !hasMore,
3192
3853
  };
3193
3854
  },
3855
+
3856
+ async rebuildBodyHtml(options = {}) {
3857
+ const requested = options.limit ?? 50;
3858
+ const limit = Math.min(Math.max(Math.trunc(requested), 1), 100);
3859
+ const cursor = options.cursor;
3860
+ const dryRun = options.dryRun ?? false;
3861
+
3862
+ const siteRows = await db
3863
+ .select({ id: sites.id })
3864
+ .from(sites)
3865
+ .where(eq(sites.id, siteId))
3866
+ .limit(1);
3867
+ if (!siteRows[0]) {
3868
+ throw new NotFoundError("Site");
3869
+ }
3870
+
3871
+ const whereConditions = [eq(posts.siteId, siteId)];
3872
+ if (cursor) whereConditions.push(gt(posts.id, cursor));
3873
+
3874
+ const rows = await db
3875
+ .select({
3876
+ id: posts.id,
3877
+ format: posts.format,
3878
+ title: posts.title,
3879
+ body: posts.body,
3880
+ bodyHtml: posts.bodyHtml,
3881
+ bodyHtmlVersion: posts.bodyHtmlVersion,
3882
+ })
3883
+ .from(posts)
3884
+ .where(and(...whereConditions))
3885
+ .orderBy(asc(posts.id))
3886
+ .limit(limit + 1);
3887
+
3888
+ const hasMore = rows.length > limit;
3889
+ const batch = hasMore ? rows.slice(0, limit) : rows;
3890
+ const failures: Array<{ postId: string; error: string }> = [];
3891
+ let wouldRebuild = 0;
3892
+ let rebuilt = 0;
3893
+ let wouldUpgradeFootnotes = 0;
3894
+ let upgradedFootnotes = 0;
3895
+ let skipped = 0;
3896
+ let conflicted = 0;
3897
+
3898
+ for (const row of batch) {
3899
+ if (row.bodyHtmlVersion > POST_BODY_HTML_VERSION) {
3900
+ skipped++;
3901
+ continue;
3902
+ }
3903
+
3904
+ let nextBody = row.body;
3905
+ let nextBodyHtml: string | null = null;
3906
+ let upgradesLegacyFootnotes = false;
3907
+ if (row.body !== null) {
3908
+ const prepared = tryPreparePostBodyHtml(row.id, row.body);
3909
+ if (!prepared.ok) {
3910
+ failures.push({ postId: row.id, error: prepared.error });
3911
+ continue;
3912
+ }
3913
+ nextBody = prepared.body;
3914
+ nextBodyHtml = prepared.html;
3915
+ upgradesLegacyFootnotes = prepared.upgradedLegacyFootnotes;
3916
+ }
3917
+
3918
+ if (
3919
+ row.bodyHtmlVersion === POST_BODY_HTML_VERSION &&
3920
+ row.body === nextBody &&
3921
+ row.bodyHtml === nextBodyHtml
3922
+ ) {
3923
+ skipped++;
3924
+ continue;
3925
+ }
3926
+
3927
+ wouldRebuild++;
3928
+ if (upgradesLegacyFootnotes) wouldUpgradeFootnotes++;
3929
+ if (dryRun) continue;
3930
+
3931
+ const bodyCondition =
3932
+ row.body === null ? isNull(posts.body) : eq(posts.body, row.body);
3933
+ const canonicalBodyChanged = row.body !== nextBody;
3934
+ const updates: Partial<typeof posts.$inferInsert> = {
3935
+ bodyHtml: nextBodyHtml,
3936
+ bodyHtmlVersion: POST_BODY_HTML_VERSION,
3937
+ };
3938
+ if (canonicalBodyChanged) {
3939
+ updates.body = nextBody;
3940
+ updates.bodyText = nextBody
3941
+ ? extractBodyText(nextBody, { includeLinkHrefs: true })
3942
+ : null;
3943
+
3944
+ if (options.summaryConfig) {
3945
+ updates.summary =
3946
+ row.format === "note" && row.title && nextBody
3947
+ ? extractSummary(
3948
+ nextBody,
3949
+ options.summaryConfig.maxParagraphs,
3950
+ options.summaryConfig.maxChars,
3951
+ )
3952
+ : null;
3953
+ }
3954
+ }
3955
+ const updatedRows = await db
3956
+ .update(posts)
3957
+ .set(updates)
3958
+ .where(
3959
+ and(
3960
+ eq(posts.siteId, siteId),
3961
+ eq(posts.id, row.id),
3962
+ bodyCondition,
3963
+ eq(posts.bodyHtmlVersion, row.bodyHtmlVersion),
3964
+ ),
3965
+ )
3966
+ .returning({ id: posts.id });
3967
+
3968
+ if (updatedRows.length === 0) {
3969
+ conflicted++;
3970
+ } else {
3971
+ rebuilt++;
3972
+ if (upgradesLegacyFootnotes) upgradedFootnotes++;
3973
+ }
3974
+ }
3975
+
3976
+ const lastRow = batch.at(-1);
3977
+ const lastId = lastRow ? lastRow.id : null;
3978
+
3979
+ return {
3980
+ processed: batch.length,
3981
+ wouldRebuild,
3982
+ rebuilt,
3983
+ wouldUpgradeFootnotes,
3984
+ upgradedFootnotes,
3985
+ skipped,
3986
+ conflicted,
3987
+ failed: failures.length,
3988
+ failures,
3989
+ nextCursor: hasMore ? lastId : null,
3990
+ done: !hasMore,
3991
+ targetVersion: POST_BODY_HTML_VERSION,
3992
+ };
3993
+ },
3194
3994
  };
3195
3995
  }