@jant/core 0.6.15 → 0.6.16

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 (112) hide show
  1. package/bin/commands/import-site.js +7 -0
  2. package/dist/app-C75Zc8dD.js +6 -0
  3. package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
  4. package/dist/client/.vite/manifest.json +8 -8
  5. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  6. package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
  7. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  8. package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
  9. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  10. package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
  11. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  12. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  13. package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
  14. package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
  15. package/dist/index.js +5 -5
  16. package/dist/node.js +6 -6
  17. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  18. package/package.json +1 -1
  19. package/src/__tests__/mise-config.test.ts +22 -0
  20. package/src/client/__tests__/compose-bridge.test.ts +252 -2
  21. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  22. package/src/client/__tests__/toast.test.ts +65 -1
  23. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
  28. package/src/client/components/compose-format-convert.ts +5 -1
  29. package/src/client/components/compose-types.ts +47 -3
  30. package/src/client/components/jant-command-palette.ts +12 -6
  31. package/src/client/components/jant-compose-dialog.ts +1464 -680
  32. package/src/client/components/jant-compose-editor.ts +225 -93
  33. package/src/client/components/jant-compose-fullscreen.ts +3 -5
  34. package/src/client/components/jant-post-menu.ts +91 -21
  35. package/src/client/compose-bridge.ts +143 -122
  36. package/src/client/compose-launch.ts +13 -0
  37. package/src/client/compose-shortcuts.ts +5 -1
  38. package/src/client/post-refresh.ts +135 -0
  39. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  40. package/src/client/tiptap/link-input-rules.ts +4 -4
  41. package/src/client/toast.ts +130 -38
  42. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  43. package/src/db/__tests__/thread-activity.test.ts +113 -0
  44. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  45. package/src/db/migrations/0031_many_ego.sql +3 -0
  46. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  47. package/src/db/migrations/meta/_journal.json +7 -0
  48. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  49. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  50. package/src/db/migrations/pg/meta/_journal.json +7 -0
  51. package/src/db/pg/schema.ts +18 -0
  52. package/src/db/schema.ts +20 -0
  53. package/src/db/thread-activity.ts +101 -0
  54. package/src/i18n/locales/public/en.po +89 -21
  55. package/src/i18n/locales/public/en.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hans.po +88 -20
  57. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/public/zh-Hant.po +88 -20
  59. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/feed.test.ts +168 -0
  61. package/src/lib/__tests__/markdown.test.ts +16 -0
  62. package/src/lib/__tests__/slug.test.ts +18 -0
  63. package/src/lib/__tests__/translit.test.ts +87 -0
  64. package/src/lib/__tests__/url.test.ts +53 -0
  65. package/src/lib/feed.ts +113 -11
  66. package/src/lib/hugo-markdown.ts +5 -0
  67. package/src/lib/link-preview.ts +25 -0
  68. package/src/lib/markdown-manager.ts +6 -1
  69. package/src/lib/post-display.ts +42 -9
  70. package/src/lib/timeline.ts +39 -9
  71. package/src/lib/tiptap-render.ts +4 -2
  72. package/src/lib/translit.ts +288 -0
  73. package/src/lib/url.ts +123 -12
  74. package/src/lib/view.ts +28 -0
  75. package/src/routes/api/__tests__/palette.test.ts +44 -0
  76. package/src/routes/api/posts.ts +13 -9
  77. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  78. package/src/routes/api/public/posts.ts +7 -0
  79. package/src/routes/compose.tsx +6 -2
  80. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  81. package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
  82. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  83. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  84. package/src/routes/pages/archive.tsx +65 -17
  85. package/src/routes/pages/page.tsx +5 -2
  86. package/src/services/__tests__/collection.test.ts +32 -1
  87. package/src/services/__tests__/post-timeline.test.ts +142 -11
  88. package/src/services/__tests__/post.test.ts +277 -0
  89. package/src/services/collection.ts +8 -10
  90. package/src/services/export-theme/assets/client-site.css +1 -1
  91. package/src/services/export.ts +3 -5
  92. package/src/services/path.ts +4 -1
  93. package/src/services/post.ts +297 -100
  94. package/src/services/search.ts +9 -0
  95. package/src/styles/components.css +0 -14
  96. package/src/styles/site-media.css +4 -4
  97. package/src/styles/tokens.css +62 -1
  98. package/src/styles/ui.css +1363 -1283
  99. package/src/types/entities.ts +5 -0
  100. package/src/types/props.ts +12 -0
  101. package/src/types/views.ts +6 -0
  102. package/src/ui/compose/ComposeDialog.tsx +17 -17
  103. package/src/ui/feed/LinkPreview.tsx +2 -7
  104. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  105. package/src/ui/pages/ArchivePage.tsx +131 -24
  106. package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
  107. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  108. package/src/ui/shared/PostFooter.tsx +35 -12
  109. package/src/ui/shared/custom-icons.ts +5 -0
  110. package/src/ui/shared/post-article-attributes.ts +6 -0
  111. package/dist/app-BxCOR3Uc.js +0 -6
  112. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
