@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
@@ -5,50 +5,15 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "6",
8
- "when": 1770564499811,
9
- "tag": "0000_square_wallflower",
8
+ "when": 1773271543545,
9
+ "tag": "0000_baseline",
10
10
  "breakpoints": true
11
11
  },
12
12
  {
13
13
  "idx": 1,
14
14
  "version": "6",
15
- "when": 1770564499812,
16
- "tag": "0001_add_search_fts",
17
- "breakpoints": true
18
- },
19
- {
20
- "idx": 2,
21
- "version": "6",
22
- "when": 1770746168872,
23
- "tag": "0002_add_media_attachments",
24
- "breakpoints": true
25
- },
26
- {
27
- "idx": 3,
28
- "version": "6",
29
- "when": 1770746168873,
30
- "tag": "0003_add_navigation_links",
31
- "breakpoints": true
32
- },
33
- {
34
- "idx": 4,
35
- "version": "6",
36
- "when": 1770946168874,
37
- "tag": "0004_add_storage_provider",
38
- "breakpoints": true
39
- },
40
- {
41
- "idx": 5,
42
- "version": "6",
43
- "when": 1771346168875,
44
- "tag": "0005_v2_schema_migration",
45
- "breakpoints": true
46
- },
47
- {
48
- "idx": 6,
49
- "version": "6",
50
- "when": 1771746168876,
51
- "tag": "0006_rename_slug_to_path",
15
+ "when": 1773271546509,
16
+ "tag": "0001_fts_setup",
52
17
  "breakpoints": true
53
18
  }
54
19
  ]
package/src/db/schema.ts CHANGED
@@ -4,179 +4,439 @@
4
4
  * Database schema for Jant v2
5
5
  */
6
6
 
7
- import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
7
+ import {
8
+ sqliteTable,
9
+ text,
10
+ integer,
11
+ primaryKey,
12
+ foreignKey,
13
+ index,
14
+ uniqueIndex,
15
+ check,
16
+ } from "drizzle-orm/sqlite-core";
17
+ import { sql } from "drizzle-orm";
8
18
 
9
- // =============================================================================
10
- // Posts
11
- // =============================================================================
19
+ const FORMATS = ["note", "link", "quote"] as const;
20
+ const STATUSES = ["draft", "published"] as const;
21
+ const VISIBILITIES = ["public", "unlisted", "private"] as const;
22
+ const SORT_ORDERS = ["newest", "oldest", "rating_desc", "rating_asc"] as const;
23
+ const NAV_ITEM_TYPES = ["link", "system"] as const;
24
+ const MEDIA_KINDS = ["image", "video", "audio", "text", "document"] as const;
25
+ const STORAGE_DRIVERS = ["r2", "s3"] as const;
12
26
 
13
- export const posts = sqliteTable("posts", {
14
- id: integer("id").primaryKey({ autoIncrement: true }),
15
- format: text("format", {
16
- enum: ["note", "link", "quote"],
17
- }).notNull(),
18
- status: text("status", {
19
- enum: ["draft", "published"],
20
- })
21
- .notNull()
22
- .default("published"),
23
- visibility: text("visibility", {
24
- enum: ["listed", "featured", "unlisted"],
25
- })
26
- .notNull()
27
- .default("listed"),
28
- pinned: integer("pinned").notNull().default(0),
29
- path: text("path").unique(),
30
- title: text("title"),
31
- url: text("url"),
32
- body: text("body"),
33
- bodyHtml: text("body_html"),
34
- quoteText: text("quote_text"),
35
- summary: text("summary"),
36
- rating: integer("rating"),
37
- replyToId: integer("reply_to_id"),
38
- threadId: integer("thread_id"),
39
- deletedAt: integer("deleted_at"),
40
- publishedAt: integer("published_at").notNull(),
41
- createdAt: integer("created_at").notNull(),
42
- updatedAt: integer("updated_at").notNull(),
43
- });
27
+ function sqlTextEnum(values: readonly string[]) {
28
+ return sql.raw(values.map((value) => `'${value}'`).join(", "));
29
+ }
44
30
 
45
31
  // =============================================================================
46
- // Pages
32
+ // Posts
47
33
  // =============================================================================
48
34
 
