@jant/core 0.3.35 → 0.3.37

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 (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -1,72 +1,113 @@
1
1
  import { describe, it, expect, beforeEach } from "vitest";
2
+ import { eq } from "drizzle-orm";
2
3
  import { createTestDatabase } from "../../__tests__/helpers/db.js";
4
+ import { posts } from "../../db/schema.js";
3
5
  import { createPostService } from "../post.js";
4
- import { createPageService } from "../page.js";
5
- import { createPathRegistryService } from "../path-registry.js";
6
- import { ValidationError, ConflictError } from "../../lib/errors.js";
7
6
  import type { Database } from "../../db/index.js";
7
+ import { createPathService } from "../path.js";
8
8
 
9
9
  describe("PostService", () => {
10
10
  let db: Database;
11
11
  let postService: ReturnType<typeof createPostService>;
12
- let pageService: ReturnType<typeof createPageService>;
13
- let pathRegistry: ReturnType<typeof createPathRegistryService>;
14
12
 
15
13
  beforeEach(() => {
16
14
  const testDb = createTestDatabase();
17
15
  db = testDb.db as unknown as Database;
18
- pathRegistry = createPathRegistryService(db);
19
- postService = createPostService(db, pathRegistry);
20
- pageService = createPageService(db, pathRegistry);
16
+ postService = createPostService(db, { slugIdLength: 5 });
21
17
  });
22
18
 
23
19
  describe("create", () => {
24
20
  it("creates a note post with required fields", async () => {
21
+ const body = JSON.stringify({
22
+ type: "doc",
23
+ content: [
24
+ {
25
+ type: "paragraph",
26
+ content: [{ type: "text", text: "Hello world" }],
27
+ },
28
+ ],
29
+ });
25
30
  const post = await postService.create({
26
31
  format: "note",
27
- body: "Hello world",
32
+ body,
28
33
  });
29
34
 
30
- expect(post.id).toBe(1);
35
+ expect(typeof post.id).toBe("string");
36
+ expect(post.id).toMatch(
37
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
38
+ );
31
39
  expect(post.format).toBe("note");
32
- expect(post.body).toBe("Hello world");
40
+ expect(post.body).toBe(body);
33
41
  expect(post.status).toBe("published"); // default
34
- expect(post.featured).toBe(0);
35
- expect(post.pinned).toBe(0);
42
+ expect(post.visibility).toBe("public");
43
+ expect(post.pinnedAt).toBeNull();
36
44
  expect(post.bodyHtml).toContain("<p>Hello world</p>");
37
45
  expect(post.deletedAt).toBeNull();
38
- });
39
-
40
- it("creates a post with all fields", async () => {
46
+ expect(post.threadId).toBe(post.id);
47
+ });
48
+
49
+ it("creates a link post with commentary", async () => {
50
+ const body = JSON.stringify({
51
+ type: "doc",
52
+ content: [
53
+ {
54
+ type: "heading",
55
+ attrs: { level: 1 },
56
+ content: [{ type: "text", text: "Introduction" }],
57
+ },
58
+ {
59
+ type: "paragraph",
60
+ content: [{ type: "text", text: "Some content." }],
61
+ },
62
+ ],
63
+ });
41
64
  const post = await postService.create({
42
65
  format: "link",
43
66
  title: "My Link",
44
- body: "# Introduction\n\nSome content.",
67
+ body,
45
68
  status: "published",
69
+ visibility: "public",
46
70
  featured: true,
47
71
  pinned: true,
48
- path: "my-link",
72
+ slug: "my-link",
49
73
  url: "https://example.com/source",
50
- quoteText: "A notable quote",
51
74
  rating: 5,
52
75
  });
53
76
 
54
77
  expect(post.format).toBe("link");
55
78
  expect(post.title).toBe("My Link");
56
79
  expect(post.status).toBe("published");
57
- expect(post.featured).toBe(1);
58
- expect(post.pinned).toBe(1);
59
- expect(post.path).toBe("my-link");
80
+ expect(post.visibility).toBe("public");
81
+ expect(post.featuredAt).toBeTypeOf("number");
82
+ expect(post.pinnedAt).toBeTypeOf("number");
83
+ expect(post.slug).toBe("my-link");
60
84
  expect(post.url).toBe("https://example.com/source");
61
- expect(post.quoteText).toBe("A notable quote");
85
+ expect(post.quoteText).toBeNull();
62
86
  expect(post.rating).toBe(5);
63
87
  expect(post.bodyHtml).toContain("<h1>");
64
88
  });
65
89
 
66
- it("renders markdown body to HTML", async () => {
90
+ it("renders Tiptap JSON body to HTML", async () => {
91
+ const body = JSON.stringify({
92
+ type: "doc",
93
+ content: [
94
+ {
95
+ type: "paragraph",
96
+ content: [
97
+ { type: "text", text: "This is " },
98
+ {
99
+ type: "text",
100
+ marks: [{ type: "bold" }],
101
+ text: "bold",
102
+ },
103
+ { type: "text", text: " text" },
104
+ ],
105
+ },
106
+ ],
107
+ });
67
108
  const post = await postService.create({
68
109
  format: "note",
69
- body: "This is **bold** text",
110
+ body,
70
111
  });
71
112
 
72
113
  expect(post.bodyHtml).toContain("<strong>bold</strong>");
@@ -75,7 +116,7 @@ describe("PostService", () => {
75
116
  it("sets publishedAt and timestamps", async () => {
76
117
  const post = await postService.create({
77
118
  format: "note",
78
- body: "test",
119
+ bodyMarkdown: "test",
79
120
  });
80
121
 
81
122
  expect(post.publishedAt).toBeGreaterThan(0);
@@ -87,31 +128,33 @@ describe("PostService", () => {
87
128
  const customTime = 1706745600;
88
129
  const post = await postService.create({
89
130
  format: "note",
90
- body: "test",
131
+ bodyMarkdown: "test",
91
132
  publishedAt: customTime,
92
133
  });
93
134
 
94
135
  expect(post.publishedAt).toBe(customTime);
95
136
  });
96
137
 
97
- it("creates incrementing IDs", async () => {
138
+ it("creates unique UUIDv7 IDs that sort chronologically", async () => {
98
139
  const post1 = await postService.create({
99
140
  format: "note",
100
- body: "first",
141
+ bodyMarkdown: "first",
101
142
  });
102
143
  const post2 = await postService.create({
103
144
  format: "note",
104
- body: "second",
145
+ bodyMarkdown: "second",
105
146
  });
106
147
 
107
- expect(post2.id).toBeGreaterThan(post1.id);
148
+ expect(post1.id).not.toBe(post2.id);
149
+ // UUIDv7 strings sort chronologically
150
+ expect(post2.id > post1.id).toBe(true);
108
151
  });
109
152
 
110
153
  it("creates a quote post", async () => {
111
154
  const post = await postService.create({
112
155
  format: "quote",
113
156
  quoteText: "To be or not to be",
114
- body: "Shakespeare's famous line",
157
+ bodyMarkdown: "Shakespeare's famous line",
115
158
  url: "https://example.com/hamlet",
116
159
  });
117
160
 
@@ -123,11 +166,110 @@ describe("PostService", () => {
123
166
  it("creates a draft post", async () => {
124
167
  const post = await postService.create({
125
168
  format: "note",
126
- body: "draft content",
169
+ bodyMarkdown: "draft content",
127
170
  status: "draft",
128
171
  });
129
172
 
130
173
  expect(post.status).toBe("draft");
174
+ expect(post.publishedAt).toBeNull();
175
+ });
176
+
177
+ it("rejects ratings outside the database range", async () => {
178
+ await expect(
179
+ postService.create({
180
+ format: "note",
181
+ bodyMarkdown: "test",
182
+ rating: 6,
183
+ }),
184
+ ).rejects.toThrow();
185
+ });
186
+
187
+ it("rejects draft posts with an explicit publish time", async () => {
188
+ await expect(
189
+ postService.create({
190
+ format: "note",
191
+ bodyMarkdown: "draft content",
192
+ status: "draft",
193
+ publishedAt: 1706745600,
194
+ }),
195
+ ).rejects.toThrow("Drafts can't set a publish time.");
196
+ });
197
+
198
+ it("rejects note posts with a URL", async () => {
199
+ await expect(
200
+ postService.create({
201
+ format: "note",
202
+ url: "https://example.com",
203
+ }),
204
+ ).rejects.toThrow("Notes can't include a URL.");
205
+ });
206
+
207
+ it("rejects link posts without a URL", async () => {
208
+ await expect(
209
+ postService.create({
210
+ format: "link",
211
+ bodyMarkdown: "commentary",
212
+ }),
213
+ ).rejects.toThrow("Link posts need a URL.");
214
+ });
215
+
216
+ it("rejects link posts with quoted text", async () => {
217
+ await expect(
218
+ postService.create({
219
+ format: "link",
220
+ url: "https://example.com",
221
+ quoteText: "A notable quote",
222
+ }),
223
+ ).rejects.toThrow("Link posts can't include quoted text.");
224
+ });
225
+
226
+ it("rejects quote posts without quoted text", async () => {
227
+ await expect(
228
+ postService.create({
229
+ format: "quote",
230
+ bodyMarkdown: "commentary",
231
+ }),
232
+ ).rejects.toThrow("Quote posts need quoted text.");
233
+ });
234
+
235
+ it("rejects replies to missing posts", async () => {
236
+ await expect(
237
+ postService.create({
238
+ format: "note",
239
+ bodyMarkdown: "reply",
240
+ replyToId: "00000000-0000-0000-0000-000000009999",
241
+ }),
242
+ ).rejects.toThrow("Parent post not found");
243
+ });
244
+
245
+ it("rolls back the post insert when slug persistence fails inside the batch", async () => {
246
+ await postService.create({
247
+ format: "note",
248
+ bodyMarkdown: "existing",
249
+ slug: "race-condition",
250
+ });
251
+
252
+ const paths = createPathService(db);
253
+ const raceyPaths = {
254
+ ...paths,
255
+ isPathAvailable: async () => true,
256
+ };
257
+ const raceyPostService = createPostService(
258
+ db,
259
+ { slugIdLength: 5 },
260
+ raceyPaths,
261
+ );
262
+
263
+ await expect(
264
+ raceyPostService.create({
265
+ format: "note",
266
+ bodyMarkdown: "test",
267
+ slug: "race-condition",
268
+ }),
269
+ ).rejects.toThrow('Slug "race-condition" is already in use');
270
+
271
+ const rows = await db.select({ id: posts.id }).from(posts);
272
+ expect(rows).toHaveLength(1);
131
273
  });
132
274
  });
133
275
 
@@ -135,13 +277,13 @@ describe("PostService", () => {
135
277
  it("returns a post by ID", async () => {
136
278
  const created = await postService.create({
137
279
  format: "note",
138
- body: "test",
280
+ bodyMarkdown: "test",
139
281
  });
140
282
 
141
283
  const found = await postService.getById(created.id);
142
284
  expect(found).not.toBeNull();
143
285
  expect(found?.id).toBe(created.id);
144
- expect(found?.body).toBe("test");
286
+ expect(found?.bodyText).toBe("test");
145
287
  });
146
288
 
147
289
  it("returns null for non-existent ID", async () => {
@@ -152,7 +294,7 @@ describe("PostService", () => {
152
294
  it("excludes soft-deleted posts", async () => {
153
295
  const post = await postService.create({
154
296
  format: "note",
155
- body: "test",
297
+ bodyMarkdown: "test",
156
298
  });
157
299
  await postService.delete(post.id);
158
300
 
@@ -161,47 +303,35 @@ describe("PostService", () => {
161
303
  });
162
304
  });
163
305
 
164
- describe("getByPath", () => {
165
- it("returns a post by path", async () => {
306
+ describe("getBySlug", () => {
307
+ it("returns a post by slug", async () => {
166
308
  await postService.create({
167
309
  format: "note",
168
- body: "About page",
169
- path: "about",
310
+ bodyMarkdown: "About page",
311
+ slug: "about",
170
312
  });
171
313
 
172
- const found = await postService.getByPath("about");
314
+ const found = await postService.getBySlug("about");
173
315
  expect(found).not.toBeNull();
174
- expect(found?.path).toBe("about");
316
+ expect(found?.slug).toBe("about");
175
317
  });
176
318
 
177
- it("returns null for non-existent path", async () => {
178
- const found = await postService.getByPath("nonexistent");
319
+ it("returns null for non-existent slug", async () => {
320
+ const found = await postService.getBySlug("nonexistent");
179
321
  expect(found).toBeNull();
180
322
  });
181
323
 
182
324
  it("excludes soft-deleted posts", async () => {
183
325
  const post = await postService.create({
184
326
  format: "note",
185
- body: "test",
186
- path: "test-page",
327
+ bodyMarkdown: "test",
328
+ slug: "test-page",
187
329
  });
188
330
  await postService.delete(post.id);
189
331
 
190
- const found = await postService.getByPath("test-page");
332
+ const found = await postService.getBySlug("test-page");
191
333
  expect(found).toBeNull();
192
334
  });
193
-
194
- it("finds a post with a multi-level path", async () => {
195
- await postService.create({
196
- format: "note",
197
- body: "Blog migration",
198
- path: "2024/01/my-post",
199
- });
200
-
201
- const found = await postService.getByPath("2024/01/my-post");
202
- expect(found).not.toBeNull();
203
- expect(found?.path).toBe("2024/01/my-post");
204
- });
205
335
  });
206
336
 
207
337
  describe("list", () => {
@@ -211,9 +341,9 @@ describe("PostService", () => {
211
341
  });
212
342
 
213
343
  it("returns all non-deleted posts", async () => {
214
- await postService.create({ format: "note", body: "first" });
215
- await postService.create({ format: "note", body: "second" });
216
- await postService.create({ format: "note", body: "third" });
344
+ await postService.create({ format: "note", bodyMarkdown: "first" });
345
+ await postService.create({ format: "note", bodyMarkdown: "second" });
346
+ await postService.create({ format: "note", bodyMarkdown: "third" });
217
347
 
218
348
  const posts = await postService.list();
219
349
  expect(posts).toHaveLength(3);
@@ -222,25 +352,50 @@ describe("PostService", () => {
222
352
  it("orders by publishedAt descending", async () => {
223
353
  await postService.create({
224
354
  format: "note",
225
- body: "old",
355
+ bodyMarkdown: "old",
226
356
  publishedAt: 1000,
227
357
  });
228
358
  await postService.create({
229
359
  format: "note",
230
- body: "new",
360
+ bodyMarkdown: "new",
231
361
  publishedAt: 2000,
232
362
  });
233
363
 
234
364
  const posts = await postService.list();
235
- expect(posts[0]?.body).toBe("new");
236
- expect(posts[1]?.body).toBe("old");
365
+ expect(posts[0]?.bodyText).toBe("new");
366
+ expect(posts[1]?.bodyText).toBe("old");
367
+ });
368
+
369
+ it("orders drafts by updatedAt descending", async () => {
370
+ const older = await postService.create({
371
+ format: "note",
372
+ bodyMarkdown: "older draft",
373
+ status: "draft",
374
+ });
375
+
376
+ await new Promise((r) => setTimeout(r, 1100));
377
+
378
+ const newer = await postService.create({
379
+ format: "note",
380
+ bodyMarkdown: "newer draft",
381
+ status: "draft",
382
+ });
383
+
384
+ await new Promise((r) => setTimeout(r, 1100));
385
+ await postService.update(older.id, {
386
+ bodyMarkdown: "older draft edited",
387
+ });
388
+
389
+ const drafts = await postService.list({ status: "draft" });
390
+ expect(drafts[0]?.id).toBe(older.id);
391
+ expect(drafts[1]?.id).toBe(newer.id);
237
392
  });
238
393
 
239
394
  it("filters by format", async () => {
240
- await postService.create({ format: "note", body: "a note" });
395
+ await postService.create({ format: "note", bodyMarkdown: "a note" });
241
396
  await postService.create({
242
397
  format: "link",
243
- body: "a link",
398
+ bodyMarkdown: "a link",
244
399
  title: "Link",
245
400
  url: "https://example.com",
246
401
  });
@@ -253,12 +408,12 @@ describe("PostService", () => {
253
408
  it("filters by status", async () => {
254
409
  await postService.create({
255
410
  format: "note",
256
- body: "published post",
411
+ bodyMarkdown: "published post",
257
412
  status: "published",
258
413
  });
259
414
  await postService.create({
260
415
  format: "note",
261
- body: "draft post",
416
+ bodyMarkdown: "draft post",
262
417
  status: "draft",
263
418
  });
264
419
 
@@ -267,67 +422,149 @@ describe("PostService", () => {
267
422
  expect(published[0]?.status).toBe("published");
268
423
  });
269
424
 
425
+ it("filters by visibility", async () => {
426
+ await postService.create({
427
+ format: "note",
428
+ bodyMarkdown: "public post",
429
+ });
430
+ await postService.create({
431
+ format: "note",
432
+ bodyMarkdown: "unlisted post",
433
+ visibility: "unlisted",
434
+ });
435
+ await postService.create({
436
+ format: "note",
437
+ bodyMarkdown: "private post",
438
+ visibility: "private",
439
+ });
440
+
441
+ const publicPosts = await postService.list({ visibility: "public" });
442
+ expect(publicPosts).toHaveLength(1);
443
+ expect(publicPosts[0]?.visibility).toBe("public");
444
+ expect(publicPosts[0]?.bodyText).toBe("public post");
445
+
446
+ const unlisted = await postService.list({ visibility: "unlisted" });
447
+ expect(unlisted).toHaveLength(1);
448
+ expect(unlisted[0]?.visibility).toBe("unlisted");
449
+ expect(unlisted[0]?.bodyText).toBe("unlisted post");
450
+
451
+ const privatePosts = await postService.list({ visibility: "private" });
452
+ expect(privatePosts).toHaveLength(1);
453
+ expect(privatePosts[0]?.visibility).toBe("private");
454
+ expect(privatePosts[0]?.bodyText).toBe("private post");
455
+ });
456
+
270
457
  it("filters by featured", async () => {
271
458
  await postService.create({
272
459
  format: "note",
273
- body: "featured post",
460
+ bodyMarkdown: "featured post",
274
461
  featured: true,
275
462
  });
276
463
  await postService.create({
277
464
  format: "note",
278
- body: "normal post",
465
+ bodyMarkdown: "normal post",
279
466
  });
280
467
 
281
468
  const featured = await postService.list({ featured: true });
282
469
  expect(featured).toHaveLength(1);
283
- expect(featured[0]?.featured).toBe(1);
284
- expect(featured[0]?.body).toBe("featured post");
470
+ expect(featured[0]?.featuredAt).toBeTypeOf("number");
471
+ expect(featured[0]?.bodyText).toBe("featured post");
285
472
 
286
473
  const notFeatured = await postService.list({ featured: false });
287
474
  expect(notFeatured).toHaveLength(1);
288
- expect(notFeatured[0]?.featured).toBe(0);
289
- expect(notFeatured[0]?.body).toBe("normal post");
475
+ expect(notFeatured[0]?.featuredAt).toBeNull();
476
+ expect(notFeatured[0]?.bodyText).toBe("normal post");
477
+ });
478
+
479
+ it("excludes unlisted posts when requested", async () => {
480
+ await postService.create({
481
+ format: "note",
482
+ bodyMarkdown: "public post",
483
+ });
484
+ await postService.create({
485
+ format: "note",
486
+ bodyMarkdown: "unlisted post",
487
+ visibility: "unlisted",
488
+ });
489
+ await postService.create({
490
+ format: "note",
491
+ bodyMarkdown: "featured post",
492
+ featured: true,
493
+ });
494
+
495
+ const posts = await postService.list({ excludeUnlisted: true });
496
+ expect(posts).toHaveLength(2);
497
+ // Featured posts have visibility "public", so both public and featured appear
498
+ expect(posts.map((p) => p.bodyText).sort()).toEqual([
499
+ "featured post",
500
+ "public post",
501
+ ]);
502
+ });
503
+
504
+ it("excludes private posts when excludePrivate is set", async () => {
505
+ await postService.create({
506
+ format: "note",
507
+ bodyMarkdown: "public post",
508
+ });
509
+ await postService.create({
510
+ format: "note",
511
+ bodyMarkdown: "private post",
512
+ visibility: "private",
513
+ });
514
+ await postService.create({
515
+ format: "note",
516
+ bodyMarkdown: "featured post",
517
+ featured: true,
518
+ });
519
+
520
+ const posts = await postService.list({ excludePrivate: true });
521
+ expect(posts).toHaveLength(2);
522
+ // Featured posts have visibility "public", so both public and featured appear
523
+ expect(posts.map((p) => p.bodyText).sort()).toEqual([
524
+ "featured post",
525
+ "public post",
526
+ ]);
290
527
  });
291
528
 
292
529
  it("filters by pinned", async () => {
293
530
  await postService.create({
294
531
  format: "note",
295
- body: "pinned post",
532
+ bodyMarkdown: "pinned post",
296
533
  pinned: true,
297
534
  });
298
535
  await postService.create({
299
536
  format: "note",
300
- body: "normal post",
537
+ bodyMarkdown: "normal post",
301
538
  });
302
539
 
303
540
  const pinned = await postService.list({ pinned: true });
304
541
  expect(pinned).toHaveLength(1);
305
- expect(pinned[0]?.pinned).toBe(1);
306
- expect(pinned[0]?.body).toBe("pinned post");
542
+ expect(pinned[0]?.pinnedAt).toBeTypeOf("number");
543
+ expect(pinned[0]?.bodyText).toBe("pinned post");
307
544
 
308
545
  const notPinned = await postService.list({ pinned: false });
309
546
  expect(notPinned).toHaveLength(1);
310
- expect(notPinned[0]?.pinned).toBe(0);
311
- expect(notPinned[0]?.body).toBe("normal post");
547
+ expect(notPinned[0]?.pinnedAt).toBeNull();
548
+ expect(notPinned[0]?.bodyText).toBe("normal post");
312
549
  });
313
550
 
314
551
  it("excludes deleted posts by default", async () => {
315
552
  const post = await postService.create({
316
553
  format: "note",
317
- body: "test",
554
+ bodyMarkdown: "test",
318
555
  });
319
- await postService.create({ format: "note", body: "kept" });
556
+ await postService.create({ format: "note", bodyMarkdown: "kept" });
320
557
  await postService.delete(post.id);
321
558
 
322
559
  const posts = await postService.list();
323
560
  expect(posts).toHaveLength(1);
324
- expect(posts[0]?.body).toBe("kept");
561
+ expect(posts[0]?.bodyText).toBe("kept");
325
562
  });
326
563
 
327
564
  it("includes deleted posts when requested", async () => {
328
565
  const post = await postService.create({
329
566
  format: "note",
330
- body: "test",
567
+ bodyMarkdown: "test",
331
568
  });
332
569
  await postService.delete(post.id);
333
570
 
@@ -337,7 +574,7 @@ describe("PostService", () => {
337
574
 
338
575
  it("supports limit", async () => {
339
576
  for (let i = 0; i < 5; i++) {
340
- await postService.create({ format: "note", body: `post ${i}` });
577
+ await postService.create({ format: "note", bodyMarkdown: `post ${i}` });
341
578
  }
342
579
 
343
580
  const posts = await postService.list({ limit: 2 });
@@ -350,7 +587,7 @@ describe("PostService", () => {
350
587
  created.push(
351
588
  await postService.create({
352
589
  format: "note",
353
- body: `post ${i}`,
590
+ bodyMarkdown: `post ${i}`,
354
591
  publishedAt: 1000 + i,
355
592
  }),
356
593
  );
@@ -365,24 +602,24 @@ describe("PostService", () => {
365
602
  it("excludes replies when requested", async () => {
366
603
  const root = await postService.create({
367
604
  format: "note",
368
- body: "root post",
605
+ bodyMarkdown: "root post",
369
606
  });
370
607
  await postService.create({
371
608
  format: "note",
372
- body: "reply",
609
+ bodyMarkdown: "reply",
373
610
  replyToId: root.id,
374
611
  });
375
612
 
376
613
  const posts = await postService.list({ excludeReplies: true });
377
614
  expect(posts).toHaveLength(1);
378
- expect(posts[0]?.body).toBe("root post");
615
+ expect(posts[0]?.bodyText).toBe("root post");
379
616
  });
380
617
 
381
618
  it("supports offset pagination", async () => {
382
619
  for (let i = 0; i < 5; i++) {
383
620
  await postService.create({
384
621
  format: "note",
385
- body: `post ${i}`,
622
+ bodyMarkdown: `post ${i}`,
386
623
  publishedAt: 1000 + i,
387
624
  });
388
625
  }
@@ -390,8 +627,8 @@ describe("PostService", () => {
390
627
  // Skip the first 2 posts (newest), get 2 more
391
628
  const posts = await postService.list({ limit: 2, offset: 2 });
392
629
  expect(posts).toHaveLength(2);
393
- expect(posts[0]?.body).toBe("post 2");
394
- expect(posts[1]?.body).toBe("post 1");
630
+ expect(posts[0]?.bodyText).toBe("post 2");
631
+ expect(posts[1]?.bodyText).toBe("post 1");
395
632
  });
396
633
  });
397
634
 
@@ -402,9 +639,9 @@ describe("PostService", () => {
402
639
  });
403
640
 
404
641
  it("counts all non-deleted posts", async () => {
405
- await postService.create({ format: "note", body: "first" });
406
- await postService.create({ format: "note", body: "second" });
407
- await postService.create({ format: "note", body: "third" });
642
+ await postService.create({ format: "note", bodyMarkdown: "first" });
643
+ await postService.create({ format: "note", bodyMarkdown: "second" });
644
+ await postService.create({ format: "note", bodyMarkdown: "third" });
408
645
 
409
646
  const count = await postService.count();
410
647
  expect(count).toBe(3);
@@ -413,12 +650,12 @@ describe("PostService", () => {
413
650
  it("filters by status", async () => {
414
651
  await postService.create({
415
652
  format: "note",
416
- body: "published",
653
+ bodyMarkdown: "published",
417
654
  status: "published",
418
655
  });
419
656
  await postService.create({
420
657
  format: "note",
421
- body: "draft",
658
+ bodyMarkdown: "draft",
422
659
  status: "draft",
423
660
  });
424
661
 
@@ -426,24 +663,39 @@ describe("PostService", () => {
426
663
  expect(count).toBe(1);
427
664
  });
428
665
 
666
+ it("filters by visibility", async () => {
667
+ await postService.create({
668
+ format: "note",
669
+ bodyMarkdown: "unlisted",
670
+ visibility: "unlisted",
671
+ });
672
+ await postService.create({ format: "note", bodyMarkdown: "normal" });
673
+
674
+ const count = await postService.count({ visibility: "unlisted" });
675
+ expect(count).toBe(1);
676
+ });
677
+
429
678
  it("filters by featured", async () => {
430
679
  await postService.create({
431
680
  format: "note",
432
- body: "featured",
681
+ bodyMarkdown: "featured",
433
682
  featured: true,
434
683
  });
435
- await postService.create({ format: "note", body: "normal" });
684
+ await postService.create({ format: "note", bodyMarkdown: "normal" });
436
685
 
437
- const count = await postService.count({ featured: true });
438
- expect(count).toBe(1);
686
+ const featuredCount = await postService.count({ featured: true });
687
+ expect(featuredCount).toBe(1);
688
+
689
+ const notFeaturedCount = await postService.count({ featured: false });
690
+ expect(notFeaturedCount).toBe(1);
439
691
  });
440
692
 
441
693
  it("excludes deleted posts by default", async () => {
442
694
  const post = await postService.create({
443
695
  format: "note",
444
- body: "to delete",
696
+ bodyMarkdown: "to delete",
445
697
  });
446
- await postService.create({ format: "note", body: "keep" });
698
+ await postService.create({ format: "note", bodyMarkdown: "keep" });
447
699
  await postService.delete(post.id);
448
700
 
449
701
  const count = await postService.count();
@@ -453,11 +705,11 @@ describe("PostService", () => {
453
705
  it("excludes replies when requested", async () => {
454
706
  const root = await postService.create({
455
707
  format: "note",
456
- body: "root",
708
+ bodyMarkdown: "root",
457
709
  });
458
710
  await postService.create({
459
711
  format: "note",
460
- body: "reply",
712
+ bodyMarkdown: "reply",
461
713
  replyToId: root.id,
462
714
  });
463
715
 
@@ -470,22 +722,39 @@ describe("PostService", () => {
470
722
  it("updates post body", async () => {
471
723
  const post = await postService.create({
472
724
  format: "note",
473
- body: "original",
725
+ body: JSON.stringify({
726
+ type: "doc",
727
+ content: [
728
+ {
729
+ type: "paragraph",
730
+ content: [{ type: "text", text: "original" }],
731
+ },
732
+ ],
733
+ }),
734
+ });
735
+
736
+ const updatedBody = JSON.stringify({
737
+ type: "doc",
738
+ content: [
739
+ {
740
+ type: "paragraph",
741
+ content: [{ type: "text", text: "updated content" }],
742
+ },
743
+ ],
474
744
  });
475
-
476
745
  const updated = await postService.update(post.id, {
477
- body: "updated content",
746
+ body: updatedBody,
478
747
  });
479
748
 
480
749
  expect(updated).not.toBeNull();
481
- expect(updated?.body).toBe("updated content");
750
+ expect(updated?.body).toBe(updatedBody);
482
751
  expect(updated?.bodyHtml).toContain("updated content");
483
752
  });
484
753
 
485
754
  it("updates post title", async () => {
486
755
  const post = await postService.create({
487
756
  format: "link",
488
- body: "body",
757
+ bodyMarkdown: "body",
489
758
  title: "Original Title",
490
759
  url: "https://example.com",
491
760
  });
@@ -500,7 +769,7 @@ describe("PostService", () => {
500
769
  it("updates post url", async () => {
501
770
  const post = await postService.create({
502
771
  format: "link",
503
- body: "link post",
772
+ bodyMarkdown: "link post",
504
773
  url: "https://old.com",
505
774
  });
506
775
 
@@ -511,29 +780,29 @@ describe("PostService", () => {
511
780
  expect(updated?.url).toBe("https://new-source.com/path");
512
781
  });
513
782
 
514
- it("clears url when set to null", async () => {
783
+ it("rejects clearing url from a link post", async () => {
515
784
  const post = await postService.create({
516
785
  format: "link",
517
- body: "test",
786
+ bodyMarkdown: "test",
518
787
  url: "https://example.com",
519
788
  });
520
789
 
521
- const updated = await postService.update(post.id, {
522
- url: null,
523
- });
524
-
525
- expect(updated?.url).toBeNull();
790
+ await expect(
791
+ postService.update(post.id, {
792
+ url: null,
793
+ }),
794
+ ).rejects.toThrow("Link posts need a URL.");
526
795
  });
527
796
 
528
797
  it("returns null for non-existent post", async () => {
529
- const result = await postService.update(9999, { body: "test" });
798
+ const result = await postService.update(9999, { bodyMarkdown: "test" });
530
799
  expect(result).toBeNull();
531
800
  });
532
801
 
533
802
  it("updates updatedAt timestamp", async () => {
534
803
  const post = await postService.create({
535
804
  format: "note",
536
- body: "test",
805
+ bodyMarkdown: "test",
537
806
  });
538
807
  const originalUpdatedAt = post.updatedAt;
539
808
 
@@ -541,54 +810,124 @@ describe("PostService", () => {
541
810
  await new Promise((r) => setTimeout(r, 1100));
542
811
 
543
812
  const updated = await postService.update(post.id, {
544
- body: "modified",
813
+ bodyMarkdown: "modified",
545
814
  });
546
815
 
547
816
  expect(updated?.updatedAt).toBeGreaterThanOrEqual(originalUpdatedAt);
548
817
  });
549
818
 
550
- it("updates featured flag", async () => {
819
+ it("sets publishedAt when publishing a draft", async () => {
551
820
  const post = await postService.create({
552
821
  format: "note",
553
- body: "test",
822
+ bodyMarkdown: "draft",
823
+ status: "draft",
554
824
  });
555
825
 
556
- expect(post.featured).toBe(0);
826
+ expect(post.publishedAt).toBeNull();
827
+
828
+ await new Promise((r) => setTimeout(r, 1100));
829
+
830
+ const published = await postService.update(post.id, {
831
+ status: "published",
832
+ });
833
+
834
+ expect(published?.status).toBe("published");
835
+ expect(published?.publishedAt).toBeTypeOf("number");
836
+ expect((published?.publishedAt ?? 0) >= post.updatedAt).toBe(true);
837
+ });
838
+
839
+ it("clears publishedAt when converting a published post back to draft", async () => {
840
+ const post = await postService.create({
841
+ format: "note",
842
+ bodyMarkdown: "published",
843
+ publishedAt: 1706745600,
844
+ });
845
+
846
+ const draft = await postService.update(post.id, {
847
+ status: "draft",
848
+ });
849
+
850
+ expect(draft?.status).toBe("draft");
851
+ expect(draft?.publishedAt).toBeNull();
852
+ });
853
+
854
+ it("rejects setting publishedAt while remaining a draft", async () => {
855
+ const post = await postService.create({
856
+ format: "note",
857
+ bodyMarkdown: "draft",
858
+ status: "draft",
859
+ });
860
+
861
+ await expect(
862
+ postService.update(post.id, {
863
+ publishedAt: 1706745600,
864
+ }),
865
+ ).rejects.toThrow("Drafts can't set a publish time.");
866
+ });
867
+
868
+ it("updates visibility", async () => {
869
+ const post = await postService.create({
870
+ format: "note",
871
+ bodyMarkdown: "test",
872
+ });
873
+
874
+ expect(post.visibility).toBe("public");
557
875
 
558
876
  const updated = await postService.update(post.id, {
877
+ visibility: "unlisted",
878
+ });
879
+
880
+ expect(updated?.visibility).toBe("unlisted");
881
+ });
882
+
883
+ it("updates featured flag", async () => {
884
+ const post = await postService.create({
885
+ format: "note",
886
+ bodyMarkdown: "test",
887
+ });
888
+
889
+ expect(post.featuredAt).toBeNull();
890
+
891
+ const featured = await postService.update(post.id, {
559
892
  featured: true,
560
893
  });
561
894
 
562
- expect(updated?.featured).toBe(1);
895
+ expect(featured?.featuredAt).toBeTypeOf("number");
896
+
897
+ const unfeatured = await postService.update(post.id, {
898
+ featured: false,
899
+ });
900
+
901
+ expect(unfeatured?.featuredAt).toBeNull();
563
902
  });
564
903
 
565
904
  it("updates pinned flag", async () => {
566
905
  const post = await postService.create({
567
906
  format: "note",
568
- body: "test",
907
+ bodyMarkdown: "test",
569
908
  });
570
909
 
571
- expect(post.pinned).toBe(0);
910
+ expect(post.pinnedAt).toBeNull();
572
911
 
573
912
  const updated = await postService.update(post.id, {
574
913
  pinned: true,
575
914
  });
576
915
 
577
- expect(updated?.pinned).toBe(1);
916
+ expect(updated?.pinnedAt).toBeTypeOf("number");
578
917
  });
579
918
 
580
- it("updates path", async () => {
919
+ it("updates slug", async () => {
581
920
  const post = await postService.create({
582
921
  format: "note",
583
- body: "test",
584
- path: "old-path",
922
+ bodyMarkdown: "test",
923
+ slug: "old-slug",
585
924
  });
586
925
 
587
926
  const updated = await postService.update(post.id, {
588
- path: "new-path",
927
+ slug: "new-slug",
589
928
  });
590
929
 
591
- expect(updated?.path).toBe("new-path");
930
+ expect(updated?.slug).toBe("new-slug");
592
931
  });
593
932
 
594
933
  it("updates quoteText and rating", async () => {
@@ -606,13 +945,40 @@ describe("PostService", () => {
606
945
  expect(updated?.quoteText).toBe("Updated quote");
607
946
  expect(updated?.rating).toBe(5);
608
947
  });
948
+
949
+ it("rejects switching a note to link without adding a URL", async () => {
950
+ const post = await postService.create({
951
+ format: "note",
952
+ bodyMarkdown: "test",
953
+ });
954
+
955
+ await expect(
956
+ postService.update(post.id, {
957
+ format: "link",
958
+ }),
959
+ ).rejects.toThrow("Link posts need a URL.");
960
+ });
961
+
962
+ it("rejects switching a link to note without clearing the URL", async () => {
963
+ const post = await postService.create({
964
+ format: "link",
965
+ bodyMarkdown: "test",
966
+ url: "https://example.com",
967
+ });
968
+
969
+ await expect(
970
+ postService.update(post.id, {
971
+ format: "note",
972
+ }),
973
+ ).rejects.toThrow("Notes can't include a URL.");
974
+ });
609
975
  });
610
976
 
611
977
  describe("delete (soft delete)", () => {
612
978
  it("soft-deletes a post", async () => {
613
979
  const post = await postService.create({
614
980
  format: "note",
615
- body: "test",
981
+ bodyMarkdown: "test",
616
982
  });
617
983
 
618
984
  const result = await postService.delete(post.id);
@@ -631,11 +997,11 @@ describe("PostService", () => {
631
997
  it("cascade deletes thread when deleting root post", async () => {
632
998
  const root = await postService.create({
633
999
  format: "note",
634
- body: "root",
1000
+ bodyMarkdown: "root",
635
1001
  });
636
1002
  const reply = await postService.create({
637
1003
  format: "note",
638
- body: "reply",
1004
+ bodyMarkdown: "reply",
639
1005
  replyToId: root.id,
640
1006
  });
641
1007
 
@@ -649,16 +1015,16 @@ describe("PostService", () => {
649
1015
  it("only deletes single post when deleting a reply", async () => {
650
1016
  const root = await postService.create({
651
1017
  format: "note",
652
- body: "root",
1018
+ bodyMarkdown: "root",
653
1019
  });
654
1020
  const reply1 = await postService.create({
655
1021
  format: "note",
656
- body: "reply1",
1022
+ bodyMarkdown: "reply1",
657
1023
  replyToId: root.id,
658
1024
  });
659
1025
  await postService.create({
660
1026
  format: "note",
661
- body: "reply2",
1027
+ bodyMarkdown: "reply2",
662
1028
  replyToId: root.id,
663
1029
  });
664
1030
 
@@ -675,11 +1041,11 @@ describe("PostService", () => {
675
1041
  it("sets threadId on reply to a root post", async () => {
676
1042
  const root = await postService.create({
677
1043
  format: "note",
678
- body: "root",
1044
+ bodyMarkdown: "root",
679
1045
  });
680
1046
  const reply = await postService.create({
681
1047
  format: "note",
682
- body: "reply",
1048
+ bodyMarkdown: "reply",
683
1049
  replyToId: root.id,
684
1050
  });
685
1051
 
@@ -690,16 +1056,16 @@ describe("PostService", () => {
690
1056
  it("inherits threadId from parent in nested replies", async () => {
691
1057
  const root = await postService.create({
692
1058
  format: "note",
693
- body: "root",
1059
+ bodyMarkdown: "root",
694
1060
  });
695
1061
  const reply1 = await postService.create({
696
1062
  format: "note",
697
- body: "reply1",
1063
+ bodyMarkdown: "reply1",
698
1064
  replyToId: root.id,
699
1065
  });
700
1066
  const reply2 = await postService.create({
701
1067
  format: "note",
702
- body: "reply2",
1068
+ bodyMarkdown: "reply2",
703
1069
  replyToId: reply1.id,
704
1070
  });
705
1071
 
@@ -711,63 +1077,121 @@ describe("PostService", () => {
711
1077
  it("inherits status from root post", async () => {
712
1078
  const root = await postService.create({
713
1079
  format: "note",
714
- body: "root",
1080
+ bodyMarkdown: "root",
715
1081
  status: "draft",
716
1082
  });
717
1083
  const reply = await postService.create({
718
1084
  format: "note",
719
- body: "reply",
1085
+ bodyMarkdown: "reply",
1086
+ replyToId: root.id,
1087
+ });
1088
+
1089
+ expect(reply.status).toBe("draft");
1090
+ });
1091
+
1092
+ it("preserves draft status when reply explicitly requests it", async () => {
1093
+ const root = await postService.create({
1094
+ format: "note",
1095
+ bodyMarkdown: "root",
1096
+ status: "published",
1097
+ });
1098
+ const reply = await postService.create({
1099
+ format: "note",
1100
+ bodyMarkdown: "reply",
1101
+ status: "draft",
720
1102
  replyToId: root.id,
721
1103
  });
722
1104
 
723
1105
  expect(reply.status).toBe("draft");
1106
+ expect(reply.threadId).toBe(root.id);
1107
+ });
1108
+
1109
+ it("inherits visibility from root post", async () => {
1110
+ const root = await postService.create({
1111
+ format: "note",
1112
+ bodyMarkdown: "root",
1113
+ visibility: "unlisted",
1114
+ });
1115
+ const reply = await postService.create({
1116
+ format: "note",
1117
+ bodyMarkdown: "reply",
1118
+ replyToId: root.id,
1119
+ });
1120
+
1121
+ expect(reply.visibility).toBe("unlisted");
1122
+ });
1123
+
1124
+ it("stores reply visibility as null and resolves it from the root", async () => {
1125
+ const root = await postService.create({
1126
+ format: "note",
1127
+ bodyMarkdown: "root",
1128
+ visibility: "private",
1129
+ });
1130
+ const reply = await postService.create({
1131
+ format: "note",
1132
+ bodyMarkdown: "reply",
1133
+ replyToId: root.id,
1134
+ });
1135
+
1136
+ const rows = await db
1137
+ .select({ visibility: posts.visibility })
1138
+ .from(posts)
1139
+ .where(eq(posts.id, reply.id))
1140
+ .limit(1);
1141
+
1142
+ expect(rows[0]?.visibility).toBeNull();
1143
+ expect(reply.visibility).toBe("private");
724
1144
  });
725
1145
 
726
- it("inherits featured from root post", async () => {
1146
+ it("does not inherit featuredAt from root post", async () => {
727
1147
  const root = await postService.create({
728
1148
  format: "note",
729
- body: "root",
1149
+ bodyMarkdown: "root",
730
1150
  featured: true,
731
1151
  });
1152
+
1153
+ expect(root.featuredAt).toBeTypeOf("number");
1154
+
732
1155
  const reply = await postService.create({
733
1156
  format: "note",
734
- body: "reply",
1157
+ bodyMarkdown: "reply",
735
1158
  replyToId: root.id,
736
1159
  });
737
1160
 
738
- expect(reply.featured).toBe(1);
1161
+ // featuredAt is an independent property — replies should NOT inherit it
1162
+ expect(reply.featuredAt).toBeNull();
739
1163
  });
740
1164
 
741
1165
  it("getThread returns all posts in a thread", async () => {
742
1166
  const root = await postService.create({
743
1167
  format: "note",
744
- body: "root",
1168
+ bodyMarkdown: "root",
745
1169
  });
746
1170
  await postService.create({
747
1171
  format: "note",
748
- body: "reply1",
1172
+ bodyMarkdown: "reply1",
749
1173
  replyToId: root.id,
750
1174
  });
751
1175
  await postService.create({
752
1176
  format: "note",
753
- body: "reply2",
1177
+ bodyMarkdown: "reply2",
754
1178
  replyToId: root.id,
755
1179
  });
756
1180
 
757
1181
  const thread = await postService.getThread(root.id);
758
1182
  expect(thread).toHaveLength(3);
759
1183
  // Ordered by createdAt
760
- expect(thread[0]?.body).toBe("root");
1184
+ expect(thread[0]?.bodyText).toBe("root");
761
1185
  });
762
1186
 
763
1187
  it("getThread excludes deleted posts", async () => {
764
1188
  const root = await postService.create({
765
1189
  format: "note",
766
- body: "root",
1190
+ bodyMarkdown: "root",
767
1191
  });
768
1192
  const reply = await postService.create({
769
1193
  format: "note",
770
- body: "reply",
1194
+ bodyMarkdown: "reply",
771
1195
  replyToId: root.id,
772
1196
  });
773
1197
 
@@ -780,12 +1204,12 @@ describe("PostService", () => {
780
1204
  it("cascades status changes from root to thread", async () => {
781
1205
  const root = await postService.create({
782
1206
  format: "note",
783
- body: "root",
1207
+ bodyMarkdown: "root",
784
1208
  status: "published",
785
1209
  });
786
1210
  await postService.create({
787
1211
  format: "note",
788
- body: "reply",
1212
+ bodyMarkdown: "reply",
789
1213
  replyToId: root.id,
790
1214
  });
791
1215
 
@@ -794,27 +1218,151 @@ describe("PostService", () => {
794
1218
  const thread = await postService.getThread(root.id);
795
1219
  for (const post of thread) {
796
1220
  expect(post.status).toBe("draft");
1221
+ expect(post.publishedAt).toBeNull();
1222
+ }
1223
+ });
1224
+
1225
+ it("publishing a draft thread stamps publishedAt on all posts", async () => {
1226
+ const root = await postService.create({
1227
+ format: "note",
1228
+ bodyMarkdown: "root",
1229
+ status: "draft",
1230
+ });
1231
+ await postService.create({
1232
+ format: "note",
1233
+ bodyMarkdown: "reply",
1234
+ replyToId: root.id,
1235
+ });
1236
+
1237
+ await new Promise((r) => setTimeout(r, 1100));
1238
+ await postService.update(root.id, { status: "published" });
1239
+
1240
+ const thread = await postService.getThread(root.id);
1241
+ for (const post of thread) {
1242
+ expect(post.status).toBe("published");
1243
+ expect(post.publishedAt).toBeTypeOf("number");
797
1244
  }
798
1245
  });
799
1246
 
800
- it("cascades featured changes from root to thread", async () => {
1247
+ it("cascades visibility changes from root to thread", async () => {
801
1248
  const root = await postService.create({
802
1249
  format: "note",
803
- body: "root",
1250
+ bodyMarkdown: "root",
804
1251
  });
805
1252
  await postService.create({
806
1253
  format: "note",
807
- body: "reply",
1254
+ bodyMarkdown: "reply",
808
1255
  replyToId: root.id,
809
1256
  });
810
1257
 
811
- await postService.update(root.id, { featured: true });
1258
+ await postService.update(root.id, { visibility: "unlisted" });
812
1259
 
813
1260
  const thread = await postService.getThread(root.id);
814
1261
  for (const post of thread) {
815
- expect(post.featured).toBe(1);
1262
+ expect(post.visibility).toBe("unlisted");
816
1263
  }
817
1264
  });
1265
+
1266
+ it("filters replies by the root post visibility", async () => {
1267
+ const unlistedRoot = await postService.create({
1268
+ format: "note",
1269
+ bodyMarkdown: "root",
1270
+ visibility: "unlisted",
1271
+ });
1272
+ const unlistedReply = await postService.create({
1273
+ format: "note",
1274
+ bodyMarkdown: "reply",
1275
+ replyToId: unlistedRoot.id,
1276
+ });
1277
+ await postService.create({
1278
+ format: "note",
1279
+ bodyMarkdown: "public root",
1280
+ });
1281
+
1282
+ const postsByVisibility = await postService.list({
1283
+ visibility: "unlisted",
1284
+ });
1285
+
1286
+ expect(postsByVisibility.map((post) => post.id)).toEqual([
1287
+ unlistedReply.id,
1288
+ unlistedRoot.id,
1289
+ ]);
1290
+ });
1291
+
1292
+ it("rejects visibility changes on thread replies", async () => {
1293
+ const root = await postService.create({
1294
+ format: "note",
1295
+ bodyMarkdown: "root",
1296
+ });
1297
+ const reply = await postService.create({
1298
+ format: "note",
1299
+ bodyMarkdown: "reply",
1300
+ replyToId: root.id,
1301
+ });
1302
+
1303
+ await expect(
1304
+ postService.update(reply.id, { visibility: "unlisted" }),
1305
+ ).rejects.toThrow(
1306
+ "Cannot change visibility of a thread reply. Update the root post instead.",
1307
+ );
1308
+ });
1309
+
1310
+ it("allows featuring a thread reply", async () => {
1311
+ const root = await postService.create({
1312
+ format: "note",
1313
+ bodyMarkdown: "root",
1314
+ });
1315
+ const reply = await postService.create({
1316
+ format: "note",
1317
+ bodyMarkdown: "reply",
1318
+ replyToId: root.id,
1319
+ });
1320
+
1321
+ // Featured is independent of visibility — replies can be featured
1322
+ const updated = await postService.update(reply.id, { featured: true });
1323
+ expect(updated?.featuredAt).toBeTypeOf("number");
1324
+
1325
+ const unfeatured = await postService.update(reply.id, {
1326
+ featured: false,
1327
+ });
1328
+ expect(unfeatured?.featuredAt).toBeNull();
1329
+ });
1330
+
1331
+ it("rejects creating a pinned thread reply", async () => {
1332
+ const root = await postService.create({
1333
+ format: "note",
1334
+ bodyMarkdown: "root",
1335
+ });
1336
+
1337
+ await expect(
1338
+ postService.create({
1339
+ format: "note",
1340
+ bodyMarkdown: "reply",
1341
+ replyToId: root.id,
1342
+ pinned: true,
1343
+ }),
1344
+ ).rejects.toThrow(
1345
+ "Cannot pin a thread reply. Pin the root post instead.",
1346
+ );
1347
+ });
1348
+
1349
+ it("rejects pinning a thread reply", async () => {
1350
+ const root = await postService.create({
1351
+ format: "note",
1352
+ bodyMarkdown: "root",
1353
+ });
1354
+ const reply = await postService.create({
1355
+ format: "note",
1356
+ bodyMarkdown: "reply",
1357
+ replyToId: root.id,
1358
+ });
1359
+
1360
+ await expect(
1361
+ postService.update(reply.id, { pinned: true }),
1362
+ ).rejects.toThrow(
1363
+ "Cannot pin a thread reply. Pin the root post instead.",
1364
+ );
1365
+ });
818
1366
  });
819
1367
 
820
1368
  describe("getReplyCounts", () => {
@@ -826,16 +1374,16 @@ describe("PostService", () => {
826
1374
  it("returns reply counts for posts", async () => {
827
1375
  const root = await postService.create({
828
1376
  format: "note",
829
- body: "root",
1377
+ bodyMarkdown: "root",
830
1378
  });
831
1379
  await postService.create({
832
1380
  format: "note",
833
- body: "reply1",
1381
+ bodyMarkdown: "reply1",
834
1382
  replyToId: root.id,
835
1383
  });
836
1384
  await postService.create({
837
1385
  format: "note",
838
- body: "reply2",
1386
+ bodyMarkdown: "reply2",
839
1387
  replyToId: root.id,
840
1388
  });
841
1389
 
@@ -846,7 +1394,7 @@ describe("PostService", () => {
846
1394
  it("returns 0 (missing) for posts without replies", async () => {
847
1395
  const post = await postService.create({
848
1396
  format: "note",
849
- body: "no replies",
1397
+ bodyMarkdown: "no replies",
850
1398
  });
851
1399
 
852
1400
  const counts = await postService.getReplyCounts([post.id]);
@@ -856,16 +1404,16 @@ describe("PostService", () => {
856
1404
  it("excludes deleted replies from count", async () => {
857
1405
  const root = await postService.create({
858
1406
  format: "note",
859
- body: "root",
1407
+ bodyMarkdown: "root",
860
1408
  });
861
1409
  const reply = await postService.create({
862
1410
  format: "note",
863
- body: "reply",
1411
+ bodyMarkdown: "reply",
864
1412
  replyToId: root.id,
865
1413
  });
866
1414
  await postService.create({
867
1415
  format: "note",
868
- body: "reply2",
1416
+ bodyMarkdown: "reply2",
869
1417
  replyToId: root.id,
870
1418
  });
871
1419
 
@@ -874,100 +1422,122 @@ describe("PostService", () => {
874
1422
  const counts = await postService.getReplyCounts([root.id]);
875
1423
  expect(counts.get(root.id)).toBe(1);
876
1424
  });
877
- });
878
1425
 
879
- describe("path registry integration", () => {
880
- it("claims path on create", async () => {
881
- const post = await postService.create({
1426
+ it("excludes draft replies from count", async () => {
1427
+ const root = await postService.create({
882
1428
  format: "note",
883
- body: "test",
884
- path: "my-post",
1429
+ bodyMarkdown: "root",
885
1430
  });
886
-
887
- const entry = await pathRegistry.getByPath("my-post");
888
- expect(entry).not.toBeNull();
889
- expect(entry?.ownerType).toBe("post");
890
- expect(entry?.ownerId).toBe(post.id);
891
- });
892
-
893
- it("does not claim when no path provided", async () => {
894
1431
  await postService.create({
895
1432
  format: "note",
896
- body: "test",
1433
+ bodyMarkdown: "published reply",
1434
+ replyToId: root.id,
1435
+ });
1436
+ await postService.create({
1437
+ format: "note",
1438
+ bodyMarkdown: "draft reply",
1439
+ replyToId: root.id,
1440
+ status: "draft",
897
1441
  });
898
1442
 
899
- // No path registry entries should exist
900
- expect(await pathRegistry.isAvailable("test")).toBe(true);
901
- });
902
-
903
- it("rejects path that conflicts with a page slug", async () => {
904
- await pageService.create({ slug: "about", title: "About" });
905
-
906
- await expect(
907
- postService.create({ format: "note", body: "test", path: "about" }),
908
- ).rejects.toThrow(ConflictError);
909
- });
910
-
911
- it("rejects reserved path on create", async () => {
912
- await expect(
913
- postService.create({ format: "note", body: "test", path: "dash" }),
914
- ).rejects.toThrow(ValidationError);
1443
+ const counts = await postService.getReplyCounts([root.id]);
1444
+ expect(counts.get(root.id)).toBe(1);
915
1445
  });
1446
+ });
916
1447
 
917
- it("releases old path and claims new on update", async () => {
1448
+ describe("lastActivityAt (thread bump-to-top)", () => {
1449
+ it("sets lastActivityAt equal to publishedAt for non-thread posts", async () => {
918
1450
  const post = await postService.create({
919
1451
  format: "note",
920
- body: "test",
921
- path: "old-path",
1452
+ bodyMarkdown: "standalone",
1453
+ publishedAt: 5000,
922
1454
  });
923
1455
 
924
- await postService.update(post.id, { path: "new-path" });
925
-
926
- expect(await pathRegistry.isAvailable("old-path")).toBe(true);
927
- expect(await pathRegistry.isAvailable("new-path")).toBe(false);
1456
+ expect(post.lastActivityAt).toBe(5000);
928
1457
  });
929
1458
 
930
- it("releases path when cleared to null on update", async () => {
931
- const post = await postService.create({
1459
+ it("updates root lastActivityAt when a reply is created", async () => {
1460
+ const root = await postService.create({
932
1461
  format: "note",
933
- body: "test",
934
- path: "my-path",
1462
+ bodyMarkdown: "root",
1463
+ publishedAt: 1000,
935
1464
  });
1465
+ expect(root.lastActivityAt).toBe(1000);
936
1466
 
937
- await postService.update(post.id, { path: null });
1467
+ await postService.create({
1468
+ format: "note",
1469
+ bodyMarkdown: "reply",
1470
+ replyToId: root.id,
1471
+ publishedAt: 9000,
1472
+ });
938
1473
 
939
- expect(await pathRegistry.isAvailable("my-path")).toBe(true);
1474
+ const updatedRoot = await postService.getById(root.id);
1475
+ expect(updatedRoot?.lastActivityAt).toBe(9000);
940
1476
  });
941
1477
 
942
- it("releases path on soft-delete", async () => {
943
- const post = await postService.create({
1478
+ it("list returns thread root bumped to top after reply", async () => {
1479
+ const oldPost = await postService.create({
1480
+ format: "note",
1481
+ bodyMarkdown: "old thread root",
1482
+ publishedAt: 1000,
1483
+ });
1484
+ await postService.create({
944
1485
  format: "note",
945
- body: "test",
946
- path: "my-path",
1486
+ bodyMarkdown: "newer standalone",
1487
+ publishedAt: 5000,
947
1488
  });
948
1489
 
949
- await postService.delete(post.id);
1490
+ // Reply to old post with a newer timestamp — should bump it above standalone
1491
+ await postService.create({
1492
+ format: "note",
1493
+ bodyMarkdown: "reply",
1494
+ replyToId: oldPost.id,
1495
+ publishedAt: 9000,
1496
+ });
950
1497
 
951
- expect(await pathRegistry.isAvailable("my-path")).toBe(true);
1498
+ const listed = await postService.list({ excludeReplies: true });
1499
+ expect(listed[0]?.bodyText).toBe("old thread root");
1500
+ expect(listed[1]?.bodyText).toBe("newer standalone");
952
1501
  });
953
1502
 
954
- it("releases paths for all thread posts on root delete", async () => {
1503
+ it("recalculates root lastActivityAt when a reply is deleted", async () => {
955
1504
  const root = await postService.create({
956
1505
  format: "note",
957
- body: "root",
958
- path: "thread-root",
1506
+ bodyMarkdown: "root",
1507
+ publishedAt: 1000,
1508
+ });
1509
+ const reply1 = await postService.create({
1510
+ format: "note",
1511
+ bodyMarkdown: "reply1",
1512
+ replyToId: root.id,
1513
+ publishedAt: 3000,
959
1514
  });
960
1515
  await postService.create({
961
1516
  format: "note",
962
- body: "reply",
963
- path: "thread-reply",
1517
+ bodyMarkdown: "reply2",
964
1518
  replyToId: root.id,
1519
+ publishedAt: 5000,
965
1520
  });
966
1521
 
967
- await postService.delete(root.id);
1522
+ // Root should be bumped to latest reply
1523
+ let updatedRoot = await postService.getById(root.id);
1524
+ expect(updatedRoot?.lastActivityAt).toBe(5000);
1525
+
1526
+ // Delete the latest reply — root should fall back to reply1's time
1527
+ const reply2 = (await postService.list({ threadId: root.id })).find(
1528
+ (p) => p.bodyText === "reply2",
1529
+ );
1530
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- test setup guarantees reply2 exists
1531
+ await postService.delete(reply2!.id);
1532
+
1533
+ updatedRoot = await postService.getById(root.id);
1534
+ expect(updatedRoot?.lastActivityAt).toBe(3000);
1535
+
1536
+ // Delete the remaining reply — root should fall back to its own publishedAt
1537
+ await postService.delete(reply1.id);
968
1538
 
969
- expect(await pathRegistry.isAvailable("thread-root")).toBe(true);
970
- expect(await pathRegistry.isAvailable("thread-reply")).toBe(true);
1539
+ updatedRoot = await postService.getById(root.id);
1540
+ expect(updatedRoot?.lastActivityAt).toBe(1000);
971
1541
  });
972
1542
  });
973
1543
  });