@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
@@ -3,20 +3,38 @@
3
3
  *
4
4
  * CRUD operations for posts with Thread support.
5
5
  * Posts have format (note/link/quote), status (draft/published),
6
- * featured flag, and pinned flag.
6
+ * visibility (public/unlisted/private), featuredAt, and pinnedAt timestamp.
7
7
  */
8
8
 
9
- import { eq, and, isNull, desc, or, inArray, sql } from "drizzle-orm";
9
+ import { eq, and, isNull, desc, inArray, sql, isNotNull } from "drizzle-orm";
10
10
  import type { BatchItem } from "drizzle-orm/batch";
11
- import type { Database } from "../db/index.js";
12
- import { posts, postCollections } from "../db/schema.js";
11
+ import { uuidv7 } from "uuidv7";
12
+ import { type Database, batchQueryRows } from "../db/index.js";
13
+ import { pathRegistry, posts, postCollections } from "../db/schema.js";
13
14
  import { now } from "../lib/time.js";
14
- import { render as renderMarkdown } from "../lib/markdown.js";
15
+ import { renderTiptapJson } from "../lib/tiptap-render.js";
16
+ import { extractSummary, extractBodyText } from "../lib/summary.js";
17
+ import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
18
+ import { generatePostSlug } from "../lib/slug.js";
19
+ import { normalizePath, slugify } from "../lib/url.js";
15
20
  import type { StorageDriver } from "../lib/storage.js";
16
21
  import type { MediaService } from "./media.js";
17
- import type { Format, Status, Post, CreatePost, UpdatePost } from "../types.js";
18
- import type { PathRegistryService } from "./path-registry.js";
19
- import { ConflictError } from "../lib/errors.js";
22
+ import type {
23
+ Format,
24
+ Status,
25
+ Visibility,
26
+ MediaKind,
27
+ Post,
28
+ CreatePost,
29
+ UpdatePost,
30
+ ThreadTimelineContext,
31
+ } from "../types.js";
32
+ import {
33
+ ConflictError,
34
+ ValidationError,
35
+ NotFoundError,
36
+ } from "../lib/errors.js";
37
+ import { createPathService, type PathService } from "./path.js";
20
38
 
21
39
  /** Dependencies for operations that coordinate with other services */
