@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
@@ -0,0 +1,364 @@
1
+ import Database from "better-sqlite3";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ preflightThreadCollectionMigration,
5
+ verifyThreadCollectionMigration,
6
+ } from "../../../bin/lib/thread-collection-migration.js";
7
+
8
+ function createQuery(sqlite: Database.Database) {
9
+ return (sql: string) => sqlite.prepare(sql).all();
10
+ }
11
+
12
+ function createDialectQuery(
13
+ sqlite: Database.Database,
14
+ dialect: "pg" | "sqlite",
15
+ ) {
16
+ if (dialect === "sqlite") return createQuery(sqlite);
17
+
18
+ return (sql: string) => {
19
+ if (sql.includes("to_regclass('public.post_collection')")) {
20
+ const tableExists = (name: string) =>
21
+ sqlite
22
+ .prepare(
23
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
24
+ )
25
+ .get(name) !== undefined;
26
+ return [
27
+ {
28
+ legacy_exists: tableExists("post_collection"),
29
+ target_exists: tableExists("thread_collection"),
30
+ },
31
+ ];
32
+ }
33
+
34
+ if (sql.includes("information_schema.columns")) {
35
+ return sqlite
36
+ .prepare(`PRAGMA table_info("post")`)
37
+ .all()
38
+ .map((row) => ({
39
+ column_name: String((row as { name: unknown }).name),
40
+ }));
41
+ }
42
+
43
+ return sqlite.prepare(sql).all();
44
+ };
45
+ }
46
+
47
+ function createDomainTables(sqlite: Database.Database) {
48
+ sqlite.exec(`
49
+ CREATE TABLE site (
50
+ id TEXT PRIMARY KEY
51
+ );
52
+
53
+ CREATE TABLE post (
54
+ id TEXT PRIMARY KEY,
55
+ site_id TEXT NOT NULL,
56
+ reply_to_id TEXT,
57
+ thread_id TEXT NOT NULL
58
+ );
59
+
60
+ CREATE TABLE collection (
61
+ id TEXT PRIMARY KEY,
62
+ site_id TEXT NOT NULL
63
+ );
64
+ `);
65
+ }
66
+
67
+ function createLegacyTable(sqlite: Database.Database) {
68
+ sqlite.exec(`
69
+ CREATE TABLE post_collection (
70
+ site_id TEXT NOT NULL,
71
+ post_id TEXT NOT NULL,
72
+ collection_id TEXT NOT NULL
73
+ );
74
+ `);
75
+ }
76
+
77
+ function createTargetTable(sqlite: Database.Database) {
78
+ sqlite.exec(`
79
+ CREATE TABLE thread_collection (
80
+ site_id TEXT NOT NULL,
81
+ thread_id TEXT NOT NULL,
82
+ collection_id TEXT NOT NULL,
83
+ PRIMARY KEY (site_id, thread_id, collection_id)
84
+ );
85
+ `);
86
+ }
87
+
88
+ function seedLegacyThread(sqlite: Database.Database) {
89
+ sqlite.exec(`
90
+ INSERT INTO site (id) VALUES ('site-1');
91
+ INSERT INTO post (id, site_id, reply_to_id, thread_id) VALUES
92
+ ('root', 'site-1', NULL, 'root'),
93
+ ('child-1', 'site-1', 'root', 'root'),
94
+ ('child-2', 'site-1', 'child-1', 'root');
95
+ INSERT INTO collection (id, site_id) VALUES
96
+ ('shared', 'site-1'),
97
+ ('child-only', 'site-1');
98
+ INSERT INTO post_collection (site_id, post_id, collection_id) VALUES
99
+ ('site-1', 'root', 'shared'),
100
+ ('site-1', 'child-1', 'shared'),
101
+ ('site-1', 'child-2', 'shared'),
102
+ ('site-1', 'child-1', 'child-only');
103
+ `);
104
+ }
105
+
106
+ function seedSoftDeletedLegacyMembership(sqlite: Database.Database) {
107
+ sqlite.exec(`
108
+ ALTER TABLE post ADD COLUMN deleted_at INTEGER;
109
+ `);
110
+ seedLegacyThread(sqlite);
111
+ sqlite.exec(`
112
+ INSERT INTO post (id, site_id, reply_to_id, thread_id, deleted_at)
113
+ VALUES ('deleted-root', 'site-1', NULL, 'deleted-root', 100);
114
+ INSERT INTO collection (id, site_id)
115
+ VALUES ('deleted-only', 'site-1');
116
+ INSERT INTO post_collection (site_id, post_id, collection_id)
117
+ VALUES ('site-1', 'deleted-root', 'deleted-only');
118
+ `);
119
+ }
120
+
121
+ function applySoftDeleteAndThreadCollectionCutover(
122
+ sqlite: Database.Database,
123
+ options: { loseChildOnlyMembership?: boolean } = {},
124
+ ) {
125
+ sqlite.exec(`
126
+ DELETE FROM post_collection
127
+ WHERE post_id IN (
128
+ SELECT id FROM post WHERE deleted_at IS NOT NULL
129
+ );
130
+ DELETE FROM post WHERE deleted_at IS NOT NULL;
131
+ `);
132
+ createTargetTable(sqlite);
133
+ sqlite.exec(`
134
+ INSERT INTO thread_collection (site_id, thread_id, collection_id)
135
+ SELECT membership.site_id, post.thread_id, membership.collection_id
136
+ FROM post_collection AS membership
137
+ INNER JOIN post
138
+ ON post.site_id = membership.site_id
139
+ AND post.id = membership.post_id
140
+ ${options.loseChildOnlyMembership ? "WHERE membership.collection_id <> 'child-only'" : ""}
141
+ GROUP BY membership.site_id, post.thread_id, membership.collection_id;
142
+ DROP TABLE post_collection;
143
+ `);
144
+ }
145
+
146
+ describe("Thread Collection migration guard", () => {
147
+ it("verifies a fresh database after schema migration", async () => {
148
+ const sqlite = new Database(":memory:");
149
+ const query = createQuery(sqlite);
150
+
151
+ await expect(
152
+ preflightThreadCollectionMigration({
153
+ dialect: "sqlite",
154
+ log: vi.fn(),
155
+ query,
156
+ }),
157
+ ).resolves.toEqual({ phase: "fresh" });
158
+
159
+ createDomainTables(sqlite);
160
+ createTargetTable(sqlite);
161
+
162
+ await expect(
163
+ verifyThreadCollectionMigration(
164
+ { dialect: "sqlite", log: vi.fn(), query },
165
+ { phase: "fresh" },
166
+ ),
167
+ ).resolves.toBeUndefined();
168
+
169
+ sqlite.close();
170
+ });
171
+
172
+ it("records and verifies the lossless union before allowing repeat runs", async () => {
173
+ const sqlite = new Database(":memory:");
174
+ createDomainTables(sqlite);
175
+ createLegacyTable(sqlite);
176
+ seedLegacyThread(sqlite);
177
+ const query = createQuery(sqlite);
178
+
179
+ const preflight = await preflightThreadCollectionMigration({
180
+ dialect: "sqlite",
181
+ log: vi.fn(),
182
+ query,
183
+ });
184
+
185
+ expect(preflight).toEqual({
186
+ expectedCount: 2,
187
+ phase: "legacy",
188
+ sourceCount: 4,
189
+ });
190
+
191
+ createTargetTable(sqlite);
192
+ sqlite.exec(`
193
+ INSERT INTO thread_collection (site_id, thread_id, collection_id)
194
+ SELECT membership.site_id, post.thread_id, membership.collection_id
195
+ FROM post_collection AS membership
196
+ INNER JOIN post
197
+ ON post.site_id = membership.site_id
198
+ AND post.id = membership.post_id
199
+ GROUP BY membership.site_id, post.thread_id, membership.collection_id;
200
+ DROP TABLE post_collection;
201
+ `);
202
+
203
+ await expect(
204
+ verifyThreadCollectionMigration(
205
+ { dialect: "sqlite", log: vi.fn(), query },
206
+ preflight,
207
+ ),
208
+ ).resolves.toBeUndefined();
209
+
210
+ const repeatPreflight = await preflightThreadCollectionMigration({
211
+ dialect: "sqlite",
212
+ log: vi.fn(),
213
+ query,
214
+ });
215
+ expect(repeatPreflight).toEqual({ phase: "already-migrated" });
216
+ await expect(
217
+ verifyThreadCollectionMigration(
218
+ { dialect: "sqlite", log: vi.fn(), query },
219
+ repeatPreflight,
220
+ ),
221
+ ).resolves.toBeUndefined();
222
+
223
+ sqlite.close();
224
+ });
225
+
226
+ it("stops before mutation when legacy memberships reference missing data", async () => {
227
+ const sqlite = new Database(":memory:");
228
+ createDomainTables(sqlite);
229
+ createLegacyTable(sqlite);
230
+ sqlite.exec(`
231
+ INSERT INTO site (id) VALUES ('site-1');
232
+ INSERT INTO collection (id, site_id) VALUES ('collection-1', 'site-1');
233
+ INSERT INTO post_collection (site_id, post_id, collection_id)
234
+ VALUES ('site-1', 'missing-post', 'collection-1');
235
+ `);
236
+
237
+ await expect(
238
+ preflightThreadCollectionMigration({
239
+ dialect: "sqlite",
240
+ log: vi.fn(),
241
+ query: createQuery(sqlite),
242
+ }),
243
+ ).rejects.toThrow("missing posts=1");
244
+ expect(
245
+ sqlite
246
+ .prepare(
247
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'post_collection'",
248
+ )
249
+ .get(),
250
+ ).toEqual({ name: "post_collection" });
251
+
252
+ sqlite.close();
253
+ });
254
+
255
+ it("rejects ambiguous and count-mismatched cutover states", async () => {
256
+ const sqlite = new Database(":memory:");
257
+ createDomainTables(sqlite);
258
+ createLegacyTable(sqlite);
259
+ seedLegacyThread(sqlite);
260
+ createTargetTable(sqlite);
261
+ const query = createQuery(sqlite);
262
+
263
+ await expect(
264
+ preflightThreadCollectionMigration({
265
+ dialect: "sqlite",
266
+ log: vi.fn(),
267
+ query,
268
+ }),
269
+ ).rejects.toThrow("both post_collection and thread_collection");
270
+
271
+ sqlite.exec(`
272
+ DROP TABLE thread_collection;
273
+ `);
274
+ const preflight = await preflightThreadCollectionMigration({
275
+ dialect: "sqlite",
276
+ log: vi.fn(),
277
+ query,
278
+ });
279
+ createTargetTable(sqlite);
280
+ sqlite.exec(`
281
+ INSERT INTO thread_collection (site_id, thread_id, collection_id)
282
+ VALUES ('site-1', 'root', 'shared');
283
+ DROP TABLE post_collection;
284
+ `);
285
+
286
+ await expect(
287
+ verifyThreadCollectionMigration(
288
+ { dialect: "sqlite", log: vi.fn(), query },
289
+ preflight,
290
+ ),
291
+ ).rejects.toThrow("expected rows=2, actual rows=1");
292
+
293
+ sqlite.close();
294
+ });
295
+
296
+ it.each(["sqlite", "pg"] as const)(
297
+ "allows a %s legacy upgrade to discard collected soft-deleted posts",
298
+ async (dialect) => {
299
+ const sqlite = new Database(":memory:");
300
+ createDomainTables(sqlite);
301
+ createLegacyTable(sqlite);
302
+ seedSoftDeletedLegacyMembership(sqlite);
303
+ const query = createDialectQuery(sqlite, dialect);
304
+ const log = vi.fn();
305
+
306
+ const preflight = await preflightThreadCollectionMigration({
307
+ dialect,
308
+ log,
309
+ query,
310
+ });
311
+
312
+ expect(preflight).toEqual({
313
+ expectedCount: 2,
314
+ phase: "legacy",
315
+ sourceCount: 5,
316
+ });
317
+ expect(log).toHaveBeenCalledWith(
318
+ expect.stringContaining(
319
+ "1 soft-deleted post membership will be removed",
320
+ ),
321
+ );
322
+
323
+ applySoftDeleteAndThreadCollectionCutover(sqlite);
324
+
325
+ await expect(
326
+ verifyThreadCollectionMigration(
327
+ { dialect, log: vi.fn(), query },
328
+ preflight,
329
+ ),
330
+ ).resolves.toBeUndefined();
331
+
332
+ sqlite.close();
333
+ },
334
+ );
335
+
336
+ it.each(["sqlite", "pg"] as const)(
337
+ "still detects a real %s membership loss after anticipated soft deletes",
338
+ async (dialect) => {
339
+ const sqlite = new Database(":memory:");
340
+ createDomainTables(sqlite);
341
+ createLegacyTable(sqlite);
342
+ seedSoftDeletedLegacyMembership(sqlite);
343
+ const query = createDialectQuery(sqlite, dialect);
344
+ const preflight = await preflightThreadCollectionMigration({
345
+ dialect,
346
+ log: vi.fn(),
347
+ query,
348
+ });
349
+
350
+ applySoftDeleteAndThreadCollectionCutover(sqlite, {
351
+ loseChildOnlyMembership: true,
352
+ });
353
+
354
+ await expect(
355
+ verifyThreadCollectionMigration(
356
+ { dialect, log: vi.fn(), query },
357
+ preflight,
358
+ ),
359
+ ).rejects.toThrow("expected rows=2, actual rows=1");
360
+
361
+ sqlite.close();
362
+ },
363
+ );
364
+ });
@@ -0,0 +1,39 @@
1
+ CREATE TABLE `thread_collection` (
2
+ `site_id` text NOT NULL,
3
+ `thread_id` text NOT NULL,
4
+ `collection_id` text NOT NULL,
5
+ `created_at` integer NOT NULL,
6
+ `position` integer DEFAULT 0 NOT NULL,
7
+ `pinned_at` integer,
8
+ PRIMARY KEY(`site_id`, `thread_id`, `collection_id`),
9
+ FOREIGN KEY (`site_id`) REFERENCES `site`(`id`) ON UPDATE no action ON DELETE cascade,
10
+ FOREIGN KEY (`thread_id`) REFERENCES `post`(`id`) ON UPDATE no action ON DELETE cascade,
11
+ FOREIGN KEY (`collection_id`) REFERENCES `collection`(`id`) ON UPDATE no action ON DELETE cascade
12
+ );
13
+ --> statement-breakpoint
14
+ CREATE INDEX `idx_thread_collection_site_collection_id` ON `thread_collection` (`site_id`,`collection_id`);--> statement-breakpoint
15
+ CREATE INDEX `idx_thread_collection_site_collection_created_thread` ON `thread_collection` (`site_id`,`collection_id`,`created_at`,`thread_id`);--> statement-breakpoint
16
+ INSERT INTO `thread_collection` (
17
+ `site_id`,
18
+ `thread_id`,
19
+ `collection_id`,
20
+ `created_at`,
21
+ `position`,
22
+ `pinned_at`
23
+ )
24
+ SELECT
25
+ `post_collection`.`site_id`,
26
+ `post`.`thread_id`,
27
+ `post_collection`.`collection_id`,
28
+ MAX(`post_collection`.`created_at`),
29
+ MIN(`post_collection`.`position`),
30
+ MAX(`post_collection`.`pinned_at`)
31
+ FROM `post_collection`
32
+ INNER JOIN `post`
33
+ ON `post`.`site_id` = `post_collection`.`site_id`
34
+ AND `post`.`id` = `post_collection`.`post_id`
35
+ GROUP BY
36
+ `post_collection`.`site_id`,
37
+ `post`.`thread_id`,
38
+ `post_collection`.`collection_id`;--> statement-breakpoint
39
+ DROP TABLE `post_collection`;
@@ -0,0 +1,2 @@
1
+ DROP INDEX `idx_post_site_featured_featured_at`;--> statement-breakpoint
2
+ CREATE INDEX `idx_post_site_featured_thread_published` ON `post` (`site_id`,`thread_id`,`published_at`,`id`) WHERE "post"."status" = 'published' AND "post"."featured_at" IS NOT NULL;
@@ -0,0 +1 @@
1
+ ALTER TABLE `post` ADD `body_html_version` integer DEFAULT 1 NOT NULL;
@@ -0,0 +1,50 @@
1
+ PRAGMA foreign_keys=OFF;--> statement-breakpoint
2
+ CREATE TABLE `__new_nav_item` (
3
+ `id` text PRIMARY KEY NOT NULL,
4
+ `site_id` text NOT NULL,
5
+ `type` text DEFAULT 'link' NOT NULL,
6
+ `system_key` text,
7
+ `collection_id` text,
8
+ `post_id` text,
9
+ `label` text NOT NULL,
10
+ `url` text NOT NULL,
11
+ `placement` text DEFAULT 'header' NOT NULL,
12
+ `position` text DEFAULT 'a0' NOT NULL,
13
+ `created_at` integer NOT NULL,
14
+ `updated_at` integer NOT NULL,
15
+ FOREIGN KEY (`site_id`) REFERENCES `site`(`id`) ON UPDATE no action ON DELETE cascade,
16
+ FOREIGN KEY (`collection_id`) REFERENCES `collection`(`id`) ON UPDATE no action ON DELETE cascade,
17
+ FOREIGN KEY (`post_id`) REFERENCES `post`(`id`) ON UPDATE no action ON DELETE cascade,
18
+ CONSTRAINT "chk_nav_item_type" CHECK("__new_nav_item"."type" IN ('link', 'system', 'collection', 'page')),
19
+ CONSTRAINT "chk_nav_item_placement" CHECK("__new_nav_item"."placement" IN ('header', 'more')),
20
+ CONSTRAINT "chk_nav_item_shape" CHECK((
21
+ "__new_nav_item"."type" = 'link'
22
+ AND "__new_nav_item"."system_key" IS NULL
23
+ AND "__new_nav_item"."collection_id" IS NULL
24
+ AND "__new_nav_item"."post_id" IS NULL
25
+ ) OR (
26
+ "__new_nav_item"."type" = 'system'
27
+ AND "__new_nav_item"."system_key" IS NOT NULL
28
+ AND "__new_nav_item"."collection_id" IS NULL
29
+ AND "__new_nav_item"."post_id" IS NULL
30
+ ) OR (
31
+ "__new_nav_item"."type" = 'collection'
32
+ AND "__new_nav_item"."system_key" IS NULL
33
+ AND "__new_nav_item"."collection_id" IS NOT NULL
34
+ AND "__new_nav_item"."post_id" IS NULL
35
+ ) OR (
36
+ "__new_nav_item"."type" = 'page'
37
+ AND "__new_nav_item"."system_key" IS NULL
38
+ AND "__new_nav_item"."collection_id" IS NULL
39
+ AND "__new_nav_item"."post_id" IS NOT NULL
40
+ ))
41
+ );
42
+ --> statement-breakpoint
43
+ INSERT INTO `__new_nav_item`("id", "site_id", "type", "system_key", "collection_id", "post_id", "label", "url", "placement", "position", "created_at", "updated_at") SELECT "id", "site_id", "type", "system_key", "collection_id", NULL, "label", "url", "placement", "position", "created_at", "updated_at" FROM `nav_item`;--> statement-breakpoint
44
+ DROP TABLE `nav_item`;--> statement-breakpoint
45
+ ALTER TABLE `__new_nav_item` RENAME TO `nav_item`;--> statement-breakpoint
46
+ PRAGMA foreign_keys=ON;--> statement-breakpoint
47
+ CREATE UNIQUE INDEX `uq_nav_item_site_position` ON `nav_item` (`site_id`,`position`);--> statement-breakpoint
48
+ CREATE UNIQUE INDEX `uq_nav_item_site_system_key` ON `nav_item` (`site_id`,`system_key`) WHERE "nav_item"."system_key" IS NOT NULL;--> statement-breakpoint
49
+ CREATE UNIQUE INDEX `uq_nav_item_site_collection_id` ON `nav_item` (`site_id`,`collection_id`) WHERE "nav_item"."collection_id" IS NOT NULL;--> statement-breakpoint
50
+ CREATE UNIQUE INDEX `uq_nav_item_site_post_id` ON `nav_item` (`site_id`,`post_id`) WHERE "nav_item"."post_id" IS NOT NULL;