@jant/core 0.3.36 → 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 (271) 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/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -1,23 +1,19 @@
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", () => {
@@ -36,17 +32,21 @@ describe("PostService", () => {
36
32
  body,
37
33
  });
38
34
 
39
- 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
+ );
40
39
  expect(post.format).toBe("note");
41
40
  expect(post.body).toBe(body);
42
41
  expect(post.status).toBe("published"); // default
43
- expect(post.visibility).toBe("listed");
44
- expect(post.pinned).toBe(0);
42
+ expect(post.visibility).toBe("public");
43
+ expect(post.pinnedAt).toBeNull();
45
44
  expect(post.bodyHtml).toContain("<p>Hello world</p>");
46
45
  expect(post.deletedAt).toBeNull();
46
+ expect(post.threadId).toBe(post.id);
47
47
  });
48
48
 
49
- it("creates a post with all fields", async () => {
49
+ it("creates a link post with commentary", async () => {
50
50
  const body = JSON.stringify({
51
51
  type: "doc",
52
52
  content: [
@@ -66,22 +66,23 @@ describe("PostService", () => {
66
66
  title: "My Link",
67
67
  body,
68
68
  status: "published",
69
- visibility: "featured",
69
+ visibility: "public",
70
+ featured: true,
70
71
  pinned: true,
71
- path: "my-link",
72
+ slug: "my-link",
72
73
  url: "https://example.com/source",
73
- quoteText: "A notable quote",
74
74
  rating: 5,
75
75
  });
76
76
 
77
77
  expect(post.format).toBe("link");
78
78
  expect(post.title).toBe("My Link");
79
79
  expect(post.status).toBe("published");
80
- expect(post.visibility).toBe("featured");
81
- expect(post.pinned).toBe(1);
82
- 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");
83
84
  expect(post.url).toBe("https://example.com/source");
84
- expect(post.quoteText).toBe("A notable quote");
85
+ expect(post.quoteText).toBeNull();
85
86
  expect(post.rating).toBe(5);
86
87
  expect(post.bodyHtml).toContain("<h1>");
87
88
  });
@@ -115,7 +116,7 @@ describe("PostService", () => {
115
116
  it("sets publishedAt and timestamps", async () => {
116
117
  const post = await postService.create({
117
118
  format: "note",
118
- body: "test",
119
+ bodyMarkdown: "test",
119
120
  });
120
121
 
121
122
  expect(post.publishedAt).toBeGreaterThan(0);
@@ -127,31 +128,33 @@ describe("PostService", () => {
127
128
  const customTime = 1706745600;
128
129
  const post = await postService.create({
129
130
  format: "note",
130
- body: "test",
131
+ bodyMarkdown: "test",
131
132
  publishedAt: customTime,
132
133
  });
133
134
 
134
135
  expect(post.publishedAt).toBe(customTime);
135
136
  });
136
137
 
137
- it("creates incrementing IDs", async () => {
138
+ it("creates unique UUIDv7 IDs that sort chronologically", async () => {
138
139
  const post1 = await postService.create({
139
140
  format: "note",
140
- body: "first",
141
+ bodyMarkdown: "first",
141
142
  });
142
143
  const post2 = await postService.create({
143
144
  format: "note",
144
- body: "second",
145
+ bodyMarkdown: "second",
145
146
  });
146
147
 
147
- 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);
148
151
  });
149
152
 
150
153
  it("creates a quote post", async () => {
151
154
  const post = await postService.create({
152
155
  format: "quote",
153
156
  quoteText: "To be or not to be",
154
- body: "Shakespeare's famous line",
157
+ bodyMarkdown: "Shakespeare's famous line",
155
158
  url: "https://example.com/hamlet",
156
159
  });
157
160
 
@@ -163,11 +166,110 @@ describe("PostService", () => {
163
166
  it("creates a draft post", async () => {
164
167
  const post = await postService.create({
165
168
  format: "note",
166
- body: "draft content",
169
+ bodyMarkdown: "draft content",
167
170
  status: "draft",
168
171
  });
169
172
 
170
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);
171
273
  });
172
274
  });
173
275
 
@@ -175,13 +277,13 @@ describe("PostService", () => {
175
277
  it("returns a post by ID", async () => {
176
278
  const created = await postService.create({
177
279
  format: "note",
178
- body: "test",
280
+ bodyMarkdown: "test",
179
281
  });
180
282
 
181
283
  const found = await postService.getById(created.id);
182
284
  expect(found).not.toBeNull();
183
285
  expect(found?.id).toBe(created.id);
184
- expect(found?.body).toBe("test");
286
+ expect(found?.bodyText).toBe("test");
185
287
  });
186
288
 
187
289
  it("returns null for non-existent ID", async () => {
@@ -192,7 +294,7 @@ describe("PostService", () => {
192
294
  it("excludes soft-deleted posts", async () => {
193
295
  const post = await postService.create({
194
296
  format: "note",
195
- body: "test",
297
+ bodyMarkdown: "test",
196
298
  });
197
299
  await postService.delete(post.id);
198
300
 
@@ -201,47 +303,35 @@ describe("PostService", () => {
201
303
  });
202
304
  });
203
305
 
204
- describe("getByPath", () => {
205
- it("returns a post by path", async () => {
306
+ describe("getBySlug", () => {
307
+ it("returns a post by slug", async () => {
206
308
  await postService.create({
207
309
  format: "note",
208
- body: "About page",
209
- path: "about",
310
+ bodyMarkdown: "About page",
311
+ slug: "about",
210
312
  });
211
313
 
212
- const found = await postService.getByPath("about");
314
+ const found = await postService.getBySlug("about");
213
315
  expect(found).not.toBeNull();
214
- expect(found?.path).toBe("about");
316
+ expect(found?.slug).toBe("about");
215
317
  });
216
318
 
217
- it("returns null for non-existent path", async () => {
218
- const found = await postService.getByPath("nonexistent");
319
+ it("returns null for non-existent slug", async () => {
320
+ const found = await postService.getBySlug("nonexistent");
219
321
  expect(found).toBeNull();
220
322
  });
221
323
 
222
324
  it("excludes soft-deleted posts", async () => {
223
325
  const post = await postService.create({
224
326
  format: "note",
225
- body: "test",
226
- path: "test-page",
327
+ bodyMarkdown: "test",
328
+ slug: "test-page",
227
329
  });
228
330
  await postService.delete(post.id);
229
331
 
230
- const found = await postService.getByPath("test-page");
332
+ const found = await postService.getBySlug("test-page");
231
333
  expect(found).toBeNull();
232
334
  });
233
-
234
- it("finds a post with a multi-level path", async () => {
235
- await postService.create({
236
- format: "note",
237
- body: "Blog migration",
238
- path: "2024/01/my-post",
239
- });
240
-
241
- const found = await postService.getByPath("2024/01/my-post");
242
- expect(found).not.toBeNull();
243
- expect(found?.path).toBe("2024/01/my-post");
244
- });
245
335
  });
246
336
 
247
337
  describe("list", () => {
@@ -251,9 +341,9 @@ describe("PostService", () => {
251
341
  });
252
342
 
253
343
  it("returns all non-deleted posts", async () => {
254
- await postService.create({ format: "note", body: "first" });
255
- await postService.create({ format: "note", body: "second" });
256
- 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" });
257
347
 
258
348
  const posts = await postService.list();
259
349
  expect(posts).toHaveLength(3);
@@ -262,25 +352,50 @@ describe("PostService", () => {
262
352
  it("orders by publishedAt descending", async () => {
263
353
  await postService.create({
264
354
  format: "note",
265
- body: "old",
355
+ bodyMarkdown: "old",
266
356
  publishedAt: 1000,
267
357
  });
268
358
  await postService.create({
269
359
  format: "note",
270
- body: "new",
360
+ bodyMarkdown: "new",
271
361
  publishedAt: 2000,
272
362
  });
273
363
 
274
364
  const posts = await postService.list();
275
- expect(posts[0]?.body).toBe("new");
276
- 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);
277
392
  });
278
393
 
279
394
  it("filters by format", async () => {
280
- await postService.create({ format: "note", body: "a note" });
395
+ await postService.create({ format: "note", bodyMarkdown: "a note" });
281
396
  await postService.create({
282
397
  format: "link",
283
- body: "a link",
398
+ bodyMarkdown: "a link",
284
399
  title: "Link",
285
400
  url: "https://example.com",
286
401
  });
@@ -293,12 +408,12 @@ describe("PostService", () => {
293
408
  it("filters by status", async () => {
294
409
  await postService.create({
295
410
  format: "note",
296
- body: "published post",
411
+ bodyMarkdown: "published post",
297
412
  status: "published",
298
413
  });
299
414
  await postService.create({
300
415
  format: "note",
301
- body: "draft post",
416
+ bodyMarkdown: "draft post",
302
417
  status: "draft",
303
418
  });
304
419
 
@@ -310,98 +425,146 @@ describe("PostService", () => {
310
425
  it("filters by visibility", async () => {
311
426
  await postService.create({
312
427
  format: "note",
313
- body: "featured post",
314
- visibility: "featured",
428
+ bodyMarkdown: "public post",
315
429
  });
316
430
  await postService.create({
317
431
  format: "note",
318
- body: "normal post",
432
+ bodyMarkdown: "unlisted post",
433
+ visibility: "unlisted",
319
434
  });
320
435
  await postService.create({
321
436
  format: "note",
322
- body: "unlisted post",
323
- visibility: "unlisted",
437
+ bodyMarkdown: "private post",
438
+ visibility: "private",
324
439
  });
325
440
 
326
- const featured = await postService.list({ visibility: "featured" });
327
- expect(featured).toHaveLength(1);
328
- expect(featured[0]?.visibility).toBe("featured");
329
- expect(featured[0]?.body).toBe("featured post");
330
-
331
- const listed = await postService.list({ visibility: "listed" });
332
- expect(listed).toHaveLength(1);
333
- expect(listed[0]?.visibility).toBe("listed");
334
- expect(listed[0]?.body).toBe("normal post");
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");
335
445
 
336
446
  const unlisted = await postService.list({ visibility: "unlisted" });
337
447
  expect(unlisted).toHaveLength(1);
338
448
  expect(unlisted[0]?.visibility).toBe("unlisted");
339
- expect(unlisted[0]?.body).toBe("unlisted post");
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
+
457
+ it("filters by featured", async () => {
458
+ await postService.create({
459
+ format: "note",
460
+ bodyMarkdown: "featured post",
461
+ featured: true,
462
+ });
463
+ await postService.create({
464
+ format: "note",
465
+ bodyMarkdown: "normal post",
466
+ });
467
+
468
+ const featured = await postService.list({ featured: true });
469
+ expect(featured).toHaveLength(1);
470
+ expect(featured[0]?.featuredAt).toBeTypeOf("number");
471
+ expect(featured[0]?.bodyText).toBe("featured post");
472
+
473
+ const notFeatured = await postService.list({ featured: false });
474
+ expect(notFeatured).toHaveLength(1);
475
+ expect(notFeatured[0]?.featuredAt).toBeNull();
476
+ expect(notFeatured[0]?.bodyText).toBe("normal post");
340
477
  });
341
478
 
342
479
  it("excludes unlisted posts when requested", async () => {
343
480
  await postService.create({
344
481
  format: "note",
345
- body: "listed post",
482
+ bodyMarkdown: "public post",
346
483
  });
347
484
  await postService.create({
348
485
  format: "note",
349
- body: "unlisted post",
486
+ bodyMarkdown: "unlisted post",
350
487
  visibility: "unlisted",
351
488
  });
352
489
  await postService.create({
353
490
  format: "note",
354
- body: "featured post",
355
- visibility: "featured",
491
+ bodyMarkdown: "featured post",
492
+ featured: true,
356
493
  });
357
494
 
358
495
  const posts = await postService.list({ excludeUnlisted: true });
359
496
  expect(posts).toHaveLength(2);
360
- expect(posts.map((p) => p.body).sort()).toEqual([
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([
361
524
  "featured post",
362
- "listed post",
525
+ "public post",
363
526
  ]);
364
527
  });
365
528
 
366
529
  it("filters by pinned", async () => {
367
530
  await postService.create({
368
531
  format: "note",
369
- body: "pinned post",
532
+ bodyMarkdown: "pinned post",
370
533
  pinned: true,
371
534
  });
372
535
  await postService.create({
373
536
  format: "note",
374
- body: "normal post",
537
+ bodyMarkdown: "normal post",
375
538
  });
376
539
 
377
540
  const pinned = await postService.list({ pinned: true });
378
541
  expect(pinned).toHaveLength(1);
379
- expect(pinned[0]?.pinned).toBe(1);
380
- expect(pinned[0]?.body).toBe("pinned post");
542
+ expect(pinned[0]?.pinnedAt).toBeTypeOf("number");
543
+ expect(pinned[0]?.bodyText).toBe("pinned post");
381
544
 
382
545
  const notPinned = await postService.list({ pinned: false });
383
546
  expect(notPinned).toHaveLength(1);
384
- expect(notPinned[0]?.pinned).toBe(0);
385
- expect(notPinned[0]?.body).toBe("normal post");
547
+ expect(notPinned[0]?.pinnedAt).toBeNull();
548
+ expect(notPinned[0]?.bodyText).toBe("normal post");
386
549
  });
387
550
 
388
551
  it("excludes deleted posts by default", async () => {
389
552
  const post = await postService.create({
390
553
  format: "note",
391
- body: "test",
554
+ bodyMarkdown: "test",
392
555
  });
393
- await postService.create({ format: "note", body: "kept" });
556
+ await postService.create({ format: "note", bodyMarkdown: "kept" });
394
557
  await postService.delete(post.id);
395
558
 
396
559
  const posts = await postService.list();
397
560
  expect(posts).toHaveLength(1);
398
- expect(posts[0]?.body).toBe("kept");
561
+ expect(posts[0]?.bodyText).toBe("kept");
399
562
  });
400
563
 
401
564
  it("includes deleted posts when requested", async () => {
402
565
  const post = await postService.create({
403
566
  format: "note",
404
- body: "test",
567
+ bodyMarkdown: "test",
405
568
  });
406
569
  await postService.delete(post.id);
407
570
 
@@ -411,7 +574,7 @@ describe("PostService", () => {
411
574
 
412
575
  it("supports limit", async () => {
413
576
  for (let i = 0; i < 5; i++) {
414
- await postService.create({ format: "note", body: `post ${i}` });
577
+ await postService.create({ format: "note", bodyMarkdown: `post ${i}` });
415
578
  }
416
579
 
417
580
  const posts = await postService.list({ limit: 2 });
@@ -424,7 +587,7 @@ describe("PostService", () => {
424
587
  created.push(
425
588
  await postService.create({
426
589
  format: "note",
427
- body: `post ${i}`,
590
+ bodyMarkdown: `post ${i}`,
428
591
  publishedAt: 1000 + i,
429
592
  }),
430
593
  );
@@ -439,24 +602,24 @@ describe("PostService", () => {
439
602
  it("excludes replies when requested", async () => {
440
603
  const root = await postService.create({
441
604
  format: "note",
442
- body: "root post",
605
+ bodyMarkdown: "root post",
443
606
  });
444
607
  await postService.create({
445
608
  format: "note",
446
- body: "reply",
609
+ bodyMarkdown: "reply",
447
610
  replyToId: root.id,
448
611
  });
449
612
 
450
613
  const posts = await postService.list({ excludeReplies: true });
451
614
  expect(posts).toHaveLength(1);
452
- expect(posts[0]?.body).toBe("root post");
615
+ expect(posts[0]?.bodyText).toBe("root post");
453
616
  });
454
617
 
455
618
  it("supports offset pagination", async () => {
456
619
  for (let i = 0; i < 5; i++) {
457
620
  await postService.create({
458
621
  format: "note",
459
- body: `post ${i}`,
622
+ bodyMarkdown: `post ${i}`,
460
623
  publishedAt: 1000 + i,
461
624
  });
462
625
  }
@@ -464,8 +627,8 @@ describe("PostService", () => {
464
627
  // Skip the first 2 posts (newest), get 2 more
465
628
  const posts = await postService.list({ limit: 2, offset: 2 });
466
629
  expect(posts).toHaveLength(2);
467
- expect(posts[0]?.body).toBe("post 2");
468
- expect(posts[1]?.body).toBe("post 1");
630
+ expect(posts[0]?.bodyText).toBe("post 2");
631
+ expect(posts[1]?.bodyText).toBe("post 1");
469
632
  });
470
633
  });
471
634
 
@@ -476,9 +639,9 @@ describe("PostService", () => {
476
639
  });
477
640
 
478
641
  it("counts all non-deleted posts", async () => {
479
- await postService.create({ format: "note", body: "first" });
480
- await postService.create({ format: "note", body: "second" });
481
- 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" });
482
645
 
483
646
  const count = await postService.count();
484
647
  expect(count).toBe(3);
@@ -487,12 +650,12 @@ describe("PostService", () => {
487
650
  it("filters by status", async () => {
488
651
  await postService.create({
489
652
  format: "note",
490
- body: "published",
653
+ bodyMarkdown: "published",
491
654
  status: "published",
492
655
  });
493
656
  await postService.create({
494
657
  format: "note",
495
- body: "draft",
658
+ bodyMarkdown: "draft",
496
659
  status: "draft",
497
660
  });
498
661
 
@@ -503,21 +666,36 @@ describe("PostService", () => {
503
666
  it("filters by visibility", async () => {
504
667
  await postService.create({
505
668
  format: "note",
506
- body: "featured",
507
- visibility: "featured",
669
+ bodyMarkdown: "unlisted",
670
+ visibility: "unlisted",
508
671
  });
509
- await postService.create({ format: "note", body: "normal" });
672
+ await postService.create({ format: "note", bodyMarkdown: "normal" });
510
673
 
511
- const count = await postService.count({ visibility: "featured" });
674
+ const count = await postService.count({ visibility: "unlisted" });
512
675
  expect(count).toBe(1);
513
676
  });
514
677
 
678
+ it("filters by featured", async () => {
679
+ await postService.create({
680
+ format: "note",
681
+ bodyMarkdown: "featured",
682
+ featured: true,
683
+ });
684
+ await postService.create({ format: "note", bodyMarkdown: "normal" });
685
+
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);
691
+ });
692
+
515
693
  it("excludes deleted posts by default", async () => {
516
694
  const post = await postService.create({
517
695
  format: "note",
518
- body: "to delete",
696
+ bodyMarkdown: "to delete",
519
697
  });
520
- await postService.create({ format: "note", body: "keep" });
698
+ await postService.create({ format: "note", bodyMarkdown: "keep" });
521
699
  await postService.delete(post.id);
522
700
 
523
701
  const count = await postService.count();
@@ -527,11 +705,11 @@ describe("PostService", () => {
527
705
  it("excludes replies when requested", async () => {
528
706
  const root = await postService.create({
529
707
  format: "note",
530
- body: "root",
708
+ bodyMarkdown: "root",
531
709
  });
532
710
  await postService.create({
533
711
  format: "note",
534
- body: "reply",
712
+ bodyMarkdown: "reply",
535
713
  replyToId: root.id,
536
714
  });
537
715
 
@@ -576,7 +754,7 @@ describe("PostService", () => {
576
754
  it("updates post title", async () => {
577
755
  const post = await postService.create({
578
756
  format: "link",
579
- body: "body",
757
+ bodyMarkdown: "body",
580
758
  title: "Original Title",
581
759
  url: "https://example.com",
582
760
  });
@@ -591,7 +769,7 @@ describe("PostService", () => {
591
769
  it("updates post url", async () => {
592
770
  const post = await postService.create({
593
771
  format: "link",
594
- body: "link post",
772
+ bodyMarkdown: "link post",
595
773
  url: "https://old.com",
596
774
  });
597
775
 
@@ -602,29 +780,29 @@ describe("PostService", () => {
602
780
  expect(updated?.url).toBe("https://new-source.com/path");
603
781
  });
604
782
 
605
- it("clears url when set to null", async () => {
783
+ it("rejects clearing url from a link post", async () => {
606
784
  const post = await postService.create({
607
785
  format: "link",
608
- body: "test",
786
+ bodyMarkdown: "test",
609
787
  url: "https://example.com",
610
788
  });
611
789
 
612
- const updated = await postService.update(post.id, {
613
- url: null,
614
- });
615
-
616
- expect(updated?.url).toBeNull();
790
+ await expect(
791
+ postService.update(post.id, {
792
+ url: null,
793
+ }),
794
+ ).rejects.toThrow("Link posts need a URL.");
617
795
  });
618
796
 
619
797
  it("returns null for non-existent post", async () => {
620
- const result = await postService.update(9999, { body: "test" });
798
+ const result = await postService.update(9999, { bodyMarkdown: "test" });
621
799
  expect(result).toBeNull();
622
800
  });
623
801
 
624
802
  it("updates updatedAt timestamp", async () => {
625
803
  const post = await postService.create({
626
804
  format: "note",
627
- body: "test",
805
+ bodyMarkdown: "test",
628
806
  });
629
807
  const originalUpdatedAt = post.updatedAt;
630
808
 
@@ -632,54 +810,124 @@ describe("PostService", () => {
632
810
  await new Promise((r) => setTimeout(r, 1100));
633
811
 
634
812
  const updated = await postService.update(post.id, {
635
- body: "modified",
813
+ bodyMarkdown: "modified",
636
814
  });
637
815
 
638
816
  expect(updated?.updatedAt).toBeGreaterThanOrEqual(originalUpdatedAt);
639
817
  });
640
818
 
819
+ it("sets publishedAt when publishing a draft", async () => {
820
+ const post = await postService.create({
821
+ format: "note",
822
+ bodyMarkdown: "draft",
823
+ status: "draft",
824
+ });
825
+
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
+
641
868
  it("updates visibility", async () => {
642
869
  const post = await postService.create({
643
870
  format: "note",
644
- body: "test",
871
+ bodyMarkdown: "test",
645
872
  });
646
873
 
647
- expect(post.visibility).toBe("listed");
874
+ expect(post.visibility).toBe("public");
648
875
 
649
876
  const updated = await postService.update(post.id, {
650
- visibility: "featured",
877
+ visibility: "unlisted",
651
878
  });
652
879
 
653
- expect(updated?.visibility).toBe("featured");
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, {
892
+ featured: true,
893
+ });
894
+
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();
654
902
  });
655
903
 
656
904
  it("updates pinned flag", async () => {
657
905
  const post = await postService.create({
658
906
  format: "note",
659
- body: "test",
907
+ bodyMarkdown: "test",
660
908
  });
661
909
 
662
- expect(post.pinned).toBe(0);
910
+ expect(post.pinnedAt).toBeNull();
663
911
 
664
912
  const updated = await postService.update(post.id, {
665
913
  pinned: true,
666
914
  });
667
915
 
668
- expect(updated?.pinned).toBe(1);
916
+ expect(updated?.pinnedAt).toBeTypeOf("number");
669
917
  });
670
918
 
671
- it("updates path", async () => {
919
+ it("updates slug", async () => {
672
920
  const post = await postService.create({
673
921
  format: "note",
674
- body: "test",
675
- path: "old-path",
922
+ bodyMarkdown: "test",
923
+ slug: "old-slug",
676
924
  });
677
925
 
678
926
  const updated = await postService.update(post.id, {
679
- path: "new-path",
927
+ slug: "new-slug",
680
928
  });
681
929
 
682
- expect(updated?.path).toBe("new-path");
930
+ expect(updated?.slug).toBe("new-slug");
683
931
  });
684
932
 
685
933
  it("updates quoteText and rating", async () => {
@@ -697,13 +945,40 @@ describe("PostService", () => {
697
945
  expect(updated?.quoteText).toBe("Updated quote");
698
946
  expect(updated?.rating).toBe(5);
699
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
+ });
700
975
  });
701
976
 
702
977
  describe("delete (soft delete)", () => {
703
978
  it("soft-deletes a post", async () => {
704
979
  const post = await postService.create({
705
980
  format: "note",
706
- body: "test",
981
+ bodyMarkdown: "test",
707
982
  });
708
983
 
709
984
  const result = await postService.delete(post.id);
@@ -722,11 +997,11 @@ describe("PostService", () => {
722
997
  it("cascade deletes thread when deleting root post", async () => {
723
998
  const root = await postService.create({
724
999
  format: "note",
725
- body: "root",
1000
+ bodyMarkdown: "root",
726
1001
  });
727
1002
  const reply = await postService.create({
728
1003
  format: "note",
729
- body: "reply",
1004
+ bodyMarkdown: "reply",
730
1005
  replyToId: root.id,
731
1006
  });
732
1007
 
@@ -740,16 +1015,16 @@ describe("PostService", () => {
740
1015
  it("only deletes single post when deleting a reply", async () => {
741
1016
  const root = await postService.create({
742
1017
  format: "note",
743
- body: "root",
1018
+ bodyMarkdown: "root",
744
1019
  });
745
1020
  const reply1 = await postService.create({
746
1021
  format: "note",
747
- body: "reply1",
1022
+ bodyMarkdown: "reply1",
748
1023
  replyToId: root.id,
749
1024
  });
750
1025
  await postService.create({
751
1026
  format: "note",
752
- body: "reply2",
1027
+ bodyMarkdown: "reply2",
753
1028
  replyToId: root.id,
754
1029
  });
755
1030
 
@@ -766,11 +1041,11 @@ describe("PostService", () => {
766
1041
  it("sets threadId on reply to a root post", async () => {
767
1042
  const root = await postService.create({
768
1043
  format: "note",
769
- body: "root",
1044
+ bodyMarkdown: "root",
770
1045
  });
771
1046
  const reply = await postService.create({
772
1047
  format: "note",
773
- body: "reply",
1048
+ bodyMarkdown: "reply",
774
1049
  replyToId: root.id,
775
1050
  });
776
1051
 
@@ -781,16 +1056,16 @@ describe("PostService", () => {
781
1056
  it("inherits threadId from parent in nested replies", async () => {
782
1057
  const root = await postService.create({
783
1058
  format: "note",
784
- body: "root",
1059
+ bodyMarkdown: "root",
785
1060
  });
786
1061
  const reply1 = await postService.create({
787
1062
  format: "note",
788
- body: "reply1",
1063
+ bodyMarkdown: "reply1",
789
1064
  replyToId: root.id,
790
1065
  });
791
1066
  const reply2 = await postService.create({
792
1067
  format: "note",
793
- body: "reply2",
1068
+ bodyMarkdown: "reply2",
794
1069
  replyToId: reply1.id,
795
1070
  });
796
1071
 
@@ -802,63 +1077,121 @@ describe("PostService", () => {
802
1077
  it("inherits status from root post", async () => {
803
1078
  const root = await postService.create({
804
1079
  format: "note",
805
- body: "root",
1080
+ bodyMarkdown: "root",
806
1081
  status: "draft",
807
1082
  });
808
1083
  const reply = await postService.create({
809
1084
  format: "note",
810
- body: "reply",
1085
+ bodyMarkdown: "reply",
811
1086
  replyToId: root.id,
812
1087
  });
813
1088
 
814
1089
  expect(reply.status).toBe("draft");
815
1090
  });
816
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",
1102
+ replyToId: root.id,
1103
+ });
1104
+
1105
+ expect(reply.status).toBe("draft");
1106
+ expect(reply.threadId).toBe(root.id);
1107
+ });
1108
+
817
1109
  it("inherits visibility from root post", async () => {
818
1110
  const root = await postService.create({
819
1111
  format: "note",
820
- body: "root",
821
- visibility: "featured",
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",
822
1129
  });
823
1130
  const reply = await postService.create({
824
1131
  format: "note",
825
- body: "reply",
1132
+ bodyMarkdown: "reply",
826
1133
  replyToId: root.id,
827
1134
  });
828
1135
 
829
- expect(reply.visibility).toBe("featured");
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");
1144
+ });
1145
+
1146
+ it("does not inherit featuredAt from root post", async () => {
1147
+ const root = await postService.create({
1148
+ format: "note",
1149
+ bodyMarkdown: "root",
1150
+ featured: true,
1151
+ });
1152
+
1153
+ expect(root.featuredAt).toBeTypeOf("number");
1154
+
1155
+ const reply = await postService.create({
1156
+ format: "note",
1157
+ bodyMarkdown: "reply",
1158
+ replyToId: root.id,
1159
+ });
1160
+
1161
+ // featuredAt is an independent property — replies should NOT inherit it
1162
+ expect(reply.featuredAt).toBeNull();
830
1163
  });
831
1164
 
832
1165
  it("getThread returns all posts in a thread", async () => {
833
1166
  const root = await postService.create({
834
1167
  format: "note",
835
- body: "root",
1168
+ bodyMarkdown: "root",
836
1169
  });
837
1170
  await postService.create({
838
1171
  format: "note",
839
- body: "reply1",
1172
+ bodyMarkdown: "reply1",
840
1173
  replyToId: root.id,
841
1174
  });
842
1175
  await postService.create({
843
1176
  format: "note",
844
- body: "reply2",
1177
+ bodyMarkdown: "reply2",
845
1178
  replyToId: root.id,
846
1179
  });
847
1180
 
848
1181
  const thread = await postService.getThread(root.id);
849
1182
  expect(thread).toHaveLength(3);
850
1183
  // Ordered by createdAt
851
- expect(thread[0]?.body).toBe("root");
1184
+ expect(thread[0]?.bodyText).toBe("root");
852
1185
  });
853
1186
 
854
1187
  it("getThread excludes deleted posts", async () => {
855
1188
  const root = await postService.create({
856
1189
  format: "note",
857
- body: "root",
1190
+ bodyMarkdown: "root",
858
1191
  });
859
1192
  const reply = await postService.create({
860
1193
  format: "note",
861
- body: "reply",
1194
+ bodyMarkdown: "reply",
862
1195
  replyToId: root.id,
863
1196
  });
864
1197
 
@@ -871,12 +1204,12 @@ describe("PostService", () => {
871
1204
  it("cascades status changes from root to thread", async () => {
872
1205
  const root = await postService.create({
873
1206
  format: "note",
874
- body: "root",
1207
+ bodyMarkdown: "root",
875
1208
  status: "published",
876
1209
  });
877
1210
  await postService.create({
878
1211
  format: "note",
879
- body: "reply",
1212
+ bodyMarkdown: "reply",
880
1213
  replyToId: root.id,
881
1214
  });
882
1215
 
@@ -885,27 +1218,151 @@ describe("PostService", () => {
885
1218
  const thread = await postService.getThread(root.id);
886
1219
  for (const post of thread) {
887
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");
888
1244
  }
889
1245
  });
890
1246
 
891
1247
  it("cascades visibility changes from root to thread", async () => {
892
1248
  const root = await postService.create({
893
1249
  format: "note",
894
- body: "root",
1250
+ bodyMarkdown: "root",
895
1251
  });
896
1252
  await postService.create({
897
1253
  format: "note",
898
- body: "reply",
1254
+ bodyMarkdown: "reply",
899
1255
  replyToId: root.id,
900
1256
  });
901
1257
 
902
- await postService.update(root.id, { visibility: "featured" });
1258
+ await postService.update(root.id, { visibility: "unlisted" });
903
1259
 
904
1260
  const thread = await postService.getThread(root.id);
905
1261
  for (const post of thread) {
906
- expect(post.visibility).toBe("featured");
1262
+ expect(post.visibility).toBe("unlisted");
907
1263
  }
908
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
+ });
909
1366
  });
910
1367
 
911
1368
  describe("getReplyCounts", () => {
@@ -917,16 +1374,16 @@ describe("PostService", () => {
917
1374
  it("returns reply counts for posts", async () => {
918
1375
  const root = await postService.create({
919
1376
  format: "note",
920
- body: "root",
1377
+ bodyMarkdown: "root",
921
1378
  });
922
1379
  await postService.create({
923
1380
  format: "note",
924
- body: "reply1",
1381
+ bodyMarkdown: "reply1",
925
1382
  replyToId: root.id,
926
1383
  });
927
1384
  await postService.create({
928
1385
  format: "note",
929
- body: "reply2",
1386
+ bodyMarkdown: "reply2",
930
1387
  replyToId: root.id,
931
1388
  });
932
1389
 
@@ -937,7 +1394,7 @@ describe("PostService", () => {
937
1394
  it("returns 0 (missing) for posts without replies", async () => {
938
1395
  const post = await postService.create({
939
1396
  format: "note",
940
- body: "no replies",
1397
+ bodyMarkdown: "no replies",
941
1398
  });
942
1399
 
943
1400
  const counts = await postService.getReplyCounts([post.id]);
@@ -947,16 +1404,16 @@ describe("PostService", () => {
947
1404
  it("excludes deleted replies from count", async () => {
948
1405
  const root = await postService.create({
949
1406
  format: "note",
950
- body: "root",
1407
+ bodyMarkdown: "root",
951
1408
  });
952
1409
  const reply = await postService.create({
953
1410
  format: "note",
954
- body: "reply",
1411
+ bodyMarkdown: "reply",
955
1412
  replyToId: root.id,
956
1413
  });
957
1414
  await postService.create({
958
1415
  format: "note",
959
- body: "reply2",
1416
+ bodyMarkdown: "reply2",
960
1417
  replyToId: root.id,
961
1418
  });
962
1419
 
@@ -965,100 +1422,122 @@ describe("PostService", () => {
965
1422
  const counts = await postService.getReplyCounts([root.id]);
966
1423
  expect(counts.get(root.id)).toBe(1);
967
1424
  });
968
- });
969
1425
 
970
- describe("path registry integration", () => {
971
- it("claims path on create", async () => {
972
- const post = await postService.create({
1426
+ it("excludes draft replies from count", async () => {
1427
+ const root = await postService.create({
973
1428
  format: "note",
974
- body: "test",
975
- path: "my-post",
1429
+ bodyMarkdown: "root",
976
1430
  });
977
-
978
- const entry = await pathRegistry.getByPath("my-post");
979
- expect(entry).not.toBeNull();
980
- expect(entry?.ownerType).toBe("post");
981
- expect(entry?.ownerId).toBe(post.id);
982
- });
983
-
984
- it("does not claim when no path provided", async () => {
985
1431
  await postService.create({
986
1432
  format: "note",
987
- 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",
988
1441
  });
989
1442
 
990
- // No path registry entries should exist
991
- expect(await pathRegistry.isAvailable("test")).toBe(true);
992
- });
993
-
994
- it("rejects path that conflicts with a page slug", async () => {
995
- await pageService.create({ slug: "about", title: "About" });
996
-
997
- await expect(
998
- postService.create({ format: "note", body: "test", path: "about" }),
999
- ).rejects.toThrow(ConflictError);
1000
- });
1001
-
1002
- it("rejects reserved path on create", async () => {
1003
- await expect(
1004
- postService.create({ format: "note", body: "test", path: "dash" }),
1005
- ).rejects.toThrow(ValidationError);
1443
+ const counts = await postService.getReplyCounts([root.id]);
1444
+ expect(counts.get(root.id)).toBe(1);
1006
1445
  });
1446
+ });
1007
1447
 
1008
- 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 () => {
1009
1450
  const post = await postService.create({
1010
1451
  format: "note",
1011
- body: "test",
1012
- path: "old-path",
1452
+ bodyMarkdown: "standalone",
1453
+ publishedAt: 5000,
1013
1454
  });
1014
1455
 
1015
- await postService.update(post.id, { path: "new-path" });
1016
-
1017
- expect(await pathRegistry.isAvailable("old-path")).toBe(true);
1018
- expect(await pathRegistry.isAvailable("new-path")).toBe(false);
1456
+ expect(post.lastActivityAt).toBe(5000);
1019
1457
  });
1020
1458
 
1021
- it("releases path when cleared to null on update", async () => {
1022
- const post = await postService.create({
1459
+ it("updates root lastActivityAt when a reply is created", async () => {
1460
+ const root = await postService.create({
1023
1461
  format: "note",
1024
- body: "test",
1025
- path: "my-path",
1462
+ bodyMarkdown: "root",
1463
+ publishedAt: 1000,
1026
1464
  });
1465
+ expect(root.lastActivityAt).toBe(1000);
1027
1466
 
1028
- 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
+ });
1029
1473
 
1030
- expect(await pathRegistry.isAvailable("my-path")).toBe(true);
1474
+ const updatedRoot = await postService.getById(root.id);
1475
+ expect(updatedRoot?.lastActivityAt).toBe(9000);
1031
1476
  });
1032
1477
 
1033
- it("releases path on soft-delete", async () => {
1034
- 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({
1035
1485
  format: "note",
1036
- body: "test",
1037
- path: "my-path",
1486
+ bodyMarkdown: "newer standalone",
1487
+ publishedAt: 5000,
1038
1488
  });
1039
1489
 
1040
- 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
+ });
1041
1497
 
1042
- 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");
1043
1501
  });
1044
1502
 
1045
- it("releases paths for all thread posts on root delete", async () => {
1503
+ it("recalculates root lastActivityAt when a reply is deleted", async () => {
1046
1504
  const root = await postService.create({
1047
1505
  format: "note",
1048
- body: "root",
1049
- 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,
1050
1514
  });
1051
1515
  await postService.create({
1052
1516
  format: "note",
1053
- body: "reply",
1054
- path: "thread-reply",
1517
+ bodyMarkdown: "reply2",
1055
1518
  replyToId: root.id,
1519
+ publishedAt: 5000,
1056
1520
  });
1057
1521
 
1058
- 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);
1059
1538
 
1060
- expect(await pathRegistry.isAvailable("thread-root")).toBe(true);
1061
- expect(await pathRegistry.isAvailable("thread-reply")).toBe(true);
1539
+ updatedRoot = await postService.getById(root.id);
1540
+ expect(updatedRoot?.lastActivityAt).toBe(1000);
1062
1541
  });
1063
1542
  });
1064
1543
  });