49
- export const pages = sqliteTable("pages", {
50
- id: integer("id").primaryKey({ autoIncrement: true }),
51
- slug: text("slug").notNull().unique(),
52
- title: text("title"),
53
- body: text("body"),
54
- bodyHtml: text("body_html"),
55
- status: text("status", {
56
- enum: ["draft", "published"],
57
- })
58
- .notNull()
59
- .default("published"),
60
- createdAt: integer("created_at").notNull(),
61
- updatedAt: integer("updated_at").notNull(),
62
- });
35
+ export const posts = sqliteTable(
36
+ "post",
37
+ {
38
+ id: text("id").primaryKey(),
39
+ format: text("format", {
40
+ enum: FORMATS,
41
+ }).notNull(),
42
+ status: text("status", {
43
+ enum: STATUSES,
44
+ })
45
+ .notNull()
46
+ .default("published"),
47
+ visibility: text("visibility", {
48
+ enum: VISIBILITIES,
49
+ }).default("public"),
50
+ pinnedAt: integer("pinned_at"),
51
+ featuredAt: integer("featured_at"),
52
+ title: text("title"),
53
+ url: text("url"),
54
+ body: text("body"),
55
+ bodyHtml: text("body_html"),
56
+ bodyText: text("body_text"),
57
+ quoteText: text("quote_text"),
58
+ summary: text("summary"),
59
+ rating: integer("rating"),
60
+ replyToId: text("reply_to_id"),
61
+ threadId: text("thread_id").notNull(),
62
+ deletedAt: integer("deleted_at"),
63
+ publishedAt: integer("published_at"),
64
+ lastActivityAt: integer("last_activity_at"),
65
+ createdAt: integer("created_at").notNull(),
66
+ updatedAt: integer("updated_at").notNull(),
67
+ },
68
+ (table) => [
69
+ check("chk_post_format", sql`${table.format} IN (${sqlTextEnum(FORMATS)})`),
70
+ check(
71
+ "chk_post_status",
72
+ sql`${table.status} IN (${sqlTextEnum(STATUSES)})`,
73
+ ),
74
+ check(
75
+ "chk_post_visibility",
76
+ sql`${table.visibility} IN (${sqlTextEnum(VISIBILITIES)})`,
77
+ ),
78
+ check(
79
+ "chk_post_root_visibility_present",
80
+ sql`(
81
+ ${table.replyToId} IS NULL
82
+ AND ${table.visibility} IS NOT NULL
83
+ ) OR (
84
+ ${table.replyToId} IS NOT NULL
85
+ AND ${table.visibility} IS NULL
86
+ )`,
87
+ ),
88
+ check(
89
+ "chk_post_reply_to_not_self",
90
+ sql`${table.replyToId} IS NULL OR ${table.replyToId} <> ${table.id}`,
91
+ ),
92
+ check(
93
+ "chk_post_thread_shape",
94
+ sql`(
95
+ ${table.replyToId} IS NULL
96
+ AND ${table.threadId} = ${table.id}
97
+ ) OR (
98
+ ${table.replyToId} IS NOT NULL
99
+ AND ${table.threadId} <> ${table.id}
100
+ )`,
101
+ ),
102
+ check(
103
+ "chk_post_reply_not_pinned",
104
+ sql`${table.pinnedAt} IS NULL OR ${table.replyToId} IS NULL`,
105
+ ),
106
+ check(
107
+ "chk_post_format_shape",
108
+ sql`(
109
+ ${table.format} = 'note'
110
+ AND (${table.url} IS NULL OR trim(${table.url}) = '')
111
+ AND (${table.quoteText} IS NULL OR trim(${table.quoteText}) = '')
112
+ ) OR (
113
+ ${table.format} = 'link'
114
+ AND ${table.url} IS NOT NULL
115
+ AND trim(${table.url}) <> ''
116
+ AND (${table.quoteText} IS NULL OR trim(${table.quoteText}) = '')
117
+ ) OR (
118
+ ${table.format} = 'quote'
119
+ AND ${table.quoteText} IS NOT NULL
120
+ AND trim(${table.quoteText}) <> ''
121
+ )`,
122
+ ),
123
+ check(
124
+ "chk_post_rating_range",
125
+ sql`${table.rating} IS NULL OR ${table.rating} BETWEEN 1 AND 5`,
126
+ ),
127
+ check(
128
+ "chk_post_status_published_at",
129
+ sql`(
130
+ ${table.status} = 'draft'
131
+ AND ${table.publishedAt} IS NULL
132
+ ) OR (
133
+ ${table.status} = 'published'
134
+ AND ${table.publishedAt} IS NOT NULL
135
+ )`,
136
+ ),
137
+ foreignKey({
138
+ columns: [table.replyToId],
139
+ foreignColumns: [table.id],
140
+ }),
141
+ foreignKey({
142
+ columns: [table.threadId],
143
+ foreignColumns: [table.id],
144
+ }),
145
+ foreignKey({
146
+ columns: [table.replyToId, table.threadId],
147
+ foreignColumns: [table.id, table.threadId],
148
+ }),
149
+ uniqueIndex("uq_post_id_thread_id").on(table.id, table.threadId),
150
+ index("idx_post_thread_id").on(table.threadId),
151
+ index("idx_post_thread_live_created")
152
+ .on(table.threadId, table.createdAt, table.id)
153
+ .where(sql`${table.deletedAt} IS NULL`),
154
+ index("idx_post_status_deleted_published").on(
155
+ table.status,
156
+ table.deletedAt,
157
+ table.publishedAt,
158
+ ),
159
+ index("idx_post_status_deleted_activity").on(
160
+ table.status,
161
+ table.deletedAt,
162
+ table.lastActivityAt,
163
+ ),
164
+ index("idx_post_root_live_published_activity")
165
+ .on(table.lastActivityAt, table.id)
166
+ .where(
167
+ sql`${table.deletedAt} IS NULL AND ${table.replyToId} IS NULL AND ${table.status} = 'published'`,
168
+ ),
169
+ index("idx_post_root_live_draft_updated")
170
+ .on(table.updatedAt, table.id)
171
+ .where(
172
+ sql`${table.deletedAt} IS NULL AND ${table.replyToId} IS NULL AND ${table.status} = 'draft'`,
173
+ ),
174
+ index("idx_post_reply_live_thread_created")
175
+ .on(table.threadId, table.createdAt, table.id)
176
+ .where(
177
+ sql`${table.deletedAt} IS NULL AND ${table.replyToId} IS NOT NULL AND ${table.status} = 'published'`,
178
+ ),
179
+ ],
180
+ );
63
181
 
