@jant/core 0.6.14 → 0.6.15

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 (311) hide show
  1. package/bin/commands/import-site.js +81 -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-BxCOR3Uc.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C-oi_t8W.js} +7408 -6343
  13. package/dist/client/.vite/manifest.json +3 -3
  14. package/dist/client/_assets/client-B_eGKN5p.css +2 -0
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-DxY5BFe8.js} +58 -37
  16. package/dist/client/_assets/client-auth-CakPESv9.js +5533 -0
  17. package/dist/{export-DWn149b7.js → export-CsFx6F_M.js} +1259 -125
  18. package/dist/{github-sync-Ckk0sJxK.js → github-sync-Cq1pzzOI.js} +1 -1
  19. package/dist/{github-sync-C0zyF6XS.js → github-sync-ppWXN3jb.js} +2 -2
  20. package/dist/index.js +3 -3
  21. package/dist/node.js +5 -5
  22. package/package.json +11 -10
  23. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  24. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  25. package/src/__tests__/export-hugo-build.test.ts +63 -4
  26. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  27. package/src/__tests__/export-service.test.ts +142 -9
  28. package/src/__tests__/helpers/app.ts +3 -0
  29. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  30. package/src/__tests__/import-site-command.test.ts +65 -0
  31. package/src/__tests__/vite-config.test.ts +45 -0
  32. package/src/app.tsx +16 -3
  33. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  34. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  35. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  36. package/src/client/__tests__/compose-bridge.test.ts +64 -0
  37. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  38. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  39. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  40. package/src/client/__tests__/toast.test.ts +18 -1
  41. package/src/client/collection-created-notice.ts +66 -0
  42. package/src/client/collection-form-bridge.ts +17 -0
  43. package/src/client/collection-navigation.ts +44 -0
  44. package/src/client/collection-page-actions.ts +57 -1
  45. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  46. package/src/client/components/__tests__/jant-compose-dialog.test.ts +370 -5
  47. package/src/client/components/__tests__/jant-compose-editor.test.ts +97 -0
  48. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +104 -0
  49. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  50. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  51. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  52. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  53. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  54. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  55. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  56. package/src/client/components/collection-manager-types.ts +9 -3
  57. package/src/client/components/compose-types.ts +8 -0
  58. package/src/client/components/jant-collection-directory.ts +165 -30
  59. package/src/client/components/jant-command-palette.ts +2 -1
  60. package/src/client/components/jant-compose-dialog.ts +85 -21
  61. package/src/client/components/jant-compose-editor.ts +13 -7
  62. package/src/client/components/jant-compose-fullscreen.ts +24 -4
  63. package/src/client/components/jant-config-editor.ts +722 -0
  64. package/src/client/components/jant-media-lightbox.ts +100 -18
  65. package/src/client/components/jant-nav-manager.ts +1283 -54
  66. package/src/client/components/jant-post-form.ts +3 -3
  67. package/src/client/components/jant-post-menu.ts +86 -74
  68. package/src/client/components/nav-manager-types.ts +51 -2
  69. package/src/client/components/post-form-types.ts +1 -1
  70. package/src/client/compose-bridge.ts +4 -1
  71. package/src/client/feed-video-player.ts +62 -2
  72. package/src/client/footnote-rail.ts +478 -0
  73. package/src/client/hydrate-partial.ts +2 -0
  74. package/src/client/media-lightbox-events.ts +63 -0
  75. package/src/client/site-header-fragment.ts +38 -0
  76. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  77. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  78. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  79. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  80. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  81. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  82. package/src/client/tiptap/bubble-menu.ts +88 -10
  83. package/src/client/tiptap/continuous-lists.ts +102 -0
  84. package/src/client/tiptap/create-editor.ts +3 -0
  85. package/src/client/tiptap/extensions.ts +14 -5
  86. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  87. package/src/client/tiptap/slash-commands.ts +8 -1
  88. package/src/client/tiptap/structural-keymap.ts +211 -0
  89. package/src/client/tiptap/tab-indent.ts +8 -22
  90. package/src/client/tiptap/table-control-labels.ts +30 -0
  91. package/src/client/tiptap/table-controls.ts +537 -0
  92. package/src/client/tiptap/table-size-picker.ts +254 -0
  93. package/src/client/toast.ts +8 -6
  94. package/src/client-auth.ts +1 -0
  95. package/src/client.ts +1 -0
  96. package/src/db/__tests__/d1-query.test.ts +43 -1
  97. package/src/db/__tests__/migration-runner.test.ts +63 -0
  98. package/src/db/__tests__/migrations.test.ts +469 -0
  99. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  100. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  101. package/src/db/migrations/0028_superb_post.sql +2 -0
  102. package/src/db/migrations/0029_boring_magma.sql +1 -0
  103. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  104. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  105. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  106. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  107. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  108. package/src/db/migrations/meta/_journal.json +28 -0
  109. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  110. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  111. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  112. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  113. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  114. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  115. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  116. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  117. package/src/db/migrations/pg/meta/_journal.json +28 -0
  118. package/src/db/pg/schema.ts +28 -20
  119. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  120. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  121. package/src/db/schema.ts +28 -20
  122. package/src/i18n/__tests__/detect.test.ts +8 -2
  123. package/src/i18n/detect.ts +14 -5
  124. package/src/i18n/locales/public/en.po +133 -30
  125. package/src/i18n/locales/public/en.ts +1 -1
  126. package/src/i18n/locales/public/zh-Hans.po +128 -25
  127. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  128. package/src/i18n/locales/public/zh-Hant.po +128 -25
  129. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  130. package/src/i18n/locales/settings/en.po +513 -10
  131. package/src/i18n/locales/settings/en.ts +1 -1
  132. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  133. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  134. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  135. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  136. package/src/lib/__tests__/api-settings.test.ts +299 -0
  137. package/src/lib/__tests__/constants.test.ts +5 -0
  138. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  139. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  140. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  141. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  142. package/src/lib/__tests__/markdown.test.ts +8 -6
  143. package/src/lib/__tests__/navigation.test.ts +59 -2
  144. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  145. package/src/lib/__tests__/post-display.test.ts +64 -0
  146. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  147. package/src/lib/__tests__/summary.test.ts +82 -0
  148. package/src/lib/__tests__/timeline.test.ts +109 -70
  149. package/src/lib/__tests__/timezones.test.ts +31 -3
  150. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  151. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  152. package/src/lib/__tests__/view.test.ts +53 -0
  153. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  154. package/src/lib/api-settings.ts +239 -12
  155. package/src/lib/collection-sort.ts +7 -5
  156. package/src/lib/constants.ts +2 -0
  157. package/src/lib/feed-path.ts +25 -0
  158. package/src/lib/feed-policy.ts +64 -0
  159. package/src/lib/footnotes.ts +264 -0
  160. package/src/lib/github-sync-site-config.ts +2 -0
  161. package/src/lib/hugo-markdown.ts +10 -3
  162. package/src/lib/jant-branding.ts +1 -0
  163. package/src/lib/markdown-manager.ts +188 -5
  164. package/src/lib/markdown.ts +10 -3
  165. package/src/lib/navigation.ts +16 -3
  166. package/src/lib/post-body-html.ts +131 -0
  167. package/src/lib/post-display.ts +7 -1
  168. package/src/lib/render.tsx +9 -2
  169. package/src/lib/resolve-config.ts +100 -16
  170. package/src/lib/schemas.ts +133 -1
  171. package/src/lib/settings-paths.ts +5 -0
  172. package/src/lib/site-skill.ts +78 -0
  173. package/src/lib/summary.ts +16 -5
  174. package/src/lib/timeline.ts +98 -122
  175. package/src/lib/timezones.ts +54 -14
  176. package/src/lib/tiptap-render.ts +355 -81
  177. package/src/lib/view.ts +30 -20
  178. package/src/lib/worker-response-cache.ts +11 -18
  179. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  180. package/src/middleware/config.ts +2 -2
  181. package/src/middleware/public-content-access.ts +87 -0
  182. package/src/node/__tests__/cli-export.test.ts +14 -0
  183. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  184. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  185. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  186. package/src/node/index.ts +2 -1
  187. package/src/preset.css +16 -2
  188. package/src/routes/__tests__/compose.test.ts +51 -0
  189. package/src/routes/__tests__/site-skill.test.ts +112 -0
  190. package/src/routes/api/__tests__/collections.test.ts +91 -19
  191. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  192. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  193. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  194. package/src/routes/api/__tests__/settings.test.ts +217 -0
  195. package/src/routes/api/collections.ts +22 -26
  196. package/src/routes/api/export.ts +2 -0
  197. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  198. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  199. package/src/routes/api/internal/post-body-html.ts +43 -0
  200. package/src/routes/api/internal/sites.ts +24 -0
  201. package/src/routes/api/nav-items.ts +24 -1
  202. package/src/routes/api/posts.ts +2 -2
  203. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  204. package/src/routes/api/public/__tests__/posts.test.ts +208 -0
  205. package/src/routes/api/public/archive.ts +4 -0
  206. package/src/routes/api/public/posts.ts +41 -24
  207. package/src/routes/api/search.ts +3 -0
  208. package/src/routes/api/settings.ts +33 -0
  209. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  210. package/src/routes/compose.tsx +2 -1
  211. package/src/routes/dash/settings.tsx +45 -2
  212. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  213. package/src/routes/feed/feed.ts +23 -14
  214. package/src/routes/pages/__tests__/archive-params.test.ts +92 -3
  215. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  216. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  217. package/src/routes/pages/__tests__/preview.test.ts +174 -0
  218. package/src/routes/pages/__tests__/search.test.ts +69 -0
  219. package/src/routes/pages/archive.tsx +27 -7
  220. package/src/routes/pages/collection.tsx +32 -19
  221. package/src/routes/pages/collections.tsx +6 -1
  222. package/src/routes/pages/page.tsx +83 -11
  223. package/src/routes/site-skill.ts +18 -0
  224. package/src/services/__tests__/auth.test.ts +7 -5
  225. package/src/services/__tests__/collection.test.ts +159 -44
  226. package/src/services/__tests__/navigation.test.ts +229 -21
  227. package/src/services/__tests__/post-timeline.test.ts +109 -30
  228. package/src/services/__tests__/post.test.ts +567 -5
  229. package/src/services/__tests__/search.test.ts +48 -1
  230. package/src/services/__tests__/settings.test.ts +8 -0
  231. package/src/services/__tests__/site-admin.test.ts +121 -1
  232. package/src/services/auth.ts +2 -2
  233. package/src/services/bootstrap.ts +3 -2
  234. package/src/services/collection.ts +226 -167
  235. package/src/services/export-theme/assets/client-site.css +1 -1
  236. package/src/services/export-theme/assets/client-site.js +46 -25
  237. package/src/services/export-theme/layouts/_default/list.html +18 -4
  238. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  239. package/src/services/export-theme/layouts/featured/list.html +5 -4
  240. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  241. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  242. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  243. package/src/services/export-theme/styles/main.css +30 -2
  244. package/src/services/export.ts +91 -80
  245. package/src/services/mcp.ts +37 -27
  246. package/src/services/media.ts +1 -1
  247. package/src/services/navigation.ts +185 -46
  248. package/src/services/post.ts +874 -271
  249. package/src/services/search.ts +8 -2
  250. package/src/services/settings.ts +2 -10
  251. package/src/services/site-admin.ts +63 -43
  252. package/src/style-cjk-jp.css +1 -1
  253. package/src/style-cjk-kr.css +1 -1
  254. package/src/style-cjk-tc.css +1 -1
  255. package/src/style-cjk.css +1 -1
  256. package/src/styles/components.css +540 -0
  257. package/src/styles/site-media.css +111 -19
  258. package/src/styles/tokens.css +14 -9
  259. package/src/styles/ui.css +829 -29
  260. package/src/types/bindings.ts +3 -0
  261. package/src/types/config.ts +243 -12
  262. package/src/types/constants.ts +22 -1
  263. package/src/types/entities.ts +9 -8
  264. package/src/types/operations.ts +18 -10
  265. package/src/types/props.ts +8 -2
  266. package/src/types/raw-assets.d.ts +5 -0
  267. package/src/types/views.ts +16 -4
  268. package/src/ui/__tests__/font-themes.test.ts +42 -30
  269. package/src/ui/compose/ComposeDialog.tsx +92 -2
  270. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  271. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  272. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  273. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  274. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  275. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  276. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  277. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  278. package/src/ui/feed/LinkCard.tsx +2 -12
  279. package/src/ui/feed/NoteCard.tsx +10 -20
  280. package/src/ui/feed/QuoteCard.tsx +2 -12
  281. package/src/ui/feed/ThreadPreview.tsx +26 -34
  282. package/src/ui/feed/TimelineFeed.tsx +2 -2
  283. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  284. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  285. package/src/ui/feed/thread-preview-state.ts +5 -7
  286. package/src/ui/font-themes.ts +74 -26
  287. package/src/ui/layouts/BaseLayout.tsx +22 -8
  288. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  289. package/src/ui/pages/CollectionPage.tsx +101 -22
  290. package/src/ui/pages/CollectionsPage.tsx +3 -1
  291. package/src/ui/pages/PostPage.tsx +39 -12
  292. package/src/ui/pages/SearchPage.tsx +6 -14
  293. package/src/ui/pages/__tests__/ArchivePage.test.tsx +7 -0
  294. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  295. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  296. package/src/ui/shared/CollectionsManager.tsx +17 -7
  297. package/src/ui/shared/DraftPreviewBar.tsx +33 -0
  298. package/src/ui/shared/HomePageBranding.tsx +2 -2
  299. package/src/ui/shared/MediaGallery.tsx +1 -0
  300. package/src/ui/shared/PostFooter.tsx +3 -1
  301. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  302. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  303. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  304. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  305. package/src/ui/shared/collection-management-labels.ts +33 -2
  306. package/src/ui/shared/navigation-labels.ts +2 -4
  307. package/src/ui/shared/post-article-attributes.ts +46 -0
  308. package/dist/app-DZDlmh7C.js +0 -6
  309. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  310. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  311. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -21,6 +21,10 @@ import {
21
21
  extractNumberPrefix,
22
22
  isCanonicalNumberedSqlFile,
23
23
  } from "../../../bin/lib/migration-artifacts.js";
