emdash 0.5.0 → 0.7.0
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/{adapters-C2BzVy0p.d.mts → adapters-Di31kZ28.d.mts} +16 -1
- package/dist/adapters-Di31kZ28.d.mts.map +1 -0
- package/dist/{apply-Cma_PiF6.mjs → apply-5uslYdUu.mjs} +197 -25
- package/dist/apply-5uslYdUu.mjs.map +1 -0
- package/dist/astro/index.d.mts +6 -6
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +203 -33
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/auth.d.mts.map +1 -1
- package/dist/astro/middleware/auth.mjs +30 -4
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/redirect.mjs +2 -2
- package/dist/astro/middleware/request-context.d.mts.map +1 -1
- package/dist/astro/middleware/request-context.mjs +11 -4
- package/dist/astro/middleware/request-context.mjs.map +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +467 -186
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +17 -9
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
- package/dist/byline-C4OVd8b3.mjs.map +1 -0
- package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
- package/dist/bylines-hPTW79hw.mjs.map +1 -0
- package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
- package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
- package/dist/chunks-HGz06Soa.mjs +19 -0
- package/dist/chunks-HGz06Soa.mjs.map +1 -0
- package/dist/cli/index.mjs +12 -11
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +1 -1
- package/dist/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
- package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
- package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
- package/dist/connection-2igzM-AT.mjs.map +1 -0
- package/dist/{content-BsBoyj8G.mjs → content-D7J5y73J.mjs} +27 -1
- package/dist/{content-BsBoyj8G.mjs.map → content-D7J5y73J.mjs.map} +1 -1
- package/dist/database/instrumentation.d.mts +45 -0
- package/dist/database/instrumentation.d.mts.map +1 -0
- package/dist/database/instrumentation.mjs +61 -0
- package/dist/database/instrumentation.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +1 -1
- package/dist/db/index.mjs.map +1 -1
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/db-errors-D0UT85nC.mjs +41 -0
- package/dist/db-errors-D0UT85nC.mjs.map +1 -0
- package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
- package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
- package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
- package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
- package/dist/{index-CCWzlriB.d.mts → index-De6_Xv3v.d.mts} +209 -19
- package/dist/index-De6_Xv3v.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +23 -21
- package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
- package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
- package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
- package/dist/loader-DeiBJEMe.mjs.map +1 -0
- package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
- package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
- package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
- package/dist/page/index.d.mts +11 -2
- package/dist/page/index.d.mts.map +1 -1
- package/dist/page/index.mjs +23 -1
- package/dist/page/index.mjs.map +1 -1
- package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
- package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
- package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
- package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.map} +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-B6Vu0d2i.mjs → query-g4Ug-9j9.mjs} +79 -12
- package/dist/query-g4Ug-9j9.mjs.map +1 -0
- package/dist/{redirect-7lGhLBNZ.mjs → redirect-CN0Rt9Ob.mjs} +66 -10
- package/dist/redirect-CN0Rt9Ob.mjs.map +1 -0
- package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
- package/dist/registry-Ci3WxVAr.mjs.map +1 -0
- package/dist/request-cache-DiR961CV.mjs +79 -0
- package/dist/request-cache-DiR961CV.mjs.map +1 -0
- package/dist/request-context.d.mts +19 -16
- package/dist/request-context.d.mts.map +1 -1
- package/dist/request-context.mjs.map +1 -1
- package/dist/{runner-DYv3rX8P.d.mts → runner-BR2xKwhn.d.mts} +2 -2
- package/dist/{runner-DYv3rX8P.d.mts.map → runner-BR2xKwhn.d.mts.map} +1 -1
- package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
- package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +1 -1
- package/dist/{search-Cn1SYvYF.mjs → search-B0effn3j.mjs} +210 -226
- package/dist/search-B0effn3j.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +10 -9
- package/dist/seo/index.d.mts +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.d.mts +1 -1
- package/dist/storage/s3.mjs +1 -1
- package/dist/taxonomies-K2z0Uhnj.mjs +308 -0
- package/dist/taxonomies-K2z0Uhnj.mjs.map +1 -0
- package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
- package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
- package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
- package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
- package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
- package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
- package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
- package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
- package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
- package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
- package/dist/{types-B6BzlZxx.d.mts → types-C2v0c34j.d.mts} +10 -1
- package/dist/{types-B6BzlZxx.d.mts.map → types-C2v0c34j.d.mts.map} +1 -1
- package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
- package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
- package/dist/{types-DeG21anB.d.mts → types-CnZYHyLW.d.mts} +55 -5
- package/dist/types-CnZYHyLW.d.mts.map +1 -0
- package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
- package/dist/types-DDS4MxsT.mjs.map +1 -0
- package/dist/{types-C3ronwXb.d.mts → types-DgrIP0tF.d.mts} +102 -4
- package/dist/types-DgrIP0tF.d.mts.map +1 -0
- package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
- package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
- package/dist/{validate-Db1yNL3i.d.mts → validate-kM8Pjuf7.d.mts} +5 -52
- package/dist/validate-kM8Pjuf7.d.mts.map +1 -0
- package/dist/version-BnTKdfam.mjs +7 -0
- package/dist/{version-CMMjTuqu.mjs.map → version-BnTKdfam.mjs.map} +1 -1
- package/package.json +10 -5
- package/src/after.ts +62 -0
- package/src/api/handlers/content.ts +2 -0
- package/src/api/handlers/oauth-authorization.ts +2 -32
- package/src/api/handlers/oauth-clients.ts +40 -4
- package/src/api/handlers/taxonomies.ts +13 -0
- package/src/api/oauth/redirect-uri.ts +34 -0
- package/src/api/openapi/document.ts +126 -118
- package/src/api/schemas/content.ts +8 -0
- package/src/api/schemas/media.ts +26 -15
- package/src/api/schemas/schema.ts +1 -0
- package/src/astro/integration/font-provider.ts +178 -0
- package/src/astro/integration/index.ts +44 -0
- package/src/astro/integration/routes.ts +6 -0
- package/src/astro/integration/runtime.ts +117 -0
- package/src/astro/integration/virtual-modules.ts +41 -39
- package/src/astro/integration/vite-config.ts +16 -5
- package/src/astro/middleware/auth.ts +33 -1
- package/src/astro/middleware/request-context.ts +15 -3
- package/src/astro/middleware.ts +340 -263
- package/src/astro/routes/admin.astro +21 -10
- package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
- package/src/astro/routes/api/auth/passkey/options.ts +2 -1
- package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
- package/src/astro/routes/api/auth/signup/request.ts +26 -8
- package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
- package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
- package/src/astro/routes/api/content/[collection]/[id]/translations.ts +26 -0
- package/src/astro/routes/api/content/[collection]/[id].ts +30 -2
- package/src/astro/routes/api/content/[collection]/index.ts +19 -1
- package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
- package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
- package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +5 -4
- package/src/astro/routes/api/manifest.ts +7 -0
- package/src/astro/routes/api/media/upload-url.ts +10 -2
- package/src/astro/routes/api/media.ts +10 -7
- package/src/astro/routes/api/oauth/device/code.ts +2 -1
- package/src/astro/routes/api/oauth/device/token.ts +2 -1
- package/src/astro/routes/api/oauth/register.ts +178 -0
- package/src/astro/routes/api/oauth/token.ts +15 -0
- package/src/astro/routes/api/openapi.json.ts +15 -5
- package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
- package/src/astro/routes/api/search/index.ts +5 -0
- package/src/astro/routes/api/search/suggest.ts +3 -0
- package/src/astro/routes/api/setup/admin-verify.ts +30 -5
- package/src/astro/routes/api/setup/admin.ts +32 -8
- package/src/astro/routes/api/setup/index.ts +5 -2
- package/src/astro/routes/api/taxonomies/index.ts +1 -0
- package/src/astro/routes/api/well-known/oauth-authorization-server.ts +1 -1
- package/src/astro/types.ts +9 -0
- package/src/auth/rate-limit.ts +50 -22
- package/src/auth/setup-nonce.ts +22 -0
- package/src/auth/trusted-proxy.ts +92 -0
- package/src/bylines/index.ts +22 -45
- package/src/components/EmDashHead.astro +23 -7
- package/src/database/connection.ts +23 -1
- package/src/database/instrumentation.ts +98 -0
- package/src/database/migrations/035_bounded_404_log.ts +112 -0
- package/src/database/migrations/runner.ts +2 -0
- package/src/database/repositories/content.ts +39 -0
- package/src/database/repositories/options.ts +25 -0
- package/src/database/repositories/redirect.ts +111 -8
- package/src/database/types.ts +9 -0
- package/src/db/adapters.ts +15 -0
- package/src/emdash-runtime.ts +312 -92
- package/src/import/registry.ts +4 -3
- package/src/import/ssrf.ts +253 -12
- package/src/index.ts +6 -0
- package/src/loader.ts +19 -24
- package/src/mcp/server.ts +76 -3
- package/src/menus/index.ts +6 -3
- package/src/page/index.ts +1 -1
- package/src/page/seo-contributions.ts +36 -0
- package/src/plugins/context.ts +15 -3
- package/src/plugins/manager.ts +6 -0
- package/src/plugins/request-meta.ts +66 -15
- package/src/plugins/routes.ts +3 -1
- package/src/query.ts +104 -7
- package/src/request-cache.ts +106 -0
- package/src/request-context.ts +19 -0
- package/src/schema/query.ts +5 -2
- package/src/schema/registry.ts +243 -166
- package/src/schema/types.ts +13 -2
- package/src/schema/zod-generator.ts +4 -0
- package/src/search/fts-manager.ts +19 -5
- package/src/search/query.ts +4 -3
- package/src/seed/apply.ts +41 -1
- package/src/settings/index.ts +24 -5
- package/src/taxonomies/index.ts +324 -124
- package/src/utils/db-errors.ts +46 -0
- package/src/virtual-modules.d.ts +31 -10
- package/src/visual-editing/toolbar.ts +6 -1
- package/src/widgets/index.ts +54 -25
- package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
- package/dist/apply-Cma_PiF6.mjs.map +0 -1
- package/dist/byline-WuOq9MFJ.mjs.map +0 -1
- package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
- package/dist/connection-B4zVnQIa.mjs.map +0 -1
- package/dist/index-CCWzlriB.d.mts.map +0 -1
- package/dist/loader-BYzwzORf.mjs.map +0 -1
- package/dist/query-B6Vu0d2i.mjs.map +0 -1
- package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
- package/dist/registry-BgnP3ysR.mjs.map +0 -1
- package/dist/runner-Cd-_WyDo.mjs.map +0 -1
- package/dist/search-Cn1SYvYF.mjs.map +0 -1
- package/dist/types-C3ronwXb.d.mts.map +0 -1
- package/dist/types-DeG21anB.d.mts.map +0 -1
- package/dist/types-xxCWI3j0.mjs.map +0 -1
- package/dist/validate-Db1yNL3i.d.mts.map +0 -1
- package/dist/version-CMMjTuqu.mjs +0 -7
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the list of client-IP headers the operator trusts.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order:
|
|
5
|
+
* 1. `config.trustedProxyHeaders` — explicit opt-in via astro.config.mjs.
|
|
6
|
+
* An empty array is respected (means "trust nothing, ignore env").
|
|
7
|
+
* 2. `EMDASH_TRUSTED_PROXY_HEADERS` env var — comma-separated header names.
|
|
8
|
+
* 3. `[]` — default, no trusted headers.
|
|
9
|
+
*
|
|
10
|
+
* Operators must only set this when they control the reverse proxy.
|
|
11
|
+
* Untrusted clients can set any header they like; trusting headers from
|
|
12
|
+
* an open network defeats rate limiting.
|
|
13
|
+
*
|
|
14
|
+
* Header names are returned lowercased because HTTP header lookups are
|
|
15
|
+
* case-insensitive.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { EmDashConfig } from "../astro/integration/runtime.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* RFC 7230 token — valid characters for an HTTP header name. Invalid names
|
|
22
|
+
* passed to `Headers.get()` throw a TypeError at runtime, which would
|
|
23
|
+
* otherwise surface as a 500 from every auth route.
|
|
24
|
+
*/
|
|
25
|
+
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9a-z]+$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalise a list of header names the way both the config path and any
|
|
29
|
+
* caller passing a pre-resolved list should do: trim, lowercase, drop
|
|
30
|
+
* empty, drop anything that isn't a valid RFC 7230 token. Invalid names
|
|
31
|
+
* would crash `Headers.get()` at runtime.
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeTrustedHeaders(names: readonly string[]): string[] {
|
|
34
|
+
return names
|
|
35
|
+
.map((h) => h.trim().toLowerCase())
|
|
36
|
+
.filter((h) => h.length > 0 && HEADER_NAME_PATTERN.test(h));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isValidHeaderName(name: string): boolean {
|
|
40
|
+
return HEADER_NAME_PATTERN.test(name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Cache for the env-derived value. `null` means "not yet parsed". */
|
|
44
|
+
let _envCache: string[] | null = null;
|
|
45
|
+
|
|
46
|
+
/** Test-only: clear the env cache so a fresh value is read on next call. */
|
|
47
|
+
export function _resetTrustedProxyHeadersCache(): void {
|
|
48
|
+
_envCache = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getEnvTrustedHeaders(): string[] {
|
|
52
|
+
if (_envCache !== null) return _envCache;
|
|
53
|
+
let raw: string | undefined;
|
|
54
|
+
try {
|
|
55
|
+
// Prefer process.env so SSR/container deployments can override this
|
|
56
|
+
// value at runtime (Vite/Astro inline import.meta.env at build time,
|
|
57
|
+
// which locks the value into the bundle). Fall back to import.meta.env
|
|
58
|
+
// for bundler-managed environments where process.env isn't populated.
|
|
59
|
+
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- import.meta.env shape varies by bundler
|
|
60
|
+
const importMetaEnv = (import.meta as unknown as { env?: Record<string, string | undefined> })
|
|
61
|
+
.env;
|
|
62
|
+
raw =
|
|
63
|
+
(typeof process !== "undefined" ? process.env?.EMDASH_TRUSTED_PROXY_HEADERS : undefined) ||
|
|
64
|
+
importMetaEnv?.EMDASH_TRUSTED_PROXY_HEADERS;
|
|
65
|
+
} catch {
|
|
66
|
+
raw = undefined;
|
|
67
|
+
}
|
|
68
|
+
if (!raw) {
|
|
69
|
+
_envCache = [];
|
|
70
|
+
return _envCache;
|
|
71
|
+
}
|
|
72
|
+
_envCache = raw
|
|
73
|
+
.split(",")
|
|
74
|
+
.map((s) => s.trim().toLowerCase())
|
|
75
|
+
.filter((s) => s.length > 0 && isValidHeaderName(s));
|
|
76
|
+
return _envCache;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Return the lowercased list of headers to trust for client-IP resolution.
|
|
81
|
+
*
|
|
82
|
+
* When `config?.trustedProxyHeaders` is explicitly set (even to `[]`), it
|
|
83
|
+
* wins. Otherwise fall through to the env var, then to `[]`.
|
|
84
|
+
*/
|
|
85
|
+
export function getTrustedProxyHeaders(config: EmDashConfig | null | undefined): string[] {
|
|
86
|
+
if (config && config.trustedProxyHeaders !== undefined) {
|
|
87
|
+
return config.trustedProxyHeaders
|
|
88
|
+
.map((h) => h.trim().toLowerCase())
|
|
89
|
+
.filter((h) => h.length > 0 && isValidHeaderName(h));
|
|
90
|
+
}
|
|
91
|
+
return getEnvTrustedHeaders();
|
|
92
|
+
}
|
package/src/bylines/index.ts
CHANGED
|
@@ -13,47 +13,19 @@ import type { BylineSummary, ContentBylineCredit } from "../database/repositorie
|
|
|
13
13
|
import { validateIdentifier } from "../database/validate.js";
|
|
14
14
|
import { getDb } from "../loader.js";
|
|
15
15
|
import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
|
|
16
|
+
import { isMissingTableError } from "../utils/db-errors.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
* Invalidate the cached "has any bylines" check.
|
|
26
|
-
* Call this when bylines are created, updated, or deleted.
|
|
19
|
+
* No-op — kept for API compatibility.
|
|
20
|
+
*
|
|
21
|
+
* Used to invalidate a worker-lifetime "has any byline?" probe. That
|
|
22
|
+
* probe added a query on every cold isolate to save one query on sites
|
|
23
|
+
* with zero bylines (i.e. the wrong tradeoff), so we dropped it. The
|
|
24
|
+
* batch byline join below returns an empty map for empty sites at the
|
|
25
|
+
* same cost as the probe, without the pre-check.
|
|
27
26
|
*/
|
|
28
27
|
export function invalidateBylineCache(): void {
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Check if any bylines exist in the database. Result is cached
|
|
34
|
-
* for the lifetime of the worker/process and invalidated on writes.
|
|
35
|
-
*/
|
|
36
|
-
async function hasAnyBylines(): Promise<boolean> {
|
|
37
|
-
if (hasBylines !== null) return hasBylines;
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
const db = await getDb();
|
|
41
|
-
const result = await sql<{ id: string }>`
|
|
42
|
-
SELECT id FROM _emdash_bylines LIMIT 1
|
|
43
|
-
`.execute(db);
|
|
44
|
-
hasBylines = result.rows.length > 0;
|
|
45
|
-
} catch (error: unknown) {
|
|
46
|
-
// Only treat "no such table" as a safe false -- anything else should
|
|
47
|
-
// not be cached so the next request retries.
|
|
48
|
-
const message = error instanceof Error ? error.message : "";
|
|
49
|
-
if (message.includes("no such table")) {
|
|
50
|
-
hasBylines = false;
|
|
51
|
-
} else {
|
|
52
|
-
return false;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return hasBylines;
|
|
28
|
+
// Intentionally empty.
|
|
57
29
|
}
|
|
58
30
|
|
|
59
31
|
/**
|
|
@@ -176,17 +148,22 @@ export async function getBylinesForEntries(
|
|
|
176
148
|
return result;
|
|
177
149
|
}
|
|
178
150
|
|
|
179
|
-
// Skip DB queries entirely when no bylines have been created.
|
|
180
|
-
// The cache is invalidated when bylines are created/deleted.
|
|
181
|
-
if (!(await hasAnyBylines())) {
|
|
182
|
-
return result;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
151
|
const db = await getDb();
|
|
186
152
|
const repo = new BylineRepository(db);
|
|
187
153
|
|
|
188
|
-
// 1. Batch fetch all explicit byline credits
|
|
189
|
-
|
|
154
|
+
// 1. Batch fetch all explicit byline credits. Sites with no bylines
|
|
155
|
+
// get an empty map back for one query — the previous "has any bylines"
|
|
156
|
+
// probe traded an extra round-trip on every request to save that one
|
|
157
|
+
// query on empty sites, which is exactly backwards for the common case.
|
|
158
|
+
// Pre-migration databases (bylines table missing) fall through to the
|
|
159
|
+
// `isMissingTableError` catch below and return empty results.
|
|
160
|
+
let bylinesMap;
|
|
161
|
+
try {
|
|
162
|
+
bylinesMap = await repo.getContentBylinesMany(collection, entryIds);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (isMissingTableError(error)) return result;
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
190
167
|
|
|
191
168
|
// 2. Collect entry IDs that need fallback lookup
|
|
192
169
|
const fallbackEntryIds: string[] = [];
|
|
@@ -16,8 +16,12 @@
|
|
|
16
16
|
import type { PublicPageContext, PageMetadataContribution } from "../plugins/types.js";
|
|
17
17
|
import { resolvePageMetadata, renderPageMetadata } from "../page/metadata.js";
|
|
18
18
|
import { renderFragments } from "../page/fragments.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
generateBaseSeoContributions,
|
|
21
|
+
generateSiteSeoContributions,
|
|
22
|
+
} from "../page/seo-contributions.js";
|
|
20
23
|
import { getPageRuntime } from "../page/index.js";
|
|
24
|
+
import { getSiteSetting } from "../settings/index.js";
|
|
21
25
|
|
|
22
26
|
interface Props {
|
|
23
27
|
page: PublicPageContext;
|
|
@@ -33,14 +37,26 @@ let metadataHtml = "";
|
|
|
33
37
|
let fragmentsHtml = "";
|
|
34
38
|
|
|
35
39
|
if (runtime) {
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
// Run independent async loads in parallel: site SEO settings (for
|
|
41
|
+
// search engine verification meta tags) and plugin page-metadata
|
|
42
|
+
// contributions. Plugin contributions come BEFORE site/base in the
|
|
43
|
+
// array, so resolvePageMetadata's first-wins dedup lets plugins
|
|
44
|
+
// override defaults.
|
|
45
|
+
//
|
|
46
|
+
// `getSiteSetting("seo")` is request-cached and — crucially — reads
|
|
47
|
+
// from `getSiteSettings()`'s cached batch when a parent template has
|
|
48
|
+
// already called it. So this is either a single-key query or free,
|
|
49
|
+
// not a second round-trip.
|
|
50
|
+
const [seoSettings, pluginContributions, fragments] = await Promise.all([
|
|
51
|
+
getSiteSetting("seo"),
|
|
52
|
+
runtime.collectPageMetadata(page),
|
|
53
|
+
runtime.collectPageFragments(page),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const siteContributions = generateSiteSeoContributions(seoSettings);
|
|
57
|
+
const allContributions = [...pluginContributions, ...siteContributions, ...baseContributions];
|
|
40
58
|
const resolved = resolvePageMetadata(allContributions);
|
|
41
59
|
metadataHtml = renderPageMetadata(resolved);
|
|
42
|
-
|
|
43
|
-
const fragments = await runtime.collectPageFragments(page);
|
|
44
60
|
fragmentsHtml = renderFragments(fragments, "head");
|
|
45
61
|
} else {
|
|
46
62
|
// No runtime (EmDash not initialized) — still render base SEO
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import BetterSqlite3 from "better-sqlite3";
|
|
2
2
|
import { Kysely, SqliteDialect } from "kysely";
|
|
3
3
|
|
|
4
|
+
import { kyselyLogOption } from "./instrumentation.js";
|
|
4
5
|
import type { Database } from "./types.js";
|
|
5
6
|
|
|
6
7
|
export interface DatabaseConfig {
|
|
@@ -18,6 +19,23 @@ export class EmDashDatabaseError extends Error {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Returns a helpful, actionable message when better-sqlite3's native binary
|
|
24
|
+
* was compiled against a different Node.js version than the one running. This
|
|
25
|
+
* happens after upgrading Node without rebuilding native deps.
|
|
26
|
+
*
|
|
27
|
+
* Returns null if the error is not a NODE_MODULE_VERSION mismatch.
|
|
28
|
+
*/
|
|
29
|
+
export function formatNativeModuleVersionError(error: unknown): string | null {
|
|
30
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
31
|
+
if (!message.includes("NODE_MODULE_VERSION")) return null;
|
|
32
|
+
return (
|
|
33
|
+
"better-sqlite3's native binary was compiled against a different Node.js version. " +
|
|
34
|
+
"Rebuild it with `pnpm rebuild better-sqlite3` (or `npm rebuild better-sqlite3`), " +
|
|
35
|
+
"or reinstall dependencies with your current Node.js version."
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
/**
|
|
22
40
|
* Creates a Kysely database instance
|
|
23
41
|
* Supports:
|
|
@@ -45,7 +63,7 @@ export function createDatabase(config: DatabaseConfig): Kysely<Database> {
|
|
|
45
63
|
database: sqlite,
|
|
46
64
|
});
|
|
47
65
|
|
|
48
|
-
return new Kysely<Database>({ dialect });
|
|
66
|
+
return new Kysely<Database>({ dialect, log: kyselyLogOption() });
|
|
49
67
|
}
|
|
50
68
|
|
|
51
69
|
// Handle libSQL (Turso)
|
|
@@ -62,6 +80,10 @@ export function createDatabase(config: DatabaseConfig): Kysely<Database> {
|
|
|
62
80
|
if (error instanceof EmDashDatabaseError) {
|
|
63
81
|
throw error;
|
|
64
82
|
}
|
|
83
|
+
const nativeVersionHint = formatNativeModuleVersionError(error);
|
|
84
|
+
if (nativeVersionHint) {
|
|
85
|
+
throw new EmDashDatabaseError(nativeVersionHint, error);
|
|
86
|
+
}
|
|
65
87
|
throw new EmDashDatabaseError("Failed to create database", error);
|
|
66
88
|
}
|
|
67
89
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query instrumentation
|
|
3
|
+
*
|
|
4
|
+
* Dev/test-only: captures every Kysely query executed inside a request,
|
|
5
|
+
* tagged with the route, method, and a caller-supplied phase (e.g. "cold"
|
|
6
|
+
* or "warm"). Events are emitted as prefixed NDJSON on stdout so the
|
|
7
|
+
* harness can capture them from both Node and workerd — workerd has no
|
|
8
|
+
* filesystem access, but `console.log` is portable.
|
|
9
|
+
*
|
|
10
|
+
* The recorder lives on the request context (AsyncLocalStorage). The
|
|
11
|
+
* Kysely `log` hook reads the recorder at query time and appends an
|
|
12
|
+
* event. When no recorder is attached, the hook is a null check.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { LogEvent, Logger } from "kysely";
|
|
16
|
+
|
|
17
|
+
import { getRequestContext } from "../request-context.js";
|
|
18
|
+
|
|
19
|
+
export const QUERY_LOG_ENV = "EMDASH_QUERY_LOG";
|
|
20
|
+
export const QUERY_LOG_PREFIX = "[emdash-query-log]";
|
|
21
|
+
|
|
22
|
+
export interface QueryEvent {
|
|
23
|
+
sql: string;
|
|
24
|
+
params: readonly unknown[];
|
|
25
|
+
durationMs: number;
|
|
26
|
+
route: string;
|
|
27
|
+
method: string;
|
|
28
|
+
phase: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface QueryRecorder {
|
|
32
|
+
events: QueryEvent[];
|
|
33
|
+
route: string;
|
|
34
|
+
method: string;
|
|
35
|
+
phase: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createRecorder(route: string, method: string, phase: string): QueryRecorder {
|
|
39
|
+
return { events: [], route, method, phase };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function recordEvent(
|
|
43
|
+
rec: QueryRecorder,
|
|
44
|
+
sql: string,
|
|
45
|
+
params: readonly unknown[],
|
|
46
|
+
durationMs: number,
|
|
47
|
+
): void {
|
|
48
|
+
rec.events.push({
|
|
49
|
+
sql,
|
|
50
|
+
params,
|
|
51
|
+
durationMs,
|
|
52
|
+
route: rec.route,
|
|
53
|
+
method: rec.method,
|
|
54
|
+
phase: rec.phase,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Emit all events from a recorder as prefixed NDJSON on stdout. The
|
|
60
|
+
* harness pipes the child's stdout, filters lines beginning with
|
|
61
|
+
* QUERY_LOG_PREFIX, and writes them to its own file. Using stdout means
|
|
62
|
+
* the sink works uniformly in Node and in workerd (which has no fs).
|
|
63
|
+
*/
|
|
64
|
+
export function flushRecorder(rec: QueryRecorder): void {
|
|
65
|
+
if (rec.events.length === 0) return;
|
|
66
|
+
for (const e of rec.events) {
|
|
67
|
+
console.log(`${QUERY_LOG_PREFIX} ${JSON.stringify(e)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether query instrumentation is enabled. Read at Kysely construction
|
|
73
|
+
* time and middleware entry — the env var is a process-lifetime flag, not
|
|
74
|
+
* per-request. Gated via `process.env` so adapters that ship env through
|
|
75
|
+
* to the worker (e.g. Miniflare via wrangler.jsonc `vars` or host env
|
|
76
|
+
* pass-through) can enable it at runtime.
|
|
77
|
+
*/
|
|
78
|
+
export function isInstrumentationEnabled(): boolean {
|
|
79
|
+
return Boolean(
|
|
80
|
+
typeof process !== "undefined" && process.env && process.env[QUERY_LOG_ENV] === "1",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function kyselyLog(event: LogEvent): void {
|
|
85
|
+
if (event.level !== "query") return;
|
|
86
|
+
const rec = getRequestContext()?.queryRecorder;
|
|
87
|
+
if (!rec) return;
|
|
88
|
+
recordEvent(rec, event.query.sql, event.query.parameters, event.queryDurationMillis);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns a Kysely `log` option when instrumentation is enabled, or undefined.
|
|
93
|
+
* Pass as `new Kysely({ dialect, log: kyselyLogOption() })` so disabled mode
|
|
94
|
+
* has zero overhead — Kysely skips query timing entirely when `log` is absent.
|
|
95
|
+
*/
|
|
96
|
+
export function kyselyLogOption(): Logger | undefined {
|
|
97
|
+
return isInstrumentationEnabled() ? kyselyLog : undefined;
|
|
98
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Migration: Bounded 404 logging
|
|
6
|
+
*
|
|
7
|
+
* Hardens `_emdash_404_log` against unauthenticated DoS. Previously every 404
|
|
8
|
+
* inserted a new row, so an attacker could grow the table without bound.
|
|
9
|
+
*
|
|
10
|
+
* Changes:
|
|
11
|
+
* - Adds `hits` (default 1, NOT NULL)
|
|
12
|
+
* - Adds `last_seen_at` (nullable; SQLite can't add NOT NULL with a
|
|
13
|
+
* non-constant default to a populated table, so the column is nullable
|
|
14
|
+
* at the schema level and backfilled from `created_at` for existing rows;
|
|
15
|
+
* new inserts via `log404` always set it)
|
|
16
|
+
* - Deduplicates existing rows by path, keeping the most recent row per
|
|
17
|
+
* path and summing hits
|
|
18
|
+
* - Adds a UNIQUE index on `path` so upsert semantics work
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
22
|
+
// 1. Add columns.
|
|
23
|
+
await db.schema
|
|
24
|
+
.alterTable("_emdash_404_log")
|
|
25
|
+
.addColumn("hits", "integer", (col) => col.notNull().defaultTo(1))
|
|
26
|
+
.execute();
|
|
27
|
+
|
|
28
|
+
// SQLite won't accept a non-constant default when adding a NOT NULL column
|
|
29
|
+
// to a table with existing rows, so backfill in two steps: add nullable,
|
|
30
|
+
// populate, then rely on the application layer / future inserts to set it.
|
|
31
|
+
await db.schema.alterTable("_emdash_404_log").addColumn("last_seen_at", "text").execute();
|
|
32
|
+
|
|
33
|
+
// Backfill last_seen_at from created_at for existing rows.
|
|
34
|
+
await sql`
|
|
35
|
+
UPDATE _emdash_404_log
|
|
36
|
+
SET last_seen_at = created_at
|
|
37
|
+
WHERE last_seen_at IS NULL
|
|
38
|
+
`.execute(db);
|
|
39
|
+
|
|
40
|
+
// 2. Deduplicate existing rows by path.
|
|
41
|
+
// For each path, roll up hits and pick the freshest last_seen_at onto
|
|
42
|
+
// a single keeper row, then delete the non-keepers. Uses window
|
|
43
|
+
// functions (ROW_NUMBER) so the dedup SQL is valid on both SQLite
|
|
44
|
+
// (3.25+, 2018) and Postgres. The previous GROUP BY approach was
|
|
45
|
+
// accepted by SQLite but invalid on Postgres because `id` wasn't in
|
|
46
|
+
// the GROUP BY or wrapped in an aggregate.
|
|
47
|
+
await sql`
|
|
48
|
+
WITH ranked AS (
|
|
49
|
+
SELECT
|
|
50
|
+
id,
|
|
51
|
+
path,
|
|
52
|
+
ROW_NUMBER() OVER (
|
|
53
|
+
PARTITION BY path
|
|
54
|
+
ORDER BY created_at DESC, id DESC
|
|
55
|
+
) AS rn,
|
|
56
|
+
COUNT(*) OVER (PARTITION BY path) AS path_count,
|
|
57
|
+
MAX(created_at) OVER (PARTITION BY path) AS latest_created_at
|
|
58
|
+
FROM _emdash_404_log
|
|
59
|
+
)
|
|
60
|
+
UPDATE _emdash_404_log
|
|
61
|
+
SET
|
|
62
|
+
hits = (SELECT path_count FROM ranked WHERE ranked.id = _emdash_404_log.id),
|
|
63
|
+
last_seen_at = (SELECT latest_created_at FROM ranked WHERE ranked.id = _emdash_404_log.id)
|
|
64
|
+
WHERE id IN (SELECT id FROM ranked WHERE rn = 1)
|
|
65
|
+
`.execute(db);
|
|
66
|
+
|
|
67
|
+
// Delete the non-keepers (every row except the freshest per path).
|
|
68
|
+
await sql`
|
|
69
|
+
DELETE FROM _emdash_404_log
|
|
70
|
+
WHERE id IN (
|
|
71
|
+
SELECT id FROM (
|
|
72
|
+
SELECT
|
|
73
|
+
id,
|
|
74
|
+
ROW_NUMBER() OVER (
|
|
75
|
+
PARTITION BY path
|
|
76
|
+
ORDER BY created_at DESC, id DESC
|
|
77
|
+
) AS rn
|
|
78
|
+
FROM _emdash_404_log
|
|
79
|
+
) AS ranked
|
|
80
|
+
WHERE rn > 1
|
|
81
|
+
)
|
|
82
|
+
`.execute(db);
|
|
83
|
+
|
|
84
|
+
// 3. Add unique index on path for upsert semantics.
|
|
85
|
+
await db.schema
|
|
86
|
+
.createIndex("idx_404_log_path_unique")
|
|
87
|
+
.on("_emdash_404_log")
|
|
88
|
+
.column("path")
|
|
89
|
+
.unique()
|
|
90
|
+
.execute();
|
|
91
|
+
|
|
92
|
+
// Drop the old non-unique index; the unique one covers the same lookups.
|
|
93
|
+
await db.schema.dropIndex("idx_404_log_path").execute();
|
|
94
|
+
|
|
95
|
+
// 4. Index on last_seen_at for eviction ordering.
|
|
96
|
+
await db.schema
|
|
97
|
+
.createIndex("idx_404_log_last_seen")
|
|
98
|
+
.on("_emdash_404_log")
|
|
99
|
+
.column("last_seen_at")
|
|
100
|
+
.execute();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
104
|
+
await db.schema.dropIndex("idx_404_log_last_seen").execute();
|
|
105
|
+
await db.schema.dropIndex("idx_404_log_path_unique").execute();
|
|
106
|
+
|
|
107
|
+
// Restore the original non-unique path index.
|
|
108
|
+
await db.schema.createIndex("idx_404_log_path").on("_emdash_404_log").column("path").execute();
|
|
109
|
+
|
|
110
|
+
await db.schema.alterTable("_emdash_404_log").dropColumn("last_seen_at").execute();
|
|
111
|
+
await db.schema.alterTable("_emdash_404_log").dropColumn("hits").execute();
|
|
112
|
+
}
|
|
@@ -35,6 +35,7 @@ import * as m031 from "./031_bylines.js";
|
|
|
35
35
|
import * as m032 from "./032_rate_limits.js";
|
|
36
36
|
import * as m033 from "./033_optimize_content_indexes.js";
|
|
37
37
|
import * as m034 from "./034_published_at_index.js";
|
|
38
|
+
import * as m035 from "./035_bounded_404_log.js";
|
|
38
39
|
|
|
39
40
|
const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
|
|
40
41
|
"001_initial": m001,
|
|
@@ -70,6 +71,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
|
|
|
70
71
|
"032_rate_limits": m032,
|
|
71
72
|
"033_optimize_content_indexes": m033,
|
|
72
73
|
"034_published_at_index": m034,
|
|
74
|
+
"035_bounded_404_log": m035,
|
|
73
75
|
});
|
|
74
76
|
|
|
75
77
|
/** Total number of registered migrations. Exported for use in tests. */
|
|
@@ -1031,6 +1031,45 @@ export class ContentRepository {
|
|
|
1031
1031
|
return updated;
|
|
1032
1032
|
}
|
|
1033
1033
|
|
|
1034
|
+
/**
|
|
1035
|
+
* Set the draft revision pointer for a content item.
|
|
1036
|
+
*
|
|
1037
|
+
* Used by seed/import paths that stage a new revision's data before
|
|
1038
|
+
* promoting it to live via `publish()`.
|
|
1039
|
+
*
|
|
1040
|
+
* Validates that the content item exists and is not soft-deleted, that
|
|
1041
|
+
* the revision exists, and that the revision belongs to the same
|
|
1042
|
+
* collection and entry. Without these checks, a caller could leave the
|
|
1043
|
+
* content row pointing at a missing or unrelated revision.
|
|
1044
|
+
*/
|
|
1045
|
+
async setDraftRevision(type: string, id: string, revisionId: string): Promise<void> {
|
|
1046
|
+
const tableName = getTableName(type);
|
|
1047
|
+
const now = new Date().toISOString();
|
|
1048
|
+
|
|
1049
|
+
const existing = await this.findById(type, id);
|
|
1050
|
+
if (!existing) {
|
|
1051
|
+
throw new EmDashValidationError("Content item not found");
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const revisionRepo = new RevisionRepository(this.db);
|
|
1055
|
+
const revision = await revisionRepo.findById(revisionId);
|
|
1056
|
+
if (!revision) {
|
|
1057
|
+
throw new EmDashValidationError("Revision not found");
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (revision.collection !== type || revision.entryId !== id) {
|
|
1061
|
+
throw new EmDashValidationError("Revision does not belong to the specified content item");
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
await sql`
|
|
1065
|
+
UPDATE ${sql.ref(tableName)}
|
|
1066
|
+
SET draft_revision_id = ${revisionId},
|
|
1067
|
+
updated_at = ${now}
|
|
1068
|
+
WHERE id = ${id}
|
|
1069
|
+
AND deleted_at IS NULL
|
|
1070
|
+
`.execute(this.db);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1034
1073
|
/**
|
|
1035
1074
|
* Discard pending draft changes
|
|
1036
1075
|
*
|
|
@@ -55,6 +55,31 @@ export class OptionsRepository {
|
|
|
55
55
|
.execute();
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Set an option value only if no row with that name exists. Atomic at the
|
|
60
|
+
* database level via INSERT ... ON CONFLICT DO NOTHING, so concurrent
|
|
61
|
+
* callers can't race past the check.
|
|
62
|
+
*
|
|
63
|
+
* Returns true when the row was inserted, false when a row already
|
|
64
|
+
* existed (regardless of its value — even an empty string or null).
|
|
65
|
+
*/
|
|
66
|
+
async setIfAbsent<T = unknown>(name: string, value: T): Promise<boolean> {
|
|
67
|
+
const row: OptionTable = {
|
|
68
|
+
name,
|
|
69
|
+
value: JSON.stringify(value),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const result = await this.db
|
|
73
|
+
.insertInto("options")
|
|
74
|
+
.values(row)
|
|
75
|
+
.onConflict((oc) => oc.column("name").doNothing())
|
|
76
|
+
.executeTakeFirst();
|
|
77
|
+
|
|
78
|
+
// SQLite reports numInsertedOrUpdatedRows; Postgres reports the same.
|
|
79
|
+
// When the ON CONFLICT branch fires and does nothing, the count is 0.
|
|
80
|
+
return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
|
|
81
|
+
}
|
|
82
|
+
|
|
58
83
|
/**
|
|
59
84
|
* Delete an option
|
|
60
85
|
*/
|