@jant/core 0.3.32 → 0.3.33

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 (95) hide show
  1. package/dist/client/client.css +1 -1
  2. package/dist/client/client.js +1442 -989
  3. package/dist/index.js +1429 -1055
  4. package/package.json +1 -1
  5. package/src/__tests__/helpers/app.ts +6 -3
  6. package/src/__tests__/helpers/db.ts +3 -0
  7. package/src/client.ts +2 -1
  8. package/src/db/migrations/0011_add_path_registry.sql +23 -0
  9. package/src/db/schema.ts +12 -1
  10. package/src/i18n/locales/en.po +225 -91
  11. package/src/i18n/locales/en.ts +1 -1
  12. package/src/i18n/locales/zh-Hans.po +201 -152
  13. package/src/i18n/locales/zh-Hans.ts +1 -1
  14. package/src/i18n/locales/zh-Hant.po +201 -152
  15. package/src/i18n/locales/zh-Hant.ts +1 -1
  16. package/src/lib/__tests__/excerpt.test.ts +25 -0
  17. package/src/lib/__tests__/resolve-config.test.ts +26 -2
  18. package/src/lib/__tests__/timeline.test.ts +2 -1
  19. package/src/lib/compose-bridge.ts +30 -1
  20. package/src/lib/excerpt.ts +16 -7
  21. package/src/lib/nav-manager-bridge.ts +54 -0
  22. package/src/lib/navigation.ts +7 -4
  23. package/src/lib/render.tsx +5 -2
  24. package/src/lib/resolve-config.ts +7 -0
  25. package/src/lib/view.ts +42 -10
  26. package/src/middleware/error-handler.ts +16 -0
  27. package/src/routes/api/__tests__/posts.test.ts +80 -0
  28. package/src/routes/api/__tests__/settings.test.ts +1 -1
  29. package/src/routes/api/posts.ts +6 -29
  30. package/src/routes/api/upload.ts +2 -14
  31. package/src/routes/auth/__tests__/setup.test.ts +2 -1
  32. package/src/routes/compose.tsx +13 -5
  33. package/src/routes/dash/__tests__/pages.test.ts +2 -1
  34. package/src/routes/dash/__tests__/settings-avatar.test.ts +151 -33
  35. package/src/routes/dash/appearance.tsx +71 -4
  36. package/src/routes/dash/collections.tsx +15 -21
  37. package/src/routes/dash/media.tsx +1 -13
  38. package/src/routes/dash/pages.tsx +5 -150
  39. package/src/routes/dash/posts.tsx +25 -32
  40. package/src/routes/dash/redirects.tsx +9 -11
  41. package/src/routes/dash/settings.tsx +29 -111
  42. package/src/routes/feed/__tests__/rss.test.ts +5 -1
  43. package/src/routes/pages/__tests__/collections.test.ts +2 -1
  44. package/src/routes/pages/__tests__/featured.test.ts +2 -1
  45. package/src/routes/pages/page.tsx +20 -25
  46. package/src/services/__tests__/collection.test.ts +2 -1
  47. package/src/services/__tests__/media.test.ts +78 -1
  48. package/src/services/__tests__/navigation.test.ts +2 -1
  49. package/src/services/__tests__/page.test.ts +78 -1
  50. package/src/services/__tests__/path-registry.test.ts +165 -0
  51. package/src/services/__tests__/post-timeline.test.ts +2 -1
  52. package/src/services/__tests__/post.test.ts +103 -1
  53. package/src/services/__tests__/redirect.test.ts +53 -4
  54. package/src/services/__tests__/search.test.ts +2 -1
  55. package/src/services/__tests__/settings.test.ts +153 -0
  56. package/src/services/index.ts +12 -4
  57. package/src/services/media.ts +72 -4
  58. package/src/services/page.ts +64 -17
  59. package/src/services/path-registry.ts +160 -0
  60. package/src/services/post.ts +119 -24
  61. package/src/services/redirect.ts +23 -3
  62. package/src/services/settings.ts +181 -0
  63. package/src/styles/components.css +135 -0
  64. package/src/styles/tokens.css +6 -1
  65. package/src/styles/ui.css +70 -26
  66. package/src/types/bindings.ts +1 -0
  67. package/src/types/config.ts +7 -2
  68. package/src/types/constants.ts +9 -1
  69. package/src/types/sortablejs.d.ts +8 -2
  70. package/src/types/views.ts +1 -1
  71. package/src/ui/color-themes.ts +31 -31
  72. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +0 -3
  73. package/src/ui/components/__tests__/jant-settings-general.test.ts +2 -6
  74. package/src/ui/components/jant-compose-dialog.ts +3 -2
  75. package/src/ui/components/jant-compose-editor.ts +17 -2
  76. package/src/ui/components/jant-nav-manager.ts +1067 -0
  77. package/src/ui/components/jant-settings-general.ts +2 -35
  78. package/src/ui/components/nav-manager-types.ts +72 -0
  79. package/src/ui/components/settings-types.ts +0 -3
  80. package/src/ui/compose/ComposePrompt.tsx +3 -11
  81. package/src/ui/dash/appearance/AdvancedContent.tsx +0 -3
  82. package/src/ui/dash/appearance/AppearanceNav.tsx +12 -8
  83. package/src/ui/dash/appearance/ColorThemeContent.tsx +1 -4
  84. package/src/ui/dash/appearance/FontThemeContent.tsx +0 -3
  85. package/src/ui/dash/appearance/NavigationContent.tsx +302 -0
  86. package/src/ui/dash/pages/PagesContent.tsx +74 -0
  87. package/src/ui/dash/settings/AccountContent.tsx +0 -3
  88. package/src/ui/dash/settings/GeneralContent.tsx +1 -19
  89. package/src/ui/dash/settings/SettingsNav.tsx +2 -6
  90. package/src/ui/feed/NoteCard.tsx +2 -2
  91. package/src/ui/layouts/DashLayout.tsx +83 -86
  92. package/src/ui/layouts/SiteLayout.tsx +82 -21
  93. package/src/lib/nav-reorder.ts +0 -26
  94. package/src/ui/dash/pages/LinkFormContent.tsx +0 -119
  95. package/src/ui/dash/pages/UnifiedPagesContent.tsx +0 -203