64
182
  // =============================================================================
65
183
  // Media
66
184
  // =============================================================================
67
185
 
68
- export const media = sqliteTable("media", {
69
- id: text("id").primaryKey(), // UUIDv7
70
- postId: integer("post_id").references(() => posts.id),
71
- filename: text("filename").notNull(),
72
- originalName: text("original_name").notNull(),
73
- mimeType: text("mime_type").notNull(),
74
- size: integer("size").notNull(),
75
- storageKey: text("storage_key").notNull(),
76
- provider: text("provider").notNull().default("r2"),
77
- width: integer("width"),
78
- height: integer("height"),
79
- alt: text("alt"),
80
- position: integer("position").notNull().default(0),
81
- blurhash: text("blurhash"),
82
- createdAt: integer("created_at").notNull(),
83
- });
186
+ export const media = sqliteTable(
187
+ "media",
188
+ {
189
+ id: text("id").primaryKey(), // UUIDv7
190
+ postId: text("post_id").references(() => posts.id, {
191
+ onDelete: "set null",
192
+ }),
193
+ filename: text("filename").notNull(),
194
+ originalName: text("original_name").notNull(),
195
+ mimeType: text("mime_type").notNull(),
196
+ size: integer("size").notNull(),
197
+ storageKey: text("storage_key").notNull(),
198
+ provider: text("provider").notNull().default("r2"),
199
+ width: integer("width"),
200
+ height: integer("height"),
201
+ alt: text("alt"),
202
+ position: text("position").notNull().default("a0"),
203
+ blurhash: text("blurhash"),
204
+ waveform: text("waveform"),
205
+ posterKey: text("poster_key"),
206
+ summary: text("summary"),
207
+ chars: integer("chars"),
208
+ mediaKind: text("media_kind").notNull().default("document"),
209
+ createdAt: integer("created_at").notNull(),
210
+ updatedAt: integer("updated_at").notNull(),
211
+ },
212
+ (table) => [
213
+ check(
214
+ "chk_media_provider",
215
+ sql`${table.provider} IN (${sqlTextEnum(STORAGE_DRIVERS)})`,
216
+ ),
217
+ check(
218
+ "chk_media_media_kind",
219
+ sql`${table.mediaKind} IN (${sqlTextEnum(MEDIA_KINDS)})`,
220
+ ),
221
+ check("chk_media_size_positive", sql`${table.size} > 0`),
222
+ check("chk_media_position_not_blank", sql`trim(${table.position}) <> ''`),
223
+ check(
224
+ "chk_media_dimensions_positive",
225
+ sql`(
226
+ ${table.width} IS NULL OR ${table.width} > 0
227
+ ) AND (
228
+ ${table.height} IS NULL OR ${table.height} > 0
229
+ )`,
230
+ ),
231
+ check(
232
+ "chk_media_chars_nonnegative",
233
+ sql`${table.chars} IS NULL OR ${table.chars} >= 0`,
234
+ ),
235
+ index("idx_media_post_id_position").on(table.postId, table.position),
236
+ uniqueIndex("uq_media_post_position")
237
+ .on(table.postId, table.position)
238
+ .where(sql`${table.postId} IS NOT NULL`),
239
+ uniqueIndex("uq_media_provider_storage_key").on(
240
+ table.provider,
241
+ table.storageKey,
242
+ ),
243
+ index("idx_media_media_kind_post_id").on(table.mediaKind, table.postId),
244
+ ],
245
+ );
84
246
 
