@jant/core 0.3.36 → 0.3.38

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
@@ -3,28 +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
- * visibility (listed/featured/unlisted), 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
15
  import { renderTiptapJson } from "../lib/tiptap-render.js";
15
- import { extractSummary } from "../lib/summary.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";
16
20
  import type { StorageDriver } from "../lib/storage.js";
17
21
  import type { MediaService } from "./media.js";
18
22
  import type {
19
23
  Format,
20
24
  Status,
21
25
  Visibility,
26
+ MediaKind,
22
27
  Post,
23
28
  CreatePost,
24
29
  UpdatePost,
30
+ ThreadTimelineContext,
25
31
  } from "../types.js";
26
- import type { PathRegistryService } from "./path-registry.js";
27
- import { ConflictError } from "../lib/errors.js";
32
+ import {
33
+ ConflictError,
34
+ ValidationError,
35
+ NotFoundError,
36
+ } from "../lib/errors.js";
37
+ import { createPathService, type PathService } from "./path.js";
28
38
 
29
39
  /** Dependencies for operations that coordinate with other services */
30
40
  export interface PostDeleteDeps {
@@ -37,15 +47,28 @@ export interface PostFilters {
37
47
  status?: Status;
38
48
  visibility?: Visibility;
39
49
  pinned?: boolean;
40
- collectionId?: number;
41
- /** Exclude posts that are replies (have threadId set) */
50
+ featured?: boolean;
51
+ collectionId?: string;
52
+ /** Exclude posts that are replies (have replyToId set) */
42
53
  excludeReplies?: boolean;
43
54
  /** Exclude unlisted posts from results */
44
55
  excludeUnlisted?: boolean;
56
+ /** Exclude private posts from results */
57
+ excludePrivate?: boolean;
45
58
  includeDeleted?: boolean;
46
- 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;
47
70
  limit?: number;
48
- cursor?: number; // post id for cursor pagination
71
+ cursor?: string; // post id for cursor pagination (UUIDv7 sorts chronologically)
49
72
  offset?: number; // offset for page-based pagination
50
73
  }
51
74
 
@@ -56,14 +79,14 @@ export interface SummaryConfig {
56
79
  }
57
80
 
58
81
  export interface PostService {
59
- getById(id: number): Promise<Post | null>;
60
- getByPath(path: string): Promise<Post | null>;
82
+ getById(id: string): Promise<Post | null>;
83
+ getBySlug(slug: string): Promise<Post | null>;
61
84
  list(filters?: PostFilters): Promise<Post[]>;
62
85
  /** Count posts matching filters (ignores cursor, offset, limit) */
63
86
  count(filters?: PostFilters): Promise<number>;
64
87
  create(data: CreatePost, summaryConfig?: SummaryConfig): Promise<Post>;
65
88
  update(
66
- id: number,
89
+ id: string,
67
90
  data: UpdatePost,
68
91
  summaryConfig?: SummaryConfig,
69
92
  ): Promise<Post | null>;
@@ -74,20 +97,34 @@ export interface PostService {
74
97
  * @param id - Post ID
75
98
  * @param deps - Media service and optional storage driver for file cleanup
76
99
  */
77
- delete(id: number, deps?: PostDeleteDeps): Promise<boolean>;
78
- getThread(rootId: number): Promise<Post[]>;
100
+ delete(id: string, deps?: PostDeleteDeps): Promise<boolean>;
101
+ getThread(rootId: string): Promise<Post[]>;
79
102
  updateThreadStatusAndVisibility(
80
- rootId: number,
103
+ rootId: string,
81
104
  status: Status,
82
105
  visibility: Visibility,
83
106
  ): Promise<void>;
84
107
  /** Get reply counts for multiple posts */
85
- getReplyCounts(postIds: number[]): Promise<Map<number, number>>;
108
+ getReplyCounts(postIds: string[]): Promise<Map<string, number>>;
86
109
  /** Get preview replies for multiple thread roots */
87
110
  getThreadPreviews(
88
- rootIds: number[],
111
+ rootIds: string[],
89
112
  previewCount?: number,
90
- ): 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);
91
128
  }
92
129
 
93
130
  /** Check if an error (or any of its causes) is a SQLite UNIQUE constraint violation */
@@ -109,10 +146,102 @@ function isUniqueConstraintError(err: unknown): boolean {
109
146
  return false;
110
147
  }
111
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
+ }
197
+ }
198
+
112
199
  export function createPostService(
113
200
  db: Database,
114
- pathRegistry: PathRegistryService,
201
+ config: { slugIdLength: number },
202
+ paths: PathService = createPathService(db),
115
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
+
116
245
  /** Build WHERE conditions from filters (shared by list and count) */
117
246
  function buildFilterConditions(filters: PostFilters) {
118
247
  const conditions = [];
@@ -121,13 +250,27 @@ export function createPostService(
121
250
  conditions.push(eq(posts.status, filters.status));
122
251
  }
123
252
  if (filters.visibility !== undefined) {
124
- conditions.push(eq(posts.visibility, filters.visibility));
253
+ conditions.push(sql`${effectiveVisibilityExpr} = ${filters.visibility}`);
125
254
  }
126
255
  if (filters.excludeUnlisted) {
127
- conditions.push(sql`${posts.visibility} != 'unlisted'`);
256
+ conditions.push(sql`${effectiveVisibilityExpr} != 'unlisted'`);
257
+ }
258
+ if (filters.excludePrivate) {
259
+ conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
128
260
  }
129
261
  if (filters.pinned !== undefined) {
130
- 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
+ );
131
274
  }
132
275
  if (filters.format) {
133
276
  conditions.push(eq(posts.format, filters.format));
@@ -135,34 +278,68 @@ export function createPostService(
135
278
  if (filters.collectionId !== undefined) {
136
279
  // Filter by collection via junction table
137
280
  conditions.push(
138
- 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})`,
139
282
  );
140
283
  }
141
284
  if (filters.threadId) {
142
285
  conditions.push(eq(posts.threadId, filters.threadId));
143
286
  }
144
287
  if (filters.excludeReplies) {
145
- conditions.push(isNull(posts.threadId));
288
+ conditions.push(isNull(posts.replyToId));
146
289
  }
147
290
  if (!filters.includeDeleted) {
148
291
  conditions.push(isNull(posts.deletedAt));
149
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
+ }
150
321
 
151
322
  return conditions;
152
323
  }
153
324
 
154
- function toPost(row: typeof posts.$inferSelect): Post {
325
+ function toPost(
326
+ row: typeof posts.$inferSelect,
327
+ slug: string,
328
+ visibility: Visibility,
329
+ ): Post {
155
330
  return {
156
331
  id: row.id,
157
332
  format: row.format as Format,
158
333
  status: row.status as Status,
159
- visibility: row.visibility as Visibility,
160
- pinned: row.pinned,
161
- path: row.path,
334
+ visibility,
335
+ pinnedAt: row.pinnedAt,
336
+ featuredAt: row.featuredAt,
337
+ slug,
162
338
  title: row.title,
163
339
  url: row.url,
164
340
  body: row.body,
165
341
  bodyHtml: row.bodyHtml,
342
+ bodyText: row.bodyText,
166
343
  quoteText: row.quoteText,
167
344
  summary: row.summary,
168
345
  rating: row.rating,
@@ -170,11 +347,67 @@ export function createPostService(
170
347
  threadId: row.threadId,
171
348
  deletedAt: row.deletedAt,
172
349
  publishedAt: row.publishedAt,
350
+ lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
173
351
  createdAt: row.createdAt,
174
352
  updatedAt: row.updatedAt,
175
353
  };
176
354
  }
177
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
+
178
411
  return {
179
412
  async getById(id) {
180
413
  const result = await db
@@ -182,20 +415,28 @@ export function createPostService(
182
415
  .from(posts)
183
416
  .where(and(eq(posts.id, id), isNull(posts.deletedAt)))
184
417
  .limit(1);
185
- return result[0] ? toPost(result[0]) : null;
418
+ return hydratePost(result[0]);
186
419
  },
187
420
 
188
- async getByPath(path) {
189
- const result = await db
190
- .select()
191
- .from(posts)
192
- .where(and(eq(posts.path, path), isNull(posts.deletedAt)))
193
- .limit(1);
194
- 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);
195
427
  },
196
428
 
197
429
  async list(filters = {}) {
198
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`;
199
440
 
200
441
  if (filters.cursor) {
201
442
  conditions.push(sql`${posts.id} < ${filters.cursor}`);
@@ -205,7 +446,11 @@ export function createPostService(
205
446
  .select()
206
447
  .from(posts)
207
448
  .where(conditions.length > 0 ? and(...conditions) : undefined)
208
- .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
+ )
209
454
  .limit(filters.limit ?? 100);
210
455
 
211
456
  if (filters.offset !== undefined) {
@@ -213,7 +458,7 @@ export function createPostService(
213
458
  }
214
459
 
215
460
  const rows = await query;
216
- return rows.map(toPost);
461
+ return hydratePosts(rows);
217
462
  },
218
463
 
219
464
  async count(filters = {}) {
@@ -228,96 +473,195 @@ export function createPostService(
228
473
  },
229
474
 
230
475
  async create(data, summaryConfig) {
476
+ const id = uuidv7();
231
477
  const timestamp = now();
232
478
 
233
- const bodyHtml = data.body ? renderTiptapJson(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;
234
490
 
235
491
  // Generate summary for titled notes with body content
236
492
  let summary: string | null = null;
237
- if (data.format === "note" && data.title && data.body && summaryConfig) {
493
+ if (data.format === "note" && data.title && body && summaryConfig) {
238
494
  summary = extractSummary(
239
- data.body,
495
+ body,
240
496
  summaryConfig.maxParagraphs,
241
497
  summaryConfig.maxChars,
242
498
  );
243
499
  }
244
500
 
245
501
  // Handle thread relationship
246
- let threadId: number | null = null;
502
+ let threadId = id;
247
503
  let status: Status = data.status ?? "published";
248
- let visibility: Visibility = data.visibility ?? "listed";
504
+ let visibility: Visibility | null = data.visibility ?? "public";
249
505
 
250
506
  if (data.replyToId) {
251
507
  const parent = await this.getById(data.replyToId);
252
- if (parent) {
253
- threadId = parent.threadId ?? parent.id;
254
- // Inherit status and visibility from root
255
- const root = parent.threadId
256
- ? await this.getById(parent.threadId)
257
- : parent;
258
- 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") {
259
527
  status = root.status as Status;
260
- visibility = root.visibility as Visibility;
261
528
  }
262
529
  }
530
+ visibility = null;
263
531
  }
264
532
 
265
- // Validate path availability before DB insert — throws friendly
266
- // ConflictError/ValidationError instead of a raw UNIQUE constraint error.
267
- // 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
+
268
541
  if (data.path) {
269
- 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
+ });
270
572
  }
271
573
 
272
- let result;
574
+ const collectionIds = [...new Set(data.collectionIds ?? [])];
575
+
273
576
  try {
274
- result = await db
275
- .insert(posts)
276
- .values({
577
+ const writeQueries: BatchItem<"sqlite">[] = [
578
+ db.insert(posts).values({
579
+ id,
277
580
  format: data.format,
278
581
  status,
279
582
  visibility,
280
- pinned: data.pinned ? 1 : 0,
281
- path: data.path ?? null,
583
+ pinnedAt: data.pinned ? timestamp : null,
584
+ featuredAt: data.featured ? timestamp : null,
282
585
  title: data.title ?? null,
283
586
  url: data.url ?? null,
284
- body: data.body ?? null,
587
+ body: body ?? null,
285
588
  bodyHtml,
589
+ bodyText,
286
590
  quoteText: data.quoteText ?? null,
287
591
  summary,
288
592
  rating: data.rating ?? null,
289
593
  replyToId: data.replyToId ?? null,
290
594
  threadId,
291
- publishedAt: data.publishedAt ?? timestamp,
595
+ publishedAt,
596
+ lastActivityAt: publishedAt ?? timestamp,
292
597
  createdAt: timestamp,
293
598
  updatedAt: timestamp,
294
- })
295
- .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
+ );
296
647
  } catch (err) {
297
- if (data.path) await pathRegistry.release(data.path);
298
- if (isUniqueConstraintError(err)) {
299
- 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`);
300
653
  }
301
654
  throw err;
302
655
  }
303
656
 
304
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
305
- const post = toPost(result[0]!);
306
-
307
- // Update registry with actual post ID
308
- if (post.path) {
309
- await pathRegistry.release(post.path);
310
- 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`);
311
660
  }
312
661
 
313
- // Sync collection memberships if provided
314
- if (data.collectionIds && data.collectionIds.length > 0) {
315
- await db.insert(postCollections).values(
316
- data.collectionIds.map((collectionId) => ({
317
- postId: post.id,
318
- collectionId,
319
- })),
320
- );
662
+ // Bump thread root's lastActivityAt when creating a published reply
663
+ if (data.replyToId && status === "published") {
664
+ await recalculateThreadLastActivity(threadId);
321
665
  }
322
666
 
323
667
  return post;
@@ -327,45 +671,73 @@ export function createPostService(
327
671
  const existing = await this.getById(id);
328
672
  if (!existing) return null;
329
673
 
330
- // Handle path changes in the registry before modifying the post
331
- const pathChanging =
332
- data.path !== undefined && data.path !== existing.path;
333
- if (pathChanging) {
334
- // Claim new path (if non-null) before releasing old
335
- if (data.path) {
336
- await pathRegistry.claim(data.path, "post", id);
337
- }
338
- // Release old path (if it existed)
339
- if (existing.path) {
340
- await pathRegistry.release(existing.path);
341
- }
342
- }
343
-
344
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
+
345
688
  const updates: Partial<typeof posts.$inferInsert> = {
346
689
  updatedAt: timestamp,
347
690
  };
348
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
+
349
706
  if (data.format !== undefined) updates.format = data.format;
350
- if (data.path !== undefined) updates.path = data.path;
351
707
  if (data.title !== undefined) updates.title = data.title;
352
708
  if (data.url !== undefined) updates.url = data.url;
353
709
  if (data.quoteText !== undefined) updates.quoteText = data.quoteText;
354
710
  if (data.rating !== undefined) updates.rating = data.rating;
355
- if (data.publishedAt !== undefined)
356
- updates.publishedAt = data.publishedAt;
357
- 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;
358
715
 
359
- if (data.body !== undefined) {
360
- updates.body = data.body;
361
- updates.bodyHtml = data.body ? renderTiptapJson(data.body) : null;
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;
362
727
  }
363
728
 
364
729
  // Recompute summary when body, title, or format change
365
730
  if (summaryConfig) {
366
731
  const format = data.format ?? (existing.format as Format);
367
732
  const title = data.title !== undefined ? data.title : existing.title;
368
- const body = data.body !== undefined ? data.body : existing.body;
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;
369
741
  if (format === "note" && title && body) {
370
742
  updates.summary = extractSummary(
371
743
  body,
@@ -377,21 +749,54 @@ export function createPostService(
377
749
  }
378
750
  }
379
751
 
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
+ }
764
+ }
765
+
380
766
  // Handle status/visibility change - cascade to thread if this is root
381
767
  const statusChanged =
382
768
  data.status !== undefined && data.status !== existing.status;
383
769
  const visibilityChanged =
384
770
  data.visibility !== undefined &&
385
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);
386
781
 
387
782
  if (statusChanged) updates.status = data.status;
388
- if (visibilityChanged) updates.visibility = data.visibility;
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
+ }
389
790
 
390
791
  // Build all write queries for atomic execution via D1 batch
391
- const needsCascade =
392
- (statusChanged || visibilityChanged) && !existing.threadId;
792
+ const needsCascade = statusChanged && !isThreadReply(existing);
793
+ const needsReplyVisibilityCleanup =
794
+ !isThreadReply(existing) && (statusChanged || visibilityChanged);
393
795
  const needsCollectionSync = data.collectionIds !== undefined;
394
- const hasExtraWrites = needsCascade || needsCollectionSync;
796
+ const needsThreadActivityRecalc =
797
+ statusChanged || publishedAtChanged || existing.status === "draft";
798
+ const hasExtraWrites =
799
+ needsCascade || needsReplyVisibilityCleanup || needsCollectionSync;
395
800
 
396
801
  if (!hasExtraWrites) {
397
802
  // Simple case: only the post update
@@ -400,7 +805,11 @@ export function createPostService(
400
805
  .set(updates)
401
806
  .where(eq(posts.id, id))
402
807
  .returning();
403
- 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]);
404
813
  }
405
814
 
406
815
  // Complex case: batch cascade + update + collection sync atomically
@@ -412,11 +821,23 @@ export function createPostService(
412
821
  .update(posts)
413
822
  .set({
414
823
  status: data.status ?? (existing.status as Status),
415
- visibility:
416
- data.visibility ?? (existing.visibility as Visibility),
824
+ publishedAt: nextStatus === "published" ? nextPublishedAt : null,
825
+ lastActivityAt:
826
+ nextStatus === "published"
827
+ ? (nextPublishedAt ?? timestamp)
828
+ : timestamp,
417
829
  updatedAt: timestamp,
418
830
  })
419
- .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))),
420
841
  );
421
842
  }
422
843
 
@@ -438,6 +859,7 @@ export function createPostService(
438
859
  data.collectionIds!.map((collectionId) => ({
439
860
  postId: id,
440
861
  collectionId,
862
+ createdAt: now(),
441
863
  })),
442
864
  ),
443
865
  );
@@ -453,7 +875,11 @@ export function createPostService(
453
875
  const updateResult = results[updateIdx] as
454
876
  | (typeof posts.$inferSelect)[]
455
877
  | undefined;
456
- 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]);
457
883
  },
