@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
@@ -176,6 +176,41 @@
176
176
  "when": 1781807044981,
177
177
  "tag": "0024_high_violations",
178
178
  "breakpoints": true
179
+ },
180
+ {
181
+ "idx": 25,
182
+ "version": "7",
183
+ "when": 1784131458842,
184
+ "tag": "0025_moaning_epoch",
185
+ "breakpoints": true
186
+ },
187
+ {
188
+ "idx": 26,
189
+ "version": "7",
190
+ "when": 1784343834845,
191
+ "tag": "0026_talented_killer_shrike",
192
+ "breakpoints": true
193
+ },
194
+ {
195
+ "idx": 27,
196
+ "version": "7",
197
+ "when": 1784436805462,
198
+ "tag": "0027_short_cannonball",
199
+ "breakpoints": true
200
+ },
201
+ {
202
+ "idx": 28,
203
+ "version": "7",
204
+ "when": 1784531392184,
205
+ "tag": "0028_perfect_sharon_carter",
206
+ "breakpoints": true
207
+ },
208
+ {
209
+ "idx": 29,
210
+ "version": "7",
211
+ "when": 1786008671529,
212
+ "tag": "0029_rare_hairball",
213
+ "breakpoints": true
179
214
  }
180
215
  ]
181
216
  }
@@ -19,6 +19,7 @@ import {
19
19
  customType,
20
20
  } from "drizzle-orm/pg-core";
21
21
  import { sql } from "drizzle-orm";
22
+ import { SYSTEM_NAV_KEY_VALUES } from "../../types/constants.js";
22
23
 
23
24
  const SITE_STATUSES = ["active", "suspended"] as const;
24
25
  const SITE_DOMAIN_KINDS = ["primary", "alias"] as const;
@@ -27,16 +28,8 @@ const FORMATS = ["note", "link", "quote"] as const;
27
28
  const STATUSES = ["draft", "published"] as const;
28
29
  const VISIBILITIES = ["public", "latest_hidden", "private"] as const;
29
30
  const COLLECTION_SORT_ORDERS = ["newest", "oldest", "rating_desc"] as const;
30
- const NAV_ITEM_TYPES = ["link", "system", "collection"] as const;
31
+ const NAV_ITEM_TYPES = ["link", "system", "collection", "page"] as const;
31
32
  const NAV_ITEM_PLACEMENTS = ["header", "more"] as const;