85
247
  // =============================================================================
86
248
  // Collections
87
249
  // =============================================================================
88
250
 
89
- export const collections = sqliteTable("collections", {
90
- id: integer("id").primaryKey({ autoIncrement: true }),
91
- slug: text("slug").notNull().unique(),
92
- title: text("title").notNull(),
93
- description: text("description"),
94
- icon: text("icon"),
95
- sortOrder: text("sort_order", {
96
- enum: ["newest", "oldest", "rating_desc", "rating_asc"],
97
- })
98
- .notNull()
99
- .default("newest"),
100
- position: integer("position").notNull().default(0),
101
- createdAt: integer("created_at").notNull(),
102
- updatedAt: integer("updated_at").notNull(),
103
- });
251
+ export const collections = sqliteTable(
252
+ "collection",
253
+ {
254
+ id: text("id").primaryKey(),
255
+ title: text("title").notNull(),
256
+ description: text("description"),
257
+ icon: text("icon"),
258
+ sortOrder: text("sort_order", {
259
+ enum: SORT_ORDERS,
260
+ })
261
+ .notNull()
262
+ .default("newest"),
263
+ createdAt: integer("created_at").notNull(),
264
+ updatedAt: integer("updated_at").notNull(),
265
+ },
266
+ (table) => [
267
+ check(
268
+ "chk_collection_sort_order",
269
+ sql`${table.sortOrder} IN (${sqlTextEnum(SORT_ORDERS)})`,
270
+ ),
271
+ ],
272
+ );
104
273
 
105
274
  // =============================================================================
106
- // Collection Dividers (standalone sortable separators)
275
+ // Path Registry (slug + alias + redirect)
107
276
  // =============================================================================
108
277
 
109
- export const collectionDividers = sqliteTable("collection_dividers", {
110
- id: integer("id").primaryKey({ autoIncrement: true }),
111
- position: integer("position").notNull().default(0),
112
- createdAt: integer("created_at").notNull(),
113
- updatedAt: integer("updated_at").notNull(),
114
- });
278
+ export const pathRegistry = sqliteTable(
279
+ "path_registry",
280
+ {
281
+ id: text("id").primaryKey(),
282
+ path: text("path").notNull().unique(),
283
+ kind: text("kind", {
284
+ enum: ["slug", "alias", "redirect"],
285
+ }).notNull(),
286
+ postId: text("post_id").references(() => posts.id, {
287
+ onDelete: "cascade",
288
+ }),
289
+ collectionId: text("collection_id").references(() => collections.id, {
290
+ onDelete: "cascade",
291
+ }),
292
+ redirectToPath: text("redirect_to_path"),
293
+ redirectType: integer("redirect_type"),
294
+ createdAt: integer("created_at").notNull(),
295
+ updatedAt: integer("updated_at").notNull(),
296
+ },
297
+ (table) => [
298
+ check(
299
+ "chk_path_registry_kind",
300
+ sql`${table.kind} IN ('slug', 'alias', 'redirect')`,
301
+ ),
302
+ uniqueIndex("uq_path_registry_post_slug")
303
+ .on(table.postId)
304
+ .where(sql`${table.kind} = 'slug' AND ${table.postId} IS NOT NULL`),
305
+ uniqueIndex("uq_path_registry_collection_slug")
306
+ .on(table.collectionId)
307
+ .where(sql`${table.kind} = 'slug' AND ${table.collectionId} IS NOT NULL`),
308
+ index("idx_path_registry_post_id").on(table.postId),
309
+ index("idx_path_registry_collection_id").on(table.collectionId),
310
+ check(
311
+ "chk_path_registry_shape",
312
+ sql`(
313
+ ${table.kind} IN ('slug', 'alias')
314
+ AND (
315
+ (${table.postId} IS NOT NULL AND ${table.collectionId} IS NULL)
316
+ OR (${table.postId} IS NULL AND ${table.collectionId} IS NOT NULL)
317
+ )
318
+ AND ${table.redirectToPath} IS NULL
319
+ AND ${table.redirectType} IS NULL
320
+ ) OR (
321
+ ${table.kind} = 'redirect'
322
+ AND ${table.postId} IS NULL
323
+ AND ${table.collectionId} IS NULL
324
+ AND ${table.redirectToPath} IS NOT NULL
325
+ AND ${table.redirectType} IN (301, 302)
326
+ )`,
327
+ ),
328
+ ],
329
+ );
115
330
 