24
+ import {
25
+ preflightThreadCollectionMigration,
26
+ verifyThreadCollectionMigration,
27
+ } from "../../../bin/lib/thread-collection-migration.js";
24
28
 
25
29
  const MIGRATIONS_DIR = resolve(import.meta.dirname, "../migrations");
26
30
  const PG_MIGRATIONS_DIR = resolve(import.meta.dirname, "../migrations/pg");
@@ -71,6 +75,33 @@ function applyMigration(sqlite: Database.Database, filename: string) {
71
75
  }
72
76
  }
73
77
 
78
+ function applyMigrationsThrough(
79
+ sqlite: Database.Database,
80
+ finalFilename: string,
81
+ ) {
82
+ for (const filename of listMigrationFiles()) {
83
+ applyMigration(sqlite, filename);
84
+ if (filename === finalFilename) return;
85
+ }
86
+
87
+ throw new Error(`Migration not found: ${finalFilename}`);
88
+ }
89
+
90
+ function applyMigrationsAfter(
91
+ sqlite: Database.Database,
92
+ previousFilename: string,
93
+ ) {
94
+ let foundPrevious = false;
95
+ for (const filename of listMigrationFiles()) {
96
+ if (foundPrevious) applyMigration(sqlite, filename);
97
+ if (filename === previousFilename) foundPrevious = true;
98
+ }
99
+
100
+ if (!foundPrevious) {
101
+ throw new Error(`Migration not found: ${previousFilename}`);
102
+ }
103
+ }
104
+
74
105
  function insertRootPost(
75
106
  sqlite: Database.Database,
76
107
  values: {
@@ -319,6 +350,444 @@ describe("migration integrity", () => {
319
350
  ).toEqual([]);
320
351
  });
321
352
 
353
+ it("adds body_html_version as a legacy-safe projection marker", () => {
354
+ const sqlite = new Database(":memory:");
355
+ sqlite.pragma("foreign_keys = ON");
356
+ applyMigrationsThrough(sqlite, "0028_superb_post.sql");
357
+
358
+ const siteId = "sit_body_html_migration";
359
+ insertSite(sqlite, { id: siteId, key: "default", createdAt: 1 });
360
+ insertRootPost(sqlite, {
361
+ siteId,
362
+ id: "post-before-version",
363
+ title: "Legacy projection",
364
+ bodyText: "Legacy",
365
+ createdAt: 2,
366
+ });
367
+
368
+ applyMigration(sqlite, "0029_boring_magma.sql");
369
+
370
+ expect(
371
+ sqlite
372
+ .prepare(
373
+ "SELECT body_html_version AS version FROM post WHERE id = 'post-before-version'",
374
+ )
375
+ .get(),
376
+ ).toEqual({ version: 1 });
377
+ const column = sqlite
378
+ .prepare(
379
+ "SELECT \"notnull\" AS required, dflt_value AS defaultValue FROM pragma_table_info('post') WHERE name = 'body_html_version'",
380
+ )
381
+ .get();
382
+ expect(column).toEqual({ required: 1, defaultValue: "1" });
383
+
384
+ const postgresMigration = readFileSync(
385
+ resolve(PG_MIGRATIONS_DIR, "0027_short_cannonball.sql"),
386
+ "utf-8",
387
+ );
388
+ expect(postgresMigration).toContain(
389
+ 'ADD COLUMN "body_html_version" integer DEFAULT 1 NOT NULL',
390
+ );
391
+ });
392
+
393
+ it("migrates post collections into a lossless thread-level union", () => {
394
+ const sqlite = new Database(":memory:");
395
+ sqlite.pragma("foreign_keys = ON");
396
+
397
+ applyMigrationsThrough(sqlite, "0026_absent_rhodey.sql");
398
+
399
+ const siteId = "sit_test00000000000000000000000";
400
+ const rootId = "post-root";
401
+ const firstReplyId = "post-reply-1";
402
+ const secondReplyId = "post-reply-2";
403
+ const sharedCollectionId = "collection-shared";
404
+ const childOnlyCollectionId = "collection-child-only";
405
+
406
+ insertSite(sqlite, {
407
+ id: siteId,
408
+ key: "default",
409
+ createdAt: 1,
410
+ });
411
+
412
+ sqlite
413
+ .prepare(
414
+ `
415
+ INSERT INTO collection (
416
+ id,
417
+ site_id,
418
+ title,
419
+ description,
420
+ sort_order,
421
+ created_at,
422
+ updated_at
423
+ ) VALUES
424
+ (?, ?, 'Shared', NULL, 'newest', 1, 1),
425
+ (?, ?, 'Child only', NULL, 'newest', 2, 2)
426
+ `,
427
+ )
428
+ .run(sharedCollectionId, siteId, childOnlyCollectionId, siteId);
429
+
430
+ insertRootPost(sqlite, {
431
+ siteId,
432
+ id: rootId,
433
+ title: "Root",
434
+ bodyText: "Root body",
435
+ createdAt: 10,
436
+ });
437
+
438
+ const insertReply = sqlite.prepare(
439
+ `
440
+ INSERT INTO post (
441
+ id,
442
+ site_id,
443
+ format,
444
+ status,
445
+ visibility,
446
+ title,
447
+ body_text,
448
+ reply_to_id,
449
+ thread_id,
450
+ published_at,
451
+ last_activity_at,
452
+ created_at,
453
+ updated_at
454
+ ) VALUES (?, ?, 'note', 'published', NULL, ?, ?, ?, ?, ?, ?, ?, ?)
455
+ `,
456
+ );
457
+ insertReply.run(
458
+ firstReplyId,
459
+ siteId,
460
+ "First reply",
461
+ "First reply body",
462
+ rootId,
463
+ rootId,
464
+ 20,
465
+ 20,
466
+ 20,
467
+ 20,
468
+ );
469
+ insertReply.run(
470
+ secondReplyId,
471
+ siteId,
472
+ "Second reply",
473
+ "Second reply body",
474
+ firstReplyId,
475
+ rootId,
476
+ 30,
477
+ 30,
478
+ 30,
479
+ 30,
480
+ );
481
+
482
+ const insertMembership = sqlite.prepare(
483
+ `
484
+ INSERT INTO post_collection (
485
+ site_id,
486
+ post_id,
487
+ collection_id,
488
+ created_at,
489
+ position,
490
+ pinned_at
491
+ ) VALUES (?, ?, ?, ?, ?, ?)
492
+ `,
493
+ );
494
+ insertMembership.run(siteId, rootId, sharedCollectionId, 100, 7, 300);
495
+ insertMembership.run(
496
+ siteId,
497
+ firstReplyId,
498
+ sharedCollectionId,
499
+ 400,
500
+ 5,
501
+ null,
502
+ );
503
+ insertMembership.run(
504
+ siteId,
505
+ secondReplyId,
506
+ sharedCollectionId,
507
+ 250,
508
+ 1,
509
+ 900,
510
+ );
511
+ insertMembership.run(
512
+ siteId,
513
+ firstReplyId,
514
+ childOnlyCollectionId,
515
+ 500,
516
+ 4,
517
+ 700,
518
+ );
519
+
520
+ expect(
521
+ sqlite.prepare("SELECT COUNT(*) AS count FROM post_collection").get(),
522
+ ).toEqual({ count: 4 });
523
+
524
+ applyMigration(sqlite, "0027_old_blue_blade.sql");
525
+
526
+ expect(
527
+ sqlite
528
+ .prepare(
529
+ `
530
+ SELECT
531
+ site_id AS siteId,
532
+ thread_id AS threadId,
533
+ collection_id AS collectionId,
534
+ created_at AS createdAt,
535
+ position,
536
+ pinned_at AS pinnedAt
537
+ FROM thread_collection
538
+ ORDER BY collection_id
539
+ `,
540
+ )
541
+ .all(),
542
+ ).toEqual([
543
+ {
544
+ siteId,
545
+ threadId: rootId,
546
+ collectionId: childOnlyCollectionId,
547
+ createdAt: 500,
548
+ position: 4,
549
+ pinnedAt: 700,
550
+ },
551
+ {
552
+ siteId,
553
+ threadId: rootId,
554
+ collectionId: sharedCollectionId,
555
+ createdAt: 400,
556
+ position: 1,
557
+ pinnedAt: 900,
558
+ },
559
+ ]);
560
+
561
+ expect(
562
+ sqlite
563
+ .prepare(
564
+ `
565
+ SELECT COUNT(*) AS count
566
+ FROM thread_collection tc
567
+ INNER JOIN post root
568
+ ON root.site_id = tc.site_id
569
+ AND root.id = tc.thread_id
570
+ WHERE root.reply_to_id IS NOT NULL
571
+ OR root.thread_id <> root.id
572
+ `,
573
+ )
574
+ .get(),
575
+ ).toEqual({ count: 0 });
576
+ expect(
577
+ sqlite
578
+ .prepare(
579
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'post_collection'",
580
+ )
581
+ .get(),
582
+ ).toBeUndefined();
583
+ expect(sqlite.pragma("foreign_key_check")).toEqual([]);
584
+ });
585
+
586
+ it("verifies an upgrade from 0020 with a collected soft-deleted post", async () => {
587
+ const sqlite = new Database(":memory:");
588
+ sqlite.pragma("foreign_keys = ON");
589
+ applyMigrationsThrough(sqlite, "0020_free_zaladane.sql");
590
+
591
+ const siteId = "sit_soft_delete_upgrade";
592
+ const collectionId = "collection-upgrade";
593
+ insertSite(sqlite, { id: siteId, key: "default", createdAt: 1 });
594
+ sqlite
595
+ .prepare(
596
+ `
597
+ INSERT INTO collection (
598
+ id,
599
+ site_id,
600
+ title,
601
+ description,
602
+ sort_order,
603
+ created_at,
604
+ updated_at
605
+ ) VALUES (?, ?, 'Upgrade', NULL, 'newest', 1, 1)
606
+ `,
607
+ )
608
+ .run(collectionId, siteId);
609
+ insertRootPost(sqlite, {
610
+ siteId,
611
+ id: "post-live",
612
+ title: "Live",
613
+ bodyText: "Live post",
614
+ createdAt: 10,
615
+ });
616
+ insertRootPost(sqlite, {
617
+ siteId,
618
+ id: "post-soft-deleted",
619
+ title: "Deleted",
620
+ bodyText: "Deleted post",
621
+ createdAt: 20,
622
+ });
623
+ sqlite
624
+ .prepare("UPDATE post SET deleted_at = ? WHERE id = ?")
625
+ .run(30, "post-soft-deleted");
626
+ sqlite.exec(`
627
+ INSERT INTO post_collection (
628
+ site_id,
629
+ post_id,
630
+ collection_id,
631
+ created_at,
632
+ position,
633
+ pinned_at
634
+ ) VALUES
635
+ ('${siteId}', 'post-live', '${collectionId}', 10, 0, NULL),
636
+ ('${siteId}', 'post-soft-deleted', '${collectionId}', 20, 0, NULL);
637
+ `);
638
+
639
+ const query = (sql: string) => sqlite.prepare(sql).all();
640
+ const preflight = await preflightThreadCollectionMigration({
641
+ dialect: "sqlite",
642
+ log: () => undefined,
643
+ query,
644
+ });
645
+ expect(preflight).toEqual({
646
+ expectedCount: 1,
647
+ phase: "legacy",
648
+ sourceCount: 2,
649
+ });
650
+
651
+ applyMigrationsAfter(sqlite, "0020_free_zaladane.sql");
652
+
653
+ await expect(
654
+ verifyThreadCollectionMigration(
655
+ { dialect: "sqlite", log: () => undefined, query },
656
+ preflight,
657
+ ),
658
+ ).resolves.toBeUndefined();
659
+ expect(
660
+ sqlite
661
+ .prepare(
662
+ "SELECT thread_id AS threadId FROM thread_collection ORDER BY thread_id",
663
+ )
664
+ .all(),
665
+ ).toEqual([{ threadId: "post-live" }]);
666
+ expect(sqlite.pragma("foreign_key_check")).toEqual([]);
667
+
668
+ sqlite.close();
669
+ });
670
+
671
+ it("indexes Featured Thread ordering by publication time in both dialects", () => {
672
+ const sqlite = new Database(":memory:");
673
+ sqlite.pragma("foreign_keys = ON");
674
+
675
+ applyMigrationsThrough(sqlite, "0027_old_blue_blade.sql");
676
+ applyMigration(sqlite, "0028_superb_post.sql");
677
+
678
+ const indexes = sqlite
679
+ .prepare(
680
+ `
681
+ SELECT name
682
+ FROM sqlite_master
683
+ WHERE type = 'index'
684
+ AND name IN (
685
+ 'idx_post_site_featured_featured_at',
686
+ 'idx_post_site_featured_thread_published'
687
+ )
688
+ ORDER BY name
689
+ `,
690
+ )
691
+ .all();
692
+ expect(indexes).toEqual([
693
+ { name: "idx_post_site_featured_thread_published" },
694
+ ]);
695
+ expect(
696
+ sqlite
697
+ .prepare(
698
+ "SELECT name FROM pragma_index_info('idx_post_site_featured_thread_published') ORDER BY seqno",
699
+ )
700
+ .all(),
701
+ ).toEqual([
702
+ { name: "site_id" },
703
+ { name: "thread_id" },
704
+ { name: "published_at" },
705
+ { name: "id" },
706
+ ]);
707
+
708
+ insertSite(sqlite, {
709
+ id: "sit_test00000000000000000000000",
710
+ key: "default",
711
+ createdAt: 1,
712
+ });
713
+ sqlite.exec(`
714
+ WITH RECURSIVE sequence(value) AS (
715
+ VALUES (1)
716
+ UNION ALL
717
+ SELECT value + 1 FROM sequence WHERE value < 1000
718
+ )
719
+ INSERT INTO post (
720
+ id,
721
+ site_id,
722
+ format,
723
+ status,
724
+ visibility,
725
+ title,
726
+ body_text,
727
+ thread_id,
728
+ published_at,
729
+ featured_at,
730
+ created_at,
731
+ updated_at
732
+ )
733
+ SELECT
734
+ printf('post-%04d', value),
735
+ 'sit_test00000000000000000000000',
736
+ 'note',
737
+ 'published',
738
+ 'public',
739
+ printf('Post %d', value),
740
+ '',
741
+ printf('post-%04d', value),
742
+ value,
743
+ CASE WHEN value % 20 = 0 THEN value + 1000 ELSE NULL END,
744
+ value,
745
+ value
746
+ FROM sequence
747
+ `);
748
+ sqlite.exec("ANALYZE");
749
+
750
+ const queryPlan = sqlite
751
+ .prepare(
752
+ `
753
+ EXPLAIN QUERY PLAN
754
+ SELECT
755
+ thread_id,
756
+ MAX(published_at) AS latest_featured_published_at
757
+ FROM post
758
+ WHERE site_id = ?
759
+ AND status = 'published'
760
+ AND featured_at IS NOT NULL
761
+ GROUP BY thread_id
762
+ ORDER BY latest_featured_published_at DESC, thread_id DESC
763
+ LIMIT 20
764
+ `,
765
+ )
766
+ .all("sit_test00000000000000000000000") as Array<{
767
+ detail: string;
768
+ }>;
769
+ expect(
770
+ queryPlan.some(({ detail }) =>
771
+ detail.includes("idx_post_site_featured_thread_published"),
772
+ ),
773
+ JSON.stringify(queryPlan),
774
+ ).toBe(true);
775
+ expect(
776
+ queryPlan.some(({ detail }) => detail.includes("FOR GROUP BY")),
777
+ ).toBe(false);
778
+
779
+ const postgresMigration = readFileSync(
780
+ resolve(PG_MIGRATIONS_DIR, "0026_talented_killer_shrike.sql"),
781
+ "utf-8",
782
+ );
783
+ expect(postgresMigration).toContain(
784
+ 'DROP INDEX "idx_post_site_featured_featured_at"',
785
+ );
786
+ expect(postgresMigration).toContain(
787
+ 'CREATE INDEX "idx_post_site_featured_thread_published" ON "post" USING btree ("site_id","thread_id","published_at","id")',
788
+ );
789
+ });
790
+
322
791
  it("fts schema migration rebuilds and maintains the post_fts index", () => {
323
792
  const sqlite = new Database(":memory:");
324
793
  sqlite.pragma("foreign_keys = ON");