32
- const SYSTEM_NAV_KEYS = [
33
- "latest",
34
- "featured",
35
- "collections",
36
- "archive",
37
- "rss",
38
- "settings",
39
- ] as const;
40
33
  const UPLOAD_SESSION_STATES = [
41
34
  "pending",
42
35
  "uploaded",
@@ -167,6 +160,7 @@ export const posts = pgTable(
167
160
  url: text("url"),
168
161
  body: text("body"),
169
162
  bodyHtml: text("body_html"),
163
+ bodyHtmlVersion: integer("body_html_version").notNull().default(1),
170
164
  bodyText: text("body_text"),
171
165
  quoteText: text("quote_text"),
172
166
  summary: text("summary"),
@@ -185,8 +179,23 @@ export const posts = pgTable(
185
179
  previewProvider: text("preview_provider"),
186
180
  replyToId: text("reply_to_id"),
187
181
  threadId: text("thread_id").notNull(),
182
+ /**
183
+ * The author added this reply without announcing it. Persisted so both
184
+ * activity timestamps below stay recomputable from the rows alone.
185
+ * Always false on Thread roots.
186
+ */
187
+ quietReply: boolean("quiet_reply").notNull().default(false),
188
188
  publishedAt: integer("published_at"),
189
+ /**
190
+ * Root only: newest published_at in the Thread, excluding quiet replies —
191
+ * "last announced". Drives Latest and the feeds.
192
+ */
189
193
  lastActivityAt: integer("last_activity_at"),
194
+ /**
195
+ * Root only: newest published_at in the Thread, quiet replies included —
196
+ * "last changed". Drives the archive's updated sort.
197
+ */
198
+ threadUpdatedAt: integer("thread_updated_at"),
190
199
  createdAt: integer("created_at").notNull(),
191
200
  updatedAt: integer("updated_at").notNull(),
192
201
  },
@@ -234,6 +243,9 @@ export const posts = pgTable(
234
243
  index("idx_post_site_root_published_activity")
235
244
  .on(table.siteId, table.lastActivityAt, table.id)
236
245
  .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
246
+ index("idx_post_site_root_thread_updated")
247
+ .on(table.siteId, table.threadUpdatedAt, table.id)
248
+ .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
237
249
  index("idx_post_site_root_draft_updated")
238
250
  .on(table.siteId, table.updatedAt, table.id)
239
251
  .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'draft'`),
@@ -242,8 +254,8 @@ export const posts = pgTable(
242
254
  .where(
243
255
  sql`${table.replyToId} IS NOT NULL AND ${table.status} = 'published'`,
244
256
  ),
245
- index("idx_post_site_featured_featured_at")
246
- .on(table.siteId, table.featuredAt, table.threadId, table.id)
257
+ index("idx_post_site_featured_thread_published")
258
+ .on(table.siteId, table.threadId, table.publishedAt, table.id)
247
259
  .where(
248
260
  sql`${table.status} = 'published' AND ${table.featuredAt} IS NOT NULL`,
249
261
  ),
@@ -591,16 +603,16 @@ export const collectionDirectoryItems = pgTable(
591
603
  );
592
604
 
593
605
  // =============================================================================
594
- // Post-Collection Junction Table (M:N)
606
+ // Thread-Collection Junction Table (M:N)
595
607
  // =============================================================================
596
608
 
597
- export const postCollections = pgTable(
598
- "post_collection",
609
+ export const threadCollections = pgTable(
610
+ "thread_collection",
599
611
  {
600
612
  siteId: text("site_id")
601
613
  .notNull()
602
614
  .references(() => sites.id, { onDelete: "cascade" }),
603
- postId: text("post_id")
615
+ threadId: text("thread_id")
604
616
  .notNull()
605
617
  .references(() => posts.id, { onDelete: "cascade" }),
606
618
  collectionId: text("collection_id")
@@ -611,16 +623,16 @@ export const postCollections = pgTable(
611
623
  pinnedAt: integer("pinned_at"),
612
624
  },
613
625
  (table) => [
614
- primaryKey({ columns: [table.siteId, table.postId, table.collectionId] }),
615
- index("idx_post_collection_site_collection_id").on(
626
+ primaryKey({ columns: [table.siteId, table.threadId, table.collectionId] }),
627
+ index("idx_thread_collection_site_collection_id").on(
616
628
  table.siteId,
617
629
  table.collectionId,
618
630
  ),
619
- index("idx_post_collection_site_collection_created_post").on(
631
+ index("idx_thread_collection_site_collection_created_thread").on(
620
632
  table.siteId,
621
633
  table.collectionId,
622
634
  table.createdAt,
623
- table.postId,
635
+ table.threadId,
624
636
  ),
625
637
  ],
626
638
  );
@@ -642,11 +654,14 @@ export const navItems = pgTable(
642
654
  .notNull()
643
655
  .default("link"),
644
656
  systemKey: text("system_key", {
645
- enum: SYSTEM_NAV_KEYS,
657
+ enum: SYSTEM_NAV_KEY_VALUES,
646
658
  }),
647
659
  collectionId: text("collection_id").references(() => collections.id, {
648
660
  onDelete: "cascade",
649
661
  }),
662
+ postId: text("post_id").references(() => posts.id, {
663
+ onDelete: "cascade",
664
+ }),
650
665
  label: text("label").notNull(),
651
666
  url: text("url").notNull(),
652
667
  placement: text("placement", {
@@ -673,14 +688,22 @@ export const navItems = pgTable(
673
688
  ${table.type} = 'link'
674
689
  AND ${table.systemKey} IS NULL
675
690
  AND ${table.collectionId} IS NULL
691
+ AND ${table.postId} IS NULL
676
692
  ) OR (
677
693
  ${table.type} = 'system'
678
694
  AND ${table.systemKey} IS NOT NULL
679
695
  AND ${table.collectionId} IS NULL
696
+ AND ${table.postId} IS NULL
680
697
  ) OR (
681
698
  ${table.type} = 'collection'
682
699
  AND ${table.systemKey} IS NULL
683
700
  AND ${table.collectionId} IS NOT NULL
701
+ AND ${table.postId} IS NULL
702
+ ) OR (
703
+ ${table.type} = 'page'
704
+ AND ${table.systemKey} IS NULL
705
+ AND ${table.collectionId} IS NULL
706
+ AND ${table.postId} IS NOT NULL
684
707
  )`,
685
708
  ),
686
709
  uniqueIndex("uq_nav_item_site_position").on(table.siteId, table.position),
@@ -690,6 +713,9 @@ export const navItems = pgTable(
690
713
  uniqueIndex("uq_nav_item_site_collection_id")
691
714
  .on(table.siteId, table.collectionId)
692
715
  .where(sql`${table.collectionId} IS NOT NULL`),
716
+ uniqueIndex("uq_nav_item_site_post_id")
717
+ .on(table.siteId, table.postId)
718
+ .where(sql`${table.postId} IS NOT NULL`),
693
719
  ],
694
720
  );
695
721
 
@@ -1,25 +1,60 @@
1
1
  {
2
2
  "name": "demo-current",
3
- "baseMigrationTag": "0003_fts_site_aware",
3
+ "baseMigrationTag": "0026_absent_rhodey",
4
4
  "seedPath": "./demo-current.sql",
5
5
  "assertions": [
6
6
  {
7
7
  "name": "published posts exist",
8
8
  "sql": "SELECT COUNT(*) AS count FROM post",
9
9
  "column": "count",
10
- "atLeast": 1
10
+ "equals": 3
11
11
  },
12
12
  {
13
13
  "name": "collections exist",
14
14
  "sql": "SELECT COUNT(*) AS count FROM collection",
15
15
  "column": "count",
16
- "atLeast": 1
16
+ "equals": 2
17
17
  },
18
18
  {
19
19
  "name": "fts index populated",
20
20
  "sql": "SELECT COUNT(*) AS count FROM post_fts",
21
21
  "column": "count",
22
- "atLeast": 1
22
+ "equals": 3
23
+ },
24
+ {
25
+ "name": "thread collection union preserves both collections",
26
+ "sql": "SELECT COUNT(*) AS count FROM thread_collection",
27
+ "column": "count",
28
+ "equals": 2
29
+ },
30
+ {
31
+ "name": "shared collection metadata is aggregated across the thread",
32
+ "sql": "SELECT COUNT(*) AS count FROM thread_collection WHERE site_id = 'sit_01kme7j7j1f4m7s2k8r5c9t0qb' AND thread_id = 'pst_01kme7ja2mf4m7s2k8r5c9t0qf' AND collection_id = 'col_01kme7j8f9f4m7s2k8r5c9t0qc' AND created_at = 1774200040 AND position = 1 AND pinned_at = 1774200500",
33
+ "column": "count",
34
+ "equals": 1
35
+ },
36
+ {
37
+ "name": "child-only collection becomes thread membership",
38
+ "sql": "SELECT COUNT(*) AS count FROM thread_collection WHERE site_id = 'sit_01kme7j7j1f4m7s2k8r5c9t0qb' AND thread_id = 'pst_01kme7ja2mf4m7s2k8r5c9t0qf' AND collection_id = 'col_01kme7j8f9f4m7s2k8r5c9t0qd' AND created_at = 1774200060 AND position = 4 AND pinned_at = 1774200600",
39
+ "column": "count",
40
+ "equals": 1
41
+ },
42
+ {
43
+ "name": "thread collection targets are roots",
44
+ "sql": "SELECT COUNT(*) AS count FROM thread_collection tc INNER JOIN post root ON root.site_id = tc.site_id AND root.id = tc.thread_id WHERE root.reply_to_id IS NOT NULL OR root.thread_id <> root.id",
45
+ "column": "count",
46
+ "equals": 0
47
+ },
48
+ {
49
+ "name": "legacy post collection table is removed",
50
+ "sql": "SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'post_collection'",
51
+ "column": "count",
52
+ "equals": 0
53
+ },
54
+ {
55
+ "name": "foreign keys remain valid",
56
+ "sql": "PRAGMA foreign_key_check",
57
+ "rowCount": 0
23
58
  }
24
59
  ]
25
60
  }
@@ -31,6 +31,24 @@ INSERT INTO "collection" (
31
31
  1774200010
32
32
  );
33
33
 
34
+ INSERT INTO "collection" (
35
+ "id",
36
+ "site_id",
37
+ "title",
38
+ "description",
39
+ "sort_order",
40
+ "created_at",
41
+ "updated_at"
42
+ ) VALUES (
43
+ 'col_01kme7j8f9f4m7s2k8r5c9t0qd',
44
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
45
+ 'Child-only Notes',
46
+ 'Collection assigned only to a reply before the Thread migration.',
47
+ 'newest',
48
+ 1774200011,
49
+ 1774200011
50
+ );
51
+
34
52
  INSERT INTO "nav_item" (
35
53
  "id",
36
54
  "site_id",
@@ -149,18 +167,115 @@ INSERT INTO "post" (
149
167
  1774200020
150
168
  );
151
169
 
152
- INSERT INTO "post_collection" (
170
+ INSERT INTO "post" (
171
+ "id",
153
172
  "site_id",
154
- "post_id",
155
- "collection_id",
156
- "created_at"
173
+ "format",
174
+ "status",
175
+ "visibility",
176
+ "title",
177
+ "body",
178
+ "body_html",
179
+ "body_text",
180
+ "reply_to_id",
181
+ "thread_id",
182
+ "published_at",
183
+ "last_activity_at",
184
+ "created_at",
185
+ "updated_at"
157
186
  ) VALUES (
187
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qh',
158
188
  'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
189
+ 'note',
190
+ 'published',
191
+ NULL,
192
+ 'First rehearsal reply',
193
+ '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"First migration rehearsal reply."}]}]}',
194
+ '<p>First migration rehearsal reply.</p>',
195
+ 'First migration rehearsal reply.',
159
196
  'pst_01kme7ja2mf4m7s2k8r5c9t0qf',
160
- 'col_01kme7j8f9f4m7s2k8r5c9t0qc',
161
- 1774200021
197
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qf',
198
+ 1774200030,
199
+ 1774200030,
200
+ 1774200030,
201
+ 1774200030
162
202
  );
163
203
 
204
+ INSERT INTO "post" (
205
+ "id",
206
+ "site_id",
207
+ "format",
208
+ "status",
209
+ "visibility",
210
+ "title",
211
+ "body",
212
+ "body_html",
213
+ "body_text",
214
+ "reply_to_id",
215
+ "thread_id",
216
+ "published_at",
217
+ "last_activity_at",
218
+ "created_at",
219
+ "updated_at"
220
+ ) VALUES (
221
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qi',
222
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
223
+ 'note',
224
+ 'published',
225
+ NULL,
226
+ 'Second rehearsal reply',
227
+ '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Second migration rehearsal reply."}]}]}',
228
+ '<p>Second migration rehearsal reply.</p>',
229
+ 'Second migration rehearsal reply.',
230
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qh',
231
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qf',
232
+ 1774200040,
233
+ 1774200040,
234
+ 1774200040,
235
+ 1774200040
236
+ );
237
+
238
+ INSERT INTO "post_collection" (
239
+ "site_id",
240
+ "post_id",
241
+ "collection_id",
242
+ "created_at",
243
+ "position",
244
+ "pinned_at"
245
+ ) VALUES
246
+ (
247
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
248
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qf',
249
+ 'col_01kme7j8f9f4m7s2k8r5c9t0qc',
250
+ 1774200021,
251
+ 7,
252
+ 1774200300
253
+ ),
254
+ (
255
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
256
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qh',
257
+ 'col_01kme7j8f9f4m7s2k8r5c9t0qc',
258
+ 1774200040,
259
+ 5,
260
+ NULL
261
+ ),
262
+ (
263
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
264
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qi',
265
+ 'col_01kme7j8f9f4m7s2k8r5c9t0qc',
266
+ 1774200030,
267
+ 1,
268
+ 1774200500
269
+ ),
270
+ (
271
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
272
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qh',
273
+ 'col_01kme7j8f9f4m7s2k8r5c9t0qd',
274
+ 1774200060,
275
+ 4,
276
+ 1774200600
277
+ );
278
+
164
279
  INSERT INTO "path_registry" (
165
280
  "id",
166
281
  "site_id",
package/src/db/schema.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  check,
16
16
  } from "drizzle-orm/sqlite-core";
17
17
  import { sql } from "drizzle-orm";
18
+ import { SYSTEM_NAV_KEY_VALUES } from "../types/constants.js";
18
19
 
19
20
  const SITE_STATUSES = ["active", "suspended"] as const;
20
21
  const SITE_DOMAIN_KINDS = ["primary", "alias"] as const;
@@ -23,16 +24,8 @@ const FORMATS = ["note", "link", "quote"] as const;
23
24
  const STATUSES = ["draft", "published"] as const;
24
25
  const VISIBILITIES = ["public", "latest_hidden", "private"] as const;
25
26
  const COLLECTION_SORT_ORDERS = ["newest", "oldest", "rating_desc"] as const;
26
- const NAV_ITEM_TYPES = ["link", "system", "collection"] as const;
27
+ const NAV_ITEM_TYPES = ["link", "system", "collection", "page"] as const;
27
28
  const NAV_ITEM_PLACEMENTS = ["header", "more"] as const;
28
- const SYSTEM_NAV_KEYS = [
29
- "latest",
30
- "featured",
31
- "collections",
32
- "archive",
33
- "rss",
34
- "settings",
35
- ] as const;
36
29
  const UPLOAD_SESSION_STATES = [
37
30
  "pending",
38
31
  "uploaded",
@@ -161,6 +154,7 @@ export const posts = sqliteTable(
161
154
  url: text("url"),
162
155
  body: text("body"),
163
156
  bodyHtml: text("body_html"),
157
+ bodyHtmlVersion: integer("body_html_version").notNull().default(1),
164
158
  bodyText: text("body_text"),
165
159
  quoteText: text("quote_text"),
166
160
  summary: text("summary"),
@@ -170,8 +164,25 @@ export const posts = sqliteTable(
170
164
  previewProvider: text("preview_provider"),
171
165
  replyToId: text("reply_to_id"),
172
166
  threadId: text("thread_id").notNull(),
167
+ /**
168
+ * The author added this reply without announcing it. Persisted so both
169
+ * activity timestamps below stay recomputable from the rows alone.
170
+ * Always false on Thread roots.
171
+ */
172
+ quietReply: integer("quiet_reply", { mode: "boolean" })
173
+ .notNull()
174
+ .default(false),
173
175
  publishedAt: integer("published_at"),
176
+ /**
177
+ * Root only: newest published_at in the Thread, excluding quiet replies —
178
+ * "last announced". Drives Latest and the feeds.
179
+ */
174
180
  lastActivityAt: integer("last_activity_at"),
181
+ /**
182
+ * Root only: newest published_at in the Thread, quiet replies included —
183
+ * "last changed". Drives the archive's updated sort.
184
+ */
185
+ threadUpdatedAt: integer("thread_updated_at"),
175
186
  createdAt: integer("created_at").notNull(),
176
187
  updatedAt: integer("updated_at").notNull(),
177
188
  },
@@ -219,6 +230,9 @@ export const posts = sqliteTable(
219
230
  index("idx_post_site_root_published_activity")
220
231
  .on(table.siteId, table.lastActivityAt, table.id)
221
232
  .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
233
+ index("idx_post_site_root_thread_updated")
234
+ .on(table.siteId, table.threadUpdatedAt, table.id)
235
+ .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
222
236
  index("idx_post_site_root_draft_updated")
223
237
  .on(table.siteId, table.updatedAt, table.id)
224
238
  .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'draft'`),
@@ -227,8 +241,8 @@ export const posts = sqliteTable(
227
241
  .where(
228
242
  sql`${table.replyToId} IS NOT NULL AND ${table.status} = 'published'`,
229
243
  ),
230
- index("idx_post_site_featured_featured_at")
231
- .on(table.siteId, table.featuredAt, table.threadId, table.id)
244
+ index("idx_post_site_featured_thread_published")
245
+ .on(table.siteId, table.threadId, table.publishedAt, table.id)
232
246
  .where(
233
247
  sql`${table.status} = 'published' AND ${table.featuredAt} IS NOT NULL`,
234
248
  ),
@@ -571,16 +585,16 @@ export const collectionDirectoryItems = sqliteTable(
571
585
  );
572
586
 
573
587
  // =============================================================================
574
- // Post-Collection Junction Table (M:N)
588
+ // Thread-Collection Junction Table (M:N)
575
589
  // =============================================================================
576
590
 
577
- export const postCollections = sqliteTable(
578
- "post_collection",
591
+ export const threadCollections = sqliteTable(
592
+ "thread_collection",
579
593
  {
580
594
  siteId: text("site_id")
581
595
  .notNull()
582
596
  .references(() => sites.id, { onDelete: "cascade" }),
583
- postId: text("post_id")
597
+ threadId: text("thread_id")
584
598
  .notNull()
585
599
  .references(() => posts.id, { onDelete: "cascade" }),
586
600
  collectionId: text("collection_id")
@@ -591,16 +605,16 @@ export const postCollections = sqliteTable(
591
605
  pinnedAt: integer("pinned_at"),
592
606
  },
593
607
  (table) => [
594
- primaryKey({ columns: [table.siteId, table.postId, table.collectionId] }),
595
- index("idx_post_collection_site_collection_id").on(
608
+ primaryKey({ columns: [table.siteId, table.threadId, table.collectionId] }),
609
+ index("idx_thread_collection_site_collection_id").on(
596
610
  table.siteId,
597
611
  table.collectionId,
598
612
  ),
599
- index("idx_post_collection_site_collection_created_post").on(
613
+ index("idx_thread_collection_site_collection_created_thread").on(
600
614
  table.siteId,
601
615
  table.collectionId,
602
616
  table.createdAt,
603
- table.postId,
617
+ table.threadId,
604
618
  ),
605
619
  ],
606
620
  );
@@ -622,11 +636,14 @@ export const navItems = sqliteTable(
622
636
  .notNull()
623
637
  .default("link"),
624
638
  systemKey: text("system_key", {
625
- enum: SYSTEM_NAV_KEYS,
639
+ enum: SYSTEM_NAV_KEY_VALUES,
626
640
  }),
627
641
  collectionId: text("collection_id").references(() => collections.id, {
628
642
  onDelete: "cascade",
629
643
  }),
644
+ postId: text("post_id").references(() => posts.id, {
645
+ onDelete: "cascade",
646
+ }),
630
647
  label: text("label").notNull(),
631
648
  url: text("url").notNull(),
632
649
  placement: text("placement", {
@@ -653,14 +670,22 @@ export const navItems = sqliteTable(
653
670
  ${table.type} = 'link'
654
671
  AND ${table.systemKey} IS NULL
655
672
  AND ${table.collectionId} IS NULL
673
+ AND ${table.postId} IS NULL
656
674
  ) OR (
657
675
  ${table.type} = 'system'
658
676
  AND ${table.systemKey} IS NOT NULL
659
677
  AND ${table.collectionId} IS NULL
678
+ AND ${table.postId} IS NULL
660
679
  ) OR (
661
680
  ${table.type} = 'collection'
662
681
  AND ${table.systemKey} IS NULL
663
682
  AND ${table.collectionId} IS NOT NULL
683
+ AND ${table.postId} IS NULL
684
+ ) OR (
685
+ ${table.type} = 'page'
686
+ AND ${table.systemKey} IS NULL
687
+ AND ${table.collectionId} IS NULL
688
+ AND ${table.postId} IS NOT NULL
664
689
  )`,
665
690
  ),
666
691
  uniqueIndex("uq_nav_item_site_position").on(table.siteId, table.position),
@@ -670,6 +695,9 @@ export const navItems = sqliteTable(
670
695
  uniqueIndex("uq_nav_item_site_collection_id")
671
696
  .on(table.siteId, table.collectionId)
672
697
  .where(sql`${table.collectionId} IS NOT NULL`),
698
+ uniqueIndex("uq_nav_item_site_post_id")
699
+ .on(table.siteId, table.postId)
700
+ .where(sql`${table.postId} IS NOT NULL`),
673
701
  ],
674
702
  );
675
703
 
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Thread activity — one definition, shared by every surface that orders by it.
3
+ *
4
+ * A Thread's activity is when it last **gained a post**. Editing an existing
5
+ * post is not activity: otherwise fixing a typo would drag a years-old thread
6
+ * back to the top of Latest and of every collection that holds it.
7
+ *
8
+ * This lived as three separate copies (the collection thread sort, the
9
+ * collection directory, and the Hugo export). One of them was changed in #144
10
+ * to treat a newer `updated_at` as activity and the others were not, so the
11
+ * same thread ranked differently depending on which page you were looking at,
12
+ * and the drift went unnoticed for months. Adding a fourth copy is the failure
13
+ * mode this module exists to prevent — call it instead.
14
+ *
15
+ * Two flavors of "last activity" exist, and they differ only in how they treat
16
+ * quiet replies:
17
+ *
18
+ * - `last_activity_at` — quiet replies excluded. "Last announced." Latest, the
19
+ * feeds, and collection ordering read this.
20
+ * - `thread_updated_at` — quiet replies included. "Last changed." The
21
+ * archive's updated sort reads this.
22
+ *
23
+ * Both are maintained on the Thread root by the post service; everything here
24
+ * only reads them, with fallbacks for rows that predate the columns.
25
+ */
26
+
27
+ import { sql, type SQL, type SQLWrapper } from "drizzle-orm";
28
+
29
+ /**
30
+ * Activity timestamp for a Thread root row, in SQL.
31
+ *
32
+ * Pass column references for a query whose rows are already Thread roots, or
33
+ * raw fragments (`sql.raw("root.last_activity_at")`) when reaching a root
34
+ * through a correlated subquery.
35
+ *
36
+ * **Correlated-subquery warning:** Drizzle only qualifies interpolated columns
37
+ * with their table name when the query has a join. In a single-table
38
+ * `select()`, `${posts.id}` renders as a bare `"id"`, which inside a subquery
39
+ * binds to the subquery's own alias instead of correlating to the outer row —
40
+ * silently, with no error. Write the outer table out (`"post"."id"`) in that
41
+ * case rather than interpolating.
42
+ *
43
+ * @param root - The root's three timestamp columns, most authoritative first
44
+ * @returns COALESCE expression usable in ORDER BY, GROUP BY, or a projection
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * // Rows are already Thread roots (joined on thread_collection.thread_id):
49
+ * const activityAt = buildRootActivityExpr({
50
+ * lastActivityAt: posts.lastActivityAt,
51
+ * publishedAt: posts.publishedAt,
52
+ * updatedAt: posts.updatedAt,
53
+ * });
54
+ * ```
55
+ */
56
+ export function buildRootActivityExpr(root: {
57
+ lastActivityAt: SQLWrapper;
58
+ publishedAt: SQLWrapper;
59
+ updatedAt: SQLWrapper;
60
+ }): SQL<number> {
61
+ return sql<number>`COALESCE(
62
+ ${root.lastActivityAt},
63
+ ${root.publishedAt},
64
+ ${root.updatedAt}
65
+ )`;
66
+ }
67
+
68
+ /** The Thread root's timestamp columns, named for a correlated subquery alias. */
69
+ export function rootActivityColumns(alias: string): {
70
+ lastActivityAt: SQLWrapper;
71
+ publishedAt: SQLWrapper;
72
+ updatedAt: SQLWrapper;
73
+ } {
74
+ return {
75
+ lastActivityAt: sql.raw(`${alias}.last_activity_at`),
76
+ publishedAt: sql.raw(`${alias}.published_at`),
77
+ updatedAt: sql.raw(`${alias}.updated_at`),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Activity timestamp for an already-loaded Thread root, in TypeScript.
83
+ *
84
+ * The same COALESCE order as {@link buildRootActivityExpr}, for callers that
85
+ * hold hydrated posts rather than a query builder.
86
+ *
87
+ * @param root - A Thread root post
88
+ * @returns Unix timestamp (seconds) of the root's last activity
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * const activityAt = getRootActivityAt(threadRoot);
93
+ * ```
94
+ */
95
+ export function getRootActivityAt(root: {
96
+ lastActivityAt?: number | null;
97
+ publishedAt?: number | null;
98
+ updatedAt: number;
99
+ }): number {
100
+ return root.lastActivityAt ?? root.publishedAt ?? root.updatedAt;
101
+ }