116
331
  // =============================================================================
117
- // Post-Collection Junction Table (M:N)
332
+ // Sidebar Items (unified ordering for collections + dividers)
118
333
  // =============================================================================
119
334
 
120
- export const postCollections = sqliteTable("post_collections", {
121
- postId: integer("post_id")
122
- .notNull()
123
- .references(() => posts.id, { onDelete: "cascade" }),
124
- collectionId: integer("collection_id")
125
- .notNull()
126
- .references(() => collections.id, { onDelete: "cascade" }),
127
- });
335
+ export const sidebarItems = sqliteTable(
336
+ "sidebar_item",
337
+ {
338
+ id: text("id").primaryKey(),
339
+ type: text("type", { enum: ["collection", "divider"] }).notNull(),
340
+ collectionId: text("collection_id").references(() => collections.id, {
341
+ onDelete: "cascade",
342
+ }),
343
+ position: text("position").notNull().default("a0"),
344
+ createdAt: integer("created_at").notNull(),
345
+ updatedAt: integer("updated_at").notNull(),
346
+ },
347
+ (table) => [
348
+ check(
349
+ "chk_sidebar_item_type",
350
+ sql`${table.type} IN ('collection', 'divider')`,
351
+ ),
352
+ index("idx_sidebar_item_collection_id").on(table.collectionId),
353
+ uniqueIndex("uq_sidebar_item_position").on(table.position),
354
+ uniqueIndex("uq_sidebar_item_collection_once")
355
+ .on(table.collectionId)
356
+ .where(
357
+ sql`${table.type} = 'collection' AND ${table.collectionId} IS NOT NULL`,
358
+ ),
359
+ check(
360
+ "chk_sidebar_item_shape",
361
+ sql`(
362
+ ${table.type} = 'collection' AND ${table.collectionId} IS NOT NULL
363
+ ) OR (
364
+ ${table.type} = 'divider' AND ${table.collectionId} IS NULL
365
+ )`,
366
+ ),
367
+ ],
368
+ );
128
369
 
129
370
  // =============================================================================
130
- // Navigation Items
371
+ // Post-Collection Junction Table (M:N)
131
372
  // =============================================================================
132
373
 
133
- export const navItems = sqliteTable("nav_items", {
134
- id: integer("id").primaryKey({ autoIncrement: true }),
135
- type: text("type", {
136
- enum: ["page", "link", "system"],
137
- })
138
- .notNull()
139
- .default("link"),
140
- label: text("label").notNull(),
141
- url: text("url").notNull(),
142
- pageId: integer("page_id").references(() => pages.id, {
143
- onDelete: "cascade",
144
- }),
145
- position: integer("position").notNull().default(0),
146
- createdAt: integer("created_at").notNull(),
147
- updatedAt: integer("updated_at").notNull(),
148
- });
374
+ export const postCollections = sqliteTable(
375
+ "post_collection",
376
+ {
377
+ postId: text("post_id")
378
+ .notNull()
379
+ .references(() => posts.id, { onDelete: "cascade" }),
380
+ collectionId: text("collection_id")
381
+ .notNull()
382
+ .references(() => collections.id, { onDelete: "cascade" }),
383
+ createdAt: integer("created_at").notNull(),
384
+ },
385
+ (table) => [
386
+ primaryKey({ columns: [table.postId, table.collectionId] }),
387
+ index("idx_post_collection_collection_id").on(table.collectionId),
388
+ ],
389
+ );
149
390
 
