emdash 0.17.1 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/route-utils.mjs +11 -11
- package/dist/api/schemas/index.d.mts +1 -1
- package/dist/{api-Dmz40c2V.mjs → api-B7GATEYo.mjs} +12 -12
- package/dist/{api-Dmz40c2V.mjs.map → api-B7GATEYo.mjs.map} +1 -1
- package/dist/{apply-CuuZG6op.mjs → apply-BrVqULFe.mjs} +16 -16
- package/dist/{apply-CuuZG6op.mjs.map → apply-BrVqULFe.mjs.map} +1 -1
- package/dist/astro/index.d.mts +2 -2
- package/dist/astro/index.mjs +1 -1
- package/dist/astro/middleware/auth.d.mts +2 -2
- package/dist/astro/middleware/auth.mjs +2 -2
- package/dist/astro/middleware/redirect.mjs +5 -5
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +65 -49
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/routes/api/admin/allowed-domains/_domain_.mjs +3 -3
- package/dist/astro/routes/api/admin/allowed-domains/index.mjs +3 -3
- package/dist/astro/routes/api/admin/api-tokens/_id_.mjs +2 -2
- package/dist/astro/routes/api/admin/api-tokens/index.mjs +3 -3
- package/dist/astro/routes/api/admin/byline-fields/_slug_/usage.mjs +3 -3
- package/dist/astro/routes/api/admin/byline-fields/_slug_.mjs +4 -4
- package/dist/astro/routes/api/admin/byline-fields/index.mjs +4 -4
- package/dist/astro/routes/api/admin/byline-fields/reorder.mjs +4 -4
- package/dist/astro/routes/api/admin/bylines/_id_/index.mjs +9 -9
- package/dist/astro/routes/api/admin/bylines/_id_/translations.mjs +9 -9
- package/dist/astro/routes/api/admin/bylines/index.mjs +9 -9
- package/dist/astro/routes/api/admin/comments/_id_/status.mjs +7 -7
- package/dist/astro/routes/api/admin/comments/_id_.mjs +5 -5
- package/dist/astro/routes/api/admin/comments/bulk.mjs +6 -6
- package/dist/astro/routes/api/admin/comments/counts.mjs +5 -5
- package/dist/astro/routes/api/admin/comments/index.mjs +6 -6
- package/dist/astro/routes/api/admin/hooks/exclusive/_hookName_.mjs +4 -4
- package/dist/astro/routes/api/admin/hooks/exclusive/index.mjs +3 -3
- package/dist/astro/routes/api/admin/oauth-clients/_id_.mjs +3 -3
- package/dist/astro/routes/api/admin/oauth-clients/index.mjs +3 -3
- package/dist/astro/routes/api/admin/plugins/_id_/disable.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/_id_/enable.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/_id_/index.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/_id_/update.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/index.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/icon.mjs +3 -3
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs +27 -27
- package/dist/astro/routes/api/admin/plugins/registry/artifact.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/registry/install.mjs +27 -27
- package/dist/astro/routes/api/admin/plugins/updates.mjs +26 -26
- package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs +26 -26
- package/dist/astro/routes/api/admin/themes/marketplace/_id_/thumbnail.mjs +3 -3
- package/dist/astro/routes/api/admin/themes/marketplace/index.mjs +26 -26
- package/dist/astro/routes/api/admin/users/_id_/disable.mjs +2 -2
- package/dist/astro/routes/api/admin/users/_id_/enable.mjs +2 -2
- package/dist/astro/routes/api/admin/users/_id_/index.mjs +3 -3
- package/dist/astro/routes/api/admin/users/_id_/send-recovery.mjs +2 -2
- package/dist/astro/routes/api/admin/users/index.mjs +3 -3
- package/dist/astro/routes/api/auth/dev-bypass.mjs +4 -4
- package/dist/astro/routes/api/auth/invite/accept.mjs +2 -2
- package/dist/astro/routes/api/auth/invite/complete.mjs +3 -3
- package/dist/astro/routes/api/auth/invite/index.mjs +3 -3
- package/dist/astro/routes/api/auth/invite/register-options.mjs +3 -3
- package/dist/astro/routes/api/auth/logout.mjs +2 -2
- package/dist/astro/routes/api/auth/magic-link/send.mjs +4 -4
- package/dist/astro/routes/api/auth/magic-link/verify.mjs +2 -2
- package/dist/astro/routes/api/auth/me.mjs +4 -4
- package/dist/astro/routes/api/auth/passkey/_id_.mjs +3 -3
- package/dist/astro/routes/api/auth/passkey/index.mjs +2 -2
- package/dist/astro/routes/api/auth/passkey/options.mjs +4 -4
- package/dist/astro/routes/api/auth/passkey/register/options.mjs +3 -3
- package/dist/astro/routes/api/auth/passkey/register/verify.mjs +3 -3
- package/dist/astro/routes/api/auth/passkey/verify.mjs +3 -3
- package/dist/astro/routes/api/auth/signup/complete.mjs +3 -3
- package/dist/astro/routes/api/auth/signup/request.mjs +4 -4
- package/dist/astro/routes/api/auth/signup/verify.mjs +2 -2
- package/dist/astro/routes/api/comments/_collection_/_contentId_/index.mjs +6 -6
- package/dist/astro/routes/api/content/_collection_/_id_/compare.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/discard-draft.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/duplicate.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/permanent.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/preview-url.mjs +4 -4
- package/dist/astro/routes/api/content/_collection_/_id_/publish.mjs +4 -4
- package/dist/astro/routes/api/content/_collection_/_id_/restore.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/revisions.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/schedule.mjs +4 -4
- package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.mjs +9 -9
- package/dist/astro/routes/api/content/_collection_/_id_/translations.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/unpublish.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_.mjs +4 -4
- package/dist/astro/routes/api/content/_collection_/index.mjs +4 -4
- package/dist/astro/routes/api/content/_collection_/trash.mjs +4 -4
- package/dist/astro/routes/api/dashboard.mjs +7 -7
- package/dist/astro/routes/api/dev/emails.mjs +2 -2
- package/dist/astro/routes/api/import/probe.mjs +4 -4
- package/dist/astro/routes/api/import/wordpress/analyze.mjs +3 -3
- package/dist/astro/routes/api/import/wordpress/execute.d.mts +2 -2
- package/dist/astro/routes/api/import/wordpress/execute.mjs +8 -8
- package/dist/astro/routes/api/import/wordpress/media.mjs +4 -4
- package/dist/astro/routes/api/import/wordpress/prepare.mjs +6 -6
- package/dist/astro/routes/api/import/wordpress/rewrite-urls.mjs +5 -5
- package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs +4 -4
- package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs +6 -6
- package/dist/astro/routes/api/manifest.mjs +3 -3
- package/dist/astro/routes/api/mcp.mjs +26 -26
- package/dist/astro/routes/api/media/_id_/confirm.mjs +4 -4
- package/dist/astro/routes/api/media/_id_.mjs +4 -4
- package/dist/astro/routes/api/media/file/_...key_.mjs +2 -2
- package/dist/astro/routes/api/media/providers/_providerId_/_itemId_.mjs +3 -3
- package/dist/astro/routes/api/media/providers/_providerId_/index.mjs +3 -3
- package/dist/astro/routes/api/media/providers/index.mjs +3 -3
- package/dist/astro/routes/api/media/upload-url.mjs +4 -4
- package/dist/astro/routes/api/media.mjs +5 -5
- package/dist/astro/routes/api/menus/_name_/items/_id_.mjs +5 -5
- package/dist/astro/routes/api/menus/_name_/items.mjs +5 -5
- package/dist/astro/routes/api/menus/_name_/reorder.mjs +5 -5
- package/dist/astro/routes/api/menus/_name_/translations.mjs +5 -5
- package/dist/astro/routes/api/menus/_name_.mjs +5 -5
- package/dist/astro/routes/api/menus/index.mjs +5 -5
- package/dist/astro/routes/api/oauth/device/authorize.mjs +3 -3
- package/dist/astro/routes/api/oauth/device/code.mjs +4 -4
- package/dist/astro/routes/api/oauth/device/token.mjs +4 -4
- package/dist/astro/routes/api/oauth/register.mjs +2 -2
- package/dist/astro/routes/api/oauth/token/refresh.mjs +3 -3
- package/dist/astro/routes/api/oauth/token/revoke.mjs +3 -3
- package/dist/astro/routes/api/oauth/token.mjs +2 -2
- package/dist/astro/routes/api/openapi.json.mjs +2 -2
- package/dist/astro/routes/api/plugins/_pluginId_/_...path_.mjs +3 -3
- package/dist/astro/routes/api/redirects/404s/index.mjs +7 -7
- package/dist/astro/routes/api/redirects/404s/summary.mjs +7 -7
- package/dist/astro/routes/api/redirects/_id_.mjs +8 -8
- package/dist/astro/routes/api/redirects/index.mjs +8 -8
- package/dist/astro/routes/api/revisions/_revisionId_/index.mjs +3 -3
- package/dist/astro/routes/api/revisions/_revisionId_/restore.mjs +3 -3
- package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs +26 -26
- package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs +26 -26
- package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs +26 -26
- package/dist/astro/routes/api/schema/collections/_slug_/index.mjs +26 -26
- package/dist/astro/routes/api/schema/collections/index.mjs +26 -26
- package/dist/astro/routes/api/schema/index.mjs +7 -7
- package/dist/astro/routes/api/schema/orphans/_slug_.mjs +26 -26
- package/dist/astro/routes/api/schema/orphans/index.mjs +26 -26
- package/dist/astro/routes/api/search/enable.mjs +8 -8
- package/dist/astro/routes/api/search/index.mjs +7 -7
- package/dist/astro/routes/api/search/rebuild.mjs +8 -8
- package/dist/astro/routes/api/search/stats.mjs +7 -7
- package/dist/astro/routes/api/search/suggest.mjs +7 -7
- package/dist/astro/routes/api/sections/_slug_.mjs +7 -7
- package/dist/astro/routes/api/sections/index.mjs +7 -7
- package/dist/astro/routes/api/settings/email.mjs +4 -4
- package/dist/astro/routes/api/settings.mjs +9 -9
- package/dist/astro/routes/api/setup/admin-verify.mjs +3 -3
- package/dist/astro/routes/api/setup/admin.mjs +3 -3
- package/dist/astro/routes/api/setup/dev-bypass.mjs +16 -16
- package/dist/astro/routes/api/setup/dev-reset.mjs +2 -2
- package/dist/astro/routes/api/setup/index.mjs +17 -17
- package/dist/astro/routes/api/setup/status.mjs +3 -3
- package/dist/astro/routes/api/snapshot.mjs +3 -3
- package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_/translations.mjs +9 -9
- package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_.mjs +9 -9
- package/dist/astro/routes/api/taxonomies/_name_/terms/index.mjs +9 -9
- package/dist/astro/routes/api/taxonomies/index.mjs +9 -9
- package/dist/astro/routes/api/themes/preview.mjs +3 -3
- package/dist/astro/routes/api/typegen.mjs +5 -5
- package/dist/astro/routes/api/widget-areas/_name_/reorder.mjs +4 -4
- package/dist/astro/routes/api/widget-areas/_name_/widgets/_id_.mjs +7 -7
- package/dist/astro/routes/api/widget-areas/_name_/widgets.mjs +7 -7
- package/dist/astro/routes/api/widget-areas/_name_.mjs +6 -6
- package/dist/astro/routes/api/widget-areas/index.mjs +7 -7
- package/dist/astro/routes/api/widget-components.mjs +2 -2
- package/dist/astro/routes/robots.txt.mjs +5 -5
- package/dist/astro/routes/sitemap-_collection_.xml.mjs +5 -5
- package/dist/astro/routes/sitemap.xml.mjs +5 -5
- package/dist/astro/types.d.mts +2 -2
- package/dist/{authorize-_wWM_44T.mjs → authorize-CLTmOUyx.mjs} +2 -2
- package/dist/{authorize-_wWM_44T.mjs.map → authorize-CLTmOUyx.mjs.map} +1 -1
- package/dist/{byline-BrIVWLm-.mjs → byline-CAhk4FrG.mjs} +4 -4
- package/dist/{byline-BrIVWLm-.mjs.map → byline-CAhk4FrG.mjs.map} +1 -1
- package/dist/{byline-fields-BNy7Ng1U.d.mts → byline-fields-CR5hGLMw.d.mts} +28 -28
- package/dist/{byline-fields-BNy7Ng1U.d.mts.map → byline-fields-CR5hGLMw.d.mts.map} +1 -1
- package/dist/{bylines-sqExMElV.mjs → bylines-CbrD7STW.mjs} +3 -3
- package/dist/{bylines-sqExMElV.mjs.map → bylines-CbrD7STW.mjs.map} +1 -1
- package/dist/{bylines-C_POWmGT.mjs → bylines-DCczH3AV.mjs} +4 -4
- package/dist/{bylines-C_POWmGT.mjs.map → bylines-DCczH3AV.mjs.map} +1 -1
- package/dist/{cache-wsDkA8ru.mjs → cache-DIHHyPkt.mjs} +2 -2
- package/dist/{cache-wsDkA8ru.mjs.map → cache-DIHHyPkt.mjs.map} +1 -1
- package/dist/{chunks-BAYkM-CF.mjs → chunks-DnnHlRG3.mjs} +2 -2
- package/dist/{chunks-BAYkM-CF.mjs.map → chunks-DnnHlRG3.mjs.map} +1 -1
- package/dist/cli/index.mjs +14 -14
- package/dist/{comment-Cd29aktf.mjs → comment-DkAfGX9E.mjs} +2 -2
- package/dist/{comment-Cd29aktf.mjs.map → comment-DkAfGX9E.mjs.map} +1 -1
- package/dist/{comments-B7ufhkxN.mjs → comments-DLFnXs7J.mjs} +3 -3
- package/dist/{comments-B7ufhkxN.mjs.map → comments-DLFnXs7J.mjs.map} +1 -1
- package/dist/{content-BbqKo3Kc.mjs → content-C7aJ7keg.mjs} +3 -3
- package/dist/{content-BbqKo3Kc.mjs.map → content-C7aJ7keg.mjs.map} +1 -1
- package/dist/{context-BsF1rhoI.mjs → context-Ca0HkaIh.mjs} +8 -8
- package/dist/{context-BsF1rhoI.mjs.map → context-Ca0HkaIh.mjs.map} +1 -1
- package/dist/{dashboard-BwIX9r-X.mjs → dashboard-BrfLIsX1.mjs} +4 -4
- package/dist/{dashboard-BwIX9r-X.mjs.map → dashboard-BrfLIsX1.mjs.map} +1 -1
- package/dist/db/index.mjs +2 -2
- package/dist/{dialect-helpers-BKCvISIQ.mjs → dialect-helpers-DRI5pyY3.mjs} +3 -3
- package/dist/dialect-helpers-DRI5pyY3.mjs.map +1 -0
- package/dist/{error-npZWBSb7.mjs → error-Bk9s3Ism.mjs} +2 -2
- package/dist/{error-npZWBSb7.mjs.map → error-Bk9s3Ism.mjs.map} +1 -1
- package/dist/{fts-manager-DmUAk-kQ.mjs → fts-manager-XpDfbIKo.mjs} +3 -3
- package/dist/{fts-manager-DmUAk-kQ.mjs.map → fts-manager-XpDfbIKo.mjs.map} +1 -1
- package/dist/{index-CjKdMZ3U.d.mts → index-C8ciqSMJ.d.mts} +4 -4
- package/dist/{index-CjKdMZ3U.d.mts.map → index-C8ciqSMJ.d.mts.map} +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +35 -35
- package/dist/{load-DsoLq7ex.mjs → load-CF5oETkh.mjs} +2 -2
- package/dist/{load-DsoLq7ex.mjs.map → load-CF5oETkh.mjs.map} +1 -1
- package/dist/{loader-CJ6lWO0d.mjs → loader-BxyvbrZP.mjs} +4 -4
- package/dist/{loader-CJ6lWO0d.mjs.map → loader-BxyvbrZP.mjs.map} +1 -1
- package/dist/media/local-runtime.d.mts +2 -2
- package/dist/media/local-runtime.mjs +5 -5
- package/dist/{media-jk_HzzOl.mjs → media-Cyz5BhSN.mjs} +2 -2
- package/dist/{media-jk_HzzOl.mjs.map → media-Cyz5BhSN.mjs.map} +1 -1
- package/dist/{menus-CyMO6GBx.mjs → menus-CIdZ_Q6U.mjs} +4 -4
- package/dist/{menus-CyMO6GBx.mjs.map → menus-CIdZ_Q6U.mjs.map} +1 -1
- package/dist/{menus-B-5-3aon.mjs → menus-PFp8FDuO.mjs} +2 -2
- package/dist/{menus-B-5-3aon.mjs.map → menus-PFp8FDuO.mjs.map} +1 -1
- package/dist/{parse-4zO5Y2DL.mjs → parse-B-K21lvm.mjs} +2 -2
- package/dist/{parse-4zO5Y2DL.mjs.map → parse-B-K21lvm.mjs.map} +1 -1
- package/dist/plugin-utils.d.mts +2 -2
- package/dist/plugins/adapt-sandbox-entry.d.mts +2 -2
- package/dist/{query-Bt52mHXp.mjs → query-Cc649nDl.mjs} +10 -10
- package/dist/{query-Bt52mHXp.mjs.map → query-Cc649nDl.mjs.map} +1 -1
- package/dist/{rate-limit-D6VQqBk_.mjs → rate-limit-BI1OdpQH.mjs} +2 -2
- package/dist/{rate-limit-D6VQqBk_.mjs.map → rate-limit-BI1OdpQH.mjs.map} +1 -1
- package/dist/{redirect-BZUJltlj.mjs → redirect-C-FeA4j9.mjs} +3 -3
- package/dist/{redirect-BZUJltlj.mjs.map → redirect-C-FeA4j9.mjs.map} +1 -1
- package/dist/{redirects-DnYuqsEf.mjs → redirects-C1UgU9E0.mjs} +3 -3
- package/dist/{redirects-DnYuqsEf.mjs.map → redirects-C1UgU9E0.mjs.map} +1 -1
- package/dist/{registry-Dn6gsx3L.mjs → registry-C-T_PWgp.mjs} +5 -5
- package/dist/{registry-Dn6gsx3L.mjs.map → registry-C-T_PWgp.mjs.map} +1 -1
- package/dist/{runner-eAgyIkeg.mjs → runner-BiuUfx-V.mjs} +4 -4
- package/dist/runner-BiuUfx-V.mjs.map +1 -0
- package/dist/runtime.d.mts +2 -2
- package/dist/runtime.mjs +3 -3
- package/dist/{schema--mYZX4D7.mjs → schema-BpCJh2lU.mjs} +4 -4
- package/dist/{schema--mYZX4D7.mjs.map → schema-BpCJh2lU.mjs.map} +1 -1
- package/dist/{search-C6U_NvZI.mjs → search-BrF7k0Ho.mjs} +4 -4
- package/dist/{search-C6U_NvZI.mjs.map → search-BrF7k0Ho.mjs.map} +1 -1
- package/dist/{sections-Ba-rJLKb.mjs → sections-8DEa-dWt.mjs} +3 -3
- package/dist/{sections-Ba-rJLKb.mjs.map → sections-8DEa-dWt.mjs.map} +1 -1
- package/dist/seed/index.mjs +14 -14
- package/dist/seo/index.mjs +1 -0
- package/dist/seo/index.mjs.map +1 -1
- package/dist/{seo-BTzb5ksq.mjs → seo-CKr7pLfA.mjs} +2 -2
- package/dist/{seo-BTzb5ksq.mjs.map → seo-CKr7pLfA.mjs.map} +1 -1
- package/dist/{service-Cn-kIfZn.mjs → service-9P2cdyR_.mjs} +2 -2
- package/dist/{service-Cn-kIfZn.mjs.map → service-9P2cdyR_.mjs.map} +1 -1
- package/dist/{settings-C65OSm41.mjs → settings-DYVzINdn.mjs} +3 -3
- package/dist/{settings-C65OSm41.mjs.map → settings-DYVzINdn.mjs.map} +1 -1
- package/dist/{settings-ChlQbwU0.mjs → settings-Jro4YcUb.mjs} +3 -3
- package/dist/{settings-ChlQbwU0.mjs.map → settings-Jro4YcUb.mjs.map} +1 -1
- package/dist/{taxonomies-CbO6v7EE.mjs → taxonomies-C0bVme_m.mjs} +4 -4
- package/dist/{taxonomies-CbO6v7EE.mjs.map → taxonomies-C0bVme_m.mjs.map} +1 -1
- package/dist/{taxonomies-ByLlXrv5.mjs → taxonomies-CGD6y79Q.mjs} +5 -5
- package/dist/{taxonomies-ByLlXrv5.mjs.map → taxonomies-CGD6y79Q.mjs.map} +1 -1
- package/dist/{taxonomy-BBK-UAEo.mjs → taxonomy-Db5xwphL.mjs} +3 -3
- package/dist/{taxonomy-BBK-UAEo.mjs.map → taxonomy-Db5xwphL.mjs.map} +1 -1
- package/dist/{types-SF1DwGf2.mjs → types-CfyYQ7eY.mjs} +2 -2
- package/dist/{types-SF1DwGf2.mjs.map → types-CfyYQ7eY.mjs.map} +1 -1
- package/dist/{user-X4rtyO4Y.mjs → user-tLdHUEXV.mjs} +2 -2
- package/dist/{user-X4rtyO4Y.mjs.map → user-tLdHUEXV.mjs.map} +1 -1
- package/dist/{validate-DactmcJG.mjs → validate-DWmnRg6E.mjs} +2 -2
- package/dist/{validate-DactmcJG.mjs.map → validate-DWmnRg6E.mjs.map} +1 -1
- package/dist/{validation-BYA4i85b.mjs → validation-BQ_TP-On.mjs} +6 -6
- package/dist/{validation-BYA4i85b.mjs.map → validation-BQ_TP-On.mjs.map} +1 -1
- package/dist/version-CgcnMvqS.mjs +7 -0
- package/dist/{version-CWbvq9LG.mjs.map → version-CgcnMvqS.mjs.map} +1 -1
- package/dist/{widgets-DG-1jxnz.mjs → widgets-DzlINGI6.mjs} +2 -2
- package/dist/{widgets-DG-1jxnz.mjs.map → widgets-DzlINGI6.mjs.map} +1 -1
- package/dist/{zod-generator-BNAObjSt.mjs → zod-generator-MMm56Prt.mjs} +2 -2
- package/dist/{zod-generator-BNAObjSt.mjs.map → zod-generator-MMm56Prt.mjs.map} +1 -1
- package/package.json +5 -5
- package/src/astro/middleware.ts +34 -8
- package/src/database/dialect-helpers.ts +8 -2
- package/src/database/migrations/019_i18n.ts +2 -2
- package/src/seo/index.ts +10 -1
- package/dist/dialect-helpers-BKCvISIQ.mjs.map +0 -1
- package/dist/runner-eAgyIkeg.mjs.map +0 -1
- package/dist/version-CWbvq9LG.mjs +0 -7
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bylines-sqExMElV.mjs","names":[],"sources":["../src/api/handlers/bylines.ts"],"sourcesContent":["import type { Kysely } from \"kysely\";\n\nimport {\n\tBylineRepository,\n\ttype CreateBylineInput,\n\ttype UpdateBylineInput,\n} from \"../../database/repositories/byline.js\";\nimport { EmDashValidationError, type BylineSummary } from \"../../database/repositories/types.js\";\nimport type { Database } from \"../../database/types.js\";\nimport { getI18nConfig } from \"../../i18n/config.js\";\nimport type { ApiResult } from \"../types.js\";\n\n// `undefined → null` so a missing field in the create payload matches the\n// repo's stored `null` (BylineRepository normalises with `?? null` on write).\nconst norm = (v: string | null | undefined): string | null => v ?? null;\n\n/**\n * Whether the existing byline row's fixed columns match a fresh-create\n * payload after null/undefined normalisation. Used by the D1 create-retry\n * recovery branch.\n */\nfunction bylineFixedFieldsMatch(\n\texisting: BylineSummary,\n\tinput: CreateBylineInput,\n\teffectiveLocale: string,\n): boolean {\n\treturn (\n\t\texisting.displayName === input.displayName &&\n\t\tnorm(existing.bio) === norm(input.bio) &&\n\t\tnorm(existing.avatarMediaId) === norm(input.avatarMediaId) &&\n\t\tnorm(existing.websiteUrl) === norm(input.websiteUrl) &&\n\t\tnorm(existing.userId) === norm(input.userId) &&\n\t\texisting.isGuest === (input.isGuest ?? false) &&\n\t\texisting.locale === effectiveLocale\n\t);\n}\n\n/**\n * Whether every key in `existing` appears in `input` with the same value.\n * Allows `input` to contain additional keys (the partial-write recovery\n * case); rejects on a divergent value or a key the input omits.\n */\nfunction existingCustomFieldsAreSubsetOf(\n\texisting: Record<string, unknown>,\n\tinput: Record<string, unknown> | undefined,\n): boolean {\n\tif (!input) return Object.keys(existing).length === 0;\n\tfor (const [slug, value] of Object.entries(existing)) {\n\t\tif (!Object.hasOwn(input, slug)) return false;\n\t\tif (input[slug] !== value) return false;\n\t}\n\treturn true;\n}\n\n/**\n * Reject locales the site doesn't configure. Returns `null` when the locale\n * is fine (omitted, or matches `locales` in the i18n config, or i18n isn't\n * configured at all).\n */\nfunction rejectUnknownLocale(locale: string | undefined): ApiResult<never> | null {\n\tif (!locale) return null;\n\tconst config = getI18nConfig();\n\tif (!config) return null;\n\tif (config.locales.includes(locale)) return null;\n\treturn {\n\t\tsuccess: false,\n\t\terror: {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `Locale \"${locale}\" is not configured for this site`,\n\t\t},\n\t};\n}\n\n/**\n * Business-logic helpers for the bylines admin API.\n *\n * Mirrors the shape of `packages/core/src/api/handlers/menus.ts`. Route files\n * stay thin: they parse input, call these handlers, and forward the result via\n * `unwrapResult`. The repository (`BylineRepository`) is strict per locale; the\n * handlers add validation and translation-flow guards on top.\n */\n\nexport interface BylineTranslationsResponse {\n\titems: BylineSummary[];\n}\n\n/**\n * List every translation of a byline (by row id). Returns NOT_FOUND when no\n * row with the given id exists.\n */\nexport async function handleBylineTranslations(\n\tdb: Kysely<Database>,\n\tid: string,\n): Promise<ApiResult<BylineTranslationsResponse>> {\n\ttry {\n\t\tconst repo = new BylineRepository(db);\n\t\tconst anchor = await repo.findById(id);\n\t\tif (!anchor) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Byline not found\" },\n\t\t\t};\n\t\t}\n\t\tconst items = await repo.listTranslations(id);\n\t\treturn { success: true, data: { items } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"BYLINE_TRANSLATIONS_ERROR\",\n\t\t\t\tmessage: \"Failed to list byline translations\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * Create a new byline. When `translationOf` is supplied, the new row joins the\n * source byline's translation_group (a sibling in the same logical identity).\n *\n * Translating from a source row only makes sense when the caller names the\n * target locale, otherwise we'd silently clone into the configured default,\n * which is almost never what's intended (and will collide if the source is\n * already the default-locale row). Mirrors `handleMenuCreate`.\n */\nexport async function handleBylineCreate(\n\tdb: Kysely<Database>,\n\tinput: CreateBylineInput,\n): Promise<ApiResult<BylineSummary>> {\n\ttry {\n\t\tif (input.translationOf && !input.locale) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"`locale` is required when `translationOf` is provided\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst localeErr = rejectUnknownLocale(input.locale);\n\t\tif (localeErr) return localeErr;\n\n\t\tconst repo = new BylineRepository(db);\n\n\t\t// Existence check up front so the repo's \"Source not found\" throw\n\t\t// becomes a clean NOT_FOUND on the API.\n\t\tlet sourceGroup: string | undefined;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await repo.findById(input.translationOf);\n\t\t\tif (!source) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\t\tmessage: \"Source byline for translation not found\",\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t\tsourceGroup = source.translationGroup ?? source.id;\n\t\t}\n\n\t\tconst effectiveLocale = input.locale ?? getI18nConfig()?.defaultLocale ?? \"en\";\n\n\t\t// Translation-group guard: the row-per-locale model (PR #916)\n\t\t// allows exactly one row per (translation_group, locale). Reject\n\t\t// here so callers get a clean 409 instead of a UNIQUE constraint\n\t\t// failure from the partial index. The DB constraint is the safety\n\t\t// net; this is the friendly error.\n\t\tif (sourceGroup) {\n\t\t\tconst siblings = await repo.findByTranslationGroup(sourceGroup);\n\t\t\tif (siblings.some((b) => b.locale === effectiveLocale)) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\t\tmessage: `Translation already exists in locale \"${effectiveLocale}\" for this byline`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Duplicate guard: same (slug, locale) — matches the DB unique key\n\t\t// from migration 040.\n\t\tconst existing = await repo.findBySlug(input.slug, { locale: effectiveLocale });\n\t\tif (existing) {\n\t\t\t// D1 has no transactions, so a crash between the byline insert\n\t\t\t// and the per-field writes leaves a partial row that's\n\t\t\t// otherwise unrecoverable. Treat a same-identity retry that\n\t\t\t// provides customFields as completing the abandoned create.\n\t\t\t// Recovery requires fixed-column + translation-group +\n\t\t\t// subset-customFields match; anything else collapses to a\n\t\t\t// standard duplicate-slug conflict.\n\t\t\tconst expectedTranslationGroup = sourceGroup ?? existing.id;\n\t\t\tconst inputHasFields = !!input.customFields && Object.keys(input.customFields).length > 0;\n\t\t\tif (\n\t\t\t\tinputHasFields &&\n\t\t\t\tbylineFixedFieldsMatch(existing, input, effectiveLocale) &&\n\t\t\t\texisting.translationGroup === expectedTranslationGroup &&\n\t\t\t\texistingCustomFieldsAreSubsetOf(existing.customFields ?? {}, input.customFields)\n\t\t\t) {\n\t\t\t\tconst recovered = await repo.update(existing.id, {\n\t\t\t\t\tcustomFields: input.customFields,\n\t\t\t\t});\n\t\t\t\tif (recovered) return { success: true, data: recovered };\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `Byline \"${input.slug}\" already exists${\n\t\t\t\t\t\tinput.locale ? ` in locale \"${input.locale}\"` : \"\"\n\t\t\t\t\t}`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst byline = await repo.create(input);\n\t\treturn { success: true, data: byline };\n\t} catch (error) {\n\t\t// Mirror handleBylineUpdate: surface customFields validation\n\t\t// errors as 400 rather than swallowing them as a generic 500.\n\t\tif (error instanceof EmDashValidationError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\tconsole.error(\"[BYLINE_CREATE_ERROR]\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"BYLINE_CREATE_ERROR\", message: \"Failed to create byline\" },\n\t\t};\n\t}\n}\n\n/**\n * Update an existing byline. Forwards every field on `UpdateBylineInput`\n * to `BylineRepository.update`, including the Phase 3 `customFields`\n * map; per-field type validation lives in the repo, which throws\n * `EmDashValidationError` on unknown slugs, type mismatches, or\n * `select`-choice misses. This handler translates that into a clean\n * `VALIDATION_ERROR` (400 via `mapErrorStatus`).\n *\n * Returns `NOT_FOUND` when the byline id doesn't resolve. Generic\n * failures surface as `BYLINE_UPDATE_ERROR` (500) without leaking the\n * underlying message.\n */\nexport async function handleBylineUpdate(\n\tdb: Kysely<Database>,\n\tid: string,\n\tinput: UpdateBylineInput,\n): Promise<ApiResult<BylineSummary>> {\n\ttry {\n\t\tconst repo = new BylineRepository(db);\n\t\tconst byline = await repo.update(id, input);\n\t\tif (!byline) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Byline not found\" },\n\t\t\t};\n\t\t}\n\t\treturn { success: true, data: byline };\n\t} catch (error) {\n\t\t// Unknown-key + type-mismatch + select-choice writes throw\n\t\t// EmDashValidationError (Phase 3, see BylineRepository.update).\n\t\t// Map to a clean 400 — the error message names the offending\n\t\t// slug/type, which is safe to surface to the admin client.\n\t\tif (error instanceof EmDashValidationError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\tconsole.error(\"[BYLINE_UPDATE_ERROR]\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"BYLINE_UPDATE_ERROR\", message: \"Failed to update byline\" },\n\t\t};\n\t}\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"bylines-CbrD7STW.mjs","names":[],"sources":["../src/api/handlers/bylines.ts"],"sourcesContent":["import type { Kysely } from \"kysely\";\n\nimport {\n\tBylineRepository,\n\ttype CreateBylineInput,\n\ttype UpdateBylineInput,\n} from \"../../database/repositories/byline.js\";\nimport { EmDashValidationError, type BylineSummary } from \"../../database/repositories/types.js\";\nimport type { Database } from \"../../database/types.js\";\nimport { getI18nConfig } from \"../../i18n/config.js\";\nimport type { ApiResult } from \"../types.js\";\n\n// `undefined → null` so a missing field in the create payload matches the\n// repo's stored `null` (BylineRepository normalises with `?? null` on write).\nconst norm = (v: string | null | undefined): string | null => v ?? null;\n\n/**\n * Whether the existing byline row's fixed columns match a fresh-create\n * payload after null/undefined normalisation. Used by the D1 create-retry\n * recovery branch.\n */\nfunction bylineFixedFieldsMatch(\n\texisting: BylineSummary,\n\tinput: CreateBylineInput,\n\teffectiveLocale: string,\n): boolean {\n\treturn (\n\t\texisting.displayName === input.displayName &&\n\t\tnorm(existing.bio) === norm(input.bio) &&\n\t\tnorm(existing.avatarMediaId) === norm(input.avatarMediaId) &&\n\t\tnorm(existing.websiteUrl) === norm(input.websiteUrl) &&\n\t\tnorm(existing.userId) === norm(input.userId) &&\n\t\texisting.isGuest === (input.isGuest ?? false) &&\n\t\texisting.locale === effectiveLocale\n\t);\n}\n\n/**\n * Whether every key in `existing` appears in `input` with the same value.\n * Allows `input` to contain additional keys (the partial-write recovery\n * case); rejects on a divergent value or a key the input omits.\n */\nfunction existingCustomFieldsAreSubsetOf(\n\texisting: Record<string, unknown>,\n\tinput: Record<string, unknown> | undefined,\n): boolean {\n\tif (!input) return Object.keys(existing).length === 0;\n\tfor (const [slug, value] of Object.entries(existing)) {\n\t\tif (!Object.hasOwn(input, slug)) return false;\n\t\tif (input[slug] !== value) return false;\n\t}\n\treturn true;\n}\n\n/**\n * Reject locales the site doesn't configure. Returns `null` when the locale\n * is fine (omitted, or matches `locales` in the i18n config, or i18n isn't\n * configured at all).\n */\nfunction rejectUnknownLocale(locale: string | undefined): ApiResult<never> | null {\n\tif (!locale) return null;\n\tconst config = getI18nConfig();\n\tif (!config) return null;\n\tif (config.locales.includes(locale)) return null;\n\treturn {\n\t\tsuccess: false,\n\t\terror: {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `Locale \"${locale}\" is not configured for this site`,\n\t\t},\n\t};\n}\n\n/**\n * Business-logic helpers for the bylines admin API.\n *\n * Mirrors the shape of `packages/core/src/api/handlers/menus.ts`. Route files\n * stay thin: they parse input, call these handlers, and forward the result via\n * `unwrapResult`. The repository (`BylineRepository`) is strict per locale; the\n * handlers add validation and translation-flow guards on top.\n */\n\nexport interface BylineTranslationsResponse {\n\titems: BylineSummary[];\n}\n\n/**\n * List every translation of a byline (by row id). Returns NOT_FOUND when no\n * row with the given id exists.\n */\nexport async function handleBylineTranslations(\n\tdb: Kysely<Database>,\n\tid: string,\n): Promise<ApiResult<BylineTranslationsResponse>> {\n\ttry {\n\t\tconst repo = new BylineRepository(db);\n\t\tconst anchor = await repo.findById(id);\n\t\tif (!anchor) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Byline not found\" },\n\t\t\t};\n\t\t}\n\t\tconst items = await repo.listTranslations(id);\n\t\treturn { success: true, data: { items } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"BYLINE_TRANSLATIONS_ERROR\",\n\t\t\t\tmessage: \"Failed to list byline translations\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * Create a new byline. When `translationOf` is supplied, the new row joins the\n * source byline's translation_group (a sibling in the same logical identity).\n *\n * Translating from a source row only makes sense when the caller names the\n * target locale, otherwise we'd silently clone into the configured default,\n * which is almost never what's intended (and will collide if the source is\n * already the default-locale row). Mirrors `handleMenuCreate`.\n */\nexport async function handleBylineCreate(\n\tdb: Kysely<Database>,\n\tinput: CreateBylineInput,\n): Promise<ApiResult<BylineSummary>> {\n\ttry {\n\t\tif (input.translationOf && !input.locale) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"`locale` is required when `translationOf` is provided\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst localeErr = rejectUnknownLocale(input.locale);\n\t\tif (localeErr) return localeErr;\n\n\t\tconst repo = new BylineRepository(db);\n\n\t\t// Existence check up front so the repo's \"Source not found\" throw\n\t\t// becomes a clean NOT_FOUND on the API.\n\t\tlet sourceGroup: string | undefined;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await repo.findById(input.translationOf);\n\t\t\tif (!source) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\t\tmessage: \"Source byline for translation not found\",\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t\tsourceGroup = source.translationGroup ?? source.id;\n\t\t}\n\n\t\tconst effectiveLocale = input.locale ?? getI18nConfig()?.defaultLocale ?? \"en\";\n\n\t\t// Translation-group guard: the row-per-locale model (PR #916)\n\t\t// allows exactly one row per (translation_group, locale). Reject\n\t\t// here so callers get a clean 409 instead of a UNIQUE constraint\n\t\t// failure from the partial index. The DB constraint is the safety\n\t\t// net; this is the friendly error.\n\t\tif (sourceGroup) {\n\t\t\tconst siblings = await repo.findByTranslationGroup(sourceGroup);\n\t\t\tif (siblings.some((b) => b.locale === effectiveLocale)) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\t\tmessage: `Translation already exists in locale \"${effectiveLocale}\" for this byline`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Duplicate guard: same (slug, locale) — matches the DB unique key\n\t\t// from migration 040.\n\t\tconst existing = await repo.findBySlug(input.slug, { locale: effectiveLocale });\n\t\tif (existing) {\n\t\t\t// D1 has no transactions, so a crash between the byline insert\n\t\t\t// and the per-field writes leaves a partial row that's\n\t\t\t// otherwise unrecoverable. Treat a same-identity retry that\n\t\t\t// provides customFields as completing the abandoned create.\n\t\t\t// Recovery requires fixed-column + translation-group +\n\t\t\t// subset-customFields match; anything else collapses to a\n\t\t\t// standard duplicate-slug conflict.\n\t\t\tconst expectedTranslationGroup = sourceGroup ?? existing.id;\n\t\t\tconst inputHasFields = !!input.customFields && Object.keys(input.customFields).length > 0;\n\t\t\tif (\n\t\t\t\tinputHasFields &&\n\t\t\t\tbylineFixedFieldsMatch(existing, input, effectiveLocale) &&\n\t\t\t\texisting.translationGroup === expectedTranslationGroup &&\n\t\t\t\texistingCustomFieldsAreSubsetOf(existing.customFields ?? {}, input.customFields)\n\t\t\t) {\n\t\t\t\tconst recovered = await repo.update(existing.id, {\n\t\t\t\t\tcustomFields: input.customFields,\n\t\t\t\t});\n\t\t\t\tif (recovered) return { success: true, data: recovered };\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `Byline \"${input.slug}\" already exists${\n\t\t\t\t\t\tinput.locale ? ` in locale \"${input.locale}\"` : \"\"\n\t\t\t\t\t}`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst byline = await repo.create(input);\n\t\treturn { success: true, data: byline };\n\t} catch (error) {\n\t\t// Mirror handleBylineUpdate: surface customFields validation\n\t\t// errors as 400 rather than swallowing them as a generic 500.\n\t\tif (error instanceof EmDashValidationError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\tconsole.error(\"[BYLINE_CREATE_ERROR]\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"BYLINE_CREATE_ERROR\", message: \"Failed to create byline\" },\n\t\t};\n\t}\n}\n\n/**\n * Update an existing byline. Forwards every field on `UpdateBylineInput`\n * to `BylineRepository.update`, including the Phase 3 `customFields`\n * map; per-field type validation lives in the repo, which throws\n * `EmDashValidationError` on unknown slugs, type mismatches, or\n * `select`-choice misses. This handler translates that into a clean\n * `VALIDATION_ERROR` (400 via `mapErrorStatus`).\n *\n * Returns `NOT_FOUND` when the byline id doesn't resolve. Generic\n * failures surface as `BYLINE_UPDATE_ERROR` (500) without leaking the\n * underlying message.\n */\nexport async function handleBylineUpdate(\n\tdb: Kysely<Database>,\n\tid: string,\n\tinput: UpdateBylineInput,\n): Promise<ApiResult<BylineSummary>> {\n\ttry {\n\t\tconst repo = new BylineRepository(db);\n\t\tconst byline = await repo.update(id, input);\n\t\tif (!byline) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Byline not found\" },\n\t\t\t};\n\t\t}\n\t\treturn { success: true, data: byline };\n\t} catch (error) {\n\t\t// Unknown-key + type-mismatch + select-choice writes throw\n\t\t// EmDashValidationError (Phase 3, see BylineRepository.update).\n\t\t// Map to a clean 400 — the error message names the offending\n\t\t// slug/type, which is safe to surface to the admin client.\n\t\tif (error instanceof EmDashValidationError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\tconsole.error(\"[BYLINE_UPDATE_ERROR]\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"BYLINE_UPDATE_ERROR\", message: \"Failed to update byline\" },\n\t\t};\n\t}\n}\n"],"mappings":"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"}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { i as __exportAll } from "./runner-
|
|
1
|
+
import { i as __exportAll } from "./runner-BiuUfx-V.mjs";
|
|
2
2
|
import { t as validateIdentifier } from "./validate-VPnKoIzW.mjs";
|
|
3
3
|
import { r as requestCached } from "./request-cache-BYMs-BGX.mjs";
|
|
4
|
-
import { t as BylineRepository } from "./byline-
|
|
4
|
+
import { t as BylineRepository } from "./byline-CAhk4FrG.mjs";
|
|
5
5
|
import { n as isMissingTableError } from "./db-errors-CtzxKBxe.mjs";
|
|
6
|
-
import { r as getDb } from "./loader-
|
|
6
|
+
import { r as getDb } from "./loader-BxyvbrZP.mjs";
|
|
7
7
|
import { i as resolveLocaleChain } from "./resolve-BqYMVG0D.mjs";
|
|
8
8
|
import { sql } from "kysely";
|
|
9
9
|
|
|
@@ -185,4 +185,4 @@ async function getBylinesForEntries(collection, entries) {
|
|
|
185
185
|
|
|
186
186
|
//#endregion
|
|
187
187
|
export { invalidateBylineCache as i, getByline as n, getBylineBySlug as r, bylines_exports as t };
|
|
188
|
-
//# sourceMappingURL=bylines-
|
|
188
|
+
//# sourceMappingURL=bylines-DCczH3AV.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bylines-C_POWmGT.mjs","names":[],"sources":["../src/bylines/index.ts"],"sourcesContent":["/**\n * Runtime API for bylines\n *\n * Provides functions to query byline profiles and byline credits\n * associated with content entries. Follows the same pattern as\n * the taxonomies runtime API.\n *\n * i18n model (migration 040): byline rows are per-locale and share a\n * `translation_group`. Credits on `_emdash_content_bylines.byline_id` store\n * the translation_group, so a single credit spans every locale of a byline.\n *\n * Hydration is strict per locale: a credit at locale X renders iff a byline\n * row exists at locale X within the credited translation_group. There is no\n * read-time fallback. Mirrors `getEntryTerms` and the convention in PR #916.\n * Locale is passed in by callers — `query.ts` resolves it from the entry's\n * own `data.locale` for the runtime path.\n */\n\nimport { sql } from \"kysely\";\n\nimport { BylineRepository } from \"../database/repositories/byline.js\";\nimport type { BylineSummary, ContentBylineCredit } from \"../database/repositories/types.js\";\nimport { validateIdentifier } from \"../database/validate.js\";\nimport { resolveLocaleChain } from \"../i18n/resolve.js\";\nimport { getDb } from \"../loader.js\";\nimport { requestCached } from \"../request-cache.js\";\nimport { isMissingTableError } from \"../utils/db-errors.js\";\n\n/**\n * No-op — kept for API compatibility.\n *\n * Used to invalidate a worker-lifetime \"has any byline?\" probe. That\n * probe added a query on every cold isolate to save one query on sites\n * with zero bylines (i.e. the wrong tradeoff), so we dropped it. The\n * batch byline join below returns an empty map for empty sites at the\n * same cost as the probe, without the pre-check.\n */\nexport function invalidateBylineCache(): void {\n\t// Intentionally empty.\n}\n\n/**\n * Get a byline by ID.\n *\n * @example\n * ```ts\n * import { getByline } from \"emdash\";\n *\n * const byline = await getByline(\"01HXYZ...\");\n * if (byline) {\n * console.log(byline.displayName);\n * }\n * ```\n */\nexport async function getByline(id: string): Promise<BylineSummary | null> {\n\tconst db = await getDb();\n\tconst repo = new BylineRepository(db);\n\treturn repo.findById(id);\n}\n\n/**\n * Get a byline by slug.\n *\n * Standalone identity lookup (e.g. rendering an author profile page). Walks\n * the configured locale fallback chain — same pattern as `getMenu` and\n * `getTerm`, see PR #916. Returns the first match found, walking\n * `[requestedLocale, ...fallbacks, defaultLocale]` in order.\n *\n * Note: this is intentionally different from credit hydration on a content\n * entry (`getEntryBylines`), which is strict per locale with no fallback.\n * The distinction: identity lookups answer \"give me this byline\", and\n * falling back to another locale's display name is acceptable. Credit\n * hydration answers \"what should render on this entry\", where falling back\n * silently surfaces a stale-locale name and contradicts editorial intent.\n *\n * @example\n * ```ts\n * import { getBylineBySlug } from \"emdash\";\n *\n * const byline = await getBylineBySlug(\"jane-doe\", { locale: \"de-de\" });\n * if (byline) {\n * console.log(byline.displayName);\n * }\n * ```\n */\nexport async function getBylineBySlug(\n\tslug: string,\n\toptions?: { locale?: string },\n): Promise<BylineSummary | null> {\n\tconst chain = resolveLocaleChain(options?.locale);\n\tconst cacheKey = `byline-by-slug:${slug}:${chain.length > 0 ? chain.join(\",\") : \"*\"}`;\n\treturn requestCached(cacheKey, async () => {\n\t\tconst db = await getDb();\n\t\tconst repo = new BylineRepository(db);\n\n\t\tif (chain.length === 0) {\n\t\t\t// No i18n or no resolved locale — fall back to the repo's\n\t\t\t// \"lowest-locale-code\" deterministic match.\n\t\t\treturn repo.findBySlug(slug);\n\t\t}\n\n\t\tfor (const locale of chain) {\n\t\t\tconst row = await repo.findBySlug(slug, { locale });\n\t\t\tif (row) return row;\n\t\t}\n\t\treturn null;\n\t});\n}\n\n/**\n * Get byline credits for a single content entry.\n *\n * Strict per locale (post-migration 040): a credit renders iff a byline row\n * exists at the requested locale within the credited translation_group.\n * Callers wanting fallback behaviour apply it themselves. When `locale` is\n * omitted, returns every locale variant of every credit on the entry —\n * useful for admin tooling, not for end-user rendering.\n *\n * Internal: not re-exported from the `emdash` package entry point. Every\n * entry returned by `getEmDashCollection` / `getEmDashEntry` already has\n * `data.bylines` populated by `hydrateEntryBylines` (which uses the batch\n * helper `getBylinesForEntries` directly). Site code should read those\n * fields rather than calling this function.\n */\nexport async function getEntryBylines(\n\tcollection: string,\n\tentryId: string,\n\toptions?: { locale?: string },\n): Promise<ContentBylineCredit[]> {\n\tvalidateIdentifier(collection, \"collection\");\n\tconst db = await getDb();\n\tconst repo = new BylineRepository(db);\n\n\tconst localeOpt = options?.locale !== undefined ? { locale: options.locale } : undefined;\n\tconst explicit = await repo.getContentBylines(collection, entryId, localeOpt);\n\tif (explicit.length > 0) {\n\t\treturn explicit.map((c) => ({ ...c, source: \"explicit\" as const }));\n\t}\n\n\t// `primary_byline_id` is the explicit-credit sentinel: non-null\n\t// suppresses author fallback even when the credit doesn't resolve\n\t// at this locale.\n\tconst ctx = await getEntryContext(db, collection, entryId);\n\tif (ctx.primaryBylineId) return [];\n\n\tif (ctx.authorId) {\n\t\tconst fallback = await repo.findByUserId(ctx.authorId, localeOpt);\n\t\tif (fallback) {\n\t\t\treturn [{ byline: fallback, sortOrder: 0, roleLabel: null, source: \"inferred\" }];\n\t\t}\n\t}\n\n\treturn [];\n}\n\n/**\n * Entry reference for batch byline lookups. Passing `authorId`,\n * `primaryBylineId`, and `locale` in directly avoids a per-entry\n * `SELECT` against the content table during hydration.\n *\n * `primaryBylineId` is the explicit-credit sentinel — non-null suppresses\n * author fallback. `locale` drives the strict per-locale join.\n */\nexport interface BylineEntry {\n\tid: string;\n\tauthorId: string | null;\n\tprimaryBylineId?: string | null;\n\tlocale?: string | null;\n}\n\n/**\n * Batch-fetch byline credits for multiple content entries.\n *\n * Per-entry strict-locale hydration: entries are bucketed by `entry.locale`\n * and each bucket gets a single batched call to the strict-locale repo\n * method. Items with no `locale` field (legacy / single-locale installs)\n * share an unscoped bucket.\n *\n * Internal: consumed by `hydrateEntryBylines` in `query.ts` so that every\n * entry returned from `getEmDashCollection` / `getEmDashEntry` already has\n * `data.bylines` populated. Site code should rely on that eager hydration\n * rather than calling this directly -- this function is not re-exported\n * from the `emdash` package entry point.\n *\n * @param collection - The collection slug (e.g., \"posts\")\n * @param entries - Entry id + authorId + locale (each entry resolves at its own locale)\n * @returns Map from entry ID to array of byline credits\n */\nexport async function getBylinesForEntries(\n\tcollection: string,\n\tentries: BylineEntry[],\n): Promise<Map<string, ContentBylineCredit[]>> {\n\tvalidateIdentifier(collection, \"collection\");\n\tconst result = new Map<string, ContentBylineCredit[]>();\n\n\tfor (const { id } of entries) {\n\t\tresult.set(id, []);\n\t}\n\n\tif (entries.length === 0) {\n\t\treturn result;\n\t}\n\n\tconst db = await getDb();\n\tconst repo = new BylineRepository(db);\n\n\t// Bucket entries by locale so each bucket fires a single strict-locale\n\t// `getContentBylinesMany` call. Items with no locale field share a\n\t// bucket keyed by null (no `WHERE locale = ?` applied — legacy\n\t// pre-i18n shape).\n\tconst buckets = new Map<string | null, BylineEntry[]>();\n\tfor (const entry of entries) {\n\t\tconst key = entry.locale ?? null;\n\t\tconst bucket = buckets.get(key);\n\t\tif (bucket) bucket.push(entry);\n\t\telse buckets.set(key, [entry]);\n\t}\n\n\t// Sites with no bylines get an empty map back at the same cost as the\n\t// previous \"has any bylines\" probe, without the extra round-trip.\n\t// Pre-migration databases (bylines table missing) fall through to the\n\t// `isMissingTableError` catch below and return empty.\n\t//\n\t// Each bucket's `getContentBylinesMany` call uses `skipHydration: true`\n\t// so the per-bucket fetches return bylines with `customFields = {}`.\n\t// We then hydrate the union of returned bylines in a SINGLE batched\n\t// pass via `hydrateBylineCustomFields`. This keeps mixed-locale list\n\t// hydration at one batched group-shared query (and one batched\n\t// translatable query) per request, even when locale buckets reference\n\t// disjoint translation_groups — the strict reading of the Phase 3\n\t// query-count envelope.\n\tconst explicitByEntry = new Map<string, ContentBylineCredit[]>();\n\tconst entriesNeedingAuthorCheck: BylineEntry[] = [];\n\tconst hydrationTargets: BylineSummary[] = [];\n\tfor (const [locale, bucket] of buckets) {\n\t\tconst localeOpt = locale ? { locale, skipHydration: true } : { skipHydration: true };\n\t\tconst bucketIds = bucket.map((e) => e.id);\n\t\tlet bylinesMap;\n\t\ttry {\n\t\t\tbylinesMap = await repo.getContentBylinesMany(collection, bucketIds, localeOpt);\n\t\t} catch (error) {\n\t\t\tif (isMissingTableError(error)) return result;\n\t\t\tthrow error;\n\t\t}\n\t\tfor (const [id, list] of bylinesMap) {\n\t\t\texplicitByEntry.set(id, list);\n\t\t\tfor (const credit of list) hydrationTargets.push(credit.byline);\n\t\t}\n\n\t\tfor (const entry of bucket) {\n\t\t\tconst hasResolved = bylinesMap.has(entry.id) && bylinesMap.get(entry.id)!.length > 0;\n\t\t\tif (hasResolved) continue;\n\t\t\tif (entry.authorId) entriesNeedingAuthorCheck.push(entry);\n\t\t}\n\t}\n\n\t// Only entries without an explicit credit (primaryBylineId null) are\n\t// eligible for author fallback.\n\tconst fallbackByEntry = new Map<string, BylineSummary>();\n\tif (entriesNeedingAuthorCheck.length > 0) {\n\t\tconst authorBuckets = new Map<string | null, BylineEntry[]>();\n\t\tfor (const entry of entriesNeedingAuthorCheck) {\n\t\t\tif (entry.primaryBylineId) continue;\n\t\t\tconst key = entry.locale ?? null;\n\t\t\tconst bucket = authorBuckets.get(key);\n\t\t\tif (bucket) bucket.push(entry);\n\t\t\telse authorBuckets.set(key, [entry]);\n\t\t}\n\n\t\tfor (const [locale, bucket] of authorBuckets) {\n\t\t\tconst localeOpt: { locale?: string; skipHydration: true } = locale\n\t\t\t\t? { locale, skipHydration: true }\n\t\t\t\t: { skipHydration: true };\n\t\t\tconst authorIds = bucket.map((e) => e.authorId).filter((id): id is string => id !== null);\n\t\t\tconst uniqueAuthorIds = [...new Set(authorIds)];\n\t\t\tif (uniqueAuthorIds.length === 0) continue;\n\t\t\t// `skipHydration: true` returns bylines with `customFields = {}`\n\t\t\t// so the fallback path participates in the single batched\n\t\t\t// `hydrateBylineCustomFields` call below — keeping the query\n\t\t\t// envelope at \"+1 group-shared query per hydration pass\" even\n\t\t\t// when author bylines across locale buckets reference disjoint\n\t\t\t// translation_groups.\n\t\t\tconst authorBylineMap = await repo.findByUserIds(uniqueAuthorIds, localeOpt);\n\t\t\tfor (const entry of bucket) {\n\t\t\t\tif (!entry.authorId) continue;\n\t\t\t\tconst f = authorBylineMap.get(entry.authorId);\n\t\t\t\tif (f) {\n\t\t\t\t\tfallbackByEntry.set(entry.id, f);\n\t\t\t\t\thydrationTargets.push(f);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Single batched hydration over every byline returned from both the\n\t// per-bucket explicit-credit fetches AND the per-bucket author-\n\t// fallback fetches. One translatable query + one group-shared query\n\t// for the whole pass, regardless of bucket count or whether\n\t// translation_groups overlap across locales.\n\tif (hydrationTargets.length > 0) {\n\t\tawait repo.hydrateBylineCustomFields(hydrationTargets);\n\t}\n\n\tfor (const { id } of entries) {\n\t\tconst explicit = explicitByEntry.get(id);\n\t\tif (explicit && explicit.length > 0) {\n\t\t\tresult.set(\n\t\t\t\tid,\n\t\t\t\texplicit.map((c) => ({ ...c, source: \"explicit\" as const })),\n\t\t\t);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst fallback = fallbackByEntry.get(id);\n\t\tif (fallback) {\n\t\t\tresult.set(id, [{ byline: fallback, sortOrder: 0, roleLabel: null, source: \"inferred\" }]);\n\t\t}\n\t}\n\n\treturn result;\n}\n\n/** Reads `author_id` + `primary_byline_id` for one entry in a single query. */\nasync function getEntryContext(\n\tdb: Awaited<ReturnType<typeof getDb>>,\n\tcollection: string,\n\tentryId: string,\n): Promise<{ authorId: string | null; primaryBylineId: string | null }> {\n\tvalidateIdentifier(collection, \"collection\");\n\tconst tableName = `ec_${collection}`;\n\n\tconst result = await sql<{\n\t\tauthor_id: string | null;\n\t\tprimary_byline_id: string | null;\n\t}>`\n\t\tSELECT author_id, primary_byline_id FROM ${sql.ref(tableName)}\n\t\tWHERE id = ${entryId}\n\t\tLIMIT 1\n\t`.execute(db);\n\n\tconst row = result.rows[0];\n\treturn {\n\t\tauthorId: row?.author_id ?? null,\n\t\tprimaryBylineId: row?.primary_byline_id ?? null,\n\t};\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"bylines-DCczH3AV.mjs","names":[],"sources":["../src/bylines/index.ts"],"sourcesContent":["/**\n * Runtime API for bylines\n *\n * Provides functions to query byline profiles and byline credits\n * associated with content entries. Follows the same pattern as\n * the taxonomies runtime API.\n *\n * i18n model (migration 040): byline rows are per-locale and share a\n * `translation_group`. Credits on `_emdash_content_bylines.byline_id` store\n * the translation_group, so a single credit spans every locale of a byline.\n *\n * Hydration is strict per locale: a credit at locale X renders iff a byline\n * row exists at locale X within the credited translation_group. There is no\n * read-time fallback. Mirrors `getEntryTerms` and the convention in PR #916.\n * Locale is passed in by callers — `query.ts` resolves it from the entry's\n * own `data.locale` for the runtime path.\n */\n\nimport { sql } from \"kysely\";\n\nimport { BylineRepository } from \"../database/repositories/byline.js\";\nimport type { BylineSummary, ContentBylineCredit } from \"../database/repositories/types.js\";\nimport { validateIdentifier } from \"../database/validate.js\";\nimport { resolveLocaleChain } from \"../i18n/resolve.js\";\nimport { getDb } from \"../loader.js\";\nimport { requestCached } from \"../request-cache.js\";\nimport { isMissingTableError } from \"../utils/db-errors.js\";\n\n/**\n * No-op — kept for API compatibility.\n *\n * Used to invalidate a worker-lifetime \"has any byline?\" probe. That\n * probe added a query on every cold isolate to save one query on sites\n * with zero bylines (i.e. the wrong tradeoff), so we dropped it. The\n * batch byline join below returns an empty map for empty sites at the\n * same cost as the probe, without the pre-check.\n */\nexport function invalidateBylineCache(): void {\n\t// Intentionally empty.\n}\n\n/**\n * Get a byline by ID.\n *\n * @example\n * ```ts\n * import { getByline } from \"emdash\";\n *\n * const byline = await getByline(\"01HXYZ...\");\n * if (byline) {\n * console.log(byline.displayName);\n * }\n * ```\n */\nexport async function getByline(id: string): Promise<BylineSummary | null> {\n\tconst db = await getDb();\n\tconst repo = new BylineRepository(db);\n\treturn repo.findById(id);\n}\n\n/**\n * Get a byline by slug.\n *\n * Standalone identity lookup (e.g. rendering an author profile page). Walks\n * the configured locale fallback chain — same pattern as `getMenu` and\n * `getTerm`, see PR #916. Returns the first match found, walking\n * `[requestedLocale, ...fallbacks, defaultLocale]` in order.\n *\n * Note: this is intentionally different from credit hydration on a content\n * entry (`getEntryBylines`), which is strict per locale with no fallback.\n * The distinction: identity lookups answer \"give me this byline\", and\n * falling back to another locale's display name is acceptable. Credit\n * hydration answers \"what should render on this entry\", where falling back\n * silently surfaces a stale-locale name and contradicts editorial intent.\n *\n * @example\n * ```ts\n * import { getBylineBySlug } from \"emdash\";\n *\n * const byline = await getBylineBySlug(\"jane-doe\", { locale: \"de-de\" });\n * if (byline) {\n * console.log(byline.displayName);\n * }\n * ```\n */\nexport async function getBylineBySlug(\n\tslug: string,\n\toptions?: { locale?: string },\n): Promise<BylineSummary | null> {\n\tconst chain = resolveLocaleChain(options?.locale);\n\tconst cacheKey = `byline-by-slug:${slug}:${chain.length > 0 ? chain.join(\",\") : \"*\"}`;\n\treturn requestCached(cacheKey, async () => {\n\t\tconst db = await getDb();\n\t\tconst repo = new BylineRepository(db);\n\n\t\tif (chain.length === 0) {\n\t\t\t// No i18n or no resolved locale — fall back to the repo's\n\t\t\t// \"lowest-locale-code\" deterministic match.\n\t\t\treturn repo.findBySlug(slug);\n\t\t}\n\n\t\tfor (const locale of chain) {\n\t\t\tconst row = await repo.findBySlug(slug, { locale });\n\t\t\tif (row) return row;\n\t\t}\n\t\treturn null;\n\t});\n}\n\n/**\n * Get byline credits for a single content entry.\n *\n * Strict per locale (post-migration 040): a credit renders iff a byline row\n * exists at the requested locale within the credited translation_group.\n * Callers wanting fallback behaviour apply it themselves. When `locale` is\n * omitted, returns every locale variant of every credit on the entry —\n * useful for admin tooling, not for end-user rendering.\n *\n * Internal: not re-exported from the `emdash` package entry point. Every\n * entry returned by `getEmDashCollection` / `getEmDashEntry` already has\n * `data.bylines` populated by `hydrateEntryBylines` (which uses the batch\n * helper `getBylinesForEntries` directly). Site code should read those\n * fields rather than calling this function.\n */\nexport async function getEntryBylines(\n\tcollection: string,\n\tentryId: string,\n\toptions?: { locale?: string },\n): Promise<ContentBylineCredit[]> {\n\tvalidateIdentifier(collection, \"collection\");\n\tconst db = await getDb();\n\tconst repo = new BylineRepository(db);\n\n\tconst localeOpt = options?.locale !== undefined ? { locale: options.locale } : undefined;\n\tconst explicit = await repo.getContentBylines(collection, entryId, localeOpt);\n\tif (explicit.length > 0) {\n\t\treturn explicit.map((c) => ({ ...c, source: \"explicit\" as const }));\n\t}\n\n\t// `primary_byline_id` is the explicit-credit sentinel: non-null\n\t// suppresses author fallback even when the credit doesn't resolve\n\t// at this locale.\n\tconst ctx = await getEntryContext(db, collection, entryId);\n\tif (ctx.primaryBylineId) return [];\n\n\tif (ctx.authorId) {\n\t\tconst fallback = await repo.findByUserId(ctx.authorId, localeOpt);\n\t\tif (fallback) {\n\t\t\treturn [{ byline: fallback, sortOrder: 0, roleLabel: null, source: \"inferred\" }];\n\t\t}\n\t}\n\n\treturn [];\n}\n\n/**\n * Entry reference for batch byline lookups. Passing `authorId`,\n * `primaryBylineId`, and `locale` in directly avoids a per-entry\n * `SELECT` against the content table during hydration.\n *\n * `primaryBylineId` is the explicit-credit sentinel — non-null suppresses\n * author fallback. `locale` drives the strict per-locale join.\n */\nexport interface BylineEntry {\n\tid: string;\n\tauthorId: string | null;\n\tprimaryBylineId?: string | null;\n\tlocale?: string | null;\n}\n\n/**\n * Batch-fetch byline credits for multiple content entries.\n *\n * Per-entry strict-locale hydration: entries are bucketed by `entry.locale`\n * and each bucket gets a single batched call to the strict-locale repo\n * method. Items with no `locale` field (legacy / single-locale installs)\n * share an unscoped bucket.\n *\n * Internal: consumed by `hydrateEntryBylines` in `query.ts` so that every\n * entry returned from `getEmDashCollection` / `getEmDashEntry` already has\n * `data.bylines` populated. Site code should rely on that eager hydration\n * rather than calling this directly -- this function is not re-exported\n * from the `emdash` package entry point.\n *\n * @param collection - The collection slug (e.g., \"posts\")\n * @param entries - Entry id + authorId + locale (each entry resolves at its own locale)\n * @returns Map from entry ID to array of byline credits\n */\nexport async function getBylinesForEntries(\n\tcollection: string,\n\tentries: BylineEntry[],\n): Promise<Map<string, ContentBylineCredit[]>> {\n\tvalidateIdentifier(collection, \"collection\");\n\tconst result = new Map<string, ContentBylineCredit[]>();\n\n\tfor (const { id } of entries) {\n\t\tresult.set(id, []);\n\t}\n\n\tif (entries.length === 0) {\n\t\treturn result;\n\t}\n\n\tconst db = await getDb();\n\tconst repo = new BylineRepository(db);\n\n\t// Bucket entries by locale so each bucket fires a single strict-locale\n\t// `getContentBylinesMany` call. Items with no locale field share a\n\t// bucket keyed by null (no `WHERE locale = ?` applied — legacy\n\t// pre-i18n shape).\n\tconst buckets = new Map<string | null, BylineEntry[]>();\n\tfor (const entry of entries) {\n\t\tconst key = entry.locale ?? null;\n\t\tconst bucket = buckets.get(key);\n\t\tif (bucket) bucket.push(entry);\n\t\telse buckets.set(key, [entry]);\n\t}\n\n\t// Sites with no bylines get an empty map back at the same cost as the\n\t// previous \"has any bylines\" probe, without the extra round-trip.\n\t// Pre-migration databases (bylines table missing) fall through to the\n\t// `isMissingTableError` catch below and return empty.\n\t//\n\t// Each bucket's `getContentBylinesMany` call uses `skipHydration: true`\n\t// so the per-bucket fetches return bylines with `customFields = {}`.\n\t// We then hydrate the union of returned bylines in a SINGLE batched\n\t// pass via `hydrateBylineCustomFields`. This keeps mixed-locale list\n\t// hydration at one batched group-shared query (and one batched\n\t// translatable query) per request, even when locale buckets reference\n\t// disjoint translation_groups — the strict reading of the Phase 3\n\t// query-count envelope.\n\tconst explicitByEntry = new Map<string, ContentBylineCredit[]>();\n\tconst entriesNeedingAuthorCheck: BylineEntry[] = [];\n\tconst hydrationTargets: BylineSummary[] = [];\n\tfor (const [locale, bucket] of buckets) {\n\t\tconst localeOpt = locale ? { locale, skipHydration: true } : { skipHydration: true };\n\t\tconst bucketIds = bucket.map((e) => e.id);\n\t\tlet bylinesMap;\n\t\ttry {\n\t\t\tbylinesMap = await repo.getContentBylinesMany(collection, bucketIds, localeOpt);\n\t\t} catch (error) {\n\t\t\tif (isMissingTableError(error)) return result;\n\t\t\tthrow error;\n\t\t}\n\t\tfor (const [id, list] of bylinesMap) {\n\t\t\texplicitByEntry.set(id, list);\n\t\t\tfor (const credit of list) hydrationTargets.push(credit.byline);\n\t\t}\n\n\t\tfor (const entry of bucket) {\n\t\t\tconst hasResolved = bylinesMap.has(entry.id) && bylinesMap.get(entry.id)!.length > 0;\n\t\t\tif (hasResolved) continue;\n\t\t\tif (entry.authorId) entriesNeedingAuthorCheck.push(entry);\n\t\t}\n\t}\n\n\t// Only entries without an explicit credit (primaryBylineId null) are\n\t// eligible for author fallback.\n\tconst fallbackByEntry = new Map<string, BylineSummary>();\n\tif (entriesNeedingAuthorCheck.length > 0) {\n\t\tconst authorBuckets = new Map<string | null, BylineEntry[]>();\n\t\tfor (const entry of entriesNeedingAuthorCheck) {\n\t\t\tif (entry.primaryBylineId) continue;\n\t\t\tconst key = entry.locale ?? null;\n\t\t\tconst bucket = authorBuckets.get(key);\n\t\t\tif (bucket) bucket.push(entry);\n\t\t\telse authorBuckets.set(key, [entry]);\n\t\t}\n\n\t\tfor (const [locale, bucket] of authorBuckets) {\n\t\t\tconst localeOpt: { locale?: string; skipHydration: true } = locale\n\t\t\t\t? { locale, skipHydration: true }\n\t\t\t\t: { skipHydration: true };\n\t\t\tconst authorIds = bucket.map((e) => e.authorId).filter((id): id is string => id !== null);\n\t\t\tconst uniqueAuthorIds = [...new Set(authorIds)];\n\t\t\tif (uniqueAuthorIds.length === 0) continue;\n\t\t\t// `skipHydration: true` returns bylines with `customFields = {}`\n\t\t\t// so the fallback path participates in the single batched\n\t\t\t// `hydrateBylineCustomFields` call below — keeping the query\n\t\t\t// envelope at \"+1 group-shared query per hydration pass\" even\n\t\t\t// when author bylines across locale buckets reference disjoint\n\t\t\t// translation_groups.\n\t\t\tconst authorBylineMap = await repo.findByUserIds(uniqueAuthorIds, localeOpt);\n\t\t\tfor (const entry of bucket) {\n\t\t\t\tif (!entry.authorId) continue;\n\t\t\t\tconst f = authorBylineMap.get(entry.authorId);\n\t\t\t\tif (f) {\n\t\t\t\t\tfallbackByEntry.set(entry.id, f);\n\t\t\t\t\thydrationTargets.push(f);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Single batched hydration over every byline returned from both the\n\t// per-bucket explicit-credit fetches AND the per-bucket author-\n\t// fallback fetches. One translatable query + one group-shared query\n\t// for the whole pass, regardless of bucket count or whether\n\t// translation_groups overlap across locales.\n\tif (hydrationTargets.length > 0) {\n\t\tawait repo.hydrateBylineCustomFields(hydrationTargets);\n\t}\n\n\tfor (const { id } of entries) {\n\t\tconst explicit = explicitByEntry.get(id);\n\t\tif (explicit && explicit.length > 0) {\n\t\t\tresult.set(\n\t\t\t\tid,\n\t\t\t\texplicit.map((c) => ({ ...c, source: \"explicit\" as const })),\n\t\t\t);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst fallback = fallbackByEntry.get(id);\n\t\tif (fallback) {\n\t\t\tresult.set(id, [{ byline: fallback, sortOrder: 0, roleLabel: null, source: \"inferred\" }]);\n\t\t}\n\t}\n\n\treturn result;\n}\n\n/** Reads `author_id` + `primary_byline_id` for one entry in a single query. */\nasync function getEntryContext(\n\tdb: Awaited<ReturnType<typeof getDb>>,\n\tcollection: string,\n\tentryId: string,\n): Promise<{ authorId: string | null; primaryBylineId: string | null }> {\n\tvalidateIdentifier(collection, \"collection\");\n\tconst tableName = `ec_${collection}`;\n\n\tconst result = await sql<{\n\t\tauthor_id: string | null;\n\t\tprimary_byline_id: string | null;\n\t}>`\n\t\tSELECT author_id, primary_byline_id FROM ${sql.ref(tableName)}\n\t\tWHERE id = ${entryId}\n\t\tLIMIT 1\n\t`.execute(db);\n\n\tconst row = result.rows[0];\n\treturn {\n\t\tauthorId: row?.author_id ?? null,\n\t\tprimaryBylineId: row?.primary_byline_id ?? null,\n\t};\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as __exportAll } from "./runner-
|
|
1
|
+
import { i as __exportAll } from "./runner-BiuUfx-V.mjs";
|
|
2
2
|
import { i as matchPattern, n as interpolateDestination, t as compilePattern } from "./patterns-CqG5Ya3i.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/redirects/cache.ts
|
|
@@ -62,4 +62,4 @@ function matchCachedPatterns(rules, pathname) {
|
|
|
62
62
|
|
|
63
63
|
//#endregion
|
|
64
64
|
export { setCachedRedirects as a, matchCachedPatterns as i, getCachedRedirects as n, invalidateRedirectCache as r, cache_exports as t };
|
|
65
|
-
//# sourceMappingURL=cache-
|
|
65
|
+
//# sourceMappingURL=cache-DIHHyPkt.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache-
|
|
1
|
+
{"version":3,"file":"cache-DIHHyPkt.mjs","names":[],"sources":["../src/redirects/cache.ts"],"sourcesContent":["/**\n * Redirect rule cache.\n *\n * Module-level cache for enabled redirect rules. The middleware populates this\n * on first request; route handlers invalidate it on writes.\n *\n * Both exact-match and pattern rules are loaded from one query and cached\n * together: exact rules indexed by source path in a Map, pattern rules\n * pre-compiled into an array. A single warm request issues zero database\n * queries; a cold isolate issues one.\n *\n * This module deliberately has NO Astro imports so it can be safely imported\n * from handlers, seed, CLI, and tests without dragging in `astro:middleware`.\n */\n\nimport type { Redirect } from \"../database/repositories/redirect.js\";\nimport type { CompiledPattern } from \"./patterns.js\";\nimport { compilePattern, interpolateDestination, matchPattern } from \"./patterns.js\";\n\nexport interface CachedRedirectRule {\n\tredirect: Redirect;\n\tcompiled: CompiledPattern;\n}\n\nexport interface CachedRedirects {\n\t/** Exact-match rules indexed by source path (`source` -> `Redirect`). */\n\texact: Map<string, Redirect>;\n\t/** Pattern rules with their compiled regexes, preserving insertion order. */\n\tpatterns: CachedRedirectRule[];\n}\n\n/**\n * Cached enabled redirects.\n * null = not yet populated, object = cached.\n */\nlet cachedRedirects: CachedRedirects | null = null;\n\n/**\n * Invalidate the cached redirects (both exact and pattern).\n * Call when redirects are created, updated, or deleted.\n */\nexport function invalidateRedirectCache(): void {\n\tcachedRedirects = null;\n}\n\n/**\n * Get the cached redirects, or null if the cache is cold.\n */\nexport function getCachedRedirects(): CachedRedirects | null {\n\treturn cachedRedirects;\n}\n\n/**\n * Populate the cache from a list of enabled redirects (both exact and\n * pattern). The caller is responsible for passing only enabled rows — the\n * cache stores them as-is.\n */\nexport function setCachedRedirects(redirects: Redirect[]): CachedRedirects {\n\tconst exact = new Map<string, Redirect>();\n\tconst patterns: CachedRedirectRule[] = [];\n\tfor (const r of redirects) {\n\t\tif (r.isPattern) {\n\t\t\tpatterns.push({ redirect: r, compiled: compilePattern(r.source) });\n\t\t} else {\n\t\t\texact.set(r.source, r);\n\t\t}\n\t}\n\tcachedRedirects = { exact, patterns };\n\treturn cachedRedirects;\n}\n\n/**\n * Match a path against the cached pattern rules.\n * Returns the resolved destination and matching redirect, or null.\n */\nexport function matchCachedPatterns(\n\trules: CachedRedirectRule[],\n\tpathname: string,\n): { redirect: Redirect; destination: string } | null {\n\tfor (const { redirect, compiled } of rules) {\n\t\tconst params = matchPattern(compiled, pathname);\n\t\tif (params) {\n\t\t\tconst dest = interpolateDestination(redirect.destination, params);\n\t\t\treturn { redirect, destination: dest };\n\t\t}\n\t}\n\treturn null;\n}\n"],"mappings":";;;;;;;;;;;;;;AAmCA,IAAI,kBAA0C;;;;;AAM9C,SAAgB,0BAAgC;AAC/C,mBAAkB;;;;;AAMnB,SAAgB,qBAA6C;AAC5D,QAAO;;;;;;;AAQR,SAAgB,mBAAmB,WAAwC;CAC1E,MAAM,wBAAQ,IAAI,KAAuB;CACzC,MAAM,WAAiC,EAAE;AACzC,MAAK,MAAM,KAAK,UACf,KAAI,EAAE,UACL,UAAS,KAAK;EAAE,UAAU;EAAG,UAAU,eAAe,EAAE,OAAO;EAAE,CAAC;KAElE,OAAM,IAAI,EAAE,QAAQ,EAAE;AAGxB,mBAAkB;EAAE;EAAO;EAAU;AACrC,QAAO;;;;;;AAOR,SAAgB,oBACf,OACA,UACqD;AACrD,MAAK,MAAM,EAAE,UAAU,cAAc,OAAO;EAC3C,MAAM,SAAS,aAAa,UAAU,SAAS;AAC/C,MAAI,OAEH,QAAO;GAAE;GAAU,aADN,uBAAuB,SAAS,aAAa,OAAO;GAC3B;;AAGxC,QAAO"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as __exportAll } from "./runner-
|
|
1
|
+
import { i as __exportAll } from "./runner-BiuUfx-V.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/utils/chunks.ts
|
|
4
4
|
var chunks_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -22,4 +22,4 @@ const SQL_BATCH_SIZE = 50;
|
|
|
22
22
|
|
|
23
23
|
//#endregion
|
|
24
24
|
export { chunks as n, chunks_exports as r, SQL_BATCH_SIZE as t };
|
|
25
|
-
//# sourceMappingURL=chunks-
|
|
25
|
+
//# sourceMappingURL=chunks-DnnHlRG3.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chunks-
|
|
1
|
+
{"version":3,"file":"chunks-DnnHlRG3.mjs","names":[],"sources":["../src/utils/chunks.ts"],"sourcesContent":["/**\n * Split an array into chunks of at most `size` elements.\n *\n * Used to keep SQL `IN (?, ?, …)` clauses within Cloudflare D1's\n * bound-parameter limit (~100 per statement).\n */\nexport function chunks<T>(arr: T[], size: number): T[][] {\n\tif (arr.length === 0) return [];\n\tconst result: T[][] = [];\n\tfor (let i = 0; i < arr.length; i += size) {\n\t\tresult.push(arr.slice(i, i + size));\n\t}\n\treturn result;\n}\n\n/** Conservative default chunk size for SQL IN clauses (well within D1's limit). */\nexport const SQL_BATCH_SIZE = 50;\n"],"mappings":";;;;;;;;;;;;;AAMA,SAAgB,OAAU,KAAU,MAAqB;AACxD,KAAI,IAAI,WAAW,EAAG,QAAO,EAAE;CAC/B,MAAM,SAAgB,EAAE;AACxB,MAAK,IAAI,IAAI,GAAG,IAAI,IAAI,QAAQ,KAAK,KACpC,QAAO,KAAK,IAAI,MAAM,GAAG,IAAI,KAAK,CAAC;AAEpC,QAAO;;;AAIR,MAAa,iBAAiB"}
|
package/dist/cli/index.mjs
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { i as __exportAll, r as runMigrations, t as getMigrationStatus } from "../runner-
|
|
2
|
+
import { i as __exportAll, r as runMigrations, t as getMigrationStatus } from "../runner-BiuUfx-V.mjs";
|
|
3
3
|
import { t as EmDashDatabaseError } from "../errors-9P_FDrJ_.mjs";
|
|
4
4
|
import { t as validateIdentifier } from "../validate-VPnKoIzW.mjs";
|
|
5
|
-
import { c as listTablesLike } from "../dialect-helpers-
|
|
5
|
+
import { c as listTablesLike } from "../dialect-helpers-DRI5pyY3.mjs";
|
|
6
6
|
import { r as isI18nEnabled } from "../config-CVssduLe.mjs";
|
|
7
7
|
import { n as slugify } from "../slugify-Cjh1ssOZ.mjs";
|
|
8
|
-
import { t as ContentRepository } from "../content-
|
|
8
|
+
import { t as ContentRepository } from "../content-C7aJ7keg.mjs";
|
|
9
9
|
import { i as encodeBase64url } from "../base64-CqR-7kqF.mjs";
|
|
10
|
-
import "../types-
|
|
11
|
-
import { t as MediaRepository } from "../media-
|
|
12
|
-
import { t as TaxonomyRepository } from "../taxonomy-
|
|
10
|
+
import "../types-CfyYQ7eY.mjs";
|
|
11
|
+
import { t as MediaRepository } from "../media-Cyz5BhSN.mjs";
|
|
12
|
+
import { t as TaxonomyRepository } from "../taxonomy-Db5xwphL.mjs";
|
|
13
13
|
import { t as OptionsRepository } from "../options-BL4X94qY.mjs";
|
|
14
|
-
import "../redirect-
|
|
14
|
+
import "../redirect-C-FeA4j9.mjs";
|
|
15
15
|
import "../request-cache-BYMs-BGX.mjs";
|
|
16
16
|
import "../byline-registry-CxK5g559.mjs";
|
|
17
|
-
import { t as BylineRepository } from "../byline-
|
|
17
|
+
import { t as BylineRepository } from "../byline-CAhk4FrG.mjs";
|
|
18
18
|
import { n as isMissingTableError } from "../db-errors-CtzxKBxe.mjs";
|
|
19
|
-
import "../fts-manager-
|
|
20
|
-
import { n as SchemaRegistry } from "../registry-
|
|
19
|
+
import "../fts-manager-XpDfbIKo.mjs";
|
|
20
|
+
import { n as SchemaRegistry } from "../registry-C-T_PWgp.mjs";
|
|
21
21
|
import { kyselyLogOption } from "../database/instrumentation.mjs";
|
|
22
|
-
import "../loader-
|
|
23
|
-
import "../settings-
|
|
22
|
+
import "../loader-BxyvbrZP.mjs";
|
|
23
|
+
import "../settings-Jro4YcUb.mjs";
|
|
24
24
|
import { i as pluginManifestSchema } from "../manifest-schema-Cj-YrzrF.mjs";
|
|
25
25
|
import { n as isDeprecatedCapability, t as CAPABILITY_RENAMES } from "../types-Cj2S6FuC.mjs";
|
|
26
26
|
import "../ssrf-BsVGIE0Z.mjs";
|
|
27
27
|
import "../ssrf-BvgVcfNQ.mjs";
|
|
28
|
-
import { t as validateSeed } from "../validate-
|
|
29
|
-
import { t as applySeed } from "../apply-
|
|
28
|
+
import { t as validateSeed } from "../validate-DWmnRg6E.mjs";
|
|
29
|
+
import { t as applySeed } from "../apply-BrVqULFe.mjs";
|
|
30
30
|
import { n as fingerprintKey, r as generateEncryptionKey, t as EmDashSecretsError } from "../secrets-YYbTgB1w.mjs";
|
|
31
31
|
import { LocalStorage } from "../storage/local.mjs";
|
|
32
32
|
import { o as convertDataForRead } from "../transport--Ck3RBin.mjs";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as encodeCursor, r as decodeCursor } from "./types-
|
|
1
|
+
import { i as encodeCursor, r as decodeCursor } from "./types-CfyYQ7eY.mjs";
|
|
2
2
|
import { sql } from "kysely";
|
|
3
3
|
import { ulid } from "ulidx";
|
|
4
4
|
|
|
@@ -244,4 +244,4 @@ function safeJsonParse(value) {
|
|
|
244
244
|
|
|
245
245
|
//#endregion
|
|
246
246
|
export { CommentRepository as t };
|
|
247
|
-
//# sourceMappingURL=comment-
|
|
247
|
+
//# sourceMappingURL=comment-DkAfGX9E.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"comment-Cd29aktf.mjs","names":[],"sources":["../src/database/repositories/comment.ts"],"sourcesContent":["import { sql, type ExpressionBuilder, type Kysely } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport type { Database } from \"../types.js\";\nimport { encodeCursor, decodeCursor, type FindManyResult } from \"./types.js\";\n\n/** Matches LIKE wildcard characters and the escape character itself */\nconst LIKE_ESCAPE_RE = /[%_\\\\]/g;\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type CommentStatus = \"pending\" | \"approved\" | \"spam\" | \"trash\";\n\nexport interface Comment {\n\tid: string;\n\tcollection: string;\n\tcontentId: string;\n\tparentId: string | null;\n\tauthorName: string;\n\tauthorEmail: string;\n\tauthorUserId: string | null;\n\tbody: string;\n\tstatus: CommentStatus;\n\tipHash: string | null;\n\tuserAgent: string | null;\n\tmoderationMetadata: Record<string, unknown> | null;\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\n/** Public-facing comment shape — no private fields */\nexport interface PublicComment {\n\tid: string;\n\tparentId: string | null;\n\tauthorName: string;\n\tisRegisteredUser: boolean;\n\tbody: string;\n\tcreatedAt: string;\n\treplies?: PublicComment[];\n}\n\nexport interface CreateCommentInput {\n\tcollection: string;\n\tcontentId: string;\n\tparentId?: string | null;\n\tauthorName: string;\n\tauthorEmail: string;\n\tauthorUserId?: string | null;\n\tbody: string;\n\tstatus?: CommentStatus;\n\tipHash?: string | null;\n\tuserAgent?: string | null;\n\tmoderationMetadata?: Record<string, unknown> | null;\n}\n\nexport interface CommentFindOptions {\n\tstatus?: CommentStatus;\n\tcollection?: string;\n\tsearch?: string;\n\tlimit?: number;\n\tcursor?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Repository\n// ---------------------------------------------------------------------------\n\nexport class CommentRepository {\n\tconstructor(private db: Kysely<Database>) {}\n\n\t/**\n\t * Create a new comment\n\t */\n\tasync create(input: CreateCommentInput): Promise<Comment> {\n\t\tconst id = ulid();\n\t\tconst now = new Date().toISOString();\n\n\t\tawait this.db\n\t\t\t.insertInto(\"_emdash_comments\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tcollection: input.collection,\n\t\t\t\tcontent_id: input.contentId,\n\t\t\t\tparent_id: input.parentId ?? null,\n\t\t\t\tauthor_name: input.authorName,\n\t\t\t\tauthor_email: input.authorEmail,\n\t\t\t\tauthor_user_id: input.authorUserId ?? null,\n\t\t\t\tbody: input.body,\n\t\t\t\tstatus: input.status ?? \"pending\",\n\t\t\t\tip_hash: input.ipHash ?? null,\n\t\t\t\tuser_agent: input.userAgent ?? null,\n\t\t\t\tmoderation_metadata: input.moderationMetadata\n\t\t\t\t\t? JSON.stringify(input.moderationMetadata)\n\t\t\t\t\t: null,\n\t\t\t\tcreated_at: now,\n\t\t\t\tupdated_at: now,\n\t\t\t})\n\t\t\t.execute();\n\n\t\tconst comment = await this.findById(id);\n\t\tif (!comment) {\n\t\t\tthrow new Error(\"Failed to create comment\");\n\t\t}\n\t\treturn comment;\n\t}\n\n\t/**\n\t * Find comment by ID\n\t */\n\tasync findById(id: string): Promise<Comment | null> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirst();\n\n\t\treturn row ? this.rowToComment(row) : null;\n\t}\n\n\t/**\n\t * Find comments for a content item with optional status filter.\n\t * Results are ordered by created_at ASC (oldest first) for display.\n\t */\n\tasync findByContent(\n\t\tcollection: string,\n\t\tcontentId: string,\n\t\toptions: { status?: CommentStatus; limit?: number; cursor?: string } = {},\n\t): Promise<FindManyResult<Comment>> {\n\t\tconst limit = Math.min(options.limit || 50, 100);\n\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t.selectAll()\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"content_id\", \"=\", contentId);\n\n\t\tif (options.status) {\n\t\t\tquery = query.where(\"status\", \"=\", options.status);\n\t\t}\n\n\t\t// Cursor pagination (ascending by created_at)\n\t\tif (options.cursor) {\n\t\t\tconst decoded = decodeCursor(options.cursor);\n\t\t\tquery = query.where((eb: ExpressionBuilder<Database, \"_emdash_comments\">) =>\n\t\t\t\teb.or([\n\t\t\t\t\teb(\"created_at\", \">\", decoded.orderValue),\n\t\t\t\t\teb.and([eb(\"created_at\", \"=\", decoded.orderValue), eb(\"id\", \">\", decoded.id)]),\n\t\t\t\t]),\n\t\t\t);\n\t\t}\n\n\t\tquery = query\n\t\t\t.orderBy(\"created_at\", \"asc\")\n\t\t\t.orderBy(\"id\", \"asc\")\n\t\t\t.limit(limit + 1);\n\n\t\tconst rows = await query.execute();\n\t\tconst hasMore = rows.length > limit;\n\t\tconst items = rows.slice(0, limit).map((r) => this.rowToComment(r));\n\n\t\tconst result: FindManyResult<Comment> = { items };\n\t\tif (hasMore && items.length > 0) {\n\t\t\tconst last = items.at(-1)!;\n\t\t\tresult.nextCursor = encodeCursor(last.createdAt, last.id);\n\t\t}\n\t\treturn result;\n\t}\n\n\t/**\n\t * Find comments by status (moderation inbox).\n\t * Results are ordered by created_at DESC (newest first).\n\t */\n\tasync findByStatus(\n\t\tstatus: CommentStatus,\n\t\toptions: { collection?: string; search?: string; limit?: number; cursor?: string } = {},\n\t): Promise<FindManyResult<Comment>> {\n\t\tconst limit = Math.min(options.limit || 50, 100);\n\n\t\tlet query = this.db.selectFrom(\"_emdash_comments\").selectAll().where(\"status\", \"=\", status);\n\n\t\tif (options.collection) {\n\t\t\tquery = query.where(\"collection\", \"=\", options.collection);\n\t\t}\n\n\t\tif (options.search) {\n\t\t\t// Escape LIKE wildcards to prevent them acting as SQL pattern characters\n\t\t\tconst escaped = options.search.replace(LIKE_ESCAPE_RE, (ch) => `\\\\${ch}`);\n\t\t\tconst term = `%${escaped}%`;\n\t\t\tquery = query.where((eb: ExpressionBuilder<Database, \"_emdash_comments\">) =>\n\t\t\t\teb.or([\n\t\t\t\t\tsql<boolean>`author_name LIKE ${term} ESCAPE '\\\\'`,\n\t\t\t\t\tsql<boolean>`author_email LIKE ${term} ESCAPE '\\\\'`,\n\t\t\t\t\tsql<boolean>`body LIKE ${term} ESCAPE '\\\\'`,\n\t\t\t\t]),\n\t\t\t);\n\t\t}\n\n\t\t// Cursor pagination (descending by created_at)\n\t\tif (options.cursor) {\n\t\t\tconst decoded = decodeCursor(options.cursor);\n\t\t\tquery = query.where((eb: ExpressionBuilder<Database, \"_emdash_comments\">) =>\n\t\t\t\teb.or([\n\t\t\t\t\teb(\"created_at\", \"<\", decoded.orderValue),\n\t\t\t\t\teb.and([eb(\"created_at\", \"=\", decoded.orderValue), eb(\"id\", \"<\", decoded.id)]),\n\t\t\t\t]),\n\t\t\t);\n\t\t}\n\n\t\tquery = query\n\t\t\t.orderBy(\"created_at\", \"desc\")\n\t\t\t.orderBy(\"id\", \"desc\")\n\t\t\t.limit(limit + 1);\n\n\t\tconst rows = await query.execute();\n\t\tconst hasMore = rows.length > limit;\n\t\tconst items = rows.slice(0, limit).map((r) => this.rowToComment(r));\n\n\t\tconst result: FindManyResult<Comment> = { items };\n\t\tif (hasMore && items.length > 0) {\n\t\t\tconst last = items.at(-1)!;\n\t\t\tresult.nextCursor = encodeCursor(last.createdAt, last.id);\n\t\t}\n\t\treturn result;\n\t}\n\n\t/**\n\t * Update comment status\n\t */\n\tasync updateStatus(id: string, status: CommentStatus): Promise<Comment | null> {\n\t\tconst now = new Date().toISOString();\n\n\t\tawait this.db\n\t\t\t.updateTable(\"_emdash_comments\")\n\t\t\t.set({ status, updated_at: now })\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.execute();\n\n\t\treturn this.findById(id);\n\t}\n\n\t/**\n\t * Bulk update comment statuses\n\t */\n\tasync bulkUpdateStatus(ids: string[], status: CommentStatus): Promise<number> {\n\t\tif (ids.length === 0) return 0;\n\n\t\tconst now = new Date().toISOString();\n\n\t\tconst result = await this.db\n\t\t\t.updateTable(\"_emdash_comments\")\n\t\t\t.set({ status, updated_at: now })\n\t\t\t.where(\"id\", \"in\", ids)\n\t\t\t.executeTakeFirst();\n\n\t\treturn Number(result.numUpdatedRows ?? 0);\n\t}\n\n\t/**\n\t * Hard-delete a single comment. Replies cascade via FK.\n\t */\n\tasync delete(id: string): Promise<boolean> {\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"_emdash_comments\")\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirst();\n\n\t\treturn (result.numDeletedRows ?? 0) > 0;\n\t}\n\n\t/**\n\t * Bulk hard-delete comments\n\t */\n\tasync bulkDelete(ids: string[]): Promise<number> {\n\t\tif (ids.length === 0) return 0;\n\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"_emdash_comments\")\n\t\t\t.where(\"id\", \"in\", ids)\n\t\t\t.executeTakeFirst();\n\n\t\treturn Number(result.numDeletedRows ?? 0);\n\t}\n\n\t/**\n\t * Delete all comments for a content item (cascade on content deletion)\n\t */\n\tasync deleteByContent(collection: string, contentId: string): Promise<number> {\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"_emdash_comments\")\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"content_id\", \"=\", contentId)\n\t\t\t.executeTakeFirst();\n\n\t\treturn Number(result.numDeletedRows ?? 0);\n\t}\n\n\t/**\n\t * Count comments for a content item, optionally filtered by status\n\t */\n\tasync countByContent(\n\t\tcollection: string,\n\t\tcontentId: string,\n\t\tstatus?: CommentStatus,\n\t): Promise<number> {\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"content_id\", \"=\", contentId);\n\n\t\tif (status) {\n\t\t\tquery = query.where(\"status\", \"=\", status);\n\t\t}\n\n\t\tconst result = await query.executeTakeFirst();\n\t\treturn Number(result?.count ?? 0);\n\t}\n\n\t/**\n\t * Count comments grouped by status (for inbox badges)\n\t *\n\t * Uses four parallel COUNT queries with WHERE filters to leverage partial indexes\n\t * (idx_comments_pending, idx_comments_approved, idx_comments_spam, idx_comments_trash)\n\t * instead of a full table GROUP BY scan.\n\t */\n\tasync countByStatus(): Promise<Record<CommentStatus, number>> {\n\t\t// Execute four parallel COUNT queries, each using its partial index\n\t\tconst [pending, approved, spam, trash] = await Promise.all([\n\t\t\tthis.db\n\t\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t\t.where(\"status\", \"=\", \"pending\")\n\t\t\t\t.executeTakeFirst(),\n\t\t\tthis.db\n\t\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t\t.where(\"status\", \"=\", \"approved\")\n\t\t\t\t.executeTakeFirst(),\n\t\t\tthis.db\n\t\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t\t.where(\"status\", \"=\", \"spam\")\n\t\t\t\t.executeTakeFirst(),\n\t\t\tthis.db\n\t\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t\t.where(\"status\", \"=\", \"trash\")\n\t\t\t\t.executeTakeFirst(),\n\t\t]);\n\n\t\treturn {\n\t\t\tpending: Number(pending?.count ?? 0),\n\t\t\tapproved: Number(approved?.count ?? 0),\n\t\t\tspam: Number(spam?.count ?? 0),\n\t\t\ttrash: Number(trash?.count ?? 0),\n\t\t};\n\t}\n\n\t/**\n\t * Count approved comments from a given email address.\n\t * Used for \"first time commenter\" moderation logic.\n\t */\n\tasync countApprovedByEmail(email: string): Promise<number> {\n\t\tconst result = await this.db\n\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t.where(\"author_email\", \"=\", email)\n\t\t\t.where(\"status\", \"=\", \"approved\")\n\t\t\t.executeTakeFirst();\n\n\t\treturn Number(result?.count ?? 0);\n\t}\n\n\t/**\n\t * Update the moderation metadata JSON on a comment\n\t */\n\tasync updateModerationMetadata(id: string, metadata: Record<string, unknown>): Promise<void> {\n\t\tawait this.db\n\t\t\t.updateTable(\"_emdash_comments\")\n\t\t\t.set({ moderation_metadata: JSON.stringify(metadata) })\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.execute();\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// Helpers\n\t// ---------------------------------------------------------------------------\n\n\t/**\n\t * Assemble a flat list of comments into a threaded structure (1-level nesting)\n\t */\n\tstatic assembleThreads(comments: Comment[]): Comment[] {\n\t\tconst roots: Comment[] = [];\n\t\tconst childrenMap = new Map<string, Comment[]>();\n\n\t\tfor (const comment of comments) {\n\t\t\tif (comment.parentId) {\n\t\t\t\tconst siblings = childrenMap.get(comment.parentId) ?? [];\n\t\t\t\tsiblings.push(comment);\n\t\t\t\tchildrenMap.set(comment.parentId, siblings);\n\t\t\t} else {\n\t\t\t\troots.push(comment);\n\t\t\t}\n\t\t}\n\n\t\t// Attach children as a non-standard property — callers map to PublicComment.replies\n\t\treturn roots.map((root) => ({\n\t\t\t...root,\n\t\t\t_replies: childrenMap.get(root.id) ?? [],\n\t\t})) as Comment[];\n\t}\n\n\t/**\n\t * Convert a Comment to its public-facing shape\n\t */\n\tstatic toPublicComment(comment: Comment & { _replies?: Comment[] }): PublicComment {\n\t\tconst pub: PublicComment = {\n\t\t\tid: comment.id,\n\t\t\tparentId: comment.parentId,\n\t\t\tauthorName: comment.authorName,\n\t\t\tisRegisteredUser: comment.authorUserId !== null,\n\t\t\tbody: comment.body,\n\t\t\tcreatedAt: comment.createdAt,\n\t\t};\n\n\t\tif (comment._replies && comment._replies.length > 0) {\n\t\t\tpub.replies = comment._replies.map((r) => CommentRepository.toPublicComment(r));\n\t\t}\n\n\t\treturn pub;\n\t}\n\n\t// eslint-disable-next-line @typescript-eslint/no-explicit-any -- selectAll returns runtime row\n\tprivate rowToComment(row: any): Comment {\n\t\treturn {\n\t\t\tid: row.id,\n\t\t\tcollection: row.collection,\n\t\t\tcontentId: row.content_id,\n\t\t\tparentId: row.parent_id,\n\t\t\tauthorName: row.author_name,\n\t\t\tauthorEmail: row.author_email,\n\t\t\tauthorUserId: row.author_user_id,\n\t\t\tbody: row.body,\n\t\t\tstatus: row.status as CommentStatus,\n\t\t\tipHash: row.ip_hash,\n\t\t\tuserAgent: row.user_agent,\n\t\t\tmoderationMetadata: row.moderation_metadata ? safeJsonParse(row.moderation_metadata) : null,\n\t\t\tcreatedAt: row.created_at,\n\t\t\tupdatedAt: row.updated_at,\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Module helpers\n// ---------------------------------------------------------------------------\n\nfunction safeJsonParse(value: string): Record<string, unknown> | null {\n\ttry {\n\t\treturn JSON.parse(value) as Record<string, unknown>;\n\t} catch {\n\t\treturn null;\n\t}\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"comment-DkAfGX9E.mjs","names":[],"sources":["../src/database/repositories/comment.ts"],"sourcesContent":["import { sql, type ExpressionBuilder, type Kysely } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport type { Database } from \"../types.js\";\nimport { encodeCursor, decodeCursor, type FindManyResult } from \"./types.js\";\n\n/** Matches LIKE wildcard characters and the escape character itself */\nconst LIKE_ESCAPE_RE = /[%_\\\\]/g;\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type CommentStatus = \"pending\" | \"approved\" | \"spam\" | \"trash\";\n\nexport interface Comment {\n\tid: string;\n\tcollection: string;\n\tcontentId: string;\n\tparentId: string | null;\n\tauthorName: string;\n\tauthorEmail: string;\n\tauthorUserId: string | null;\n\tbody: string;\n\tstatus: CommentStatus;\n\tipHash: string | null;\n\tuserAgent: string | null;\n\tmoderationMetadata: Record<string, unknown> | null;\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\n/** Public-facing comment shape — no private fields */\nexport interface PublicComment {\n\tid: string;\n\tparentId: string | null;\n\tauthorName: string;\n\tisRegisteredUser: boolean;\n\tbody: string;\n\tcreatedAt: string;\n\treplies?: PublicComment[];\n}\n\nexport interface CreateCommentInput {\n\tcollection: string;\n\tcontentId: string;\n\tparentId?: string | null;\n\tauthorName: string;\n\tauthorEmail: string;\n\tauthorUserId?: string | null;\n\tbody: string;\n\tstatus?: CommentStatus;\n\tipHash?: string | null;\n\tuserAgent?: string | null;\n\tmoderationMetadata?: Record<string, unknown> | null;\n}\n\nexport interface CommentFindOptions {\n\tstatus?: CommentStatus;\n\tcollection?: string;\n\tsearch?: string;\n\tlimit?: number;\n\tcursor?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Repository\n// ---------------------------------------------------------------------------\n\nexport class CommentRepository {\n\tconstructor(private db: Kysely<Database>) {}\n\n\t/**\n\t * Create a new comment\n\t */\n\tasync create(input: CreateCommentInput): Promise<Comment> {\n\t\tconst id = ulid();\n\t\tconst now = new Date().toISOString();\n\n\t\tawait this.db\n\t\t\t.insertInto(\"_emdash_comments\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tcollection: input.collection,\n\t\t\t\tcontent_id: input.contentId,\n\t\t\t\tparent_id: input.parentId ?? null,\n\t\t\t\tauthor_name: input.authorName,\n\t\t\t\tauthor_email: input.authorEmail,\n\t\t\t\tauthor_user_id: input.authorUserId ?? null,\n\t\t\t\tbody: input.body,\n\t\t\t\tstatus: input.status ?? \"pending\",\n\t\t\t\tip_hash: input.ipHash ?? null,\n\t\t\t\tuser_agent: input.userAgent ?? null,\n\t\t\t\tmoderation_metadata: input.moderationMetadata\n\t\t\t\t\t? JSON.stringify(input.moderationMetadata)\n\t\t\t\t\t: null,\n\t\t\t\tcreated_at: now,\n\t\t\t\tupdated_at: now,\n\t\t\t})\n\t\t\t.execute();\n\n\t\tconst comment = await this.findById(id);\n\t\tif (!comment) {\n\t\t\tthrow new Error(\"Failed to create comment\");\n\t\t}\n\t\treturn comment;\n\t}\n\n\t/**\n\t * Find comment by ID\n\t */\n\tasync findById(id: string): Promise<Comment | null> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirst();\n\n\t\treturn row ? this.rowToComment(row) : null;\n\t}\n\n\t/**\n\t * Find comments for a content item with optional status filter.\n\t * Results are ordered by created_at ASC (oldest first) for display.\n\t */\n\tasync findByContent(\n\t\tcollection: string,\n\t\tcontentId: string,\n\t\toptions: { status?: CommentStatus; limit?: number; cursor?: string } = {},\n\t): Promise<FindManyResult<Comment>> {\n\t\tconst limit = Math.min(options.limit || 50, 100);\n\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t.selectAll()\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"content_id\", \"=\", contentId);\n\n\t\tif (options.status) {\n\t\t\tquery = query.where(\"status\", \"=\", options.status);\n\t\t}\n\n\t\t// Cursor pagination (ascending by created_at)\n\t\tif (options.cursor) {\n\t\t\tconst decoded = decodeCursor(options.cursor);\n\t\t\tquery = query.where((eb: ExpressionBuilder<Database, \"_emdash_comments\">) =>\n\t\t\t\teb.or([\n\t\t\t\t\teb(\"created_at\", \">\", decoded.orderValue),\n\t\t\t\t\teb.and([eb(\"created_at\", \"=\", decoded.orderValue), eb(\"id\", \">\", decoded.id)]),\n\t\t\t\t]),\n\t\t\t);\n\t\t}\n\n\t\tquery = query\n\t\t\t.orderBy(\"created_at\", \"asc\")\n\t\t\t.orderBy(\"id\", \"asc\")\n\t\t\t.limit(limit + 1);\n\n\t\tconst rows = await query.execute();\n\t\tconst hasMore = rows.length > limit;\n\t\tconst items = rows.slice(0, limit).map((r) => this.rowToComment(r));\n\n\t\tconst result: FindManyResult<Comment> = { items };\n\t\tif (hasMore && items.length > 0) {\n\t\t\tconst last = items.at(-1)!;\n\t\t\tresult.nextCursor = encodeCursor(last.createdAt, last.id);\n\t\t}\n\t\treturn result;\n\t}\n\n\t/**\n\t * Find comments by status (moderation inbox).\n\t * Results are ordered by created_at DESC (newest first).\n\t */\n\tasync findByStatus(\n\t\tstatus: CommentStatus,\n\t\toptions: { collection?: string; search?: string; limit?: number; cursor?: string } = {},\n\t): Promise<FindManyResult<Comment>> {\n\t\tconst limit = Math.min(options.limit || 50, 100);\n\n\t\tlet query = this.db.selectFrom(\"_emdash_comments\").selectAll().where(\"status\", \"=\", status);\n\n\t\tif (options.collection) {\n\t\t\tquery = query.where(\"collection\", \"=\", options.collection);\n\t\t}\n\n\t\tif (options.search) {\n\t\t\t// Escape LIKE wildcards to prevent them acting as SQL pattern characters\n\t\t\tconst escaped = options.search.replace(LIKE_ESCAPE_RE, (ch) => `\\\\${ch}`);\n\t\t\tconst term = `%${escaped}%`;\n\t\t\tquery = query.where((eb: ExpressionBuilder<Database, \"_emdash_comments\">) =>\n\t\t\t\teb.or([\n\t\t\t\t\tsql<boolean>`author_name LIKE ${term} ESCAPE '\\\\'`,\n\t\t\t\t\tsql<boolean>`author_email LIKE ${term} ESCAPE '\\\\'`,\n\t\t\t\t\tsql<boolean>`body LIKE ${term} ESCAPE '\\\\'`,\n\t\t\t\t]),\n\t\t\t);\n\t\t}\n\n\t\t// Cursor pagination (descending by created_at)\n\t\tif (options.cursor) {\n\t\t\tconst decoded = decodeCursor(options.cursor);\n\t\t\tquery = query.where((eb: ExpressionBuilder<Database, \"_emdash_comments\">) =>\n\t\t\t\teb.or([\n\t\t\t\t\teb(\"created_at\", \"<\", decoded.orderValue),\n\t\t\t\t\teb.and([eb(\"created_at\", \"=\", decoded.orderValue), eb(\"id\", \"<\", decoded.id)]),\n\t\t\t\t]),\n\t\t\t);\n\t\t}\n\n\t\tquery = query\n\t\t\t.orderBy(\"created_at\", \"desc\")\n\t\t\t.orderBy(\"id\", \"desc\")\n\t\t\t.limit(limit + 1);\n\n\t\tconst rows = await query.execute();\n\t\tconst hasMore = rows.length > limit;\n\t\tconst items = rows.slice(0, limit).map((r) => this.rowToComment(r));\n\n\t\tconst result: FindManyResult<Comment> = { items };\n\t\tif (hasMore && items.length > 0) {\n\t\t\tconst last = items.at(-1)!;\n\t\t\tresult.nextCursor = encodeCursor(last.createdAt, last.id);\n\t\t}\n\t\treturn result;\n\t}\n\n\t/**\n\t * Update comment status\n\t */\n\tasync updateStatus(id: string, status: CommentStatus): Promise<Comment | null> {\n\t\tconst now = new Date().toISOString();\n\n\t\tawait this.db\n\t\t\t.updateTable(\"_emdash_comments\")\n\t\t\t.set({ status, updated_at: now })\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.execute();\n\n\t\treturn this.findById(id);\n\t}\n\n\t/**\n\t * Bulk update comment statuses\n\t */\n\tasync bulkUpdateStatus(ids: string[], status: CommentStatus): Promise<number> {\n\t\tif (ids.length === 0) return 0;\n\n\t\tconst now = new Date().toISOString();\n\n\t\tconst result = await this.db\n\t\t\t.updateTable(\"_emdash_comments\")\n\t\t\t.set({ status, updated_at: now })\n\t\t\t.where(\"id\", \"in\", ids)\n\t\t\t.executeTakeFirst();\n\n\t\treturn Number(result.numUpdatedRows ?? 0);\n\t}\n\n\t/**\n\t * Hard-delete a single comment. Replies cascade via FK.\n\t */\n\tasync delete(id: string): Promise<boolean> {\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"_emdash_comments\")\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirst();\n\n\t\treturn (result.numDeletedRows ?? 0) > 0;\n\t}\n\n\t/**\n\t * Bulk hard-delete comments\n\t */\n\tasync bulkDelete(ids: string[]): Promise<number> {\n\t\tif (ids.length === 0) return 0;\n\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"_emdash_comments\")\n\t\t\t.where(\"id\", \"in\", ids)\n\t\t\t.executeTakeFirst();\n\n\t\treturn Number(result.numDeletedRows ?? 0);\n\t}\n\n\t/**\n\t * Delete all comments for a content item (cascade on content deletion)\n\t */\n\tasync deleteByContent(collection: string, contentId: string): Promise<number> {\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"_emdash_comments\")\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"content_id\", \"=\", contentId)\n\t\t\t.executeTakeFirst();\n\n\t\treturn Number(result.numDeletedRows ?? 0);\n\t}\n\n\t/**\n\t * Count comments for a content item, optionally filtered by status\n\t */\n\tasync countByContent(\n\t\tcollection: string,\n\t\tcontentId: string,\n\t\tstatus?: CommentStatus,\n\t): Promise<number> {\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"content_id\", \"=\", contentId);\n\n\t\tif (status) {\n\t\t\tquery = query.where(\"status\", \"=\", status);\n\t\t}\n\n\t\tconst result = await query.executeTakeFirst();\n\t\treturn Number(result?.count ?? 0);\n\t}\n\n\t/**\n\t * Count comments grouped by status (for inbox badges)\n\t *\n\t * Uses four parallel COUNT queries with WHERE filters to leverage partial indexes\n\t * (idx_comments_pending, idx_comments_approved, idx_comments_spam, idx_comments_trash)\n\t * instead of a full table GROUP BY scan.\n\t */\n\tasync countByStatus(): Promise<Record<CommentStatus, number>> {\n\t\t// Execute four parallel COUNT queries, each using its partial index\n\t\tconst [pending, approved, spam, trash] = await Promise.all([\n\t\t\tthis.db\n\t\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t\t.where(\"status\", \"=\", \"pending\")\n\t\t\t\t.executeTakeFirst(),\n\t\t\tthis.db\n\t\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t\t.where(\"status\", \"=\", \"approved\")\n\t\t\t\t.executeTakeFirst(),\n\t\t\tthis.db\n\t\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t\t.where(\"status\", \"=\", \"spam\")\n\t\t\t\t.executeTakeFirst(),\n\t\t\tthis.db\n\t\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t\t.where(\"status\", \"=\", \"trash\")\n\t\t\t\t.executeTakeFirst(),\n\t\t]);\n\n\t\treturn {\n\t\t\tpending: Number(pending?.count ?? 0),\n\t\t\tapproved: Number(approved?.count ?? 0),\n\t\t\tspam: Number(spam?.count ?? 0),\n\t\t\ttrash: Number(trash?.count ?? 0),\n\t\t};\n\t}\n\n\t/**\n\t * Count approved comments from a given email address.\n\t * Used for \"first time commenter\" moderation logic.\n\t */\n\tasync countApprovedByEmail(email: string): Promise<number> {\n\t\tconst result = await this.db\n\t\t\t.selectFrom(\"_emdash_comments\")\n\t\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t\t.where(\"author_email\", \"=\", email)\n\t\t\t.where(\"status\", \"=\", \"approved\")\n\t\t\t.executeTakeFirst();\n\n\t\treturn Number(result?.count ?? 0);\n\t}\n\n\t/**\n\t * Update the moderation metadata JSON on a comment\n\t */\n\tasync updateModerationMetadata(id: string, metadata: Record<string, unknown>): Promise<void> {\n\t\tawait this.db\n\t\t\t.updateTable(\"_emdash_comments\")\n\t\t\t.set({ moderation_metadata: JSON.stringify(metadata) })\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.execute();\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// Helpers\n\t// ---------------------------------------------------------------------------\n\n\t/**\n\t * Assemble a flat list of comments into a threaded structure (1-level nesting)\n\t */\n\tstatic assembleThreads(comments: Comment[]): Comment[] {\n\t\tconst roots: Comment[] = [];\n\t\tconst childrenMap = new Map<string, Comment[]>();\n\n\t\tfor (const comment of comments) {\n\t\t\tif (comment.parentId) {\n\t\t\t\tconst siblings = childrenMap.get(comment.parentId) ?? [];\n\t\t\t\tsiblings.push(comment);\n\t\t\t\tchildrenMap.set(comment.parentId, siblings);\n\t\t\t} else {\n\t\t\t\troots.push(comment);\n\t\t\t}\n\t\t}\n\n\t\t// Attach children as a non-standard property — callers map to PublicComment.replies\n\t\treturn roots.map((root) => ({\n\t\t\t...root,\n\t\t\t_replies: childrenMap.get(root.id) ?? [],\n\t\t})) as Comment[];\n\t}\n\n\t/**\n\t * Convert a Comment to its public-facing shape\n\t */\n\tstatic toPublicComment(comment: Comment & { _replies?: Comment[] }): PublicComment {\n\t\tconst pub: PublicComment = {\n\t\t\tid: comment.id,\n\t\t\tparentId: comment.parentId,\n\t\t\tauthorName: comment.authorName,\n\t\t\tisRegisteredUser: comment.authorUserId !== null,\n\t\t\tbody: comment.body,\n\t\t\tcreatedAt: comment.createdAt,\n\t\t};\n\n\t\tif (comment._replies && comment._replies.length > 0) {\n\t\t\tpub.replies = comment._replies.map((r) => CommentRepository.toPublicComment(r));\n\t\t}\n\n\t\treturn pub;\n\t}\n\n\t// eslint-disable-next-line @typescript-eslint/no-explicit-any -- selectAll returns runtime row\n\tprivate rowToComment(row: any): Comment {\n\t\treturn {\n\t\t\tid: row.id,\n\t\t\tcollection: row.collection,\n\t\t\tcontentId: row.content_id,\n\t\t\tparentId: row.parent_id,\n\t\t\tauthorName: row.author_name,\n\t\t\tauthorEmail: row.author_email,\n\t\t\tauthorUserId: row.author_user_id,\n\t\t\tbody: row.body,\n\t\t\tstatus: row.status as CommentStatus,\n\t\t\tipHash: row.ip_hash,\n\t\t\tuserAgent: row.user_agent,\n\t\t\tmoderationMetadata: row.moderation_metadata ? safeJsonParse(row.moderation_metadata) : null,\n\t\t\tcreatedAt: row.created_at,\n\t\t\tupdatedAt: row.updated_at,\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Module helpers\n// ---------------------------------------------------------------------------\n\nfunction safeJsonParse(value: string): Record<string, unknown> | null {\n\ttry {\n\t\treturn JSON.parse(value) as Record<string, unknown>;\n\t} catch {\n\t\treturn null;\n\t}\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as InvalidCursorError } from "./types-
|
|
2
|
-
import { t as CommentRepository } from "./comment-
|
|
1
|
+
import { n as InvalidCursorError } from "./types-CfyYQ7eY.mjs";
|
|
2
|
+
import { t as CommentRepository } from "./comment-DkAfGX9E.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/api/handlers/comments.ts
|
|
5
5
|
async function handleCommentList(db, collection, contentId, options = {}) {
|
|
@@ -201,4 +201,4 @@ async function hashIp(ip, salt) {
|
|
|
201
201
|
|
|
202
202
|
//#endregion
|
|
203
203
|
export { handleCommentGet as a, hashIp as c, handleCommentDelete as i, handleCommentBulk as n, handleCommentInbox as o, handleCommentCounts as r, handleCommentList as s, checkRateLimit as t };
|
|
204
|
-
//# sourceMappingURL=comments-
|
|
204
|
+
//# sourceMappingURL=comments-DLFnXs7J.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"comments-B7ufhkxN.mjs","names":[],"sources":["../src/api/handlers/comments.ts"],"sourcesContent":["/**\n * Comment handlers — business logic for comment API routes.\n *\n * Standalone functions that return ApiResult<T>. Routes are thin wrappers.\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { CommentRepository } from \"../../database/repositories/comment.js\";\nimport type { Comment, CommentStatus, PublicComment } from \"../../database/repositories/comment.js\";\nimport { InvalidCursorError } from \"../../database/repositories/types.js\";\nimport type { Database } from \"../../database/types.js\";\nimport type { ApiResult } from \"../types.js\";\n\n// ---------------------------------------------------------------------------\n// Public: List approved comments for content\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentList(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tcontentId: string,\n\toptions: { limit?: number; cursor?: string; threaded?: boolean } = {},\n): Promise<ApiResult<{ items: PublicComment[]; nextCursor?: string; total: number }>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\n\t\t// Get total approved count\n\t\tconst total = await repo.countByContent(collection, contentId, \"approved\");\n\n\t\tlet publicItems: PublicComment[];\n\t\tlet nextCursor: string | undefined;\n\n\t\tif (options.threaded) {\n\t\t\t// Threaded mode: fetch all approved comments (capped) so threading\n\t\t\t// doesn't lose children that would fall on later pages.\n\t\t\tconst MAX_THREADED = 500;\n\t\t\tconst result = await repo.findByContent(collection, contentId, {\n\t\t\t\tstatus: \"approved\",\n\t\t\t\tlimit: MAX_THREADED,\n\t\t\t});\n\t\t\tconst threaded = CommentRepository.assembleThreads(result.items);\n\t\t\tpublicItems = threaded.map((c) => CommentRepository.toPublicComment(c));\n\t\t\t// No cursor for threaded mode — all comments returned at once\n\t\t} else {\n\t\t\tconst result = await repo.findByContent(collection, contentId, {\n\t\t\t\tstatus: \"approved\",\n\t\t\t\tlimit: options.limit,\n\t\t\t\tcursor: options.cursor,\n\t\t\t});\n\t\t\tpublicItems = result.items.map((c) => CommentRepository.toPublicComment(c));\n\t\t\tnextCursor = result.nextCursor;\n\t\t}\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\titems: publicItems,\n\t\t\t\tnextCursor,\n\t\t\t\ttotal,\n\t\t\t},\n\t\t};\n\t} catch (error) {\n\t\tif (error instanceof InvalidCursorError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_CURSOR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\tconsole.error(\"Comment list error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_LIST_ERROR\",\n\t\t\t\tmessage: \"Failed to list comments\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Moderation inbox\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentInbox(\n\tdb: Kysely<Database>,\n\toptions: {\n\t\tstatus?: CommentStatus;\n\t\tcollection?: string;\n\t\tsearch?: string;\n\t\tlimit?: number;\n\t\tcursor?: string;\n\t} = {},\n): Promise<ApiResult<{ items: Comment[]; nextCursor?: string }>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\t\tconst status = options.status ?? \"pending\";\n\n\t\tconst result = await repo.findByStatus(status, {\n\t\t\tcollection: options.collection,\n\t\t\tsearch: options.search,\n\t\t\tlimit: options.limit,\n\t\t\tcursor: options.cursor,\n\t\t});\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\titems: result.items,\n\t\t\t\tnextCursor: result.nextCursor,\n\t\t\t},\n\t\t};\n\t} catch (error) {\n\t\tif (error instanceof InvalidCursorError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_CURSOR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\tconsole.error(\"Comment inbox error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_INBOX_ERROR\",\n\t\t\t\tmessage: \"Failed to list comments\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Status counts for inbox badges\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentCounts(\n\tdb: Kysely<Database>,\n): Promise<ApiResult<Record<CommentStatus, number>>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\t\tconst counts = await repo.countByStatus();\n\t\treturn { success: true, data: counts };\n\t} catch (error) {\n\t\tconsole.error(\"Comment counts error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_COUNTS_ERROR\",\n\t\t\t\tmessage: \"Failed to get comment counts\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Get single comment detail\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentGet(\n\tdb: Kysely<Database>,\n\tid: string,\n): Promise<ApiResult<Comment>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\t\tconst comment = await repo.findById(id);\n\n\t\tif (!comment) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Comment not found: ${id}` },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: comment };\n\t} catch (error) {\n\t\tconsole.error(\"Comment get error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_GET_ERROR\",\n\t\t\t\tmessage: \"Failed to get comment\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Change comment status\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentStatusChange(\n\tdb: Kysely<Database>,\n\tid: string,\n\tstatus: CommentStatus,\n): Promise<ApiResult<Comment>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\t\tconst updated = await repo.updateStatus(id, status);\n\n\t\tif (!updated) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Comment not found: ${id}` },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: updated };\n\t} catch (error) {\n\t\tconsole.error(\"Comment status change error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_STATUS_ERROR\",\n\t\t\t\tmessage: \"Failed to update comment status\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Hard delete comment\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentDelete(\n\tdb: Kysely<Database>,\n\tid: string,\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\t\tconst deleted = await repo.delete(id);\n\n\t\tif (!deleted) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Comment not found: ${id}` },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch (error) {\n\t\tconsole.error(\"Comment delete error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_DELETE_ERROR\",\n\t\t\t\tmessage: \"Failed to delete comment\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Bulk operations\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentBulk(\n\tdb: Kysely<Database>,\n\tids: string[],\n\taction: \"approve\" | \"spam\" | \"trash\" | \"delete\",\n): Promise<ApiResult<{ affected: number }>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\n\t\tlet affected: number;\n\t\tif (action === \"delete\") {\n\t\t\taffected = await repo.bulkDelete(ids);\n\t\t} else {\n\t\t\tconst statusMap: Record<string, CommentStatus> = {\n\t\t\t\tapprove: \"approved\",\n\t\t\t\tspam: \"spam\",\n\t\t\t\ttrash: \"trash\",\n\t\t\t};\n\t\t\taffected = await repo.bulkUpdateStatus(ids, statusMap[action]);\n\t\t}\n\n\t\treturn { success: true, data: { affected } };\n\t} catch (error) {\n\t\tconsole.error(\"Comment bulk error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_BULK_ERROR\",\n\t\t\t\tmessage: \"Failed to perform bulk operation\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Anti-spam: Rate limiting\n// ---------------------------------------------------------------------------\n\n/**\n * Check if an IP has exceeded the comment rate limit.\n * Uses ip_hash in the comments table — no separate counter storage.\n */\nexport async function checkRateLimit(\n\tdb: Kysely<Database>,\n\tipHash: string,\n\tmaxPerWindow: number = 5,\n\twindowMinutes: number = 10,\n): Promise<boolean> {\n\tconst cutoff = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();\n\n\t// Count recent comments from this IP\n\tconst result = await db\n\t\t.selectFrom(\"_emdash_comments\")\n\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t.where(\"ip_hash\", \"=\", ipHash)\n\t\t.where(\"created_at\", \">\", cutoff)\n\t\t.executeTakeFirst();\n\n\tconst count = Number(result?.count ?? 0);\n\treturn count >= maxPerWindow;\n}\n\n/**\n * Hash an IP address for storage (never store cleartext IPs).\n *\n * Uses full SHA-256 with a site-specific salt to prevent rainbow-table\n * recovery of IPs. The salt must be provided by the caller — typically\n * via `resolveSecretsCached(db).ipSalt` from `#config/secrets.js`. The\n * salt is generated and persisted on first need so it's stable across\n * requests within a deployment but unique per install.\n */\nexport async function hashIp(ip: string, salt: string): Promise<string> {\n\tconst data = `ip:${salt}:${ip}`;\n\tconst buf = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(data));\n\treturn Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"comments-DLFnXs7J.mjs","names":[],"sources":["../src/api/handlers/comments.ts"],"sourcesContent":["/**\n * Comment handlers — business logic for comment API routes.\n *\n * Standalone functions that return ApiResult<T>. Routes are thin wrappers.\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { CommentRepository } from \"../../database/repositories/comment.js\";\nimport type { Comment, CommentStatus, PublicComment } from \"../../database/repositories/comment.js\";\nimport { InvalidCursorError } from \"../../database/repositories/types.js\";\nimport type { Database } from \"../../database/types.js\";\nimport type { ApiResult } from \"../types.js\";\n\n// ---------------------------------------------------------------------------\n// Public: List approved comments for content\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentList(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tcontentId: string,\n\toptions: { limit?: number; cursor?: string; threaded?: boolean } = {},\n): Promise<ApiResult<{ items: PublicComment[]; nextCursor?: string; total: number }>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\n\t\t// Get total approved count\n\t\tconst total = await repo.countByContent(collection, contentId, \"approved\");\n\n\t\tlet publicItems: PublicComment[];\n\t\tlet nextCursor: string | undefined;\n\n\t\tif (options.threaded) {\n\t\t\t// Threaded mode: fetch all approved comments (capped) so threading\n\t\t\t// doesn't lose children that would fall on later pages.\n\t\t\tconst MAX_THREADED = 500;\n\t\t\tconst result = await repo.findByContent(collection, contentId, {\n\t\t\t\tstatus: \"approved\",\n\t\t\t\tlimit: MAX_THREADED,\n\t\t\t});\n\t\t\tconst threaded = CommentRepository.assembleThreads(result.items);\n\t\t\tpublicItems = threaded.map((c) => CommentRepository.toPublicComment(c));\n\t\t\t// No cursor for threaded mode — all comments returned at once\n\t\t} else {\n\t\t\tconst result = await repo.findByContent(collection, contentId, {\n\t\t\t\tstatus: \"approved\",\n\t\t\t\tlimit: options.limit,\n\t\t\t\tcursor: options.cursor,\n\t\t\t});\n\t\t\tpublicItems = result.items.map((c) => CommentRepository.toPublicComment(c));\n\t\t\tnextCursor = result.nextCursor;\n\t\t}\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\titems: publicItems,\n\t\t\t\tnextCursor,\n\t\t\t\ttotal,\n\t\t\t},\n\t\t};\n\t} catch (error) {\n\t\tif (error instanceof InvalidCursorError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_CURSOR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\tconsole.error(\"Comment list error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_LIST_ERROR\",\n\t\t\t\tmessage: \"Failed to list comments\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Moderation inbox\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentInbox(\n\tdb: Kysely<Database>,\n\toptions: {\n\t\tstatus?: CommentStatus;\n\t\tcollection?: string;\n\t\tsearch?: string;\n\t\tlimit?: number;\n\t\tcursor?: string;\n\t} = {},\n): Promise<ApiResult<{ items: Comment[]; nextCursor?: string }>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\t\tconst status = options.status ?? \"pending\";\n\n\t\tconst result = await repo.findByStatus(status, {\n\t\t\tcollection: options.collection,\n\t\t\tsearch: options.search,\n\t\t\tlimit: options.limit,\n\t\t\tcursor: options.cursor,\n\t\t});\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\titems: result.items,\n\t\t\t\tnextCursor: result.nextCursor,\n\t\t\t},\n\t\t};\n\t} catch (error) {\n\t\tif (error instanceof InvalidCursorError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_CURSOR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\tconsole.error(\"Comment inbox error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_INBOX_ERROR\",\n\t\t\t\tmessage: \"Failed to list comments\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Status counts for inbox badges\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentCounts(\n\tdb: Kysely<Database>,\n): Promise<ApiResult<Record<CommentStatus, number>>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\t\tconst counts = await repo.countByStatus();\n\t\treturn { success: true, data: counts };\n\t} catch (error) {\n\t\tconsole.error(\"Comment counts error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_COUNTS_ERROR\",\n\t\t\t\tmessage: \"Failed to get comment counts\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Get single comment detail\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentGet(\n\tdb: Kysely<Database>,\n\tid: string,\n): Promise<ApiResult<Comment>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\t\tconst comment = await repo.findById(id);\n\n\t\tif (!comment) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Comment not found: ${id}` },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: comment };\n\t} catch (error) {\n\t\tconsole.error(\"Comment get error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_GET_ERROR\",\n\t\t\t\tmessage: \"Failed to get comment\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Change comment status\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentStatusChange(\n\tdb: Kysely<Database>,\n\tid: string,\n\tstatus: CommentStatus,\n): Promise<ApiResult<Comment>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\t\tconst updated = await repo.updateStatus(id, status);\n\n\t\tif (!updated) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Comment not found: ${id}` },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: updated };\n\t} catch (error) {\n\t\tconsole.error(\"Comment status change error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_STATUS_ERROR\",\n\t\t\t\tmessage: \"Failed to update comment status\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Hard delete comment\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentDelete(\n\tdb: Kysely<Database>,\n\tid: string,\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\t\tconst deleted = await repo.delete(id);\n\n\t\tif (!deleted) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Comment not found: ${id}` },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch (error) {\n\t\tconsole.error(\"Comment delete error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_DELETE_ERROR\",\n\t\t\t\tmessage: \"Failed to delete comment\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Admin: Bulk operations\n// ---------------------------------------------------------------------------\n\nexport async function handleCommentBulk(\n\tdb: Kysely<Database>,\n\tids: string[],\n\taction: \"approve\" | \"spam\" | \"trash\" | \"delete\",\n): Promise<ApiResult<{ affected: number }>> {\n\ttry {\n\t\tconst repo = new CommentRepository(db);\n\n\t\tlet affected: number;\n\t\tif (action === \"delete\") {\n\t\t\taffected = await repo.bulkDelete(ids);\n\t\t} else {\n\t\t\tconst statusMap: Record<string, CommentStatus> = {\n\t\t\t\tapprove: \"approved\",\n\t\t\t\tspam: \"spam\",\n\t\t\t\ttrash: \"trash\",\n\t\t\t};\n\t\t\taffected = await repo.bulkUpdateStatus(ids, statusMap[action]);\n\t\t}\n\n\t\treturn { success: true, data: { affected } };\n\t} catch (error) {\n\t\tconsole.error(\"Comment bulk error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"COMMENT_BULK_ERROR\",\n\t\t\t\tmessage: \"Failed to perform bulk operation\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Anti-spam: Rate limiting\n// ---------------------------------------------------------------------------\n\n/**\n * Check if an IP has exceeded the comment rate limit.\n * Uses ip_hash in the comments table — no separate counter storage.\n */\nexport async function checkRateLimit(\n\tdb: Kysely<Database>,\n\tipHash: string,\n\tmaxPerWindow: number = 5,\n\twindowMinutes: number = 10,\n): Promise<boolean> {\n\tconst cutoff = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();\n\n\t// Count recent comments from this IP\n\tconst result = await db\n\t\t.selectFrom(\"_emdash_comments\")\n\t\t.select((eb) => eb.fn.count(\"id\").as(\"count\"))\n\t\t.where(\"ip_hash\", \"=\", ipHash)\n\t\t.where(\"created_at\", \">\", cutoff)\n\t\t.executeTakeFirst();\n\n\tconst count = Number(result?.count ?? 0);\n\treturn count >= maxPerWindow;\n}\n\n/**\n * Hash an IP address for storage (never store cleartext IPs).\n *\n * Uses full SHA-256 with a site-specific salt to prevent rainbow-table\n * recovery of IPs. The salt must be provided by the caller — typically\n * via `resolveSecretsCached(db).ipSalt` from `#config/secrets.js`. The\n * salt is generated and persisted on first need so it's stable across\n * requests within a deployment but unique per install.\n */\nexport async function hashIp(ip: string, salt: string): Promise<string> {\n\tconst data = `ip:${salt}:${ip}`;\n\tconst buf = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(data));\n\treturn Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n"],"mappings":"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"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { i as __exportAll } from "./runner-
|
|
1
|
+
import { i as __exportAll } from "./runner-BiuUfx-V.mjs";
|
|
2
2
|
import { t as validateIdentifier } from "./validate-VPnKoIzW.mjs";
|
|
3
3
|
import { n as slugify } from "./slugify-Cjh1ssOZ.mjs";
|
|
4
|
-
import { i as encodeCursor, r as decodeCursor, t as EmDashValidationError } from "./types-
|
|
4
|
+
import { i as encodeCursor, r as decodeCursor, t as EmDashValidationError } from "./types-CfyYQ7eY.mjs";
|
|
5
5
|
import { sql } from "kysely";
|
|
6
6
|
import { monotonicFactory, ulid } from "ulidx";
|
|
7
7
|
|
|
@@ -898,4 +898,4 @@ var ContentRepository = class {
|
|
|
898
898
|
|
|
899
899
|
//#endregion
|
|
900
900
|
export { content_exports as n, RevisionRepository as r, ContentRepository as t };
|
|
901
|
-
//# sourceMappingURL=content-
|
|
901
|
+
//# sourceMappingURL=content-C7aJ7keg.mjs.map
|