@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.
- package/bin/commands/export.js +1 -1
- package/bin/commands/import-site.js +529 -0
- package/bin/commands/reset-password.js +3 -2
- package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
- package/dist/client/assets/module-RjUF93sV.js +716 -0
- package/dist/client/assets/native-48B9X9Wg.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4564 -3013
- package/dist/index.js +12885 -8161
- package/package.json +23 -6
- package/src/__tests__/helpers/app.ts +10 -10
- package/src/__tests__/helpers/db.ts +91 -87
- package/src/app.tsx +157 -31
- package/src/auth.ts +20 -2
- package/src/client/archive-nav.js +187 -0
- package/src/client/audio-player.ts +478 -0
- package/src/client/audio-processor.ts +84 -0
- package/src/{lib → client}/avatar-upload.ts +4 -3
- package/src/{lib → client}/collection-form-bridge.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
- package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
- package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +43 -0
- package/src/{ui → client}/components/collection-types.ts +3 -4
- package/src/client/components/compose-types.ts +174 -0
- package/src/client/components/jant-collection-form.ts +667 -0
- package/src/client/components/jant-collection-sidebar.ts +805 -0
- package/src/client/components/jant-compose-dialog.ts +2161 -0
- package/src/client/components/jant-compose-editor.ts +1813 -0
- package/src/client/components/jant-compose-fullscreen.ts +283 -0
- package/src/client/components/jant-media-lightbox.ts +259 -0
- package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
- package/src/{ui → client}/components/jant-post-form.ts +141 -12
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
- package/src/{ui → client}/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/{ui → client}/components/nav-manager-types.ts +6 -18
- package/src/{ui → client}/components/post-form-template.ts +137 -38
- package/src/{ui → client}/components/post-form-types.ts +15 -4
- package/src/client/compose-bridge.ts +583 -0
- package/src/{lib → client}/image-processor.ts +26 -8
- package/src/client/lazy-slugify.ts +51 -0
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/{lib → client}/nav-manager-bridge.ts +1 -1
- package/src/{lib → client}/post-form-bridge.ts +53 -2
- package/src/{lib → client}/settings-bridge.ts +3 -15
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/bubble-menu.ts +205 -0
- package/src/client/tiptap/create-editor.ts +86 -0
- package/src/client/tiptap/exitable-marks.ts +73 -0
- package/src/client/tiptap/extensions.ts +65 -0
- package/src/client/tiptap/image-node.ts +482 -0
- package/src/client/tiptap/link-toolbar.ts +371 -0
- package/src/client/tiptap/more-break.ts +50 -0
- package/src/client/tiptap/paste-image.ts +129 -0
- package/src/client/tiptap/slash-commands.ts +438 -0
- package/src/{lib → client}/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +44 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +27 -17
- package/src/db/__tests__/migrations.test.ts +118 -0
- package/src/db/index.ts +52 -0
- package/src/db/migrations/0000_baseline.sql +269 -0
- package/src/db/migrations/0001_fts_setup.sql +31 -0
- package/src/db/migrations/meta/0000_snapshot.json +703 -119
- package/src/db/migrations/meta/0001_snapshot.json +1337 -0
- package/src/db/migrations/meta/_journal.json +4 -39
- package/src/db/schema.ts +409 -140
- package/src/i18n/__tests__/detect.test.ts +115 -0
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/detect.ts +85 -1
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +2 -1
- package/src/i18n/locales/en.po +783 -1087
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +867 -812
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +878 -823
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +6 -0
- package/src/index.ts +5 -7
- package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
- package/src/lib/__tests__/constants.test.ts +0 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
- package/src/lib/__tests__/nanoid.test.ts +26 -0
- package/src/lib/__tests__/resolve-config.test.ts +2 -2
- package/src/lib/__tests__/schemas.test.ts +186 -65
- package/src/lib/__tests__/slug.test.ts +126 -0
- package/src/lib/__tests__/sse.test.ts +6 -6
- package/src/lib/__tests__/summary.test.ts +264 -0
- package/src/lib/__tests__/theme.test.ts +1 -1
- package/src/lib/__tests__/timeline.test.ts +33 -30
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
- package/src/lib/__tests__/url.test.ts +2 -2
- package/src/lib/__tests__/view.test.ts +140 -65
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +963 -0
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +77 -31
- package/src/lib/html.ts +2 -1
- package/src/lib/icon-catalog.ts +5033 -1
- package/src/lib/icons.ts +3 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/markdown-to-tiptap.ts +286 -0
- package/src/lib/media-helpers.ts +22 -12
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +24 -5
- package/src/lib/resolve-config.ts +13 -2
- package/src/lib/schemas.ts +226 -58
- package/src/lib/search-snippet.ts +34 -0
- package/src/lib/slug.ts +96 -0
- package/src/lib/sse.ts +6 -6
- package/src/lib/storage.ts +115 -7
- package/src/lib/summary.ts +158 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +76 -34
- package/src/lib/tiptap-render.ts +191 -0
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +263 -14
- package/src/lib/url.ts +37 -22
- package/src/lib/view.ts +236 -55
- package/src/middleware/__tests__/auth.test.ts +191 -11
- package/src/middleware/__tests__/onboarding.test.ts +12 -10
- package/src/middleware/auth.ts +63 -9
- package/src/middleware/error-handler.ts +3 -3
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +83 -2
- package/src/routes/__tests__/compose.test.ts +17 -24
- package/src/routes/api/__tests__/collections.test.ts +109 -61
- package/src/routes/api/__tests__/nav-items.test.ts +46 -29
- package/src/routes/api/__tests__/posts.test.ts +132 -68
- package/src/routes/api/__tests__/search.test.ts +15 -2
- package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
- package/src/routes/api/collections.ts +57 -31
- package/src/routes/api/custom-urls.ts +80 -0
- package/src/routes/api/export.ts +31 -0
- package/src/routes/api/nav-items.ts +23 -19
- package/src/routes/api/posts.ts +81 -62
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +92 -24
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/reset.tsx +5 -4
- package/src/routes/auth/setup.tsx +39 -31
- package/src/routes/auth/signin.tsx +13 -14
- package/src/routes/compose.tsx +27 -63
- package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +475 -99
- package/src/routes/feed/__tests__/rss.test.ts +22 -23
- package/src/routes/feed/rss.ts +6 -2
- package/src/routes/feed/sitemap.ts +2 -12
- package/src/routes/pages/__tests__/collections.test.ts +5 -6
- package/src/routes/pages/__tests__/featured.test.ts +36 -18
- package/src/routes/pages/archive.tsx +177 -37
- package/src/routes/pages/collection.tsx +43 -14
- package/src/routes/pages/collections.tsx +11 -2
- package/src/routes/pages/featured.tsx +27 -3
- package/src/routes/pages/home.tsx +15 -14
- package/src/routes/pages/latest.tsx +1 -11
- package/src/routes/pages/new.tsx +39 -0
- package/src/routes/pages/page.tsx +95 -49
- package/src/routes/pages/search.tsx +1 -1
- package/src/services/__tests__/api-token.test.ts +135 -0
- package/src/services/__tests__/collection.test.ts +275 -227
- package/src/services/__tests__/custom-url.test.ts +213 -0
- package/src/services/__tests__/media.test.ts +162 -22
- package/src/services/__tests__/navigation.test.ts +109 -68
- package/src/services/__tests__/post-timeline.test.ts +205 -32
- package/src/services/__tests__/post.test.ts +800 -230
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/__tests__/settings.test.ts +3 -3
- package/src/services/api-token.ts +166 -0
- package/src/services/auth.ts +17 -2
- package/src/services/collection.ts +397 -131
- package/src/services/custom-url.ts +188 -0
- package/src/services/export.ts +802 -0
- package/src/services/index.ts +26 -19
- package/src/services/media.ts +100 -22
- package/src/services/navigation.ts +158 -47
- package/src/services/path.ts +339 -0
- package/src/services/post.ts +764 -172
- package/src/services/search.ts +161 -74
- package/src/services/settings.ts +6 -2
- package/src/styles/components.css +293 -62
- package/src/styles/tokens.css +93 -5
- package/src/styles/ui.css +4349 -766
- package/src/types/bindings.ts +8 -0
- package/src/types/config.ts +34 -4
- package/src/types/constants.ts +17 -2
- package/src/types/entities.ts +83 -37
- package/src/types/operations.ts +20 -27
- package/src/types/props.ts +52 -17
- package/src/types/views.ts +48 -24
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +255 -16
- package/src/ui/compose/ComposePrompt.tsx +1 -1
- package/src/ui/dash/CrudPageHeader.tsx +1 -1
- package/src/ui/dash/ListItemRow.tsx +1 -1
- package/src/ui/dash/StatusBadge.tsx +12 -2
- package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
- package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
- package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
- package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +87 -146
- package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
- package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
- package/src/ui/dash/settings/AvatarContent.tsx +78 -0
- package/src/ui/dash/settings/GeneralContent.tsx +3 -62
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
- package/src/ui/feed/LinkCard.tsx +89 -40
- package/src/ui/feed/NoteCard.tsx +39 -25
- package/src/ui/feed/PostStatusBadges.tsx +67 -0
- package/src/ui/feed/QuoteCard.tsx +38 -23
- package/src/ui/feed/ThreadPreview.tsx +90 -26
- package/src/ui/feed/TimelineFeed.tsx +3 -2
- package/src/ui/feed/TimelineItem.tsx +15 -6
- package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
- package/src/ui/feed/thread-preview-state.ts +61 -0
- package/src/ui/font-themes.ts +2 -2
- package/src/ui/layouts/BaseLayout.tsx +2 -2
- package/src/ui/layouts/SiteLayout.tsx +116 -103
- package/src/ui/pages/ArchivePage.tsx +923 -95
- package/src/ui/pages/CollectionPage.tsx +6 -35
- package/src/ui/pages/CollectionsPage.tsx +2 -1
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/FeaturedPage.tsx +2 -1
- package/src/ui/pages/HomePage.tsx +1 -1
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +182 -38
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +239 -4
- package/src/ui/shared/MediaGallery.tsx +475 -41
- package/src/ui/shared/PostFooter.tsx +204 -0
- package/src/ui/shared/StarRating.tsx +27 -0
- package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
- package/src/ui/shared/index.ts +0 -1
- package/src/db/migrations/0000_square_wallflower.sql +0 -118
- package/src/db/migrations/0001_add_search_fts.sql +0 -34
- package/src/db/migrations/0002_add_media_attachments.sql +0 -3
- package/src/db/migrations/0003_add_navigation_links.sql +0 -8
- package/src/db/migrations/0004_add_storage_provider.sql +0 -3
- package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
- package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
- package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
- package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
- package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
- package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
- package/src/db/migrations/0011_add_path_registry.sql +0 -23
- package/src/db/migrations/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/collections-reorder.ts +0 -28
- package/src/lib/compose-bridge.ts +0 -280
- package/src/lib/media-upload.ts +0 -148
- package/src/lib/sqid.ts +0 -79
- package/src/routes/api/__tests__/pages.test.ts +0 -218
- package/src/routes/api/pages.ts +0 -73
- package/src/routes/dash/__tests__/pages.test.ts +0 -226
- package/src/routes/dash/appearance.tsx +0 -240
- package/src/routes/dash/collections.tsx +0 -211
- package/src/routes/dash/index.tsx +0 -103
- package/src/routes/dash/media.tsx +0 -132
- package/src/routes/dash/pages.tsx +0 -239
- package/src/routes/dash/posts.tsx +0 -334
- package/src/routes/dash/redirects.tsx +0 -257
- package/src/routes/pages/post.tsx +0 -59
- package/src/services/__tests__/page.test.ts +0 -298
- package/src/services/__tests__/path-registry.test.ts +0 -165
- package/src/services/__tests__/redirect.test.ts +0 -159
- package/src/services/page.ts +0 -203
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/types/sortablejs.d.ts +0 -29
- package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
- package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
- package/src/ui/components/compose-types.ts +0 -75
- package/src/ui/components/jant-collection-form.ts +0 -512
- package/src/ui/components/jant-compose-dialog.ts +0 -495
- package/src/ui/components/jant-compose-editor.ts +0 -814
- package/src/ui/dash/PageForm.tsx +0 -185
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
- package/src/ui/dash/collections/CollectionForm.tsx +0 -166
- package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
- package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
- package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
- package/src/ui/dash/media/MediaListContent.tsx +0 -201
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -74
- package/src/ui/dash/posts/PostForm.tsx +0 -248
- package/src/ui/dash/settings/SettingsNav.tsx +0 -52
- package/src/ui/layouts/DashLayout.tsx +0 -165
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
- /package/src/{ui → client}/components/settings-types.ts +0 -0
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
-
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
|
-
import { createPathRegistryService } from "../path-registry.js";
|
|
4
|
-
import { ValidationError, ConflictError } from "../../lib/errors.js";
|
|
5
|
-
import type { Database } from "../../db/index.js";
|
|
6
|
-
|
|
7
|
-
describe("PathRegistryService", () => {
|
|
8
|
-
let db: Database;
|
|
9
|
-
let pathRegistry: ReturnType<typeof createPathRegistryService>;
|
|
10
|
-
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
const testDb = createTestDatabase();
|
|
13
|
-
db = testDb.db as unknown as Database;
|
|
14
|
-
pathRegistry = createPathRegistryService(db);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
describe("claim", () => {
|
|
18
|
-
it("claims a path successfully", async () => {
|
|
19
|
-
const entry = await pathRegistry.claim("about", "page", 1);
|
|
20
|
-
|
|
21
|
-
expect(entry.path).toBe("about");
|
|
22
|
-
expect(entry.ownerType).toBe("page");
|
|
23
|
-
expect(entry.ownerId).toBe(1);
|
|
24
|
-
expect(entry.createdAt).toBeGreaterThan(0);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("normalizes the path before claiming", async () => {
|
|
28
|
-
const entry = await pathRegistry.claim(" /About/ ", "page", 1);
|
|
29
|
-
expect(entry.path).toBe("about");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("rejects reserved paths", async () => {
|
|
33
|
-
await expect(pathRegistry.claim("dash", "page", 1)).rejects.toThrow(
|
|
34
|
-
ValidationError,
|
|
35
|
-
);
|
|
36
|
-
await expect(pathRegistry.claim("api", "page", 1)).rejects.toThrow(
|
|
37
|
-
ValidationError,
|
|
38
|
-
);
|
|
39
|
-
await expect(pathRegistry.claim("search", "page", 1)).rejects.toThrow(
|
|
40
|
-
ValidationError,
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("rejects reserved paths regardless of casing", async () => {
|
|
45
|
-
await expect(pathRegistry.claim("DASH", "page", 1)).rejects.toThrow(
|
|
46
|
-
ValidationError,
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("throws ConflictError when path is already claimed by another entity", async () => {
|
|
51
|
-
await pathRegistry.claim("about", "page", 1);
|
|
52
|
-
|
|
53
|
-
await expect(pathRegistry.claim("about", "post", 2)).rejects.toThrow(
|
|
54
|
-
ConflictError,
|
|
55
|
-
);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("is idempotent for the same owner", async () => {
|
|
59
|
-
const first = await pathRegistry.claim("about", "page", 1);
|
|
60
|
-
const second = await pathRegistry.claim("about", "page", 1);
|
|
61
|
-
|
|
62
|
-
expect(second.path).toBe(first.path);
|
|
63
|
-
expect(second.ownerType).toBe(first.ownerType);
|
|
64
|
-
expect(second.ownerId).toBe(first.ownerId);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("allows multi-level paths", async () => {
|
|
68
|
-
const entry = await pathRegistry.claim("2024/01/my-post", "post", 1);
|
|
69
|
-
expect(entry.path).toBe("2024/01/my-post");
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe("release", () => {
|
|
74
|
-
it("releases a claimed path", async () => {
|
|
75
|
-
await pathRegistry.claim("about", "page", 1);
|
|
76
|
-
await pathRegistry.release("about");
|
|
77
|
-
|
|
78
|
-
const entry = await pathRegistry.getByPath("about");
|
|
79
|
-
expect(entry).toBeNull();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("normalizes the path before releasing", async () => {
|
|
83
|
-
await pathRegistry.claim("about", "page", 1);
|
|
84
|
-
await pathRegistry.release(" /About/ ");
|
|
85
|
-
|
|
86
|
-
const entry = await pathRegistry.getByPath("about");
|
|
87
|
-
expect(entry).toBeNull();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("is a no-op for unclaimed paths", async () => {
|
|
91
|
-
// Should not throw
|
|
92
|
-
await pathRegistry.release("nonexistent");
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe("releaseByOwner", () => {
|
|
97
|
-
it("releases all paths for a specific owner", async () => {
|
|
98
|
-
await pathRegistry.claim("about", "page", 1);
|
|
99
|
-
await pathRegistry.claim("contact", "page", 1);
|
|
100
|
-
await pathRegistry.claim("blog", "page", 2);
|
|
101
|
-
|
|
102
|
-
await pathRegistry.releaseByOwner("page", 1);
|
|
103
|
-
|
|
104
|
-
expect(await pathRegistry.getByPath("about")).toBeNull();
|
|
105
|
-
expect(await pathRegistry.getByPath("contact")).toBeNull();
|
|
106
|
-
// Different owner's path should remain
|
|
107
|
-
expect(await pathRegistry.getByPath("blog")).not.toBeNull();
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("does not affect other owner types", async () => {
|
|
111
|
-
await pathRegistry.claim("about", "page", 1);
|
|
112
|
-
await pathRegistry.claim("my-post", "post", 1);
|
|
113
|
-
|
|
114
|
-
await pathRegistry.releaseByOwner("page", 1);
|
|
115
|
-
|
|
116
|
-
expect(await pathRegistry.getByPath("about")).toBeNull();
|
|
117
|
-
expect(await pathRegistry.getByPath("my-post")).not.toBeNull();
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe("getByPath", () => {
|
|
122
|
-
it("returns entry for claimed path", async () => {
|
|
123
|
-
await pathRegistry.claim("about", "page", 1);
|
|
124
|
-
|
|
125
|
-
const entry = await pathRegistry.getByPath("about");
|
|
126
|
-
expect(entry).not.toBeNull();
|
|
127
|
-
expect(entry?.ownerType).toBe("page");
|
|
128
|
-
expect(entry?.ownerId).toBe(1);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("normalizes the lookup path", async () => {
|
|
132
|
-
await pathRegistry.claim("about", "page", 1);
|
|
133
|
-
|
|
134
|
-
const entry = await pathRegistry.getByPath(" /About/ ");
|
|
135
|
-
expect(entry).not.toBeNull();
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("returns null for unclaimed path", async () => {
|
|
139
|
-
const entry = await pathRegistry.getByPath("nonexistent");
|
|
140
|
-
expect(entry).toBeNull();
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
describe("isAvailable", () => {
|
|
145
|
-
it("returns true for unclaimed, non-reserved paths", async () => {
|
|
146
|
-
expect(await pathRegistry.isAvailable("about")).toBe(true);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("returns false for reserved paths", async () => {
|
|
150
|
-
expect(await pathRegistry.isAvailable("dash")).toBe(false);
|
|
151
|
-
expect(await pathRegistry.isAvailable("api")).toBe(false);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("returns false for claimed paths", async () => {
|
|
155
|
-
await pathRegistry.claim("about", "page", 1);
|
|
156
|
-
expect(await pathRegistry.isAvailable("about")).toBe(false);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it("returns true after a path is released", async () => {
|
|
160
|
-
await pathRegistry.claim("about", "page", 1);
|
|
161
|
-
await pathRegistry.release("about");
|
|
162
|
-
expect(await pathRegistry.isAvailable("about")).toBe(true);
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
});
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
-
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
|
-
import { createRedirectService } from "../redirect.js";
|
|
4
|
-
import { createPageService } from "../page.js";
|
|
5
|
-
import { createPostService } from "../post.js";
|
|
6
|
-
import { createPathRegistryService } from "../path-registry.js";
|
|
7
|
-
import { ConflictError } from "../../lib/errors.js";
|
|
8
|
-
import type { Database } from "../../db/index.js";
|
|
9
|
-
|
|
10
|
-
describe("RedirectService", () => {
|
|
11
|
-
let db: Database;
|
|
12
|
-
let redirectService: ReturnType<typeof createRedirectService>;
|
|
13
|
-
let pageService: ReturnType<typeof createPageService>;
|
|
14
|
-
let postService: ReturnType<typeof createPostService>;
|
|
15
|
-
let pathRegistry: ReturnType<typeof createPathRegistryService>;
|
|
16
|
-
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
const testDb = createTestDatabase();
|
|
19
|
-
db = testDb.db as unknown as Database;
|
|
20
|
-
pathRegistry = createPathRegistryService(db);
|
|
21
|
-
redirectService = createRedirectService(db, pathRegistry);
|
|
22
|
-
pageService = createPageService(db, pathRegistry);
|
|
23
|
-
postService = createPostService(db, pathRegistry);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
describe("create", () => {
|
|
27
|
-
it("creates a 301 redirect by default", async () => {
|
|
28
|
-
const redirect = await redirectService.create("/old-path", "/new-path");
|
|
29
|
-
|
|
30
|
-
expect(redirect.fromPath).toBe("old-path"); // normalizePath removes leading slash
|
|
31
|
-
expect(redirect.toPath).toBe("/new-path");
|
|
32
|
-
expect(redirect.type).toBe(301);
|
|
33
|
-
expect(redirect.id).toBe(1);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("creates a 302 redirect", async () => {
|
|
37
|
-
const redirect = await redirectService.create(
|
|
38
|
-
"/temp",
|
|
39
|
-
"/destination",
|
|
40
|
-
302,
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
expect(redirect.type).toBe(302);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("normalizes from path", async () => {
|
|
47
|
-
const redirect = await redirectService.create(
|
|
48
|
-
" /OLD-PATH/ ",
|
|
49
|
-
"/new-path",
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
expect(redirect.fromPath).toBe("old-path");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("replaces existing redirect for same from path", async () => {
|
|
56
|
-
await redirectService.create("/old", "/first");
|
|
57
|
-
const second = await redirectService.create("/old", "/second");
|
|
58
|
-
|
|
59
|
-
expect(second.toPath).toBe("/second");
|
|
60
|
-
|
|
61
|
-
const list = await redirectService.list();
|
|
62
|
-
expect(list).toHaveLength(1);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("getByPath", () => {
|
|
67
|
-
it("finds redirect by from path", async () => {
|
|
68
|
-
await redirectService.create("/old-page", "/new-page");
|
|
69
|
-
|
|
70
|
-
const found = await redirectService.getByPath("/old-page");
|
|
71
|
-
expect(found).not.toBeNull();
|
|
72
|
-
expect(found?.toPath).toBe("/new-page");
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("normalizes the lookup path", async () => {
|
|
76
|
-
await redirectService.create("/old-page", "/new-page");
|
|
77
|
-
|
|
78
|
-
const found = await redirectService.getByPath(" /OLD-PAGE/ ");
|
|
79
|
-
expect(found).not.toBeNull();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("returns null for non-existent path", async () => {
|
|
83
|
-
const found = await redirectService.getByPath("/nonexistent");
|
|
84
|
-
expect(found).toBeNull();
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
describe("delete", () => {
|
|
89
|
-
it("deletes a redirect by ID", async () => {
|
|
90
|
-
const redirect = await redirectService.create("/old", "/new");
|
|
91
|
-
const result = await redirectService.delete(redirect.id);
|
|
92
|
-
|
|
93
|
-
expect(result).toBe(true);
|
|
94
|
-
|
|
95
|
-
const found = await redirectService.getByPath("/old");
|
|
96
|
-
expect(found).toBeNull();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("returns false for non-existent ID", async () => {
|
|
100
|
-
const result = await redirectService.delete(9999);
|
|
101
|
-
expect(result).toBe(false);
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe("list", () => {
|
|
106
|
-
it("returns empty array when no redirects exist", async () => {
|
|
107
|
-
const redirects = await redirectService.list();
|
|
108
|
-
expect(redirects).toEqual([]);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("returns all redirects", async () => {
|
|
112
|
-
await redirectService.create("/old-a", "/new-a");
|
|
113
|
-
await redirectService.create("/old-b", "/new-b");
|
|
114
|
-
await redirectService.create("/old-c", "/new-c");
|
|
115
|
-
|
|
116
|
-
const redirects = await redirectService.list();
|
|
117
|
-
expect(redirects).toHaveLength(3);
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe("path registry integration", () => {
|
|
122
|
-
it("rejects redirect that conflicts with a page", async () => {
|
|
123
|
-
await pageService.create({ slug: "about", title: "About" });
|
|
124
|
-
|
|
125
|
-
await expect(
|
|
126
|
-
redirectService.create("/about", "/new-about"),
|
|
127
|
-
).rejects.toThrow(ConflictError);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("rejects redirect that conflicts with a post path", async () => {
|
|
131
|
-
await postService.create({
|
|
132
|
-
format: "note",
|
|
133
|
-
body: "test",
|
|
134
|
-
path: "my-post",
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
await expect(
|
|
138
|
-
redirectService.create("/my-post", "/elsewhere"),
|
|
139
|
-
).rejects.toThrow(ConflictError);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("allows upsert for existing redirect (same type)", async () => {
|
|
143
|
-
await redirectService.create("/old", "/first");
|
|
144
|
-
const second = await redirectService.create("/old", "/second");
|
|
145
|
-
|
|
146
|
-
expect(second.toPath).toBe("/second");
|
|
147
|
-
|
|
148
|
-
const list = await redirectService.list();
|
|
149
|
-
expect(list).toHaveLength(1);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("releases path on delete", async () => {
|
|
153
|
-
const redirect = await redirectService.create("/old", "/new");
|
|
154
|
-
await redirectService.delete(redirect.id);
|
|
155
|
-
|
|
156
|
-
expect(await pathRegistry.isAvailable("old")).toBe(true);
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
});
|
package/src/services/page.ts
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Page Service
|
|
3
|
-
*
|
|
4
|
-
* CRUD operations for standalone pages (about, now, etc.)
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { eq, desc, sql, and } from "drizzle-orm";
|
|
8
|
-
import type { Database } from "../db/index.js";
|
|
9
|
-
import { pages, navItems } from "../db/schema.js";
|
|
10
|
-
import { now } from "../lib/time.js";
|
|
11
|
-
import { render as renderMarkdown } from "../lib/markdown.js";
|
|
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";
|
|
15
|
-
|
|
16
|
-
export interface PageFilters {
|
|
17
|
-
status?: Status;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface PageService {
|
|
21
|
-
getById(id: number): Promise<Page | null>;
|
|
22
|
-
getBySlug(slug: string): Promise<Page | null>;
|
|
23
|
-
list(filters?: PageFilters): Promise<Page[]>;
|
|
24
|
-
listNotInNav(): Promise<Page[]>;
|
|
25
|
-
create(data: CreatePage): Promise<Page>;
|
|
26
|
-
update(id: number, data: UpdatePage): Promise<Page | null>;
|
|
27
|
-
delete(id: number): Promise<boolean>;
|
|
28
|
-
}
|
|
29
|
-
|
|
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 {
|
|
40
|
-
function toPage(row: typeof pages.$inferSelect): Page {
|
|
41
|
-
return {
|
|
42
|
-
id: row.id,
|
|
43
|
-
slug: row.slug,
|
|
44
|
-
title: row.title,
|
|
45
|
-
body: row.body,
|
|
46
|
-
bodyHtml: row.bodyHtml,
|
|
47
|
-
status: row.status as Status,
|
|
48
|
-
createdAt: row.createdAt,
|
|
49
|
-
updatedAt: row.updatedAt,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
async getById(id) {
|
|
55
|
-
const result = await db
|
|
56
|
-
.select()
|
|
57
|
-
.from(pages)
|
|
58
|
-
.where(eq(pages.id, id))
|
|
59
|
-
.limit(1);
|
|
60
|
-
return result[0] ? toPage(result[0]) : null;
|
|
61
|
-
},
|
|
62
|
-
|
|
63
|
-
async getBySlug(slug) {
|
|
64
|
-
const result = await db
|
|
65
|
-
.select()
|
|
66
|
-
.from(pages)
|
|
67
|
-
.where(eq(pages.slug, slug))
|
|
68
|
-
.limit(1);
|
|
69
|
-
return result[0] ? toPage(result[0]) : null;
|
|
70
|
-
},
|
|
71
|
-
|
|
72
|
-
async list(filters?: PageFilters) {
|
|
73
|
-
const conditions = [];
|
|
74
|
-
if (filters?.status) {
|
|
75
|
-
conditions.push(eq(pages.status, filters.status));
|
|
76
|
-
}
|
|
77
|
-
const rows = await db
|
|
78
|
-
.select()
|
|
79
|
-
.from(pages)
|
|
80
|
-
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
81
|
-
.orderBy(desc(pages.createdAt));
|
|
82
|
-
return rows.map(toPage);
|
|
83
|
-
},
|
|
84
|
-
|
|
85
|
-
async listNotInNav() {
|
|
86
|
-
const rows = await db
|
|
87
|
-
.select()
|
|
88
|
-
.from(pages)
|
|
89
|
-
.where(
|
|
90
|
-
sql`${pages.id} NOT IN (SELECT ${navItems.pageId} FROM ${navItems} WHERE ${navItems.pageId} IS NOT NULL)`,
|
|
91
|
-
)
|
|
92
|
-
.orderBy(desc(pages.createdAt));
|
|
93
|
-
return rows.map(toPage);
|
|
94
|
-
},
|
|
95
|
-
|
|
96
|
-
async create(data) {
|
|
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);
|
|
101
|
-
|
|
102
|
-
const timestamp = now();
|
|
103
|
-
const bodyHtml = data.body ? renderMarkdown(data.body) : null;
|
|
104
|
-
|
|
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
|
-
}
|
|
130
|
-
|
|
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;
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
async update(id, data) {
|
|
139
|
-
const existing = await this.getById(id);
|
|
140
|
-
if (!existing) return null;
|
|
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
|
-
|
|
151
|
-
const timestamp = now();
|
|
152
|
-
const updates: Partial<typeof pages.$inferInsert> = {
|
|
153
|
-
updatedAt: timestamp,
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
if (data.slug !== undefined) updates.slug = data.slug;
|
|
157
|
-
if (data.title !== undefined) updates.title = data.title;
|
|
158
|
-
if (data.status !== undefined) updates.status = data.status;
|
|
159
|
-
|
|
160
|
-
if (data.body !== undefined) {
|
|
161
|
-
updates.body = data.body;
|
|
162
|
-
updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// If slug changed, update related nav_items
|
|
166
|
-
if (slugChanging) {
|
|
167
|
-
await db
|
|
168
|
-
.update(navItems)
|
|
169
|
-
.set({ url: `/${data.slug}`, updatedAt: timestamp })
|
|
170
|
-
.where(eq(navItems.pageId, id));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// If title changed, update related nav_items label
|
|
174
|
-
if (data.title !== undefined && data.title !== existing.title) {
|
|
175
|
-
await db
|
|
176
|
-
.update(navItems)
|
|
177
|
-
.set({ label: data.title ?? existing.slug, updatedAt: timestamp })
|
|
178
|
-
.where(eq(navItems.pageId, id));
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const result = await db
|
|
182
|
-
.update(pages)
|
|
183
|
-
.set(updates)
|
|
184
|
-
.where(eq(pages.id, id))
|
|
185
|
-
.returning();
|
|
186
|
-
|
|
187
|
-
// Release old slug from registry after successful update
|
|
188
|
-
if (slugChanging) {
|
|
189
|
-
await pathRegistry.release(existing.slug);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return result[0] ? toPage(result[0]) : null;
|
|
193
|
-
},
|
|
194
|
-
|
|
195
|
-
async delete(id) {
|
|
196
|
-
// Release path registry entries for this page
|
|
197
|
-
await pathRegistry.releaseByOwner("page", id);
|
|
198
|
-
// nav_items with page_id FK have ON DELETE CASCADE, so they auto-delete
|
|
199
|
-
const result = await db.delete(pages).where(eq(pages.id, id)).returning();
|
|
200
|
-
return result.length > 0;
|
|
201
|
-
},
|
|
202
|
-
};
|
|
203
|
-
}
|
|
@@ -1,160 +0,0 @@
|
|
|
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
|
-
}
|