22
40
  export interface PostDeleteDeps {
@@ -27,26 +45,51 @@ export interface PostDeleteDeps {
27
45
  export interface PostFilters {
28
46
  format?: Format;
29
47
  status?: Status;
30
- featured?: boolean;
48
+ visibility?: Visibility;
31
49
  pinned?: boolean;
32
- collectionId?: number;
33
- /** Exclude posts that are replies (have threadId set) */
50
+ featured?: boolean;
51
+ collectionId?: string;
52
+ /** Exclude posts that are replies (have replyToId set) */
34
53
  excludeReplies?: boolean;
54
+ /** Exclude unlisted posts from results */
55
+ excludeUnlisted?: boolean;
56
+ /** Exclude private posts from results */
57
+ excludePrivate?: boolean;
35
58
  includeDeleted?: boolean;
36
- threadId?: number;
59
+ threadId?: string;
60
+ /** Unix timestamp (inclusive) — only posts published at or after this time */
61
+ publishedAfter?: number;
62
+ /** Unix timestamp (exclusive) — only posts published before this time */
63
+ publishedBefore?: number;
64
+ /** Media kinds to filter by (OR logic: post has media of ANY selected kind). */
65
+ mediaKinds?: MediaKind[];
66
+ /** Filter by media presence */
67
+ hasMedia?: boolean;
68
+ /** Filter by title presence */
69
+ hasTitle?: boolean;
37
70
  limit?: number;
38
- cursor?: number; // post id for cursor pagination
71
+ cursor?: string; // post id for cursor pagination (UUIDv7 sorts chronologically)
39
72
  offset?: number; // offset for page-based pagination
40
73
  }
41
74
 
75
+ /** Config for automatic summary extraction */
76
+ export interface SummaryConfig {
77
+ maxParagraphs: number;
78
+ maxChars: number;
79
+ }
80
+
42
81
  export interface PostService {
43
- getById(id: number): Promise<Post | null>;
44
- getByPath(path: string): Promise<Post | null>;
82
+ getById(id: string): Promise<Post | null>;
83
+ getBySlug(slug: string): Promise<Post | null>;
45
84
  list(filters?: PostFilters): Promise<Post[]>;
46
85
  /** Count posts matching filters (ignores cursor, offset, limit) */
47
86
  count(filters?: PostFilters): Promise<number>;
48
- create(data: CreatePost): Promise<Post>;
49
- update(id: number, data: UpdatePost): Promise<Post | null>;
87
+ create(data: CreatePost, summaryConfig?: SummaryConfig): Promise<Post>;
88
+ update(
89
+ id: string,
90
+ data: UpdatePost,
91
+ summaryConfig?: SummaryConfig,
92
+ ): Promise<Post | null>;
50
93
  /**
51
94
  * Soft-delete a post and clean up its media (storage files + DB records).
52
95
  * Thread roots cascade to all replies.
@@ -54,32 +97,151 @@ export interface PostService {
54
97
  * @param id - Post ID
55
98
  * @param deps - Media service and optional storage driver for file cleanup
56
99
  */
57
- delete(id: number, deps?: PostDeleteDeps): Promise<boolean>;
58
- getThread(rootId: number): Promise<Post[]>;
59
- updateThreadStatusAndFeatured(
60
- rootId: number,
100
+ delete(id: string, deps?: PostDeleteDeps): Promise<boolean>;
101
+ getThread(rootId: string): Promise<Post[]>;
102
+ updateThreadStatusAndVisibility(
103
+ rootId: string,
61
104
  status: Status,
62
- featured: boolean,
105
+ visibility: Visibility,
63
106
  ): Promise<void>;
64
107
  /** Get reply counts for multiple posts */
65
- getReplyCounts(postIds: number[]): Promise<Map<number, number>>;
108
+ getReplyCounts(postIds: string[]): Promise<Map<string, number>>;
66
109
  /** Get preview replies for multiple thread roots */
67
110
  getThreadPreviews(
68
- rootIds: number[],
111
+ rootIds: string[],
69
112
  previewCount?: number,
70
- ): Promise<Map<number, Post[]>>;
113
+ ): Promise<Map<string, Post[]>>;
114
+ /** Get latest-reply context for multiple thread roots (for timeline display) */
115
+ getThreadTimelineContext(
116
+ rootIds: string[],
117
+ ): Promise<Map<string, ThreadTimelineContext>>;
118
+ /** Get distinct years that have published posts */
119
+ getDistinctYears(filters?: PostFilters): Promise<number[]>;
120
+ /** For each thread ID, return the ID of the last published, non-deleted post */
121
+ getLastPostIdsByThread(threadIds: string[]): Promise<Map<string, string>>;
122
+ }
123
+
124
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
125
+
126
+ function isValidSlug(value: string): boolean {
127
+ return SLUG_RE.test(value);
71
128
  }
72
129
 
73
- /** Check if an error is a SQLite UNIQUE constraint violation (D1 or better-sqlite3) */
130
+ /** Check if an error (or any of its causes) is a SQLite UNIQUE constraint violation */
74
131
  function isUniqueConstraintError(err: unknown): boolean {
75
- const msg = String(err);
76
- return msg.includes("UNIQUE constraint") || msg.includes("SQLITE_CONSTRAINT");
132
+ let current: unknown = err;
133
+ while (current) {
134
+ const msg = String(current);
135
+ if (
136
+ msg.includes("UNIQUE constraint") ||
137
+ msg.includes("SQLITE_CONSTRAINT")
138
+ ) {
139
+ return true;
140
+ }
141
+ current =
142
+ current instanceof Error && current.cause !== current
143
+ ? current.cause
144
+ : undefined;
145
+ }
146
+ return false;
147
+ }
148
+
149
+ function hasNonEmptyText(value: string | null | undefined): boolean {
150
+ return typeof value === "string" && value.trim().length > 0;
151
+ }
152
+
153
+ function assertPostFormatShape(data: {
154
+ format: Format;
155
+ url?: string | null;
156
+ quoteText?: string | null;
157
+ }): void {
158
+ const hasUrl = hasNonEmptyText(data.url);
159
+ const hasQuoteText = hasNonEmptyText(data.quoteText);
160
+
161
+ if (data.format === "note") {
162
+ if (hasUrl) {
163
+ throw new ValidationError("Notes can't include a URL.");
164
+ }
165
+ if (hasQuoteText) {
166
+ throw new ValidationError("Notes can't include quoted text.");
167
+ }
168
+ return;
169
+ }
170
+
171
+ if (data.format === "link") {
172
+ if (!hasUrl) {
173
+ throw new ValidationError("Link posts need a URL.");
174
+ }
175
+ if (hasQuoteText) {
176
+ throw new ValidationError("Link posts can't include quoted text.");
177
+ }
178
+ return;
179
+ }
180
+
181
+ if (!hasQuoteText) {
182
+ throw new ValidationError("Quote posts need quoted text.");
183
+ }
184
+ }
185
+
186
+ function isThreadReply(post: Pick<Post, "replyToId">): boolean {
187
+ return post.replyToId !== null;
188
+ }
189
+
190
+ function assertDraftPublishedAt(
191
+ status: Status,
192
+ publishedAt: number | undefined,
193
+ ): void {
194
+ if (status === "draft" && publishedAt !== undefined) {
195
+ throw new ValidationError("Drafts can't set a publish time.");
196
+ }
77
197
  }
78
198
 
79
199
  export function createPostService(
80
200
  db: Database,
81
- pathRegistry: PathRegistryService,
201
+ config: { slugIdLength: number },
202
+ paths: PathService = createPathService(db),
82
203
  ): PostService {
204
+ const effectiveVisibilityExpr = sql<string>`coalesce(
205
+ ${posts.visibility},
206
+ (SELECT root.visibility FROM post AS root WHERE root.id = ${posts.threadId})
207
+ )`;
208
+
209
+ /** Check if a slug is available (not used by posts or custom_urls) */
210
+ async function isSlugAvailable(slug: string): Promise<boolean> {
211
+ return paths.isPathAvailable(slug);
212
+ }
213
+
214
+ async function pathExists(path: string): Promise<boolean> {
215
+ const rows = await db
216
+ .select({ id: pathRegistry.id })
217
+ .from(pathRegistry)
218
+ .where(eq(pathRegistry.path, normalizePath(path)))
219
+ .limit(1);
220
+ return rows.length > 0;
221
+ }
222
+
223
+ async function recalculateThreadLastActivity(rootId: string): Promise<void> {
224
+ const rootRows = await db
225
+ .select({
226
+ latestPublishedAt: sql<number | null>`MAX(${posts.publishedAt})`.as(
227
+ "latest_published_at",
228
+ ),
229
+ })
230
+ .from(posts)
231
+ .where(and(eq(posts.threadId, rootId), isNull(posts.deletedAt)));
232
+
233
+ const latestPublishedAt = rootRows[0]?.latestPublishedAt ?? null;
234
+ const root = await db
235
+ .select({ updatedAt: posts.updatedAt })
236
+ .from(posts)
237
+ .where(eq(posts.id, rootId))
238
+ .limit(1);
239
+
240
+ const lastActivityAt = latestPublishedAt ?? root[0]?.updatedAt ?? now();
241
+
242
+ await db.update(posts).set({ lastActivityAt }).where(eq(posts.id, rootId));
243
+ }
244
+
83
245
  /** Build WHERE conditions from filters (shared by list and count) */
84
246
  function buildFilterConditions(filters: PostFilters) {
85
247
  const conditions = [];
@@ -87,11 +249,28 @@ export function createPostService(
87
249
  if (filters.status) {
88
250
  conditions.push(eq(posts.status, filters.status));
89
251
  }
90
- if (filters.featured !== undefined) {
91
- conditions.push(eq(posts.featured, filters.featured ? 1 : 0));
252
+ if (filters.visibility !== undefined) {
253
+ conditions.push(sql`${effectiveVisibilityExpr} = ${filters.visibility}`);
254
+ }
255
+ if (filters.excludeUnlisted) {
256
+ conditions.push(sql`${effectiveVisibilityExpr} != 'unlisted'`);
257
+ }
258
+ if (filters.excludePrivate) {
259
+ conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
92
260
  }
93
261
  if (filters.pinned !== undefined) {
94
- conditions.push(eq(posts.pinned, filters.pinned ? 1 : 0));
262
+ conditions.push(
263
+ filters.pinned
264
+ ? sql`${posts.pinnedAt} IS NOT NULL`
265
+ : isNull(posts.pinnedAt),
266
+ );
267
+ }
268
+ if (filters.featured !== undefined) {
269
+ conditions.push(
270
+ filters.featured
271
+ ? sql`${posts.featuredAt} IS NOT NULL`
272
+ : isNull(posts.featuredAt),
273
+ );
95
274
  }
96
275
  if (filters.format) {
97
276
  conditions.push(eq(posts.format, filters.format));
@@ -99,45 +278,136 @@ export function createPostService(
99
278
  if (filters.collectionId !== undefined) {
100
279
  // Filter by collection via junction table
101
280
  conditions.push(
102
- sql`${posts.id} IN (SELECT post_id FROM post_collections WHERE collection_id = ${filters.collectionId})`,
281
+ sql`${posts.id} IN (SELECT post_id FROM post_collection WHERE collection_id = ${filters.collectionId})`,
103
282
  );
104
283
  }
105
284
  if (filters.threadId) {
106
285
  conditions.push(eq(posts.threadId, filters.threadId));
107
286
  }
108
287
  if (filters.excludeReplies) {
109
- conditions.push(isNull(posts.threadId));
288
+ conditions.push(isNull(posts.replyToId));
110
289
  }
111
290
  if (!filters.includeDeleted) {
112
291
  conditions.push(isNull(posts.deletedAt));
113
292
  }
293
+ if (filters.publishedAfter !== undefined) {
294
+ conditions.push(sql`${posts.publishedAt} >= ${filters.publishedAfter}`);
295
+ }
296
+ if (filters.publishedBefore !== undefined) {
297
+ conditions.push(sql`${posts.publishedAt} < ${filters.publishedBefore}`);
298
+ }
299
+ if (filters.mediaKinds && filters.mediaKinds.length > 0) {
300
+ const placeholders = filters.mediaKinds.map((k) => sql`${k}`);
301
+ conditions.push(
302
+ sql`${posts.id} IN (SELECT post_id FROM media WHERE media_kind IN (${sql.join(placeholders, sql`, `)}))`,
303
+ );
304
+ }
305
+ if (filters.hasMedia !== undefined) {
306
+ if (filters.hasMedia) {
307
+ conditions.push(sql`${posts.id} IN (SELECT post_id FROM media)`);
308
+ } else {
309
+ conditions.push(sql`${posts.id} NOT IN (SELECT post_id FROM media)`);
310
+ }
311
+ }
312
+ if (filters.hasTitle !== undefined) {
313
+ if (filters.hasTitle) {
314
+ conditions.push(
315
+ sql`${posts.title} IS NOT NULL AND ${posts.title} != ''`,
316
+ );
317
+ } else {
318
+ conditions.push(sql`(${posts.title} IS NULL OR ${posts.title} = '')`);
319
+ }
320
+ }
114
321
 
115
322
  return conditions;
116
323
  }
117
324
 
118
- function toPost(row: typeof posts.$inferSelect): Post {
325
+ function toPost(
326
+ row: typeof posts.$inferSelect,
327
+ slug: string,
328
+ visibility: Visibility,
329
+ ): Post {
119
330
  return {
120
331
  id: row.id,
121
332
  format: row.format as Format,
122
333
  status: row.status as Status,
123
- featured: row.featured,
124
- pinned: row.pinned,
125
- path: row.path,
334
+ visibility,
335
+ pinnedAt: row.pinnedAt,
336
+ featuredAt: row.featuredAt,
337
+ slug,
126
338
  title: row.title,
127
339
  url: row.url,
128
340
  body: row.body,
129
341
  bodyHtml: row.bodyHtml,
342
+ bodyText: row.bodyText,
130
343
  quoteText: row.quoteText,
344
+ summary: row.summary,
131
345
  rating: row.rating,
132
346
  replyToId: row.replyToId,
133
347
  threadId: row.threadId,
134
348
  deletedAt: row.deletedAt,
135
349
  publishedAt: row.publishedAt,
350
+ lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
136
351
  createdAt: row.createdAt,
137
352
  updatedAt: row.updatedAt,
138
353
  };
139
354
  }
140
355
 
356
+ async function hydratePost(
357
+ row: typeof posts.$inferSelect | undefined,
358
+ ): Promise<Post | null> {
359
+ if (!row) return null;
360
+ const slug = await paths.getPostSlug(row.id);
361
+ if (!slug) return null;
362
+ const rootVisibilityMap = await getThreadVisibilityMap([row.threadId]);
363
+ const visibility = rootVisibilityMap.get(row.threadId) ?? row.visibility;
364
+ if (!visibility) return null;
365
+ return toPost(row, slug, visibility as Visibility);
366
+ }
367
+
368
+ async function hydratePosts(
369
+ rows: (typeof posts.$inferSelect)[],
370
+ ): Promise<Post[]> {
371
+ if (rows.length === 0) return [];
372
+ const slugMap = await paths.getPostSlugMap(rows.map((row) => row.id));
373
+ const rootVisibilityMap = await getThreadVisibilityMap(
374
+ rows.map((row) => row.threadId),
375
+ );
376
+ return rows
377
+ .map((row) => {
378
+ const slug = slugMap.get(row.id);
379
+ const visibility =
380
+ rootVisibilityMap.get(row.threadId) ?? row.visibility;
381
+ return slug && visibility
382
+ ? toPost(row, slug, visibility as Visibility)
383
+ : null;
384
+ })
385
+ .filter((row): row is Post => row !== null);
386
+ }
387
+
388
+ async function getThreadVisibilityMap(
389
+ threadIds: string[],
390
+ ): Promise<Map<string, Visibility>> {
391
+ const uniqueThreadIds = [...new Set(threadIds)];
392
+ const result = new Map<string, Visibility>();
393
+ if (uniqueThreadIds.length === 0) return result;
394
+
395
+ const rows = await batchQueryRows(uniqueThreadIds, (chunk) =>
396
+ db
397
+ .select({ id: posts.id, visibility: posts.visibility })
398
+ .from(posts)
399
+ .where(inArray(posts.id, chunk)),
400
+ );
401
+
402
+ for (const row of rows) {
403
+ if (row.visibility) {
404
+ result.set(row.id, row.visibility as Visibility);
405
+ }
406
+ }
407
+
408
+ return result;
409
+ }
410
+
141
411
  return {
142
412
  async getById(id) {
143
413
  const result = await db
@@ -145,20 +415,28 @@ export function createPostService(
145
415
  .from(posts)
146
416
  .where(and(eq(posts.id, id), isNull(posts.deletedAt)))
147
417
  .limit(1);
148
- return result[0] ? toPost(result[0]) : null;
418
+ return hydratePost(result[0]);
149
419
  },
150
420
 
151
- async getByPath(path) {
152
- const result = await db
153
- .select()
154
- .from(posts)
155
- .where(and(eq(posts.path, path), isNull(posts.deletedAt)))
156
- .limit(1);
157
- return result[0] ? toPost(result[0]) : null;
421
+ async getBySlug(slug) {
422
+ const resolved = await paths.resolve(slug);
423
+ if (!resolved || resolved.kind !== "slug" || !resolved.postId) {
424
+ return null;
425
+ }
426
+ return this.getById(resolved.postId);
158
427
  },
159
428
 
160
429
  async list(filters = {}) {
161
430
  const conditions = buildFilterConditions(filters);
431
+ const sortTimestamp =
432
+ filters.status === "draft"
433
+ ? posts.updatedAt
434
+ : filters.status === "published"
435
+ ? posts.lastActivityAt
436
+ : sql<number>`CASE
437
+ WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
438
+ ELSE ${posts.lastActivityAt}
439
+ END`;
162
440
 
163
441
  if (filters.cursor) {
164
442
  conditions.push(sql`${posts.id} < ${filters.cursor}`);
@@ -168,7 +446,11 @@ export function createPostService(
168
446
  .select()
169
447
  .from(posts)
170
448
  .where(conditions.length > 0 ? and(...conditions) : undefined)
171
- .orderBy(desc(posts.publishedAt), desc(posts.id))
449
+ .orderBy(
450
+ desc(posts.pinnedAt),
451
+ filters.featured ? desc(posts.featuredAt) : desc(sortTimestamp),
452
+ desc(posts.id),
453
+ )
172
454
  .limit(filters.limit ?? 100);
173
455
 
174
456
  if (filters.offset !== undefined) {
@@ -176,7 +458,7 @@ export function createPostService(
176
458
  }
177
459
 
178
460
  const rows = await query;
179
- return rows.map(toPost);
461
+ return hydratePosts(rows);
180
462
  },
181
463
 
182
464
  async count(filters = {}) {
@@ -190,144 +472,331 @@ export function createPostService(
190
472
  return result[0]?.count ?? 0;
191
473
  },
192
474
 
193
- async create(data) {
475
+ async create(data, summaryConfig) {
476
+ const id = uuidv7();
194
477
  const timestamp = now();
195
478
 
196
- const bodyHtml = data.body ? renderMarkdown(data.body) : null;
479
+ assertPostFormatShape({
480
+ format: data.format,
481
+ url: data.url,
482
+ quoteText: data.quoteText,
483
+ });
484
+
485
+ const body = data.bodyMarkdown
486
+ ? markdownToTiptapJson(data.bodyMarkdown)
487
+ : (data.body ?? null);
488
+ const bodyHtml = body ? renderTiptapJson(body) : null;
489
+ const bodyText = body ? extractBodyText(body) : null;
490
+
491
+ // Generate summary for titled notes with body content
492
+ let summary: string | null = null;
493
+ if (data.format === "note" && data.title && body && summaryConfig) {
494
+ summary = extractSummary(
495
+ body,
496
+ summaryConfig.maxParagraphs,
497
+ summaryConfig.maxChars,
498
+ );
499
+ }
197
500
 
198
501
  // Handle thread relationship
199
- let threadId: number | null = null;
502
+ let threadId = id;
200
503
  let status: Status = data.status ?? "published";
201
- let featured = data.featured ?? false;
504
+ let visibility: Visibility | null = data.visibility ?? "public";
202
505
 
203
506
  if (data.replyToId) {
204
507
  const parent = await this.getById(data.replyToId);
205
- if (parent) {
206
- threadId = parent.threadId ?? parent.id;
207
- // Inherit status and featured from root
208
- const root = parent.threadId
209
- ? await this.getById(parent.threadId)
210
- : parent;
211
- if (root) {
508
+ if (!parent) {
509
+ throw new NotFoundError("Parent post");
510
+ }
511
+
512
+ if (data.pinned) {
513
+ throw new ConflictError(
514
+ "Cannot pin a thread reply. Pin the root post instead.",
515
+ );
516
+ }
517
+
518
+ threadId = parent.threadId;
519
+
520
+ // Replies inherit visibility from the root at read time.
521
+ const root =
522
+ parent.threadId === parent.id
523
+ ? parent
524
+ : await this.getById(parent.threadId);
525
+ if (root) {
526
+ if (data.status !== "draft") {
212
527
  status = root.status as Status;
213
- featured = root.featured === 1;
214
528
  }
215
529
  }
530
+ visibility = null;
216
531
  }
217
532
 
218
- // Validate path availability before DB insert — throws friendly
219
- // ConflictError/ValidationError instead of a raw UNIQUE constraint error.
220
- // Uses placeholder owner ID; corrected to real ID after insert.
533
+ assertDraftPublishedAt(status, data.publishedAt);
534
+ const publishedAt =
535
+ status === "published" ? (data.publishedAt ?? timestamp) : null;
536
+
537
+ // Resolve slug from slug, path, or title
538
+ let slug: string;
539
+ let aliasPath: string | null = null;
540
+
221
541
  if (data.path) {
222
- await pathRegistry.claim(data.path, "post", 0);
542
+ const normalized = normalizePath(data.path);
543
+ if (isValidSlug(normalized)) {
544
+ // Path is a valid slug — use it directly
545
+ slug = await generatePostSlug({
546
+ slug: normalized,
547
+ idLength: config.slugIdLength,
548
+ isAvailable: isSlugAvailable,
549
+ });
550
+ } else {
551
+ // Path is not a valid slug — slugify it for the slug, keep original as alias
552
+ const slugified = slugify(normalized);
553
+ slug = await generatePostSlug({
554
+ slug: slugified || undefined,
555
+ title: data.title,
556
+ idLength: config.slugIdLength,
557
+ isAvailable: isSlugAvailable,
558
+ });
559
+ // Verify the alias path is available before proceeding
560
+ if (!(await paths.isPathAvailable(normalized))) {
561
+ throw new ConflictError(`Path "${normalized}" is already in use`);
562
+ }
563
+ aliasPath = normalized;
564
+ }
565
+ } else {
566
+ slug = await generatePostSlug({
567
+ slug: data.slug,
568
+ title: data.title,
569
+ idLength: config.slugIdLength,
570
+ isAvailable: isSlugAvailable,
571
+ });
223
572
  }
224
573
 
225
- let result;
574
+ const collectionIds = [...new Set(data.collectionIds ?? [])];
575
+
226
576
  try {
227
- result = await db
228
- .insert(posts)
229
- .values({
577
+ const writeQueries: BatchItem<"sqlite">[] = [
578
+ db.insert(posts).values({
579
+ id,
230
580
  format: data.format,
231
581
  status,
232
- featured: featured ? 1 : 0,
233
- pinned: data.pinned ? 1 : 0,
234
- path: data.path ?? null,
582
+ visibility,
583
+ pinnedAt: data.pinned ? timestamp : null,
584
+ featuredAt: data.featured ? timestamp : null,
235
585
  title: data.title ?? null,
236
586
  url: data.url ?? null,
237
- body: data.body ?? null,
587
+ body: body ?? null,
238
588
  bodyHtml,
589
+ bodyText,
239
590
  quoteText: data.quoteText ?? null,
591
+ summary,
240
592
  rating: data.rating ?? null,
241
593
  replyToId: data.replyToId ?? null,
242
594
  threadId,
243
- publishedAt: data.publishedAt ?? timestamp,
595
+ publishedAt,
596
+ lastActivityAt: publishedAt ?? timestamp,
244
597
  createdAt: timestamp,
245
598
  updatedAt: timestamp,
246
- })
247
- .returning();
599
+ }),
600
+ db.insert(pathRegistry).values({
601
+ id: uuidv7(),
602
+ path: normalizePath(slug),
603
+ kind: "slug",
604
+ postId: id,
605
+ collectionId: null,
606
+ redirectToPath: null,
607
+ redirectType: null,
608
+ createdAt: timestamp,
609
+ updatedAt: timestamp,
610
+ }),
611
+ ];
612
+
613
+ if (aliasPath) {
614
+ writeQueries.push(
615
+ db.insert(pathRegistry).values({
616
+ id: uuidv7(),
617
+ path: normalizePath(aliasPath),
618
+ kind: "alias",
619
+ postId: id,
620
+ collectionId: null,
621
+ redirectToPath: null,
622
+ redirectType: null,
623
+ createdAt: timestamp,
624
+ updatedAt: timestamp,
625
+ }),
626
+ );
627
+ }
628
+
629
+ if (collectionIds.length > 0) {
630
+ writeQueries.push(
631
+ db.insert(postCollections).values(
632
+ collectionIds.map((collectionId) => ({
633
+ postId: id,
634
+ collectionId,
635
+ createdAt: timestamp,
636
+ })),
637
+ ),
638
+ );
639
+ }
640
+
641
+ await db.batch(
642
+ writeQueries as [
643
+ (typeof writeQueries)[number],
644
+ ...(typeof writeQueries)[number][],
645
+ ],
646
+ );
248
647
  } catch (err) {
249
- if (data.path) await pathRegistry.release(data.path);
250
- if (isUniqueConstraintError(err)) {
251
- throw new ConflictError(`Path "${data.path}" is already in use`);
648
+ if (err instanceof ConflictError) {
649
+ throw new ConflictError(`Slug "${slug}" is already in use`);
650
+ }
651
+ if (isUniqueConstraintError(err) && (await pathExists(slug))) {
652
+ throw new ConflictError(`Slug "${slug}" is already in use`);
252
653
  }
253
654
  throw err;
254
655
  }
255
656
 
256
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
257
- const post = toPost(result[0]!);
258
-
259
- // Update registry with actual post ID
260
- if (post.path) {
261
- await pathRegistry.release(post.path);
262
- await pathRegistry.claim(post.path, "post", post.id);
657
+ const post = await this.getById(id);
658
+ if (!post) {
659
+ throw new ConflictError(`Slug "${slug}" could not be resolved`);
263
660
  }
264
661
 
265
- // Sync collection memberships if provided
266
- if (data.collectionIds && data.collectionIds.length > 0) {
267
- await db.insert(postCollections).values(
268
- data.collectionIds.map((collectionId) => ({
269
- postId: post.id,
270
- collectionId,
271
- })),
272
- );
662
+ // Bump thread root's lastActivityAt when creating a published reply
663
+ if (data.replyToId && status === "published") {
664
+ await recalculateThreadLastActivity(threadId);
273
665
  }
274
666
 
275
667
  return post;
276
668
  },
277
669
 
278
- async update(id, data) {
670
+ async update(id, data, summaryConfig) {
279
671
  const existing = await this.getById(id);
280
672
  if (!existing) return null;
281
673
 
282
- // Handle path changes in the registry before modifying the post
283
- const pathChanging =
284
- data.path !== undefined && data.path !== existing.path;
285
- if (pathChanging) {
286
- // Claim new path (if non-null) before releasing old
287
- if (data.path) {
288
- await pathRegistry.claim(data.path, "post", id);
289
- }
290
- // Release old path (if it existed)
291
- if (existing.path) {
292
- await pathRegistry.release(existing.path);
293
- }
294
- }
295
-
296
674
  const timestamp = now();
675
+ const nextFormat = data.format ?? existing.format;
676
+ const nextUrl = data.url !== undefined ? data.url : existing.url;
677
+ const nextQuoteText =
678
+ data.quoteText !== undefined ? data.quoteText : existing.quoteText;
679
+ const nextStatus = data.status ?? existing.status;
680
+
681
+ assertPostFormatShape({
682
+ format: nextFormat,
683
+ url: nextUrl,
684
+ quoteText: nextQuoteText,
685
+ });
686
+ assertDraftPublishedAt(nextStatus, data.publishedAt);
687
+
297
688
  const updates: Partial<typeof posts.$inferInsert> = {
298
689
  updatedAt: timestamp,
299
690
  };
300
691
 
692
+ // Handle slug change
693
+ const slugChanged =
694
+ data.slug !== undefined && data.slug !== existing.slug;
695
+ if (slugChanged && data.slug) {
696
+ try {
697
+ await paths.updatePostSlug(id, data.slug);
698
+ } catch (err) {
699
+ if (err instanceof ConflictError) {
700
+ throw new ConflictError(`Slug "${data.slug}" is already in use`);
701
+ }
702
+ throw err;
703
+ }
704
+ }
705
+
301
706
  if (data.format !== undefined) updates.format = data.format;
302
- if (data.path !== undefined) updates.path = data.path;
303
707
  if (data.title !== undefined) updates.title = data.title;
304
708
  if (data.url !== undefined) updates.url = data.url;
305
709
  if (data.quoteText !== undefined) updates.quoteText = data.quoteText;
306
710
  if (data.rating !== undefined) updates.rating = data.rating;
307
- if (data.publishedAt !== undefined)
308
- updates.publishedAt = data.publishedAt;
309
- if (data.pinned !== undefined) updates.pinned = data.pinned ? 1 : 0;
711
+ if (data.pinned !== undefined)
712
+ updates.pinnedAt = data.pinned ? now() : null;
713
+ if (data.featured !== undefined)
714
+ updates.featuredAt = data.featured ? now() : null;
715
+
716
+ if (data.body !== undefined || data.bodyMarkdown !== undefined) {
717
+ const normalizedBody = data.bodyMarkdown
718
+ ? markdownToTiptapJson(data.bodyMarkdown)
719
+ : (data.body ?? null);
720
+ updates.body = normalizedBody;
721
+ updates.bodyHtml = normalizedBody
722
+ ? renderTiptapJson(normalizedBody)
723
+ : null;
724
+ updates.bodyText = normalizedBody
725
+ ? extractBodyText(normalizedBody)
726
+ : null;
727
+ }
728
+
729
+ // Recompute summary when body, title, or format change
730
+ if (summaryConfig) {
731
+ const format = data.format ?? (existing.format as Format);
732
+ const title = data.title !== undefined ? data.title : existing.title;
733
+ const body =
734
+ data.bodyMarkdown !== undefined
735
+ ? data.bodyMarkdown
736
+ ? markdownToTiptapJson(data.bodyMarkdown)
737
+ : null
738
+ : data.body !== undefined
739
+ ? data.body
740
+ : existing.body;
741
+ if (format === "note" && title && body) {
742
+ updates.summary = extractSummary(
743
+ body,
744
+ summaryConfig.maxParagraphs,
745
+ summaryConfig.maxChars,
746
+ );
747
+ } else {
748
+ updates.summary = null;
749
+ }
750
+ }
310
751
 
311
- if (data.body !== undefined) {
312
- updates.body = data.body;
313
- updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
752
+ // Thread replies inherit visibility/pinned from root — reject direct changes
753
+ if (isThreadReply(existing)) {
754
+ if (data.visibility !== undefined) {
755
+ throw new ConflictError(
756
+ "Cannot change visibility of a thread reply. Update the root post instead.",
757
+ );
758
+ }
759
+ if (data.pinned !== undefined) {
760
+ throw new ConflictError(
761
+ "Cannot pin a thread reply. Pin the root post instead.",
762
+ );
763
+ }
314
764
  }
315
765
 
316
- // Handle status/featured change - cascade to thread if this is root
766
+ // Handle status/visibility change - cascade to thread if this is root
317
767
  const statusChanged =
318
768
  data.status !== undefined && data.status !== existing.status;
319
- const featuredChanged =
320
- data.featured !== undefined &&
321
- (data.featured ? 1 : 0) !== existing.featured;
769
+ const visibilityChanged =
770
+ data.visibility !== undefined &&
771
+ data.visibility !== existing.visibility;
772
+ const publishedAtChanged = data.publishedAt !== undefined;
773
+ const nextPublishedAt =
774
+ nextStatus === "draft"
775
+ ? null
776
+ : publishedAtChanged
777
+ ? (data.publishedAt ?? timestamp)
778
+ : existing.status === "draft"
779
+ ? timestamp
780
+ : (existing.publishedAt ?? timestamp);
322
781
 
323
782
  if (statusChanged) updates.status = data.status;
324
- if (featuredChanged) updates.featured = data.featured ? 1 : 0;
783
+ if (visibilityChanged && !isThreadReply(existing)) {
784
+ updates.visibility = data.visibility;
785
+ }
786
+ if (statusChanged || publishedAtChanged || existing.status === "draft") {
787
+ updates.publishedAt = nextPublishedAt;
788
+ updates.lastActivityAt = nextPublishedAt ?? timestamp;
789
+ }
325
790
 
326
791
  // Build all write queries for atomic execution via D1 batch
327
- const needsCascade =
328
- (statusChanged || featuredChanged) && !existing.threadId;
792
+ const needsCascade = statusChanged && !isThreadReply(existing);
793
+ const needsReplyVisibilityCleanup =
794
+ !isThreadReply(existing) && (statusChanged || visibilityChanged);
329
795
  const needsCollectionSync = data.collectionIds !== undefined;
330
- const hasExtraWrites = needsCascade || needsCollectionSync;
796
+ const needsThreadActivityRecalc =
797
+ statusChanged || publishedAtChanged || existing.status === "draft";
798
+ const hasExtraWrites =
799
+ needsCascade || needsReplyVisibilityCleanup || needsCollectionSync;
331
800
 
332
801
  if (!hasExtraWrites) {
333
802
  // Simple case: only the post update
@@ -336,7 +805,11 @@ export function createPostService(
336
805
  .set(updates)
337
806
  .where(eq(posts.id, id))
338
807
  .returning();
339
- return result[0] ? toPost(result[0]) : null;
808
+ if (needsThreadActivityRecalc) {
809
+ await recalculateThreadLastActivity(existing.threadId);
810
+ return this.getById(id);
811
+ }
812
+ return hydratePost(result[0]);
340
813
  }
341
814
 
342
815
  // Complex case: batch cascade + update + collection sync atomically
@@ -348,16 +821,23 @@ export function createPostService(
348
821
  .update(posts)
349
822
  .set({
350
823
  status: data.status ?? (existing.status as Status),
351
- featured: (
352
- data.featured !== undefined
353
- ? data.featured
354
- : existing.featured === 1
355
- )
356
- ? 1
357
- : 0,
824
+ publishedAt: nextStatus === "published" ? nextPublishedAt : null,
825
+ lastActivityAt:
826
+ nextStatus === "published"
827
+ ? (nextPublishedAt ?? timestamp)
828
+ : timestamp,
358
829
  updatedAt: timestamp,
359
830
  })
360
- .where(eq(posts.threadId, id)),
831
+ .where(and(eq(posts.threadId, id), isNotNull(posts.replyToId))),
832
+ );
833
+ }
834
+
835
+ if (needsReplyVisibilityCleanup) {
836
+ writeQueries.push(
837
+ db
838
+ .update(posts)
839
+ .set({ visibility: null, updatedAt: timestamp })
840
+ .where(and(eq(posts.threadId, id), isNotNull(posts.replyToId))),
361
841
  );
362
842
  }
363
843
 
@@ -379,6 +859,7 @@ export function createPostService(
379
859
  data.collectionIds!.map((collectionId) => ({
380
860
  postId: id,
381
861
  collectionId,
862
+ createdAt: now(),
382
863
  })),
383
864
  ),
384
865
  );
@@ -394,7 +875,11 @@ export function createPostService(
394
875
  const updateResult = results[updateIdx] as
395
876
  | (typeof posts.$inferSelect)[]
396
877
  | undefined;
397
- return updateResult?.[0] ? toPost(updateResult[0]) : null;
878
+ if (needsThreadActivityRecalc) {
879
+ await recalculateThreadLastActivity(existing.threadId);
880
+ return this.getById(id);
881
+ }
882
+ return hydratePost(updateResult?.[0]);
398
883
  },
399
884
 
400
885
  async delete(id, deps) {
@@ -403,8 +888,8 @@ export function createPostService(
403
888
 
404
889
  // Clean up media for all affected posts
405
890
  if (deps?.media) {
406
- let postIds: number[];
407
- if (!existing.threadId) {
891
+ let postIds: string[];
892
+ if (!isThreadReply(existing)) {
408
893
  const thread = await this.getThread(id);
409
894
  postIds = thread.map((p) => p.id);
410
895
  } else {
@@ -421,32 +906,21 @@ export function createPostService(
421
906
  }
422
907
  }
423
908
 
424
- // Release paths from registry
425
- if (!existing.threadId) {
426
- // Thread root: release paths for all posts in thread
427
- const thread = await this.getThread(id);
428
- for (const post of thread) {
429
- if (post.path) {
430
- await pathRegistry.release(post.path);
431
- }
432
- }
433
- } else if (existing.path) {
434
- await pathRegistry.release(existing.path);
435
- }
436
-
437
909
  const timestamp = now();
438
910
 
439
911
  // If this is a thread root, soft delete all posts in the thread
440
- if (!existing.threadId) {
912
+ if (!isThreadReply(existing)) {
441
913
  await db
442
914
  .update(posts)
443
915
  .set({ deletedAt: timestamp, updatedAt: timestamp })
444
- .where(or(eq(posts.id, id), eq(posts.threadId, id)));
916
+ .where(eq(posts.threadId, id));
445
917
  } else {
918
+ // Soft-delete the single reply
446
919
  await db
447
920
  .update(posts)
448
921
  .set({ deletedAt: timestamp, updatedAt: timestamp })
449
922
  .where(eq(posts.id, id));
923
+ await recalculateThreadLastActivity(existing.threadId);
450
924
  }
451
925
 
452
926
  return true;
@@ -456,23 +930,37 @@ export function createPostService(
456
930
  const rows = await db
457
931
  .select()
458
932
  .from(posts)
459
- .where(
460
- and(
461
- or(eq(posts.id, rootId), eq(posts.threadId, rootId)),
462
- isNull(posts.deletedAt),
463
- ),
464
- )
933
+ .where(and(eq(posts.threadId, rootId), isNull(posts.deletedAt)))
465
934
  .orderBy(posts.createdAt);
466
935
 
467
- return rows.map(toPost);
936
+ return hydratePosts(rows);
468
937
  },
469
938
 
470
- async updateThreadStatusAndFeatured(rootId, status, featured) {
939
+ async updateThreadStatusAndVisibility(rootId, status, visibility) {
471
940
  const timestamp = now();
472
- await db
473
- .update(posts)
474
- .set({ status, featured: featured ? 1 : 0, updatedAt: timestamp })
475
- .where(eq(posts.threadId, rootId));
941
+ await db.batch([
942
+ db
943
+ .update(posts)
944
+ .set({
945
+ status,
946
+ visibility,
947
+ publishedAt: status === "published" ? timestamp : null,
948
+ lastActivityAt: timestamp,
949
+ updatedAt: timestamp,
950
+ })
951
+ .where(eq(posts.id, rootId)),
952
+ db
953
+ .update(posts)
954
+ .set({
955
+ status,
956
+ visibility: null,
957
+ publishedAt: status === "published" ? timestamp : null,
958
+ lastActivityAt: timestamp,
959
+ updatedAt: timestamp,
960
+ })
961
+ .where(and(eq(posts.threadId, rootId), isNotNull(posts.replyToId))),
962
+ ]);
963
+ await recalculateThreadLastActivity(rootId);
476
964
  },
477
965
 
478
966
  async getReplyCounts(postIds) {
@@ -484,14 +972,19 @@ export function createPostService(
484
972
  count: sql<number>`count(*)`.as("count"),
485
973
  })
486
974
  .from(posts)
487
- .where(and(inArray(posts.threadId, postIds), isNull(posts.deletedAt)))
975
+ .where(
976
+ and(
977
+ inArray(posts.threadId, postIds),
978
+ eq(posts.status, "published"),
979
+ isNotNull(posts.replyToId),
980
+ isNull(posts.deletedAt),
981
+ ),
982
+ )
488
983
  .groupBy(posts.threadId);
489
984
 
490
- const counts = new Map<number, number>();
985
+ const counts = new Map<string, number>();
491
986
  for (const row of rows) {
492
- if (row.threadId !== null) {
493
- counts.set(row.threadId, row.count);
494
- }
987
+ counts.set(row.threadId, row.count);
495
988
  }
496
989
  return counts;
497
990
  },
@@ -502,13 +995,18 @@ export function createPostService(
502
995
  const rows = await db
503
996
  .select()
504
997
  .from(posts)
505
- .where(and(inArray(posts.threadId, rootIds), isNull(posts.deletedAt)))
998
+ .where(
999
+ and(
1000
+ inArray(posts.threadId, rootIds),
1001
+ eq(posts.status, "published"),
1002
+ isNotNull(posts.replyToId),
1003
+ isNull(posts.deletedAt),
1004
+ ),
1005
+ )
506
1006
  .orderBy(posts.threadId, posts.createdAt);
507
1007
 
508
- const result = new Map<number, Post[]>();
509
- for (const row of rows) {
510
- const post = toPost(row);
511
- if (post.threadId === null) continue;
1008
+ const result = new Map<string, Post[]>();
1009
+ for (const post of await hydratePosts(rows)) {
512
1010
  const list = result.get(post.threadId);
513
1011
  if (list) {
514
1012
  if (list.length < previewCount) {
@@ -520,5 +1018,99 @@ export function createPostService(
520
1018
  }
521
1019
  return result;
522
1020
  },
1021
+
1022
+ async getThreadTimelineContext(rootIds) {
1023
+ if (rootIds.length === 0) return new Map();
1024
+
1025
+ // Fetch all non-deleted replies ordered by thread, newest first
1026
+ const rows = await db
1027
+ .select()
1028
+ .from(posts)
1029
+ .where(
1030
+ and(
1031
+ inArray(posts.threadId, rootIds),
1032
+ eq(posts.status, "published"),
1033
+ isNotNull(posts.replyToId),
1034
+ isNull(posts.deletedAt),
1035
+ ),
1036
+ )
1037
+ .orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
1038
+
1039
+ // Group by threadId, extract latest reply + its parent + count
1040
+ const grouped = new Map<string, Post[]>();
1041
+ for (const post of await hydratePosts(rows)) {
1042
+ const list = grouped.get(post.threadId);
1043
+ if (list) {
1044
+ list.push(post);
1045
+ } else {
1046
+ grouped.set(post.threadId, [post]);
1047
+ }
1048
+ }
1049
+
1050
+ const result = new Map<string, ThreadTimelineContext>();
1051
+ for (const [threadId, replies] of grouped) {
1052
+ // replies are ordered newest-first; first element is the latest
1053
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- grouped only contains non-empty arrays
1054
+ const latestReply = replies[0]!;
1055
+ const totalReplyCount = replies.length;
1056
+
1057
+ // Find parent of latestReply if it's not the root
1058
+ let parentReply: Post | null = null;
1059
+ if (latestReply.replyToId && latestReply.replyToId !== threadId) {
1060
+ parentReply =
1061
+ replies.find((r) => r.id === latestReply.replyToId) ?? null;
1062
+ }
1063
+
1064
+ result.set(threadId, { latestReply, parentReply, totalReplyCount });
1065
+ }
1066
+
1067
+ return result;
1068
+ },
1069
+
1070
+ async getLastPostIdsByThread(threadIds) {
1071
+ const result = new Map<string, string>();
1072
+ if (threadIds.length === 0) return result;
1073
+
1074
+ const unique = [...new Set(threadIds)];
1075
+ const rows = await db
1076
+ .select({
1077
+ threadId: posts.threadId,
1078
+ id: sql<string>`(
1079
+ SELECT p2.id FROM post AS p2
1080
+ WHERE p2.thread_id = ${posts.threadId}
1081
+ AND p2.deleted_at IS NULL
1082
+ AND p2.status = 'published'
1083
+ ORDER BY p2.created_at DESC, p2.id DESC
1084
+ LIMIT 1
1085
+ )`.as("last_id"),
1086
+ })
1087
+ .from(posts)
1088
+ .where(inArray(posts.id, unique));
1089
+
1090
+ for (const row of rows) {
1091
+ if (row.id) result.set(row.threadId, row.id);
1092
+ }
1093
+ return result;
1094
+ },
1095
+
1096
+ async getDistinctYears(filters = {}) {
1097
+ const conditions = [
1098
+ ...buildFilterConditions(filters),
1099
+ isNotNull(posts.publishedAt),
1100
+ ];
1101
+
1102
+ const rows = await db
1103
+ .select({
1104
+ year: sql<string>`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`.as(
1105
+ "year",
1106
+ ),
1107
+ })
1108
+ .from(posts)
1109
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
1110
+ .groupBy(sql`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`)
1111
+ .orderBy(desc(sql`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`));
1112
+
1113
+ return rows.map((r) => parseInt(r.year, 10));
1114
+ },
523
1115
  };
524
1116
  }