From affed5fb3c1d60a836cbaaecf6516ca187cd2432 Mon Sep 17 00:00:00 2001 From: LudoLoops Date: Tue, 13 Oct 2026 00:36:24 +0200 Subject: [PATCH 1/2] fix(compat): replace deprecated Kit 3 lib alias (#584) --- .changeset/quiet-lib-imports.md | 18 + AGENTS.md | 2 +- graphify-out/graph.json | 806 +++++++++++------- graphify-out/manifest.json | 306 +++---- packages/addon-kit/README.md | 2 +- packages/addon-kit/src/capabilities.ts | 4 +- packages/audit/README.md | 6 +- packages/audit/src/index.ts | 6 +- packages/audit/src/templates.ts | 6 +- .../templates/src/lib/server/audit/index.ts | 2 +- .../routes/(app)/admin/audit/+page.server.ts | 6 +- .../src/routes/(app)/admin/audit/+page.svelte | 6 +- packages/blog/src/index.ts | 4 +- packages/blog/src/templates.ts | 8 +- .../templates/src/routes/blog/+page.server.ts | 2 +- .../templates/src/routes/blog/+page.svelte | 10 +- .../src/routes/blog/[slug]/+page.server.ts | 2 +- .../src/routes/blog/[slug]/+page.svelte | 6 +- packages/chat/README.md | 6 +- packages/chat/src/index.ts | 6 +- packages/chat/src/templates.ts | 12 +- .../src/lib/server/chat/chat-security.test.ts | 6 +- .../templates/src/lib/server/chat/index.ts | 2 +- .../templates/src/routes/chat/+page.server.ts | 2 +- .../templates/src/routes/chat/+page.svelte | 6 +- .../src/routes/chat/[id]/+page.server.ts | 2 +- .../src/routes/chat/[id]/+page.svelte | 6 +- packages/dnd/README.md | 2 +- packages/dnd/src/index.ts | 2 +- packages/dnd/src/templates.ts | 2 +- .../svforge/dnd/SortableList.svelte | 2 +- packages/email/README.md | 10 +- packages/email/src/index.ts | 2 +- packages/graph/README.md | 2 +- packages/graph/src/index.ts | 4 +- packages/jobs/README.md | 2 +- packages/jobs/src/index.ts | 6 +- packages/jobs/src/templates.ts | 4 +- .../templates/src/lib/server/jobs/index.ts | 2 +- .../src/lib/server/jobs/jobs-claim.test.ts | 2 +- packages/notifications/README.md | 10 +- packages/notifications/src/index.ts | 6 +- packages/notifications/src/templates.ts | 6 +- .../svforge/ui/NotificationsBell.svelte | 6 +- .../src/lib/server/notifications/index.ts | 2 +- .../api/notifications/read-all/+server.ts | 2 +- packages/oauth/README.md | 2 +- packages/oauth/src/index.ts | 4 +- packages/oauth/src/templates.ts | 2 +- .../components/svforge/ui/OAuthButtons.svelte | 2 +- packages/realtime/README.md | 18 +- packages/realtime/src/index.ts | 2 +- packages/realtime/src/templates.ts | 4 +- .../templates/src/lib/server/realtime/hub.ts | 2 +- .../src/lib/server/realtime/index.ts | 2 +- .../svforge/docs/sveltekit-compatibility.md | 4 +- packages/svforge/src/modes/base.ts | 50 +- packages/svforge/src/module-recipes.ts | 54 +- packages/svforge/src/scaffolded-agents.ts | 6 +- packages/svforge/src/templates.ts | 110 +-- .../templates/base/root/svforge-modules.json | 2 +- .../templates/base/src/hooks.server.ts | 4 +- packages/svforge/templates/base/src/hooks.ts | 2 +- .../svforge/layout/AuthLayout.svelte | 6 +- .../components/svforge/layout/Footer.svelte | 2 +- .../components/svforge/layout/Navbar.svelte | 12 +- .../svforge/primitives/Badge.svelte | 2 +- .../svforge/primitives/Button.svelte | 4 +- .../svforge/primitives/Checkbox.svelte | 2 +- .../svforge/primitives/Input.svelte | 2 +- .../svforge/primitives/Select.svelte | 2 +- .../svforge/primitives/Textarea.svelte | 2 +- .../svforge/primitives/Toggle.svelte | 2 +- .../lib/components/svforge/ui/Alert.svelte | 2 +- .../src/lib/components/svforge/ui/Card.svelte | 2 +- .../src/lib/components/svforge/ui/Logo.svelte | 4 +- .../src/lib/components/svforge/ui/Seo.svelte | 2 +- .../lib/components/svforge/ui/Table.svelte | 2 +- .../components/svforge/ui/ThemeToggle.svelte | 8 +- .../templates/base/src/lib/example.test.ts | 4 +- .../templates/base/src/routes/+layout.svelte | 8 +- .../templates/base/src/routes/+page.svelte | 6 +- .../base/src/routes/demo-ui/+page.svelte | 6 +- .../templates/base/src/vitest.config.ts | 7 +- .../dashboard/src/hooks.server.test.ts | 10 +- .../templates/dashboard/src/hooks.server.ts | 10 +- .../svforge/layout/AdminLayout.svelte | 10 +- .../svforge/ui/AvatarInitial.svelte | 2 +- .../lib/components/svforge/ui/Feedback.svelte | 8 +- .../src/lib/server/admin-users.test.ts | 4 +- .../dashboard/src/lib/server/admin-users.ts | 4 +- .../dashboard/src/lib/server/admin.ts | 4 +- .../dashboard/src/lib/server/auth.test.ts | 4 +- .../dashboard/src/lib/server/auth.ts | 8 +- .../dashboard/src/lib/server/db/client.ts | 2 +- .../src/lib/server/first-admin.test.ts | 2 +- .../dashboard/src/lib/server/invitations.ts | 4 +- .../src/lib/server/signup-mode.test.ts | 8 +- .../dashboard/src/routes/(app)/+layout.svelte | 4 +- .../src/routes/(app)/admin/+page.server.ts | 6 +- .../src/routes/(app)/admin/+page.svelte | 10 +- .../(app)/admin/settings/+page.server.ts | 4 +- .../routes/(app)/admin/settings/+page.svelte | 8 +- .../routes/(app)/admin/users/+page.server.ts | 12 +- .../src/routes/(app)/admin/users/+page.svelte | 8 +- .../(app)/admin/users/page.server.test.ts | 28 +- .../src/routes/(app)/layout.server.test.ts | 8 +- .../dashboard/src/routes/+layout.svelte | 2 +- .../src/routes/login/+page.server.ts | 8 +- .../dashboard/src/routes/login/+page.svelte | 12 +- .../src/routes/login/page.server.test.ts | 10 +- .../src/routes/setup/+page.server.ts | 6 +- .../dashboard/src/routes/setup/+page.svelte | 10 +- .../src/routes/setup/page.server.test.ts | 4 +- .../templates/dashboard/src/vitest.config.ts | 6 +- packages/tiptap/README.md | 6 +- packages/tiptap/src/index.ts | 2 +- packages/tiptap/src/templates.ts | 6 +- .../svforge/tiptap/TiptapEditor.svelte | 2 +- .../svforge/tiptap/TiptapPreview.svelte | 2 +- .../svforge/tiptap/TiptapToolbar.svelte | 6 +- packages/ui_toast/README.md | 4 +- packages/ui_toast/src/index.ts | 4 +- packages/uploads/README.md | 2 +- packages/uploads/src/templates.ts | 6 +- .../svforge/uploads/FileUpload.svelte | 4 +- .../src/routes/api/upload/+server.ts | 4 +- .../routes/api/upload/upload-security.test.ts | 4 +- tests/auth-experience.test.ts | 4 +- tests/blog-transport-compose.test.ts | 2 +- tests/chat.test.ts | 2 +- tests/file-upload-contract.test.ts | 2 +- tests/helpers/fixtures.ts | 2 +- tests/icon-barrel.test.ts | 2 +- tests/lib-subpath-imports.test.ts | 78 ++ tests/login-enhance.test.ts | 12 +- tests/multi-addon-compose.test.ts | 2 +- tests/patch-markers.test.ts | 9 +- tests/scaffold-paths.test.ts | 2 +- tests/skeleton-markup.test.ts | 2 +- .../components/svforge/primitives/index.js | 1 + tests/stubs/lib/icons/index.js | 1 + tests/stubs/lib/paraglide/messages.js | 2 +- tests/stubs/lib/utils/cn.js | 1 + tests/stubs/lib/utils/cn.ts | 2 +- tests/theme-toggle-contract.test.ts | 2 +- tests/vite-config-modern-scaffold.test.ts | 28 +- tests/vite-config-regex.test.ts | 29 +- tests/web-hardening.test.ts | 10 +- vitest.config.ts | 7 +- 150 files changed, 1240 insertions(+), 921 deletions(-) create mode 100644 .changeset/quiet-lib-imports.md create mode 100644 tests/lib-subpath-imports.test.ts create mode 100644 tests/stubs/lib/components/svforge/primitives/index.js create mode 100644 tests/stubs/lib/icons/index.js create mode 100644 tests/stubs/lib/utils/cn.js diff --git a/.changeset/quiet-lib-imports.md b/.changeset/quiet-lib-imports.md new file mode 100644 index 00000000..2f041dc7 --- /dev/null +++ b/.changeset/quiet-lib-imports.md @@ -0,0 +1,18 @@ +--- +"svforge": patch +"@svforge/addon-kit": patch +"@svforge/audit": patch +"@svforge/blog": patch +"@svforge/chat": patch +"@svforge/dnd": patch +"@svforge/email": patch +"@svforge/graph": patch +"@svforge/jobs": patch +"@svforge/notifications": patch +"@svforge/oauth": patch +"@svforge/realtime": patch +"@svforge/tiptap": patch +"@svforge/ui_toast": patch +"@svforge/uploads": patch +--- +Replace deprecated SvelteKit alias configuration with explicit `#lib` package subpath imports while retaining Kit 2 compatibility. diff --git a/AGENTS.md b/AGENTS.md index c9c0d03e..a983166e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Les sources scaffoldées vivent sous `packages/*/templates/`. Le prebuild n’em ## Conventions d’implémentation - Composants : classes/presets Skeleton et Tailwind, pas de CSS brut; `cn()` et prop `class`; `HTMLAttributes` de `svelte/elements`; `$bindable()` déclaré dans l’interface Props. -- Icônes Phosphor : consomme-les via `$lib/icons`; les imports profonds `phosphor-svelte/lib/*` sont réservés aux définitions dans `src/lib/icons/`. +- Icônes Phosphor : consomme-les via `#lib/icons/index.js`; les imports profonds `phosphor-svelte/lib/*` sont réservés aux définitions dans `src/lib/icons/`. - Un contrôle de thème partagé utilise `ui/ThemeToggle.svelte`; ne crée pas de contrôle local dupliqué. - Le checker de design-system est livré avec le scaffold : garde ses règles de structure, primitives et utilitaires alignées sur son inventaire généré. diff --git a/graphify-out/graph.json b/graphify-out/graph.json index 065b5d9f..f4befdce 100644 --- a/graphify-out/graph.json +++ b/graphify-out/graph.json @@ -2913,25 +2913,17 @@ }, { "id": "messages_js", - "label": "$lib/paraglide/messages.js", + "label": "#lib/paraglide/messages.js", "file_type": "code", - "source_file": "$lib/paraglide/messages.js", + "source_file": "#lib/paraglide/messages.js", "confidence": "EXTRACTED", "_origin": "ast" }, { - "id": "ui", - "label": "$lib/components/svforge/ui", + "id": "lib_components_svforge_ui_index_js_index_js", + "label": "ui/index.js", "file_type": "code", - "source_file": "$lib/components/svforge/ui", - "confidence": "EXTRACTED", - "_origin": "ast" - }, - { - "id": "primitives", - "label": "$lib/components/svforge/primitives", - "file_type": "code", - "source_file": "$lib/components/svforge/primitives", + "source_file": "#lib/components/svforge/ui/index.js", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -3374,18 +3366,18 @@ "_origin": "ast" }, { - "id": "runtime", - "label": "$lib/paraglide/runtime", + "id": "runtime_js", + "label": "#lib/paraglide/runtime.js", "file_type": "code", - "source_file": "$lib/paraglide/runtime", + "source_file": "#lib/paraglide/runtime.js", "confidence": "EXTRACTED", "_origin": "ast" }, { - "id": "post_date", - "label": "$lib/utils/post-date", + "id": "post_date_js", + "label": "#lib/utils/post-date.js", "file_type": "code", - "source_file": "$lib/utils/post-date", + "source_file": "#lib/utils/post-date.js", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -3760,10 +3752,10 @@ "_origin": "ast" }, { - "id": "db", - "label": "$lib/server/db", + "id": "lib_server_db_index_js_index_js", + "label": "db/index.js", "file_type": "code", - "source_file": "$lib/server/db", + "source_file": "#lib/server/db/index.js", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -3914,6 +3906,14 @@ "confidence": "EXTRACTED", "_origin": "ast" }, + { + "id": "lib_components_svforge_primitives_index_js_index_js", + "label": "#lib/components/svforge/primitives/index.js", + "file_type": "code", + "source_file": "#lib/components/svforge/primitives/index.js", + "confidence": "EXTRACTED", + "_origin": "ast" + }, { "id": "packages_chat_tsdown_config", "label": "chat/tsdown.config.ts", @@ -4235,10 +4235,10 @@ "_origin": "ast" }, { - "id": "cn", - "label": "$lib/utils/cn", + "id": "cn_js", + "label": "#lib/utils/cn.js", "file_type": "code", - "source_file": "$lib/utils/cn", + "source_file": "#lib/utils/cn.js", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -5803,14 +5803,6 @@ "source_location": "L1", "_origin": "ast" }, - { - "id": "icons", - "label": "$lib/icons", - "file_type": "code", - "source_file": "$lib/icons", - "confidence": "EXTRACTED", - "_origin": "ast" - }, { "id": "skeleton_svelte", "label": "@skeletonlabs/skeleton-svelte", @@ -6209,10 +6201,10 @@ "_origin": "ast" }, { - "id": "lib_client_auth_auth", - "label": "client/auth", + "id": "lib_client_auth_js_auth_js", + "label": "client/auth.js", "file_type": "code", - "source_file": "$lib/client/auth", + "source_file": "#lib/client/auth.js", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -9083,7 +9075,7 @@ "label": "patchVitestConfig()", "file_type": "code", "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L148", + "source_location": "L136", "_callable": true, "_origin": "ast" }, @@ -9092,7 +9084,7 @@ "label": "applyBaseMode()", "file_type": "code", "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L172", + "source_location": "L173", "_callable": true, "_origin": "ast" }, @@ -9583,10 +9575,10 @@ "_origin": "ast" }, { - "id": "lib_server_auth_auth", - "label": "server/auth", + "id": "lib_server_auth_js_auth_js", + "label": "server/auth.js", "file_type": "code", - "source_file": "$lib/server/auth", + "source_file": "#lib/server/auth.js", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -14352,17 +14344,17 @@ }, { "id": "logo_svelte", - "label": "$lib/components/svforge/ui/Logo.svelte", + "label": "#lib/components/svforge/ui/Logo.svelte", "file_type": "code", - "source_file": "$lib/components/svforge/ui/Logo.svelte", + "source_file": "#lib/components/svforge/ui/Logo.svelte", "confidence": "EXTRACTED", "_origin": "ast" }, { "id": "themetoggle_svelte", - "label": "$lib/components/svforge/ui/ThemeToggle.svelte", + "label": "#lib/components/svforge/ui/ThemeToggle.svelte", "file_type": "code", - "source_file": "$lib/components/svforge/ui/ThemeToggle.svelte", + "source_file": "#lib/components/svforge/ui/ThemeToggle.svelte", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -14686,6 +14678,14 @@ "source_location": "L1", "_origin": "ast" }, + { + "id": "lib_icons_index_js_index_js", + "label": "#lib/icons/index.js", + "file_type": "code", + "source_file": "#lib/icons/index.js", + "confidence": "EXTRACTED", + "_origin": "ast" + }, { "id": "packages_svforge_templates_base_src_lib_components_svforge_ui_seo", "label": "Seo.svelte", @@ -14695,10 +14695,10 @@ "_origin": "ast" }, { - "id": "web", - "label": "$lib/utils/web", + "id": "web_js", + "label": "#lib/utils/web.js", "file_type": "code", - "source_file": "$lib/utils/web", + "source_file": "#lib/utils/web.js", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -14789,10 +14789,10 @@ "_origin": "ast" }, { - "id": "theme", - "label": "$lib/utils/theme", + "id": "theme_js", + "label": "#lib/utils/theme.js", "file_type": "code", - "source_file": "$lib/utils/theme", + "source_file": "#lib/utils/theme.js", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -14933,10 +14933,10 @@ "_origin": "ast" }, { - "id": "layout", - "label": "$lib/components/svforge/layout", + "id": "lib_components_svforge_layout_index_js_index_js", + "label": "layout/index.js", "file_type": "code", - "source_file": "$lib/components/svforge/layout", + "source_file": "#lib/components/svforge/layout/index.js", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -17247,9 +17247,9 @@ }, { "id": "adminlayout_svelte", - "label": "$lib/components/svforge/layout/AdminLayout.svelte", + "label": "#lib/components/svforge/layout/AdminLayout.svelte", "file_type": "code", - "source_file": "$lib/components/svforge/layout/AdminLayout.svelte", + "source_file": "#lib/components/svforge/layout/AdminLayout.svelte", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -17387,18 +17387,18 @@ "_origin": "ast" }, { - "id": "admin", - "label": "$lib/server/admin", + "id": "admin_js", + "label": "#lib/server/admin.js", "file_type": "code", - "source_file": "$lib/server/admin", + "source_file": "#lib/server/admin.js", "confidence": "EXTRACTED", "_origin": "ast" }, { - "id": "schema", - "label": "$lib/server/db/schema", + "id": "schema_js", + "label": "#lib/server/db/schema.js", "file_type": "code", - "source_file": "$lib/server/db/schema", + "source_file": "#lib/server/db/schema.js", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -17527,9 +17527,9 @@ }, { "id": "authlayout_svelte", - "label": "$lib/components/svforge/layout/AuthLayout.svelte", + "label": "#lib/components/svforge/layout/AuthLayout.svelte", "file_type": "code", - "source_file": "$lib/components/svforge/layout/AuthLayout.svelte", + "source_file": "#lib/components/svforge/layout/AuthLayout.svelte", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -18982,10 +18982,10 @@ "_origin": "ast" }, { - "id": "post_form", - "label": "$lib/uploads/post-form", + "id": "post_form_js", + "label": "#lib/uploads/post-form.js", "file_type": "code", - "source_file": "$lib/uploads/post-form", + "source_file": "#lib/uploads/post-form.js", "confidence": "EXTRACTED", "_origin": "ast" }, @@ -23657,6 +23657,49 @@ "source_location": "L1", "_origin": "ast" }, + { + "id": "tests_lib_subpath_imports_test", + "label": "lib-subpath-imports.test.ts", + "file_type": "code", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L1", + "_origin": "ast" + }, + { + "id": "tests_lib_subpath_imports_test_packages", + "label": "PACKAGES", + "file_type": "code", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L6", + "_origin": "ast" + }, + { + "id": "tests_lib_subpath_imports_test_templatesourcefiles", + "label": "templateSourceFiles()", + "file_type": "code", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L8", + "_callable": true, + "_origin": "ast" + }, + { + "id": "tests_lib_subpath_imports_test_templatesourcefiles_visit", + "label": "visit()", + "file_type": "code", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L10", + "_callable": true, + "_origin": "ast" + }, + { + "id": "tests_lib_subpath_imports_test_capturebasepackagetransform", + "label": "captureBasePackageTransform()", + "file_type": "code", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L25", + "_callable": true, + "_origin": "ast" + }, { "id": "tests_llms_docs_freshness_test", "label": "llms-docs-freshness.test.ts", @@ -24869,7 +24912,15 @@ "_origin": "ast" }, { - "id": "tests_stubs_lib_components_svforge_primitives_index", + "id": "tests_stubs_lib_components_svforge_primitives_index_js_tests_stubs_lib_components_svforge_primitives_index", + "label": "lib/components/svforge/primitives/index.js", + "file_type": "code", + "source_file": "tests/stubs/lib/components/svforge/primitives/index.js", + "source_location": "L1", + "_origin": "ast" + }, + { + "id": "tests_stubs_lib_components_svforge_primitives_index_ts_tests_stubs_lib_components_svforge_primitives_index", "label": "stubs/lib/components/svforge/primitives/index.ts", "file_type": "code", "source_file": "tests/stubs/lib/components/svforge/primitives/index.ts", @@ -24884,6 +24935,14 @@ "source_location": "L1", "_origin": "ast" }, + { + "id": "tests_stubs_lib_icons_index", + "label": "lib/icons/index.js", + "file_type": "code", + "source_file": "tests/stubs/lib/icons/index.js", + "source_location": "L1", + "_origin": "ast" + }, { "id": "tests_stubs_lib_paraglide_messages", "label": "lib/paraglide/messages.js", @@ -25208,7 +25267,15 @@ "_origin": "ast" }, { - "id": "tests_stubs_lib_utils_cn", + "id": "tests_stubs_lib_utils_cn_js_tests_stubs_lib_utils_cn", + "label": "lib/utils/cn.js", + "file_type": "code", + "source_file": "tests/stubs/lib/utils/cn.js", + "source_location": "L1", + "_origin": "ast" + }, + { + "id": "tests_stubs_lib_utils_cn_ts_tests_stubs_lib_utils_cn", "label": "stubs/lib/utils/cn.ts", "file_type": "code", "source_file": "tests/stubs/lib/utils/cn.ts", @@ -25863,7 +25930,7 @@ "label": "ROOT", "file_type": "code", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L19", + "source_location": "L20", "_origin": "ast" }, { @@ -25871,7 +25938,7 @@ "label": "runSv()", "file_type": "code", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L24", + "source_location": "L25", "_callable": true, "_origin": "ast" }, @@ -25880,7 +25947,7 @@ "label": "ensureBuilt()", "file_type": "code", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L32", + "source_location": "L33", "_callable": true, "_origin": "ast" }, @@ -25889,7 +25956,7 @@ "label": "realScaffold()", "file_type": "code", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L40", + "source_location": "L41", "_callable": true, "_origin": "ast" }, @@ -25898,7 +25965,7 @@ "label": "syntaxErrors()", "file_type": "code", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L51", + "source_location": "L52", "_callable": true, "_origin": "ast" }, @@ -26278,6 +26345,15 @@ "source_location": "L1", "_origin": "ast" }, + { + "id": "changeset_quiet_lib_imports", + "label": "quiet-lib-imports.md", + "file_type": "document", + "node_kind": "page", + "source_file": ".changeset/quiet-lib-imports.md", + "source_location": "L1", + "_origin": "ast" + }, { "id": "changeset_tidy_button_classes", "label": "tidy-button-classes.md", @@ -32448,6 +32524,24 @@ "external": true, "source_file": "", "_origin": "semantic" + }, + { + "id": "index_js", + "label": "index_js", + "file_type": "concept", + "type": "external", + "external": true, + "source_file": "", + "_origin": "semantic" + }, + { + "id": "auth_js", + "label": "auth_js", + "file_type": "concept", + "type": "external", + "external": true, + "source_file": "", + "_origin": "semantic" } ], "links": [ @@ -37037,15 +37131,7 @@ }, { "source": "packages_audit_templates_src_routes_app_admin_audit_page", - "target": "ui", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/audit/templates/src/routes/(app)/admin/audit/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_audit_templates_src_routes_app_admin_audit_page", - "target": "primitives", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/audit/templates/src/routes/(app)/admin/audit/+page.svelte", @@ -37717,15 +37803,7 @@ }, { "source": "packages_blog_templates_src_routes_blog_page", - "target": "ui", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/blog/templates/src/routes/blog/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_blog_templates_src_routes_blog_page", - "target": "primitives", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/blog/templates/src/routes/blog/+page.svelte", @@ -37733,7 +37811,7 @@ }, { "source": "packages_blog_templates_src_routes_blog_page", - "target": "runtime", + "target": "runtime_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/blog/templates/src/routes/blog/+page.svelte", @@ -37741,7 +37819,7 @@ }, { "source": "packages_blog_templates_src_routes_blog_page", - "target": "post_date", + "target": "post_date_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/blog/templates/src/routes/blog/+page.svelte", @@ -37801,7 +37879,7 @@ }, { "source": "packages_blog_templates_src_routes_blog_slug_page", - "target": "runtime", + "target": "runtime_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/blog/templates/src/routes/blog/[slug]/+page.svelte", @@ -37809,7 +37887,7 @@ }, { "source": "packages_blog_templates_src_routes_blog_slug_page", - "target": "post_date", + "target": "post_date_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/blog/templates/src/routes/blog/[slug]/+page.svelte", @@ -38430,7 +38508,7 @@ }, { "source": "packages_chat_templates_src_lib_server_chat_chat_security_test", - "target": "db", + "target": "index_js", "relation": "dynamic_import", "confidence": "EXTRACTED", "source_file": "packages/chat/templates/src/lib/server/chat/chat-security.test.ts", @@ -38646,15 +38724,7 @@ }, { "source": "packages_chat_templates_src_routes_chat_page", - "target": "ui", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/chat/templates/src/routes/chat/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_chat_templates_src_routes_chat_page", - "target": "primitives", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/chat/templates/src/routes/chat/+page.svelte", @@ -38740,15 +38810,7 @@ }, { "source": "packages_chat_templates_src_routes_chat_id_page", - "target": "primitives", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/chat/templates/src/routes/chat/[id]/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_chat_templates_src_routes_chat_id_page", - "target": "ui", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/chat/templates/src/routes/chat/[id]/+page.svelte", @@ -39209,7 +39271,7 @@ }, { "source": "packages_dnd_templates_src_lib_components_svforge_dnd_sortablelist", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/dnd/templates/src/lib/components/svforge/dnd/SortableList.svelte", @@ -41838,15 +41900,7 @@ }, { "source": "packages_notifications_templates_src_lib_components_svforge_ui_notificationsbell", - "target": "primitives", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/notifications/templates/src/lib/components/svforge/ui/NotificationsBell.svelte", - "_origin": "ast" - }, - { - "source": "packages_notifications_templates_src_lib_components_svforge_ui_notificationsbell", - "target": "icons", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/notifications/templates/src/lib/components/svforge/ui/NotificationsBell.svelte", @@ -42489,7 +42543,7 @@ }, { "source": "packages_oauth_templates_src_lib_components_svforge_ui_oauthbuttons", - "target": "auth", + "target": "auth_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte", @@ -49663,7 +49717,7 @@ "relation": "contains", "confidence": "EXTRACTED", "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L148", + "source_location": "L136", "weight": 1, "_origin": "ast" }, @@ -49673,7 +49727,7 @@ "relation": "contains", "confidence": "EXTRACTED", "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L172", + "source_location": "L173", "weight": 1, "_origin": "ast" }, @@ -49684,7 +49738,7 @@ "context": "call", "confidence": "EXTRACTED", "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L122", + "source_location": "L108", "weight": 1, "_origin": "ast" }, @@ -49695,7 +49749,7 @@ "context": "call", "confidence": "EXTRACTED", "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L124", + "source_location": "L110", "weight": 1, "_origin": "ast" }, @@ -49706,7 +49760,7 @@ "context": "call", "confidence": "EXTRACTED", "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L243", + "source_location": "L248", "weight": 1, "_origin": "ast" }, @@ -49717,7 +49771,7 @@ "context": "call", "confidence": "EXTRACTED", "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L252", + "source_location": "L258", "weight": 1, "_origin": "ast" }, @@ -50873,7 +50927,7 @@ }, { "source": "packages_svforge_src_templates", - "target": "auth", + "target": "auth_js", "relation": "dynamic_import", "confidence": "EXTRACTED", "source_file": "packages/svforge/src/templates.ts", @@ -58086,7 +58140,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_layout_footer", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/layout/Footer.svelte", @@ -58110,7 +58164,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_layout_navbar", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/layout/Navbar.svelte", @@ -58134,7 +58188,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_layout_navbar", - "target": "primitives", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/layout/Navbar.svelte", @@ -58164,14 +58218,6 @@ "source_file": "packages/svforge/templates/base/src/lib/components/svforge/layout/Navbar.svelte", "_origin": "ast" }, - { - "source": "packages_svforge_templates_base_src_lib_components_svforge_layout_navbar", - "target": "icons", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/base/src/lib/components/svforge/layout/Navbar.svelte", - "_origin": "ast" - }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_layout_index", "target": "packages_svforge_templates_base_src_lib_components_svforge_layout_authlayout", @@ -58207,7 +58253,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_primitives_badge", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/primitives/Badge.svelte", @@ -58301,7 +58347,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_primitives_button", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/primitives/Button.svelte", @@ -58333,7 +58379,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_primitives_checkbox", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/primitives/Checkbox.svelte", @@ -58359,7 +58405,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_primitives_input", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/primitives/Input.svelte", @@ -58393,7 +58439,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_primitives_select", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/primitives/Select.svelte", @@ -58419,7 +58465,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_primitives_textarea", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/primitives/Textarea.svelte", @@ -58435,7 +58481,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_primitives_toggle", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/primitives/Toggle.svelte", @@ -58658,7 +58704,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_ui_alert", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/ui/Alert.svelte", @@ -58692,7 +58738,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_ui_card", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/ui/Card.svelte", @@ -58716,7 +58762,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_ui_logo", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/ui/Logo.svelte", @@ -58724,7 +58770,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_ui_logo", - "target": "icons", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/ui/Logo.svelte", @@ -58740,7 +58786,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_ui_seo", - "target": "web", + "target": "web_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/ui/Seo.svelte", @@ -58850,7 +58896,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_ui_table", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/ui/Table.svelte", @@ -58874,15 +58920,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_ui_themetoggle", - "target": "icons", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/base/src/lib/components/svforge/ui/ThemeToggle.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_base_src_lib_components_svforge_ui_themetoggle", - "target": "primitives", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/ui/ThemeToggle.svelte", @@ -58898,7 +58936,7 @@ }, { "source": "packages_svforge_templates_base_src_lib_components_svforge_ui_themetoggle", - "target": "theme", + "target": "theme_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/lib/components/svforge/ui/ThemeToggle.svelte", @@ -59168,7 +59206,7 @@ }, { "source": "packages_svforge_templates_base_src_routes_layout_svelte_packages_svforge_templates_base_src_routes_layout", - "target": "runtime", + "target": "runtime_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/routes/+layout.svelte", @@ -59184,7 +59222,7 @@ }, { "source": "packages_svforge_templates_base_src_routes_layout_svelte_packages_svforge_templates_base_src_routes_layout", - "target": "layout", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/routes/+layout.svelte", @@ -59210,15 +59248,7 @@ }, { "source": "packages_svforge_templates_base_src_routes_page", - "target": "ui", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/base/src/routes/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_base_src_routes_page", - "target": "primitives", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/routes/+page.svelte", @@ -59226,15 +59256,7 @@ }, { "source": "packages_svforge_templates_base_src_routes_demo_ui_page", - "target": "ui", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/base/src/routes/demo-ui/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_base_src_routes_demo_ui_page", - "target": "primitives", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/base/src/routes/demo-ui/+page.svelte", @@ -61033,7 +61055,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_lib_components_svforge_layout_adminlayout", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte", @@ -61065,15 +61087,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_lib_components_svforge_layout_adminlayout", - "target": "icons", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_lib_components_svforge_layout_adminlayout", - "target": "primitives", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte", @@ -61097,7 +61111,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_lib_components_svforge_ui_avatarinitial", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/lib/components/svforge/ui/AvatarInitial.svelte", @@ -61105,7 +61119,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_lib_components_svforge_ui_feedback", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/lib/components/svforge/ui/Feedback.svelte", @@ -61121,15 +61135,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_lib_components_svforge_ui_feedback", - "target": "icons", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/lib/components/svforge/ui/Feedback.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_lib_components_svforge_ui_feedback", - "target": "primitives", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/lib/components/svforge/ui/Feedback.svelte", @@ -63159,7 +63165,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_lib_server_signup_mode_test", - "target": "db", + "target": "index_js", "relation": "dynamic_import", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/lib/server/signup-mode.test.ts", @@ -63394,7 +63400,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_app_layout", - "target": "auth", + "target": "auth_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/+layout.svelte", @@ -63465,23 +63471,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_app_admin_page", - "target": "ui", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_routes_app_admin_page", - "target": "primitives", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_routes_app_admin_page", - "target": "icons", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/+page.svelte", @@ -63559,23 +63549,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_app_admin_settings_page", - "target": "ui", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/settings/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_routes_app_admin_settings_page", - "target": "primitives", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/settings/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_routes_app_admin_settings_page", - "target": "icons", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/settings/+page.svelte", @@ -63708,23 +63682,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_app_admin_users_page", - "target": "ui", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_routes_app_admin_users_page", - "target": "primitives", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_routes_app_admin_users_page", - "target": "icons", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte", @@ -63787,7 +63745,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_app_admin_users_page_server_test", - "target": "admin", + "target": "admin_js", "relation": "dynamic_import", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/users/page.server.test.ts", @@ -63795,7 +63753,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_app_admin_users_page_server_test", - "target": "db", + "target": "index_js", "relation": "dynamic_import", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/users/page.server.test.ts", @@ -63803,7 +63761,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_app_admin_users_page_server_test", - "target": "schema", + "target": "schema_js", "relation": "dynamic_import", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/admin/users/page.server.test.ts", @@ -63846,7 +63804,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_app_layout_server_test", - "target": "admin", + "target": "admin_js", "relation": "dynamic_import", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/(app)/layout.server.test.ts", @@ -63870,7 +63828,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_layout_svelte_packages_svforge_templates_dashboard_src_routes_layout", - "target": "runtime", + "target": "runtime_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/+layout.svelte", @@ -64066,23 +64024,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_login_page", - "target": "ui", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/routes/login/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_routes_login_page", - "target": "primitives", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/routes/login/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_routes_login_page", - "target": "icons", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/login/+page.svelte", @@ -64090,7 +64032,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_login_page", - "target": "web", + "target": "web_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/login/+page.svelte", @@ -64151,7 +64093,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_login_page_server_test", - "target": "auth", + "target": "auth_js", "relation": "dynamic_import", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/login/page.server.test.ts", @@ -64306,23 +64248,7 @@ }, { "source": "packages_svforge_templates_dashboard_src_routes_setup_page", - "target": "ui", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/routes/setup/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_routes_setup_page", - "target": "primitives", - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": "packages/svforge/templates/dashboard/src/routes/setup/+page.svelte", - "_origin": "ast" - }, - { - "source": "packages_svforge_templates_dashboard_src_routes_setup_page", - "target": "icons", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/svforge/templates/dashboard/src/routes/setup/+page.svelte", @@ -65026,7 +64952,7 @@ }, { "source": "packages_tiptap_templates_src_lib_components_svforge_tiptap_tiptapeditor", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/tiptap/templates/src/lib/components/svforge/tiptap/TiptapEditor.svelte", @@ -65066,7 +64992,7 @@ }, { "source": "packages_tiptap_templates_src_lib_components_svforge_tiptap_tiptappreview", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/tiptap/templates/src/lib/components/svforge/tiptap/TiptapPreview.svelte", @@ -65098,7 +65024,7 @@ }, { "source": "packages_tiptap_templates_src_lib_components_svforge_tiptap_tiptaptoolbar", - "target": "primitives", + "target": "index_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/tiptap/templates/src/lib/components/svforge/tiptap/TiptapToolbar.svelte", @@ -65106,7 +65032,7 @@ }, { "source": "packages_tiptap_templates_src_lib_components_svforge_tiptap_tiptaptoolbar", - "target": "cn", + "target": "cn_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/tiptap/templates/src/lib/components/svforge/tiptap/TiptapToolbar.svelte", @@ -66676,7 +66602,7 @@ }, { "source": "packages_uploads_templates_src_lib_components_svforge_uploads_fileupload", - "target": "post_form", + "target": "post_form_js", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "packages/uploads/templates/src/lib/components/svforge/uploads/FileUpload.svelte", @@ -80154,6 +80080,145 @@ "weight": 1, "_origin": "ast" }, + { + "source": "tests_lib_subpath_imports_test", + "target": "ref_vitest", + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L1", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test", + "target": "ref_node_fs", + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L2", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test", + "target": "ref_node_path", + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L3", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test", + "target": "packages_svforge_src_modes_base", + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L4", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test", + "target": "packages_svforge_src_modes_base_applybasemode", + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L4", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test", + "target": "tests_lib_subpath_imports_test_packages", + "relation": "contains", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L6", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test", + "target": "tests_lib_subpath_imports_test_templatesourcefiles", + "relation": "contains", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L8", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test_templatesourcefiles", + "target": "tests_lib_subpath_imports_test_templatesourcefiles_visit", + "relation": "contains", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L10", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test", + "target": "tests_lib_subpath_imports_test_capturebasepackagetransform", + "relation": "contains", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L25", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test_templatesourcefiles", + "target": "tests_lib_subpath_imports_test_templatesourcefiles_visit", + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L20", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test_templatesourcefiles_visit", + "target": "tests_lib_subpath_imports_test_templatesourcefiles_visit", + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L13", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test", + "target": "tests_lib_subpath_imports_test_templatesourcefiles", + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L42", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_lib_subpath_imports_test", + "target": "tests_lib_subpath_imports_test_capturebasepackagetransform", + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L62", + "weight": 1, + "_origin": "ast" + }, { "source": "tests_llms_docs_freshness_test", "target": "ref_vitest", @@ -84374,7 +84439,18 @@ "_origin": "ast" }, { - "source": "tests_stubs_lib_components_svforge_primitives_index", + "source": "tests_stubs_lib_components_svforge_primitives_index_js_tests_stubs_lib_components_svforge_primitives_index", + "target": "tests_stubs_lib_components_svforge_primitives_index_ts_tests_stubs_lib_components_svforge_primitives_index", + "relation": "imports_from", + "context": "re-export", + "confidence": "EXTRACTED", + "source_file": "tests/stubs/lib/components/svforge/primitives/index.js", + "source_location": "L1", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_stubs_lib_components_svforge_primitives_index_ts_tests_stubs_lib_components_svforge_primitives_index", "target": "packages_svforge_templates_base_src_lib_components_svforge_primitives_badge", "relation": "imports_from", "context": "re-export", @@ -84385,7 +84461,7 @@ "_origin": "ast" }, { - "source": "tests_stubs_lib_components_svforge_primitives_index", + "source": "tests_stubs_lib_components_svforge_primitives_index_ts_tests_stubs_lib_components_svforge_primitives_index", "target": "packages_svforge_templates_base_src_lib_components_svforge_primitives_button", "relation": "imports_from", "context": "re-export", @@ -84396,7 +84472,7 @@ "_origin": "ast" }, { - "source": "tests_stubs_lib_components_svforge_primitives_index", + "source": "tests_stubs_lib_components_svforge_primitives_index_ts_tests_stubs_lib_components_svforge_primitives_index", "target": "packages_svforge_templates_base_src_lib_components_svforge_primitives_input", "relation": "imports_from", "context": "re-export", @@ -84417,6 +84493,17 @@ "weight": 1, "_origin": "ast" }, + { + "source": "tests_stubs_lib_icons_index", + "target": "tests_stubs_lib_icons", + "relation": "imports_from", + "context": "re-export", + "confidence": "EXTRACTED", + "source_file": "tests/stubs/lib/icons/index.js", + "source_location": "L1", + "weight": 1, + "_origin": "ast" + }, { "source": "tests_stubs_lib_paraglide_messages", "target": "tests_stubs_lib_paraglide_messages_uploads_uploading", @@ -84768,7 +84855,29 @@ "_origin": "ast" }, { - "source": "tests_stubs_lib_utils_cn", + "source": "tests_stubs_lib_utils_cn_js_tests_stubs_lib_utils_cn", + "target": "tests_stubs_lib_utils_cn_ts_tests_stubs_lib_utils_cn", + "relation": "imports_from", + "context": "re-export", + "confidence": "EXTRACTED", + "source_file": "tests/stubs/lib/utils/cn.js", + "source_location": "L1", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_stubs_lib_utils_cn_js_tests_stubs_lib_utils_cn", + "target": "tests_stubs_lib_utils_cn_cn", + "relation": "re_exports", + "context": "re-export", + "confidence": "EXTRACTED", + "source_file": "tests/stubs/lib/utils/cn.js", + "source_location": "L1", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_stubs_lib_utils_cn_ts_tests_stubs_lib_utils_cn", "target": "tests_stubs_lib_utils_cn_cn", "relation": "contains", "confidence": "EXTRACTED", @@ -87287,7 +87396,7 @@ "relation": "contains", "confidence": "EXTRACTED", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L19", + "source_location": "L20", "weight": 1, "_origin": "ast" }, @@ -87297,7 +87406,7 @@ "relation": "contains", "confidence": "EXTRACTED", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L24", + "source_location": "L25", "weight": 1, "_origin": "ast" }, @@ -87307,7 +87416,7 @@ "relation": "contains", "confidence": "EXTRACTED", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L32", + "source_location": "L33", "weight": 1, "_origin": "ast" }, @@ -87317,7 +87426,7 @@ "relation": "contains", "confidence": "EXTRACTED", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L40", + "source_location": "L41", "weight": 1, "_origin": "ast" }, @@ -87327,7 +87436,7 @@ "relation": "contains", "confidence": "EXTRACTED", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L51", + "source_location": "L52", "weight": 1, "_origin": "ast" }, @@ -87338,7 +87447,7 @@ "context": "call", "confidence": "EXTRACTED", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L42", + "source_location": "L43", "weight": 1, "_origin": "ast" }, @@ -87350,7 +87459,7 @@ "deferred": true, "confidence": "EXTRACTED", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L52", + "source_location": "L53", "weight": 1, "_origin": "ast" }, @@ -87361,7 +87470,7 @@ "context": "call", "confidence": "EXTRACTED", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L61", + "source_location": "L62", "weight": 1, "_origin": "ast" }, @@ -87372,7 +87481,7 @@ "context": "call", "confidence": "EXTRACTED", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L67", + "source_location": "L68", "weight": 1, "_origin": "ast" }, @@ -87383,7 +87492,7 @@ "context": "call", "confidence": "EXTRACTED", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L83", + "source_location": "L93", "weight": 1, "_origin": "ast" }, @@ -87394,7 +87503,7 @@ "context": "call", "confidence": "EXTRACTED", "source_file": "tests/vite-config-modern-scaffold.test.ts", - "source_location": "L94", + "source_location": "L104", "weight": 1, "_origin": "ast" }, @@ -93494,6 +93603,39 @@ "weight": 1, "_origin": "ast" }, + { + "source": "tests_stubs_lib_components_svforge_primitives_index_js_tests_stubs_lib_components_svforge_primitives_index", + "target": "tests_stubs_lib_components_svforge_primitives_index_ts_tests_stubs_lib_components_svforge_primitives_index", + "relation": "re_exports", + "context": "export", + "confidence": "EXTRACTED", + "source_file": "tests/stubs/lib/components/svforge/primitives/index.js", + "source_location": "L1", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_stubs_lib_icons_index", + "target": "tests_stubs_lib_icons", + "relation": "re_exports", + "context": "export", + "confidence": "EXTRACTED", + "source_file": "tests/stubs/lib/icons/index.js", + "source_location": "L1", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_stubs_lib_utils_cn_js_tests_stubs_lib_utils_cn", + "target": "tests_stubs_lib_utils_cn_ts_tests_stubs_lib_utils_cn", + "relation": "re_exports", + "context": "export", + "confidence": "EXTRACTED", + "source_file": "tests/stubs/lib/utils/cn.js", + "source_location": "L1", + "weight": 1, + "_origin": "ast" + }, { "source": "packages_svforge_templates_base_root_eslint_config", "target": "packages_svforge_templates_base_root_eslint_plugin_svforge_plugin", @@ -93870,7 +94012,7 @@ "confidence": "EXTRACTED", "confidence_score": 1, "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L274", + "source_location": "L280", "weight": 1, "_origin": "ast" }, @@ -93882,7 +94024,7 @@ "confidence": "EXTRACTED", "confidence_score": 1, "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L278", + "source_location": "L284", "weight": 1, "_origin": "ast" }, @@ -93894,7 +94036,7 @@ "confidence": "EXTRACTED", "confidence_score": 1, "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L284", + "source_location": "L290", "weight": 1, "_origin": "ast" }, @@ -93906,7 +94048,7 @@ "confidence": "EXTRACTED", "confidence_score": 1, "source_file": "packages/svforge/src/modes/base.ts", - "source_location": "L286", + "source_location": "L292", "weight": 1, "_origin": "ast" }, @@ -94366,6 +94508,18 @@ "weight": 1, "_origin": "ast" }, + { + "source": "tests_lib_subpath_imports_test_capturebasepackagetransform", + "target": "packages_svforge_src_modes_base_applybasemode", + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "confidence_score": 1, + "source_file": "tests/lib-subpath-imports.test.ts", + "source_location": "L34", + "weight": 1, + "_origin": "ast" + }, { "source": "tests_manifest_freshness_test", "target": "packages_svforge_scripts_generate_skeleton_inventory_buildskeletoninventory", diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index eeba4901..e1137c9f 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -20,7 +20,7 @@ "semantic_hash": "" }, "packages/addon-kit/src/capabilities.ts": { - "ast_hash": "eab0446c4d37341d0f9025192fedcb75", + "ast_hash": "7444dd753637181eaade00b3fed06d41", "semantic_hash": "" }, "packages/addon-kit/src/index.ts": { @@ -52,15 +52,15 @@ "semantic_hash": "" }, "packages/audit/src/index.ts": { - "ast_hash": "66af412d2463562f9c53eec5ba676138", + "ast_hash": "bc41ae0c292d24700af7f319eecea863", "semantic_hash": "" }, "packages/audit/src/templates.ts": { - "ast_hash": "02f88d6e3018dd055e8736970ad9f98e", + "ast_hash": "5b84c876265ae03c97fb2d8042c1e4a5", "semantic_hash": "" }, "packages/audit/templates/src/lib/server/audit/index.ts": { - "ast_hash": "6fd900fd7b29c10f1ed6da869689a36f", + "ast_hash": "8e0f0cbfce924abd663a30246ec4d23e", "semantic_hash": "" }, "packages/audit/templates/src/lib/server/audit/pagination.ts": { @@ -72,11 +72,11 @@ "semantic_hash": "" }, "packages/audit/templates/src/routes/(app)/admin/audit/+page.server.ts": { - "ast_hash": "95a106d0ad6b5810142370f2754d9d8a", + "ast_hash": "79a56412078b42004588340d3cc92af3", "semantic_hash": "" }, "packages/audit/templates/src/routes/(app)/admin/audit/+page.svelte": { - "ast_hash": "70f9ee7b928731cf510fc91db19893d1", + "ast_hash": "03d500e0614a98e4893e6f6b56fa2854", "semantic_hash": "" }, "packages/audit/tsdown.config.ts": { @@ -92,11 +92,11 @@ "semantic_hash": "" }, "packages/blog/src/index.ts": { - "ast_hash": "0a9a7925fa5337c77ecf60be4c6f50e8", + "ast_hash": "709b378222e43ee30d0f5e9fbea2f673", "semantic_hash": "" }, "packages/blog/src/templates.ts": { - "ast_hash": "3d4a5d630b424cc07871ab541d75ea0a", + "ast_hash": "90d8c066b7183666345f63a3ec77f8e9", "semantic_hash": "" }, "packages/blog/templates/src/lib/utils/posts.ts": { @@ -104,19 +104,19 @@ "semantic_hash": "" }, "packages/blog/templates/src/routes/blog/+page.server.ts": { - "ast_hash": "a2ff47e27d83fa4181beb9f1ac179a33", + "ast_hash": "d9a478083aca625f142d61171f0636bb", "semantic_hash": "" }, "packages/blog/templates/src/routes/blog/+page.svelte": { - "ast_hash": "fcbac9de893eb15948d6a10761f82a3c", + "ast_hash": "18ae7882a641f4fffddb60ed1d5f182a", "semantic_hash": "" }, "packages/blog/templates/src/routes/blog/[slug]/+page.server.ts": { - "ast_hash": "d9b91ceb77dbb14cb30513cee1ca336c", + "ast_hash": "1f41f8915aa57663d9f477681a770ba7", "semantic_hash": "" }, "packages/blog/templates/src/routes/blog/[slug]/+page.svelte": { - "ast_hash": "fa84bc588bddc7266f1e57c23d653290", + "ast_hash": "7361899e51171d93a1f23e1855cdbfd1", "semantic_hash": "" }, "packages/blog/tsdown.config.ts": { @@ -132,19 +132,19 @@ "semantic_hash": "" }, "packages/chat/src/index.ts": { - "ast_hash": "7cabd26745acae0934899a353597e2ff", + "ast_hash": "fcec1a09da57e9dcec16b59430c0c6d0", "semantic_hash": "" }, "packages/chat/src/templates.ts": { - "ast_hash": "c44c1b23ee3407346255dd2f5e0776bc", + "ast_hash": "c1b65978af5ceca13fe390a5c60504cb", "semantic_hash": "" }, "packages/chat/templates/src/lib/server/chat/chat-security.test.ts": { - "ast_hash": "0138df7f8acc266517c21b27247f7f33", + "ast_hash": "c41764515262c500ca939f4a41864c7a", "semantic_hash": "" }, "packages/chat/templates/src/lib/server/chat/index.ts": { - "ast_hash": "ff93f6a4bc0421821780442cb043d7b3", + "ast_hash": "3aaff40bb0f10ddbc008bb52db35c524", "semantic_hash": "" }, "packages/chat/templates/src/lib/server/chat/schema.ts": { @@ -152,19 +152,19 @@ "semantic_hash": "" }, "packages/chat/templates/src/routes/chat/+page.server.ts": { - "ast_hash": "a2c65319ec0e206cd04eee2ea9babcc1", + "ast_hash": "ebe730e96b28e981d96ed60a0fc80e28", "semantic_hash": "" }, "packages/chat/templates/src/routes/chat/+page.svelte": { - "ast_hash": "390e7305bb51e6d464ee7ef682d4ba53", + "ast_hash": "3954b1df8f84238f788629f1b345c7c1", "semantic_hash": "" }, "packages/chat/templates/src/routes/chat/[id]/+page.server.ts": { - "ast_hash": "76a078e788ecb9080064f5d4fef25109", + "ast_hash": "fa5f2b13dfd9fbf5ae1274580ef8a313", "semantic_hash": "" }, "packages/chat/templates/src/routes/chat/[id]/+page.svelte": { - "ast_hash": "1b7224f39a3932f70ed3d99e9d2a17d1", + "ast_hash": "db3eacd007c948c5bb1661753c8ecaa8", "semantic_hash": "" }, "packages/chat/tsdown.config.ts": { @@ -180,15 +180,15 @@ "semantic_hash": "" }, "packages/dnd/src/index.ts": { - "ast_hash": "7e80211667b84cf586964bd948463d76", + "ast_hash": "aa16e4b8feb66bd8ded7852fb2c8aa85", "semantic_hash": "" }, "packages/dnd/src/templates.ts": { - "ast_hash": "a4b1a27191b0f6440c80b61a698fb601", + "ast_hash": "99525730845d60a246e19f155470ce3c", "semantic_hash": "" }, "packages/dnd/templates/src/lib/components/svforge/dnd/SortableList.svelte": { - "ast_hash": "2420cfcc580a11f9e1ef70d4681c6e1e", + "ast_hash": "c575c60be58d32330c7647d90fbec213", "semantic_hash": "" }, "packages/dnd/tsdown.config.ts": { @@ -204,7 +204,7 @@ "semantic_hash": "" }, "packages/email/src/index.ts": { - "ast_hash": "aa4cdb9d2ec17eef4980ff0f8ec0f45b", + "ast_hash": "455eb3e9810efed47e329e0e84fe785e", "semantic_hash": "" }, "packages/email/src/templates.ts": { @@ -244,7 +244,7 @@ "semantic_hash": "" }, "packages/graph/src/index.ts": { - "ast_hash": "401e1636a880f78c8537688122be9acd", + "ast_hash": "94814f09c041e49e72610b3c90a369c1", "semantic_hash": "" }, "packages/graph/src/templates.ts": { @@ -272,19 +272,19 @@ "semantic_hash": "" }, "packages/jobs/src/index.ts": { - "ast_hash": "2cd904fb86889434045ab4ea5eada09a", + "ast_hash": "cdcf9165ca4da68b16943e48368c6dd7", "semantic_hash": "" }, "packages/jobs/src/templates.ts": { - "ast_hash": "39ddd849663dbfb31eda4b7362fb067f", + "ast_hash": "f6421d00c536fa05e60c3c50ba8a7a22", "semantic_hash": "" }, "packages/jobs/templates/src/lib/server/jobs/index.ts": { - "ast_hash": "768e53a799c49f5a4f9d432051328fa5", + "ast_hash": "06a15099ae0d7427139da3f7fad6d7f4", "semantic_hash": "" }, "packages/jobs/templates/src/lib/server/jobs/jobs-claim.test.ts": { - "ast_hash": "3501ba66d1d51bcc168232e4baba8ce7", + "ast_hash": "269b7ee76bca76b18416577587f38d1a", "semantic_hash": "" }, "packages/jobs/templates/src/lib/server/jobs/leases.ts": { @@ -316,19 +316,19 @@ "semantic_hash": "" }, "packages/notifications/src/index.ts": { - "ast_hash": "e6ff2f934157155e5d610be8ed63d4d3", + "ast_hash": "56ff77f8b63096b36c6d3c449c7afc5e", "semantic_hash": "" }, "packages/notifications/src/templates.ts": { - "ast_hash": "0e3fb6205019a46195d3b832e677f09e", + "ast_hash": "eb7376833e2ceb24430c8e80384409dd", "semantic_hash": "" }, "packages/notifications/templates/src/lib/components/svforge/ui/NotificationsBell.svelte": { - "ast_hash": "5232978acc56a31879c525c369e4410e", + "ast_hash": "d3b14e9808264daa7bca482e45ff08e7", "semantic_hash": "" }, "packages/notifications/templates/src/lib/server/notifications/index.ts": { - "ast_hash": "60c89a15a1060b0f005d1196b94f498b", + "ast_hash": "0ee640eb8c88de88d6fedee77f959b45", "semantic_hash": "" }, "packages/notifications/templates/src/lib/server/notifications/schema.ts": { @@ -336,7 +336,7 @@ "semantic_hash": "" }, "packages/notifications/templates/src/routes/api/notifications/read-all/+server.ts": { - "ast_hash": "8f95e4ae84b232b1ae116c65553053ef", + "ast_hash": "0e75f187621358d12258b8325b23cf96", "semantic_hash": "" }, "packages/notifications/tsdown.config.ts": { @@ -352,15 +352,15 @@ "semantic_hash": "" }, "packages/oauth/src/index.ts": { - "ast_hash": "a71ba124e8a25d3b0a08665745f3ffaa", + "ast_hash": "4eb8b94a9773b9c025a917c5c1152804", "semantic_hash": "" }, "packages/oauth/src/templates.ts": { - "ast_hash": "45ec21fc154be0b0bc275ef039d0621a", + "ast_hash": "b5e8f3f4f97840ec7d675f79c72128a3", "semantic_hash": "" }, "packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte": { - "ast_hash": "23b10ef3e6000e7eb074407a325feca8", + "ast_hash": "894977405199a0cfae8a47c3dd39a484", "semantic_hash": "" }, "packages/oauth/tsdown.config.ts": { @@ -376,11 +376,11 @@ "semantic_hash": "" }, "packages/realtime/src/index.ts": { - "ast_hash": "622e04285388d7e8866bbd1a773d5557", + "ast_hash": "45fbddf59ccae2f936e6c09859b38b87", "semantic_hash": "" }, "packages/realtime/src/templates.ts": { - "ast_hash": "635906aa5c6c8312982043f3060c210d", + "ast_hash": "3c5dd8ec777665b03774b09774285f76", "semantic_hash": "" }, "packages/realtime/templates/src/lib/realtime/client.ts": { @@ -388,11 +388,11 @@ "semantic_hash": "" }, "packages/realtime/templates/src/lib/server/realtime/hub.ts": { - "ast_hash": "b10e68f0f412a9d777dbfe8b427b069e", + "ast_hash": "fb058ee284a25b62272740cfad407828", "semantic_hash": "" }, "packages/realtime/templates/src/lib/server/realtime/index.ts": { - "ast_hash": "3e73a7337c79b0f133313eef8331888d", + "ast_hash": "32c3225a771576c9c07fb245c8cb44d6", "semantic_hash": "" }, "packages/realtime/tsdown.config.ts": { @@ -456,7 +456,7 @@ "semantic_hash": "" }, "packages/svforge/src/modes/base.ts": { - "ast_hash": "d1620b63e7df41ca0e512538dc9ba109", + "ast_hash": "738317873ed698e3201ef674b18ad5f1", "semantic_hash": "" }, "packages/svforge/src/modes/dashboard.ts": { @@ -468,7 +468,7 @@ "semantic_hash": "" }, "packages/svforge/src/module-recipes.ts": { - "ast_hash": "f30fe622e0055a0a6a760c0ec9b81925", + "ast_hash": "2ec6703fd3a12b827fae6f1e87678689", "semantic_hash": "" }, "packages/svforge/src/recipe-version.ts": { @@ -476,7 +476,7 @@ "semantic_hash": "" }, "packages/svforge/src/scaffolded-agents.ts": { - "ast_hash": "6063fe44a393a1ffdc9e2709ea35868b", + "ast_hash": "87a0ad61e0cd06504ca0ff3446e8556d", "semantic_hash": "" }, "packages/svforge/src/skeleton-inventory.ts": { @@ -488,7 +488,7 @@ "semantic_hash": "" }, "packages/svforge/src/templates.ts": { - "ast_hash": "8ea3c6d025e49ef0678e7d2994cc08a3", + "ast_hash": "45944ccabfdddddea22cb9f895bd8399", "semantic_hash": "" }, "packages/svforge/src/upgrade.ts": { @@ -544,7 +544,7 @@ "semantic_hash": "" }, "packages/svforge/templates/base/root/svforge-modules.json": { - "ast_hash": "fb790ff1387537cd845549f82b771cb5", + "ast_hash": "760059ede7549eb335c91a3da720aa37", "semantic_hash": "" }, "packages/svforge/templates/base/src/app.d.ts": { @@ -552,19 +552,19 @@ "semantic_hash": "" }, "packages/svforge/templates/base/src/hooks.server.ts": { - "ast_hash": "936722f0424c3a0787b6fa204dd5dc82", + "ast_hash": "349af9516dd6fe84ebc848670e299949", "semantic_hash": "" }, "packages/svforge/templates/base/src/hooks.ts": { - "ast_hash": "549bab06228a950e3706e0ec1a59ed25", + "ast_hash": "fe898db2f5014a7e6a8c4edd094b80d4", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/layout/Footer.svelte": { - "ast_hash": "a8c35c0d63a59b47c7eec031d6306251", + "ast_hash": "552893ac5a88692c789511910761f8ca", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/layout/Navbar.svelte": { - "ast_hash": "e1bf8cac6572d06080522b81f0eaa8fe", + "ast_hash": "20436feeb5c35948739d7e17d6e03747", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/layout/index.ts": { @@ -572,31 +572,31 @@ "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/primitives/Badge.svelte": { - "ast_hash": "7d86c5dc2e159eea00085585da5c2b23", + "ast_hash": "1770661d69922df5e4ecffbc64116c7b", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/primitives/Button.svelte": { - "ast_hash": "36a64bf0b285ad2da96a5e1a4362c1c3", + "ast_hash": "0e61d54db8bace7865a015d13b5a1384", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/primitives/Checkbox.svelte": { - "ast_hash": "8a55d666fa728748e4b3a2950df0a681", + "ast_hash": "d0eb443bb614a57749eabe7960a7439f", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/primitives/Input.svelte": { - "ast_hash": "4cacb674e191778c93967a0e01b0205e", + "ast_hash": "49a1c93f1cfef824a7696f8c8cb454a5", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/primitives/Select.svelte": { - "ast_hash": "4b5a796486d55b691320e1198a379b78", + "ast_hash": "141a8c04adf0f2cb44a2474069d35dbb", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/primitives/Textarea.svelte": { - "ast_hash": "978760576452c1a3028c407afd768990", + "ast_hash": "555124fb4a3477f90bc270e3d8701c16", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/primitives/Toggle.svelte": { - "ast_hash": "73e0145c5578d2d09ef9aaa207df3a4d", + "ast_hash": "c636c0e9cb6ec40424133f5f43202c8a", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/primitives/index.ts": { @@ -608,19 +608,19 @@ "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/ui/Alert.svelte": { - "ast_hash": "9194517bc167e3e2b25e483f38adcf41", + "ast_hash": "1ed6de77dc0081213f774263a143f432", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/ui/Card.svelte": { - "ast_hash": "ba1d83898e7149714db5f8d04a299b93", + "ast_hash": "4254dfefc2fbc9f17dd1a41f15642544", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/ui/Logo.svelte": { - "ast_hash": "478365d197ffe2ac6c8cc6857faf87ac", + "ast_hash": "40ae8aeaeafac1a701616094ed558cb7", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/ui/Seo.svelte": { - "ast_hash": "bdcd26f23a2ecc08269e2eaee1cc9aa5", + "ast_hash": "37c4ad03a2c29f1dbeef47fab0cd6c1b", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/ui/Sitemap.ts": { @@ -628,11 +628,11 @@ "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/ui/Table.svelte": { - "ast_hash": "29ad5b7a5037d287c5109b00de644567", + "ast_hash": "f309adbd5710f12bf773003af5f305b3", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/ui/ThemeToggle.svelte": { - "ast_hash": "62be58f7754d1237d36b4ef4711b7c17", + "ast_hash": "8a943889ba6a1175711587ea7a2c8ada", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/ui/index.ts": { @@ -640,7 +640,7 @@ "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/example.test.ts": { - "ast_hash": "e88938e9e3eb1dabfecc552276baba47", + "ast_hash": "d967d134e93b308ccb43e0e175a8bb6a", "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/index.ts": { @@ -660,15 +660,15 @@ "semantic_hash": "" }, "packages/svforge/templates/base/src/routes/+layout.svelte": { - "ast_hash": "38b39c6802135451d7fa589a07d77be6", + "ast_hash": "a062877ebbe4ffe60d33b0bf74cd28f2", "semantic_hash": "" }, "packages/svforge/templates/base/src/routes/+page.svelte": { - "ast_hash": "3778cc613f561ebf1b0364a876731363", + "ast_hash": "315435db022b9cf5202ccd4704282b75", "semantic_hash": "" }, "packages/svforge/templates/base/src/routes/demo-ui/+page.svelte": { - "ast_hash": "3b9ebc59dd3d7c9a9c595b7c0537ff5c", + "ast_hash": "2c9eddef4a98e57d1e6a9fdad1dd2936", "semantic_hash": "" }, "packages/svforge/templates/base/src/routes/favicon.ico/+server.ts": { @@ -676,7 +676,7 @@ "semantic_hash": "" }, "packages/svforge/templates/base/src/vitest.config.ts": { - "ast_hash": "fc409c301255c60fa8c4c79e1454a4a9", + "ast_hash": "0924b83585632c8de7e804ccdeb6ef61", "semantic_hash": "" }, "packages/svforge/templates/base/svelte.config.js": { @@ -724,11 +724,11 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/hooks.server.test.ts": { - "ast_hash": "2d575c182601c071e4f222000676e30d", + "ast_hash": "e971027777747aea54c4d1790bc682d2", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/hooks.server.ts": { - "ast_hash": "4c8f5927b5b42ec5714fe7d6a826a14d", + "ast_hash": "771bde1b445633951a156e8f3d43a972", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/client/auth.ts": { @@ -736,15 +736,15 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte": { - "ast_hash": "11f5ca893c77243145fd2c1ca6347103", + "ast_hash": "d286c98b8949d614d4e96906851f1a62", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/components/svforge/ui/AvatarInitial.svelte": { - "ast_hash": "46a92d8a4705c8099ca3ac105ce3279b", + "ast_hash": "2429a1b2d87321a7bcc4205edeadd459", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/components/svforge/ui/Feedback.svelte": { - "ast_hash": "3b96d8a5825aa05883555fa76e983bac", + "ast_hash": "354a8b94d55b12f8cf5f0bdf74158b1a", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/components/svforge/ui/index.ts": { @@ -752,23 +752,23 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/admin-users.test.ts": { - "ast_hash": "03bb80afda64372ccabc2d087283b2ce", + "ast_hash": "2a4426340814105557f76e0b727e2e87", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/admin-users.ts": { - "ast_hash": "33faaaebe9bf4e98db32ed661568d8ec", + "ast_hash": "0fcde3942a5307c4101afe95a27f7b5f", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/admin.ts": { - "ast_hash": "45a65bfd3300112784405d91fe78d3e1", + "ast_hash": "4cf5ef5c733f9aff602c06f5eb984803", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/auth.test.ts": { - "ast_hash": "b5b3095596794013a8554245c13add97", + "ast_hash": "3b9d3db8c29a15bab3a71e9f95185a28", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/auth.ts": { - "ast_hash": "7ac62fd9b372eb5168b0733f188a19a5", + "ast_hash": "f0e0d9b97bd031cbb69e722c9312a59a", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/db/auth.schema.ts": { @@ -776,7 +776,7 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/db/client.ts": { - "ast_hash": "60d0336b7e2d6bac7c3a73dcbe8bacc8", + "ast_hash": "39763424d0e0ac81a1edcce183405b85", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/db/index.ts": { @@ -788,7 +788,7 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/first-admin.test.ts": { - "ast_hash": "d9d0894d1f59afdad8f85a8eb92b4d0e", + "ast_hash": "4f32fa03a0f94c927f12cd31c2e5f14c", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/first-admin.ts": { @@ -796,7 +796,7 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/invitations.ts": { - "ast_hash": "c5146f6fc3d9d248e3f1e77e5fb6030a", + "ast_hash": "328ceca7313a78bd797c7ac53ae3cd08", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/schemas.ts": { @@ -804,7 +804,7 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/signup-mode.test.ts": { - "ast_hash": "d43d916282d484a0d864955364d6ddd6", + "ast_hash": "5ad239851103ad68ff2f2699f348ec41", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/lib/server/signup-mode.ts": { @@ -828,43 +828,43 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/(app)/+layout.svelte": { - "ast_hash": "1e333d7053e463415d60e9713adb6440", + "ast_hash": "c57c0a5a9cf45f3ec0450d8318ffbc0a", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/(app)/admin/+page.server.ts": { - "ast_hash": "6d79140491d304b09f6320922cc2fcf8", + "ast_hash": "a36008b8048ab012c2596040737f2cdd", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/(app)/admin/+page.svelte": { - "ast_hash": "aa76046a3365069aeeb22e84ab08d7d7", + "ast_hash": "19331630af02d45c7860cad23df7d265", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/(app)/admin/settings/+page.server.ts": { - "ast_hash": "b99fd703b20c6cfc472a5c1fdc8a0d3e", + "ast_hash": "8116ccc3c4c62a1cf8b935cc8e4eee02", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/(app)/admin/settings/+page.svelte": { - "ast_hash": "6a39d3ec3d71aa0e5eb387cce6efbb8e", + "ast_hash": "f4a8f6bcb422511b4a3eca5b1a2d0fbe", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.server.ts": { - "ast_hash": "910fefe4091911daad4af693bb6e27d2", + "ast_hash": "76cc48e3ef9128326d48e7ea323167f3", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte": { - "ast_hash": "bb95a5c9bc497e80cca0ddbb777d1088", + "ast_hash": "1a17d3bc791758cd5cc348d123c103b9", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/(app)/admin/users/page.server.test.ts": { - "ast_hash": "da755cac1b6d95af827b23132da9854e", + "ast_hash": "1e534782fac46888616cc80b7acc82dd", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/(app)/layout.server.test.ts": { - "ast_hash": "c0f4919278f5de6e984e2f2ab4841938", + "ast_hash": "d84719a4feb8d22aea7cce2084433610", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/+layout.svelte": { - "ast_hash": "38aac75627c742051f32e285bdb93fb6", + "ast_hash": "2914d9110682e2243341b5847b28f1ed", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/+page.server.ts": { @@ -872,27 +872,27 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/login/+page.server.ts": { - "ast_hash": "a18b97b1e68ee61be70dac7f087ad1b8", + "ast_hash": "70ca275b4b99861ede86a1916aabbeea", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/login/+page.svelte": { - "ast_hash": "200e35e95cf6ab66e3d68b321baa1a46", + "ast_hash": "ebcf280a5ee26dd0cf195a46e2e17b43", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/login/page.server.test.ts": { - "ast_hash": "678bad240d591be2a484b16df9e956a2", + "ast_hash": "02734ffaa165f851a3af41cc52cf8deb", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/setup/+page.server.ts": { - "ast_hash": "56a53faaac82471e9e33c6c14c05ec5d", + "ast_hash": "e30f98c742bda654866ffd7f4f663766", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/setup/+page.svelte": { - "ast_hash": "d382f426e1019812653f8ca7e822df59", + "ast_hash": "bba8858f2b43c36a9929d66c6e3f9ff7", "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/vitest.config.ts": { - "ast_hash": "47871dc7728fd87faca7870fdd181d8e", + "ast_hash": "42ed1e11085ab6681c21ee136b23e64c", "semantic_hash": "" }, "packages/svforge/templates/dashboard/svelte.config.js": { @@ -916,23 +916,23 @@ "semantic_hash": "" }, "packages/tiptap/src/index.ts": { - "ast_hash": "aa68089ccdc524c530a502a4623cae56", + "ast_hash": "367d2d248b8f3b1abaeb675f8221e246", "semantic_hash": "" }, "packages/tiptap/src/templates.ts": { - "ast_hash": "3fb6446da3c16b7a8d8ba8301e47fed7", + "ast_hash": "095057071d51c02a44f9915934c9ea64", "semantic_hash": "" }, "packages/tiptap/templates/src/lib/components/svforge/tiptap/TiptapEditor.svelte": { - "ast_hash": "497f25faa0b9f5e1c62405ca84f844ec", + "ast_hash": "acf5507a0ff67b2a997f9dbcb4798d66", "semantic_hash": "" }, "packages/tiptap/templates/src/lib/components/svforge/tiptap/TiptapPreview.svelte": { - "ast_hash": "afa2409cafbbbe9da69d1014d58546cd", + "ast_hash": "e0a168e40e96d56f4505acde13bafbc4", "semantic_hash": "" }, "packages/tiptap/templates/src/lib/components/svforge/tiptap/TiptapToolbar.svelte": { - "ast_hash": "da3e21d44ed57b23a563154b6332fc82", + "ast_hash": "cb3676162e6c059f30492aaa2a72ee2f", "semantic_hash": "" }, "packages/tiptap/templates/src/lib/components/svforge/tiptap/index.ts": { @@ -960,7 +960,7 @@ "semantic_hash": "" }, "packages/ui_toast/src/index.ts": { - "ast_hash": "820bae376b4d8150444d88554eb6cc97", + "ast_hash": "8aebee76289ae272238217fb04a41be6", "semantic_hash": "" }, "packages/ui_toast/src/templates.ts": { @@ -992,11 +992,11 @@ "semantic_hash": "" }, "packages/uploads/src/templates.ts": { - "ast_hash": "d5919cd75452c5061381a05133941d90", + "ast_hash": "1f9e873b2d0e71794ab03d4500ec3bd4", "semantic_hash": "" }, "packages/uploads/templates/src/lib/components/svforge/uploads/FileUpload.svelte": { - "ast_hash": "77c07ce0cc2d8a531abd80a8b2473619", + "ast_hash": "ab06f260ce3686fc7e56239ab17532e1", "semantic_hash": "" }, "packages/uploads/templates/src/lib/server/s3.ts": { @@ -1008,11 +1008,11 @@ "semantic_hash": "" }, "packages/uploads/templates/src/routes/api/upload/+server.ts": { - "ast_hash": "92247f5700210eb1205d100e12f13b3a", + "ast_hash": "210fe232bd9a1b77b95c9003cb3a6929", "semantic_hash": "" }, "packages/uploads/templates/src/routes/api/upload/upload-security.test.ts": { - "ast_hash": "98a717e8c924379345412472911b0799", + "ast_hash": "051ed2b7d159d51b312df614eb24f9f2", "semantic_hash": "" }, "packages/uploads/tsdown.config.ts": { @@ -1140,7 +1140,7 @@ "semantic_hash": "" }, "tests/blog-transport-compose.test.ts": { - "ast_hash": "fb5ba39ff50807a1835ba4b84fe3bdd6", + "ast_hash": "c1ac4214e0c527354141bf6b41962408", "semantic_hash": "" }, "tests/build-config.test.ts": { @@ -1168,7 +1168,7 @@ "semantic_hash": "" }, "tests/chat.test.ts": { - "ast_hash": "7a8900620933ab6d3c2b83306ca80316", + "ast_hash": "ac2c6dadf6fa4d810b3e391e313db9d2", "semantic_hash": "" }, "tests/check-generated.test.ts": { @@ -1220,7 +1220,7 @@ "semantic_hash": "" }, "tests/file-upload-contract.test.ts": { - "ast_hash": "58635a5ff1b05da1b3005884f3fbd881", + "ast_hash": "11367b46a7b5b693b6fceb54ffa557c6", "semantic_hash": "" }, "tests/fixtures/CheckboxHarness.svelte": { @@ -1248,7 +1248,7 @@ "semantic_hash": "" }, "tests/helpers/fixtures.ts": { - "ast_hash": "bb2ff1db1e595e067b81c6b6f8f8e273", + "ast_hash": "af17c98910ac9f757c5fc8d54cd038ed", "semantic_hash": "" }, "tests/helpers/skeleton-theme-validator.ts": { @@ -1284,7 +1284,7 @@ "semantic_hash": "" }, "tests/multi-addon-compose.test.ts": { - "ast_hash": "34fd2c1469841ccec9b3444419d68500", + "ast_hash": "a899abef15f2537da49f442906dd292c", "semantic_hash": "" }, "tests/notifications.test.ts": { @@ -1312,7 +1312,7 @@ "semantic_hash": "" }, "tests/patch-markers.test.ts": { - "ast_hash": "a5fb0ef8ad480cf0cb8734a6599e273b", + "ast_hash": "18e7fbba30588d45977e127994e39c72", "semantic_hash": "" }, "tests/primitives-button.test.ts": { @@ -1352,7 +1352,7 @@ "semantic_hash": "" }, "tests/scaffold-paths.test.ts": { - "ast_hash": "41ae1b1b222f308bf510fed971c74c2c", + "ast_hash": "917e19041f2bbfe62cc772044d7aa20c", "semantic_hash": "" }, "tests/security-policy.test.ts": { @@ -1364,7 +1364,7 @@ "semantic_hash": "" }, "tests/skeleton-markup.test.ts": { - "ast_hash": "fbf7903e2fe3526e3c04831c24307a48", + "ast_hash": "18de69ae6c3bee9b68696539358597f7", "semantic_hash": "" }, "tests/skeleton-native.test.ts": { @@ -1388,11 +1388,11 @@ "semantic_hash": "" }, "tests/stubs/lib/paraglide/messages.js": { - "ast_hash": "3ee0e402f7e03aab1c25c282b6c0a733", + "ast_hash": "4f9e654855b05cb440a10dc45f5a925a", "semantic_hash": "" }, "tests/stubs/lib/utils/cn.ts": { - "ast_hash": "221aaa972f88fe0a82f2d9978acb826e", + "ast_hash": "b9f796c16bf9b2f65889a6168439a67a", "semantic_hash": "" }, "tests/svelte-shims.d.ts": { @@ -1412,7 +1412,7 @@ "semantic_hash": "" }, "tests/theme-toggle-contract.test.ts": { - "ast_hash": "7c3709955a8a918bdf10d874d080cbaf", + "ast_hash": "cabe8c73af12a95fd4aaf15317c060fd", "semantic_hash": "" }, "tests/theme-v5.test.ts": { @@ -1448,7 +1448,7 @@ "semantic_hash": "" }, "tests/web-hardening.test.ts": { - "ast_hash": "4bf2525a9c33ef60b9036a8b1f0a3cd7", + "ast_hash": "07494d6a1fba4d5051ac830d9f3b4408", "semantic_hash": "" }, "tsconfig.json": { @@ -1460,7 +1460,7 @@ "semantic_hash": "" }, "vitest.config.ts": { - "ast_hash": "f6b47b2d94e8e4f0615f56e316de38d5", + "ast_hash": "b83c6759971d07f9ef9686f8dcfb9cd5", "semantic_hash": "" }, ".github/workflows/ci.yml": { @@ -1472,7 +1472,7 @@ "semantic_hash": "" }, "AGENTS.md": { - "ast_hash": "7c891a215512f561c9a77b0a6b0014b0", + "ast_hash": "1dbda9d270e8db0ccc9851c192e91b5a", "semantic_hash": "" }, "CHANGELOG.md": { @@ -1500,11 +1500,11 @@ "semantic_hash": "" }, "packages/addon-kit/README.md": { - "ast_hash": "641d4ec4879578532258600507da510f", + "ast_hash": "60cf9137387b058ac2973d5d8e42b3ba", "semantic_hash": "" }, "packages/audit/README.md": { - "ast_hash": "8e18209fa591a8f44a7d430f36960820", + "ast_hash": "50648e085a03dd45c0efd223693322b4", "semantic_hash": "" }, "packages/blog/README.md": { @@ -1516,35 +1516,35 @@ "semantic_hash": "" }, "packages/chat/README.md": { - "ast_hash": "62222e94d55f37d974efd76a67af70c6", + "ast_hash": "fa797ae70552e16be22a7fe294172403", "semantic_hash": "" }, "packages/dnd/README.md": { - "ast_hash": "7942cce2516bd1e1722a496c84f5c6b0", + "ast_hash": "938db7d3bdd9d3cebdb03ae2afacec62", "semantic_hash": "" }, "packages/email/README.md": { - "ast_hash": "95c82f54b37e71254c2c39dca4b763e4", + "ast_hash": "59457bf26c4ea0f4a2e8f6258a667dc7", "semantic_hash": "" }, "packages/graph/README.md": { - "ast_hash": "a05917ab71383d0fb429ac45948b058c", + "ast_hash": "88ce16c2e86b83c260e39ac6c5a50973", "semantic_hash": "" }, "packages/jobs/README.md": { - "ast_hash": "2dfae64a0499363bf57918c61f62cd73", + "ast_hash": "628ab924b52e3b7a116d738603ac3db7", "semantic_hash": "" }, "packages/notifications/README.md": { - "ast_hash": "1613713dff16a1f233c0ec6914eb3f37", + "ast_hash": "072ccd072b537304cc08a3b1f9ffcd00", "semantic_hash": "" }, "packages/oauth/README.md": { - "ast_hash": "953b9ed89dc68a7d88854fa40f56d7b2", + "ast_hash": "029ed5936d5932436b54abde439c9093", "semantic_hash": "" }, "packages/realtime/README.md": { - "ast_hash": "ac8f8276f1d21052945de86481bc54f2", + "ast_hash": "97d301c4c020cd32f95987a2e881f857", "semantic_hash": "" }, "packages/svforge/AGENTS.md": { @@ -1584,15 +1584,15 @@ "semantic_hash": "" }, "packages/tiptap/README.md": { - "ast_hash": "ecf791a13e5e8c3d58c9355759c02e2e", + "ast_hash": "267c6ef391b1b506efa7a9f2d0b334b2", "semantic_hash": "" }, "packages/ui_toast/README.md": { - "ast_hash": "7f4533e18bae83fd0346174f84082d6e", + "ast_hash": "0080113ad95217e2de1ca9fc5df77bb9", "semantic_hash": "" }, "packages/uploads/README.md": { - "ast_hash": "1915a8c9c307420b400793dc16c50e0a", + "ast_hash": "9e71bf2ffb438ef4fac08ec9352cff2d", "semantic_hash": "" }, "docs/screenshots/dashboard.png": { @@ -1696,7 +1696,7 @@ "semantic_hash": "" }, "tests/login-enhance.test.ts": { - "ast_hash": "9897791d5bbc3ef005790ebc09c9c838", + "ast_hash": "ce0a6dbd91cfa62ba5313f4b6fd00d34", "semantic_hash": "" }, "tests/stubs/lib/components/svforge/primitives/index.ts": { @@ -1716,11 +1716,11 @@ "semantic_hash": "" }, "tests/vite-config-modern-scaffold.test.ts": { - "ast_hash": "d99a5f9bbd649fd681ff32f60643e4c2", + "ast_hash": "9f9e24377e412cc0ed80e66b37c95ff1", "semantic_hash": "" }, "tests/vite-config-regex.test.ts": { - "ast_hash": "97cbaedd1451a1dd1eb97570c6f8dde0", + "ast_hash": "76dafb8a468517b86dcf735b13d0a864", "semantic_hash": "" }, "tests/card-surface.test.ts": { @@ -1820,11 +1820,11 @@ "semantic_hash": "" }, "packages/svforge/templates/base/src/lib/components/svforge/layout/AuthLayout.svelte": { - "ast_hash": "9735b3e7fc79277982e9f9d07108ceba", + "ast_hash": "d8a485463c61dafad8b417b07625fc12", "semantic_hash": "" }, "tests/auth-experience.test.ts": { - "ast_hash": "3d601588d6f2bc7f26332e26f4e49933", + "ast_hash": "214e087104bada1d89c5533eb8358b2b", "semantic_hash": "" }, "artifacts/issue-541-auth/login-1024-dark.png": { @@ -1892,7 +1892,7 @@ "semantic_hash": "" }, "packages/svforge/templates/dashboard/src/routes/setup/page.server.test.ts": { - "ast_hash": "e3e5404a4b273d643e48137b3c42053b", + "ast_hash": "ef465b61e686d892c29f3c1f6ce8f0e8", "semantic_hash": "" }, "packages/notifications/templates/src/lib/icons/notifications.ts": { @@ -1912,7 +1912,7 @@ "semantic_hash": "" }, "tests/icon-barrel.test.ts": { - "ast_hash": "1655ddf45ffd287f276025b13cc11973", + "ast_hash": "3f7f5a37739f48b58bed8d07318f7064", "semantic_hash": "" }, "tests/stubs/lib/icons.ts": { @@ -1920,7 +1920,7 @@ "semantic_hash": "" }, "packages/svforge/docs/sveltekit-compatibility.md": { - "ast_hash": "231ca22341061fefb54fc4ad6c4c8050", + "ast_hash": "dbd5466ec5bbc7db9cc8304a031276fc", "semantic_hash": "" }, ".changeset/config.json": { @@ -2082,5 +2082,25 @@ ".changeset/bright-dialog-cursors.md": { "ast_hash": "fdd08c0666fe945dc1f0baf093f852f8", "semantic_hash": "" + }, + "tests/lib-subpath-imports.test.ts": { + "ast_hash": "4e78fe686977cca21c8ff5b2792bea77", + "semantic_hash": "" + }, + "tests/stubs/lib/components/svforge/primitives/index.js": { + "ast_hash": "d352656567f3b3b88c918477db78037b", + "semantic_hash": "" + }, + "tests/stubs/lib/icons/index.js": { + "ast_hash": "3ec7e71e01ca3fca7b438cf164e46b20", + "semantic_hash": "" + }, + "tests/stubs/lib/utils/cn.js": { + "ast_hash": "e2dfd08ff6a9b006c15e2775cd0e73eb", + "semantic_hash": "" + }, + ".changeset/quiet-lib-imports.md": { + "ast_hash": "57486720ca9d0cceabc74c522c88ab1e", + "semantic_hash": "" } } diff --git a/packages/addon-kit/README.md b/packages/addon-kit/README.md index 83456550..0d9da6a1 100644 --- a/packages/addon-kit/README.md +++ b/packages/addon-kit/README.md @@ -39,7 +39,7 @@ stay byte-for-byte identical. | `ui.svforge` | SVForge base UI kit (primitives/ui + utils) | `src/lib/components/svforge/` present | | `i18n.messages` | Paraglide message catalogs (FR/EN initial locales) | paraglide dependency + `messages/*.json` | | `auth.currentUser` | Authenticated user in `locals.user` | `better-auth` dependency | -| `auth.admin` | Admin role helpers (`$lib/server/admin`) | `src/lib/server/admin.*` present | +| `auth.admin` | Admin role helpers (`#lib/server/admin.js`) | `src/lib/server/admin.*` present | | `database.drizzle.postgres` | Drizzle ORM + PostgreSQL driver | `drizzle-orm` + `postgres`/`pg` dependencies | | `storage.object` | S3-compatible object storage client | `@aws-sdk/client-s3` dependency | | `runtime.longLivedWorker` | Runtime keeping a worker alive | not verifiable from files → warning | diff --git a/packages/addon-kit/src/capabilities.ts b/packages/addon-kit/src/capabilities.ts index fb6dd87e..54ff082f 100644 --- a/packages/addon-kit/src/capabilities.ts +++ b/packages/addon-kit/src/capabilities.ts @@ -108,7 +108,7 @@ export const CAPABILITIES: Record = { remedy: 'install the SvelteForge dashboard template (`sv add svforge=template:dashboard`) or set up Better Auth in your project' }, 'auth.admin': { - title: 'Admin role helpers ($lib/server/admin)', + title: 'Admin role helpers (#lib/server/admin.js)', description: 'role helpers such as isAdmin() guarding admin-only routes', remedy: 'install the SvelteForge dashboard template (`sv add svforge=template:dashboard`) or provide your own src/lib/server/admin module exporting isAdmin()' }, @@ -377,7 +377,7 @@ export function detectCapability(token: Capability, project: ProjectSnapshot): D } case 'auth.admin': // SVForge contract file — an external implementation vendors the same - // path (the module imports $lib/server/admin). + // path (the module imports #lib/server/admin.js). return project.hasFile( 'src/lib/server/admin.ts', 'src/lib/server/admin.js', diff --git a/packages/audit/README.md b/packages/audit/README.md index 7f23d5a4..b010e3a9 100644 --- a/packages/audit/README.md +++ b/packages/audit/README.md @@ -22,7 +22,7 @@ Set a product retention period, classify metadata/PII, and limit read access to ## Record ```ts -import { audit } from '$lib/server/audit'; +import { audit } from '#lib/server/audit/index.js'; await audit.record({ actorId: user.id, // null → system action @@ -87,8 +87,8 @@ CREATE TRIGGER audit_logs_append_only ## What's included -- `$lib/server/audit/schema.ts` — Drizzle schema (`audit_logs`) -- `$lib/server/audit/index.ts` — `audit.record` / `forEntity` / `byActor` / `list` +- `src/lib/server/audit/schema.ts` — Drizzle schema (`audit_logs`) +- `src/lib/server/audit/index.ts` — `audit.record` / `forEntity` / `byActor` / `list` - `src/routes/(app)/admin/audit/` — paginated admin view (FR/EN via Paraglide) ## Limits (v1) diff --git a/packages/audit/src/index.ts b/packages/audit/src/index.ts index e5f37ab3..6e4c858d 100644 --- a/packages/audit/src/index.ts +++ b/packages/audit/src/index.ts @@ -81,8 +81,8 @@ export default defineAddon({ // Audit schema must be registered in the Drizzle schema barrel. sv.file('src/lib/server/db/schema.ts', (content) => { - if (hasPatchApplied(content, 'audit-schema', ["from '$lib/server/audit/schema'"])) return content; - return `import { auditLogs } from '$lib/server/audit/schema'; // svforge:patch:audit-schema\n${content}\nexport { auditLogs }; // svforge:patch:audit-schema\n`; + if (hasPatchApplied(content, 'audit-schema', ["from '#lib/server/audit/schema.js'", "from '$lib/server/audit/schema'"])) return content; + return `import { auditLogs } from '#lib/server/audit/schema.js'; // svforge:patch:audit-schema\n${content}\nexport { auditLogs }; // svforge:patch:audit-schema\n`; }); // Paraglide messages (#239): audit UI copy merged FR/EN — precomputed @@ -100,7 +100,7 @@ export default defineAddon({ nextSteps: ({ cwd }) => { const steps = [ '@svforge/audit installed!', - 'Record: import { audit } from "$lib/server/audit";', + 'Record: import { audit } from "#lib/server/audit/index.js";', ' await audit.record({ actorId: user.id, action: "punch.corrected", entityType: "punch", entityId: punch.id });', 'Read: await audit.forEntity("punch", punchId); await audit.byActor(userId);', 'Admin view: /admin/audit (pagination + filters)' diff --git a/packages/audit/src/templates.ts b/packages/audit/src/templates.ts index 12141719..71074d19 100644 --- a/packages/audit/src/templates.ts +++ b/packages/audit/src/templates.ts @@ -1,8 +1,8 @@ // AUTO-GENERATED - DO NOT EDIT export const files = { - "/lib/server/audit/index.ts": "import { db } from '$lib/server/db';\nimport { auditLogs } from './schema';\nimport { desc, eq, and } from 'drizzle-orm';\n\n/**\n * SvelteForge audit trail (#232) — append-only business action logging.\n *\n * await audit.record({\n * actorId: user.id,\n * action: 'punch.corrected',\n * entityType: 'punch',\n * entityId: punch.id,\n * metadata: { before: { time: oldTime }, after: { time: newTime }, reason }\n * });\n *\n * Reads:\n * await audit.forEntity('punch', punchId);\n * await audit.byActor(userId, { limit: 50 });\n */\n\nexport interface AuditEntryInput {\n\tactorId?: string | null;\n\taction: string;\n\tentityType: string;\n\tentityId?: string | null;\n\tmetadata?: Record;\n\tipAddress?: string | null;\n\tuserAgent?: string | null;\n}\n\nexport interface AuditListOptions {\n\tlimit?: number;\n\toffset?: number;\n}\n\nexport interface AuditFilters extends AuditListOptions {\n\taction?: string;\n\tentityType?: string;\n}\n\nexport const audit = {\n\t/** Append an audit entry. Never updates/deletes existing rows. */\n\tasync record(input: AuditEntryInput): Promise {\n\t\tconst [row] = await db\n\t\t\t.insert(auditLogs)\n\t\t\t.values({\n\t\t\t\tactorId: input.actorId ?? null,\n\t\t\t\taction: input.action,\n\t\t\t\tentityType: input.entityType,\n\t\t\t\tentityId: input.entityId ?? null,\n\t\t\t\tmetadata: input.metadata ?? {},\n\t\t\t\tipAddress: input.ipAddress ?? null,\n\t\t\t\tuserAgent: input.userAgent ?? null,\n\t\t\t\tcreatedAt: new Date()\n\t\t\t})\n\t\t\t.returning();\n\t\treturn row;\n\t},\n\n\t/** Full history for one entity (newest first). */\n\tasync forEntity(entityType: string, entityId: string, opts: AuditListOptions = {}): Promise {\n\t\treturn db\n\t\t\t.select()\n\t\t\t.from(auditLogs)\n\t\t\t.where(and(eq(auditLogs.entityType, entityType), eq(auditLogs.entityId, entityId)))\n\t\t\t.orderBy(desc(auditLogs.createdAt))\n\t\t\t.limit(opts.limit ?? 50)\n\t\t\t.offset(opts.offset ?? 0);\n\t},\n\n\t/** Actions by one actor (newest first). */\n\tasync byActor(actorId: string, opts: AuditListOptions = {}): Promise {\n\t\treturn db\n\t\t\t.select()\n\t\t\t.from(auditLogs)\n\t\t\t.where(eq(auditLogs.actorId, actorId))\n\t\t\t.orderBy(desc(auditLogs.createdAt))\n\t\t\t.limit(opts.limit ?? 50)\n\t\t\t.offset(opts.offset ?? 0);\n\t},\n\n\t/** Recent actions with optional filters (admin view). */\n\tasync list(filters: AuditFilters = {}): Promise {\n\t\tconst conditions = [];\n\t\tif (filters.action) conditions.push(eq(auditLogs.action, filters.action));\n\t\tif (filters.entityType) conditions.push(eq(auditLogs.entityType, filters.entityType));\n\t\tconst where = conditions.length ? and(...conditions) : undefined;\n\t\tconst q = db.select().from(auditLogs);\n\t\tif (where) q.where(where);\n\t\treturn q.orderBy(desc(auditLogs.createdAt)).limit(filters.limit ?? 50).offset(filters.offset ?? 0);\n\t}\n};\n", + "/lib/server/audit/index.ts": "import { db } from '#lib/server/db/index.js';\nimport { auditLogs } from './schema';\nimport { desc, eq, and } from 'drizzle-orm';\n\n/**\n * SvelteForge audit trail (#232) — append-only business action logging.\n *\n * await audit.record({\n * actorId: user.id,\n * action: 'punch.corrected',\n * entityType: 'punch',\n * entityId: punch.id,\n * metadata: { before: { time: oldTime }, after: { time: newTime }, reason }\n * });\n *\n * Reads:\n * await audit.forEntity('punch', punchId);\n * await audit.byActor(userId, { limit: 50 });\n */\n\nexport interface AuditEntryInput {\n\tactorId?: string | null;\n\taction: string;\n\tentityType: string;\n\tentityId?: string | null;\n\tmetadata?: Record;\n\tipAddress?: string | null;\n\tuserAgent?: string | null;\n}\n\nexport interface AuditListOptions {\n\tlimit?: number;\n\toffset?: number;\n}\n\nexport interface AuditFilters extends AuditListOptions {\n\taction?: string;\n\tentityType?: string;\n}\n\nexport const audit = {\n\t/** Append an audit entry. Never updates/deletes existing rows. */\n\tasync record(input: AuditEntryInput): Promise {\n\t\tconst [row] = await db\n\t\t\t.insert(auditLogs)\n\t\t\t.values({\n\t\t\t\tactorId: input.actorId ?? null,\n\t\t\t\taction: input.action,\n\t\t\t\tentityType: input.entityType,\n\t\t\t\tentityId: input.entityId ?? null,\n\t\t\t\tmetadata: input.metadata ?? {},\n\t\t\t\tipAddress: input.ipAddress ?? null,\n\t\t\t\tuserAgent: input.userAgent ?? null,\n\t\t\t\tcreatedAt: new Date()\n\t\t\t})\n\t\t\t.returning();\n\t\treturn row;\n\t},\n\n\t/** Full history for one entity (newest first). */\n\tasync forEntity(entityType: string, entityId: string, opts: AuditListOptions = {}): Promise {\n\t\treturn db\n\t\t\t.select()\n\t\t\t.from(auditLogs)\n\t\t\t.where(and(eq(auditLogs.entityType, entityType), eq(auditLogs.entityId, entityId)))\n\t\t\t.orderBy(desc(auditLogs.createdAt))\n\t\t\t.limit(opts.limit ?? 50)\n\t\t\t.offset(opts.offset ?? 0);\n\t},\n\n\t/** Actions by one actor (newest first). */\n\tasync byActor(actorId: string, opts: AuditListOptions = {}): Promise {\n\t\treturn db\n\t\t\t.select()\n\t\t\t.from(auditLogs)\n\t\t\t.where(eq(auditLogs.actorId, actorId))\n\t\t\t.orderBy(desc(auditLogs.createdAt))\n\t\t\t.limit(opts.limit ?? 50)\n\t\t\t.offset(opts.offset ?? 0);\n\t},\n\n\t/** Recent actions with optional filters (admin view). */\n\tasync list(filters: AuditFilters = {}): Promise {\n\t\tconst conditions = [];\n\t\tif (filters.action) conditions.push(eq(auditLogs.action, filters.action));\n\t\tif (filters.entityType) conditions.push(eq(auditLogs.entityType, filters.entityType));\n\t\tconst where = conditions.length ? and(...conditions) : undefined;\n\t\tconst q = db.select().from(auditLogs);\n\t\tif (where) q.where(where);\n\t\treturn q.orderBy(desc(auditLogs.createdAt)).limit(filters.limit ?? 50).offset(filters.offset ?? 0);\n\t}\n};\n", "/lib/server/audit/pagination.ts": "/**\n * Audit pagination parsing (#297).\n *\n * Pure helper (no $lib imports) so it is unit-testable from the repo root:\n * `limit` is clamped to the explicit 1..100 range (default 50) and `offset`\n * to >= 0 (default 0). A negative limit must never reach the query layer\n * (LIMIT -10 is invalid SQL); non-numeric or out-of-range values fall back.\n */\nexport function parsePagination(\n\tsearchParams: URLSearchParams\n): { limit: number; offset: number } {\n\tconst rawLimit = searchParams.get('limit');\n\tconst parsedLimit = rawLimit === null || rawLimit === '' ? NaN : Number(rawLimit);\n\t// Clamp to the explicit 1..100 range: negative/zero values are clamped up\n\t// to 1, over-max values are clamped down to 100, missing/non-numeric\n\t// values fall back to the 50 default. A negative LIMIT can never reach\n\t// the query layer.\n\tconst limit = Number.isNaN(parsedLimit) ? 50 : Math.min(100, Math.max(1, Math.trunc(parsedLimit)));\n\tconst rawOffset = searchParams.get('offset');\n\tconst parsedOffset = rawOffset === null || rawOffset === '' ? NaN : Number(rawOffset);\n\tconst offset = Number.isNaN(parsedOffset) ? 0 : Math.max(0, Math.trunc(parsedOffset));\n\treturn { limit, offset };\n}\n", "/lib/server/audit/schema.ts": "import { pgTable, uuid, text, jsonb, timestamp } from 'drizzle-orm/pg-core';\n\n/**\n * Audit log schema (#232) — append-only at the application level.\n * There is NO update/delete path exposed by the API.\n *\n * `metadata` is for structured context (before/after, reason). NEVER store:\n * passwords, tokens, full object dumps of sensitive data, PII beyond what the\n * feature genuinely needs. See README.\n */\nexport const auditLogs = pgTable('audit_logs', {\n\tid: uuid('id').primaryKey().defaultRandom(),\n\tactorId: text('actor_id'), // nullable → system action\n\taction: text('action').notNull(),\n\tentityType: text('entity_type').notNull(),\n\tentityId: text('entity_id'),\n\tmetadata: jsonb('metadata').$type>(),\n\tipAddress: text('ip_address'),\n\tuserAgent: text('user_agent'),\n\tcreatedAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()\n});\n", - "/routes/(app)/admin/audit/+page.server.ts": "import { isAdmin } from '$lib/server/admin';\nimport { audit } from '$lib/server/audit';\nimport { parsePagination } from '$lib/server/audit/pagination';\nimport { error, type RequestEvent } from '@sveltejs/kit';\nimport type { PageServerLoad } from './$types';\n\n/** Admin guard (same pattern as the other admin pages). */\nasync function requireAdmin(event: RequestEvent): Promise {\n\tif (!event.locals.user) {\n\t\tthrow error(401, { message: 'Authentication required' });\n\t}\n\tif (!(await isAdmin(event.locals.user.id))) {\n\t\tthrow error(403, { message: 'Admin access required' });\n\t}\n\treturn event.locals.user.id;\n}\n\nexport const load: PageServerLoad = async ({ locals, url }) => {\n\tawait requireAdmin({ locals } as RequestEvent);\n\n\tconst { limit, offset } = parsePagination(url.searchParams);\n\tconst action = url.searchParams.get('action') ?? undefined;\n\tconst entityType = url.searchParams.get('entityType') ?? undefined;\n\n\tconst entries = await audit.list({ limit, offset, action, entityType });\n\treturn { entries, limit, offset };\n};\n", - "/routes/(app)/admin/audit/+page.svelte": "\n\n{m.audit_title()}\n\n
\n\t
\n\t\t

{m.audit_title()}

\n\t\t

{m.audit_subtitle()}

\n\t
\n\n\t
\n\t\t\n\t\t\n\t\t\n\t\n\n\t{#if rows.length}\n\t\t\n\n\t\t
\n\t\t\t{#if offset > 0}\n\t\t\t\t\n\t\t\t{/if}\n\t\t\t{#if rows.length === limit}\n\t\t\t\t\n\t\t\t{/if}\n\t\t
\n\t{:else}\n\t\t{m.audit_empty()}\n\t{/if}\n\n" + "/routes/(app)/admin/audit/+page.server.ts": "import { isAdmin } from '#lib/server/admin.js';\nimport { audit } from '#lib/server/audit/index.js';\nimport { parsePagination } from '#lib/server/audit/pagination.js';\nimport { error, type RequestEvent } from '@sveltejs/kit';\nimport type { PageServerLoad } from './$types';\n\n/** Admin guard (same pattern as the other admin pages). */\nasync function requireAdmin(event: RequestEvent): Promise {\n\tif (!event.locals.user) {\n\t\tthrow error(401, { message: 'Authentication required' });\n\t}\n\tif (!(await isAdmin(event.locals.user.id))) {\n\t\tthrow error(403, { message: 'Admin access required' });\n\t}\n\treturn event.locals.user.id;\n}\n\nexport const load: PageServerLoad = async ({ locals, url }) => {\n\tawait requireAdmin({ locals } as RequestEvent);\n\n\tconst { limit, offset } = parsePagination(url.searchParams);\n\tconst action = url.searchParams.get('action') ?? undefined;\n\tconst entityType = url.searchParams.get('entityType') ?? undefined;\n\n\tconst entries = await audit.list({ limit, offset, action, entityType });\n\treturn { entries, limit, offset };\n};\n", + "/routes/(app)/admin/audit/+page.svelte": "\n\n{m.audit_title()}\n\n
\n\t
\n\t\t

{m.audit_title()}

\n\t\t

{m.audit_subtitle()}

\n\t
\n\n\t
\n\t\t\n\t\t\n\t\t\n\t\n\n\t{#if rows.length}\n\t\t
\n\n\t\t
\n\t\t\t{#if offset > 0}\n\t\t\t\t\n\t\t\t{/if}\n\t\t\t{#if rows.length === limit}\n\t\t\t\t\n\t\t\t{/if}\n\t\t
\n\t{:else}\n\t\t{m.audit_empty()}\n\t{/if}\n\n" }; diff --git a/packages/audit/templates/src/lib/server/audit/index.ts b/packages/audit/templates/src/lib/server/audit/index.ts index fee2f37a..bc33d6db 100644 --- a/packages/audit/templates/src/lib/server/audit/index.ts +++ b/packages/audit/templates/src/lib/server/audit/index.ts @@ -1,4 +1,4 @@ -import { db } from '$lib/server/db'; +import { db } from '#lib/server/db/index.js'; import { auditLogs } from './schema'; import { desc, eq, and } from 'drizzle-orm'; diff --git a/packages/audit/templates/src/routes/(app)/admin/audit/+page.server.ts b/packages/audit/templates/src/routes/(app)/admin/audit/+page.server.ts index ff67f7cd..dc65fd8e 100644 --- a/packages/audit/templates/src/routes/(app)/admin/audit/+page.server.ts +++ b/packages/audit/templates/src/routes/(app)/admin/audit/+page.server.ts @@ -1,6 +1,6 @@ -import { isAdmin } from '$lib/server/admin'; -import { audit } from '$lib/server/audit'; -import { parsePagination } from '$lib/server/audit/pagination'; +import { isAdmin } from '#lib/server/admin.js'; +import { audit } from '#lib/server/audit/index.js'; +import { parsePagination } from '#lib/server/audit/pagination.js'; import { error, type RequestEvent } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; diff --git a/packages/audit/templates/src/routes/(app)/admin/audit/+page.svelte b/packages/audit/templates/src/routes/(app)/admin/audit/+page.svelte index e169bb86..58ecf3fc 100644 --- a/packages/audit/templates/src/routes/(app)/admin/audit/+page.svelte +++ b/packages/audit/templates/src/routes/(app)/admin/audit/+page.svelte @@ -1,7 +1,7 @@ \n\n\n```\n", - "/routes/blog/+page.server.ts": "import { getAllPosts } from '$lib/utils/posts';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async () => {\nreturn { posts: await getAllPosts() };\n};\n", - "/routes/blog/+page.svelte": "\n\n{m.blog_title()}\n\n
\n

{m.blog_title()}

\n\n
\n", - "/routes/blog/[slug]/+page.server.ts": "import { getPost } from '$lib/utils/posts';\nimport { error } from '@sveltejs/kit';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async ({ params }) => {\n\tconst post = await getPost(params.slug);\n\tif (!post) throw error(404, 'Post not found');\n\treturn { post };\n};\n", - "/routes/blog/[slug]/+page.svelte": "\n\n\n{data.post.title}\n\n\n\n\n" + "/routes/blog/+page.server.ts": "import { getAllPosts } from '#lib/utils/posts.js';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async () => {\nreturn { posts: await getAllPosts() };\n};\n", + "/routes/blog/+page.svelte": "\n\n{m.blog_title()}\n\n
\n

{m.blog_title()}

\n\n
\n", + "/routes/blog/[slug]/+page.server.ts": "import { getPost } from '#lib/utils/posts.js';\nimport { error } from '@sveltejs/kit';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async ({ params }) => {\n\tconst post = await getPost(params.slug);\n\tif (!post) throw error(404, 'Post not found');\n\treturn { post };\n};\n", + "/routes/blog/[slug]/+page.svelte": "\n\n\n{data.post.title}\n\n\n\n\n" }; diff --git a/packages/blog/templates/src/routes/blog/+page.server.ts b/packages/blog/templates/src/routes/blog/+page.server.ts index 799237f4..11ecb3fc 100644 --- a/packages/blog/templates/src/routes/blog/+page.server.ts +++ b/packages/blog/templates/src/routes/blog/+page.server.ts @@ -1,4 +1,4 @@ -import { getAllPosts } from '$lib/utils/posts'; +import { getAllPosts } from '#lib/utils/posts.js'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { diff --git a/packages/blog/templates/src/routes/blog/+page.svelte b/packages/blog/templates/src/routes/blog/+page.svelte index f1e0e917..a74439aa 100644 --- a/packages/blog/templates/src/routes/blog/+page.svelte +++ b/packages/blog/templates/src/routes/blog/+page.svelte @@ -1,9 +1,9 @@ diff --git a/packages/blog/templates/src/routes/blog/[slug]/+page.server.ts b/packages/blog/templates/src/routes/blog/[slug]/+page.server.ts index 7cee0157..6f389c3e 100644 --- a/packages/blog/templates/src/routes/blog/[slug]/+page.server.ts +++ b/packages/blog/templates/src/routes/blog/[slug]/+page.server.ts @@ -1,4 +1,4 @@ -import { getPost } from '$lib/utils/posts'; +import { getPost } from '#lib/utils/posts.js'; import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; diff --git a/packages/blog/templates/src/routes/blog/[slug]/+page.svelte b/packages/blog/templates/src/routes/blog/[slug]/+page.svelte index b2dbda4c..19fb87db 100644 --- a/packages/blog/templates/src/routes/blog/[slug]/+page.svelte +++ b/packages/blog/templates/src/routes/blog/[slug]/+page.svelte @@ -1,7 +1,7 @@ \n\n{m.chat_title()}\n\n\n", - "/routes/chat/[id]/+page.server.ts": "import { error, redirect, fail } from '@sveltejs/kit';\nimport { chat } from '$lib/server/chat';\nimport type { PageServerLoad, Actions } from './$types';\n\nexport const load: PageServerLoad = async ({ locals, params }) => {\n\tif (!locals.user) throw redirect(302, '/login');\n\tconst currentUser = locals.user; // capture — TS narrows are lost after awaits\n\tconst conversationId = params.id; // uuid (#255)\n\n\ttry {\n\t\tconst messages = await chat.listMessages(conversationId, currentUser.id, { limit: 50 });\n\t\tawait chat.markRead(conversationId, currentUser.id);\n\t\treturn { conversationId, messages };\n\t} catch (e) {\n\t\tthrow error(403, { message: e instanceof Error ? e.message : 'Forbidden' });\n\t}\n};\n\nexport const actions: Actions = {\n\tsend: async ({ locals, params, request }) => {\n\t\tif (!locals.user) throw redirect(302, '/login');\n\t\tconst currentUser = locals.user; // capture — TS narrows are lost after awaits\n\t\tconst conversationId = params.id; // uuid (#255)\n\t\tconst form = await request.formData();\n\t\tconst content = String(form.get('content') ?? '').trim();\n\t\tif (!content) return fail(400, { error: 'Message is required' });\n\n\t\ttry {\n\t\t\tconst message = await chat.sendMessage({\n\t\t\t\tconversationId,\n\t\t\t\tauthorId: currentUser.id, // server-side identity — no client spoofing\n\t\t\t\tcontent\n\t\t\t});\n\t\t\treturn { message };\n\t\t} catch (e) {\n\t\t\treturn fail(403, { error: e instanceof Error ? e.message : 'Forbidden' });\n\t\t}\n\t}\n};\n", - "/routes/chat/[id]/+page.svelte": "\n\n{m.chat_conversation()} #{conversationId}\n\n
\n\t

{m.chat_conversation()} #{conversationId}

\n\n\t{#if messages.length === 0}\n\t\t{m.chat_empty()}\n\t{:else}\n\t\t\n\t\t
    \n\t\t\t{#each [...messages].reverse() as msg (msg.id)}\n\t\t\t\t
  • \n\t\t\t\t\t{msg.authorId} · {new Date(msg.createdAt).toLocaleString()}\n\t\t\t\t\t{msg.content}\n\t\t\t\t
  • \n\t\t\t{/each}\n\t\t
\n\t\t
\n\t{/if}\n\n\t{#if form?.error}\n\t\t

{form.error}

\n\t{/if}\n\n\t
\n\t\t\n\t\t\n\t\n
\n" + "/routes/chat/+page.server.ts": "import { redirect, type RequestEvent } from '@sveltejs/kit';\nimport { chat } from '#lib/server/chat/index.js';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async ({ locals }) => {\n\tif (!locals.user) throw redirect(302, '/login');\n\tconst conversations = await chat.listConversations(locals.user.id);\n\treturn { conversations };\n};\n", + "/routes/chat/+page.svelte": "\n\n{m.chat_title()}\n\n\n", + "/routes/chat/[id]/+page.server.ts": "import { error, redirect, fail } from '@sveltejs/kit';\nimport { chat } from '#lib/server/chat/index.js';\nimport type { PageServerLoad, Actions } from './$types';\n\nexport const load: PageServerLoad = async ({ locals, params }) => {\n\tif (!locals.user) throw redirect(302, '/login');\n\tconst currentUser = locals.user; // capture — TS narrows are lost after awaits\n\tconst conversationId = params.id; // uuid (#255)\n\n\ttry {\n\t\tconst messages = await chat.listMessages(conversationId, currentUser.id, { limit: 50 });\n\t\tawait chat.markRead(conversationId, currentUser.id);\n\t\treturn { conversationId, messages };\n\t} catch (e) {\n\t\tthrow error(403, { message: e instanceof Error ? e.message : 'Forbidden' });\n\t}\n};\n\nexport const actions: Actions = {\n\tsend: async ({ locals, params, request }) => {\n\t\tif (!locals.user) throw redirect(302, '/login');\n\t\tconst currentUser = locals.user; // capture — TS narrows are lost after awaits\n\t\tconst conversationId = params.id; // uuid (#255)\n\t\tconst form = await request.formData();\n\t\tconst content = String(form.get('content') ?? '').trim();\n\t\tif (!content) return fail(400, { error: 'Message is required' });\n\n\t\ttry {\n\t\t\tconst message = await chat.sendMessage({\n\t\t\t\tconversationId,\n\t\t\t\tauthorId: currentUser.id, // server-side identity — no client spoofing\n\t\t\t\tcontent\n\t\t\t});\n\t\t\treturn { message };\n\t\t} catch (e) {\n\t\t\treturn fail(403, { error: e instanceof Error ? e.message : 'Forbidden' });\n\t\t}\n\t}\n};\n", + "/routes/chat/[id]/+page.svelte": "\n\n{m.chat_conversation()} #{conversationId}\n\n
\n\t

{m.chat_conversation()} #{conversationId}

\n\n\t{#if messages.length === 0}\n\t\t{m.chat_empty()}\n\t{:else}\n\t\t\n\t\t
    \n\t\t\t{#each [...messages].reverse() as msg (msg.id)}\n\t\t\t\t
  • \n\t\t\t\t\t{msg.authorId} · {new Date(msg.createdAt).toLocaleString()}\n\t\t\t\t\t{msg.content}\n\t\t\t\t
  • \n\t\t\t{/each}\n\t\t
\n\t\t
\n\t{/if}\n\n\t{#if form?.error}\n\t\t

{form.error}

\n\t{/if}\n\n\t
\n\t\t\n\t\t\n\t\n
\n" }; diff --git a/packages/chat/templates/src/lib/server/chat/chat-security.test.ts b/packages/chat/templates/src/lib/server/chat/chat-security.test.ts index 88abf32f..8db41286 100644 --- a/packages/chat/templates/src/lib/server/chat/chat-security.test.ts +++ b/packages/chat/templates/src/lib/server/chat/chat-security.test.ts @@ -17,8 +17,8 @@ vi.mock('$app/env/private', async () => { // about the SQL itself (row volume, DISTINCT ON) that results alone cannot. const recorded = vi.hoisted(() => [] as { sql: string; params: unknown[] }[]); -vi.mock('$lib/server/db', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('#lib/server/db/index.js', async (importOriginal) => { + const actual = await importOriginal(); // Wraps a drizzle query builder: chained builders (objects returned by // .from()/.where()/…) get wrapped too; awaiting (`then`) snapshots the // fully built query first, then delegates to the real execution. @@ -57,7 +57,7 @@ vi.mock('$lib/server/db', async (importOriginal) => { }); import { chat } from './index'; -import { db } from '$lib/server/db'; +import { db } from '#lib/server/db/index.js'; import { conversations } from './schema'; /** diff --git a/packages/chat/templates/src/lib/server/chat/index.ts b/packages/chat/templates/src/lib/server/chat/index.ts index cb844a38..90c9ec0b 100644 --- a/packages/chat/templates/src/lib/server/chat/index.ts +++ b/packages/chat/templates/src/lib/server/chat/index.ts @@ -1,4 +1,4 @@ -import { db } from '$lib/server/db'; +import { db } from '#lib/server/db/index.js'; import { conversations, conversationParticipants, diff --git a/packages/chat/templates/src/routes/chat/+page.server.ts b/packages/chat/templates/src/routes/chat/+page.server.ts index c2ba3923..be041f1f 100644 --- a/packages/chat/templates/src/routes/chat/+page.server.ts +++ b/packages/chat/templates/src/routes/chat/+page.server.ts @@ -1,5 +1,5 @@ import { redirect, type RequestEvent } from '@sveltejs/kit'; -import { chat } from '$lib/server/chat'; +import { chat } from '#lib/server/chat/index.js'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals }) => { diff --git a/packages/chat/templates/src/routes/chat/+page.svelte b/packages/chat/templates/src/routes/chat/+page.svelte index 0be47e77..8be0d0a3 100644 --- a/packages/chat/templates/src/routes/chat/+page.svelte +++ b/packages/chat/templates/src/routes/chat/+page.svelte @@ -1,7 +1,7 @@ \n\n
    \n\t{#each items as item, index (item.id)}\n\t\t\n\t\t\t⠿\n\t\t\t
    \n\t\t\t\t{@render children(item)}\n\t\t\t
    \n\t\t\n\t{/each}\n
\n" + "/lib/components/svforge/dnd/SortableList.svelte": "\n\n
    \n\t{#each items as item, index (item.id)}\n\t\t\n\t\t\t⠿\n\t\t\t
    \n\t\t\t\t{@render children(item)}\n\t\t\t
    \n\t\t\n\t{/each}\n
\n" }; diff --git a/packages/dnd/templates/src/lib/components/svforge/dnd/SortableList.svelte b/packages/dnd/templates/src/lib/components/svforge/dnd/SortableList.svelte index d95faa72..efe84e31 100644 --- a/packages/dnd/templates/src/lib/components/svforge/dnd/SortableList.svelte +++ b/packages/dnd/templates/src/lib/components/svforge/dnd/SortableList.svelte @@ -1,6 +1,6 @@ \n\n (open = details.open)}\n\tcloseOnInteractOutside={true}\n\trestoreFocus={true}\n>\n\t\n\t\t\n\t\t{#if unreadCount > 0}\n\t\t\t{unreadCount}\n\t\t{/if}\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t{m.notif_title()}\n\t\t\t\t\t{m.notif_mark_all()}\n\t\t\t\t
\n\n\t\t\t\t{#if items.length === 0}\n\t\t\t\t\t

{m.notif_empty()}

\n\t\t\t\t{:else}\n\t\t\t\t\t\n\t\t\t\t{/if}\n\t\t\t
\n\t\t
\n\t
\n\n", + "/lib/components/svforge/ui/NotificationsBell.svelte": "\n\n (open = details.open)}\n\tcloseOnInteractOutside={true}\n\trestoreFocus={true}\n>\n\t\n\t\t\n\t\t{#if unreadCount > 0}\n\t\t\t{unreadCount}\n\t\t{/if}\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t{m.notif_title()}\n\t\t\t\t\t{m.notif_mark_all()}\n\t\t\t\t
\n\n\t\t\t\t{#if items.length === 0}\n\t\t\t\t\t

{m.notif_empty()}

\n\t\t\t\t{:else}\n\t\t\t\t\t\n\t\t\t\t{/if}\n\t\t\t
\n\t\t
\n\t
\n\n", "/lib/icons/notifications.ts": "export { default as Bell } from 'phosphor-svelte/lib/Bell';\n", - "/lib/server/notifications/index.ts": "import { db } from '$lib/server/db';\nimport { notifications } from './schema';\nimport { desc, eq, and, isNull } from 'drizzle-orm';\n\n/**\n * SvelteForge notifications (#230) — persistent business notifications.\n *\n * await notifications.create({\n * userId,\n * type: 'export.ready',\n * title: 'Export terminé',\n * message: 'Votre export est prêt.',\n * actionUrl: `/exports/${exportId}`\n * });\n * await notifications.markAsRead(id, userId);\n * await notifications.markAllAsRead(userId);\n *\n * The DB is the source of truth. If @svforge/realtime is installed, publish\n * `notification.created` on `user:{userId}` after persist (optional).\n */\n\nexport interface NotificationInput {\n\tuserId: string;\n\ttype: string;\n\ttitle: string;\n\tmessage: string;\n\tactionUrl?: string;\n\tmetadata?: Record;\n}\n\nexport interface NotificationListOptions {\n\tlimit?: number;\n\toffset?: number;\n\tonlyUnread?: boolean;\n}\n\nexport const notificationsApi = {\n\t/** Create a notification for a user. */\n\tasync create(input: NotificationInput) {\n\t\tconst [row] = await db\n\t\t\t.insert(notifications)\n\t\t\t.values({\n\t\t\t\tuserId: input.userId,\n\t\t\t\ttype: input.type,\n\t\t\t\ttitle: input.title,\n\t\t\t\tmessage: input.message,\n\t\t\t\tactionUrl: input.actionUrl ?? null,\n\t\t\t\tmetadata: input.metadata ?? {},\n\t\t\t\treadAt: null,\n\t\t\t\tcreatedAt: new Date()\n\t\t\t})\n\t\t\t.returning();\n\t\treturn row;\n\t},\n\n\t/** Recent notifications for a user (newest first). */\n\tasync list(userId: string, opts: NotificationListOptions = {}) {\n\t\tconst conditions = [eq(notifications.userId, userId)];\n\t\tif (opts.onlyUnread) conditions.push(isNull(notifications.readAt));\n\t\treturn db\n\t\t\t.select()\n\t\t\t.from(notifications)\n\t\t\t.where(and(...conditions))\n\t\t\t.orderBy(desc(notifications.createdAt))\n\t\t\t.limit(opts.limit ?? 20)\n\t\t\t.offset(opts.offset ?? 0);\n\t},\n\n\t/** Count of unread notifications for a user. */\n\tasync unreadCount(userId: string): Promise {\n\t\tconst rows = await db\n\t\t\t.select({ id: notifications.id })\n\t\t\t.from(notifications)\n\t\t\t.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));\n\t\treturn rows.length;\n\t},\n\n\t/** Mark one notification as read (ownership-checked). */\n\tasync markAsRead(notificationId: string, userId: string) {\n\t\tawait db\n\t\t\t.update(notifications)\n\t\t\t.set({ readAt: new Date() })\n\t\t\t.where(and(eq(notifications.id, notificationId), eq(notifications.userId, userId)));\n\t},\n\n\t/** Mark all notifications of a user as read. */\n\tasync markAllAsRead(userId: string) {\n\t\tawait db\n\t\t\t.update(notifications)\n\t\t\t.set({ readAt: new Date() })\n\t\t\t.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));\n\t}\n};\n", + "/lib/server/notifications/index.ts": "import { db } from '#lib/server/db/index.js';\nimport { notifications } from './schema';\nimport { desc, eq, and, isNull } from 'drizzle-orm';\n\n/**\n * SvelteForge notifications (#230) — persistent business notifications.\n *\n * await notifications.create({\n * userId,\n * type: 'export.ready',\n * title: 'Export terminé',\n * message: 'Votre export est prêt.',\n * actionUrl: `/exports/${exportId}`\n * });\n * await notifications.markAsRead(id, userId);\n * await notifications.markAllAsRead(userId);\n *\n * The DB is the source of truth. If @svforge/realtime is installed, publish\n * `notification.created` on `user:{userId}` after persist (optional).\n */\n\nexport interface NotificationInput {\n\tuserId: string;\n\ttype: string;\n\ttitle: string;\n\tmessage: string;\n\tactionUrl?: string;\n\tmetadata?: Record;\n}\n\nexport interface NotificationListOptions {\n\tlimit?: number;\n\toffset?: number;\n\tonlyUnread?: boolean;\n}\n\nexport const notificationsApi = {\n\t/** Create a notification for a user. */\n\tasync create(input: NotificationInput) {\n\t\tconst [row] = await db\n\t\t\t.insert(notifications)\n\t\t\t.values({\n\t\t\t\tuserId: input.userId,\n\t\t\t\ttype: input.type,\n\t\t\t\ttitle: input.title,\n\t\t\t\tmessage: input.message,\n\t\t\t\tactionUrl: input.actionUrl ?? null,\n\t\t\t\tmetadata: input.metadata ?? {},\n\t\t\t\treadAt: null,\n\t\t\t\tcreatedAt: new Date()\n\t\t\t})\n\t\t\t.returning();\n\t\treturn row;\n\t},\n\n\t/** Recent notifications for a user (newest first). */\n\tasync list(userId: string, opts: NotificationListOptions = {}) {\n\t\tconst conditions = [eq(notifications.userId, userId)];\n\t\tif (opts.onlyUnread) conditions.push(isNull(notifications.readAt));\n\t\treturn db\n\t\t\t.select()\n\t\t\t.from(notifications)\n\t\t\t.where(and(...conditions))\n\t\t\t.orderBy(desc(notifications.createdAt))\n\t\t\t.limit(opts.limit ?? 20)\n\t\t\t.offset(opts.offset ?? 0);\n\t},\n\n\t/** Count of unread notifications for a user. */\n\tasync unreadCount(userId: string): Promise {\n\t\tconst rows = await db\n\t\t\t.select({ id: notifications.id })\n\t\t\t.from(notifications)\n\t\t\t.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));\n\t\treturn rows.length;\n\t},\n\n\t/** Mark one notification as read (ownership-checked). */\n\tasync markAsRead(notificationId: string, userId: string) {\n\t\tawait db\n\t\t\t.update(notifications)\n\t\t\t.set({ readAt: new Date() })\n\t\t\t.where(and(eq(notifications.id, notificationId), eq(notifications.userId, userId)));\n\t},\n\n\t/** Mark all notifications of a user as read. */\n\tasync markAllAsRead(userId: string) {\n\t\tawait db\n\t\t\t.update(notifications)\n\t\t\t.set({ readAt: new Date() })\n\t\t\t.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));\n\t}\n};\n", "/lib/server/notifications/schema.ts": "import { pgTable, uuid, text, jsonb, timestamp } from 'drizzle-orm/pg-core';\n\n/**\n * Notification schema (#230) — persistent business notifications.\n * Distinct from ui_toast: a notification is USER DATA with history and\n * read/unread state. `message` is plain text (no arbitrary HTML in v1).\n */\nexport const notifications = pgTable('notifications', {\n\tid: uuid('id').primaryKey().defaultRandom(),\n\tuserId: text('user_id').notNull(),\n\ttype: text('type').notNull(),\n\ttitle: text('title').notNull(),\n\tmessage: text('message').notNull(),\n\tactionUrl: text('action_url'),\n\tmetadata: jsonb('metadata').$type>(),\n\treadAt: timestamp('read_at', { withTimezone: true }),\n\tcreatedAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()\n});\n", - "/routes/api/notifications/read-all/+server.ts": "import { json, error } from '@sveltejs/kit';\nimport { notificationsApi } from '$lib/server/notifications';\nimport type { RequestHandler } from './$types';\n\n/** Mark all notifications of the current user as read. */\nexport const POST: RequestHandler = async ({ locals }) => {\n\tif (!locals.user) throw error(401, { message: 'Authentication required' });\n\tawait notificationsApi.markAllAsRead(locals.user.id);\n\treturn json({ ok: true });\n};\n" + "/routes/api/notifications/read-all/+server.ts": "import { json, error } from '@sveltejs/kit';\nimport { notificationsApi } from '#lib/server/notifications/index.js';\nimport type { RequestHandler } from './$types';\n\n/** Mark all notifications of the current user as read. */\nexport const POST: RequestHandler = async ({ locals }) => {\n\tif (!locals.user) throw error(401, { message: 'Authentication required' });\n\tawait notificationsApi.markAllAsRead(locals.user.id);\n\treturn json({ ok: true });\n};\n" }; diff --git a/packages/notifications/templates/src/lib/components/svforge/ui/NotificationsBell.svelte b/packages/notifications/templates/src/lib/components/svforge/ui/NotificationsBell.svelte index a172c904..cba487dd 100644 --- a/packages/notifications/templates/src/lib/components/svforge/ui/NotificationsBell.svelte +++ b/packages/notifications/templates/src/lib/components/svforge/ui/NotificationsBell.svelte @@ -1,7 +1,7 @@
diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index b8a8d2db..c12c3417 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -33,7 +33,7 @@ export default defineAddon({ run: ({ sv, cancel, cwd }) => { // Capability gate (#323): the OAuth buttons call the authenticated - // session client ($lib/client/auth) — Better Auth must be present. + // session client (#lib/client/auth.js) — Better Auth must be present. const gate = checkModuleCapabilities(cwd, 'oauth'); if (!gate.ok) { cancel(gate.message); @@ -71,7 +71,7 @@ export default defineAddon({ ' google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET },', ' github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET }', ' }', - 'Add OAuth buttons: import OAuthButtons from "$lib/components/svforge/ui/OAuthButtons.svelte"', + 'Add OAuth buttons: import OAuthButtons from "#lib/components/svforge/ui/OAuthButtons.svelte"', 'Copy the OAuth placeholders from .env.example into .env and replace them (src/env.ts declarations were added automatically):', ' GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET' ]; diff --git a/packages/oauth/src/templates.ts b/packages/oauth/src/templates.ts index 926b08dd..743d4233 100644 --- a/packages/oauth/src/templates.ts +++ b/packages/oauth/src/templates.ts @@ -1,4 +1,4 @@ // AUTO-GENERATED - DO NOT EDIT export const files = { - "/lib/components/svforge/ui/OAuthButtons.svelte": "\n\n
\n signIn('google')}\n disabled={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'google'}\n \n {:else}\n \n {/if}\n Google\n \n\n signIn('github')}\n disabled={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'github'}\n \n {:else}\n \n {/if}\n GitHub\n \n
\n" + "/lib/components/svforge/ui/OAuthButtons.svelte": "\n\n
\n signIn('google')}\n disabled={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'google'}\n \n {:else}\n \n {/if}\n Google\n \n\n signIn('github')}\n disabled={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'github'}\n \n {:else}\n \n {/if}\n GitHub\n \n
\n" }; diff --git a/packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte b/packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte index a8f6d5cf..a63ba293 100644 --- a/packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte +++ b/packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte @@ -1,5 +1,5 @@ \n\n{m.audit_title()}\n\n
\n\t
\n\t\t

{m.audit_title()}

\n\t\t

{m.audit_subtitle()}

\n\t
\n\n\t
\n\t\t\n\t\t\n\t\t\n\t\n\n\t{#if rows.length}\n\t\t
\n\n\t\t
\n\t\t\t{#if offset > 0}\n\t\t\t\t\n\t\t\t{/if}\n\t\t\t{#if rows.length === limit}\n\t\t\t\t\n\t\t\t{/if}\n\t\t
\n\t{:else}\n\t\t{m.audit_empty()}\n\t{/if}\n\n" + "/routes/(app)/admin/audit/+page.server.ts": "import { isAdmin } from '#lib/server/admin.js';\nimport { audit } from '#lib/server/audit/index.js';\nimport { parsePagination } from '#lib/server/audit/pagination.js';\nimport { error, type RequestEvent } from '@sveltejs/kit';\nimport type { PageServerLoad } from './$types';\n\n/** Admin guard (same pattern as the other admin pages). */\nasync function requireAdmin(event: RequestEvent): Promise {\n\tif (!event.locals.user) {\n\t\tthrow error(401, { message: 'Authentication required' });\n\t}\n\tif (!(await isAdmin(event.locals.user.id))) {\n\t\tthrow error(403, { message: 'Admin access required' });\n\t}\n\treturn event.locals.user.id;\n}\n\nexport const load: PageServerLoad = async ({ locals, url }) => {\n\tawait requireAdmin({ locals } as RequestEvent);\n\n\tconst { limit, offset } = parsePagination(url.searchParams);\n\tconst action = url.searchParams.get('action') ?? undefined;\n\tconst entityType = url.searchParams.get('entityType') ?? undefined;\n\n\tconst entries = await audit.list({ limit, offset, action, entityType });\n\treturn { entries, limit, offset };\n};\n", + "/routes/(app)/admin/audit/+page.svelte": "\n\n{m.audit_title()}\n\n
\n\t
\n\t\t

{m.audit_title()}

\n\t\t

{m.audit_subtitle()}

\n\t
\n\n\t
\n\t\t\n\t\t\n\t\t\n\t\n\n\t{#if rows.length}\n\t\t
\n\n\t\t
\n\t\t\t{#if offset > 0}\n\t\t\t\t\n\t\t\t{/if}\n\t\t\t{#if rows.length === limit}\n\t\t\t\t\n\t\t\t{/if}\n\t\t
\n\t{:else}\n\t\t{m.audit_empty()}\n\t{/if}\n\n" } }, "blog": { @@ -43,10 +43,10 @@ export const MODULE_RECIPE_DATA: Record = { "/lib/utils/post-date.ts": "/**\n * Format a blog frontmatter date as a calendar date, not a timezone-dependent\n * instant. The explicit UTC zone keeps date-only values stable across server\n * and browser timezones during SSR and hydration.\n */\nexport function formatPostDate(date: string, locale: string): string {\n\treturn new Intl.DateTimeFormat(locale, {\n\t\tyear: 'numeric',\n\t\tmonth: 'long',\n\t\tday: 'numeric',\n\t\ttimeZone: 'UTC'\n\t}).format(new Date(date));\n}\n", "/lib/utils/posts.ts": "import type { Component } from 'svelte';\n\n/** Post metadata returned by getAllPosts and getPost. */\nexport interface PostMeta {\n\ttitle: string;\n\tdate: string;\n\texcerpt: string;\n\ttags: string[];\n\tslug: string;\n}\n\n/**\n * MDsveX compiles a Markdown document into a Svelte COMPONENT (a function).\n * A function cannot cross the SvelteKit data boundary, so `PostComponent`\n * wraps the real component together with its slug:\n *\n * - on the SERVER the page renders `post.content.component` directly\n * (``), no `{@html}`, no cast to string;\n * - the transport hook in `src/hooks.ts` (installed with this module)\n * encodes the wrapper as its slug for the client payload and re-imports\n * the compiled component on the browser via the same import.meta.glob.\n */\nexport class PostComponent {\n\treadonly __brand = 'mdx-post';\n\tconstructor(\n\t\treadonly component: Component,\n\t\treadonly slug: string\n\t) {}\n}\n\n/** Full post: metadata + the MDsveX component wrapper. */\nexport interface Post extends PostMeta {\n\tcontent: PostComponent;\n}\n\n// Dynamic import of all .md files in src/posts/ — the single source of truth\n// used by the server (load), the client (transport decode) and the list page.\nconst postFiles = import.meta.glob('/src/posts/*.md');\n\n/** Resolve the compiled MDsveX component of a post by slug. */\nexport async function loadPostComponent(slug: string): Promise {\n\tconst resolver = postFiles[`/src/posts/${slug}.md`];\n\tif (!resolver) return null;\n\tconst mod = (await resolver()) as { default: Component };\n\treturn mod.default ?? null;\n}\n\nexport async function getAllPosts(): Promise {\n\tconst posts = await Promise.all(\n\t\tObject.entries(postFiles).map(async ([path, resolver]) => {\n\t\t\tconst mod = (await resolver()) as { metadata: PostMeta };\n\t\t\tconst slug = path.split('/').pop()!.replace('.md', '');\n\t\t\treturn { ...mod.metadata, slug } as PostMeta;\n\t\t})\n\t);\n\treturn posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());\n}\n\n/** Get a single post by slug, including the MDsveX component wrapper. */\nexport async function getPost(slug: string): Promise {\n\tconst path = `/src/posts/${slug}.md`;\n\tconst resolver = postFiles[path];\n\tif (!resolver) return null;\n\n\tconst mod = (await resolver()) as { metadata: PostMeta; default: Component };\n\treturn {\n\t\t...mod.metadata,\n\t\tslug,\n\t\tcontent: new PostComponent(mod.default, slug)\n\t};\n}\n", "/posts/welcome.md": "---\ntitle: Welcome to your blog\ndate: 2025-01-01\nexcerpt: Your first blog post powered by MDsveX and SvelteForge.\ntags: [welcome, blog]\n---\n\n# Welcome!\n\nThis is your first blog post. Edit it in `src/posts/welcome.md`.\n\n## MDsveX features\n\nYou can use **bold**, *italic*, code blocks, and even Svelte components:\n\n```svelte\n\n\n\n```\n", - "/routes/blog/+page.server.ts": "import { getAllPosts } from '$lib/utils/posts';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async () => {\nreturn { posts: await getAllPosts() };\n};\n", - "/routes/blog/+page.svelte": "\n\n{m.blog_title()}\n\n
\n

{m.blog_title()}

\n\n
\n", - "/routes/blog/[slug]/+page.server.ts": "import { getPost } from '$lib/utils/posts';\nimport { error } from '@sveltejs/kit';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async ({ params }) => {\n\tconst post = await getPost(params.slug);\n\tif (!post) throw error(404, 'Post not found');\n\treturn { post };\n};\n", - "/routes/blog/[slug]/+page.svelte": "\n\n\n{data.post.title}\n\n\n\n\n" + "/routes/blog/+page.server.ts": "import { getAllPosts } from '#lib/utils/posts.js';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async () => {\nreturn { posts: await getAllPosts() };\n};\n", + "/routes/blog/+page.svelte": "\n\n{m.blog_title()}\n\n
\n

{m.blog_title()}

\n\n
\n", + "/routes/blog/[slug]/+page.server.ts": "import { getPost } from '#lib/utils/posts.js';\nimport { error } from '@sveltejs/kit';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async ({ params }) => {\n\tconst post = await getPost(params.slug);\n\tif (!post) throw error(404, 'Post not found');\n\treturn { post };\n};\n", + "/routes/blog/[slug]/+page.svelte": "\n\n\n{data.post.title}\n\n\n\n\n" } }, "chat": { @@ -55,13 +55,13 @@ export const MODULE_RECIPE_DATA: Record = { "version": "2.2.0", "dependencies": [], "files": { - "/lib/server/chat/chat-security.test.ts": "import { describe, it, expect, beforeAll, vi } from 'vitest';\nimport type postgres from 'postgres';\n\n// $app/env/private is a SvelteKit virtual module — not resolvable by the\n// bare vitest environment. Read DATABASE_URL from the project .env (created\n// by scripts/setup.sh before the CI runs `bun run test`).\nvi.mock('$app/env/private', async () => {\n\tconst { readFileSync } = await import('node:fs');\n\tconst dotenv = readFileSync('.env', 'utf8');\n\tconst m = dotenv.match(/^DATABASE_URL=\"?([^\"\\n]+)\"?$/m);\n\treturn { DATABASE_URL: m ? m[1].trim() : undefined };\n});\n\n// #401 — thin recorder: a Proxy over the real drizzle instance that snapshots\n// every SELECT the service builds via toSQL() right before it executes. The\n// tests then replay a captured query raw (db.$client.unsafe) to prove facts\n// about the SQL itself (row volume, DISTINCT ON) that results alone cannot.\nconst recorded = vi.hoisted(() => [] as { sql: string; params: unknown[] }[]);\n\nvi.mock('$lib/server/db', async (importOriginal) => {\n\tconst actual = await importOriginal();\n\t// Wraps a drizzle query builder: chained builders (objects returned by\n\t// .from()/.where()/…) get wrapped too; awaiting (`then`) snapshots the\n\t// fully built query first, then delegates to the real execution.\n\tconst recordBuilder = (builder: object): object =>\n\t\tnew Proxy(builder, {\n\t\t\tget(target, prop) {\n\t\t\t\tif (prop === 'then') {\n\t\t\t\t\tconst query = (target as { toSQL(): { sql: string; params: unknown[] } }).toSQL();\n\t\t\t\t\trecorded.push(query);\n\t\t\t\t\treturn (target as { then: (...args: unknown[]) => unknown }).then.bind(target);\n\t\t\t\t}\n\t\t\t\tconst value = Reflect.get(target, prop, target);\n\t\t\t\tif (typeof value === 'function') {\n\t\t\t\t\treturn (...args: unknown[]) => {\n\t\t\t\t\t\tconst result = (value as (...a: unknown[]) => unknown).apply(target, args);\n\t\t\t\t\t\treturn result !== null && typeof result === 'object'\n\t\t\t\t\t\t\t? recordBuilder(result as object)\n\t\t\t\t\t\t\t: result;\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\treturn value;\n\t\t\t}\n\t\t});\n\treturn {\n\t\t...actual,\n\t\tdb: new Proxy(actual.db, {\n\t\t\tget(target, prop) {\n\t\t\t\tif (prop === 'select') {\n\t\t\t\t\treturn (...args: unknown[]) =>\n\t\t\t\t\t\trecordBuilder((target as { select: (...a: unknown[]) => object }).select(...args));\n\t\t\t\t}\n\t\t\t\treturn Reflect.get(target, prop, target);\n\t\t\t}\n\t\t})\n\t};\n});\n\nimport { chat } from './index';\nimport { db } from '$lib/server/db';\nimport { conversations } from './schema';\n\n/**\n * Membership & per-user read state (#281) — runs inside the\n * dashboard-foundations scaffold against the REAL PostgreSQL database\n * (the CI profile performs a drizzle push before `bun run test`).\n *\n * Users are opaque ids (the chat schema does not FK to the user table):\n * A and B are participants, OUTSIDER is not.\n */\nconst A = crypto.randomUUID();\nconst B = crypto.randomUUID();\nconst OUTSIDER = crypto.randomUUID();\n\ndescribe('chat membership & per-user read state (#281)', () => {\n\tbeforeAll(async () => {\n\t\t// Clean slate — FK cascades wipe participants/messages/reads.\n\t\tawait db.delete(conversations);\n\t});\n\n\tit('unread counts are per-user: reading as A never marks messages read for B', async () => {\n\t\tconst conv = await chat.createConversation({ participantIds: [A, B], type: 'direct' });\n\t\tawait chat.sendMessage({ conversationId: conv.id, authorId: A, content: 'hello from A' });\n\t\tawait chat.sendMessage({ conversationId: conv.id, authorId: B, content: 'hello from B' });\n\n\t\t// Nobody has read anything yet.\n\t\tconst before = await chat.listConversations(A);\n\t\texpect(before[0].unreadCount).toBe(2);\n\n\t\t// A marks the whole conversation as read.\n\t\tawait chat.markRead(conv.id, A);\n\n\t\tconst afterA = await chat.listConversations(A);\n\t\texpect(afterA[0].unreadCount).toBe(0);\n\n\t\t// B's counter is UNAFFECTED by A's reads (#281).\n\t\tconst afterB = await chat.listConversations(B);\n\t\texpect(afterB[0].unreadCount).toBe(2);\n\t});\n\n\tit('an outsider cannot read or modify a conversation through ANY public method', async () => {\n\t\tconst conv = await chat.createConversation({ participantIds: [A, B], type: 'direct' });\n\t\tawait chat.sendMessage({ conversationId: conv.id, authorId: A, content: 'secret' });\n\n\t\t// listMessages: read → refused\n\t\tawait expect(chat.listMessages(conv.id, OUTSIDER, { limit: 10 })).rejects.toThrow(/Forbidden/);\n\n\t\t// sendMessage: write → refused (identity is server-side, no spoof)\n\t\tawait expect(\n\t\t\tchat.sendMessage({ conversationId: conv.id, authorId: OUTSIDER, content: 'intrusion' })\n\t\t).rejects.toThrow(/Forbidden/);\n\n\t\t// markRead: state mutation → refused\n\t\tawait expect(chat.markRead(conv.id, OUTSIDER)).rejects.toThrow(/Forbidden/);\n\n\t\t// listConversations: the outsider does not see the conversation at all\n\t\tconst theirs = await chat.listConversations(OUTSIDER);\n\t\texpect(theirs.find((c) => c.id === conv.id)).toBeUndefined();\n\t});\n\n\tit('the last-message helper is not exposed as an unguarded public method', () => {\n\t\t// The public API surface is exactly the documented methods; helpers\n\t\t// backing listConversations must NOT be reachable without membership.\n\t\tconst api = Object.keys(chat).sort();\n\t\texpect(api).toEqual(['createConversation', 'listConversations', 'listMessages', 'markRead', 'sendMessage']);\n\t\texpect((chat as any).getLastMessage).toBeUndefined();\n\t\texpect((chat as any).unreadCount).toBeUndefined();\n\t});\n\n\tdescribe('#401 — bounded queries & pagination', () => {\n\t\tit('listConversations is bounded-query and paginated (#401)', async () => {\n\t\t\t// Clean slate — cascade wipes participants/messages/reads left by\n\t\t\t// the tests above.\n\t\t\tawait db.delete(conversations);\n\n\t\t\t// Small delays guarantee distinct created_at (ms precision), so\n\t\t\t// \"newest first\" and \"last message = 2nd\" are deterministic.\n\t\t\tconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\t\t\tconst expected: { id: string; last: string; unread: number }[] = [];\n\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\tconst conv = await chat.createConversation({ participantIds: [A, B], type: 'direct' });\n\t\t\t\tawait chat.sendMessage({ conversationId: conv.id, authorId: A, content: `conv${i}-first` });\n\t\t\t\tawait sleep(5);\n\t\t\t\tawait chat.sendMessage({ conversationId: conv.id, authorId: A, content: `conv${i}-second` });\n\t\t\t\tawait sleep(5);\n\t\t\t\texpected.push({ id: conv.id, last: `conv${i}-second`, unread: 2 });\n\t\t\t}\n\n\t\t\t// conv0 becomes a busy conversation: 6 messages total. The\n\t\t\t// last-message read must stay bounded by the CONVERSATION count,\n\t\t\t// never grow with the messages table (#401).\n\t\t\tconst busy = expected[0].id;\n\t\t\tawait chat.sendMessage({ conversationId: busy, authorId: A, content: 'conv0-extra1' });\n\t\t\tawait sleep(5);\n\t\t\tawait chat.sendMessage({ conversationId: busy, authorId: A, content: 'conv0-extra2' });\n\t\t\tawait sleep(5);\n\t\t\tawait chat.sendMessage({ conversationId: busy, authorId: A, content: 'conv0-extra3' });\n\t\t\tawait sleep(5);\n\t\t\tawait chat.sendMessage({ conversationId: busy, authorId: A, content: 'conv0-last' });\n\t\t\texpected[0].last = 'conv0-last';\n\t\t\texpected[0].unread = 6;\n\n\t\t\t// Capture the queries listConversations builds.\n\t\t\trecorded.length = 0;\n\t\t\tconst list = await chat.listConversations(A);\n\n\t\t\t// Exactly the 3 conversations.\n\t\t\texpect(list).toHaveLength(3);\n\n\t\t\t// Ordered newest conversation first.\n\t\t\texpect(list.map((c) => c.id)).toEqual([...expected].reverse().map((c) => c.id));\n\n\t\t\t// Last message per conversation = the newest message (conv0 has\n\t\t\t// 6, the others 2); unread = message count (no read entry BY A\n\t\t\t// yet — authorship is not a read).\n\t\t\tfor (const conv of list) {\n\t\t\t\tconst exp = expected.find((e) => e.id === conv.id);\n\t\t\t\texpect(conv.lastMessage?.content).toBe(exp!.last);\n\t\t\t\texpect(conv.unreadCount).toBe(exp!.unread);\n\t\t\t}\n\n\t\t\t// The last-message read is a DISTINCT ON query (one id per\n\t\t\t// conversation_id), so replaying it raw returns exactly 3 rows —\n\t\t\t// the conversation count — even though conv0 holds 6 messages and\n\t\t\t// the table holds 10. The old implementation returned EVERY\n\t\t\t// message and deduplicated in JS: unbounded rows (#401).\n\t\t\tconst lastQuery = recorded.find((q) => /distinct on/i.test(q.sql));\n\t\t\texpect(lastQuery).toBeDefined();\n\t\t\t// postgres.js `unsafe` expects ParameterOrJSON[] — the recorder stores\n\t\t// drizzle's toSQL() params as unknown[] (they are values like uuids and\n\t\t// counts here), so cast them at the replay call site.\n\t\tconst rawRows = await db.$client.unsafe(\n\t\t\tlastQuery!.sql,\n\t\t\tlastQuery!.params as postgres.ParameterOrJSON[]\n\t\t);\n\t\t\texpect(rawRows.length).toBe(3);\n\t\t});\n\t});\n});\n", - "/lib/server/chat/index.ts": "import { db } from '$lib/server/db';\nimport {\n\tconversations,\n\tconversationParticipants,\n\tmessages,\n\tmessageReads\n} from './schema';\nimport { desc, eq, and, inArray, sql } from 'drizzle-orm';\n\n/**\n * SvelteForge chat (#233) — composable app chat.\n *\n * Membership is the security rule (#281): every PUBLIC read/write method\n * verifies server-side that the caller is a participant. listConversations\n * is membership-isolated by construction (#401): every query is scoped to\n * conversations the caller participates in (IN over the user's own ids), so\n * an outsider cannot read anything through the service.\n *\n * #401: listConversations runs a BOUNDED number of queries (3 max) instead\n * of per-conversation N+1 lookups.\n *\n * Works without realtime (classic refetch). If @svforge/realtime is installed,\n * publish `message.created` on `conversation:{id}` after persist (optional).\n */\n\n// ── private helpers (NOT part of the public API) ───────────────────────────\n\n/** Server-side membership check — throws if the user cannot access. */\nasync function assertMember(conversationId: string, userId: string): Promise {\n\tconst rows = await db\n\t\t.select({ conversationId: conversationParticipants.conversationId })\n\t\t.from(conversationParticipants)\n\t\t.where(\n\t\t\tand(\n\t\t\t\teq(conversationParticipants.conversationId, conversationId),\n\t\t\t\teq(conversationParticipants.userId, userId)\n\t\t\t)\n\t\t);\n\tif (rows.length === 0) {\n\t\tthrow new Error('Forbidden: you are not a participant of this conversation');\n\t}\n}\n\n// ── public API ──────────────────────────────────────────────────────────────\n\nexport const chat = {\n\t/** Create a conversation with participants (creator included). */\n\tasync createConversation(input: { participantIds: string[]; type?: 'direct' | 'group' }) {\n\t\tconst ids = [...new Set([...input.participantIds])];\n\t\tif (ids.length < 2) throw new Error('A conversation needs at least 2 participants');\n\t\treturn db.transaction(async (tx) => {\n\t\t\tconst [conv] = await tx\n\t\t\t\t.insert(conversations)\n\t\t\t\t.values({ type: input.type ?? 'direct', createdAt: new Date() })\n\t\t\t\t.returning();\n\t\t\tfor (const userId of ids) {\n\t\t\t\tawait tx.insert(conversationParticipants).values({\n\t\t\t\t\tconversationId: conv.id,\n\t\t\t\t\tuserId,\n\t\t\t\t\tjoinedAt: new Date()\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn conv;\n\t\t});\n\t},\n\n\t/** Conversations of a user, with last message + per-user unread count. */\n\tasync listConversations(userId: string) {\n\t\t// #401 — bounded number of queries (3 max), membership-isolated by\n\t\t// construction: every query is scoped to conversations the user\n\t\t// participates in, so per-row assertMember calls are unnecessary.\n\n\t\t// 1) The user's conversations (newest first, conservative page size).\n\t\tconst rows = await db\n\t\t\t.select({\n\t\t\t\tid: conversations.id,\n\t\t\t\ttype: conversations.type,\n\t\t\t\tcreatedAt: conversations.createdAt\n\t\t\t})\n\t\t\t.from(conversationParticipants)\n\t\t\t.innerJoin(conversations, eq(conversations.id, conversationParticipants.conversationId))\n\t\t\t.where(eq(conversationParticipants.userId, userId))\n\t\t\t.orderBy(desc(conversations.createdAt))\n\t\t\t.limit(50);\n\t\tif (rows.length === 0) return [];\n\t\tconst convIds = rows.map((r) => r.id);\n\n\t\t// 2) Last message per conversation — ONE query whose row volume is\n\t\t// bounded by the conversation count (≤ page size), never by the size of\n\t\t// the messages table: a DISTINCT ON subquery selects exactly one id per\n\t\t// conversation_id (the newest by created_at) and the outer IN reads only\n\t\t// those rows. No full-message dump, no filtering kept in JS.\n\t\tconst lastRows = await db\n\t\t\t.select()\n\t\t\t.from(messages)\n\t\t\t.where(\n\t\t\t\tsql`${messages.id} in (\n\t\t\t\t\tselect distinct on (${messages.conversationId}) ${messages.id}\n\t\t\t\t\tfrom ${messages}\n\t\t\t\t\twhere ${inArray(messages.conversationId, convIds)}\n\t\t\t\t\torder by ${messages.conversationId}, ${messages.createdAt} desc\n\t\t\t\t)`\n\t\t\t);\n\t\t// The subquery guarantees at most one row per conversation.\n\t\tconst lastByConv = new Map(lastRows.map((row) => [row.conversationId, row]));\n\n\t\t// 3) Unread counts in ONE query. Per-user read state (#281) is kept\n\t\t// exact: the left join matches only THIS user's read entries, so a\n\t\t// message with no read entry BY THAT USER counts as unread via\n\t\t// COUNT(*) FILTER. Conversations with no messages produce no group\n\t\t// and default to 0 below.\n\t\tconst unreadRows = await db\n\t\t\t.select({\n\t\t\t\tconversationId: messages.conversationId,\n\t\t\t\tunread: sql`count(*) filter (where ${messageReads.messageId} is null)`\n\t\t\t})\n\t\t\t.from(messages)\n\t\t\t.leftJoin(\n\t\t\t\tmessageReads,\n\t\t\t\tand(eq(messageReads.messageId, messages.id), eq(messageReads.userId, userId))\n\t\t\t)\n\t\t\t.where(inArray(messages.conversationId, convIds))\n\t\t\t.groupBy(messages.conversationId);\n\t\t// PostgreSQL returns count(...) as a string — coerce to number.\n\t\tconst unreadByConv = new Map(unreadRows.map((r) => [r.conversationId, Number(r.unread)]));\n\n\t\treturn rows.map((row) => ({\n\t\t\t...row,\n\t\t\tlastMessage: lastByConv.get(row.id) ?? null,\n\t\t\tunreadCount: unreadByConv.get(row.id) ?? 0\n\t\t}));\n\t},\n\n\t/** Paginated messages of a conversation (membership-checked). */\n\tasync listMessages(\n\t\tconversationId: string,\n\t\tuserId: string,\n\t\topts: { limit?: number; offset?: number } = {}\n\t) {\n\t\tawait assertMember(conversationId, userId);\n\t\treturn db\n\t\t\t.select()\n\t\t\t.from(messages)\n\t\t\t.where(eq(messages.conversationId, conversationId))\n\t\t\t.orderBy(desc(messages.createdAt))\n\t\t\t.limit(opts.limit ?? 50)\n\t\t\t.offset(opts.offset ?? 0);\n\t},\n\n\t/** Send a message as the current author (server-side identity, no spoof). */\n\tasync sendMessage(input: { conversationId: string; authorId: string; content: string }) {\n\t\tif (!input.content?.trim()) throw new Error('Message content is required');\n\t\tawait assertMember(input.conversationId, input.authorId);\n\t\tconst [row] = await db\n\t\t\t.insert(messages)\n\t\t\t.values({\n\t\t\t\tconversationId: input.conversationId,\n\t\t\t\tauthorId: input.authorId,\n\t\t\t\tcontent: input.content.trim(),\n\t\t\t\tcreatedAt: new Date()\n\t\t\t})\n\t\t\t.returning();\n\t\treturn row;\n\t},\n\n\t/** Mark messages of a conversation as read for a user. */\n\tasync markRead(conversationId: string, userId: string) {\n\t\tawait assertMember(conversationId, userId);\n\t\tconst msgs = await db\n\t\t\t.select({ id: messages.id })\n\t\t\t.from(messages)\n\t\t\t.where(eq(messages.conversationId, conversationId));\n\t\tfor (const m of msgs) {\n\t\t\tawait db\n\t\t\t\t.insert(messageReads)\n\t\t\t\t.values({ messageId: m.id, userId, readAt: new Date() })\n\t\t\t\t.onConflictDoNothing();\n\t\t}\n\t}\n};\n", + "/lib/server/chat/chat-security.test.ts": "import { describe, it, expect, beforeAll, vi } from 'vitest';\nimport type postgres from 'postgres';\n\n// $app/env/private is a SvelteKit virtual module — not resolvable by the\n// bare vitest environment. Read DATABASE_URL from the project .env (created\n// by scripts/setup.sh before the CI runs `bun run test`).\nvi.mock('$app/env/private', async () => {\n\tconst { readFileSync } = await import('node:fs');\n\tconst dotenv = readFileSync('.env', 'utf8');\n\tconst m = dotenv.match(/^DATABASE_URL=\"?([^\"\\n]+)\"?$/m);\n\treturn { DATABASE_URL: m ? m[1].trim() : undefined };\n});\n\n// #401 — thin recorder: a Proxy over the real drizzle instance that snapshots\n// every SELECT the service builds via toSQL() right before it executes. The\n// tests then replay a captured query raw (db.$client.unsafe) to prove facts\n// about the SQL itself (row volume, DISTINCT ON) that results alone cannot.\nconst recorded = vi.hoisted(() => [] as { sql: string; params: unknown[] }[]);\n\nvi.mock('#lib/server/db/index.js', async (importOriginal) => {\n\tconst actual = await importOriginal();\n\t// Wraps a drizzle query builder: chained builders (objects returned by\n\t// .from()/.where()/…) get wrapped too; awaiting (`then`) snapshots the\n\t// fully built query first, then delegates to the real execution.\n\tconst recordBuilder = (builder: object): object =>\n\t\tnew Proxy(builder, {\n\t\t\tget(target, prop) {\n\t\t\t\tif (prop === 'then') {\n\t\t\t\t\tconst query = (target as { toSQL(): { sql: string; params: unknown[] } }).toSQL();\n\t\t\t\t\trecorded.push(query);\n\t\t\t\t\treturn (target as { then: (...args: unknown[]) => unknown }).then.bind(target);\n\t\t\t\t}\n\t\t\t\tconst value = Reflect.get(target, prop, target);\n\t\t\t\tif (typeof value === 'function') {\n\t\t\t\t\treturn (...args: unknown[]) => {\n\t\t\t\t\t\tconst result = (value as (...a: unknown[]) => unknown).apply(target, args);\n\t\t\t\t\t\treturn result !== null && typeof result === 'object'\n\t\t\t\t\t\t\t? recordBuilder(result as object)\n\t\t\t\t\t\t\t: result;\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\treturn value;\n\t\t\t}\n\t\t});\n\treturn {\n\t\t...actual,\n\t\tdb: new Proxy(actual.db, {\n\t\t\tget(target, prop) {\n\t\t\t\tif (prop === 'select') {\n\t\t\t\t\treturn (...args: unknown[]) =>\n\t\t\t\t\t\trecordBuilder((target as { select: (...a: unknown[]) => object }).select(...args));\n\t\t\t\t}\n\t\t\t\treturn Reflect.get(target, prop, target);\n\t\t\t}\n\t\t})\n\t};\n});\n\nimport { chat } from './index';\nimport { db } from '#lib/server/db/index.js';\nimport { conversations } from './schema';\n\n/**\n * Membership & per-user read state (#281) — runs inside the\n * dashboard-foundations scaffold against the REAL PostgreSQL database\n * (the CI profile performs a drizzle push before `bun run test`).\n *\n * Users are opaque ids (the chat schema does not FK to the user table):\n * A and B are participants, OUTSIDER is not.\n */\nconst A = crypto.randomUUID();\nconst B = crypto.randomUUID();\nconst OUTSIDER = crypto.randomUUID();\n\ndescribe('chat membership & per-user read state (#281)', () => {\n\tbeforeAll(async () => {\n\t\t// Clean slate — FK cascades wipe participants/messages/reads.\n\t\tawait db.delete(conversations);\n\t});\n\n\tit('unread counts are per-user: reading as A never marks messages read for B', async () => {\n\t\tconst conv = await chat.createConversation({ participantIds: [A, B], type: 'direct' });\n\t\tawait chat.sendMessage({ conversationId: conv.id, authorId: A, content: 'hello from A' });\n\t\tawait chat.sendMessage({ conversationId: conv.id, authorId: B, content: 'hello from B' });\n\n\t\t// Nobody has read anything yet.\n\t\tconst before = await chat.listConversations(A);\n\t\texpect(before[0].unreadCount).toBe(2);\n\n\t\t// A marks the whole conversation as read.\n\t\tawait chat.markRead(conv.id, A);\n\n\t\tconst afterA = await chat.listConversations(A);\n\t\texpect(afterA[0].unreadCount).toBe(0);\n\n\t\t// B's counter is UNAFFECTED by A's reads (#281).\n\t\tconst afterB = await chat.listConversations(B);\n\t\texpect(afterB[0].unreadCount).toBe(2);\n\t});\n\n\tit('an outsider cannot read or modify a conversation through ANY public method', async () => {\n\t\tconst conv = await chat.createConversation({ participantIds: [A, B], type: 'direct' });\n\t\tawait chat.sendMessage({ conversationId: conv.id, authorId: A, content: 'secret' });\n\n\t\t// listMessages: read → refused\n\t\tawait expect(chat.listMessages(conv.id, OUTSIDER, { limit: 10 })).rejects.toThrow(/Forbidden/);\n\n\t\t// sendMessage: write → refused (identity is server-side, no spoof)\n\t\tawait expect(\n\t\t\tchat.sendMessage({ conversationId: conv.id, authorId: OUTSIDER, content: 'intrusion' })\n\t\t).rejects.toThrow(/Forbidden/);\n\n\t\t// markRead: state mutation → refused\n\t\tawait expect(chat.markRead(conv.id, OUTSIDER)).rejects.toThrow(/Forbidden/);\n\n\t\t// listConversations: the outsider does not see the conversation at all\n\t\tconst theirs = await chat.listConversations(OUTSIDER);\n\t\texpect(theirs.find((c) => c.id === conv.id)).toBeUndefined();\n\t});\n\n\tit('the last-message helper is not exposed as an unguarded public method', () => {\n\t\t// The public API surface is exactly the documented methods; helpers\n\t\t// backing listConversations must NOT be reachable without membership.\n\t\tconst api = Object.keys(chat).sort();\n\t\texpect(api).toEqual(['createConversation', 'listConversations', 'listMessages', 'markRead', 'sendMessage']);\n\t\texpect((chat as any).getLastMessage).toBeUndefined();\n\t\texpect((chat as any).unreadCount).toBeUndefined();\n\t});\n\n\tdescribe('#401 — bounded queries & pagination', () => {\n\t\tit('listConversations is bounded-query and paginated (#401)', async () => {\n\t\t\t// Clean slate — cascade wipes participants/messages/reads left by\n\t\t\t// the tests above.\n\t\t\tawait db.delete(conversations);\n\n\t\t\t// Small delays guarantee distinct created_at (ms precision), so\n\t\t\t// \"newest first\" and \"last message = 2nd\" are deterministic.\n\t\t\tconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\t\t\tconst expected: { id: string; last: string; unread: number }[] = [];\n\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\tconst conv = await chat.createConversation({ participantIds: [A, B], type: 'direct' });\n\t\t\t\tawait chat.sendMessage({ conversationId: conv.id, authorId: A, content: `conv${i}-first` });\n\t\t\t\tawait sleep(5);\n\t\t\t\tawait chat.sendMessage({ conversationId: conv.id, authorId: A, content: `conv${i}-second` });\n\t\t\t\tawait sleep(5);\n\t\t\t\texpected.push({ id: conv.id, last: `conv${i}-second`, unread: 2 });\n\t\t\t}\n\n\t\t\t// conv0 becomes a busy conversation: 6 messages total. The\n\t\t\t// last-message read must stay bounded by the CONVERSATION count,\n\t\t\t// never grow with the messages table (#401).\n\t\t\tconst busy = expected[0].id;\n\t\t\tawait chat.sendMessage({ conversationId: busy, authorId: A, content: 'conv0-extra1' });\n\t\t\tawait sleep(5);\n\t\t\tawait chat.sendMessage({ conversationId: busy, authorId: A, content: 'conv0-extra2' });\n\t\t\tawait sleep(5);\n\t\t\tawait chat.sendMessage({ conversationId: busy, authorId: A, content: 'conv0-extra3' });\n\t\t\tawait sleep(5);\n\t\t\tawait chat.sendMessage({ conversationId: busy, authorId: A, content: 'conv0-last' });\n\t\t\texpected[0].last = 'conv0-last';\n\t\t\texpected[0].unread = 6;\n\n\t\t\t// Capture the queries listConversations builds.\n\t\t\trecorded.length = 0;\n\t\t\tconst list = await chat.listConversations(A);\n\n\t\t\t// Exactly the 3 conversations.\n\t\t\texpect(list).toHaveLength(3);\n\n\t\t\t// Ordered newest conversation first.\n\t\t\texpect(list.map((c) => c.id)).toEqual([...expected].reverse().map((c) => c.id));\n\n\t\t\t// Last message per conversation = the newest message (conv0 has\n\t\t\t// 6, the others 2); unread = message count (no read entry BY A\n\t\t\t// yet — authorship is not a read).\n\t\t\tfor (const conv of list) {\n\t\t\t\tconst exp = expected.find((e) => e.id === conv.id);\n\t\t\t\texpect(conv.lastMessage?.content).toBe(exp!.last);\n\t\t\t\texpect(conv.unreadCount).toBe(exp!.unread);\n\t\t\t}\n\n\t\t\t// The last-message read is a DISTINCT ON query (one id per\n\t\t\t// conversation_id), so replaying it raw returns exactly 3 rows —\n\t\t\t// the conversation count — even though conv0 holds 6 messages and\n\t\t\t// the table holds 10. The old implementation returned EVERY\n\t\t\t// message and deduplicated in JS: unbounded rows (#401).\n\t\t\tconst lastQuery = recorded.find((q) => /distinct on/i.test(q.sql));\n\t\t\texpect(lastQuery).toBeDefined();\n\t\t\t// postgres.js `unsafe` expects ParameterOrJSON[] — the recorder stores\n\t\t// drizzle's toSQL() params as unknown[] (they are values like uuids and\n\t\t// counts here), so cast them at the replay call site.\n\t\tconst rawRows = await db.$client.unsafe(\n\t\t\tlastQuery!.sql,\n\t\t\tlastQuery!.params as postgres.ParameterOrJSON[]\n\t\t);\n\t\t\texpect(rawRows.length).toBe(3);\n\t\t});\n\t});\n});\n", + "/lib/server/chat/index.ts": "import { db } from '#lib/server/db/index.js';\nimport {\n\tconversations,\n\tconversationParticipants,\n\tmessages,\n\tmessageReads\n} from './schema';\nimport { desc, eq, and, inArray, sql } from 'drizzle-orm';\n\n/**\n * SvelteForge chat (#233) — composable app chat.\n *\n * Membership is the security rule (#281): every PUBLIC read/write method\n * verifies server-side that the caller is a participant. listConversations\n * is membership-isolated by construction (#401): every query is scoped to\n * conversations the caller participates in (IN over the user's own ids), so\n * an outsider cannot read anything through the service.\n *\n * #401: listConversations runs a BOUNDED number of queries (3 max) instead\n * of per-conversation N+1 lookups.\n *\n * Works without realtime (classic refetch). If @svforge/realtime is installed,\n * publish `message.created` on `conversation:{id}` after persist (optional).\n */\n\n// ── private helpers (NOT part of the public API) ───────────────────────────\n\n/** Server-side membership check — throws if the user cannot access. */\nasync function assertMember(conversationId: string, userId: string): Promise {\n\tconst rows = await db\n\t\t.select({ conversationId: conversationParticipants.conversationId })\n\t\t.from(conversationParticipants)\n\t\t.where(\n\t\t\tand(\n\t\t\t\teq(conversationParticipants.conversationId, conversationId),\n\t\t\t\teq(conversationParticipants.userId, userId)\n\t\t\t)\n\t\t);\n\tif (rows.length === 0) {\n\t\tthrow new Error('Forbidden: you are not a participant of this conversation');\n\t}\n}\n\n// ── public API ──────────────────────────────────────────────────────────────\n\nexport const chat = {\n\t/** Create a conversation with participants (creator included). */\n\tasync createConversation(input: { participantIds: string[]; type?: 'direct' | 'group' }) {\n\t\tconst ids = [...new Set([...input.participantIds])];\n\t\tif (ids.length < 2) throw new Error('A conversation needs at least 2 participants');\n\t\treturn db.transaction(async (tx) => {\n\t\t\tconst [conv] = await tx\n\t\t\t\t.insert(conversations)\n\t\t\t\t.values({ type: input.type ?? 'direct', createdAt: new Date() })\n\t\t\t\t.returning();\n\t\t\tfor (const userId of ids) {\n\t\t\t\tawait tx.insert(conversationParticipants).values({\n\t\t\t\t\tconversationId: conv.id,\n\t\t\t\t\tuserId,\n\t\t\t\t\tjoinedAt: new Date()\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn conv;\n\t\t});\n\t},\n\n\t/** Conversations of a user, with last message + per-user unread count. */\n\tasync listConversations(userId: string) {\n\t\t// #401 — bounded number of queries (3 max), membership-isolated by\n\t\t// construction: every query is scoped to conversations the user\n\t\t// participates in, so per-row assertMember calls are unnecessary.\n\n\t\t// 1) The user's conversations (newest first, conservative page size).\n\t\tconst rows = await db\n\t\t\t.select({\n\t\t\t\tid: conversations.id,\n\t\t\t\ttype: conversations.type,\n\t\t\t\tcreatedAt: conversations.createdAt\n\t\t\t})\n\t\t\t.from(conversationParticipants)\n\t\t\t.innerJoin(conversations, eq(conversations.id, conversationParticipants.conversationId))\n\t\t\t.where(eq(conversationParticipants.userId, userId))\n\t\t\t.orderBy(desc(conversations.createdAt))\n\t\t\t.limit(50);\n\t\tif (rows.length === 0) return [];\n\t\tconst convIds = rows.map((r) => r.id);\n\n\t\t// 2) Last message per conversation — ONE query whose row volume is\n\t\t// bounded by the conversation count (≤ page size), never by the size of\n\t\t// the messages table: a DISTINCT ON subquery selects exactly one id per\n\t\t// conversation_id (the newest by created_at) and the outer IN reads only\n\t\t// those rows. No full-message dump, no filtering kept in JS.\n\t\tconst lastRows = await db\n\t\t\t.select()\n\t\t\t.from(messages)\n\t\t\t.where(\n\t\t\t\tsql`${messages.id} in (\n\t\t\t\t\tselect distinct on (${messages.conversationId}) ${messages.id}\n\t\t\t\t\tfrom ${messages}\n\t\t\t\t\twhere ${inArray(messages.conversationId, convIds)}\n\t\t\t\t\torder by ${messages.conversationId}, ${messages.createdAt} desc\n\t\t\t\t)`\n\t\t\t);\n\t\t// The subquery guarantees at most one row per conversation.\n\t\tconst lastByConv = new Map(lastRows.map((row) => [row.conversationId, row]));\n\n\t\t// 3) Unread counts in ONE query. Per-user read state (#281) is kept\n\t\t// exact: the left join matches only THIS user's read entries, so a\n\t\t// message with no read entry BY THAT USER counts as unread via\n\t\t// COUNT(*) FILTER. Conversations with no messages produce no group\n\t\t// and default to 0 below.\n\t\tconst unreadRows = await db\n\t\t\t.select({\n\t\t\t\tconversationId: messages.conversationId,\n\t\t\t\tunread: sql`count(*) filter (where ${messageReads.messageId} is null)`\n\t\t\t})\n\t\t\t.from(messages)\n\t\t\t.leftJoin(\n\t\t\t\tmessageReads,\n\t\t\t\tand(eq(messageReads.messageId, messages.id), eq(messageReads.userId, userId))\n\t\t\t)\n\t\t\t.where(inArray(messages.conversationId, convIds))\n\t\t\t.groupBy(messages.conversationId);\n\t\t// PostgreSQL returns count(...) as a string — coerce to number.\n\t\tconst unreadByConv = new Map(unreadRows.map((r) => [r.conversationId, Number(r.unread)]));\n\n\t\treturn rows.map((row) => ({\n\t\t\t...row,\n\t\t\tlastMessage: lastByConv.get(row.id) ?? null,\n\t\t\tunreadCount: unreadByConv.get(row.id) ?? 0\n\t\t}));\n\t},\n\n\t/** Paginated messages of a conversation (membership-checked). */\n\tasync listMessages(\n\t\tconversationId: string,\n\t\tuserId: string,\n\t\topts: { limit?: number; offset?: number } = {}\n\t) {\n\t\tawait assertMember(conversationId, userId);\n\t\treturn db\n\t\t\t.select()\n\t\t\t.from(messages)\n\t\t\t.where(eq(messages.conversationId, conversationId))\n\t\t\t.orderBy(desc(messages.createdAt))\n\t\t\t.limit(opts.limit ?? 50)\n\t\t\t.offset(opts.offset ?? 0);\n\t},\n\n\t/** Send a message as the current author (server-side identity, no spoof). */\n\tasync sendMessage(input: { conversationId: string; authorId: string; content: string }) {\n\t\tif (!input.content?.trim()) throw new Error('Message content is required');\n\t\tawait assertMember(input.conversationId, input.authorId);\n\t\tconst [row] = await db\n\t\t\t.insert(messages)\n\t\t\t.values({\n\t\t\t\tconversationId: input.conversationId,\n\t\t\t\tauthorId: input.authorId,\n\t\t\t\tcontent: input.content.trim(),\n\t\t\t\tcreatedAt: new Date()\n\t\t\t})\n\t\t\t.returning();\n\t\treturn row;\n\t},\n\n\t/** Mark messages of a conversation as read for a user. */\n\tasync markRead(conversationId: string, userId: string) {\n\t\tawait assertMember(conversationId, userId);\n\t\tconst msgs = await db\n\t\t\t.select({ id: messages.id })\n\t\t\t.from(messages)\n\t\t\t.where(eq(messages.conversationId, conversationId));\n\t\tfor (const m of msgs) {\n\t\t\tawait db\n\t\t\t\t.insert(messageReads)\n\t\t\t\t.values({ messageId: m.id, userId, readAt: new Date() })\n\t\t\t\t.onConflictDoNothing();\n\t\t}\n\t}\n};\n", "/lib/server/chat/schema.ts": "import { pgTable, uuid, text, timestamp, primaryKey } from 'drizzle-orm/pg-core';\n\n/**\n * Chat schema (#233) — conversations, participants, messages, read-state.\n * Security: memberships are the source of truth; the service layer enforces\n * them server-side on every read/write.\n *\n * Composite primary keys on membership/read tables guarantee uniqueness\n * and are required by PostgreSQL on join tables.\n */\nexport const conversations = pgTable('conversations', {\n\tid: uuid('id').primaryKey().defaultRandom(),\n\ttype: text('type', { enum: ['direct', 'group'] }).notNull().default('direct'),\n\tcreatedAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()\n});\n\nexport const conversationParticipants = pgTable(\n\t'conversation_participants',\n\t{\n\t\tconversationId: uuid('conversation_id')\n\t\t\t.notNull()\n\t\t\t.references(() => conversations.id, { onDelete: 'cascade' }),\n\t\tuserId: text('user_id').notNull(),\n\t\tjoinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow()\n\t},\n\t(table) => [primaryKey({ columns: [table.conversationId, table.userId] })]\n);\n\nexport const messages = pgTable('messages', {\n\tid: uuid('id').primaryKey().defaultRandom(),\n\tconversationId: uuid('conversation_id')\n\t\t.notNull()\n\t\t.references(() => conversations.id, { onDelete: 'cascade' }),\n\tauthorId: text('author_id').notNull(),\n\tcontent: text('content').notNull(),\n\tcreatedAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()\n});\n\nexport const messageReads = pgTable(\n\t'message_reads',\n\t{\n\t\tmessageId: uuid('message_id')\n\t\t\t.notNull()\n\t\t\t.references(() => messages.id, { onDelete: 'cascade' }),\n\t\tuserId: text('user_id').notNull(),\n\t\treadAt: timestamp('read_at', { withTimezone: true }).notNull().defaultNow()\n\t},\n\t(table) => [primaryKey({ columns: [table.messageId, table.userId] })]\n);\n", - "/routes/chat/+page.server.ts": "import { redirect, type RequestEvent } from '@sveltejs/kit';\nimport { chat } from '$lib/server/chat';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async ({ locals }) => {\n\tif (!locals.user) throw redirect(302, '/login');\n\tconst conversations = await chat.listConversations(locals.user.id);\n\treturn { conversations };\n};\n", - "/routes/chat/+page.svelte": "\n\n{m.chat_title()}\n\n\n", - "/routes/chat/[id]/+page.server.ts": "import { error, redirect, fail } from '@sveltejs/kit';\nimport { chat } from '$lib/server/chat';\nimport type { PageServerLoad, Actions } from './$types';\n\nexport const load: PageServerLoad = async ({ locals, params }) => {\n\tif (!locals.user) throw redirect(302, '/login');\n\tconst currentUser = locals.user; // capture — TS narrows are lost after awaits\n\tconst conversationId = params.id; // uuid (#255)\n\n\ttry {\n\t\tconst messages = await chat.listMessages(conversationId, currentUser.id, { limit: 50 });\n\t\tawait chat.markRead(conversationId, currentUser.id);\n\t\treturn { conversationId, messages };\n\t} catch (e) {\n\t\tthrow error(403, { message: e instanceof Error ? e.message : 'Forbidden' });\n\t}\n};\n\nexport const actions: Actions = {\n\tsend: async ({ locals, params, request }) => {\n\t\tif (!locals.user) throw redirect(302, '/login');\n\t\tconst currentUser = locals.user; // capture — TS narrows are lost after awaits\n\t\tconst conversationId = params.id; // uuid (#255)\n\t\tconst form = await request.formData();\n\t\tconst content = String(form.get('content') ?? '').trim();\n\t\tif (!content) return fail(400, { error: 'Message is required' });\n\n\t\ttry {\n\t\t\tconst message = await chat.sendMessage({\n\t\t\t\tconversationId,\n\t\t\t\tauthorId: currentUser.id, // server-side identity — no client spoofing\n\t\t\t\tcontent\n\t\t\t});\n\t\t\treturn { message };\n\t\t} catch (e) {\n\t\t\treturn fail(403, { error: e instanceof Error ? e.message : 'Forbidden' });\n\t\t}\n\t}\n};\n", - "/routes/chat/[id]/+page.svelte": "\n\n{m.chat_conversation()} #{conversationId}\n\n
\n\t

{m.chat_conversation()} #{conversationId}

\n\n\t{#if messages.length === 0}\n\t\t{m.chat_empty()}\n\t{:else}\n\t\t\n\t\t
    \n\t\t\t{#each [...messages].reverse() as msg (msg.id)}\n\t\t\t\t
  • \n\t\t\t\t\t{msg.authorId} · {new Date(msg.createdAt).toLocaleString()}\n\t\t\t\t\t{msg.content}\n\t\t\t\t
  • \n\t\t\t{/each}\n\t\t
\n\t\t
\n\t{/if}\n\n\t{#if form?.error}\n\t\t

{form.error}

\n\t{/if}\n\n\t
\n\t\t\n\t\t\n\t\n
\n" + "/routes/chat/+page.server.ts": "import { redirect, type RequestEvent } from '@sveltejs/kit';\nimport { chat } from '#lib/server/chat/index.js';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async ({ locals }) => {\n\tif (!locals.user) throw redirect(302, '/login');\n\tconst conversations = await chat.listConversations(locals.user.id);\n\treturn { conversations };\n};\n", + "/routes/chat/+page.svelte": "\n\n{m.chat_title()}\n\n\n", + "/routes/chat/[id]/+page.server.ts": "import { error, redirect, fail } from '@sveltejs/kit';\nimport { chat } from '#lib/server/chat/index.js';\nimport type { PageServerLoad, Actions } from './$types';\n\nexport const load: PageServerLoad = async ({ locals, params }) => {\n\tif (!locals.user) throw redirect(302, '/login');\n\tconst currentUser = locals.user; // capture — TS narrows are lost after awaits\n\tconst conversationId = params.id; // uuid (#255)\n\n\ttry {\n\t\tconst messages = await chat.listMessages(conversationId, currentUser.id, { limit: 50 });\n\t\tawait chat.markRead(conversationId, currentUser.id);\n\t\treturn { conversationId, messages };\n\t} catch (e) {\n\t\tthrow error(403, { message: e instanceof Error ? e.message : 'Forbidden' });\n\t}\n};\n\nexport const actions: Actions = {\n\tsend: async ({ locals, params, request }) => {\n\t\tif (!locals.user) throw redirect(302, '/login');\n\t\tconst currentUser = locals.user; // capture — TS narrows are lost after awaits\n\t\tconst conversationId = params.id; // uuid (#255)\n\t\tconst form = await request.formData();\n\t\tconst content = String(form.get('content') ?? '').trim();\n\t\tif (!content) return fail(400, { error: 'Message is required' });\n\n\t\ttry {\n\t\t\tconst message = await chat.sendMessage({\n\t\t\t\tconversationId,\n\t\t\t\tauthorId: currentUser.id, // server-side identity — no client spoofing\n\t\t\t\tcontent\n\t\t\t});\n\t\t\treturn { message };\n\t\t} catch (e) {\n\t\t\treturn fail(403, { error: e instanceof Error ? e.message : 'Forbidden' });\n\t\t}\n\t}\n};\n", + "/routes/chat/[id]/+page.svelte": "\n\n{m.chat_conversation()} #{conversationId}\n\n
\n\t

{m.chat_conversation()} #{conversationId}

\n\n\t{#if messages.length === 0}\n\t\t{m.chat_empty()}\n\t{:else}\n\t\t\n\t\t
    \n\t\t\t{#each [...messages].reverse() as msg (msg.id)}\n\t\t\t\t
  • \n\t\t\t\t\t{msg.authorId} · {new Date(msg.createdAt).toLocaleString()}\n\t\t\t\t\t{msg.content}\n\t\t\t\t
  • \n\t\t\t{/each}\n\t\t
\n\t\t
\n\t{/if}\n\n\t{#if form?.error}\n\t\t

{form.error}

\n\t{/if}\n\n\t
\n\t\t\n\t\t\n\t\n
\n" } }, "dnd": { @@ -76,7 +76,7 @@ export const MODULE_RECIPE_DATA: Record = { } ], "files": { - "/lib/components/svforge/dnd/SortableList.svelte": "\n\n
    \n\t{#each items as item, index (item.id)}\n\t\t\n\t\t\t⠿\n\t\t\t
    \n\t\t\t\t{@render children(item)}\n\t\t\t
    \n\t\t\n\t{/each}\n
\n" + "/lib/components/svforge/dnd/SortableList.svelte": "\n\n
    \n\t{#each items as item, index (item.id)}\n\t\t\n\t\t\t⠿\n\t\t\t
    \n\t\t\t\t{@render children(item)}\n\t\t\t
    \n\t\t\n\t{/each}\n
\n" } }, "email": { @@ -120,8 +120,8 @@ export const MODULE_RECIPE_DATA: Record = { "version": "2.2.0", "dependencies": [], "files": { - "/lib/server/jobs/index.ts": "import { db } from '$lib/server/db';\nimport { jobs } from './schema';\nimport { eq, and, or, lt, lte, isNull, inArray, sql, asc } from 'drizzle-orm';\nimport { DEFAULT_LEASE_MS, computeBackoffMs } from './leases';\n\n/**\n * SvelteForge jobs foundation (#231) — background tasks without coupling the\n * business to a queue provider (BullMQ/Redis/etc.).\n *\n * await jobs.enqueue('payroll.export', { organizationId, period });\n * jobs.define('payroll.export', async (payload, ctx) => {\n * await ctx.progress(10);\n * // long work\n * await ctx.progress(100);\n * return { fileId };\n * });\n *\n * Guarantees (v1, #328): at-least-once (a crashed handler may re-run →\n * handlers must be idempotent), bounded retries (maxAttempts, default 3) with\n * exponential backoff, atomic claims (FOR UPDATE SKIP LOCKED — many workers\n * can run concurrently without duplicate execution), lease-guarded running\n * jobs (a crashed worker's job is recoverable once the lease expires; long\n * handlers renew it through ctx.progress / ctx.heartbeat).\n *\n * Throw `NonRetryableJobError` from a handler to fail the job immediately.\n */\n\nexport type JobStatus = 'queued' | 'running' | 'completed' | 'failed';\n\nexport interface JobHandlerContext {\n\tjobId: string;\n\t/** Update progress 0–100 (also renews the claim lease). */\n\tprogress: (value: number) => Promise;\n\t/** Renew the claim lease without changing progress (long handlers). */\n\theartbeat: () => Promise;\n}\n\nexport type JobHandler> = (payload: T, ctx: JobHandlerContext) => Promise;\n\n/** A permanent failure: the job is failed immediately, without retrying. */\nexport class NonRetryableJobError extends Error {\n\tconstructor(message: string) {\n\t\tsuper(message);\n\t\tthis.name = 'NonRetryableJobError';\n\t}\n}\n\nconst handlers = new Map();\n\n/** Register a typed handler for a job type. */\nexport function define>(type: string, handler: JobHandler): void {\n\thandlers.set(type, handler as JobHandler);\n}\n\nexport const jobsApi = {\n\t/** Enqueue a job. Returns immediately (processing happens in background). */\n\tasync enqueue(type: string, payload: Record = {}, maxAttempts = 3) {\n\t\tconst [row] = await db\n\t\t\t.insert(jobs)\n\t\t\t.values({\n\t\t\t\ttype,\n\t\t\t\tstatus: 'queued',\n\t\t\t\tpayload,\n\t\t\t\tmaxAttempts,\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\tupdatedAt: new Date()\n\t\t\t})\n\t\t\t.returning();\n\t\treturn row;\n\t},\n\n\t/** Progress update (0–100). With `leaseMs`, also renews the claim lease. */\n\tasync progress(jobId: string, value: number, leaseMs?: number) {\n\t\tawait db\n\t\t\t.update(jobs)\n\t\t\t.set({\n\t\t\t\tprogress: Math.min(100, Math.max(0, value)),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t\t...(leaseMs ? { leaseUntil: new Date(Date.now() + leaseMs) } : {})\n\t\t\t})\n\t\t\t.where(eq(jobs.id, jobId));\n\t},\n\n\t/** Renew the claim lease (long handlers, #328). */\n\tasync heartbeat(jobId: string, leaseMs = DEFAULT_LEASE_MS) {\n\t\tawait db\n\t\t\t.update(jobs)\n\t\t\t.set({ leaseUntil: new Date(Date.now() + leaseMs), updatedAt: new Date() })\n\t\t\t.where(eq(jobs.id, jobId));\n\t},\n\n\t/** Get a job by id (diagnostics). */\n\tasync get(jobId: string) {\n\t\tconst [row] = await db.select().from(jobs).where(eq(jobs.id, jobId));\n\t\treturn row;\n\t},\n\n\t/**\n\t * Atomically claim up to `batchSize` jobs (#328): queued jobs whose\n\t * backoff elapsed, or running jobs whose lease expired (crash recovery).\n\t * `FOR UPDATE SKIP LOCKED` inside a transaction lets many workers poll the\n\t * same queue concurrently — a claimed row is invisible to the next claim\n\t * until this transaction commits (the row is `running` by then).\n\t */\n\tasync claimBatch(batchSize: number, leaseMs = DEFAULT_LEASE_MS) {\n\t\tconst now = new Date();\n\t\treturn db.transaction(async (tx) => {\n\t\t\tconst claimable = await tx\n\t\t\t\t.select()\n\t\t\t\t.from(jobs)\n\t\t\t\t.where(\n\t\t\t\t\tor(\n\t\t\t\t\t\tand(eq(jobs.status, 'queued'), or(isNull(jobs.runAfter), lte(jobs.runAfter, now))),\n\t\t\t\t\t\tand(eq(jobs.status, 'running'), lt(jobs.leaseUntil, now))\n\t\t\t\t\t)\n\t\t\t\t)\n\t\t\t\t.orderBy(asc(jobs.createdAt))\n\t\t\t\t.limit(batchSize)\n\t\t\t\t.for('update', { skipLocked: true });\n\t\t\tif (claimable.length === 0) return [];\n\t\t\tconst ids = claimable.map((job) => job.id);\n\t\t\tawait tx\n\t\t\t\t.update(jobs)\n\t\t\t\t.set({\n\t\t\t\t\tstatus: 'running',\n\t\t\t\t\tattempts: sql`${jobs.attempts} + 1`,\n\t\t\t\t\tlockedAt: now,\n\t\t\t\t\tleaseUntil: new Date(now.getTime() + leaseMs),\n\t\t\t\t\tstartedAt: sql`coalesce(${jobs.startedAt}, now())`,\n\t\t\t\t\tupdatedAt: now\n\t\t\t\t})\n\t\t\t\t.where(inArray(jobs.id, ids));\n\t\t\t// The SELECT above read the rows BEFORE the increment (#391 review):\n\t\t\t// report the incremented attempt so retry bookkeeping in\n\t\t\t// runClaimed() sees 1..maxAttempts and a job runs at most\n\t\t\t// maxAttempts times.\n\t\t\treturn claimable.map((job) => ({ ...job, attempts: job.attempts + 1 }));\n\t\t});\n\t},\n\n\t/**\n\t * Claim and run one batch. Called by the runner — see `runner.ts`.\n\t * Exposed for tests and manual triggering. `leaseMs` bounds how long a\n\t * claim stays exclusive without a heartbeat.\n\t */\n\tasync processNextBatch(batchSize = 10, leaseMs = DEFAULT_LEASE_MS): Promise {\n\t\tconst claimed = await this.claimBatch(batchSize, leaseMs);\n\t\tlet processed = 0;\n\t\tfor (const job of claimed) {\n\t\t\tconst handler = handlers.get(job.type);\n\t\t\tif (!handler) {\n\t\t\t\tawait db\n\t\t\t\t\t.update(jobs)\n\t\t\t\t\t.set({ status: 'failed', error: 'Unknown handler', updatedAt: new Date(), finishedAt: new Date() })\n\t\t\t\t\t.where(eq(jobs.id, job.id));\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tawait this.runClaimed(job.id, job.payload as Record, job.attempts, job.maxAttempts, handler, leaseMs);\n\t\t\tprocessed++;\n\t\t}\n\t\treturn processed;\n\t},\n\n\t/** Execute a claimed job (already `running`, attempts already incremented). */\n\tasync runClaimed(\n\t\tjobId: string,\n\t\tpayload: Record,\n\t\tattempts: number,\n\t\tmaxAttempts: number,\n\t\thandler: JobHandler,\n\t\tleaseMs: number\n\t) {\n\t\tconst context: JobHandlerContext = {\n\t\t\tjobId,\n\t\t\tprogress: (value) => this.progress(jobId, value, leaseMs),\n\t\t\theartbeat: () => this.heartbeat(jobId, leaseMs)\n\t\t};\n\t\ttry {\n\t\t\tconst result = await handler(payload, context);\n\t\t\tawait db\n\t\t\t\t.update(jobs)\n\t\t\t\t.set({\n\t\t\t\t\tstatus: 'completed',\n\t\t\t\t\tprogress: 100,\n\t\t\t\t\tresult: (result ?? {}) as Record,\n\t\t\t\t\terror: null,\n\t\t\t\t\tfinishedAt: new Date(),\n\t\t\t\t\tupdatedAt: new Date()\n\t\t\t\t})\n\t\t\t\t.where(eq(jobs.id, jobId));\n\t\t} catch (e) {\n\t\t\tconst message = e instanceof Error ? e.message : String(e);\n\t\t\tconst nonRetryable = e instanceof NonRetryableJobError;\n\t\t\tconst failed = nonRetryable || attempts >= maxAttempts;\n\t\t\tawait db\n\t\t\t\t.update(jobs)\n\t\t\t\t.set({\n\t\t\t\t\tstatus: failed ? 'failed' : 'queued',\n\t\t\t\t\terror: message,\n\t\t\t\t\tupdatedAt: new Date(),\n\t\t\t\t\t// retryable: exponential backoff before the next attempt (#328)\n\t\t\t\t\t...(failed ? { finishedAt: new Date() } : { runAfter: new Date(Date.now() + computeBackoffMs(attempts)) })\n\t\t\t\t})\n\t\t\t\t.where(eq(jobs.id, jobId));\n\t\t}\n\t}\n};\n", - "/lib/server/jobs/jobs-claim.test.ts": "import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';\n\n// #312 — integration suites NEVER read the application .env: the database\n// comes exclusively from TEST_DATABASE_URL (a dedicated test database,\n// enforced by resolveTestDbUrl). Without it the suite skips cleanly.\nconst hasDb = !!process.env.TEST_DATABASE_URL;\nconst d = hasDb ? describe : describe.skip;\n\nvi.mock('$app/env/private', async () => {\n\tconst { resolveTestDbUrl } = await import('../test-db');\n\treturn {\n\t\tDATABASE_URL: resolveTestDbUrl(),\n\t\tORIGIN: 'http://localhost:5173',\n\t\tBETTER_AUTH_SECRET: 'sforge-integration-secret-0123456789abcdef',\n\t\tSIGNUP_MODE: 'closed'\n\t};\n});\n\nimport { db } from '$lib/server/db';\nimport { jobs } from './schema';\nimport { jobsApi, define, NonRetryableJobError } from './index';\nimport { startJobRunner, stopJobRunner, runnerState } from './runner';\nimport { eq, like } from 'drizzle-orm';\n\n/**\n * #328 — real-PostgreSQL behavioral contract of the jobs foundation:\n * atomic claims (FOR UPDATE SKIP LOCKED), lease expiry recovery, heartbeat,\n * idempotent replay, backoff, non-retryable errors and a graceful worker\n * shutdown. Runs in the dashboard-foundations profile (CI provides PostgreSQL);\n * skips cleanly when no database is configured.\n */\n\n// Run-scoped type prefix (#312): two concurrent runs on the same dedicated\n// test database never claim or delete each other's rows.\nconst TEST_PREFIX = `claimtest-${crypto.randomUUID().slice(0, 8)}-`;\n\nd('jobs claim contract (#328, real PostgreSQL)', () => {\n\tconst executed: Record = {};\n\n\tconst count = (id: string) => (executed[id] ?? 0);\n\tconst record = (id: string) => {\n\t\texecuted[id] = (executed[id] ?? 0) + 1;\n\t};\n\n\tbeforeAll(async () => {\n\t\tawait db.delete(jobs).where(like(jobs.type, `${TEST_PREFIX}%`));\n\t});\n\n\tafterAll(async () => {\n\t\tawait db.delete(jobs).where(like(jobs.type, `${TEST_PREFIX}%`));\n\t});\n\n\tit('two concurrent workers never claim the same job', async () => {\n\t\tdefine(`${TEST_PREFIX}race`, async (payload) => {\n\t\t\trecord(String((payload as { i: number }).i));\n\t\t\treturn { ok: true };\n\t\t});\n\t\tfor (let i = 0; i < 4; i++) await jobsApi.enqueue(`${TEST_PREFIX}race`, { i });\n\t\t// Two workers claim and run batches concurrently. The claim runs inside\n\t\t// a transaction holding FOR UPDATE SKIP LOCKED row locks, so overlapping\n\t\t// batches must split the queue — never share a job.\n\t\tconst [a, b] = await Promise.all([jobsApi.processNextBatch(2), jobsApi.processNextBatch(2)]);\n\t\texpect(a + b).toBe(4);\n\t\t// 4 distinct payloads, each executed exactly once across both workers\n\t\texpect(Object.keys(executed).sort()).toEqual(['0', '1', '2', '3']);\n\t\tfor (const n of Object.values(executed)) expect(n).toBe(1);\n\t\tconst rows = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}race`));\n\t\tfor (const row of rows) expect(row.status).toBe('completed');\n\t});\n\n\tit('an expired lease makes a claimed job claimable again (crash recovery) and the idempotent handler is replayed', async () => {\n\t\tawait jobsApi.enqueue(`${TEST_PREFIX}lease`, { n: 1 });\n\t\tlet runs = 0;\n\t\tdefine(`${TEST_PREFIX}lease`, async (_payload, ctx) => {\n\t\t\truns += 1;\n\t\t\tawait ctx.progress(10);\n\t\t\tif (runs === 1) await new Promise((r) => setTimeout(r, 200)); // first \"worker\" dies mid-flight\n\t\t\treturn { ok: runs };\n\t\t});\n\t\tconst first = jobsApi.processNextBatch(1, 50); // 50ms lease, dies at +200ms\n\t\t// Wait for the first claim to actually hold the lease, then expire it\n\t\t// IN THE DATABASE — racing real timers (sleep 120 vs a progress()\n\t\t// renewal at t+50..70) was flaky under load (#426 CI).\n\t\tawait vi.waitFor(async () => {\n\t\t\tconst [claimed] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}lease`));\n\t\t\texpect(claimed?.status).toBe('running');\n\t\t});\n\t\tawait db\n\t\t\t.update(jobs)\n\t\t\t.set({ leaseUntil: new Date(Date.now() - 1000) })\n\t\t\t.where(eq(jobs.type, `${TEST_PREFIX}lease`));\n\t\tconst second = jobsApi.processNextBatch(1, 50); // second worker reclaims the expired lease\n\t\tawait Promise.all([first, second]);\n\t\texpect(runs).toBe(2); // at-least-once: replayed after crash\n\t\tconst [row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}lease`));\n\t\texpect(row.status).toBe('completed');\n\t\texpect(row.attempts).toBe(2);\n\t}, 15000);\n\n\tit('a live heartbeat keeps the lease alive — no duplicate execution', async () => {\n\t\tawait jobsApi.enqueue(`${TEST_PREFIX}heartbeat`, { n: 1 });\n\t\tlet runs = 0;\n\t\tdefine(`${TEST_PREFIX}heartbeat`, async (_payload, ctx) => {\n\t\t\truns += 1;\n\t\t\t// long work keeps renewing the claim through heartbeats (60ms lease):\n\t\t\t// last renewal at ≈t+55 keeps the lease alive past t+80.\n\t\t\tawait ctx.heartbeat();\n\t\t\tawait new Promise((r) => setTimeout(r, 50));\n\t\t\tawait ctx.heartbeat();\n\t\t\tawait new Promise((r) => setTimeout(r, 70));\n\t\t\treturn { ok: true };\n\t\t});\n\t\tconst first = jobsApi.processNextBatch(1, 60);\n\t\tawait new Promise((r) => setTimeout(r, 80)); // without the renewals, the lease would have expired here\n\t\tconst second = jobsApi.processNextBatch(1, 60); // …but the heartbeats renewed it\n\t\tawait Promise.all([first, second]);\n\t\texpect(runs).toBe(1);\n\t});\n\n\tit('retryable failures back off before the next attempt', async () => {\n\t\tawait jobsApi.enqueue(`${TEST_PREFIX}backoff`, { n: 1 }, 3);\n\t\tlet calls = 0;\n\t\tdefine(`${TEST_PREFIX}backoff`, async () => {\n\t\t\tcalls += 1;\n\t\t\tif (calls === 1) throw new Error('transient');\n\t\t\treturn { ok: true };\n\t\t});\n\t\tawait jobsApi.processNextBatch(1);\n\t\tlet [row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}backoff`));\n\t\texpect(row.status).toBe('queued');\n\t\texpect(row.attempts).toBe(1);\n\t\texpect(row.error).toContain('transient');\n\t\texpect(row.runAfter).not.toBeNull();\n\t\t// backoff: an immediate second batch must NOT pick it up\n\t\tconst claimed = await jobsApi.processNextBatch(1);\n\t\texpect(claimed).toBe(0);\n\t\t// once the backoff elapsed, the job runs again and completes\n\t\tawait new Promise((r) => setTimeout(r, 1100));\n\t\tawait jobsApi.processNextBatch(1);\n\t\t[row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}backoff`));\n\t\texpect(row.status).toBe('completed');\n\t\texpect(row.attempts).toBe(2);\n\t\texpect(row.error).toBeNull();\n\t});\n\n\tit('bounded retries: the handler runs EXACTLY maxAttempts times (#391 regression)', async () => {\n\t\tawait jobsApi.enqueue(`${TEST_PREFIX}bounded`, { n: 1 }, 3);\n\t\tlet runs = 0;\n\t\tdefine(`${TEST_PREFIX}bounded`, async () => {\n\t\t\truns += 1;\n\t\t\tthrow new Error('always fails');\n\t\t});\n\t\tfor (let attempt = 1; attempt <= 3; attempt++) {\n\t\t\tawait jobsApi.processNextBatch(1);\n\t\t\texpect(runs).toBe(attempt);\n\t\t\tconst [row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}bounded`));\n\t\t\tif (attempt < 3) {\n\t\t\t\t// skip the backoff window deterministically between attempts\n\t\t\t\tawait db.update(jobs).set({ runAfter: new Date(Date.now() - 1) }).where(eq(jobs.type, `${TEST_PREFIX}bounded`));\n\t\t\t}\n\t\t}\n\t\tconst fourth = await jobsApi.processNextBatch(1);\n\t\texpect(fourth).toBe(0);\n\t\texpect(runs).toBe(3);\n\t\tconst [row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}bounded`));\n\t\texpect(row.status).toBe('failed');\n\t\texpect(row.attempts).toBe(3);\n\t});\n\n\tit('a NonRetryableJobError fails the job immediately (no retry)', async () => {\n\t\tawait jobsApi.enqueue(`${TEST_PREFIX}permanent`, { n: 1 }, 3);\n\t\tlet calls = 0;\n\t\tdefine(`${TEST_PREFIX}permanent`, async () => {\n\t\t\tcalls += 1;\n\t\t\tthrow new NonRetryableJobError('bad payload shape');\n\t\t});\n\t\tawait jobsApi.processNextBatch(1);\n\t\texpect(calls).toBe(1);\n\t\tconst [row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}permanent`));\n\t\texpect(row.status).toBe('failed');\n\t\texpect(row.attempts).toBe(1);\n\t\texpect(row.finishedAt).not.toBeNull();\n\t});\n\n\tit('the worker drains gracefully: stopJobRunner resolves after the in-flight batch', async () => {\n\t\tfor (let i = 0; i < 3; i++) await jobsApi.enqueue(`${TEST_PREFIX}runner`, { i });\n\t\tlet handlerRuns = 0;\n\t\tdefine(`${TEST_PREFIX}runner`, async () => {\n\t\t\thandlerRuns += 1;\n\t\t\tawait new Promise((r) => setTimeout(r, 120));\n\t\t\treturn { ok: true };\n\t\t});\n\t\tstartJobRunner(15, 1, { leaseMs: 5_000 });\n\t\t// wait until the first tick is mid-handler\n\t\tfor (let i = 0; i < 50 && handlerRuns === 0; i++) await new Promise((r) => setTimeout(r, 10));\n\t\texpect(handlerRuns).toBeGreaterThanOrEqual(1);\n\t\tconst inFlightEnd = Date.now() + 120; // roughly when the active handler finishes\n\t\tawait stopJobRunner(); // must NOT resolve before the active batch completes\n\t\texpect(Date.now()).toBeGreaterThanOrEqual(inFlightEnd - 20);\n\t\texpect(runnerState().active).toBe(false);\n\t\tconst after = handlerRuns;\n\t\tawait new Promise((r) => setTimeout(r, 80));\n\t\texpect(handlerRuns).toBe(after); // no new tick after stop\n\t});\n\n\tit('the web runtime never auto-starts the poller (#328)', async () => {\n\t\tconst { readFileSync: rf } = await import('node:fs');\n\t\tconst hooks = rf('src/hooks.server.ts', 'utf8');\n\t\texpect(hooks).not.toContain('startJobRunner');\n\t});\n});\n", + "/lib/server/jobs/index.ts": "import { db } from '#lib/server/db/index.js';\nimport { jobs } from './schema';\nimport { eq, and, or, lt, lte, isNull, inArray, sql, asc } from 'drizzle-orm';\nimport { DEFAULT_LEASE_MS, computeBackoffMs } from './leases';\n\n/**\n * SvelteForge jobs foundation (#231) — background tasks without coupling the\n * business to a queue provider (BullMQ/Redis/etc.).\n *\n * await jobs.enqueue('payroll.export', { organizationId, period });\n * jobs.define('payroll.export', async (payload, ctx) => {\n * await ctx.progress(10);\n * // long work\n * await ctx.progress(100);\n * return { fileId };\n * });\n *\n * Guarantees (v1, #328): at-least-once (a crashed handler may re-run →\n * handlers must be idempotent), bounded retries (maxAttempts, default 3) with\n * exponential backoff, atomic claims (FOR UPDATE SKIP LOCKED — many workers\n * can run concurrently without duplicate execution), lease-guarded running\n * jobs (a crashed worker's job is recoverable once the lease expires; long\n * handlers renew it through ctx.progress / ctx.heartbeat).\n *\n * Throw `NonRetryableJobError` from a handler to fail the job immediately.\n */\n\nexport type JobStatus = 'queued' | 'running' | 'completed' | 'failed';\n\nexport interface JobHandlerContext {\n\tjobId: string;\n\t/** Update progress 0–100 (also renews the claim lease). */\n\tprogress: (value: number) => Promise;\n\t/** Renew the claim lease without changing progress (long handlers). */\n\theartbeat: () => Promise;\n}\n\nexport type JobHandler> = (payload: T, ctx: JobHandlerContext) => Promise;\n\n/** A permanent failure: the job is failed immediately, without retrying. */\nexport class NonRetryableJobError extends Error {\n\tconstructor(message: string) {\n\t\tsuper(message);\n\t\tthis.name = 'NonRetryableJobError';\n\t}\n}\n\nconst handlers = new Map();\n\n/** Register a typed handler for a job type. */\nexport function define>(type: string, handler: JobHandler): void {\n\thandlers.set(type, handler as JobHandler);\n}\n\nexport const jobsApi = {\n\t/** Enqueue a job. Returns immediately (processing happens in background). */\n\tasync enqueue(type: string, payload: Record = {}, maxAttempts = 3) {\n\t\tconst [row] = await db\n\t\t\t.insert(jobs)\n\t\t\t.values({\n\t\t\t\ttype,\n\t\t\t\tstatus: 'queued',\n\t\t\t\tpayload,\n\t\t\t\tmaxAttempts,\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\tupdatedAt: new Date()\n\t\t\t})\n\t\t\t.returning();\n\t\treturn row;\n\t},\n\n\t/** Progress update (0–100). With `leaseMs`, also renews the claim lease. */\n\tasync progress(jobId: string, value: number, leaseMs?: number) {\n\t\tawait db\n\t\t\t.update(jobs)\n\t\t\t.set({\n\t\t\t\tprogress: Math.min(100, Math.max(0, value)),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t\t...(leaseMs ? { leaseUntil: new Date(Date.now() + leaseMs) } : {})\n\t\t\t})\n\t\t\t.where(eq(jobs.id, jobId));\n\t},\n\n\t/** Renew the claim lease (long handlers, #328). */\n\tasync heartbeat(jobId: string, leaseMs = DEFAULT_LEASE_MS) {\n\t\tawait db\n\t\t\t.update(jobs)\n\t\t\t.set({ leaseUntil: new Date(Date.now() + leaseMs), updatedAt: new Date() })\n\t\t\t.where(eq(jobs.id, jobId));\n\t},\n\n\t/** Get a job by id (diagnostics). */\n\tasync get(jobId: string) {\n\t\tconst [row] = await db.select().from(jobs).where(eq(jobs.id, jobId));\n\t\treturn row;\n\t},\n\n\t/**\n\t * Atomically claim up to `batchSize` jobs (#328): queued jobs whose\n\t * backoff elapsed, or running jobs whose lease expired (crash recovery).\n\t * `FOR UPDATE SKIP LOCKED` inside a transaction lets many workers poll the\n\t * same queue concurrently — a claimed row is invisible to the next claim\n\t * until this transaction commits (the row is `running` by then).\n\t */\n\tasync claimBatch(batchSize: number, leaseMs = DEFAULT_LEASE_MS) {\n\t\tconst now = new Date();\n\t\treturn db.transaction(async (tx) => {\n\t\t\tconst claimable = await tx\n\t\t\t\t.select()\n\t\t\t\t.from(jobs)\n\t\t\t\t.where(\n\t\t\t\t\tor(\n\t\t\t\t\t\tand(eq(jobs.status, 'queued'), or(isNull(jobs.runAfter), lte(jobs.runAfter, now))),\n\t\t\t\t\t\tand(eq(jobs.status, 'running'), lt(jobs.leaseUntil, now))\n\t\t\t\t\t)\n\t\t\t\t)\n\t\t\t\t.orderBy(asc(jobs.createdAt))\n\t\t\t\t.limit(batchSize)\n\t\t\t\t.for('update', { skipLocked: true });\n\t\t\tif (claimable.length === 0) return [];\n\t\t\tconst ids = claimable.map((job) => job.id);\n\t\t\tawait tx\n\t\t\t\t.update(jobs)\n\t\t\t\t.set({\n\t\t\t\t\tstatus: 'running',\n\t\t\t\t\tattempts: sql`${jobs.attempts} + 1`,\n\t\t\t\t\tlockedAt: now,\n\t\t\t\t\tleaseUntil: new Date(now.getTime() + leaseMs),\n\t\t\t\t\tstartedAt: sql`coalesce(${jobs.startedAt}, now())`,\n\t\t\t\t\tupdatedAt: now\n\t\t\t\t})\n\t\t\t\t.where(inArray(jobs.id, ids));\n\t\t\t// The SELECT above read the rows BEFORE the increment (#391 review):\n\t\t\t// report the incremented attempt so retry bookkeeping in\n\t\t\t// runClaimed() sees 1..maxAttempts and a job runs at most\n\t\t\t// maxAttempts times.\n\t\t\treturn claimable.map((job) => ({ ...job, attempts: job.attempts + 1 }));\n\t\t});\n\t},\n\n\t/**\n\t * Claim and run one batch. Called by the runner — see `runner.ts`.\n\t * Exposed for tests and manual triggering. `leaseMs` bounds how long a\n\t * claim stays exclusive without a heartbeat.\n\t */\n\tasync processNextBatch(batchSize = 10, leaseMs = DEFAULT_LEASE_MS): Promise {\n\t\tconst claimed = await this.claimBatch(batchSize, leaseMs);\n\t\tlet processed = 0;\n\t\tfor (const job of claimed) {\n\t\t\tconst handler = handlers.get(job.type);\n\t\t\tif (!handler) {\n\t\t\t\tawait db\n\t\t\t\t\t.update(jobs)\n\t\t\t\t\t.set({ status: 'failed', error: 'Unknown handler', updatedAt: new Date(), finishedAt: new Date() })\n\t\t\t\t\t.where(eq(jobs.id, job.id));\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tawait this.runClaimed(job.id, job.payload as Record, job.attempts, job.maxAttempts, handler, leaseMs);\n\t\t\tprocessed++;\n\t\t}\n\t\treturn processed;\n\t},\n\n\t/** Execute a claimed job (already `running`, attempts already incremented). */\n\tasync runClaimed(\n\t\tjobId: string,\n\t\tpayload: Record,\n\t\tattempts: number,\n\t\tmaxAttempts: number,\n\t\thandler: JobHandler,\n\t\tleaseMs: number\n\t) {\n\t\tconst context: JobHandlerContext = {\n\t\t\tjobId,\n\t\t\tprogress: (value) => this.progress(jobId, value, leaseMs),\n\t\t\theartbeat: () => this.heartbeat(jobId, leaseMs)\n\t\t};\n\t\ttry {\n\t\t\tconst result = await handler(payload, context);\n\t\t\tawait db\n\t\t\t\t.update(jobs)\n\t\t\t\t.set({\n\t\t\t\t\tstatus: 'completed',\n\t\t\t\t\tprogress: 100,\n\t\t\t\t\tresult: (result ?? {}) as Record,\n\t\t\t\t\terror: null,\n\t\t\t\t\tfinishedAt: new Date(),\n\t\t\t\t\tupdatedAt: new Date()\n\t\t\t\t})\n\t\t\t\t.where(eq(jobs.id, jobId));\n\t\t} catch (e) {\n\t\t\tconst message = e instanceof Error ? e.message : String(e);\n\t\t\tconst nonRetryable = e instanceof NonRetryableJobError;\n\t\t\tconst failed = nonRetryable || attempts >= maxAttempts;\n\t\t\tawait db\n\t\t\t\t.update(jobs)\n\t\t\t\t.set({\n\t\t\t\t\tstatus: failed ? 'failed' : 'queued',\n\t\t\t\t\terror: message,\n\t\t\t\t\tupdatedAt: new Date(),\n\t\t\t\t\t// retryable: exponential backoff before the next attempt (#328)\n\t\t\t\t\t...(failed ? { finishedAt: new Date() } : { runAfter: new Date(Date.now() + computeBackoffMs(attempts)) })\n\t\t\t\t})\n\t\t\t\t.where(eq(jobs.id, jobId));\n\t\t}\n\t}\n};\n", + "/lib/server/jobs/jobs-claim.test.ts": "import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';\n\n// #312 — integration suites NEVER read the application .env: the database\n// comes exclusively from TEST_DATABASE_URL (a dedicated test database,\n// enforced by resolveTestDbUrl). Without it the suite skips cleanly.\nconst hasDb = !!process.env.TEST_DATABASE_URL;\nconst d = hasDb ? describe : describe.skip;\n\nvi.mock('$app/env/private', async () => {\n\tconst { resolveTestDbUrl } = await import('../test-db');\n\treturn {\n\t\tDATABASE_URL: resolveTestDbUrl(),\n\t\tORIGIN: 'http://localhost:5173',\n\t\tBETTER_AUTH_SECRET: 'sforge-integration-secret-0123456789abcdef',\n\t\tSIGNUP_MODE: 'closed'\n\t};\n});\n\nimport { db } from '#lib/server/db/index.js';\nimport { jobs } from './schema';\nimport { jobsApi, define, NonRetryableJobError } from './index';\nimport { startJobRunner, stopJobRunner, runnerState } from './runner';\nimport { eq, like } from 'drizzle-orm';\n\n/**\n * #328 — real-PostgreSQL behavioral contract of the jobs foundation:\n * atomic claims (FOR UPDATE SKIP LOCKED), lease expiry recovery, heartbeat,\n * idempotent replay, backoff, non-retryable errors and a graceful worker\n * shutdown. Runs in the dashboard-foundations profile (CI provides PostgreSQL);\n * skips cleanly when no database is configured.\n */\n\n// Run-scoped type prefix (#312): two concurrent runs on the same dedicated\n// test database never claim or delete each other's rows.\nconst TEST_PREFIX = `claimtest-${crypto.randomUUID().slice(0, 8)}-`;\n\nd('jobs claim contract (#328, real PostgreSQL)', () => {\n\tconst executed: Record = {};\n\n\tconst count = (id: string) => (executed[id] ?? 0);\n\tconst record = (id: string) => {\n\t\texecuted[id] = (executed[id] ?? 0) + 1;\n\t};\n\n\tbeforeAll(async () => {\n\t\tawait db.delete(jobs).where(like(jobs.type, `${TEST_PREFIX}%`));\n\t});\n\n\tafterAll(async () => {\n\t\tawait db.delete(jobs).where(like(jobs.type, `${TEST_PREFIX}%`));\n\t});\n\n\tit('two concurrent workers never claim the same job', async () => {\n\t\tdefine(`${TEST_PREFIX}race`, async (payload) => {\n\t\t\trecord(String((payload as { i: number }).i));\n\t\t\treturn { ok: true };\n\t\t});\n\t\tfor (let i = 0; i < 4; i++) await jobsApi.enqueue(`${TEST_PREFIX}race`, { i });\n\t\t// Two workers claim and run batches concurrently. The claim runs inside\n\t\t// a transaction holding FOR UPDATE SKIP LOCKED row locks, so overlapping\n\t\t// batches must split the queue — never share a job.\n\t\tconst [a, b] = await Promise.all([jobsApi.processNextBatch(2), jobsApi.processNextBatch(2)]);\n\t\texpect(a + b).toBe(4);\n\t\t// 4 distinct payloads, each executed exactly once across both workers\n\t\texpect(Object.keys(executed).sort()).toEqual(['0', '1', '2', '3']);\n\t\tfor (const n of Object.values(executed)) expect(n).toBe(1);\n\t\tconst rows = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}race`));\n\t\tfor (const row of rows) expect(row.status).toBe('completed');\n\t});\n\n\tit('an expired lease makes a claimed job claimable again (crash recovery) and the idempotent handler is replayed', async () => {\n\t\tawait jobsApi.enqueue(`${TEST_PREFIX}lease`, { n: 1 });\n\t\tlet runs = 0;\n\t\tdefine(`${TEST_PREFIX}lease`, async (_payload, ctx) => {\n\t\t\truns += 1;\n\t\t\tawait ctx.progress(10);\n\t\t\tif (runs === 1) await new Promise((r) => setTimeout(r, 200)); // first \"worker\" dies mid-flight\n\t\t\treturn { ok: runs };\n\t\t});\n\t\tconst first = jobsApi.processNextBatch(1, 50); // 50ms lease, dies at +200ms\n\t\t// Wait for the first claim to actually hold the lease, then expire it\n\t\t// IN THE DATABASE — racing real timers (sleep 120 vs a progress()\n\t\t// renewal at t+50..70) was flaky under load (#426 CI).\n\t\tawait vi.waitFor(async () => {\n\t\t\tconst [claimed] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}lease`));\n\t\t\texpect(claimed?.status).toBe('running');\n\t\t});\n\t\tawait db\n\t\t\t.update(jobs)\n\t\t\t.set({ leaseUntil: new Date(Date.now() - 1000) })\n\t\t\t.where(eq(jobs.type, `${TEST_PREFIX}lease`));\n\t\tconst second = jobsApi.processNextBatch(1, 50); // second worker reclaims the expired lease\n\t\tawait Promise.all([first, second]);\n\t\texpect(runs).toBe(2); // at-least-once: replayed after crash\n\t\tconst [row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}lease`));\n\t\texpect(row.status).toBe('completed');\n\t\texpect(row.attempts).toBe(2);\n\t}, 15000);\n\n\tit('a live heartbeat keeps the lease alive — no duplicate execution', async () => {\n\t\tawait jobsApi.enqueue(`${TEST_PREFIX}heartbeat`, { n: 1 });\n\t\tlet runs = 0;\n\t\tdefine(`${TEST_PREFIX}heartbeat`, async (_payload, ctx) => {\n\t\t\truns += 1;\n\t\t\t// long work keeps renewing the claim through heartbeats (60ms lease):\n\t\t\t// last renewal at ≈t+55 keeps the lease alive past t+80.\n\t\t\tawait ctx.heartbeat();\n\t\t\tawait new Promise((r) => setTimeout(r, 50));\n\t\t\tawait ctx.heartbeat();\n\t\t\tawait new Promise((r) => setTimeout(r, 70));\n\t\t\treturn { ok: true };\n\t\t});\n\t\tconst first = jobsApi.processNextBatch(1, 60);\n\t\tawait new Promise((r) => setTimeout(r, 80)); // without the renewals, the lease would have expired here\n\t\tconst second = jobsApi.processNextBatch(1, 60); // …but the heartbeats renewed it\n\t\tawait Promise.all([first, second]);\n\t\texpect(runs).toBe(1);\n\t});\n\n\tit('retryable failures back off before the next attempt', async () => {\n\t\tawait jobsApi.enqueue(`${TEST_PREFIX}backoff`, { n: 1 }, 3);\n\t\tlet calls = 0;\n\t\tdefine(`${TEST_PREFIX}backoff`, async () => {\n\t\t\tcalls += 1;\n\t\t\tif (calls === 1) throw new Error('transient');\n\t\t\treturn { ok: true };\n\t\t});\n\t\tawait jobsApi.processNextBatch(1);\n\t\tlet [row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}backoff`));\n\t\texpect(row.status).toBe('queued');\n\t\texpect(row.attempts).toBe(1);\n\t\texpect(row.error).toContain('transient');\n\t\texpect(row.runAfter).not.toBeNull();\n\t\t// backoff: an immediate second batch must NOT pick it up\n\t\tconst claimed = await jobsApi.processNextBatch(1);\n\t\texpect(claimed).toBe(0);\n\t\t// once the backoff elapsed, the job runs again and completes\n\t\tawait new Promise((r) => setTimeout(r, 1100));\n\t\tawait jobsApi.processNextBatch(1);\n\t\t[row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}backoff`));\n\t\texpect(row.status).toBe('completed');\n\t\texpect(row.attempts).toBe(2);\n\t\texpect(row.error).toBeNull();\n\t});\n\n\tit('bounded retries: the handler runs EXACTLY maxAttempts times (#391 regression)', async () => {\n\t\tawait jobsApi.enqueue(`${TEST_PREFIX}bounded`, { n: 1 }, 3);\n\t\tlet runs = 0;\n\t\tdefine(`${TEST_PREFIX}bounded`, async () => {\n\t\t\truns += 1;\n\t\t\tthrow new Error('always fails');\n\t\t});\n\t\tfor (let attempt = 1; attempt <= 3; attempt++) {\n\t\t\tawait jobsApi.processNextBatch(1);\n\t\t\texpect(runs).toBe(attempt);\n\t\t\tconst [row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}bounded`));\n\t\t\tif (attempt < 3) {\n\t\t\t\t// skip the backoff window deterministically between attempts\n\t\t\t\tawait db.update(jobs).set({ runAfter: new Date(Date.now() - 1) }).where(eq(jobs.type, `${TEST_PREFIX}bounded`));\n\t\t\t}\n\t\t}\n\t\tconst fourth = await jobsApi.processNextBatch(1);\n\t\texpect(fourth).toBe(0);\n\t\texpect(runs).toBe(3);\n\t\tconst [row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}bounded`));\n\t\texpect(row.status).toBe('failed');\n\t\texpect(row.attempts).toBe(3);\n\t});\n\n\tit('a NonRetryableJobError fails the job immediately (no retry)', async () => {\n\t\tawait jobsApi.enqueue(`${TEST_PREFIX}permanent`, { n: 1 }, 3);\n\t\tlet calls = 0;\n\t\tdefine(`${TEST_PREFIX}permanent`, async () => {\n\t\t\tcalls += 1;\n\t\t\tthrow new NonRetryableJobError('bad payload shape');\n\t\t});\n\t\tawait jobsApi.processNextBatch(1);\n\t\texpect(calls).toBe(1);\n\t\tconst [row] = await db.select().from(jobs).where(eq(jobs.type, `${TEST_PREFIX}permanent`));\n\t\texpect(row.status).toBe('failed');\n\t\texpect(row.attempts).toBe(1);\n\t\texpect(row.finishedAt).not.toBeNull();\n\t});\n\n\tit('the worker drains gracefully: stopJobRunner resolves after the in-flight batch', async () => {\n\t\tfor (let i = 0; i < 3; i++) await jobsApi.enqueue(`${TEST_PREFIX}runner`, { i });\n\t\tlet handlerRuns = 0;\n\t\tdefine(`${TEST_PREFIX}runner`, async () => {\n\t\t\thandlerRuns += 1;\n\t\t\tawait new Promise((r) => setTimeout(r, 120));\n\t\t\treturn { ok: true };\n\t\t});\n\t\tstartJobRunner(15, 1, { leaseMs: 5_000 });\n\t\t// wait until the first tick is mid-handler\n\t\tfor (let i = 0; i < 50 && handlerRuns === 0; i++) await new Promise((r) => setTimeout(r, 10));\n\t\texpect(handlerRuns).toBeGreaterThanOrEqual(1);\n\t\tconst inFlightEnd = Date.now() + 120; // roughly when the active handler finishes\n\t\tawait stopJobRunner(); // must NOT resolve before the active batch completes\n\t\texpect(Date.now()).toBeGreaterThanOrEqual(inFlightEnd - 20);\n\t\texpect(runnerState().active).toBe(false);\n\t\tconst after = handlerRuns;\n\t\tawait new Promise((r) => setTimeout(r, 80));\n\t\texpect(handlerRuns).toBe(after); // no new tick after stop\n\t});\n\n\tit('the web runtime never auto-starts the poller (#328)', async () => {\n\t\tconst { readFileSync: rf } = await import('node:fs');\n\t\tconst hooks = rf('src/hooks.server.ts', 'utf8');\n\t\texpect(hooks).not.toContain('startJobRunner');\n\t});\n});\n", "/lib/server/jobs/leases.ts": "/**\n * Pure lease/backoff decision helpers (#328) — kept free of any import so the\n * claim rules are unit-testable without a database and reusable by both the\n * API and the tests.\n *\n * A job is claimable when:\n * - `queued` and its backoff (`runAfter`) has elapsed — fresh jobs have no\n * `runAfter` and are immediately claimable;\n * - `running` but its lease (`leaseUntil`) has EXPIRED — the claiming worker\n * crashed before completing, the job must be recoverable (at-least-once);\n * - anything else (completed/failed) is terminal and never claimable.\n */\n\n/** Default claim lease. Long handlers must heartbeat (see ctx.heartbeat). */\nexport const DEFAULT_LEASE_MS = 60_000;\n\n/** Retry backoff cap. */\nexport const MAX_BACKOFF_MS = 60_000;\n/** Retry backoff base (first retry waits 1s, then 2s, 4s, …). */\nexport const BASE_BACKOFF_MS = 1_000;\n\n/** Exponential backoff for the Nth attempt (1-based), capped. */\nexport function computeBackoffMs(attempt: number): number {\n\tconst safeAttempt = Number.isFinite(attempt) && attempt >= 1 ? Math.floor(attempt) : 1;\n\treturn Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** (safeAttempt - 1));\n}\n\nexport interface ClaimableJob {\n\tstatus: string;\n\trunAfter: Date | null;\n\tleaseUntil: Date | null;\n}\n\n/** Can this row be claimed at `now`? Mirrors the SQL claim predicate. */\nexport function isClaimable(job: ClaimableJob, now: Date): boolean {\n\tif (job.status === 'queued') {\n\t\treturn job.runAfter === null || job.runAfter.getTime() <= now.getTime();\n\t}\n\tif (job.status === 'running') {\n\t\treturn job.leaseUntil !== null && job.leaseUntil.getTime() < now.getTime();\n\t}\n\treturn false;\n}\n", "/lib/server/jobs/runner.ts": "import { jobsApi } from './index';\nimport { DEFAULT_LEASE_MS } from './leases';\n\n/**\n * Job runner (#231, #328) — processes queued jobs on an interval.\n *\n * OPT-IN ONLY (#328): installing the module never starts a poller inside the\n * web runtime. Either call `startJobRunner()` explicitly in a single-instance\n * long-lived deployment, or run the dedicated worker process\n * (`bun src/lib/server/jobs/worker.ts`, npm script `jobs:worker`) — the\n * documented production mode. Claims are atomic and lease-guarded, so many\n * runners can poll the same queue concurrently.\n *\n * Guarantees: at-least-once (handlers must be idempotent), bounded retries\n * with backoff, no overlapping batches inside THIS process. `stopJobRunner()`\n * drains gracefully: it resolves once the in-flight batch has completed.\n */\n\nlet timer: ReturnType | null = null;\nlet inFlight: Promise | null = null;\n\n/** Start the background runner (idempotent). Never called automatically. */\nexport function startJobRunner(intervalMs = 5000, batchSize = 10, options: { leaseMs?: number } = {}): void {\n\tif (timer) return;\n\tconst leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;\n\ttimer = setInterval(() => {\n\t\tif (inFlight) return; // no overlapping batches in this process\n\t\tinFlight = jobsApi\n\t\t\t.processNextBatch(batchSize, leaseMs)\n\t\t\t.catch(() => {\n\t\t\t\t// keep the loop alive; next tick retries\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tinFlight = null;\n\t\t\t});\n\t}, intervalMs);\n\t// Don't keep the process alive just for the runner — the host owns it.\n\tif (timer.unref) timer.unref();\n}\n\n/**\n * Stop polling and drain gracefully (#328): resolves once the in-flight batch\n * has completed (or immediately when idle). Safe to call repeatedly.\n */\nexport async function stopJobRunner(): Promise {\n\tif (timer) {\n\t\tclearInterval(timer);\n\t\ttimer = null;\n\t}\n\tif (inFlight) await inFlight;\n\tinFlight = null;\n}\n\n/** Runner state (diagnostics/tests): is the loop accepting ticks? */\nexport function runnerState(): { active: boolean } {\n\treturn { active: timer !== null };\n}\n", "/lib/server/jobs/schema.ts": "import { pgTable, uuid, text, jsonb, integer, timestamp } from 'drizzle-orm/pg-core';\n\n/**\n * Jobs schema (#231) — background job foundation.\n * States: queued → running → completed | failed. Retries bounded, claims\n * lease-guarded (#328).\n *\n * - `lockedAt` / `leaseUntil`: claim bookkeeping. A `running` job whose lease\n * expired is recoverable (the claiming worker crashed).\n * - `runAfter`: retry backoff — a queued job is not claimable before it.\n */\nexport const jobs = pgTable('jobs', {\n\tid: uuid('id').primaryKey().defaultRandom(),\n\ttype: text('type').notNull(),\n\tstatus: text('status', { enum: ['queued', 'running', 'completed', 'failed'] })\n\t\t.notNull()\n\t\t.default('queued'),\n\tpayload: jsonb('payload').$type>().notNull(),\n\tprogress: integer('progress').notNull().default(0),\n\tattempts: integer('attempts').notNull().default(0),\n\tmaxAttempts: integer('max_attempts').notNull().default(3),\n\tresult: jsonb('result').$type>(),\n\terror: text('error'),\n\tcreatedAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),\n\tupdatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),\n\tstartedAt: timestamp('started_at', { withTimezone: true }),\n\tfinishedAt: timestamp('finished_at', { withTimezone: true }),\n\t// #328: atomic, lease-guarded claims\n\tlockedAt: timestamp('locked_at', { withTimezone: true }),\n\tleaseUntil: timestamp('lease_until', { withTimezone: true }),\n\trunAfter: timestamp('run_after', { withTimezone: true })\n});\n", @@ -134,11 +134,11 @@ export const MODULE_RECIPE_DATA: Record = { "version": "2.2.0", "dependencies": [], "files": { - "/lib/components/svforge/ui/NotificationsBell.svelte": "\n\n (open = details.open)}\n\tcloseOnInteractOutside={true}\n\trestoreFocus={true}\n>\n\t\n\t\t\n\t\t{#if unreadCount > 0}\n\t\t\t{unreadCount}\n\t\t{/if}\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t{m.notif_title()}\n\t\t\t\t\t{m.notif_mark_all()}\n\t\t\t\t
\n\n\t\t\t\t{#if items.length === 0}\n\t\t\t\t\t

{m.notif_empty()}

\n\t\t\t\t{:else}\n\t\t\t\t\t\n\t\t\t\t{/if}\n\t\t\t
\n\t\t
\n\t
\n\n", + "/lib/components/svforge/ui/NotificationsBell.svelte": "\n\n (open = details.open)}\n\tcloseOnInteractOutside={true}\n\trestoreFocus={true}\n>\n\t\n\t\t\n\t\t{#if unreadCount > 0}\n\t\t\t{unreadCount}\n\t\t{/if}\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t{m.notif_title()}\n\t\t\t\t\t{m.notif_mark_all()}\n\t\t\t\t
\n\n\t\t\t\t{#if items.length === 0}\n\t\t\t\t\t

{m.notif_empty()}

\n\t\t\t\t{:else}\n\t\t\t\t\t\n\t\t\t\t{/if}\n\t\t\t
\n\t\t
\n\t
\n\n", "/lib/icons/notifications.ts": "export { default as Bell } from 'phosphor-svelte/lib/Bell';\n", - "/lib/server/notifications/index.ts": "import { db } from '$lib/server/db';\nimport { notifications } from './schema';\nimport { desc, eq, and, isNull } from 'drizzle-orm';\n\n/**\n * SvelteForge notifications (#230) — persistent business notifications.\n *\n * await notifications.create({\n * userId,\n * type: 'export.ready',\n * title: 'Export terminé',\n * message: 'Votre export est prêt.',\n * actionUrl: `/exports/${exportId}`\n * });\n * await notifications.markAsRead(id, userId);\n * await notifications.markAllAsRead(userId);\n *\n * The DB is the source of truth. If @svforge/realtime is installed, publish\n * `notification.created` on `user:{userId}` after persist (optional).\n */\n\nexport interface NotificationInput {\n\tuserId: string;\n\ttype: string;\n\ttitle: string;\n\tmessage: string;\n\tactionUrl?: string;\n\tmetadata?: Record;\n}\n\nexport interface NotificationListOptions {\n\tlimit?: number;\n\toffset?: number;\n\tonlyUnread?: boolean;\n}\n\nexport const notificationsApi = {\n\t/** Create a notification for a user. */\n\tasync create(input: NotificationInput) {\n\t\tconst [row] = await db\n\t\t\t.insert(notifications)\n\t\t\t.values({\n\t\t\t\tuserId: input.userId,\n\t\t\t\ttype: input.type,\n\t\t\t\ttitle: input.title,\n\t\t\t\tmessage: input.message,\n\t\t\t\tactionUrl: input.actionUrl ?? null,\n\t\t\t\tmetadata: input.metadata ?? {},\n\t\t\t\treadAt: null,\n\t\t\t\tcreatedAt: new Date()\n\t\t\t})\n\t\t\t.returning();\n\t\treturn row;\n\t},\n\n\t/** Recent notifications for a user (newest first). */\n\tasync list(userId: string, opts: NotificationListOptions = {}) {\n\t\tconst conditions = [eq(notifications.userId, userId)];\n\t\tif (opts.onlyUnread) conditions.push(isNull(notifications.readAt));\n\t\treturn db\n\t\t\t.select()\n\t\t\t.from(notifications)\n\t\t\t.where(and(...conditions))\n\t\t\t.orderBy(desc(notifications.createdAt))\n\t\t\t.limit(opts.limit ?? 20)\n\t\t\t.offset(opts.offset ?? 0);\n\t},\n\n\t/** Count of unread notifications for a user. */\n\tasync unreadCount(userId: string): Promise {\n\t\tconst rows = await db\n\t\t\t.select({ id: notifications.id })\n\t\t\t.from(notifications)\n\t\t\t.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));\n\t\treturn rows.length;\n\t},\n\n\t/** Mark one notification as read (ownership-checked). */\n\tasync markAsRead(notificationId: string, userId: string) {\n\t\tawait db\n\t\t\t.update(notifications)\n\t\t\t.set({ readAt: new Date() })\n\t\t\t.where(and(eq(notifications.id, notificationId), eq(notifications.userId, userId)));\n\t},\n\n\t/** Mark all notifications of a user as read. */\n\tasync markAllAsRead(userId: string) {\n\t\tawait db\n\t\t\t.update(notifications)\n\t\t\t.set({ readAt: new Date() })\n\t\t\t.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));\n\t}\n};\n", + "/lib/server/notifications/index.ts": "import { db } from '#lib/server/db/index.js';\nimport { notifications } from './schema';\nimport { desc, eq, and, isNull } from 'drizzle-orm';\n\n/**\n * SvelteForge notifications (#230) — persistent business notifications.\n *\n * await notifications.create({\n * userId,\n * type: 'export.ready',\n * title: 'Export terminé',\n * message: 'Votre export est prêt.',\n * actionUrl: `/exports/${exportId}`\n * });\n * await notifications.markAsRead(id, userId);\n * await notifications.markAllAsRead(userId);\n *\n * The DB is the source of truth. If @svforge/realtime is installed, publish\n * `notification.created` on `user:{userId}` after persist (optional).\n */\n\nexport interface NotificationInput {\n\tuserId: string;\n\ttype: string;\n\ttitle: string;\n\tmessage: string;\n\tactionUrl?: string;\n\tmetadata?: Record;\n}\n\nexport interface NotificationListOptions {\n\tlimit?: number;\n\toffset?: number;\n\tonlyUnread?: boolean;\n}\n\nexport const notificationsApi = {\n\t/** Create a notification for a user. */\n\tasync create(input: NotificationInput) {\n\t\tconst [row] = await db\n\t\t\t.insert(notifications)\n\t\t\t.values({\n\t\t\t\tuserId: input.userId,\n\t\t\t\ttype: input.type,\n\t\t\t\ttitle: input.title,\n\t\t\t\tmessage: input.message,\n\t\t\t\tactionUrl: input.actionUrl ?? null,\n\t\t\t\tmetadata: input.metadata ?? {},\n\t\t\t\treadAt: null,\n\t\t\t\tcreatedAt: new Date()\n\t\t\t})\n\t\t\t.returning();\n\t\treturn row;\n\t},\n\n\t/** Recent notifications for a user (newest first). */\n\tasync list(userId: string, opts: NotificationListOptions = {}) {\n\t\tconst conditions = [eq(notifications.userId, userId)];\n\t\tif (opts.onlyUnread) conditions.push(isNull(notifications.readAt));\n\t\treturn db\n\t\t\t.select()\n\t\t\t.from(notifications)\n\t\t\t.where(and(...conditions))\n\t\t\t.orderBy(desc(notifications.createdAt))\n\t\t\t.limit(opts.limit ?? 20)\n\t\t\t.offset(opts.offset ?? 0);\n\t},\n\n\t/** Count of unread notifications for a user. */\n\tasync unreadCount(userId: string): Promise {\n\t\tconst rows = await db\n\t\t\t.select({ id: notifications.id })\n\t\t\t.from(notifications)\n\t\t\t.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));\n\t\treturn rows.length;\n\t},\n\n\t/** Mark one notification as read (ownership-checked). */\n\tasync markAsRead(notificationId: string, userId: string) {\n\t\tawait db\n\t\t\t.update(notifications)\n\t\t\t.set({ readAt: new Date() })\n\t\t\t.where(and(eq(notifications.id, notificationId), eq(notifications.userId, userId)));\n\t},\n\n\t/** Mark all notifications of a user as read. */\n\tasync markAllAsRead(userId: string) {\n\t\tawait db\n\t\t\t.update(notifications)\n\t\t\t.set({ readAt: new Date() })\n\t\t\t.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));\n\t}\n};\n", "/lib/server/notifications/schema.ts": "import { pgTable, uuid, text, jsonb, timestamp } from 'drizzle-orm/pg-core';\n\n/**\n * Notification schema (#230) — persistent business notifications.\n * Distinct from ui_toast: a notification is USER DATA with history and\n * read/unread state. `message` is plain text (no arbitrary HTML in v1).\n */\nexport const notifications = pgTable('notifications', {\n\tid: uuid('id').primaryKey().defaultRandom(),\n\tuserId: text('user_id').notNull(),\n\ttype: text('type').notNull(),\n\ttitle: text('title').notNull(),\n\tmessage: text('message').notNull(),\n\tactionUrl: text('action_url'),\n\tmetadata: jsonb('metadata').$type>(),\n\treadAt: timestamp('read_at', { withTimezone: true }),\n\tcreatedAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()\n});\n", - "/routes/api/notifications/read-all/+server.ts": "import { json, error } from '@sveltejs/kit';\nimport { notificationsApi } from '$lib/server/notifications';\nimport type { RequestHandler } from './$types';\n\n/** Mark all notifications of the current user as read. */\nexport const POST: RequestHandler = async ({ locals }) => {\n\tif (!locals.user) throw error(401, { message: 'Authentication required' });\n\tawait notificationsApi.markAllAsRead(locals.user.id);\n\treturn json({ ok: true });\n};\n" + "/routes/api/notifications/read-all/+server.ts": "import { json, error } from '@sveltejs/kit';\nimport { notificationsApi } from '#lib/server/notifications/index.js';\nimport type { RequestHandler } from './$types';\n\n/** Mark all notifications of the current user as read. */\nexport const POST: RequestHandler = async ({ locals }) => {\n\tif (!locals.user) throw error(401, { message: 'Authentication required' });\n\tawait notificationsApi.markAllAsRead(locals.user.id);\n\treturn json({ ok: true });\n};\n" } }, "oauth": { @@ -147,7 +147,7 @@ export const MODULE_RECIPE_DATA: Record = { "version": "2.2.0", "dependencies": [], "files": { - "/lib/components/svforge/ui/OAuthButtons.svelte": "\n\n
\n signIn('google')}\n disabled={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'google'}\n \n {:else}\n \n {/if}\n Google\n \n\n signIn('github')}\n disabled={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'github'}\n \n {:else}\n \n {/if}\n GitHub\n \n
\n" + "/lib/components/svforge/ui/OAuthButtons.svelte": "\n\n
\n signIn('google')}\n disabled={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'google'}\n \n {:else}\n \n {/if}\n Google\n \n\n signIn('github')}\n disabled={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'github'}\n \n {:else}\n \n {/if}\n GitHub\n \n
\n" } }, "realtime": { @@ -168,8 +168,8 @@ export const MODULE_RECIPE_DATA: Record = { ], "files": { "/lib/realtime/client.ts": "/**\n * SvelteForge realtime client (#229) — reusable Svelte-side WebSocket client\n * with automatic reconnection (backoff) and typed event envelopes.\n *\n * const rt = createRealtimeClient('/api/realtime');\n * const unsub = rt.subscribe('organization:1', 'punch.created', (payload) => {\n * invalidate('app:punches');\n * });\n * onDestroy(() => unsub()); // and/or rt.close()\n */\n\nexport interface RealtimeEvent {\n\tchannel: string;\n\tevent: string;\n\tpayload: T;\n}\n\ninterface ClientOptions {\n\t/** Reconnect delay base in ms (exponential backoff). */\n\tbackoffMs?: number;\n\t/** Maximum backoff in ms. */\n\tmaxBackoffMs?: number;\n\t/** Auto-resubscribe to channels after reconnect. */\n\tresubscribe?: boolean;\n}\n\ntype Handler = (payload: unknown, event: string) => void;\n\nexport function createRealtimeClient(url: string, options: ClientOptions = {}) {\n\tconst { backoffMs = 1000, maxBackoffMs = 30000, resubscribe = true } = options;\n\tlet ws: WebSocket | null = null;\n\tlet attempt = 0;\n\tlet reconnectTimer: ReturnType | null = null;\n\tlet closed = false;\n\n\tconst handlers = new Map>();\n\t/** Channels with at least one handler — the only ones resubscribed on reconnect. */\n\tconst channelRefs = new Map();\n\n\tfunction sendSubscribe(channel: string): void {\n\t\t// Only send on an open socket: the native WebSocket throws while\n\t\t// CONNECTING, and subscriptions registered before open are replayed\n\t\t// from onopen (resubscribe loop).\n\t\tif (ws && ws.readyState === WebSocket.OPEN) {\n\t\t\tws.send(JSON.stringify({ type: 'subscribe', channel }));\n\t\t}\n\t}\n\n\tfunction sendUnsubscribe(channel: string): void {\n\t\tif (ws && ws.readyState === WebSocket.OPEN) {\n\t\t\tws.send(JSON.stringify({ type: 'unsubscribe', channel }));\n\t\t}\n\t}\n\n\tfunction connect() {\n\t\tif (closed) return;\n\t\tws = new WebSocket(url);\n\t\tws.onopen = () => {\n\t\t\tattempt = 0;\n\t\t\tif (resubscribe) {\n\t\t\t\t// Only channels that still have handlers (#264): a channel whose\n\t\t\t\t// last handler was removed is never resubscribed.\n\t\t\t\tfor (const channel of channelRefs.keys()) sendSubscribe(channel);\n\t\t\t}\n\t\t};\n\t\tws.onmessage = (e) => {\n\t\t\ttry {\n\t\t\t\tconst msg = JSON.parse(String(e.data));\n\t\t\t\tif (msg.type !== 'event' || !msg.channel || !msg.event) return;\n\t\t\t\tconst key = `${msg.channel}:${msg.event}`;\n\t\t\t\tconst set = handlers.get(key);\n\t\t\t\tif (set) for (const fn of set) fn(msg.payload, msg.event);\n\t\t\t} catch {\n\t\t\t\t// ignore malformed frames\n\t\t\t}\n\t\t};\n\t\tws.onclose = () => {\n\t\t\tif (closed) return;\n\t\t\tattempt++;\n\t\t\tconst delay = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);\n\t\t\treconnectTimer = setTimeout(connect, delay);\n\t\t};\n\t\tws.onerror = () => ws?.close();\n\t}\n\n\t/**\n\t * Subscribe to (channel, event). Returns an unsubscribe function that\n\t * removes this handler; when the channel's last handler disappears the\n\t * client stops tracking it and tells the server to unsubscribe (#264).\n\t */\n\tfunction subscribe(channel: string, event: string, handler: (payload: T, event: string) => void): () => void {\n\t\tconst key = `${channel}:${event}`;\n\t\tif (!handlers.has(key)) handlers.set(key, new Set());\n\t\thandlers.get(key)!.add(handler as Handler);\n\t\tconst refs = channelRefs.get(channel) ?? 0;\n\t\tchannelRefs.set(channel, refs + 1);\n\t\tif (refs === 0) sendSubscribe(channel);\n\t\treturn () => {\n\t\t\tconst set = handlers.get(key);\n\t\t\tif (!set?.delete(handler as Handler)) return;\n\t\t\tif (set.size === 0) handlers.delete(key);\n\t\t\tconst remaining = (channelRefs.get(channel) ?? 1) - 1;\n\t\t\tif (remaining <= 0) {\n\t\t\t\tchannelRefs.delete(channel);\n\t\t\t\tsendUnsubscribe(channel);\n\t\t\t} else {\n\t\t\t\tchannelRefs.set(channel, remaining);\n\t\t\t}\n\t\t};\n\t}\n\n\t/** Remove every handler of a channel and unsubscribe it from the server. */\n\tfunction unsubscribe(channel: string): void {\n\t\tchannelRefs.delete(channel);\n\t\tfor (const [key, set] of handlers) {\n\t\t\tif (key.startsWith(`${channel}:`)) handlers.delete(key);\n\t\t}\n\t\tsendUnsubscribe(channel);\n\t}\n\n\tfunction close(): void {\n\t\tclosed = true;\n\t\tif (reconnectTimer) clearTimeout(reconnectTimer);\n\t\tws?.close();\n\t\thandlers.clear();\n\t\tchannelRefs.clear();\n\t}\n\n\tconnect();\n\n\treturn { subscribe, unsubscribe, close };\n}\n", - "/lib/server/realtime/hub.ts": "import { WebSocketServer, WebSocket } from 'ws';\nimport type { Server } from 'node:http';\nimport type { IncomingMessage } from 'node:http';\n\n/**\n * SvelteForge realtime transport (#229) — generic WebSocket hub.\n *\n * The business layer never depends on the WS implementation: it publishes and\n * subscribes through this stable API. Channels are isolated by an authorize\n * callback so a user cannot subscribe to a scope they may not read.\n *\n * Secure by default (#264): without an `authorize` callback every subscription\n * is refused (deny-all). Open channels explicitly, e.g. `authorize: () => true`\n * for development or a per-channel rule in production.\n */\n\nexport interface RealtimeEvent {\n\tchannel: string;\n\tevent: string;\n\tpayload: T;\n}\n\nexport interface RealtimeClient {\n\tuserId?: string;\n\tchannels: Set;\n\tpendingChannels: Set;\n\t/** Generation tokens prevent an old async authorization from winning a retry. */\n\tpendingSubscriptions: Map;\n\tsubscriptionTimestamps: number[];\n}\n\nexport interface RealtimeServerOptions {\n\t/** Maximum incoming frame size in bytes (default 16 KiB). */\n\tmaxFrameBytes?: number;\n\t/** Maximum authorized channels retained by one connection (default 50). */\n\tmaxChannelsPerClient?: number;\n\t/** Maximum subscribe requests per connection per minute (default 60). */\n\tmaxSubscriptionsPerMinute?: number;\n\t/** Extract the authenticated user id from the upgrade request. */\n\tauthenticate?: (req: IncomingMessage) => Promise | string | null | undefined;\n\t/**\n\t * Authorize a subscription: return false to refuse a channel.\n\t * Defaults to deny-all when not provided (#264) — a hub never accepts a\n\t * channel it was not explicitly told to accept.\n\t */\n\tauthorize?: (userId: string | undefined, channel: string) => boolean | Promise;\n}\n\ntype SocketEntry = { ws: WebSocket; client: RealtimeClient };\n\n/**\n * The realtime hub. Instantiate once in `$lib/server/realtime/index.ts` —\n * prefer the `createRealtimeHub` factory below.\n */\nexport class RealtimeHub {\n\tprivate wss: WebSocketServer | null = null;\n\tprivate sockets = new Map();\n\tprivate options: Required> & RealtimeServerOptions;\n\n\tconstructor(options: RealtimeServerOptions = {}) {\n\t\tconst positiveInteger = (value: number | undefined, fallback: number) =>\n\t\t\tNumber.isFinite(value) && value! > 0 ? Math.floor(value!) : fallback;\n\t\tthis.options = {\n\t\t\t...options,\n\t\t\tmaxFrameBytes: positiveInteger(options.maxFrameBytes, 16 * 1024),\n\t\t\tmaxChannelsPerClient: positiveInteger(options.maxChannelsPerClient, 50),\n\t\t\tmaxSubscriptionsPerMinute: positiveInteger(options.maxSubscriptionsPerMinute, 60)\n\t\t};\n\t}\n\n\t/** Attach the WS server to an HTTP server (e.g. adapter-node customServer). */\n\tattach(server: Server): void {\n\t\tthis.wss = new WebSocketServer({ server, path: '/api/realtime', maxPayload: this.options.maxFrameBytes });\n\t\tthis.wss.on('connection', (ws, req) => this.handleConnection(ws, req));\n\t}\n\n\t/** Start a standalone WS server on its own port (portable, no adapter hook). */\n\tlisten(port: number, host = '0.0.0.0'): Promise {\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.wss = new WebSocketServer({ port, host, path: '/api/realtime', maxPayload: this.options.maxFrameBytes });\n\t\t\tthis.wss.on('connection', (ws, req) => this.handleConnection(ws, req));\n\t\t\tthis.wss.on('listening', () => resolve());\n\t\t});\n\t}\n\n\tprivate async handleConnection(ws: WebSocket, req: IncomingMessage): Promise {\n\t\tlet userId: string | undefined;\n\t\ttry {\n\t\t\tuserId = this.options.authenticate ? ((await this.options.authenticate(req)) ?? undefined) : undefined;\n\t\t} catch {\n\t\t\tws.close(1011, 'authentication failed');\n\t\t\treturn;\n\t\t}\n\t\tconst client: RealtimeClient = {\n\t\t\tuserId,\n\t\t\tchannels: new Set(),\n\t\t\tpendingChannels: new Set(),\n\t\t\tpendingSubscriptions: new Map(),\n\t\t\tsubscriptionTimestamps: []\n\t\t};\n\t\tthis.sockets.set(ws, { ws, client });\n\n\t\tws.on('message', (data) => {\n\t\t\ttry {\n\t\t\t\tconst msg = JSON.parse(data.toString());\n\t\t\t\tif (msg.type === 'subscribe' && typeof msg.channel === 'string') {\n\t\t\t\t\tthis.subscribe(ws, client, msg.channel);\n\t\t\t\t} else if (msg.type === 'unsubscribe' && typeof msg.channel === 'string') {\n\t\t\t\t\tclient.channels.delete(msg.channel);\n\t\t\t\t\t// Invalidate an authorization that is still in flight. A retry may\n\t\t\t\t\t// start immediately with a new generation token.\n\t\t\t\t\tclient.pendingChannels.delete(msg.channel);\n\t\t\t\t\tclient.pendingSubscriptions.delete(msg.channel);\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// ignore malformed frames\n\t\t\t}\n\t\t});\n\n\t\tws.on('close', () => this.sockets.delete(ws));\n\t\tws.on('error', () => this.sockets.delete(ws));\n\t}\n\n\tprivate async subscribe(ws: WebSocket, client: RealtimeClient, channel: string): Promise {\n\t\tconst now = Date.now();\n\t\tclient.subscriptionTimestamps = client.subscriptionTimestamps.filter((timestamp) => now - timestamp < 60_000);\n\t\tif (client.subscriptionTimestamps.length >= this.options.maxSubscriptionsPerMinute) {\n\t\t\tws.send(JSON.stringify({ type: 'error', channel, error: 'rate_limit' }));\n\t\t\treturn;\n\t\t}\n\t\tclient.subscriptionTimestamps.push(now);\n\t\tif (client.channels.has(channel) || client.pendingChannels.has(channel)) return;\n\t\tif (client.channels.size + client.pendingChannels.size >= this.options.maxChannelsPerClient) {\n\t\t\tws.send(JSON.stringify({ type: 'error', channel, error: 'channel_limit' }));\n\t\t\treturn;\n\t\t}\n\n\t\t// Reserve before awaiting authorization: concurrent async checks cannot\n\t\t// exceed the channel cap, and an unsubscribe during authorization wins.\n\t\tclient.pendingChannels.add(channel);\n\t\tconst generation = Symbol(channel);\n\t\tclient.pendingSubscriptions.set(channel, generation);\n\t\tconst authorize = this.options.authorize ?? (() => false);\n\t\tlet authorized = false;\n\t\ttry {\n\t\t\tauthorized = await authorize(client.userId, channel);\n\t\t} catch {\n\t\t\t// Authorization failures are treated as a refusal, never as an\n\t\t\t// implicit allow or an unhandled rejection.\n\t\t}\n\t\tif (client.pendingSubscriptions.get(channel) !== generation || !this.sockets.has(ws)) return;\n\t\tclient.pendingChannels.delete(channel);\n\t\tclient.pendingSubscriptions.delete(channel);\n\t\tif (!authorized || client.channels.has(channel)) {\n\t\t\tif (!authorized && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'error', channel, error: 'unauthorized' }));\n\t\t\treturn;\n\t\t}\n\t\tif (client.channels.size >= this.options.maxChannelsPerClient) {\n\t\t\tif (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'error', channel, error: 'channel_limit' }));\n\t\t\treturn;\n\t\t}\n\t\tclient.channels.add(channel);\n\t\tif (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'subscribed', channel }));\n\t}\n\n\t/**\n\t * Publish an event to all sockets subscribed to the channel (#264): the\n\t * single canonical contract is the object form, identical to the envelope\n\t * the client receives.\n\t *\n\t * await realtime.publish({ channel: `org:${orgId}`, event: 'punch.created', payload: { punchId } });\n\t */\n\tpublish(event: RealtimeEvent): void {\n\t\tconst msg = JSON.stringify({ type: 'event', ...event });\n\t\tfor (const { ws, client } of this.sockets.values()) {\n\t\t\tif (client.channels.has(event.channel)) {\n\t\t\t\tws.send(msg);\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Number of connected sockets. */\n\tget connectionCount(): number {\n\t\treturn this.sockets.size;\n\t}\n\n\tclose(): void {\n\t\tfor (const { ws } of this.sockets.values()) ws.close();\n\t\tthis.sockets.clear();\n\t\tthis.wss?.close();\n\t}\n}\n\n/**\n * Factory for the shared hub (#264) — configure auth at creation time instead\n * of mutating a read-only-looking instance:\n *\n * export const realtime = createRealtimeHub({\n * authenticate: async (req) => req.headers['x-user-id'] as string | undefined,\n * authorize: (userId, channel) => userId != null && channel === `org:${userId}`\n * });\n */\nexport function createRealtimeHub(options: RealtimeServerOptions = {}): RealtimeHub {\n\treturn new RealtimeHub(options);\n}\n", - "/lib/server/realtime/index.ts": "import { createRealtimeHub } from './hub';\n\n/**\n * Shared realtime hub instance — import from anywhere server-side:\n * import { realtime } from '$lib/server/realtime';\n * await realtime.publish({ channel, event, payload });\n *\n * Secure by default (#264): without an `authorize` callback every subscription\n * is refused. Configure your auth policy in this file when the app has one:\n *\n * export const realtime = createRealtimeHub({\n * authenticate: async (req) => req.headers['x-user-id'] as string | undefined,\n * authorize: (userId, channel) =>\n * userId != null && (channel === `org:${userId}` || channel.startsWith('public:'))\n * });\n */\nexport const realtime = createRealtimeHub();\n\nexport type { RealtimeEvent, RealtimeServerOptions } from './hub';\n" + "/lib/server/realtime/hub.ts": "import { WebSocketServer, WebSocket } from 'ws';\nimport type { Server } from 'node:http';\nimport type { IncomingMessage } from 'node:http';\n\n/**\n * SvelteForge realtime transport (#229) — generic WebSocket hub.\n *\n * The business layer never depends on the WS implementation: it publishes and\n * subscribes through this stable API. Channels are isolated by an authorize\n * callback so a user cannot subscribe to a scope they may not read.\n *\n * Secure by default (#264): without an `authorize` callback every subscription\n * is refused (deny-all). Open channels explicitly, e.g. `authorize: () => true`\n * for development or a per-channel rule in production.\n */\n\nexport interface RealtimeEvent {\n\tchannel: string;\n\tevent: string;\n\tpayload: T;\n}\n\nexport interface RealtimeClient {\n\tuserId?: string;\n\tchannels: Set;\n\tpendingChannels: Set;\n\t/** Generation tokens prevent an old async authorization from winning a retry. */\n\tpendingSubscriptions: Map;\n\tsubscriptionTimestamps: number[];\n}\n\nexport interface RealtimeServerOptions {\n\t/** Maximum incoming frame size in bytes (default 16 KiB). */\n\tmaxFrameBytes?: number;\n\t/** Maximum authorized channels retained by one connection (default 50). */\n\tmaxChannelsPerClient?: number;\n\t/** Maximum subscribe requests per connection per minute (default 60). */\n\tmaxSubscriptionsPerMinute?: number;\n\t/** Extract the authenticated user id from the upgrade request. */\n\tauthenticate?: (req: IncomingMessage) => Promise | string | null | undefined;\n\t/**\n\t * Authorize a subscription: return false to refuse a channel.\n\t * Defaults to deny-all when not provided (#264) — a hub never accepts a\n\t * channel it was not explicitly told to accept.\n\t */\n\tauthorize?: (userId: string | undefined, channel: string) => boolean | Promise;\n}\n\ntype SocketEntry = { ws: WebSocket; client: RealtimeClient };\n\n/**\n * The realtime hub. Instantiate once in `#lib/server/realtime/index.js` —\n * prefer the `createRealtimeHub` factory below.\n */\nexport class RealtimeHub {\n\tprivate wss: WebSocketServer | null = null;\n\tprivate sockets = new Map();\n\tprivate options: Required> & RealtimeServerOptions;\n\n\tconstructor(options: RealtimeServerOptions = {}) {\n\t\tconst positiveInteger = (value: number | undefined, fallback: number) =>\n\t\t\tNumber.isFinite(value) && value! > 0 ? Math.floor(value!) : fallback;\n\t\tthis.options = {\n\t\t\t...options,\n\t\t\tmaxFrameBytes: positiveInteger(options.maxFrameBytes, 16 * 1024),\n\t\t\tmaxChannelsPerClient: positiveInteger(options.maxChannelsPerClient, 50),\n\t\t\tmaxSubscriptionsPerMinute: positiveInteger(options.maxSubscriptionsPerMinute, 60)\n\t\t};\n\t}\n\n\t/** Attach the WS server to an HTTP server (e.g. adapter-node customServer). */\n\tattach(server: Server): void {\n\t\tthis.wss = new WebSocketServer({ server, path: '/api/realtime', maxPayload: this.options.maxFrameBytes });\n\t\tthis.wss.on('connection', (ws, req) => this.handleConnection(ws, req));\n\t}\n\n\t/** Start a standalone WS server on its own port (portable, no adapter hook). */\n\tlisten(port: number, host = '0.0.0.0'): Promise {\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.wss = new WebSocketServer({ port, host, path: '/api/realtime', maxPayload: this.options.maxFrameBytes });\n\t\t\tthis.wss.on('connection', (ws, req) => this.handleConnection(ws, req));\n\t\t\tthis.wss.on('listening', () => resolve());\n\t\t});\n\t}\n\n\tprivate async handleConnection(ws: WebSocket, req: IncomingMessage): Promise {\n\t\tlet userId: string | undefined;\n\t\ttry {\n\t\t\tuserId = this.options.authenticate ? ((await this.options.authenticate(req)) ?? undefined) : undefined;\n\t\t} catch {\n\t\t\tws.close(1011, 'authentication failed');\n\t\t\treturn;\n\t\t}\n\t\tconst client: RealtimeClient = {\n\t\t\tuserId,\n\t\t\tchannels: new Set(),\n\t\t\tpendingChannels: new Set(),\n\t\t\tpendingSubscriptions: new Map(),\n\t\t\tsubscriptionTimestamps: []\n\t\t};\n\t\tthis.sockets.set(ws, { ws, client });\n\n\t\tws.on('message', (data) => {\n\t\t\ttry {\n\t\t\t\tconst msg = JSON.parse(data.toString());\n\t\t\t\tif (msg.type === 'subscribe' && typeof msg.channel === 'string') {\n\t\t\t\t\tthis.subscribe(ws, client, msg.channel);\n\t\t\t\t} else if (msg.type === 'unsubscribe' && typeof msg.channel === 'string') {\n\t\t\t\t\tclient.channels.delete(msg.channel);\n\t\t\t\t\t// Invalidate an authorization that is still in flight. A retry may\n\t\t\t\t\t// start immediately with a new generation token.\n\t\t\t\t\tclient.pendingChannels.delete(msg.channel);\n\t\t\t\t\tclient.pendingSubscriptions.delete(msg.channel);\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// ignore malformed frames\n\t\t\t}\n\t\t});\n\n\t\tws.on('close', () => this.sockets.delete(ws));\n\t\tws.on('error', () => this.sockets.delete(ws));\n\t}\n\n\tprivate async subscribe(ws: WebSocket, client: RealtimeClient, channel: string): Promise {\n\t\tconst now = Date.now();\n\t\tclient.subscriptionTimestamps = client.subscriptionTimestamps.filter((timestamp) => now - timestamp < 60_000);\n\t\tif (client.subscriptionTimestamps.length >= this.options.maxSubscriptionsPerMinute) {\n\t\t\tws.send(JSON.stringify({ type: 'error', channel, error: 'rate_limit' }));\n\t\t\treturn;\n\t\t}\n\t\tclient.subscriptionTimestamps.push(now);\n\t\tif (client.channels.has(channel) || client.pendingChannels.has(channel)) return;\n\t\tif (client.channels.size + client.pendingChannels.size >= this.options.maxChannelsPerClient) {\n\t\t\tws.send(JSON.stringify({ type: 'error', channel, error: 'channel_limit' }));\n\t\t\treturn;\n\t\t}\n\n\t\t// Reserve before awaiting authorization: concurrent async checks cannot\n\t\t// exceed the channel cap, and an unsubscribe during authorization wins.\n\t\tclient.pendingChannels.add(channel);\n\t\tconst generation = Symbol(channel);\n\t\tclient.pendingSubscriptions.set(channel, generation);\n\t\tconst authorize = this.options.authorize ?? (() => false);\n\t\tlet authorized = false;\n\t\ttry {\n\t\t\tauthorized = await authorize(client.userId, channel);\n\t\t} catch {\n\t\t\t// Authorization failures are treated as a refusal, never as an\n\t\t\t// implicit allow or an unhandled rejection.\n\t\t}\n\t\tif (client.pendingSubscriptions.get(channel) !== generation || !this.sockets.has(ws)) return;\n\t\tclient.pendingChannels.delete(channel);\n\t\tclient.pendingSubscriptions.delete(channel);\n\t\tif (!authorized || client.channels.has(channel)) {\n\t\t\tif (!authorized && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'error', channel, error: 'unauthorized' }));\n\t\t\treturn;\n\t\t}\n\t\tif (client.channels.size >= this.options.maxChannelsPerClient) {\n\t\t\tif (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'error', channel, error: 'channel_limit' }));\n\t\t\treturn;\n\t\t}\n\t\tclient.channels.add(channel);\n\t\tif (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'subscribed', channel }));\n\t}\n\n\t/**\n\t * Publish an event to all sockets subscribed to the channel (#264): the\n\t * single canonical contract is the object form, identical to the envelope\n\t * the client receives.\n\t *\n\t * await realtime.publish({ channel: `org:${orgId}`, event: 'punch.created', payload: { punchId } });\n\t */\n\tpublish(event: RealtimeEvent): void {\n\t\tconst msg = JSON.stringify({ type: 'event', ...event });\n\t\tfor (const { ws, client } of this.sockets.values()) {\n\t\t\tif (client.channels.has(event.channel)) {\n\t\t\t\tws.send(msg);\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Number of connected sockets. */\n\tget connectionCount(): number {\n\t\treturn this.sockets.size;\n\t}\n\n\tclose(): void {\n\t\tfor (const { ws } of this.sockets.values()) ws.close();\n\t\tthis.sockets.clear();\n\t\tthis.wss?.close();\n\t}\n}\n\n/**\n * Factory for the shared hub (#264) — configure auth at creation time instead\n * of mutating a read-only-looking instance:\n *\n * export const realtime = createRealtimeHub({\n * authenticate: async (req) => req.headers['x-user-id'] as string | undefined,\n * authorize: (userId, channel) => userId != null && channel === `org:${userId}`\n * });\n */\nexport function createRealtimeHub(options: RealtimeServerOptions = {}): RealtimeHub {\n\treturn new RealtimeHub(options);\n}\n", + "/lib/server/realtime/index.ts": "import { createRealtimeHub } from './hub';\n\n/**\n * Shared realtime hub instance — import from anywhere server-side:\n * import { realtime } from '#lib/server/realtime/index.js';\n * await realtime.publish({ channel, event, payload });\n *\n * Secure by default (#264): without an `authorize` callback every subscription\n * is refused. Configure your auth policy in this file when the app has one:\n *\n * export const realtime = createRealtimeHub({\n * authenticate: async (req) => req.headers['x-user-id'] as string | undefined,\n * authorize: (userId, channel) =>\n * userId != null && (channel === `org:${userId}` || channel.startsWith('public:'))\n * });\n */\nexport const realtime = createRealtimeHub();\n\nexport type { RealtimeEvent, RealtimeServerOptions } from './hub';\n" } }, "tiptap": { @@ -199,9 +199,9 @@ export const MODULE_RECIPE_DATA: Record = { } ], "files": { - "/lib/components/svforge/tiptap/TiptapEditor.svelte": "\n\n
\n\t\n\n\t
\n\t\t{#if loading}\n\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t{/if}\n\t
\n
\n", - "/lib/components/svforge/tiptap/TiptapPreview.svelte": "\n\n\n\t\n\t{@html renderedHtml}\n\n", - "/lib/components/svforge/tiptap/TiptapToolbar.svelte": "\n\n\n\t{#if loading}\n\t\t
\n\t\t\t
\n\t\t\t{m.tiptap_loading()}\n\t\t
\n\t{:else}\n\t\t handleFormatChange(value)}\n\t\t>\n\t\t\t{#each formatControls as control (control.value)}\n\t\t\t\t\n\t\t\t\t\t{control.label}\n\t\t\t\t\n\t\t\t{/each}\n\t\t\n\n\t\t handleHeadingChange(value)}\n\t\t>\n\t\t\t{#each headingControls as control (control.value)}\n\t\t\t\t\n\t\t\t\t\t{control.label}\n\t\t\t\t\n\t\t\t{/each}\n\t\t\n\n\t\t\n\t\t\t{#each listControls as control (control.value)}\n\t\t\t\t\n\t\t\t\t\t{control.label}\n\t\t\t\t\n\t\t\t{/each}\n\t\t\n\n\t\t\n\t\t\t{#each blockControls as control (control.value)}\n\t\t\t\t\n\t\t\t\t\t{control.label}\n\t\t\t\t\n\t\t\t{/each}\n\t\t\n\n\t\t{#snippet linkTrigger(attributes: HTMLButtonAttributes)}\n\t\t\t\n\t\t\t\t{m.tiptap_link()}\n\t\t\t\n\t\t{/snippet}\n\n\t\t document.getElementById(linkInputId)}\n\t\t\tcloseOnInteractOutside={true}\n\t\t\trestoreFocus={true}\n\t\t>\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{activeLink ? m.tiptap_edit_link() : m.tiptap_insert_link()}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{m.tiptap_link_description()}\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t (linkUrlError = '')}\n\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{#if activeLink}\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tonRemoveLink();\n\t\t\t\t\t\t\t\t\t\t\tlinkPopoverOpen = false;\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{m.tiptap_link_remove()}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{m.tiptap_link_cancel()}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t\n\t{/if}\n\n", + "/lib/components/svforge/tiptap/TiptapEditor.svelte": "\n\n
\n\t\n\n\t
\n\t\t{#if loading}\n\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t{/if}\n\t
\n
\n", + "/lib/components/svforge/tiptap/TiptapPreview.svelte": "\n\n\n\t\n\t{@html renderedHtml}\n\n", + "/lib/components/svforge/tiptap/TiptapToolbar.svelte": "\n\n\n\t{#if loading}\n\t\t
\n\t\t\t
\n\t\t\t{m.tiptap_loading()}\n\t\t
\n\t{:else}\n\t\t handleFormatChange(value)}\n\t\t>\n\t\t\t{#each formatControls as control (control.value)}\n\t\t\t\t\n\t\t\t\t\t{control.label}\n\t\t\t\t\n\t\t\t{/each}\n\t\t\n\n\t\t handleHeadingChange(value)}\n\t\t>\n\t\t\t{#each headingControls as control (control.value)}\n\t\t\t\t\n\t\t\t\t\t{control.label}\n\t\t\t\t\n\t\t\t{/each}\n\t\t\n\n\t\t\n\t\t\t{#each listControls as control (control.value)}\n\t\t\t\t\n\t\t\t\t\t{control.label}\n\t\t\t\t\n\t\t\t{/each}\n\t\t\n\n\t\t\n\t\t\t{#each blockControls as control (control.value)}\n\t\t\t\t\n\t\t\t\t\t{control.label}\n\t\t\t\t\n\t\t\t{/each}\n\t\t\n\n\t\t{#snippet linkTrigger(attributes: HTMLButtonAttributes)}\n\t\t\t\n\t\t\t\t{m.tiptap_link()}\n\t\t\t\n\t\t{/snippet}\n\n\t\t document.getElementById(linkInputId)}\n\t\t\tcloseOnInteractOutside={true}\n\t\t\trestoreFocus={true}\n\t\t>\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{activeLink ? m.tiptap_edit_link() : m.tiptap_insert_link()}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{m.tiptap_link_description()}\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t (linkUrlError = '')}\n\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{#if activeLink}\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tonRemoveLink();\n\t\t\t\t\t\t\t\t\t\t\tlinkPopoverOpen = false;\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{m.tiptap_link_remove()}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{m.tiptap_link_cancel()}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t\n\t{/if}\n\n", "/lib/components/svforge/tiptap/index.ts": "export { default as TiptapEditor } from './TiptapEditor.svelte';\nexport { default as TiptapPreview } from './TiptapPreview.svelte';\nexport { VisualHeading } from './tiptap-extensions';\n", "/lib/components/svforge/tiptap/link-actions.ts": "interface LinkCommandChain {\n focus(): LinkCommandChain;\n setLink(attributes: { href: string }): LinkCommandChain;\n unsetLink(): LinkCommandChain;\n run(): unknown;\n}\n\ninterface LinkEditor {\n chain(): LinkCommandChain;\n}\n\nexport function applyTiptapLink(\n editor: LinkEditor | null | undefined,\n href: string,\n): void {\n editor?.chain().focus().setLink({ href }).run();\n}\n\nexport function removeTiptapLink(editor: LinkEditor | null | undefined): void {\n editor?.chain().focus().unsetLink().run();\n}\n", "/lib/components/svforge/tiptap/render-tiptap.ts": "/**\n * Pure renderer/sanitizer for Tiptap JSON documents (#282).\n *\n * Every value coming from the Tiptap document is either escaped or strictly\n * allowlisted BEFORE it reaches the `{@html}` output of TiptapPreview:\n *\n * - text is HTML-escaped (`& < > \" '`)\n * - link hrefs are protocol-allowlisted (http/https/mailto/tel) and escaped\n * - link targets are allowlisted (`_blank` only)\n * - heading levels are clamped to the valid 1..6 range\n * - code block language tokens are allowlisted to `[a-zA-Z0-9_-]` before\n * interpolation inside a class attribute\n *\n * The renderer is a pure function so it can be tested behaviorally with\n * malicious payloads (no regex assertions on the .svelte source).\n */\n\nimport type { JSONContent } from '@tiptap/core';\n\n/** Escape HTML special characters to prevent injection. */\nexport function escapeHtml(text: string): string {\n\treturn text\n\t\t.replace(/&/g, '&')\n\t\t.replace(//g, '>')\n\t\t.replace(/\"/g, '"')\n\t\t.replace(/'/g, ''');\n}\n\n/** Allowed link protocols. Anything else is stripped to prevent javascript: URLs. */\nconst SAFE_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:'];\n\n/**\n * Whether an href uses a protocol or relative form accepted by the renderer (#294).\n */\nexport function isSafeHref(href: unknown): href is string {\n\tif (typeof href !== 'string') return false;\n\tconst value = href.trim();\n\tif (!value) return false;\n\n\tconst scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(value)?.[1]?.toLowerCase();\n\tif (scheme) return SAFE_PROTOCOLS.includes(`${scheme}:`);\n\treturn !value.startsWith('//');\n}\n\n/**\n * Sanitize an href and preserve safe RELATIVE URLs (#294).\n *\n * Forms accepted as-is (after escaping):\n * - safe absolute/protocol URLs: http:, https:, mailto:, tel:\n * - relative URLs: /path, ./path, ../path, ?query, #hash, plain names\n *\n * Forms REFUSED (→ '#'):\n * - any other scheme (javascript:, data:, vbscript:, …) whatever the case\n * - protocol-relative //host URLs (their scheme depends on the page and a\n * //evil.com link would navigate the browser to an attacker's origin)\n * - leading/trailing whitespace tricks (\" javascript:…\")\n *\n * Never uses `new URL(href, base)` for the OUTPUT — that would absolutize a\n * relative URL against a placeholder host (the #294 regression).\n */\nexport function sanitizeHref(href: unknown): string {\n\tif (!isSafeHref(href)) return '#';\n\treturn escapeHtml(href.trim());\n}\n\n/** Link target allowlist — only '_blank' is ever produced by the toolbar. */\nexport function sanitizeTarget(target: unknown): string {\n\treturn target === '_blank' ? '_blank' : '_blank';\n}\n\n/** Clamp a heading level to the valid 1..6 range (anything else → 1). */\nexport function clampHeadingLevel(level: unknown): number {\n\tif (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) {\n\t\treturn 1;\n\t}\n\treturn level;\n}\n\n/** Allowlist a code block language token for interpolation in a class attribute. */\nexport function sanitizeLanguage(language: unknown): string {\n\tif (typeof language !== 'string') return '';\n\tconst match = language.match(/^[a-zA-Z0-9_-]+$/);\n\treturn match ? match[0] : '';\n}\n\nfunction applyMarks(text: string, marks: JSONContent['marks']): string {\n\tif (!marks) return text;\n\treturn marks.reduce((acc, mark) => {\n\t\tswitch (mark.type) {\n\t\t\tcase 'bold':\n\t\t\t\treturn `${acc}`;\n\t\t\tcase 'italic':\n\t\t\t\treturn `${acc}`;\n\t\t\tcase 'underline':\n\t\t\t\treturn `${acc}`;\n\t\t\tcase 'strike':\n\t\t\t\treturn `${acc}`;\n\t\t\tcase 'code':\n\t\t\t\treturn `${acc}`;\n\t\t\tcase 'link': {\n\t\t\t\tconst href = sanitizeHref(mark.attrs?.href);\n\t\t\t\tconst target = sanitizeTarget(mark.attrs?.target);\n\t\t\t\treturn `${acc}`;\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\treturn acc;\n\t\t}\n\t}, text);\n}\n\nfunction renderNode(node: JSONContent): string {\n\tif (!node) return '';\n\n\tconst type = node.type;\n\tconst nodeContent = node.content || [];\n\tconst text = escapeHtml(node.text || '');\n\tconst marks = node.marks || [];\n\tconst children = (nodeContent || []).map((child) => renderNode(child)).join('');\n\n\tswitch (type) {\n\t\tcase 'doc':\n\t\t\treturn children;\n\t\tcase 'paragraph':\n\t\t\treturn `

${children || '
'}

`;\n\t\tcase 'heading': {\n\t\t\tconst level = clampHeadingLevel(node.attrs?.level);\n\t\t\treturn `${children}`;\n\t\t}\n\t\tcase 'bulletList':\n\t\t\treturn `
    ${children}
`;\n\t\tcase 'orderedList':\n\t\t\treturn `
    ${children}
`;\n\t\tcase 'listItem':\n\t\t\treturn `
  • ${children}
  • `;\n\t\tcase 'blockquote':\n\t\t\treturn `
    ${children}
    `;\n\t\tcase 'codeBlock': {\n\t\t\tconst language = sanitizeLanguage(node.attrs?.language);\n\t\t\treturn `
    ${children}
    `;\n\t\t}\n\t\tcase 'hardBreak':\n\t\t\treturn '
    ';\n\t\tcase 'horizontalRule':\n\t\t\treturn '
    ';\n\t\tcase 'text':\n\t\t\treturn applyMarks(text, marks);\n\t\tdefault:\n\t\t\treturn children || text;\n\t}\n}\n\n/** Render a Tiptap JSON document to sanitized HTML. */\nexport function renderTiptap(content: JSONContent | null | undefined): string {\n\tif (!content) return '';\n\treturn renderNode(content);\n}\n", @@ -247,10 +247,10 @@ export const MODULE_RECIPE_DATA: Record = { } ], "files": { - "/lib/components/svforge/uploads/FileUpload.svelte": "\n\n
    \n\t\n\t{#if uploading}{m.uploads_uploading()}{/if}\n\t{#if error}

    {error}

    {/if}\n
    \n", + "/lib/components/svforge/uploads/FileUpload.svelte": "\n\n
    \n\t\n\t{#if uploading}{m.uploads_uploading()}{/if}\n\t{#if error}

    {error}

    {/if}\n
    \n", "/lib/server/s3.ts": "import { S3Client } from '@aws-sdk/client-s3';\nimport * as env from '$app/env/private';\n\n/**\n * Create the S3 client lazily, on first use (#237).\n *\n * The module is evaluated by SvelteKit's postbuild analysis at `vite build`\n * time — a `throw` at the top level breaks any project that scaffolds this\n * addon before configuring S3 env vars. Env vars are read at call time, so a\n * fresh scaffold builds fine and fails only at runtime if S3 is actually used\n * without credentials.\n */\nlet cached: S3Client | undefined;\n\nexport function getS3(): S3Client {\n\tif (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {\n\t\tthrow new Error('S3 credentials are not set (S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY)');\n\t}\n\treturn (cached ??= new S3Client({\n\t\tregion: env.S3_REGION ?? 'auto',\n\t\tendpoint: env.S3_ENDPOINT,\n\t\t// All three credentials are checked above, so secretAccessKey is a\n\t\t// string here — the guard also satisfies the AWS SDK types (#284).\n\t\tcredentials: { accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_SECRET_ACCESS_KEY }\n\t}));\n}\n", "/lib/uploads/post-form.ts": "/**\n * The POST policy limits the complete multipart request, rather than only the\n * file part. Reserve a fixed envelope so an at-limit file produced here still\n * fits inside the 10 MiB POST policy.\n */\nexport const MAX_POST_BODY_SIZE = 10 * 1024 * 1024;\nexport const MAX_MULTIPART_FORM_OVERHEAD = 64 * 1024;\nexport const MAX_FILE_SIZE = MAX_POST_BODY_SIZE - MAX_MULTIPART_FORM_OVERHEAD;\n\n// Keep the variable part of the multipart envelope below the reserved space.\nconst MAX_POST_FIELDS = 16;\nconst MAX_POST_FIELD_BYTES = 16 * 1024;\nconst encoder = new TextEncoder();\n\n/**\n * Build the only multipart shape sent by FileUpload.\n *\n * A fixed file part name and bounded presign fields make the 64 KiB envelope\n * reservation above conservative across browser-generated multipart boundaries.\n */\nexport function createUploadForm(fields: Record, file: File): FormData {\n\tconst entries = Object.entries(fields);\n\tconst fieldBytes = entries.reduce((total, [name, value]) => total + encoder.encode(name).byteLength + encoder.encode(value).byteLength, 0);\n\tif (\n\t\tentries.length > MAX_POST_FIELDS ||\n\t\tentries.some(([, value]) => typeof value !== 'string') ||\n\t\tfieldBytes > MAX_POST_FIELD_BYTES\n\t) {\n\t\tthrow new Error('Upload form fields exceed the supported multipart envelope');\n\t}\n\n\tconst form = new FormData();\n\tfor (const [name, value] of entries) form.append(name, value);\n\t// Never serialize the user-controlled filename into the multipart headers.\n\tform.append('file', file, 'upload');\n\treturn form;\n}\n", - "/routes/api/upload/+server.ts": "import { PutObjectCommand } from '@aws-sdk/client-s3';\nimport { createPresignedPost } from '@aws-sdk/s3-presigned-post';\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner';\nimport { getS3 } from '$lib/server/s3';\nimport { MAX_FILE_SIZE, MAX_POST_BODY_SIZE } from '$lib/uploads/post-form';\nimport * as env from '$app/env/private';\nimport { json, error } from '@sveltejs/kit';\nimport type { RequestHandler } from './$types';\n\n/**\n * The S3 POST policy applies to the complete multipart request. FileUpload\n * reserves a bounded 64 KiB envelope, so this is the largest file that its\n * at-limit multipart form can submit under the 10 MiB request policy.\n */\n\n/**\n * Upload policy selected by the deployment.\n *\n * `presigned-post` is the default hard-limit mode. S3-compatible providers\n * that implement POST policies enforce content-length-range before storing an\n * object. Set S3_UPLOAD_SIZE_POLICY=presigned-put only for providers without\n * POST-policy support: it validates the declared size before signing, but is\n * explicitly best-effort because a holder of the PUT URL can upload more.\n */\nfunction getSizePolicy(): 'best-effort' | 'storage-enforced' {\n\treturn env.S3_UPLOAD_SIZE_POLICY === 'presigned-put' ? 'best-effort' : 'storage-enforced';\n}\n\n/**\n * Allowed MIME types for uploads. SVG is deliberately EXCLUDED: serving an\n * untrusted SVG from the app origin can result in stored XSS.\n */\nconst ALLOWED_MIME_TYPES = [\n\t'image/jpeg',\n\t'image/png',\n\t'image/gif',\n\t'image/webp',\n\t'application/pdf',\n\t'text/plain',\n\t'application/json'\n] as const;\n\nfunction sanitizeFilename(name: string): string {\n\tconst cleaned = name.replace(/[/\\\\]/g, '').replace(/\\.\\.+/g, '.');\n\tconst trimmed = cleaned.trim().replace(/\\s+/g, '-').slice(0, 100);\n\treturn trimmed || 'file';\n}\n\nexport const POST: RequestHandler = async ({ request, locals }) => {\n\tif (!locals.user) throw error(401, { message: 'Authentication required' });\n\n\t// Error contract: stable machine codes under `error.code` — never raw\n\t// provider/server strings. FileUpload maps ONLY these known codes to\n\t// localized copy; any other payload keeps the client-side generic fallback.\n\tlet filename: string;\n\tlet contentType: string;\n\tlet size: number;\n\ttry {\n\t\tconst body = await request.json();\n\t\tfilename = body.filename;\n\t\tcontentType = body.contentType;\n\t\tsize = body.size;\n\t} catch {\n\t\treturn json({ error: { code: 'invalid_json' } }, { status: 400 });\n\t}\n\n\tif (!filename || typeof filename !== 'string') return json({ error: { code: 'filename_required' } }, { status: 400 });\n\tif (!contentType || typeof contentType !== 'string') return json({ error: { code: 'content_type_required' } }, { status: 400 });\n\tif (!ALLOWED_MIME_TYPES.includes(contentType as (typeof ALLOWED_MIME_TYPES)[number])) {\n\t\treturn json({ error: { code: 'file_type_not_allowed' } }, { status: 400 });\n\t}\n\tif (typeof size !== 'number' || !Number.isFinite(size) || size <= 0) {\n\t\treturn json({ error: { code: 'invalid_size' } }, { status: 400 });\n\t}\n\tif (size > MAX_FILE_SIZE) {\n\t\treturn json({ error: { code: 'file_too_large' } }, { status: 413 });\n\t}\n\n\tconst key = `uploads/${crypto.randomUUID()}-${sanitizeFilename(filename)}`;\n\tconst sizePolicy = getSizePolicy();\n\tif (sizePolicy === 'storage-enforced') {\n\t\tconst post = await createPresignedPost(getS3(), {\n\t\t\tBucket: env.S3_BUCKET!,\n\t\t\tKey: key,\n\t\t\tFields: { 'Content-Type': contentType },\n\t\t\tConditions: [\n\t\t\t\t['content-length-range', 1, MAX_POST_BODY_SIZE],\n\t\t\t\t['eq', '$Content-Type', contentType]\n\t\t\t],\n\t\t\tExpires: 60\n\t\t});\n\t\treturn json({ method: 'POST', ...post, key, sizePolicy });\n\t}\n\n\t// Fallback only: ContentLength is signed but does not constrain the object\n\t// S3 stores. Do not represent this declared-size validation as a hard limit.\n\tconst url = await getSignedUrl(\n\t\tgetS3(),\n\t\tnew PutObjectCommand({ Bucket: env.S3_BUCKET!, Key: key, ContentType: contentType, ContentLength: size }),\n\t\t{ expiresIn: 60 }\n\t);\n\treturn json({ method: 'PUT', url, key, sizePolicy });\n};\n" + "/routes/api/upload/+server.ts": "import { PutObjectCommand } from '@aws-sdk/client-s3';\nimport { createPresignedPost } from '@aws-sdk/s3-presigned-post';\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner';\nimport { getS3 } from '#lib/server/s3.js';\nimport { MAX_FILE_SIZE, MAX_POST_BODY_SIZE } from '#lib/uploads/post-form.js';\nimport * as env from '$app/env/private';\nimport { json, error } from '@sveltejs/kit';\nimport type { RequestHandler } from './$types';\n\n/**\n * The S3 POST policy applies to the complete multipart request. FileUpload\n * reserves a bounded 64 KiB envelope, so this is the largest file that its\n * at-limit multipart form can submit under the 10 MiB request policy.\n */\n\n/**\n * Upload policy selected by the deployment.\n *\n * `presigned-post` is the default hard-limit mode. S3-compatible providers\n * that implement POST policies enforce content-length-range before storing an\n * object. Set S3_UPLOAD_SIZE_POLICY=presigned-put only for providers without\n * POST-policy support: it validates the declared size before signing, but is\n * explicitly best-effort because a holder of the PUT URL can upload more.\n */\nfunction getSizePolicy(): 'best-effort' | 'storage-enforced' {\n\treturn env.S3_UPLOAD_SIZE_POLICY === 'presigned-put' ? 'best-effort' : 'storage-enforced';\n}\n\n/**\n * Allowed MIME types for uploads. SVG is deliberately EXCLUDED: serving an\n * untrusted SVG from the app origin can result in stored XSS.\n */\nconst ALLOWED_MIME_TYPES = [\n\t'image/jpeg',\n\t'image/png',\n\t'image/gif',\n\t'image/webp',\n\t'application/pdf',\n\t'text/plain',\n\t'application/json'\n] as const;\n\nfunction sanitizeFilename(name: string): string {\n\tconst cleaned = name.replace(/[/\\\\]/g, '').replace(/\\.\\.+/g, '.');\n\tconst trimmed = cleaned.trim().replace(/\\s+/g, '-').slice(0, 100);\n\treturn trimmed || 'file';\n}\n\nexport const POST: RequestHandler = async ({ request, locals }) => {\n\tif (!locals.user) throw error(401, { message: 'Authentication required' });\n\n\t// Error contract: stable machine codes under `error.code` — never raw\n\t// provider/server strings. FileUpload maps ONLY these known codes to\n\t// localized copy; any other payload keeps the client-side generic fallback.\n\tlet filename: string;\n\tlet contentType: string;\n\tlet size: number;\n\ttry {\n\t\tconst body = await request.json();\n\t\tfilename = body.filename;\n\t\tcontentType = body.contentType;\n\t\tsize = body.size;\n\t} catch {\n\t\treturn json({ error: { code: 'invalid_json' } }, { status: 400 });\n\t}\n\n\tif (!filename || typeof filename !== 'string') return json({ error: { code: 'filename_required' } }, { status: 400 });\n\tif (!contentType || typeof contentType !== 'string') return json({ error: { code: 'content_type_required' } }, { status: 400 });\n\tif (!ALLOWED_MIME_TYPES.includes(contentType as (typeof ALLOWED_MIME_TYPES)[number])) {\n\t\treturn json({ error: { code: 'file_type_not_allowed' } }, { status: 400 });\n\t}\n\tif (typeof size !== 'number' || !Number.isFinite(size) || size <= 0) {\n\t\treturn json({ error: { code: 'invalid_size' } }, { status: 400 });\n\t}\n\tif (size > MAX_FILE_SIZE) {\n\t\treturn json({ error: { code: 'file_too_large' } }, { status: 413 });\n\t}\n\n\tconst key = `uploads/${crypto.randomUUID()}-${sanitizeFilename(filename)}`;\n\tconst sizePolicy = getSizePolicy();\n\tif (sizePolicy === 'storage-enforced') {\n\t\tconst post = await createPresignedPost(getS3(), {\n\t\t\tBucket: env.S3_BUCKET!,\n\t\t\tKey: key,\n\t\t\tFields: { 'Content-Type': contentType },\n\t\t\tConditions: [\n\t\t\t\t['content-length-range', 1, MAX_POST_BODY_SIZE],\n\t\t\t\t['eq', '$Content-Type', contentType]\n\t\t\t],\n\t\t\tExpires: 60\n\t\t});\n\t\treturn json({ method: 'POST', ...post, key, sizePolicy });\n\t}\n\n\t// Fallback only: ContentLength is signed but does not constrain the object\n\t// S3 stores. Do not represent this declared-size validation as a hard limit.\n\tconst url = await getSignedUrl(\n\t\tgetS3(),\n\t\tnew PutObjectCommand({ Bucket: env.S3_BUCKET!, Key: key, ContentType: contentType, ContentLength: size }),\n\t\t{ expiresIn: 60 }\n\t);\n\treturn json({ method: 'PUT', url, key, sizePolicy });\n};\n" } } }; diff --git a/packages/svforge/src/scaffolded-agents.ts b/packages/svforge/src/scaffolded-agents.ts index 4d207f43..58ea9ca9 100644 --- a/packages/svforge/src/scaffolded-agents.ts +++ b/packages/svforge/src/scaffolded-agents.ts @@ -212,7 +212,7 @@ Components wrap Skeleton classes + Tailwind only — never raw CSS - \`cn()\` + \`class\` prop on every component (merge/override) - \`HTMLAttributes\` from \`svelte/elements\` for native attribute extension - \`$bindable()\` declared in the Props interface -- Phosphor icons: import from \`$lib/icons\`; keep deep \`phosphor-svelte/lib/*\` imports inside \`src/lib/icons/\` definition modules only +- Phosphor icons: import from \`#lib/icons/index.js\`; keep deep \`phosphor-svelte/lib/*\` imports inside \`src/lib/icons/\` definition modules only ## Theme @@ -256,7 +256,7 @@ ${renderAgentLocaleBlock(localeSettings)} Locales live in \`project.inlang/settings.json\`; copy lives in \`messages/.json\`. MUST: -- use Paraglide for static user-facing UI copy: \`import * as m from '$lib/paraglide/messages.js'\` +- use Paraglide for static user-facing UI copy: \`import * as m from '#lib/paraglide/messages.js'\` - edit the JSON catalogs in \`messages/\` — NEVER edit generated \`src/lib/paraglide/\` (regenerated at build/dev time) - add/update EVERY catalog in \`messages/\` — every locale configured in \`project.inlang/settings.json\` — for each UI copy change; key parity across all configured locales is required - reuse existing message keys when semantics are identical (\`common_save\`, \`common_cancel…\`) @@ -310,7 +310,7 @@ The admin screens are canonical examples agents should copy: - **\`/admin\`** — stats cards (\`Card variant="elevated"\`) + recent list with \`Badge\` status. - **\`/admin/settings\`** — form with client-side validation + \`Feedback\`. - **\`AdminLayout\`** — sidebar/mobile drawer with \`aria-expanded\`, \`aria-current\`, labelled navigation. -- **All UI copy is i18n via Paraglide**: import \`* as m from '$lib/paraglide/messages.js'\` and call \`m.key()\` — never hard-code user-visible text. Keys live in EVERY catalog under \`messages/\` (keep parity across all locales configured in \`project.inlang/settings.json\`). +- **All UI copy is i18n via Paraglide**: import \`* as m from '#lib/paraglide/messages.js'\` and call \`m.key()\` — never hard-code user-visible text. Keys live in EVERY catalog under \`messages/\` (keep parity across all locales configured in \`project.inlang/settings.json\`). `; export function scaffoldedAgents( diff --git a/packages/svforge/src/templates.ts b/packages/svforge/src/templates.ts index 745a011e..f1921324 100644 --- a/packages/svforge/src/templates.ts +++ b/packages/svforge/src/templates.ts @@ -4,30 +4,30 @@ export const baseFiles = { "/app.d.ts": "// See https://svelte.dev/docs/kit/types#app.d.ts\n// for information about these interfaces\ndeclare global {\n\tnamespace App {\n\t\t// interface Error {}\n\t\t// interface Locals {}\n\t\t// interface PageData {}\n\t\t// interface PageState {}\n\t\t// interface Platform {}\n\t}\n}\n\nexport {};\n", "/app.html": "\n\n\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\t%sveltekit.head%\n\t\n\t\n\t\t
    %sveltekit.body%
    \n\t\n\n", - "/hooks.server.ts": "import type { Handle } from '@sveltejs/kit/hooks';\nimport { getTextDirection } from '$lib/paraglide/runtime';\nimport { paraglideMiddleware } from '$lib/paraglide/server';\n\nconst handleParaglide: Handle = ({ event, resolve }) =>\n\tparaglideMiddleware(event.request, ({ request, locale }) => {\n\t\tconst localizedEvent = { ...event, request };\n\t\treturn resolve(localizedEvent, {\n\t\t\ttransformPageChunk: ({ html }) =>\n\t\t\t\thtml\n\t\t\t\t\t.replace('%paraglide.lang%', locale)\n\t\t\t\t\t.replace('%paraglide.dir%', getTextDirection(locale))\n\t\t});\n\t});\n\nexport const handle: Handle = handleParaglide;\n", - "/hooks.ts": "import type { Reroute } from '@sveltejs/kit/hooks';\nimport { deLocalizeUrl } from '$lib/paraglide/runtime';\n\nexport const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname;\n", - "/lib/components/svforge/layout/AuthLayout.svelte": "\n\n
    \n\t\n\n\t
    \n\t\t
    \n\t\t\t
    \n\t\t\t\t\n\t\t\t
    \n\t\t\t\n\t\t
    \n\n\t\t
    \n\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t

    {title}

    \n\t\t\t\t\t

    {description}

    \n\t\t\t\t
    \n\t\t\t\t{@render children()}\n\t\t\t\t{#if footnote}\n\t\t\t\t\t
    \n\t\t\t\t\t\t{@render footnote()}\n\t\t\t\t\t
    \n\t\t\t\t{/if}\n\t\t\t
    \n\t\t
    \n\t
    \n
    \n", - "/lib/components/svforge/layout/Footer.svelte": "\n\n
    \n\t
    \n\t\t
    \n\t\t\t{#if children}\n\t\t\t\t{@render children()}\n\t\t\t{/if}\n\n\t\t\t{#if links.length}\n\t\t\t\t
    \n\t\t\t\t\t{#each links as link (link.href)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{link.label}\n\t\t\t\t\t\t\n\t\t\t\t\t{/each}\n\t\t\t\t
    \n\t\t\t{/if}\n\t\t
    \n\n\t\t
    \n\t\t\t© {new Date().getFullYear()} SVForge. All rights reserved.\n\t\t
    \n\t
    \n
    \n", - "/lib/components/svforge/layout/Navbar.svelte": "\n\n\n", + "/hooks.server.ts": "import type { Handle } from '@sveltejs/kit/hooks';\nimport { getTextDirection } from '#lib/paraglide/runtime.js';\nimport { paraglideMiddleware } from '#lib/paraglide/server.js';\n\nconst handleParaglide: Handle = ({ event, resolve }) =>\n\tparaglideMiddleware(event.request, ({ request, locale }) => {\n\t\tconst localizedEvent = { ...event, request };\n\t\treturn resolve(localizedEvent, {\n\t\t\ttransformPageChunk: ({ html }) =>\n\t\t\t\thtml\n\t\t\t\t\t.replace('%paraglide.lang%', locale)\n\t\t\t\t\t.replace('%paraglide.dir%', getTextDirection(locale))\n\t\t});\n\t});\n\nexport const handle: Handle = handleParaglide;\n", + "/hooks.ts": "import type { Reroute } from '@sveltejs/kit/hooks';\nimport { deLocalizeUrl } from '#lib/paraglide/runtime.js';\n\nexport const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname;\n", + "/lib/components/svforge/layout/AuthLayout.svelte": "\n\n
    \n\t\n\n\t
    \n\t\t
    \n\t\t\t
    \n\t\t\t\t\n\t\t\t
    \n\t\t\t\n\t\t
    \n\n\t\t
    \n\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t

    {title}

    \n\t\t\t\t\t

    {description}

    \n\t\t\t\t
    \n\t\t\t\t{@render children()}\n\t\t\t\t{#if footnote}\n\t\t\t\t\t
    \n\t\t\t\t\t\t{@render footnote()}\n\t\t\t\t\t
    \n\t\t\t\t{/if}\n\t\t\t
    \n\t\t
    \n\t
    \n
    \n", + "/lib/components/svforge/layout/Footer.svelte": "\n\n
    \n\t
    \n\t\t
    \n\t\t\t{#if children}\n\t\t\t\t{@render children()}\n\t\t\t{/if}\n\n\t\t\t{#if links.length}\n\t\t\t\t
    \n\t\t\t\t\t{#each links as link (link.href)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{link.label}\n\t\t\t\t\t\t\n\t\t\t\t\t{/each}\n\t\t\t\t
    \n\t\t\t{/if}\n\t\t
    \n\n\t\t
    \n\t\t\t© {new Date().getFullYear()} SVForge. All rights reserved.\n\t\t
    \n\t
    \n
    \n", + "/lib/components/svforge/layout/Navbar.svelte": "\n\n\n", "/lib/components/svforge/layout/index.ts": "export { default as AuthLayout } from './AuthLayout.svelte';\nexport { default as Footer } from './Footer.svelte';\nexport { default as Navbar } from './Navbar.svelte';\n", - "/lib/components/svforge/primitives/Badge.svelte": "\n\n\n\t{@render children()}\n\n", - "/lib/components/svforge/primitives/Button.svelte": "\n\n{#snippet spinner()}\n\t\n\t{loadingLabel}\n{/snippet}\n\n{#if isAnchor}\n\t\n\t\n\t\n\t\t{#if loading}\n\t\t\t{@render spinner()}\n\t\t{/if}\n\t\t{@render children()}\n\t\n{:else}\n\t\n\t\n\t\t{#if loading}\n\t\t\t{@render spinner()}\n\t\t{/if}\n\t\t{@render children()}\n\t\n{/if}\n", - "/lib/components/svforge/primitives/Checkbox.svelte": "\n\n\n", - "/lib/components/svforge/primitives/Input.svelte": "\n\n
    \n\t{#if label}\n\t\t\n\t{/if}\n\t
    \n\t\t\n\t\t{#if trailing}\n\t\t\t
    \n\t\t\t\t{@render trailing()}\n\t\t\t
    \n\t\t{/if}\n\t
    \n\t{#if hasError}\n\t\t

    {error}

    \n\t{/if}\n
    \n", - "/lib/components/svforge/primitives/Select.svelte": "\n\n
    \n\t{#if label}\n\t\t\n\t{/if}\n\t\n\t\t{#each options as option (option.value)}\n\t\t\t\n\t\t{/each}\n\t\n\t{#if hasError}\n\t\t

    {error}

    \n\t{/if}\n
    \n", - "/lib/components/svforge/primitives/Textarea.svelte": "\n\n
    \n\t{#if label}\n\t\t\n\t{/if}\n\t\n\t{#if hasError}\n\t\t

    {error}

    \n\t{/if}\n
    \n", - "/lib/components/svforge/primitives/Toggle.svelte": "\n\n\n\t\n\t\t\n\t\n\t{#if label}\n\t\t{label}\n\t{/if}\n\t\n\n", + "/lib/components/svforge/primitives/Badge.svelte": "\n\n\n\t{@render children()}\n\n", + "/lib/components/svforge/primitives/Button.svelte": "\n\n{#snippet spinner()}\n\t\n\t{loadingLabel}\n{/snippet}\n\n{#if isAnchor}\n\t\n\t\n\t\n\t\t{#if loading}\n\t\t\t{@render spinner()}\n\t\t{/if}\n\t\t{@render children()}\n\t\n{:else}\n\t\n\t\n\t\t{#if loading}\n\t\t\t{@render spinner()}\n\t\t{/if}\n\t\t{@render children()}\n\t\n{/if}\n", + "/lib/components/svforge/primitives/Checkbox.svelte": "\n\n\n", + "/lib/components/svforge/primitives/Input.svelte": "\n\n
    \n\t{#if label}\n\t\t\n\t{/if}\n\t
    \n\t\t\n\t\t{#if trailing}\n\t\t\t
    \n\t\t\t\t{@render trailing()}\n\t\t\t
    \n\t\t{/if}\n\t
    \n\t{#if hasError}\n\t\t

    {error}

    \n\t{/if}\n
    \n", + "/lib/components/svforge/primitives/Select.svelte": "\n\n
    \n\t{#if label}\n\t\t\n\t{/if}\n\t\n\t\t{#each options as option (option.value)}\n\t\t\t\n\t\t{/each}\n\t\n\t{#if hasError}\n\t\t

    {error}

    \n\t{/if}\n
    \n", + "/lib/components/svforge/primitives/Textarea.svelte": "\n\n
    \n\t{#if label}\n\t\t\n\t{/if}\n\t\n\t{#if hasError}\n\t\t

    {error}

    \n\t{/if}\n
    \n", + "/lib/components/svforge/primitives/Toggle.svelte": "\n\n\n\t\n\t\t\n\t\n\t{#if label}\n\t\t{label}\n\t{/if}\n\t\n\n", "/lib/components/svforge/primitives/index.ts": "export { default as Badge } from './Badge.svelte';\nexport { default as Button } from './Button.svelte';\nexport { default as Checkbox } from './Checkbox.svelte';\nexport { default as Input } from './Input.svelte';\nexport { default as Select } from './Select.svelte';\nexport { default as Textarea } from './Textarea.svelte';\nexport { default as Toggle } from './Toggle.svelte';\n", "/lib/components/svforge/primitives/types.ts": "/**\n * Plain-TypeScript prop contracts for the primitives (#321).\n *\n * Living in a .ts module — instead of inline in the .svelte files — keeps the\n * discriminated unions and generics importable by type tests (`vitest\n * --typecheck`) and by consumers, without going through Svelte tooling.\n */\nimport type { Snippet } from 'svelte';\nimport type {\n\tHTMLAnchorAttributes,\n\tHTMLButtonAttributes,\n\tHTMLTableAttributes\n} from 'svelte/elements';\n\n// ── Button ───────────────────────────────────────────────────────────────\n\nexport type ButtonVariant = 'filled' | 'outlined' | 'tonal' | 'ghost';\nexport type ButtonColor =\n\t| 'primary'\n\t| 'secondary'\n\t| 'tertiary'\n\t| 'success'\n\t| 'warning'\n\t| 'error'\n\t| 'surface';\nexport type ButtonSize = 'sm' | 'md' | 'lg';\n\n/** Visual + ergonomic props shared by both branches of the union. */\nexport interface ButtonSharedProps {\n\tvariant?: ButtonVariant;\n\tcolor?: ButtonColor;\n\tsize?: ButtonSize;\n\t/**\n\t * Visual disabled state. The button branch renders a native `disabled`\n\t * attribute; the anchor branch degrades to `aria-disabled` + reduced\n\t * styling — anchors have no native disabled semantics (#321).\n\t */\n\tdisabled?: boolean;\n\tloading?: boolean;\n\t/**\n\t * Text announced to screen readers while `loading` is active — the visual\n\t * spinner is aria-hidden. Defaults to the localized `common_loading` copy.\n\t */\n\tloadingLabel?: string;\n\tclass?: string;\n\tchildren: Snippet;\n}\n\n/**\n * Button branch — rendered when `href` is absent OR an empty string (the\n * runtime discriminant, mirrored exactly). Anchor-only attributes (`target`,\n * `rel`) are rejected here: TypeScript enforces that each branch only carries\n * its own element's attributes (#321). `href` admits `''` so a dynamically\n * computed empty href keeps full button semantics (`type`, `name`, `form`…)\n * instead of being mis-typed into the anchor branch.\n */\nexport type ButtonProps = ButtonSharedProps &\n\tOmit & {\n\t\thref?: '' | undefined;\n\t\ttype?: 'button' | 'submit' | 'reset';\n\t};\n\n/**\n * Anchor branch — rendered when `href` is a non-empty string. Button-only\n * semantics (`type`, `name`, `form`) are rejected: a link styled as a button\n * is still a link, and a disabled/loading one keeps anchor semantics via\n * `aria-disabled` instead of falling back to a `
    ` element\n * (not an outer div), rows are generic, and the per-cell renderer receives the\n * typed row. Object rows REQUIRE a `rowKey`; primitive rows key on their own\n * value and must not pass one.\n */\nexport type TableProps = TableBaseProps &\n\t(Row extends object ? { rowKey: TableRowKey } : { rowKey?: undefined });\n", - "/lib/components/svforge/ui/Alert.svelte": "\n\n
    \n\t{@render children()}\n
    \n", - "/lib/components/svforge/ui/Card.svelte": "\n\n
    \n\t{#if header}\n\t\t
    \n\t\t\t{@render header()}\n\t\t
    \n\t{/if}\n\n\t{@render children()}\n\n\t{#if footer}\n\t\t
    \n\t\t\t{@render footer()}\n\t\t
    \n\t{/if}\n
    \n", - "/lib/components/svforge/ui/Logo.svelte": "\n\n\n\t\n\t\t\n\t\n\t{#if brandName}{brandName}{/if}\n\n", - "/lib/components/svforge/ui/Seo.svelte": "\n\n\n\t{title}\n\t\n\t\n\t\n\t\n\t\n\t\n\t{#if resolvedImage}\n\t\t\n\t{/if}\n\t\n\t\n\t\n\t{#if resolvedImage}\n\t\t\n\t{/if}\n\n", + "/lib/components/svforge/ui/Alert.svelte": "\n\n
    \n\t{@render children()}\n
    \n", + "/lib/components/svforge/ui/Card.svelte": "\n\n
    \n\t{#if header}\n\t\t
    \n\t\t\t{@render header()}\n\t\t
    \n\t{/if}\n\n\t{@render children()}\n\n\t{#if footer}\n\t\t
    \n\t\t\t{@render footer()}\n\t\t
    \n\t{/if}\n
    \n", + "/lib/components/svforge/ui/Logo.svelte": "\n\n\n\t\n\t\t\n\t\n\t{#if brandName}{brandName}{/if}\n\n", + "/lib/components/svforge/ui/Seo.svelte": "\n\n\n\t{title}\n\t\n\t\n\t\n\t\n\t\n\t\n\t{#if resolvedImage}\n\t\t\n\t{/if}\n\t\n\t\n\t\n\t{#if resolvedImage}\n\t\t\n\t{/if}\n\n", "/lib/components/svforge/ui/Sitemap.ts": "interface SitemapEntry {\n\tpath: string;\n\tlastmod?: string;\n\tchangefreq?: string;\n\tpriority?: number;\n}\n\nconst CHANGE_FREQUENCIES = new Set(['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']);\nconst MAX_TEXT_LENGTH = 2_048;\n\nfunction escapeXml(value: string): string {\n\treturn value.replace(/[&<>\"']/g, (character) => ({\n\t\t'&': '&',\n\t\t'<': '<',\n\t\t'>': '>',\n\t\t'\"': '"',\n\t\t\"'\": '''\n\t})[character]!);\n}\n\nfunction boundedText(value: string | undefined): string | undefined {\n\treturn value && value.length <= MAX_TEXT_LENGTH ? value : undefined;\n}\n\nexport function generateSitemap(baseUrl: string, routes: SitemapEntry[]): string {\n\tconst safeBaseUrl = boundedText(baseUrl) ?? '';\n\tconst entries = routes\n\t\t.map((route) => {\n\t\t\tconst path = boundedText(route.path) ?? '/';\n\t\t\tconst loc = escapeXml(`${safeBaseUrl}${path}`);\n\t\t\tlet xml = ` \\n ${loc}`;\n\t\t\tconst lastmod = boundedText(route.lastmod);\n\t\t\tif (lastmod) xml += `\\n ${escapeXml(lastmod)}`;\n\t\t\tif (route.changefreq && CHANGE_FREQUENCIES.has(route.changefreq)) {\n\t\t\t\txml += `\\n ${escapeXml(route.changefreq)}`;\n\t\t\t}\n\t\t\tif (typeof route.priority === 'number' && route.priority >= 0 && route.priority <= 1) {\n\t\t\t\txml += `\\n ${route.priority}`;\n\t\t\t}\n\t\t\txml += '\\n ';\n\t\t\treturn xml;\n\t\t})\n\t\t.join('\\n');\n\n\treturn `\n\n${entries}\n`;\n}\n", - "/lib/components/svforge/ui/Table.svelte": "\n\n\n
    \n\t
    \n\t\t{#if caption}\n\t\t\t\n\t\t{/if}\n\t\t\n\t\t\t\n\t\t\t\t{#each columns as col (col.key)}\n\t\t\t\t\t\n\t\t\t\t{/each}\n\t\t\t\n\t\t\n\t\t\n\t\t\t{#each rows as row (rowKeyOf(row))}\n\t\t\t\t\n\t\t\t\t\t{#each columns as col (col.key)}\n\t\t\t\t\t\t\n\t\t\t\t\t{/each}\n\t\t\t\t\n\t\t\t{/each}\n\t\t\t{#if rows.length === 0 && empty}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t{/if}\n\t\t\n\t
    \n\t\t\t\t{#if typeof caption === 'function'}\n\t\t\t\t\t{@render caption()}\n\t\t\t\t{:else}\n\t\t\t\t\t{caption}\n\t\t\t\t{/if}\n\t\t\t
    \n\t\t\t\t\t\t{col.label}\n\t\t\t\t\t
    \n\t\t\t\t\t\t\t{#if children}\n\t\t\t\t\t\t\t\t{@render children({ row, col })}\n\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t{String((row as Record)[col.key] ?? '')}\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t
    \n\t\t\t\t\t\t{@render empty()}\n\t\t\t\t\t
    \n
    \n", - "/lib/components/svforge/ui/ThemeToggle.svelte": "\n\n\n\t{#if isDark}\n\t\t\n\t{:else}\n\t\t\n\t{/if}\n\n", + "/lib/components/svforge/ui/Table.svelte": "\n\n\n
    \n\t\n\t\t{#if caption}\n\t\t\t\n\t\t{/if}\n\t\t\n\t\t\t\n\t\t\t\t{#each columns as col (col.key)}\n\t\t\t\t\t\n\t\t\t\t{/each}\n\t\t\t\n\t\t\n\t\t\n\t\t\t{#each rows as row (rowKeyOf(row))}\n\t\t\t\t\n\t\t\t\t\t{#each columns as col (col.key)}\n\t\t\t\t\t\t\n\t\t\t\t\t{/each}\n\t\t\t\t\n\t\t\t{/each}\n\t\t\t{#if rows.length === 0 && empty}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t{/if}\n\t\t\n\t
    \n\t\t\t\t{#if typeof caption === 'function'}\n\t\t\t\t\t{@render caption()}\n\t\t\t\t{:else}\n\t\t\t\t\t{caption}\n\t\t\t\t{/if}\n\t\t\t
    \n\t\t\t\t\t\t{col.label}\n\t\t\t\t\t
    \n\t\t\t\t\t\t\t{#if children}\n\t\t\t\t\t\t\t\t{@render children({ row, col })}\n\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t{String((row as Record)[col.key] ?? '')}\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t
    \n\t\t\t\t\t\t{@render empty()}\n\t\t\t\t\t
    \n
    \n", + "/lib/components/svforge/ui/ThemeToggle.svelte": "\n\n\n\t{#if isDark}\n\t\t\n\t{:else}\n\t\t\n\t{/if}\n\n", "/lib/components/svforge/ui/index.ts": "export { default as Alert } from './Alert.svelte';\nexport { default as Card } from './Card.svelte';\nexport { default as Logo } from './Logo.svelte';\nexport { default as Seo } from './Seo.svelte';\nexport { default as Table } from './Table.svelte';\nexport { default as ThemeToggle } from './ThemeToggle.svelte';\nexport { generateSitemap } from './Sitemap';\n", - "/lib/example.test.ts": "import { describe, it, expect } from 'vitest';\nimport { cn } from '$lib/utils/cn';\nimport { generateSitemap } from '$lib/components/svforge/ui/Sitemap';\n\ndescribe('cn utility', () => {\n\tit('merges class names', () => {\n\t\texpect(cn('px-4', 'py-2')).toBe('px-4 py-2');\n\t});\n\n\tit('handles conditional classes', () => {\n\t\tconst hidden: string | false = 'hidden';\n\t\texpect(cn('flex', false, hidden && 'italic', 'p-2')).toBe('flex italic p-2');\n\t});\n\n\tit('merges conflicting tailwind classes', () => {\n\t\texpect(cn('px-4', 'px-8')).toBe('px-8');\n\t});\n});\n\ndescribe('generateSitemap', () => {\n\tit('generates valid sitemap XML', () => {\n\t\tconst xml = generateSitemap('https://example.com', [\n\t\t\t{ path: '/', priority: 1.0 },\n\t\t\t{ path: '/about', priority: 0.8 }\n\t\t]);\n\n\t\texpect(xml).toContain('https://example.com/');\n\t\texpect(xml).toContain('https://example.com/about');\n\t\texpect(xml).toContain('1');\n\t});\n\n\tit('omits optional fields when not provided', () => {\n\t\tconst xml = generateSitemap('https://example.com', [{ path: '/' }]);\n\t\texpect(xml).toContain('');\n\t\texpect(xml).not.toContain('');\n\t});\n});\n", + "/lib/example.test.ts": "import { describe, it, expect } from 'vitest';\nimport { cn } from '#lib/utils/cn.js';\nimport { generateSitemap } from '#lib/components/svforge/ui/Sitemap.js';\n\ndescribe('cn utility', () => {\n\tit('merges class names', () => {\n\t\texpect(cn('px-4', 'py-2')).toBe('px-4 py-2');\n\t});\n\n\tit('handles conditional classes', () => {\n\t\tconst hidden: string | false = 'hidden';\n\t\texpect(cn('flex', false, hidden && 'italic', 'p-2')).toBe('flex italic p-2');\n\t});\n\n\tit('merges conflicting tailwind classes', () => {\n\t\texpect(cn('px-4', 'px-8')).toBe('px-8');\n\t});\n});\n\ndescribe('generateSitemap', () => {\n\tit('generates valid sitemap XML', () => {\n\t\tconst xml = generateSitemap('https://example.com', [\n\t\t\t{ path: '/', priority: 1.0 },\n\t\t\t{ path: '/about', priority: 0.8 }\n\t\t]);\n\n\t\texpect(xml).toContain('https://example.com/');\n\t\texpect(xml).toContain('https://example.com/about');\n\t\texpect(xml).toContain('1');\n\t});\n\n\tit('omits optional fields when not provided', () => {\n\t\tconst xml = generateSitemap('https://example.com', [{ path: '/' }]);\n\t\texpect(xml).toContain('');\n\t\texpect(xml).not.toContain('');\n\t});\n});\n", "/lib/icons/core.ts": "export { default as Menu } from 'phosphor-svelte/lib/List';\nexport { default as X } from 'phosphor-svelte/lib/X';\nexport { default as SquaresFour } from 'phosphor-svelte/lib/SquaresFour';\nexport { default as Sun } from 'phosphor-svelte/lib/Sun';\nexport { default as Moon } from 'phosphor-svelte/lib/Moon';\n", "/lib/icons/index.ts": "export * from './core';\n", "/lib/index.ts": "// place files you want to import through the `$lib` alias in this folder.\n", @@ -35,13 +35,13 @@ export const baseFiles = { "/lib/utils/cn.ts": "import { clsx } from 'clsx';\nimport type { ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]): string {\n\treturn twMerge(clsx(...inputs));\n}\n", "/lib/utils/theme.ts": "interface ThemeMediaQuery {\n\taddEventListener(type: 'change', listener: (event: { matches: boolean }) => void): void;\n\tremoveEventListener(type: 'change', listener: (event: { matches: boolean }) => void): void;\n}\n\n/** Watches OS color-scheme changes only while the visitor has no saved choice. */\nexport function followSystemTheme(\n\tstored: string | null,\n\tmedia: ThemeMediaQuery,\n\tapply: (dark: boolean) => void\n): (() => void) | undefined {\n\tif (stored === 'dark' || stored === 'light') return undefined;\n\n\tconst onSystemChange = (event: { matches: boolean }) => apply(event.matches);\n\tmedia.addEventListener('change', onSystemChange);\n\treturn () => media.removeEventListener('change', onSystemChange);\n}\n", "/lib/utils/web.ts": "const INTERNAL_ORIGIN = 'https://svelteforge.local';\n\n/** Returns a normalized in-app path, or null when a callback could leave the app. */\nexport function normalizeInternalCallback(callback: string | null): string | null {\n\tif (!callback || !callback.startsWith('/') || callback.startsWith('//') || callback.includes('\\\\')) {\n\t\treturn null;\n\t}\n\n\tlet decoded: string;\n\ttry {\n\t\t// Decode twice so both encoded and double-encoded protocol-relative paths are rejected.\n\t\tdecoded = decodeURIComponent(decodeURIComponent(callback));\n\t} catch {\n\t\treturn null;\n\t}\n\n\tif (!decoded.startsWith('/') || decoded.startsWith('//') || decoded.includes('\\\\')) return null;\n\n\tconst url = new URL(callback, INTERNAL_ORIGIN);\n\treturn `${url.pathname}${url.search}${url.hash}`;\n}\n\n/** Resolves relative SEO values against the current page so crawlers receive absolute URLs. */\nexport function resolveAbsoluteUrl(value: string | undefined, pageUrl: string): string {\n\treturn new URL(value ?? pageUrl, pageUrl).href;\n}\n", - "/routes/+layout.svelte": "\n\n\n\n{@render children()}\n\n