@jant/core 0.3.35 → 0.3.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Path Service
3
+ *
4
+ * Centralizes path ownership and resolution for posts, collections, aliases,
5
+ * and redirects. Stored paths are normalized relative paths without a leading
6
+ * slash (for example: "hello-world" or "c/notes").
7
+ */
8
+
9
+ import { and, eq, inArray, isNotNull, ne } from "drizzle-orm";
10
+ import { uuidv7 } from "uuidv7";
11
+ import { type Database, batchQuery } from "../db/index.js";
12
+ import { pathRegistry } from "../db/schema.js";
13
+ import { now } from "../lib/time.js";
14
+ import { ConflictError } from "../lib/errors.js";
15
+ import { normalizePath } from "../lib/url.js";
16
+ import type { PathKind, PathRecord } from "../types.js";
17
+
18
+ export interface ResolvedPath extends PathRecord {
19
+ targetType: "post" | "collection" | "redirect";
20
+ }
21
+
22
+ export interface CreatePathInput {
23
+ path: string;
24
+ kind: PathKind;
25
+ postId?: string | null;
26
+ collectionId?: string | null;
27
+ redirectToPath?: string | null;
28
+ redirectType?: 301 | 302 | null;
29
+ }
30
+
31
+ export interface PathService {
32
+ getByPath(path: string): Promise<PathRecord | null>;
33
+ resolve(path: string): Promise<ResolvedPath | null>;
34
+ isPathAvailable(path: string, excludeId?: string): Promise<boolean>;
35
+ getPostSlug(postId: string): Promise<string | null>;
36
+ getCollectionSlug(collectionId: string): Promise<string | null>;
37
+ getPostSlugMap(postIds: string[]): Promise<Map<string, string>>;
38
+ getCollectionSlugMap(collectionIds: string[]): Promise<Map<string, string>>;
39
+ create(input: CreatePathInput): Promise<PathRecord>;
40
+ createPostSlug(postId: string, slug: string): Promise<PathRecord>;
41
+ updatePostSlug(postId: string, slug: string): Promise<void>;
42
+ createCollectionSlug(collectionId: string, slug: string): Promise<PathRecord>;
43
+ updateCollectionSlug(collectionId: string, slug: string): Promise<void>;
44
+ deleteByPostId(postId: string): Promise<void>;
45
+ getPostAliases(postIds: string[]): Promise<Map<string, string[]>>;
46
+ }
47
+
48
+ export function toCollectionPath(slug: string): string {
49
+ return normalizePath(`c/${slug}`);
50
+ }
51
+
52
+ export function fromCollectionPath(path: string): string {
53
+ return path.startsWith("c/") ? path.slice(2) : path;
54
+ }
55
+
56
+ function isUniqueConstraintError(err: unknown): boolean {
57
+ let current: unknown = err;
58
+ while (current) {
59
+ const msg = String(current);
60
+ if (
61
+ msg.includes("UNIQUE constraint") ||
62
+ msg.includes("SQLITE_CONSTRAINT")
63
+ ) {
64
+ return true;
65
+ }
66
+ current =
67
+ current instanceof Error && current.cause !== current
68
+ ? current.cause
69
+ : undefined;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ export function createPathService(db: Database): PathService {
75
+ function toPathRecord(row: typeof pathRegistry.$inferSelect): PathRecord {
76
+ return {
77
+ id: row.id,
78
+ path: row.path,
79
+ kind: row.kind as PathKind,
80
+ postId: row.postId,
81
+ collectionId: row.collectionId,
82
+ redirectToPath: row.redirectToPath,
83
+ redirectType: row.redirectType as 301 | 302 | null,
84
+ createdAt: row.createdAt,
85
+ updatedAt: row.updatedAt,
86
+ };
87
+ }
88
+
89
+ function normalizeStoredPath(path: string): string {
90
+ return normalizePath(path);
91
+ }
92
+
93
+ async function insertPath(input: CreatePathInput): Promise<PathRecord> {
94
+ const timestamp = now();
95
+ const normalizedPath = normalizeStoredPath(input.path);
96
+
97
+ try {
98
+ const result = await db
99
+ .insert(pathRegistry)
100
+ .values({
101
+ id: uuidv7(),
102
+ path: normalizedPath,
103
+ kind: input.kind,
104
+ postId: input.postId ?? null,
105
+ collectionId: input.collectionId ?? null,
106
+ redirectToPath: input.redirectToPath
107
+ ? normalizeStoredPath(input.redirectToPath)
108
+ : null,
109
+ redirectType: input.redirectType ?? null,
110
+ createdAt: timestamp,
111
+ updatedAt: timestamp,
112
+ })
113
+ .returning();
114
+
115
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
116
+ return toPathRecord(result[0]!);
117
+ } catch (err) {
118
+ if (isUniqueConstraintError(err)) {
119
+ throw new ConflictError(`Path "${normalizedPath}" is already in use`);
120
+ }
121
+ throw err;
122
+ }
123
+ }
124
+
125
+ return {
126
+ async getByPath(path) {
127
+ const normalized = normalizeStoredPath(path);
128
+ const result = await db
129
+ .select()
130
+ .from(pathRegistry)
131
+ .where(eq(pathRegistry.path, normalized))
132
+ .limit(1);
133
+ return result[0] ? toPathRecord(result[0]) : null;
134
+ },
135
+
136
+ async resolve(path) {
137
+ const record = await this.getByPath(path);
138
+ if (!record) return null;
139
+
140
+ const targetType =
141
+ record.kind === "redirect"
142
+ ? "redirect"
143
+ : record.postId
144
+ ? "post"
145
+ : "collection";
146
+
147
+ return { ...record, targetType };
148
+ },
149
+
150
+ async isPathAvailable(path, excludeId) {
151
+ const normalized = normalizeStoredPath(path);
152
+ const conditions = [eq(pathRegistry.path, normalized)];
153
+ if (excludeId) conditions.push(ne(pathRegistry.id, excludeId));
154
+
155
+ const result = await db
156
+ .select({ id: pathRegistry.id })
157
+ .from(pathRegistry)
158
+ .where(and(...conditions))
159
+ .limit(1);
160
+
161
+ return result.length === 0;
162
+ },
163
+
164
+ async getPostSlug(postId) {
165
+ const result = await db
166
+ .select({ path: pathRegistry.path })
167
+ .from(pathRegistry)
168
+ .where(
169
+ and(eq(pathRegistry.postId, postId), eq(pathRegistry.kind, "slug")),
170
+ )
171
+ .limit(1);
172
+ return result[0]?.path ?? null;
173
+ },
174
+
175
+ async getCollectionSlug(collectionId) {
176
+ const result = await db
177
+ .select({ path: pathRegistry.path })
178
+ .from(pathRegistry)
179
+ .where(
180
+ and(
181
+ eq(pathRegistry.collectionId, collectionId),
182
+ eq(pathRegistry.kind, "slug"),
183
+ ),
184
+ )
185
+ .limit(1);
186
+ return result[0] ? fromCollectionPath(result[0].path) : null;
187
+ },
188
+
189
+ async getPostSlugMap(postIds) {
190
+ if (postIds.length === 0) return new Map<string, string>();
191
+
192
+ return batchQuery(postIds, async (chunk) => {
193
+ const result = new Map<string, string>();
194
+ const rows = await db
195
+ .select({
196
+ postId: pathRegistry.postId,
197
+ path: pathRegistry.path,
198
+ })
199
+ .from(pathRegistry)
200
+ .where(
201
+ and(
202
+ inArray(pathRegistry.postId, chunk),
203
+ eq(pathRegistry.kind, "slug"),
204
+ isNotNull(pathRegistry.postId),
205
+ ),
206
+ );
207
+
208
+ for (const row of rows) {
209
+ if (row.postId) result.set(row.postId, row.path);
210
+ }
211
+ return result;
212
+ });
213
+ },
214
+
215
+ async getCollectionSlugMap(collectionIds) {
216
+ if (collectionIds.length === 0) return new Map<string, string>();
217
+
218
+ return batchQuery(collectionIds, async (chunk) => {
219
+ const result = new Map<string, string>();
220
+ const rows = await db
221
+ .select({
222
+ collectionId: pathRegistry.collectionId,
223
+ path: pathRegistry.path,
224
+ })
225
+ .from(pathRegistry)
226
+ .where(
227
+ and(
228
+ inArray(pathRegistry.collectionId, chunk),
229
+ eq(pathRegistry.kind, "slug"),
230
+ isNotNull(pathRegistry.collectionId),
231
+ ),
232
+ );
233
+
234
+ for (const row of rows) {
235
+ if (row.collectionId) {
236
+ result.set(row.collectionId, fromCollectionPath(row.path));
237
+ }
238
+ }
239
+ return result;
240
+ });
241
+ },
242
+
243
+ async create(input) {
244
+ return insertPath(input);
245
+ },
246
+
247
+ async createPostSlug(postId, slug) {
248
+ return insertPath({ path: slug, kind: "slug", postId });
249
+ },
250
+
251
+ async updatePostSlug(postId, slug) {
252
+ const timestamp = now();
253
+ const normalized = normalizeStoredPath(slug);
254
+
255
+ try {
256
+ await db
257
+ .update(pathRegistry)
258
+ .set({
259
+ path: normalized,
260
+ updatedAt: timestamp,
261
+ })
262
+ .where(
263
+ and(eq(pathRegistry.postId, postId), eq(pathRegistry.kind, "slug")),
264
+ );
265
+ } catch (err) {
266
+ if (isUniqueConstraintError(err)) {
267
+ throw new ConflictError(`Path "${normalized}" is already in use`);
268
+ }
269
+ throw err;
270
+ }
271
+ },
272
+
273
+ async createCollectionSlug(collectionId, slug) {
274
+ return insertPath({
275
+ path: toCollectionPath(slug),
276
+ kind: "slug",
277
+ collectionId,
278
+ });
279
+ },
280
+
281
+ async updateCollectionSlug(collectionId, slug) {
282
+ const timestamp = now();
283
+ const normalized = toCollectionPath(slug);
284
+
285
+ try {
286
+ await db
287
+ .update(pathRegistry)
288
+ .set({
289
+ path: normalized,
290
+ updatedAt: timestamp,
291
+ })
292
+ .where(
293
+ and(
294
+ eq(pathRegistry.collectionId, collectionId),
295
+ eq(pathRegistry.kind, "slug"),
296
+ ),
297
+ );
298
+ } catch (err) {
299
+ if (isUniqueConstraintError(err)) {
300
+ throw new ConflictError(`Path "${normalized}" is already in use`);
301
+ }
302
+ throw err;
303
+ }
304
+ },
305
+
306
+ async deleteByPostId(postId) {
307
+ await db.delete(pathRegistry).where(eq(pathRegistry.postId, postId));
308
+ },
309
+
310
+ async getPostAliases(postIds) {
311
+ if (postIds.length === 0) return new Map<string, string[]>();
312
+
313
+ return batchQuery(postIds, async (chunk) => {
314
+ const result = new Map<string, string[]>();
315
+ const rows = await db
316
+ .select({
317
+ postId: pathRegistry.postId,
318
+ path: pathRegistry.path,
319
+ })
320
+ .from(pathRegistry)
321
+ .where(
322
+ and(
323
+ inArray(pathRegistry.postId, chunk),
324
+ inArray(pathRegistry.kind, ["alias", "redirect"]),
325
+ isNotNull(pathRegistry.postId),
326
+ ),
327
+ );
328
+
329
+ for (const row of rows) {
330
+ if (!row.postId) continue;
331
+ const existing = result.get(row.postId) ?? [];
332
+ existing.push(`/${row.path}`);
333
+ result.set(row.postId, existing);
334
+ }
335
+ return result;
336
+ });
337
+ },
338
+ };
339
+ }