458
884
 
459
885
  async delete(id, deps) {
@@ -462,8 +888,8 @@ export function createPostService(
462
888
 
463
889
  // Clean up media for all affected posts
464
890
  if (deps?.media) {
465
- let postIds: number[];
466
- if (!existing.threadId) {
891
+ let postIds: string[];
892
+ if (!isThreadReply(existing)) {
467
893
  const thread = await this.getThread(id);
468
894
  postIds = thread.map((p) => p.id);
469
895
  } else {
@@ -480,32 +906,21 @@ export function createPostService(
480
906
  }
481
907
  }
482
908
 
483
- // Release paths from registry
484
- if (!existing.threadId) {
485
- // Thread root: release paths for all posts in thread
486
- const thread = await this.getThread(id);
487
- for (const post of thread) {
488
- if (post.path) {
489
- await pathRegistry.release(post.path);
490
- }
491
- }
492
- } else if (existing.path) {
493
- await pathRegistry.release(existing.path);
494
- }
495
-
496
909
  const timestamp = now();
497
910
 
498
911
  // If this is a thread root, soft delete all posts in the thread
499
- if (!existing.threadId) {
912
+ if (!isThreadReply(existing)) {
500
913
  await db
501
914
  .update(posts)
502
915
  .set({ deletedAt: timestamp, updatedAt: timestamp })
503
- .where(or(eq(posts.id, id), eq(posts.threadId, id)));
916
+ .where(eq(posts.threadId, id));
504
917
  } else {
918
+ // Soft-delete the single reply
505
919
  await db
506
920
  .update(posts)
507
921
  .set({ deletedAt: timestamp, updatedAt: timestamp })
508
922
  .where(eq(posts.id, id));
923
+ await recalculateThreadLastActivity(existing.threadId);
509
924
  }
510
925
 
511
926
  return true;
@@ -515,23 +930,37 @@ export function createPostService(
515
930
  const rows = await db
516
931
  .select()
517
932
  .from(posts)
518
- .where(
519
- and(
520
- or(eq(posts.id, rootId), eq(posts.threadId, rootId)),
521
- isNull(posts.deletedAt),
522
- ),
523
- )
933
+ .where(and(eq(posts.threadId, rootId), isNull(posts.deletedAt)))
524
934
  .orderBy(posts.createdAt);
525
935
 
526
- return rows.map(toPost);
936
+ return hydratePosts(rows);
527
937
  },
528
938
 
529
939
  async updateThreadStatusAndVisibility(rootId, status, visibility) {
530
940
  const timestamp = now();
531
- await db
532
- .update(posts)
533
- .set({ status, visibility, updatedAt: timestamp })
534
- .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);
535
964
  },
536
965
 
537
966
  async getReplyCounts(postIds) {
@@ -543,14 +972,19 @@ export function createPostService(
543
972
  count: sql<number>`count(*)`.as("count"),
544
973
  })
545
974
  .from(posts)
546
- .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
+ )
547
983
  .groupBy(posts.threadId);
548
984
 
549
- const counts = new Map<number, number>();
985
+ const counts = new Map<string, number>();
550
986
  for (const row of rows) {
551
- if (row.threadId !== null) {
552
- counts.set(row.threadId, row.count);
553
- }
987
+ counts.set(row.threadId, row.count);
554
988
  }
555
989
  return counts;
556
990
  },
@@ -561,13 +995,18 @@ export function createPostService(
561
995
  const rows = await db
562
996
  .select()
563
997
  .from(posts)
564
- .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
+ )
565
1006
  .orderBy(posts.threadId, posts.createdAt);
566
1007
 
567
- const result = new Map<number, Post[]>();
568
- for (const row of rows) {
569
- const post = toPost(row);
570
- if (post.threadId === null) continue;
1008
+ const result = new Map<string, Post[]>();
1009
+ for (const post of await hydratePosts(rows)) {
571
1010
  const list = result.get(post.threadId);
572
1011
  if (list) {
573
1012
  if (list.length < previewCount) {
@@ -579,5 +1018,99 @@ export function createPostService(
579
1018
  }
580
1019
  return result;
581
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
+ },
582
1115
  };
583
1116
  }