@beechcms/api 0.6.0-preview.1 → 0.6.0-preview.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/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
- package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
- package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
- package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
- package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/noResult.svg +42 -42
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +265 -265
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +2 -2
- package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
- package/src/auth/__fixtures__/static-token-service.ts +22 -22
- package/src/auth/bcrypt-hash-provider.ts +24 -24
- package/src/auth/constants.ts +14 -14
- package/src/auth/generate-refresh-token.test.ts +23 -23
- package/src/auth/hash-provider.test.ts +55 -55
- package/src/auth/jose-token-service.ts +62 -62
- package/src/auth/jwt-claims-passthrough.test.ts +87 -87
- package/src/auth/login.test.ts +100 -100
- package/src/auth/login.ts +78 -78
- package/src/auth/refresh.ts +11 -11
- package/src/auth/token-service.test.ts +90 -90
- package/src/factory.ts +370 -368
- package/src/features/automations/__tests__/action-executors.test.ts +255 -255
- package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
- package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
- package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
- package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
- package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
- package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
- package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
- package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
- package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
- package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
- package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
- package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
- package/src/features/automations/action-executors/index.ts +37 -37
- package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
- package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
- package/src/features/automations/action-executors/webhook.executor.ts +67 -67
- package/src/features/automations/automation-runner.ts +85 -85
- package/src/features/automations/automation-runner.utils.ts +47 -47
- package/src/features/automations/automations.handler.ts +197 -197
- package/src/features/automations/automations.schema.ts +177 -177
- package/src/features/automations/context-resolver.ts +152 -152
- package/src/features/automations/cron-runner.ts +140 -140
- package/src/features/automations/cron-runner.utils.ts +44 -44
- package/src/features/automations/filter-translation.ts +46 -46
- package/src/features/automations/index.ts +16 -16
- package/src/features/automations/template-grammar.ts +245 -245
- package/src/features/automations/var-access-resolver.ts +140 -140
- package/src/features/automations/when-evaluator.ts +87 -87
- package/src/features/automations/when-pushdown.ts +57 -57
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
- package/src/features/backrefs/backrefs.handler.ts +168 -168
- package/src/features/backrefs/d1-backref.repository.ts +84 -84
- package/src/features/backrefs/index.ts +5 -5
- package/src/features/content/constants.ts +16 -16
- package/src/features/content/handlers/bulk.handler.ts +185 -185
- package/src/features/content/handlers/create.ts +129 -129
- package/src/features/content/handlers/delete.ts +62 -62
- package/src/features/content/handlers/facets.ts +49 -49
- package/src/features/content/handlers/get.ts +120 -120
- package/src/features/content/handlers/helpers.test.ts +180 -180
- package/src/features/content/handlers/helpers.ts +93 -93
- package/src/features/content/handlers/list.ts +174 -174
- package/src/features/content/handlers/update.ts +174 -174
- package/src/features/content/index.ts +26 -26
- package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
- package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
- package/src/features/dashboard-layout/index.ts +5 -0
- package/src/features/draft/draft.handler.ts +195 -195
- package/src/features/draft/draft.middleware.ts +66 -66
- package/src/features/draft/index.ts +5 -5
- package/src/features/email/email.provider.ts +42 -42
- package/src/features/email/email.service.ts +98 -98
- package/src/features/email/email.types.ts +119 -119
- package/src/features/email/index.ts +33 -33
- package/src/features/email/providers/resend.ts +67 -67
- package/src/features/email/providers/smtp.ts +55 -55
- package/src/features/email/templates/automation-mail.ts +19 -19
- package/src/features/email/templates/password-changed.ts +63 -63
- package/src/features/email/templates/password-reset.ts +68 -68
- package/src/features/email/templates/shell.ts +96 -96
- package/src/features/notifications/index.ts +5 -5
- package/src/features/notifications/notifications.handler.ts +105 -105
- package/src/features/password-reset/index.ts +19 -19
- package/src/features/password-reset/request.ts +92 -92
- package/src/features/password-reset/reset.ts +102 -102
- package/src/features/rotate-field/index.ts +5 -5
- package/src/features/rotate-field/rotate-field.handler.ts +132 -132
- package/src/features/rotate-field/rotate-field.schema.ts +17 -17
- package/src/features/schema/schema.handler.ts +131 -131
- package/src/features/seeds/index.ts +5 -5
- package/src/features/seeds/seeds.handler.test.ts +1199 -632
- package/src/features/seeds/seeds.handler.ts +558 -693
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
- package/src/features/settings/settings.handler.ts +392 -392
- package/src/features/setup/index.ts +288 -204
- package/src/features/stats/index.ts +5 -5
- package/src/features/stats/stats.handler.ts +420 -420
- package/src/features/webhooks/index.ts +63 -63
- package/src/index.ts +65 -65
- package/src/media-utils.ts +82 -82
- package/src/middleware/auth-providers.middleware.test.ts +85 -0
- package/src/middleware/auth-providers.middleware.ts +45 -45
- package/src/middleware/observability.middleware.test.ts +93 -0
- package/src/middleware/observability.middleware.ts +70 -70
- package/src/middleware/rate-limit.middleware.ts +45 -45
- package/src/middleware/repository.middleware.ts +117 -114
- package/src/middleware/seed-registry.middleware.test.ts +97 -97
- package/src/middleware/seed-registry.middleware.ts +21 -21
- package/src/middleware/storage.middleware.ts +25 -25
- package/src/middleware.ts +45 -45
- package/src/public/access-policy.ts +27 -27
- package/src/public/api-key-middleware.ts +57 -57
- package/src/public/cache-utils.ts +38 -38
- package/src/public/entry-projection.ts +46 -46
- package/src/public/idempotency.ts +23 -23
- package/src/public/index.ts +16 -16
- package/src/public/problem-details.ts +124 -124
- package/src/public/public-add.ts +114 -114
- package/src/public/public-edit.ts +163 -163
- package/src/public/public-errors.ts +19 -19
- package/src/public/public-read.ts +63 -63
- package/src/public/public-routes.ts +88 -88
- package/src/public/query-builder.test.ts +224 -224
- package/src/public/query-builder.ts +156 -156
- package/src/public/rate-limit-middleware.ts +34 -34
- package/src/public/read-list.ts +54 -54
- package/src/public/read-single.ts +48 -48
- package/src/public/response-builder.ts +30 -30
- package/src/public/sanitize.ts +69 -69
- package/src/public/slug-utils.ts +18 -18
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
- package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
- package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
- package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
- package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
- package/src/rate-limit/no-op-rate-limiter.ts +11 -11
- package/src/search-utils.test.ts +211 -211
- package/src/search-utils.ts +213 -213
- package/src/search.ts +65 -65
- package/src/shared/apply-policies.test.ts +81 -81
- package/src/shared/apply-policies.ts +67 -67
- package/src/shared/automations.repository.d1.ts +150 -150
- package/src/shared/background-notification-service.test.ts +62 -62
- package/src/shared/background-notification-service.ts +52 -52
- package/src/shared/base.repository.d1.ts +32 -32
- package/src/shared/content-utils.test.ts +165 -165
- package/src/shared/content-utils.ts +86 -86
- package/src/shared/content.repository.d1.test.ts +507 -458
- package/src/shared/content.repository.d1.ts +851 -851
- package/src/shared/d1-activity-log.repository.test.ts +140 -140
- package/src/shared/d1-activity-log.repository.ts +105 -105
- package/src/shared/d1-activity-logger.test.ts +86 -86
- package/src/shared/d1-activity-logger.ts +67 -67
- package/src/shared/d1-analytics.repository.test.ts +78 -78
- package/src/shared/d1-analytics.repository.ts +85 -85
- package/src/shared/d1-content-scan.repository.test.ts +80 -80
- package/src/shared/d1-content-scan.repository.ts +37 -37
- package/src/shared/d1-notification.repository.test.ts +128 -128
- package/src/shared/d1-notification.repository.ts +118 -118
- package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
- package/src/shared/d1-password-reset-token.repository.ts +56 -56
- package/src/shared/d1-search.repository.test.ts +87 -87
- package/src/shared/d1-search.repository.ts +88 -88
- package/src/shared/d1-session.repository.test.ts +125 -125
- package/src/shared/d1-session.repository.ts +102 -102
- package/src/shared/d1-setup-checklist.repository.ts +36 -36
- package/src/shared/d1-user.repository.test.ts +151 -151
- package/src/shared/d1-user.repository.ts +119 -119
- package/src/shared/d1-widget.repository.test.ts +267 -221
- package/src/shared/d1-widget.repository.ts +385 -341
- package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
- package/src/shared/dashboard-layout.repository.d1.ts +58 -0
- package/src/shared/demo-data-sql.ts +216 -54
- package/src/shared/demo-data.repository.d1.ts +20 -20
- package/src/shared/execution-context-scheduler.ts +13 -13
- package/src/shared/fixed-clock.ts +25 -25
- package/src/shared/fts-sync.ts +8 -8
- package/src/shared/idempotency.repository.d1.test.ts +83 -83
- package/src/shared/idempotency.repository.d1.ts +60 -60
- package/src/shared/in-memory-activity-logger.ts +19 -19
- package/src/shared/in-memory-notification-service.ts +19 -19
- package/src/shared/in-memory-seed.repository.ts +58 -51
- package/src/shared/media.repository.d1.test.ts +107 -107
- package/src/shared/media.repository.d1.ts +68 -68
- package/src/shared/qstash-notification-service.test.ts +67 -67
- package/src/shared/qstash-notification-service.ts +55 -55
- package/src/shared/query-utils.ts +141 -141
- package/src/shared/request-utils.ts +26 -26
- package/src/shared/schema-mutator.d1.test.ts +142 -0
- package/src/shared/schema-mutator.d1.ts +64 -64
- package/src/shared/seed-layout.repository.d1.test.ts +114 -114
- package/src/shared/seed-layout.repository.d1.ts +62 -62
- package/src/shared/seed-registry-cache.test.ts +68 -68
- package/src/shared/seed-registry-cache.ts +49 -49
- package/src/shared/seed.repository.d1.test.ts +143 -143
- package/src/shared/seed.repository.d1.ts +98 -98
- package/src/shared/sequential-id-generator.ts +33 -33
- package/src/shared/site-settings.repository.d1.ts +53 -53
- package/src/shared/storage/factory.ts +41 -41
- package/src/shared/storage/s3-bucket.ts +195 -195
- package/src/shared/storage-utils.ts +40 -40
- package/src/shared/system-stats.repository.d1.test.ts +58 -58
- package/src/shared/system-stats.repository.d1.ts +48 -48
- package/src/types.ts +90 -87
- package/src/upload.ts +198 -198
- package/src/widget.test.ts +85 -0
- package/src/widget.ts +251 -212
- package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
- package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
package/src/search-utils.ts
CHANGED
|
@@ -1,213 +1,213 @@
|
|
|
1
|
-
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
-
// See LICENSE in the repository root for license terms.
|
|
4
|
-
|
|
5
|
-
// apps/api/src/search-utils.ts
|
|
6
|
-
// Pure functions — zero Hono dependencies, importable from Vitest.
|
|
7
|
-
// v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
|
|
8
|
-
|
|
9
|
-
import type { Seed, SearchResultRow } from "@beechcms/core"
|
|
10
|
-
|
|
11
|
-
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
12
|
-
|
|
13
|
-
// Row returned by UNION ALL query across fts_{slug} JOIN content_{slug}
|
|
14
|
-
export interface FtsRow {
|
|
15
|
-
entry_id: string
|
|
16
|
-
schema_slug: string
|
|
17
|
-
slug: string | null
|
|
18
|
-
status: string
|
|
19
|
-
title: string | null
|
|
20
|
-
excerpt: string
|
|
21
|
-
rank: number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface SearchResultItem {
|
|
25
|
-
id: string
|
|
26
|
-
schema_slug: string
|
|
27
|
-
slug: string | null
|
|
28
|
-
status: string
|
|
29
|
-
title: string
|
|
30
|
-
excerpt: string
|
|
31
|
-
data: Record<string, unknown>
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface SearchResponse {
|
|
35
|
-
items: SearchResultItem[]
|
|
36
|
-
nextCursor: string | null
|
|
37
|
-
total: number
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface SearchQueryParams {
|
|
41
|
-
q: string
|
|
42
|
-
schemaSlug: string | null
|
|
43
|
-
status: string | null
|
|
44
|
-
limit: number // already clamped 1–50 by handler
|
|
45
|
-
cursor: string | null
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ─── Cursor ──────────────────────────────────────────────────────────────────
|
|
49
|
-
|
|
50
|
-
export function encodeCursor(rank: number, entryId: string): string {
|
|
51
|
-
return btoa(`${rank}:${entryId}`)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function decodeCursor(cursor: string): { rank: number; entryId: string } | null {
|
|
55
|
-
try {
|
|
56
|
-
const decoded = atob(cursor)
|
|
57
|
-
const sep = decoded.lastIndexOf(":")
|
|
58
|
-
if (sep === -1) return null
|
|
59
|
-
return {
|
|
60
|
-
rank: Number.parseFloat(decoded.slice(0, sep)),
|
|
61
|
-
entryId: decoded.slice(sep + 1),
|
|
62
|
-
}
|
|
63
|
-
} catch {
|
|
64
|
-
return null
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
function hasSearchableFts(seed: Seed): boolean {
|
|
71
|
-
return seed.branches.some(b =>
|
|
72
|
-
(b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function buildMatchExpr(q: string): string {
|
|
77
|
-
const MIN_PREFIX = 3
|
|
78
|
-
const safeQ = q.replace(/["*^()]/g, " ").trim()
|
|
79
|
-
const terms = safeQ.split(/\s+/).filter(t => t.length >= 2)
|
|
80
|
-
if (terms.length === 0) throw new Error("EMPTY_QUERY")
|
|
81
|
-
|
|
82
|
-
return terms
|
|
83
|
-
.map(t => {
|
|
84
|
-
if (/^\d+$/.test(t)) return `"${t}"`
|
|
85
|
-
if (t.length <= MIN_PREFIX) return `"${t}"*`
|
|
86
|
-
const prefixes: string[] = []
|
|
87
|
-
for (let i = MIN_PREFIX; i <= t.length; i++) {
|
|
88
|
-
prefixes.push(`"${t.slice(0, i)}"*`)
|
|
89
|
-
}
|
|
90
|
-
return `(${prefixes.join(" OR ")})`
|
|
91
|
-
})
|
|
92
|
-
.join(" ")
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ─── Query builder ───────────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Builds a UNION ALL query across all per-seed fts_{slug} tables (v0.4.0).
|
|
99
|
-
* Each SELECT joins fts_{slug} with content_{slug} to fetch title, slug, status.
|
|
100
|
-
* Seeds param: full registry — filtered internally by schemaSlug and FTS availability.
|
|
101
|
-
*/
|
|
102
|
-
export function buildFtsQuery(
|
|
103
|
-
params: SearchQueryParams,
|
|
104
|
-
seeds: Seed[],
|
|
105
|
-
): {
|
|
106
|
-
sql: string
|
|
107
|
-
binds: unknown[]
|
|
108
|
-
countSql: string
|
|
109
|
-
countBinds: unknown[]
|
|
110
|
-
} {
|
|
111
|
-
const { q, schemaSlug, status, limit, cursor } = params
|
|
112
|
-
|
|
113
|
-
const matchExpr = buildMatchExpr(q) // throws EMPTY_QUERY if needed
|
|
114
|
-
|
|
115
|
-
const targetSeeds = seeds.filter(s =>
|
|
116
|
-
hasSearchableFts(s) && (schemaSlug === null || s.slug === schemaSlug)
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
if (targetSeeds.length === 0) {
|
|
120
|
-
return {
|
|
121
|
-
sql: "SELECT NULL as entry_id, NULL as schema_slug, NULL as slug, NULL as status, NULL as title, '' as excerpt, 0 as rank WHERE 1=0",
|
|
122
|
-
binds: [],
|
|
123
|
-
countSql: "SELECT 0 as total",
|
|
124
|
-
countBinds: [],
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const decoded = cursor ? decodeCursor(cursor) : null
|
|
129
|
-
|
|
130
|
-
const parts: string[] = []
|
|
131
|
-
const binds: unknown[] = []
|
|
132
|
-
const countParts: string[] = []
|
|
133
|
-
const countBinds: unknown[] = []
|
|
134
|
-
|
|
135
|
-
for (const seed of targetSeeds) {
|
|
136
|
-
const fts = `fts_${seed.slug}`
|
|
137
|
-
const table = `content_${seed.slug}`
|
|
138
|
-
const title = seed.displayNameAlias
|
|
139
|
-
|
|
140
|
-
// Main query per seed
|
|
141
|
-
const where: string[] = [`${fts} MATCH ?`]
|
|
142
|
-
const lb: unknown[] = [matchExpr]
|
|
143
|
-
|
|
144
|
-
if (status) { where.push("ce.status = ?"); lb.push(status) }
|
|
145
|
-
|
|
146
|
-
if (decoded) {
|
|
147
|
-
where.push(`(bm25(${fts}) > ? OR (bm25(${fts}) = ? AND f.entry_id > ?))`)
|
|
148
|
-
lb.push(decoded.rank, decoded.rank, decoded.entryId)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
parts.push(
|
|
152
|
-
`SELECT f.entry_id, '${seed.slug}' AS schema_slug, ce.slug, ce.status,` +
|
|
153
|
-
` ce.${title} AS title,` +
|
|
154
|
-
` snippet(${fts}, 1, '<mark>', '</mark>', '…', 16) AS excerpt,` +
|
|
155
|
-
` bm25(${fts}) AS rank` +
|
|
156
|
-
` FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id` +
|
|
157
|
-
` WHERE ${where.join(' AND ')}`
|
|
158
|
-
)
|
|
159
|
-
binds.push(...lb)
|
|
160
|
-
|
|
161
|
-
// Count per seed (no cursor, no limit)
|
|
162
|
-
const cw: string[] = [`${fts} MATCH ?`]
|
|
163
|
-
const cb: unknown[] = [matchExpr]
|
|
164
|
-
if (status) { cw.push("ce.status = ?"); cb.push(status) }
|
|
165
|
-
|
|
166
|
-
countParts.push(
|
|
167
|
-
`SELECT COUNT(*) as c FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id WHERE ${cw.join(' AND ')}`
|
|
168
|
-
)
|
|
169
|
-
countBinds.push(...cb)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const sql = `${parts.join(' UNION ALL ')} ORDER BY rank, entry_id LIMIT ?`
|
|
173
|
-
binds.push(limit + 1)
|
|
174
|
-
|
|
175
|
-
const countSql = `SELECT SUM(c) as total FROM (${countParts.join(' UNION ALL ')})`
|
|
176
|
-
|
|
177
|
-
return { sql, binds, countSql, countBinds }
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ─── Mapper ──────────────────────────────────────────────────────────────────
|
|
181
|
-
|
|
182
|
-
function stripHtmlPreserveMark(html: string): string {
|
|
183
|
-
return html.replace(/<(?!\/?mark\b)[^>]*>/gi, ' ').replace(/\s+/g, ' ').trim()
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export function mapFtsRow(row: FtsRow): SearchResultItem {
|
|
187
|
-
return {
|
|
188
|
-
id: row.entry_id,
|
|
189
|
-
schema_slug: row.schema_slug,
|
|
190
|
-
slug: row.slug,
|
|
191
|
-
status: row.status,
|
|
192
|
-
title: row.title ?? "",
|
|
193
|
-
excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
|
|
194
|
-
data: {},
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Maps a repository-shaped SearchResultRow (camelCase) to the wire-format
|
|
200
|
-
* SearchResultItem returned by the /api/search route. Keeps the HTML
|
|
201
|
-
* stripping behaviour previously inlined inside mapFtsRow.
|
|
202
|
-
*/
|
|
203
|
-
export function mapSearchResultRow(row: SearchResultRow): SearchResultItem {
|
|
204
|
-
return {
|
|
205
|
-
id: row.entryId,
|
|
206
|
-
schema_slug: row.schemaSlug,
|
|
207
|
-
slug: row.slug,
|
|
208
|
-
status: row.status,
|
|
209
|
-
title: row.title ?? "",
|
|
210
|
-
excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
|
|
211
|
-
data: {},
|
|
212
|
-
}
|
|
213
|
-
}
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
// apps/api/src/search-utils.ts
|
|
6
|
+
// Pure functions — zero Hono dependencies, importable from Vitest.
|
|
7
|
+
// v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
|
|
8
|
+
|
|
9
|
+
import type { Seed, SearchResultRow } from "@beechcms/core"
|
|
10
|
+
|
|
11
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
// Row returned by UNION ALL query across fts_{slug} JOIN content_{slug}
|
|
14
|
+
export interface FtsRow {
|
|
15
|
+
entry_id: string
|
|
16
|
+
schema_slug: string
|
|
17
|
+
slug: string | null
|
|
18
|
+
status: string
|
|
19
|
+
title: string | null
|
|
20
|
+
excerpt: string
|
|
21
|
+
rank: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SearchResultItem {
|
|
25
|
+
id: string
|
|
26
|
+
schema_slug: string
|
|
27
|
+
slug: string | null
|
|
28
|
+
status: string
|
|
29
|
+
title: string
|
|
30
|
+
excerpt: string
|
|
31
|
+
data: Record<string, unknown>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SearchResponse {
|
|
35
|
+
items: SearchResultItem[]
|
|
36
|
+
nextCursor: string | null
|
|
37
|
+
total: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SearchQueryParams {
|
|
41
|
+
q: string
|
|
42
|
+
schemaSlug: string | null
|
|
43
|
+
status: string | null
|
|
44
|
+
limit: number // already clamped 1–50 by handler
|
|
45
|
+
cursor: string | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Cursor ──────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export function encodeCursor(rank: number, entryId: string): string {
|
|
51
|
+
return btoa(`${rank}:${entryId}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function decodeCursor(cursor: string): { rank: number; entryId: string } | null {
|
|
55
|
+
try {
|
|
56
|
+
const decoded = atob(cursor)
|
|
57
|
+
const sep = decoded.lastIndexOf(":")
|
|
58
|
+
if (sep === -1) return null
|
|
59
|
+
return {
|
|
60
|
+
rank: Number.parseFloat(decoded.slice(0, sep)),
|
|
61
|
+
entryId: decoded.slice(sep + 1),
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function hasSearchableFts(seed: Seed): boolean {
|
|
71
|
+
return seed.branches.some(b =>
|
|
72
|
+
(b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildMatchExpr(q: string): string {
|
|
77
|
+
const MIN_PREFIX = 3
|
|
78
|
+
const safeQ = q.replace(/["*^()]/g, " ").trim()
|
|
79
|
+
const terms = safeQ.split(/\s+/).filter(t => t.length >= 2)
|
|
80
|
+
if (terms.length === 0) throw new Error("EMPTY_QUERY")
|
|
81
|
+
|
|
82
|
+
return terms
|
|
83
|
+
.map(t => {
|
|
84
|
+
if (/^\d+$/.test(t)) return `"${t}"`
|
|
85
|
+
if (t.length <= MIN_PREFIX) return `"${t}"*`
|
|
86
|
+
const prefixes: string[] = []
|
|
87
|
+
for (let i = MIN_PREFIX; i <= t.length; i++) {
|
|
88
|
+
prefixes.push(`"${t.slice(0, i)}"*`)
|
|
89
|
+
}
|
|
90
|
+
return `(${prefixes.join(" OR ")})`
|
|
91
|
+
})
|
|
92
|
+
.join(" ")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Query builder ───────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Builds a UNION ALL query across all per-seed fts_{slug} tables (v0.4.0).
|
|
99
|
+
* Each SELECT joins fts_{slug} with content_{slug} to fetch title, slug, status.
|
|
100
|
+
* Seeds param: full registry — filtered internally by schemaSlug and FTS availability.
|
|
101
|
+
*/
|
|
102
|
+
export function buildFtsQuery(
|
|
103
|
+
params: SearchQueryParams,
|
|
104
|
+
seeds: Seed[],
|
|
105
|
+
): {
|
|
106
|
+
sql: string
|
|
107
|
+
binds: unknown[]
|
|
108
|
+
countSql: string
|
|
109
|
+
countBinds: unknown[]
|
|
110
|
+
} {
|
|
111
|
+
const { q, schemaSlug, status, limit, cursor } = params
|
|
112
|
+
|
|
113
|
+
const matchExpr = buildMatchExpr(q) // throws EMPTY_QUERY if needed
|
|
114
|
+
|
|
115
|
+
const targetSeeds = seeds.filter(s =>
|
|
116
|
+
hasSearchableFts(s) && (schemaSlug === null || s.slug === schemaSlug)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if (targetSeeds.length === 0) {
|
|
120
|
+
return {
|
|
121
|
+
sql: "SELECT NULL as entry_id, NULL as schema_slug, NULL as slug, NULL as status, NULL as title, '' as excerpt, 0 as rank WHERE 1=0",
|
|
122
|
+
binds: [],
|
|
123
|
+
countSql: "SELECT 0 as total",
|
|
124
|
+
countBinds: [],
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const decoded = cursor ? decodeCursor(cursor) : null
|
|
129
|
+
|
|
130
|
+
const parts: string[] = []
|
|
131
|
+
const binds: unknown[] = []
|
|
132
|
+
const countParts: string[] = []
|
|
133
|
+
const countBinds: unknown[] = []
|
|
134
|
+
|
|
135
|
+
for (const seed of targetSeeds) {
|
|
136
|
+
const fts = `fts_${seed.slug}`
|
|
137
|
+
const table = `content_${seed.slug}`
|
|
138
|
+
const title = seed.displayNameAlias
|
|
139
|
+
|
|
140
|
+
// Main query per seed
|
|
141
|
+
const where: string[] = [`${fts} MATCH ?`]
|
|
142
|
+
const lb: unknown[] = [matchExpr]
|
|
143
|
+
|
|
144
|
+
if (status) { where.push("ce.status = ?"); lb.push(status) }
|
|
145
|
+
|
|
146
|
+
if (decoded) {
|
|
147
|
+
where.push(`(bm25(${fts}) > ? OR (bm25(${fts}) = ? AND f.entry_id > ?))`)
|
|
148
|
+
lb.push(decoded.rank, decoded.rank, decoded.entryId)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
parts.push(
|
|
152
|
+
`SELECT f.entry_id, '${seed.slug}' AS schema_slug, ce.slug, ce.status,` +
|
|
153
|
+
` ce.${title} AS title,` +
|
|
154
|
+
` snippet(${fts}, 1, '<mark>', '</mark>', '…', 16) AS excerpt,` +
|
|
155
|
+
` bm25(${fts}) AS rank` +
|
|
156
|
+
` FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id` +
|
|
157
|
+
` WHERE ${where.join(' AND ')}`
|
|
158
|
+
)
|
|
159
|
+
binds.push(...lb)
|
|
160
|
+
|
|
161
|
+
// Count per seed (no cursor, no limit)
|
|
162
|
+
const cw: string[] = [`${fts} MATCH ?`]
|
|
163
|
+
const cb: unknown[] = [matchExpr]
|
|
164
|
+
if (status) { cw.push("ce.status = ?"); cb.push(status) }
|
|
165
|
+
|
|
166
|
+
countParts.push(
|
|
167
|
+
`SELECT COUNT(*) as c FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id WHERE ${cw.join(' AND ')}`
|
|
168
|
+
)
|
|
169
|
+
countBinds.push(...cb)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sql = `${parts.join(' UNION ALL ')} ORDER BY rank, entry_id LIMIT ?`
|
|
173
|
+
binds.push(limit + 1)
|
|
174
|
+
|
|
175
|
+
const countSql = `SELECT SUM(c) as total FROM (${countParts.join(' UNION ALL ')})`
|
|
176
|
+
|
|
177
|
+
return { sql, binds, countSql, countBinds }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Mapper ──────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
function stripHtmlPreserveMark(html: string): string {
|
|
183
|
+
return html.replace(/<(?!\/?mark\b)[^>]*>/gi, ' ').replace(/\s+/g, ' ').trim()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function mapFtsRow(row: FtsRow): SearchResultItem {
|
|
187
|
+
return {
|
|
188
|
+
id: row.entry_id,
|
|
189
|
+
schema_slug: row.schema_slug,
|
|
190
|
+
slug: row.slug,
|
|
191
|
+
status: row.status,
|
|
192
|
+
title: row.title ?? "",
|
|
193
|
+
excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
|
|
194
|
+
data: {},
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Maps a repository-shaped SearchResultRow (camelCase) to the wire-format
|
|
200
|
+
* SearchResultItem returned by the /api/search route. Keeps the HTML
|
|
201
|
+
* stripping behaviour previously inlined inside mapFtsRow.
|
|
202
|
+
*/
|
|
203
|
+
export function mapSearchResultRow(row: SearchResultRow): SearchResultItem {
|
|
204
|
+
return {
|
|
205
|
+
id: row.entryId,
|
|
206
|
+
schema_slug: row.schemaSlug,
|
|
207
|
+
slug: row.slug,
|
|
208
|
+
status: row.status,
|
|
209
|
+
title: row.title ?? "",
|
|
210
|
+
excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
|
|
211
|
+
data: {},
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/search.ts
CHANGED
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
-
// See LICENSE in the repository root for license terms.
|
|
4
|
-
|
|
5
|
-
// apps/api/src/search.ts
|
|
6
|
-
|
|
7
|
-
import { Hono } from "hono"
|
|
8
|
-
import type { Env, Variables } from "./types"
|
|
9
|
-
import { authMiddleware } from "./middleware"
|
|
10
|
-
import {
|
|
11
|
-
encodeCursor,
|
|
12
|
-
mapSearchResultRow,
|
|
13
|
-
type SearchResponse,
|
|
14
|
-
} from "./search-utils"
|
|
15
|
-
|
|
16
|
-
export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
17
|
-
|
|
18
|
-
searchRouter.use("*", authMiddleware())
|
|
19
|
-
|
|
20
|
-
// GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
|
|
21
|
-
searchRouter.get("/", async (c) => {
|
|
22
|
-
const queryText = c.req.query("q")?.trim() ?? ""
|
|
23
|
-
const schemaSlug = c.req.query("schema_slug") ?? null
|
|
24
|
-
const status = c.req.query("status") ?? null
|
|
25
|
-
const rawLimit = Number.parseInt(c.req.query("limit") ?? "20", 10)
|
|
26
|
-
const limit = Math.min(Math.max(rawLimit, 1), 50)
|
|
27
|
-
const cursor = c.req.query("cursor") ?? null
|
|
28
|
-
|
|
29
|
-
if (queryText.length < 2) {
|
|
30
|
-
return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const seeds = c.get('seedRegistry').all()
|
|
34
|
-
const searchRepository = c.get('searchRepository')
|
|
35
|
-
|
|
36
|
-
const queryOptions = {
|
|
37
|
-
queryText,
|
|
38
|
-
schemaSlug,
|
|
39
|
-
statusFilter: status,
|
|
40
|
-
limit,
|
|
41
|
-
cursor,
|
|
42
|
-
}
|
|
43
|
-
const countOptions = { queryText, schemaSlug, statusFilter: status }
|
|
44
|
-
|
|
45
|
-
const [rawRows, countResult] = await Promise.all([
|
|
46
|
-
searchRepository.search(queryOptions, seeds),
|
|
47
|
-
searchRepository.count(countOptions, seeds),
|
|
48
|
-
])
|
|
49
|
-
|
|
50
|
-
const hasMore = rawRows.length > limit
|
|
51
|
-
const pageRows = hasMore ? rawRows.slice(0, limit) : rawRows
|
|
52
|
-
|
|
53
|
-
const lastRow = pageRows.at(-1)
|
|
54
|
-
const nextCursor = hasMore && lastRow
|
|
55
|
-
? encodeCursor(lastRow.rank, lastRow.entryId)
|
|
56
|
-
: null
|
|
57
|
-
|
|
58
|
-
if (pageRows.length === 0) {
|
|
59
|
-
return c.json({ items: [], nextCursor: null, total: countResult.total } satisfies SearchResponse)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const items = pageRows.map(mapSearchResultRow)
|
|
63
|
-
|
|
64
|
-
return c.json({ items, nextCursor, total: countResult.total } satisfies SearchResponse)
|
|
65
|
-
})
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
// apps/api/src/search.ts
|
|
6
|
+
|
|
7
|
+
import { Hono } from "hono"
|
|
8
|
+
import type { Env, Variables } from "./types"
|
|
9
|
+
import { authMiddleware } from "./middleware"
|
|
10
|
+
import {
|
|
11
|
+
encodeCursor,
|
|
12
|
+
mapSearchResultRow,
|
|
13
|
+
type SearchResponse,
|
|
14
|
+
} from "./search-utils"
|
|
15
|
+
|
|
16
|
+
export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
17
|
+
|
|
18
|
+
searchRouter.use("*", authMiddleware())
|
|
19
|
+
|
|
20
|
+
// GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
|
|
21
|
+
searchRouter.get("/", async (c) => {
|
|
22
|
+
const queryText = c.req.query("q")?.trim() ?? ""
|
|
23
|
+
const schemaSlug = c.req.query("schema_slug") ?? null
|
|
24
|
+
const status = c.req.query("status") ?? null
|
|
25
|
+
const rawLimit = Number.parseInt(c.req.query("limit") ?? "20", 10)
|
|
26
|
+
const limit = Math.min(Math.max(rawLimit, 1), 50)
|
|
27
|
+
const cursor = c.req.query("cursor") ?? null
|
|
28
|
+
|
|
29
|
+
if (queryText.length < 2) {
|
|
30
|
+
return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const seeds = c.get('seedRegistry').all()
|
|
34
|
+
const searchRepository = c.get('searchRepository')
|
|
35
|
+
|
|
36
|
+
const queryOptions = {
|
|
37
|
+
queryText,
|
|
38
|
+
schemaSlug,
|
|
39
|
+
statusFilter: status,
|
|
40
|
+
limit,
|
|
41
|
+
cursor,
|
|
42
|
+
}
|
|
43
|
+
const countOptions = { queryText, schemaSlug, statusFilter: status }
|
|
44
|
+
|
|
45
|
+
const [rawRows, countResult] = await Promise.all([
|
|
46
|
+
searchRepository.search(queryOptions, seeds),
|
|
47
|
+
searchRepository.count(countOptions, seeds),
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
const hasMore = rawRows.length > limit
|
|
51
|
+
const pageRows = hasMore ? rawRows.slice(0, limit) : rawRows
|
|
52
|
+
|
|
53
|
+
const lastRow = pageRows.at(-1)
|
|
54
|
+
const nextCursor = hasMore && lastRow
|
|
55
|
+
? encodeCursor(lastRow.rank, lastRow.entryId)
|
|
56
|
+
: null
|
|
57
|
+
|
|
58
|
+
if (pageRows.length === 0) {
|
|
59
|
+
return c.json({ items: [], nextCursor: null, total: countResult.total } satisfies SearchResponse)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const items = pageRows.map(mapSearchResultRow)
|
|
63
|
+
|
|
64
|
+
return c.json({ items, nextCursor, total: countResult.total } satisfies SearchResponse)
|
|
65
|
+
})
|