@@ -32,6 +32,10 @@ import {
32
32
  sqliteSchemaBundle,
33
33
  type DatabaseSchema,
34
34
  } from "../db/schema-bundle.js";
35
+ import {
36
+ buildRootActivityExpr,
37
+ rootActivityColumns,
38
+ } from "../db/thread-activity.js";
35
39
  import { createEntityId } from "../lib/ids.js";
36
40
  import { now } from "../lib/time.js";
37
41
  import { trimTiptapBody } from "../lib/tiptap-render.js";
@@ -141,6 +145,14 @@ export interface PostFilters {
141
145
  publishedAfter?: number;
142
146
  /** Unix timestamp (exclusive) — only posts published before this time */
143
147
  publishedBefore?: number;
148
+ /**
149
+ * Unix timestamp (inclusive) — range filter on whichever column `sortBy`
150
+ * selects. Kept separate from `publishedAfter` so a caller can pin a
151
+ * publication window (the RSS delay) and still filter on the sort axis.
152
+ */
153
+ axisAfter?: number;
154
+ /** Unix timestamp (exclusive) — upper bound on the `sortBy` column */
155
+ axisBefore?: number;
144
156
  /** Media kinds to filter by (OR logic: post has media of ANY selected kind). */
145
157
  mediaKinds?: MediaKind[];
146
158
  /** Filter by media presence */
@@ -156,8 +168,20 @@ export interface PostFilters {
156
168
  hasReplies?: boolean;
157
169
  /** Explicit result sort order */
158
170
  sortOrder?: SortOrder;
159
- /** Timestamp used for chronological sorting (defaults to thread activity). */
160
- sortBy?: "activity" | "published";
171
+ /**
172
+ * Time axis for chronological queries (defaults to announced activity).
173
+ *
174
+ * - `activity` — `lastActivityAt`: last announced post. Quiet replies do not
175
+ * move it. Latest and the feeds use this.
176
+ * - `thread_updated` — `threadUpdatedAt`: last post of any kind, quiet
177
+ * included. The archive's updated sort uses this.
178
+ * - `published` — when the Thread root itself was published.
179
+ *
180
+ * Selects the column used for sorting **and** for the year/month bucketing
181
+ * done by `countByYearMonth` and `getDistinctYears`, so a caller that groups
182
+ * its results cannot end up with buckets that disagree with the order.
183
+ */
184
+ sortBy?: "activity" | "thread_updated" | "published";
161
185
  /** Ignore global pinned ordering when results must remain chronological. */
162
186
  ignorePinnedSort?: boolean;
163
187
  limit?: number;
@@ -289,7 +313,7 @@ export interface PostService {
289
313
  count(filters?: PostFilters): Promise<number>;
290
314
  /** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
291
315
  countUpTo(filters: PostFilters | undefined, limit: number): Promise<number>;
292
- /** Count posts grouped by published year-month (YYYY-MM) */
316
+ /** Count posts grouped by year-month (YYYY-MM) on the `sortBy` time axis */
293
317
  countByYearMonth(
294
318
  filters?: PostFilters,
295
319
  ): Promise<{ yearMonth: string; count: number }[]>;
@@ -346,6 +370,21 @@ export interface PostService {
346
370
  */
347
371
  deleteThreadDraft(id: string, deps?: PostDeleteDeps): Promise<boolean>;
348
372
  getThread(rootId: string): Promise<Post[]>;
373
+ /**
374
+ * 1-based position of a Post in the reply chain running from its Thread root
375
+ * down to it: a root is 1, a reply to it is 2, a reply to that is 3.
376
+ *
377
+ * Threads are stored as a tree (`replyToId`) flattened under one `threadId`,
378
+ * so this is the Post's depth, not how many Posts the Thread holds — two
379
+ * replies to the same parent are both at the same position.
380
+ *
381
+ * @param postId - TypeID of the Post to locate
382
+ * @returns The 1-based position, or 0 when no such Post exists
383
+ * @example
384
+ * // Root -> reply -> reply, asked about the last one
385
+ * await posts.getThreadPosition(lastId); // => 3
386
+ */
387
+ getThreadPosition(postId: string): Promise<number>;
349
388
  updateThreadStatusAndVisibility(
350
389
  rootId: string,
351
390
  status: Status,
@@ -416,10 +455,31 @@ export interface PostService {
416
455
  rootIds: string[],
417
456
  options?: Pick<PostFilters, "publishedBefore">,
418
457
  ): Promise<Map<string, Post[]>>;
419
- /** Get distinct years that have published posts */
458
+ /** Get distinct years with posts, bucketed on the `sortBy` time axis */
420
459
  getDistinctYears(filters?: PostFilters): Promise<number[]>;
421
- /** For each thread ID, return the ID of the last published, non-deleted post */
422
- getLastPostIdsByThread(threadIds: string[]): Promise<Map<string, string>>;
460
+ /**
461
+ * For each Thread ID, resolve the Post that currently ends the chain.
462
+ *
463
+ * Two callers need two different answers. Readers ask what the audience can
464
+ * see, so drafts must not count — the Reply affordance belongs on the last
465
+ * published Post. The reply guard asks what the chain physically ends with,
466
+ * and there an unpublished draft still owns the slot: attaching a second
467
+ * reply to the same parent would fork a structure that is meant to be linear.
468
+ *
469
+ * @param threadIds - Thread root IDs to resolve (duplicates are fine)
470
+ * @param options - `includeDrafts` counts unpublished members as the tail
471
+ * @returns Map of Thread root ID to its tail Post ID; threads with no
472
+ * matching member are absent from the map
473
+ * @example
474
+ * ```ts
475
+ * const tails = await posts.getThreadTailIds([root.id], { includeDrafts: true });
476
+ * const tailId = tails.get(root.id);
477
+ * ```
478
+ */
479
+ getThreadTailIds(
480
+ threadIds: string[],
481
+ options?: { includeDrafts?: boolean },
482
+ ): Promise<Map<string, string>>;
423
483
  /**
424
484
  * Rebuild `post.body_text` for a batch of non-deleted posts, cursor-paginated
425
485
  * by post id. For each row, recomputes the plain-text extraction via
@@ -640,16 +700,27 @@ export function createPostService(
640
700
  const databaseDialect = config.databaseDialect ?? "sqlite";
641
701
  const usesBatchWrites = !supportsDrizzleTransaction(db, databaseDialect);
642
702
 
643
- function buildPublishedYearMonthExpr(): SQL<string> {
703
+ /**
704
+ * Column that carries the time axis selected by `PostFilters.sortBy`.
705
+ * Sorting, year/month bucketing, and date-range filters all read it through
706
+ * here so they can never drift onto different columns.
707
+ */
708
+ function timeAxisColumn(filters: Pick<PostFilters, "sortBy">) {
709
+ if (filters.sortBy === "activity") return posts.lastActivityAt;
710
+ if (filters.sortBy === "thread_updated") return posts.threadUpdatedAt;
711
+ return posts.publishedAt;
712
+ }
713
+
714
+ function buildYearMonthExpr(column: SQLWrapper): SQL<string> {
644
715
  return databaseDialect === "pg"
645
- ? sql<string>`to_char(timezone('UTC', to_timestamp(${posts.publishedAt})), 'YYYY-MM')`
646
- : sql<string>`strftime('%Y-%m', ${posts.publishedAt}, 'unixepoch')`;
716
+ ? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY-MM')`
717
+ : sql<string>`strftime('%Y-%m', ${column}, 'unixepoch')`;
647
718
  }
648
719
 
649
- function buildPublishedYearExpr(): SQL<string> {
720
+ function buildYearExpr(column: SQLWrapper): SQL<string> {
650
721
  return databaseDialect === "pg"
651
- ? sql<string>`to_char(timezone('UTC', to_timestamp(${posts.publishedAt})), 'YYYY')`
652
- : sql<string>`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`;
722
+ ? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY')`
723
+ : sql<string>`strftime('%Y', ${column}, 'unixepoch')`;
653
724
  }
654
725
 
655
726
  const effectiveVisibilityExpr = sql<string>`coalesce(
@@ -690,28 +761,58 @@ export function createPostService(
690
761
  return rows.length > 0;
691
762
  }
692
763
 
693
- async function recalculateThreadLastActivity(rootId: string): Promise<void> {
764
+ /**
765
+ * Recompute both Thread activity timestamps on the root from the Thread's
766
+ * rows.
767
+ *
768
+ * - `lastActivityAt` — newest published post excluding quiet replies. This
769
+ * is "last announced" and drives Latest and the feeds.
770
+ * - `threadUpdatedAt` — newest published post including quiet replies. This
771
+ * is "last changed" and drives the archive's updated sort.
772
+ *
773
+ * Both are derived, never accumulated, so a quiet reply stays quiet no
774
+ * matter what later edits or deletions trigger a recalculation.
775
+ *
776
+ * @param rootId - Thread root post ID
777
+ */
778
+ async function recalculateThreadActivity(rootId: string): Promise<void> {
694
779
  const rootRows = await db
695
780
  .select({
696
- latestPublishedAt: sql<number | null>`MAX(${posts.publishedAt})`.as(
697
- "latest_published_at",
781
+ announcedAt: sql<number | null>`MAX(
782
+ CASE
783
+ WHEN ${posts.quietReply} THEN NULL
784
+ ELSE ${posts.publishedAt}
785
+ END
786
+ )`.as("announced_at"),
787
+ updatedAt: sql<number | null>`MAX(${posts.publishedAt})`.as(
788
+ "thread_updated_at",
698
789
  ),
699
790
  })
700
791
  .from(posts)
701
- .where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId)));
792
+ .where(
793
+ and(
794
+ eq(posts.siteId, siteId),
795
+ eq(posts.threadId, rootId),
796
+ eq(posts.status, "published"),
797
+ ),
798
+ );
702
799
 
703
- const latestPublishedAt = rootRows[0]?.latestPublishedAt ?? null;
800
+ const announcedAt = rootRows[0]?.announcedAt ?? null;
801
+ const threadUpdatedAt = rootRows[0]?.updatedAt ?? null;
704
802
  const root = await db
705
803
  .select({ updatedAt: posts.updatedAt })
706
804
  .from(posts)
707
805
  .where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)))
708
806
  .limit(1);
709
807
 
710
- const lastActivityAt = latestPublishedAt ?? root[0]?.updatedAt ?? now();
808
+ const fallback = root[0]?.updatedAt ?? now();
711
809
 
712
810
  await db
713
811
  .update(posts)
714
- .set({ lastActivityAt })
812
+ .set({
813
+ lastActivityAt: announcedAt ?? fallback,
814
+ threadUpdatedAt: threadUpdatedAt ?? fallback,
815
+ })
715
816
  .where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)));
716
817
  }
717
818
 
@@ -887,6 +988,15 @@ export function createPostService(
887
988
  if (filters.publishedBefore !== undefined) {
888
989
  conditions.push(sql`${posts.publishedAt} < ${filters.publishedBefore}`);
889
990
  }
991
+ if (filters.axisAfter !== undefined || filters.axisBefore !== undefined) {
992
+ const axis = timeAxisColumn(filters);
993
+ if (filters.axisAfter !== undefined) {
994
+ conditions.push(sql`${axis} >= ${filters.axisAfter}`);
995
+ }
996
+ if (filters.axisBefore !== undefined) {
997
+ conditions.push(sql`${axis} < ${filters.axisBefore}`);
998
+ }
999
+ }
890
1000
  if (filters.mediaKinds && filters.mediaKinds.length > 0) {
891
1001
  const placeholders = filters.mediaKinds.map((k) => sql`${k}`);
892
1002
  conditions.push(
@@ -899,19 +1009,13 @@ export function createPostService(
899
1009
  );
900
1010
  }
901
1011
  if (filters.hasMedia !== undefined) {
902
- if (filters.hasMedia) {
903
- conditions.push(
904
- sql`${posts.id} IN (
905
- SELECT post_id FROM media WHERE site_id = ${siteId}
906
- )`,
907
- );
908
- } else {
909
- conditions.push(
910
- sql`${posts.id} NOT IN (
911
- SELECT post_id FROM media WHERE site_id = ${siteId}
912
- )`,
913
- );
914
- }
1012
+ const mediaExists = sql`EXISTS (
1013
+ SELECT 1
1014
+ FROM media
1015
+ WHERE site_id = ${siteId}
1016
+ AND post_id = ${posts.id}
1017
+ )`;
1018
+ conditions.push(filters.hasMedia ? mediaExists : sql`NOT ${mediaExists}`);
915
1019
  }
916
1020
  if (filters.hasTitle !== undefined) {
917
1021
  if (filters.hasTitle) {
@@ -946,19 +1050,6 @@ export function createPostService(
946
1050
  return conditions;
947
1051
  }
948
1052
 
949
- async function getLastLivePostIdInThread(
950
- threadId: string,
951
- ): Promise<string | null> {
952
- const rows = await db
953
- .select({ id: posts.id })
954
- .from(posts)
955
- .where(and(eq(posts.siteId, siteId), eq(posts.threadId, threadId)))
956
- .orderBy(desc(posts.createdAt), desc(posts.id))
957
- .limit(1);
958
-
959
- return rows[0]?.id ?? null;
960
- }
961
-
962
1053
  function getCursorSortTimestamp(
963
1054
  row: typeof posts.$inferSelect,
964
1055
  filters: PostFilters,
@@ -966,9 +1057,33 @@ export function createPostService(
966
1057
  if (filters.sortBy === "published") {
967
1058
  return row.publishedAt ?? row.createdAt;
968
1059
  }
1060
+ if (filters.sortBy === "thread_updated") {
1061
+ return row.threadUpdatedAt ?? -1;
1062
+ }
969
1063
  return row.status === "draft" ? row.updatedAt : (row.lastActivityAt ?? -1);
970
1064
  }
971
1065
 
1066
+ /**
1067
+ * Chronological sort key for `list()`.
1068
+ *
1069
+ * Shared by the ORDER BY and the keyset cursor comparison — they must read
1070
+ * the same expression or pagination silently skips or repeats rows.
1071
+ */
1072
+ function buildSortTimestampExpr(filters: PostFilters): SQLWrapper {
1073
+ if (filters.sortBy === "published") {
1074
+ return sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`;
1075
+ }
1076
+ if (filters.sortBy === "thread_updated") {
1077
+ return posts.threadUpdatedAt;
1078
+ }
1079
+ if (filters.status === "draft") return posts.updatedAt;
1080
+ if (filters.status === "published") return posts.lastActivityAt;
1081
+ return sql<number>`CASE
1082
+ WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
1083
+ ELSE ${posts.lastActivityAt}
1084
+ END`;
1085
+ }
1086
+
972
1087
  function buildLexicographicCursorCondition(
973
1088
  keys: [CursorSortKey, ...CursorSortKey[]],
974
1089
  ): SQL<unknown> {
@@ -1008,17 +1123,7 @@ export function createPostService(
1008
1123
  return null;
1009
1124
  }
1010
1125
 
1011
- const sortTimestampExpr =
1012
- filters.sortBy === "published"
1013
- ? sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`
1014
- : filters.status === "draft"
1015
- ? posts.updatedAt
1016
- : filters.status === "published"
1017
- ? posts.lastActivityAt
1018
- : sql<number>`CASE
1019
- WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
1020
- ELSE ${posts.lastActivityAt}
1021
- END`;
1126
+ const sortTimestampExpr = buildSortTimestampExpr(filters);
1022
1127
  const pinnedSortExpr = sql<number>`coalesce(${posts.pinnedAt}, -1)`;
1023
1128
  const featuredPublishedSortExpr = sql<number>`coalesce(
1024
1129
  ${posts.publishedAt}, ${posts.createdAt}, -1
@@ -1153,8 +1258,14 @@ export function createPostService(
1153
1258
  previewProvider: row.previewProvider,
1154
1259
  replyToId: row.replyToId,
1155
1260
  threadId: row.threadId,
1261
+ quietReply: row.quietReply,
1156
1262
  publishedAt: row.publishedAt,
1157
1263
  lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
1264
+ threadUpdatedAt:
1265
+ row.threadUpdatedAt ??
1266
+ row.lastActivityAt ??
1267
+ row.publishedAt ??
1268
+ row.updatedAt,
1158
1269
  createdAt: row.createdAt,
1159
1270
  updatedAt: row.updatedAt,
1160
1271
  };
@@ -1284,16 +1395,19 @@ export function createPostService(
1284
1395
  return conditions;
1285
1396
  }
1286
1397
 
1398
+ /**
1399
+ * Thread activity timestamp used to order collection threads.
1400
+ *
1401
+ * Rows here are Thread *members* grouped by thread_id, so the definition is
1402
+ * reached through a subquery to the root. The outer MAX only collapses the
1403
+ * group — the subquery returns one value per thread. The outer COALESCE
1404
+ * covers a member whose root row is missing.
1405
+ */
1287
1406
  function buildCollectionThreadActivityExpr(alias: string) {
1288
1407
  return sql<number>`MAX(
1289
1408
  COALESCE(
1290
1409
  (
1291
- SELECT CASE
1292
- WHEN root.updated_at > root.created_at
1293
- AND root.updated_at > COALESCE(root.last_activity_at, -1)
1294
- THEN root.updated_at
1295
- ELSE COALESCE(root.last_activity_at, root.updated_at)
1296
- END
1410
+ SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
1297
1411
  FROM post AS root
1298
1412
  WHERE root.site_id = ${siteId}
1299
1413
  AND root.id = ${posts.threadId}
@@ -1553,17 +1667,7 @@ export function createPostService(
1553
1667
  if (filters.cursor && !cursorCondition) {
1554
1668
  return [];
1555
1669
  }
1556
- const sortTimestamp =
1557
- filters.sortBy === "published"
1558
- ? sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`
1559
- : filters.status === "draft"
1560
- ? posts.updatedAt
1561
- : filters.status === "published"
1562
- ? posts.lastActivityAt
1563
- : sql<number>`CASE
1564
- WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
1565
- ELSE ${posts.lastActivityAt}
1566
- END`;
1670
+ const sortTimestamp = buildSortTimestampExpr(filters);
1567
1671
 
1568
1672
  if (cursorCondition) {
1569
1673
  conditions.push(cursorCondition);
@@ -1645,10 +1749,30 @@ export function createPostService(
1645
1749
  conditions.push(sql`${posts.id} > ${afterId}`);
1646
1750
  }
1647
1751
 
1752
+ // `lastmod` describes the page, and a Thread root's page renders its
1753
+ // replies too — so an added or edited reply changes it even though the
1754
+ // root row is untouched. Unlike ordering, edits do count here: the page
1755
+ // really did change. Mirrors `articleModifiedTime` in lib/post-display.
1756
+ //
1757
+ // The outer columns are written out rather than interpolated: Drizzle
1758
+ // only qualifies projection columns when the query has a join, so
1759
+ // `${posts.id}` would render as a bare "id" here and bind to the
1760
+ // subquery's own alias instead of correlating to the outer row.
1761
+ const threadModifiedAt = sql<number>`COALESCE(
1762
+ (
1763
+ SELECT MAX(member.updated_at)
1764
+ FROM post AS member
1765
+ WHERE member.site_id = ${siteId}
1766
+ AND member.thread_id = "post"."id"
1767
+ AND member.status = 'published'
1768
+ ),
1769
+ "post"."updated_at"
1770
+ )`;
1771
+
1648
1772
  const rows = await db
1649
1773
  .select({
1650
1774
  id: posts.id,
1651
- updatedAt: posts.updatedAt,
1775
+ updatedAt: threadModifiedAt,
1652
1776
  featuredAt: posts.featuredAt,
1653
1777
  })
1654
1778
  .from(posts)
@@ -1738,11 +1862,9 @@ export function createPostService(
1738
1862
  },
1739
1863
 
1740
1864
  async countByYearMonth(filters = {}) {
1741
- const conditions = [
1742
- ...buildFilterConditions(filters),
1743
- isNotNull(posts.publishedAt),
1744
- ];
1745
- const publishedYearMonthExpr = buildPublishedYearMonthExpr();
1865
+ const axis = timeAxisColumn(filters);
1866
+ const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
1867
+ const publishedYearMonthExpr = buildYearMonthExpr(axis);
1746
1868
 
1747
1869
  return db
1748
1870
  .select({
@@ -1766,6 +1888,8 @@ export function createPostService(
1766
1888
  ? ensurePostVisibility(data.visibility)
1767
1889
  : undefined;
1768
1890
  const rating = ensurePostRating(data.rating);
1891
+ // Only replies can be quiet — a root has nothing to stay quiet about.
1892
+ const isQuietReply = Boolean(data.replyToId) && data.quietReply === true;
1769
1893
 
1770
1894
  const rawBody = data.bodyMarkdown
1771
1895
  ? markdownToTiptapJson(data.bodyMarkdown)
@@ -1846,10 +1970,19 @@ export function createPostService(
1846
1970
  );
1847
1971
  }
1848
1972
 
1849
- const lastLivePostId = await getLastLivePostIdInThread(parent.threadId);
1850
- if (lastLivePostId && lastLivePostId !== parent.id) {
1973
+ // Drafts count here: an unpublished reply already owns the tail slot,
1974
+ // and a sibling attached to the same parent would fork the chain.
1975
+ const tailId = (
1976
+ await this.getThreadTailIds([parent.threadId], {
1977
+ includeDrafts: true,
1978
+ })
1979
+ ).get(parent.threadId);
1980
+ if (tailId && tailId !== parent.id) {
1981
+ const tail = await this.getById(tailId);
1851
1982
  throw new ConflictError(
1852
- "This post is no longer the end of the thread. Reply to the latest post instead.",
1983
+ tail?.status === "draft"
1984
+ ? "This thread ends with an unpublished draft. Finish that draft or discard it, then reply."
1985
+ : "This post is no longer the end of the thread. Reply to the latest post instead.",
1853
1986
  );
1854
1987
  }
1855
1988
 
@@ -1860,10 +1993,12 @@ export function createPostService(
1860
1993
  parent.threadId === parent.id
1861
1994
  ? parent
1862
1995
  : await this.getById(parent.threadId);
1863
- if (root) {
1864
- if (data.status !== "draft") {
1865
- status = root.status;
1866
- }
1996
+ // A reply takes the root's status. A draft earlier in the chain does
1997
+ // not hold it back: readers never saw that post, so publishing past it
1998
+ // reads as continuous, and the draft stays parked until it is dealt
1999
+ // with on its own.
2000
+ if (root && data.status !== "draft") {
2001
+ status = root.status;
1867
2002
  }
1868
2003
  visibility = null;
1869
2004
 
@@ -1877,6 +2012,12 @@ export function createPostService(
1877
2012
  }
1878
2013
  }
1879
2014
 
2015
+ // Featured surfaces only list published posts, so featuring a draft
2016
+ // writes a flag that does nothing and shows nowhere. Say so instead.
2017
+ if (status === "draft" && resolvedFeaturedAt !== null) {
2018
+ throw new ConflictError("Publish this post before featuring it.");
2019
+ }
2020
+
1880
2021
  assertDraftPublishedAt(status, data.publishedAt);
1881
2022
  const publishedAt =
1882
2023
  status === "published" ? (data.publishedAt ?? timestamp) : null;
@@ -2006,8 +2147,10 @@ export function createPostService(
2006
2147
  rating,
2007
2148
  replyToId: data.replyToId ?? null,
2008
2149
  threadId,
2150
+ quietReply: isQuietReply,
2009
2151
  publishedAt,
2010
2152
  lastActivityAt: publishedAt ?? timestamp,
2153
+ threadUpdatedAt: publishedAt ?? timestamp,
2011
2154
  createdAt: timestamp,
2012
2155
  updatedAt: timestamp,
2013
2156
  }),
@@ -2083,8 +2226,10 @@ export function createPostService(
2083
2226
  rating,
2084
2227
  replyToId: data.replyToId ?? null,
2085
2228
  threadId,
2229
+ quietReply: isQuietReply,
2086
2230
  publishedAt,
2087
2231
  lastActivityAt: publishedAt ?? timestamp,
2232
+ threadUpdatedAt: publishedAt ?? timestamp,
2088
2233
  createdAt: timestamp,
2089
2234
  updatedAt: timestamp,
2090
2235
  });
@@ -2144,9 +2289,11 @@ export function createPostService(
2144
2289
  throw new ConflictError(`Slug "${slug}" could not be resolved`);
2145
2290
  }
2146
2291
 
2147
- // Bump thread root's lastActivityAt when creating a published reply
2148
- if (data.replyToId && status === "published" && !data.quietReply) {
2149
- await recalculateThreadLastActivity(threadId);
2292
+ // A quiet reply still changes the Thread, it just isn't announced —
2293
+ // the recalculation reads the persisted quiet_reply flag and keeps
2294
+ // lastActivityAt where it was while moving threadUpdatedAt forward.
2295
+ if (data.replyToId && status === "published") {
2296
+ await recalculateThreadActivity(threadId);
2150
2297
  }
2151
2298
 
2152
2299
  return post;
@@ -2208,11 +2355,19 @@ export function createPostService(
2208
2355
  if (!item) continue;
2209
2356
  const { data, attachments } = item;
2210
2357
  const prevPost = created[i - 1];
2358
+ // A reply with no date of its own belongs to the root's moment, not to
2359
+ // whenever the request happened to run. Without this, backdating the
2360
+ // root leaves its replies stamped today and the thread reads as if it
2361
+ // spanned years. Thread order is by `createdAt`/`id` (see the thread
2362
+ // queries), so sharing one `publishedAt` cannot reorder anything.
2363
+ const rootPublishedAt = created[0]?.publishedAt ?? undefined;
2211
2364
  const postData: CreatePost = {
2212
2365
  ...data,
2213
2366
  // Chain each post as a reply to the previous one (server-side chaining)
2214
2367
  replyToId: i === 0 ? data.replyToId : prevPost?.id,
2215
2368
  quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
2369
+ publishedAt:
2370
+ i === 0 ? data.publishedAt : (data.publishedAt ?? rootPublishedAt),
2216
2371
  };
2217
2372
 
2218
2373
  try {
@@ -2334,6 +2489,12 @@ export function createPostService(
2334
2489
  } else if (data.featured !== undefined) {
2335
2490
  updates.featuredAt = data.featured ? timestamp : null;
2336
2491
  }
2492
+ // Same rule `create` enforces: nothing unpublished can be featured.
2493
+ // Only guards turning it on — an existing flag survives a trip back to
2494
+ // draft and takes effect again when the post is republished.
2495
+ if (nextStatus === "draft" && updates.featuredAt) {
2496
+ throw new ConflictError("Publish this post before featuring it.");
2497
+ }
2337
2498
 
2338
2499
  let updatedBody: string | null | undefined;
2339
2500
  if (data.body !== undefined || data.bodyMarkdown !== undefined) {
@@ -2445,7 +2606,7 @@ export function createPostService(
2445
2606
  .where(and(eq(posts.siteId, siteId), eq(posts.id, id)))
2446
2607
  .returning();
2447
2608
  if (needsThreadActivityRecalc) {
2448
- await recalculateThreadLastActivity(existing.threadId);
2609
+ await recalculateThreadActivity(existing.threadId);
2449
2610
  return this.getById(id);
2450
2611
  }
2451
2612
  return hydratePost(result[0]);
@@ -2680,7 +2841,7 @@ export function createPostService(
2680
2841
  }
2681
2842
 
2682
2843
  if (needsThreadActivityRecalc) {
2683
- await recalculateThreadLastActivity(existing.threadId);
2844
+ await recalculateThreadActivity(existing.threadId);
2684
2845
  return this.getById(id);
2685
2846
  }
2686
2847
  return hydratePost(updateResult?.[0]);
@@ -2868,7 +3029,7 @@ export function createPostService(
2868
3029
  await db
2869
3030
  .delete(posts)
2870
3031
  .where(and(eq(posts.siteId, siteId), eq(posts.id, id)));
2871
- await recalculateThreadLastActivity(existing.threadId);
3032
+ await recalculateThreadActivity(existing.threadId);
2872
3033
  }
2873
3034
 
2874
3035
  return true;
@@ -2888,6 +3049,44 @@ export function createPostService(
2888
3049
  return hydratePosts(rows);
2889
3050
  },
2890
3051
 
3052
+ async getThreadPosition(postId) {
3053
+ const targetRows = await db
3054
+ .select({ replyToId: posts.replyToId, threadId: posts.threadId })
3055
+ .from(posts)
3056
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
3057
+ .limit(1);
3058
+
3059
+ const target = targetRows[0];
3060
+ if (!target) return 0;
3061
+ if (!target.replyToId) return 1;
3062
+
3063
+ // One read for the whole Thread, then walk up in memory: a chain of N
3064
+ // Posts would otherwise be N round trips.
3065
+ const threadRows = await db
3066
+ .select({ id: posts.id, replyToId: posts.replyToId })
3067
+ .from(posts)
3068
+ .where(
3069
+ and(eq(posts.siteId, siteId), eq(posts.threadId, target.threadId)),
3070
+ );
3071
+
3072
+ const parentOf = new Map(
3073
+ threadRows.map((row) => [row.id, row.replyToId] as const),
3074
+ );
3075
+
3076
+ let position = 1;
3077
+ let cursor: string | null = target.replyToId;
3078
+ // A cycle is impossible per the schema's CHECK constraints, but walking a
3079
+ // parent chain unguarded turns a bad row into a hung request.
3080
+ const visited = new Set<string>([postId]);
3081
+ while (cursor && !visited.has(cursor)) {
3082
+ visited.add(cursor);
3083
+ position += 1;
3084
+ cursor = parentOf.get(cursor) ?? null;
3085
+ }
3086
+
3087
+ return position;
3088
+ },
3089
+
2891
3090
  async updateThreadStatusAndVisibility(rootId, status, visibility) {
2892
3091
  const nextStatus = ensurePostStatus(status);
2893
3092
  const nextVisibility = ensurePostVisibility(visibility);
@@ -2952,7 +3151,7 @@ export function createPostService(
2952
3151
  );
2953
3152
  });
2954
3153
  }
2955
- await recalculateThreadLastActivity(rootId);
3154
+ await recalculateThreadActivity(rootId);
2956
3155
  },
2957
3156
 
2958
3157
  async getReplyCounts(postIds) {
@@ -3556,7 +3755,7 @@ export function createPostService(
3556
3755
  return result;
3557
3756
  },
3558
3757
 
3559
- async getLastPostIdsByThread(threadIds) {
3758
+ async getThreadTailIds(threadIds, options) {
3560
3759
  const result = new Map<string, string>();
3561
3760
  if (threadIds.length === 0) return result;
3562
3761
 
@@ -3571,7 +3770,7 @@ export function createPostService(
3571
3770
  and(
3572
3771
  eq(posts.siteId, siteId),
3573
3772
  inArray(posts.threadId, unique),
3574
- eq(posts.status, "published"),
3773
+ ...(options?.includeDrafts ? [] : [eq(posts.status, "published")]),
3575
3774
  ),
3576
3775
  )
3577
3776
  .orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
@@ -3585,11 +3784,9 @@ export function createPostService(
3585
3784
  },
3586
3785
 
3587
3786
  async getDistinctYears(filters = {}) {
3588
- const conditions = [
3589
- ...buildFilterConditions(filters),
3590
- isNotNull(posts.publishedAt),
3591
- ];
3592
- const publishedYearExpr = buildPublishedYearExpr();
3787
+ const axis = timeAxisColumn(filters);
3788
+ const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
3789
+ const publishedYearExpr = buildYearExpr(axis);
3593
3790
 
3594
3791
  const rows = await db
3595
3792
  .select({