150
391
  // =============================================================================
151
- // Redirects
392
+ // Navigation Items
152
393
  // =============================================================================
153
394
 
154
- export const redirects = sqliteTable("redirects", {
155
- id: integer("id").primaryKey({ autoIncrement: true }),
156
- fromPath: text("from_path").notNull().unique(),
157
- toPath: text("to_path").notNull(),
158
- type: integer("type", { mode: "number" }).notNull().default(301),
159
- createdAt: integer("created_at").notNull(),
160
- });
395
+ export const navItems = sqliteTable(
396
+ "nav_item",
397
+ {
398
+ id: text("id").primaryKey(),
399
+ type: text("type", {
400
+ enum: NAV_ITEM_TYPES,
401
+ })
402
+ .notNull()
403
+ .default("link"),
404
+ label: text("label").notNull(),
405
+ url: text("url").notNull(),
406
+ position: text("position").notNull().default("a0"),
407
+ createdAt: integer("created_at").notNull(),
408
+ updatedAt: integer("updated_at").notNull(),
409
+ },
410
+ (table) => [
411
+ check(
412
+ "chk_nav_item_type",
413
+ sql`${table.type} IN (${sqlTextEnum(NAV_ITEM_TYPES)})`,
414
+ ),
415
+ uniqueIndex("uq_nav_item_position").on(table.position),
416
+ ],
417
+ );
161
418
 
162
419
  // =============================================================================
163
- // Path Registry (URL path ownership)
420
+ // Settings (Key-Value)
164
421
  // =============================================================================
165
422
 
166
- export const pathRegistry = sqliteTable("path_registry", {
167
- path: text("path").primaryKey(),
168
- ownerType: text("owner_type").notNull(),
169
- ownerId: integer("owner_id").notNull(),
170
- createdAt: integer("created_at").notNull(),
423
+ export const settings = sqliteTable("setting", {
424
+ key: text("key").primaryKey(),
425
+ value: text("value").notNull(),
426
+ updatedAt: integer("updated_at").notNull(),
171
427
  });
172
428
 
173
429
  // =============================================================================
174
- // Settings (Key-Value)
430
+ // API Tokens
175
431
  // =============================================================================
176
432
 
177
- export const settings = sqliteTable("settings", {
178
- key: text("key").primaryKey(),
179
- value: text("value").notNull(),
433
+ export const apiTokens = sqliteTable("api_token", {
434
+ id: text("id").primaryKey(), // UUIDv7
435
+ name: text("name").notNull(), // User-assigned label
436
+ tokenHash: text("token_hash").notNull().unique(), // SHA-256 hex
437
+ prefix: text("prefix").notNull(), // First 8 hex chars for display
438
+ lastUsedAt: integer("last_used_at"), // Unix seconds, null if never used
439
+ createdAt: integer("created_at").notNull(),
180
440
  updatedAt: integer("updated_at").notNull(),
181
441
  });
182
442
 
@@ -193,23 +453,27 @@ export const user = sqliteTable("user", {
193
453
  .notNull()
194
454
  .default(false),
195
455
  image: text("image"),
196
- role: text("role").default("admin"),
456
+ role: text("role").default("member"),
197
457
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
198
458
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
199
459
  });
200
460
 
201
- export const session = sqliteTable("session", {
202
- id: text("id").primaryKey(),
203
- expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
204
- token: text("token").notNull().unique(),
205
- createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
206
- updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
207
- ipAddress: text("ip_address"),
208
- userAgent: text("user_agent"),
209
- userId: text("user_id")
210
- .notNull()
211
- .references(() => user.id),
212
- });
461
+ export const session = sqliteTable(
462
+ "session",
463
+ {
464
+ id: text("id").primaryKey(),
465
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
466
+ token: text("token").notNull().unique(),
467
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
468
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
469
+ ipAddress: text("ip_address"),
470
+ userAgent: text("user_agent"),
471
+ userId: text("user_id")
472
+ .notNull()
473
+ .references(() => user.id),
474
+ },
475
+ (table) => [index("idx_session_user_id").on(table.userId)],
476
+ );
213
477
 
214
478
  export const account = sqliteTable("account", {
215
479
  id: text("id").primaryKey(),