@@ -95,6 +95,159 @@ describe("SettingsService", () => {
95
95
  });
96
96
  });
97
97
 
98
+ describe("updateGeneral", () => {
99
+ const defaults = {
100
+ siteName: "",
101
+ siteDescription: "",
102
+ siteFooter: "",
103
+ siteLanguage: "en",
104
+ homeDefaultView: "latest",
105
+ headerNavMaxVisible: "3",
106
+ timeZone: "UTC",
107
+ };
108
+
109
+ it("sets non-empty values", async () => {
110
+ await settingsService.updateGeneral(
111
+ { ...defaults, siteName: "My Blog", siteDescription: "A blog" },
112
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
113
+ );
114
+
115
+ expect(await settingsService.get("SITE_NAME")).toBe("My Blog");
116
+ expect(await settingsService.get("SITE_DESCRIPTION")).toBe("A blog");
117
+ });
118
+
119
+ it("removes empty values", async () => {
120
+ await settingsService.set("SITE_NAME", "Old Name");
121
+ await settingsService.updateGeneral(
122
+ { ...defaults, siteName: "" },
123
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
124
+ );
125
+
126
+ expect(await settingsService.get("SITE_NAME")).toBeNull();
127
+ });
128
+
129
+ it("trims whitespace from values", async () => {
130
+ await settingsService.updateGeneral(
131
+ { ...defaults, siteName: " Trimmed " },
132
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
133
+ );
134
+
135
+ expect(await settingsService.get("SITE_NAME")).toBe("Trimmed");
136
+ });
137
+
138
+ it("removes HOME_DEFAULT_VIEW when set to default", async () => {
139
+ await settingsService.set("HOME_DEFAULT_VIEW", "featured");
140
+ await settingsService.updateGeneral(
141
+ { ...defaults, homeDefaultView: "latest" },
142
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
143
+ );
144
+
145
+ expect(await settingsService.get("HOME_DEFAULT_VIEW")).toBeNull();
146
+ });
147
+
148
+ it("stores HOME_DEFAULT_VIEW when set to featured", async () => {
149
+ await settingsService.updateGeneral(
150
+ { ...defaults, homeDefaultView: "featured" },
151
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
152
+ );
153
+
154
+ expect(await settingsService.get("HOME_DEFAULT_VIEW")).toBe("featured");
155
+ });
156
+
157
+ it("removes HEADER_NAV_MAX_VISIBLE when set to default (3)", async () => {
158
+ await settingsService.set("HEADER_NAV_MAX_VISIBLE", "5");
159
+ await settingsService.updateGeneral(
160
+ { ...defaults, headerNavMaxVisible: "3" },
161
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
162
+ );
163
+
164
+ expect(await settingsService.get("HEADER_NAV_MAX_VISIBLE")).toBeNull();
165
+ });
166
+
167
+ it("stores HEADER_NAV_MAX_VISIBLE when non-default", async () => {
168
+ await settingsService.updateGeneral(
169
+ { ...defaults, headerNavMaxVisible: "5" },
170
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
171
+ );
172
+
173
+ expect(await settingsService.get("HEADER_NAV_MAX_VISIBLE")).toBe("5");
174
+ });
175
+
176
+ it("removes TIME_ZONE when set to UTC", async () => {
177
+ await settingsService.set("TIME_ZONE", "America/New_York");
178
+ await settingsService.updateGeneral(
179
+ { ...defaults, timeZone: "UTC" },
180
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
181
+ );
182
+
183
+ expect(await settingsService.get("TIME_ZONE")).toBeNull();
184
+ });
185
+
186
+ it("stores TIME_ZONE when non-default", async () => {
187
+ await settingsService.updateGeneral(
188
+ { ...defaults, timeZone: "America/New_York" },
189
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
190
+ );
191
+
192
+ expect(await settingsService.get("TIME_ZONE")).toBe("America/New_York");
193
+ });
194
+
195
+ it("detects language change", async () => {
196
+ const result = await settingsService.updateGeneral(
197
+ { ...defaults, siteLanguage: "sv" },
198
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
199
+ );
200
+
201
+ expect(result.languageChanged).toBe(true);
202
+ });
203
+
204
+ it("returns no language change when same", async () => {
205
+ const result = await settingsService.updateGeneral(defaults, {
206
+ oldLanguage: "en",
207
+ fallbackSiteName: "Jant",
208
+ });
209
+
210
+ expect(result.languageChanged).toBe(false);
211
+ });
212
+
213
+ it("returns display name from siteName when non-empty", async () => {
214
+ const result = await settingsService.updateGeneral(
215
+ { ...defaults, siteName: "My Blog" },
216
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
217
+ );
218
+
219
+ expect(result.displayName).toBe("My Blog");
220
+ });
221
+
222
+ it("returns fallback display name when siteName is empty", async () => {
223
+ const result = await settingsService.updateGeneral(defaults, {
224
+ oldLanguage: "en",
225
+ fallbackSiteName: "Jant",
226
+ });
227
+
228
+ expect(result.displayName).toBe("Jant");
229
+ });
230
+
231
+ it("stores footer when non-empty", async () => {
232
+ await settingsService.updateGeneral(
233
+ { ...defaults, siteFooter: "© 2026" },
234
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
235
+ );
236
+
237
+ expect(await settingsService.get("SITE_FOOTER")).toBe("© 2026");
238
+ });
239
+
240
+ it("removes footer when empty", async () => {
241
+ await settingsService.set("SITE_FOOTER", "Old footer");
242
+ await settingsService.updateGeneral(
243
+ { ...defaults, siteFooter: "" },
244
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
245
+ );
246
+
247
+ expect(await settingsService.get("SITE_FOOTER")).toBeNull();
248
+ });
249
+ });
250
+
98
251
  describe("onboarding", () => {
99
252
  it("returns false when onboarding is not complete", async () => {
100
253
  const result = await settingsService.isOnboardingComplete();
@@ -17,6 +17,10 @@ import {
17
17
  import { createSearchService, type SearchService } from "./search.js";
18
18
  import { createNavItemService, type NavItemService } from "./navigation.js";
19
19
  import { createAuthService, type AuthService } from "./auth.js";
20
+ import {
21
+ createPathRegistryService,
22
+ type PathRegistryService,
23
+ } from "./path-registry.js";
20
24
 
21
25
  export interface Services {
22
26
  settings: SettingsService;
@@ -28,15 +32,18 @@ export interface Services {
28
32
  search: SearchService;
29
33
  navItems: NavItemService;
30
34
  auth: AuthService;
35
+ pathRegistry: PathRegistryService;
31
36
  }
32
37
 
33
38
  export function createServices(db: Database, d1: D1Database): Services {
34
39
  const settings = createSettingsService(db);
40
+ const pathRegistry = createPathRegistryService(db);
35
41
  return {
36
42
  settings,
37
- posts: createPostService(db),
38
- pages: createPageService(db),
39
- redirects: createRedirectService(db),
43
+ pathRegistry,
44
+ posts: createPostService(db, pathRegistry),
45
+ pages: createPageService(db, pathRegistry),
46
+ redirects: createRedirectService(db, pathRegistry),
40
47
  media: createMediaService(db),
41
48
  collections: createCollectionService(db),
42
49
  search: createSearchService(d1),
@@ -46,7 +53,7 @@ export function createServices(db: Database, d1: D1Database): Services {
46
53
  }
47
54
 
48
55
  export type { SettingsService } from "./settings.js";
49
- export type { PostService, PostFilters } from "./post.js";
56
+ export type { PostService, PostFilters, PostDeleteDeps } from "./post.js";
50
57
  export type { PageService, PageFilters } from "./page.js";
51
58
  export type { RedirectService } from "./redirect.js";
52
59
  export type { MediaService, MediaFilters } from "./media.js";
@@ -54,3 +61,4 @@ export type { CollectionService } from "./collection.js";
54
61
  export type { SearchService, SearchResult, SearchOptions } from "./search.js";
55
62
  export type { NavItemService } from "./navigation.js";
56
63
  export type { AuthService } from "./auth.js";
64
+ export type { PathRegistryService, OwnerType } from "./path-registry.js";
@@ -9,7 +9,10 @@ import { uuidv7 } from "uuidv7";
9
9
  import type { Database } from "../db/index.js";
10
10
  import { media } from "../db/schema.js";
11
11
  import { now } from "../lib/time.js";
12
+ import type { StorageDriver } from "../lib/storage.js";
12
13
  import type { Media } from "../types.js";
14
+ import { MAX_MEDIA_ATTACHMENTS } from "../types.js";
15
+ import { ValidationError } from "../lib/errors.js";
13
16
 
14
17
  export interface MediaFilters {
15
18
  limit?: number;
@@ -24,7 +27,29 @@ export interface MediaService {
24
27
  getByPostIds(postIds: number[]): Promise<Map<number, Media[]>>;
25
28
  list(filters?: MediaFilters): Promise<Media[]>;
26
29
  create(data: CreateMediaData): Promise<Media>;
27
- delete(id: string): Promise<boolean>;
30
+ /**
31
+ * Validate media IDs: checks count limit and verifies all IDs exist in the database.
32
+ * No-op when the array is empty.
33
+ *
34
+ * @param ids - Media IDs to validate
35
+ * @throws {ValidationError} When count exceeds MAX_MEDIA_ATTACHMENTS or any ID is missing
36
+ */
37
+ validateIds(ids: string[]): Promise<void>;
38
+ /**
39
+ * Delete a media record and its storage file.
40
+ *
41
+ * @param id - Media record ID
42
+ * @param storage - Optional storage driver; when provided the file is deleted from storage
43
+ * @returns true if the record existed and was deleted
44
+ */
45
+ delete(id: string, storage?: StorageDriver | null): Promise<boolean>;
46
+ /**
47
+ * Delete multiple media records and their storage files.
48
+ *
49
+ * @param ids - Media record IDs
50
+ * @param storage - Optional storage driver; when provided the files are deleted from storage
51
+ */
52
+ deleteByIds(ids: string[], storage?: StorageDriver | null): Promise<void>;
28
53
  getByStorageKey(storageKey: string): Promise<Media | null>;
29
54
  attachToPost(postId: number, mediaIds: string[]): Promise<void>;
30
55
  detachFromPost(postId: number): Promise<void>;
@@ -142,6 +167,21 @@ export function createMediaService(db: Database): MediaService {
142
167
  return rows.map(toMedia);
143
168
  },
144
169
 
170
+ async validateIds(ids) {
171
+ if (ids.length === 0) return;
172
+
173
+ if (ids.length > MAX_MEDIA_ATTACHMENTS) {
174
+ throw new ValidationError(
175
+ `Posts allow at most ${MAX_MEDIA_ATTACHMENTS} media attachments`,
176
+ );
177
+ }
178
+
179
+ const existing = await this.getByIds(ids);
180
+ if (existing.length !== ids.length) {
181
+ throw new ValidationError("One or more media IDs are invalid");
182
+ }
183
+ },
184
+
145
185
  async create(data) {
146
186
  const id = data.id ?? uuidv7();
147
187
  const timestamp = now();
@@ -207,9 +247,37 @@ export function createMediaService(db: Database): MediaService {
207
247
  await db.update(media).set({ alt }).where(eq(media.id, id));
208
248
  },
209
249
 
210
- async delete(id) {
211
- const result = await db.delete(media).where(eq(media.id, id)).returning();
212
- return result.length > 0;
250
+ async delete(id, storage) {
251
+ const record = await this.getById(id);
252
+ if (!record) return false;
253
+
254
+ if (storage) {
255
+ await storage.delete(record.storageKey).catch((err) => {
256
+ // eslint-disable-next-line no-console -- Error logging is intentional
257
+ console.error("Storage delete error:", err);
258
+ });
259
+ }
260
+
261
+ await db.delete(media).where(eq(media.id, id));
262
+ return true;
263
+ },
264
+
265
+ async deleteByIds(ids, storage) {
266
+ if (ids.length === 0) return;
267
+
268
+ if (storage) {
269
+ const records = await this.getByIds(ids);
270
+ await Promise.all(
271
+ records.map((r) =>
272
+ storage.delete(r.storageKey).catch((err) => {
273
+ // eslint-disable-next-line no-console -- Error logging is intentional
274
+ console.error("Storage delete error:", err);
275
+ }),
276
+ ),
277
+ );
278
+ }
279
+
280
+ await db.delete(media).where(inArray(media.id, ids));
213
281
  },
214
282
  };
215
283
  }
@@ -10,6 +10,8 @@ import { pages, navItems } from "../db/schema.js";
10
10
  import { now } from "../lib/time.js";
11
11
  import { render as renderMarkdown } from "../lib/markdown.js";
12
12
  import type { Page, Status, CreatePage, UpdatePage } from "../types.js";
13
+ import type { PathRegistryService } from "./path-registry.js";
14
+ import { ConflictError } from "../lib/errors.js";
13
15
 
14
16
  export interface PageFilters {
15
17
  status?: Status;
@@ -25,7 +27,16 @@ export interface PageService {
25
27
  delete(id: number): Promise<boolean>;
26
28
  }
27
29
 
28
- export function createPageService(db: Database): PageService {
30
+ /** Check if an error is a SQLite UNIQUE constraint violation (D1 or better-sqlite3) */
31
+ function isUniqueConstraintError(err: unknown): boolean {
32
+ const msg = String(err);
33
+ return msg.includes("UNIQUE constraint") || msg.includes("SQLITE_CONSTRAINT");
34
+ }
35
+
36
+ export function createPageService(
37
+ db: Database,
38
+ pathRegistry: PathRegistryService,
39
+ ): PageService {
29
40
  function toPage(row: typeof pages.$inferSelect): Page {
30
41
  return {
31
42
  id: row.id,
@@ -83,31 +94,60 @@ export function createPageService(db: Database): PageService {
83
94
  },
84
95
 
85
96
  async create(data) {
86
- const timestamp = now();
97
+ // Validate and reserve path before DB insert — throws friendly
98
+ // ConflictError/ValidationError instead of a raw UNIQUE constraint error.
99
+ // Uses placeholder owner ID; corrected to real ID after insert.
100
+ await pathRegistry.claim(data.slug, "page", 0);
87
101
 
102
+ const timestamp = now();
88
103
  const bodyHtml = data.body ? renderMarkdown(data.body) : null;
89
104
 
90
- const result = await db
91
- .insert(pages)
92
- .values({
93
- slug: data.slug,
94
- title: data.title ?? null,
95
- body: data.body ?? null,
96
- bodyHtml,
97
- status: data.status ?? "published",
98
- createdAt: timestamp,
99
- updatedAt: timestamp,
100
- })
101
- .returning();
105
+ let page: Page;
106
+ try {
107
+ const result = await db
108
+ .insert(pages)
109
+ .values({
110
+ slug: data.slug,
111
+ title: data.title ?? null,
112
+ body: data.body ?? null,
113
+ bodyHtml,
114
+ status: data.status ?? "published",
115
+ createdAt: timestamp,
116
+ updatedAt: timestamp,
117
+ })
118
+ .returning();
119
+
120
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
121
+ page = toPage(result[0]!);
122
+ } catch (err) {
123
+ await pathRegistry.release(data.slug);
124
+ // Surface DB unique constraint failures as a friendly error
125
+ if (isUniqueConstraintError(err)) {
126
+ throw new ConflictError(`Slug "${data.slug}" is already in use`);
127
+ }
128
+ throw err;
129
+ }
102
130
 
103
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
104
- return toPage(result[0]!);
131
+ // Update registry with actual page ID
132
+ await pathRegistry.release(data.slug);
133
+ await pathRegistry.claim(data.slug, "page", page.id);
134
+
135
+ return page;
105
136
  },
106
137
 
107
138
  async update(id, data) {
108
139
  const existing = await this.getById(id);
109
140
  if (!existing) return null;
110
141
 
142
+ const slugChanging =
143
+ data.slug !== undefined && data.slug !== existing.slug;
144
+
145
+ // If slug is changing, claim the new path first (validates before modifying)
146
+ if (slugChanging) {
147
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by slugChanging check
148
+ await pathRegistry.claim(data.slug!, "page", id);
149
+ }
150
+
111
151
  const timestamp = now();
112
152
  const updates: Partial<typeof pages.$inferInsert> = {
113
153
  updatedAt: timestamp,
@@ -123,7 +163,7 @@ export function createPageService(db: Database): PageService {
123
163
  }
124
164
 
125
165
  // If slug changed, update related nav_items
126
- if (data.slug !== undefined && data.slug !== existing.slug) {
166
+ if (slugChanging) {
127
167
  await db
128
168
  .update(navItems)
129
169
  .set({ url: `/${data.slug}`, updatedAt: timestamp })
@@ -144,10 +184,17 @@ export function createPageService(db: Database): PageService {
144
184
  .where(eq(pages.id, id))
145
185
  .returning();
146
186
 
187
+ // Release old slug from registry after successful update
188
+ if (slugChanging) {
189
+ await pathRegistry.release(existing.slug);
190
+ }
191
+
147
192
  return result[0] ? toPage(result[0]) : null;
148
193
  },
149
194
 
150
195
  async delete(id) {
196
+ // Release path registry entries for this page
197
+ await pathRegistry.releaseByOwner("page", id);
151
198
  // nav_items with page_id FK have ON DELETE CASCADE, so they auto-delete
152
199
  const result = await db.delete(pages).where(eq(pages.id, id)).returning();
153
200
  return result.length > 0;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Path Registry Service
3
+ *
4
+ * Central registry for URL path ownership. Every entity (page, post, redirect)
5
+ * that claims a URL path registers it here. The table's PRIMARY KEY on path
6
+ * provides DB-level uniqueness. Reserved system paths are rejected at the
7
+ * service layer.
8
+ */
9
+
10
+ import { eq, and } from "drizzle-orm";
11
+ import type { Database } from "../db/index.js";
12
+ import { pathRegistry } from "../db/schema.js";
13
+ import { now } from "../lib/time.js";
14
+ import { normalizePath } from "../lib/url.js";
15
+ import { isReservedPath } from "../lib/constants.js";
16
+ import { ValidationError, ConflictError } from "../lib/errors.js";
17
+
18
+ export type OwnerType = "page" | "post" | "redirect";
19
+
20
+ export interface PathRegistryEntry {
21
+ path: string;
22
+ ownerType: OwnerType;
23
+ ownerId: number;
24
+ createdAt: number;
25
+ }
26
+
27
+ export interface PathRegistryService {
28
+ /**
29
+ * Claim a path for an entity. Rejects reserved paths and conflicts.
30
+ * Idempotent: re-claiming the same path for the same owner is a no-op.
31
+ *
32
+ * @param path - The URL path to claim
33
+ * @param ownerType - The type of entity claiming the path
34
+ * @param ownerId - The ID of the entity claiming the path
35
+ * @returns The registry entry
36
+ */
37
+ claim(
38
+ path: string,
39
+ ownerType: OwnerType,
40
+ ownerId: number,
41
+ ): Promise<PathRegistryEntry>;
42
+
43
+ /**
44
+ * Release a claimed path.
45
+ *
46
+ * @param path - The URL path to release
47
+ */
48
+ release(path: string): Promise<void>;
49
+
50
+ /**
51
+ * Release all paths owned by a specific entity.
52
+ *
53
+ * @param ownerType - The type of entity
54
+ * @param ownerId - The ID of the entity
55
+ */
56
+ releaseByOwner(ownerType: OwnerType, ownerId: number): Promise<void>;
57
+
58
+ /**
59
+ * Look up a path in the registry.
60
+ *
61
+ * @param path - The URL path to look up
62
+ * @returns The registry entry, or null if not claimed
63
+ */
64
+ getByPath(path: string): Promise<PathRegistryEntry | null>;
65
+
66
+ /**
67
+ * Check if a path is available (not reserved and not claimed).
68
+ *
69
+ * @param path - The URL path to check
70
+ * @returns true if the path is available
71
+ */
72
+ isAvailable(path: string): Promise<boolean>;
73
+ }
74
+
75
+ export function createPathRegistryService(db: Database): PathRegistryService {
76
+ function toEntry(row: typeof pathRegistry.$inferSelect): PathRegistryEntry {
77
+ return {
78
+ path: row.path,
79
+ ownerType: row.ownerType as OwnerType,
80
+ ownerId: row.ownerId,
81
+ createdAt: row.createdAt,
82
+ };
83
+ }
84
+
85
+ return {
86
+ async claim(path, ownerType, ownerId) {
87
+ const normalized = normalizePath(path);
88
+
89
+ if (isReservedPath(normalized)) {
90
+ throw new ValidationError(
91
+ `Path "${normalized}" is reserved and cannot be used`,
92
+ );
93
+ }
94
+
95
+ // Check existing claim
96
+ const existing = await db
97
+ .select()
98
+ .from(pathRegistry)
99
+ .where(eq(pathRegistry.path, normalized))
100
+ .limit(1);
101
+
102
+ if (existing[0]) {
103
+ const entry = toEntry(existing[0]);
104
+ // Idempotent: same owner re-claiming is a no-op
105
+ if (entry.ownerType === ownerType && entry.ownerId === ownerId) {
106
+ return entry;
107
+ }
108
+ throw new ConflictError(`Path "${normalized}" is already in use`);
109
+ }
110
+
111
+ const timestamp = now();
112
+ await db.insert(pathRegistry).values({
113
+ path: normalized,
114
+ ownerType,
115
+ ownerId,
116
+ createdAt: timestamp,
117
+ });
118
+
119
+ return { path: normalized, ownerType, ownerId, createdAt: timestamp };
120
+ },
121
+
122
+ async release(path) {
123
+ const normalized = normalizePath(path);
124
+ await db.delete(pathRegistry).where(eq(pathRegistry.path, normalized));
125
+ },
126
+
127
+ async releaseByOwner(ownerType, ownerId) {
128
+ await db
129
+ .delete(pathRegistry)
130
+ .where(
131
+ and(
132
+ eq(pathRegistry.ownerType, ownerType),
133
+ eq(pathRegistry.ownerId, ownerId),
134
+ ),
135
+ );
136
+ },
137
+
138
+ async getByPath(path) {
139
+ const normalized = normalizePath(path);
140
+ const result = await db
141
+ .select()
142
+ .from(pathRegistry)
143
+ .where(eq(pathRegistry.path, normalized))
144
+ .limit(1);
145
+ return result[0] ? toEntry(result[0]) : null;
146
+ },
147
+
148
+ async isAvailable(path) {
149
+ const normalized = normalizePath(path);
150
+ if (isReservedPath(normalized)) return false;
151
+
152
+ const existing = await db
153
+ .select()
154
+ .from(pathRegistry)
155
+ .where(eq(pathRegistry.path, normalized))
156
+ .limit(1);
157
+ return existing.length === 0;
158
+ },
159
+ };
160
+ }