@beechcms/api 0.4.0-preview.9 → 0.4.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/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/constants.ts +10 -10
- package/src/auth/login.ts +91 -91
- package/src/auth/refresh.ts +127 -127
- package/src/factory.ts +347 -303
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +153 -0
- package/src/features/content/handlers/delete.ts +76 -0
- package/src/features/content/handlers/facets.ts +45 -0
- package/src/features/content/handlers/get.ts +116 -0
- package/src/features/content/handlers/list.ts +88 -0
- package/src/features/content/handlers/update.ts +207 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +272 -198
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +130 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +106 -88
- package/src/features/password-reset/reset.ts +128 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +132 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +414 -249
- package/src/features/setup/index.ts +96 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +458 -395
- package/src/index.ts +24 -11
- package/src/media-utils.ts +78 -78
- package/src/middleware/repository.middleware.ts +24 -0
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +67 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +166 -183
- package/src/public/public-edit.ts +158 -183
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -217
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.ts +152 -241
- package/src/public/rate-limit-middleware.ts +42 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- package/src/search-utils.ts +192 -192
- package/src/search.ts +72 -72
- package/src/shared/activity-logger.ts +79 -79
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.ts +56 -0
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/notification-service.ts +56 -56
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +46 -36
- package/src/upload.ts +179 -335
- package/src/widget.ts +349 -349
- package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
- package/assets/dashboard/assets/index-CQODXprH.css +0 -1
- package/src/content.ts +0 -502
- package/src/features/draft/draft.test.ts +0 -315
- package/src/features/rotate-field/rotate-field.test.ts +0 -297
package/src/public/sanitize.ts
CHANGED
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
import { validateAndSanitizeSeedPayload } from '@beechcms/core'
|
|
2
|
-
import type { Seed, ValidationDetail } from '@beechcms/core'
|
|
3
|
-
|
|
4
|
-
type PublicSanitizeSuccess = {
|
|
5
|
-
ok: true
|
|
6
|
-
data: Record<string, unknown>
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
type PublicSanitizeFailure = {
|
|
10
|
-
ok: false
|
|
11
|
-
status: 400 | 422
|
|
12
|
-
code: 'validation_failed' | 'dangerous_content'
|
|
13
|
-
message: string
|
|
14
|
-
details?: ValidationDetail[]
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export type PublicSanitizeResult = PublicSanitizeSuccess | PublicSanitizeFailure
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
*
|
|
21
|
-
*/
|
|
22
|
-
export function sanitizePublicPayload(
|
|
23
|
-
seed: Seed,
|
|
24
|
-
payload: Record<string, unknown>,
|
|
25
|
-
options: {
|
|
26
|
-
allowNull?: boolean
|
|
27
|
-
operation?: 'create' | 'update'
|
|
28
|
-
requireAtLeastOneValidField?: boolean
|
|
29
|
-
enforceRequiredFields?: boolean
|
|
30
|
-
} = {}
|
|
31
|
-
): PublicSanitizeResult {
|
|
32
|
-
const operation = options.operation ?? 'create'
|
|
33
|
-
const result = validateAndSanitizeSeedPayload(seed, payload, {
|
|
34
|
-
allowNull: options.allowNull ?? false,
|
|
35
|
-
operation,
|
|
36
|
-
requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
|
|
37
|
-
enforceRequiredFields: options.enforceRequiredFields ?? true,
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
if (result.dangerousFields.length > 0) {
|
|
41
|
-
const field = result.dangerousFields[0]
|
|
42
|
-
return {
|
|
43
|
-
ok: false,
|
|
44
|
-
status: 422,
|
|
45
|
-
code: 'dangerous_content',
|
|
46
|
-
message: `Content rejected: dangerous markup detected in field '${field}'`,
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (result.details.length > 0) {
|
|
51
|
-
return {
|
|
52
|
-
ok: false,
|
|
53
|
-
status: 400,
|
|
54
|
-
code: 'validation_failed',
|
|
55
|
-
message: 'Validation failed',
|
|
56
|
-
details: result.details,
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
ok: true,
|
|
62
|
-
data: result.data,
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
1
|
+
import { validateAndSanitizeSeedPayload } from '@beechcms/core'
|
|
2
|
+
import type { Seed, ValidationDetail } from '@beechcms/core'
|
|
3
|
+
|
|
4
|
+
type PublicSanitizeSuccess = {
|
|
5
|
+
ok: true
|
|
6
|
+
data: Record<string, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type PublicSanitizeFailure = {
|
|
10
|
+
ok: false
|
|
11
|
+
status: 400 | 422
|
|
12
|
+
code: 'validation_failed' | 'dangerous_content'
|
|
13
|
+
message: string
|
|
14
|
+
details?: ValidationDetail[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type PublicSanitizeResult = PublicSanitizeSuccess | PublicSanitizeFailure
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Public API Adapter: uses the core foundation and maps errors into the sprint format.
|
|
21
|
+
*/
|
|
22
|
+
export function sanitizePublicPayload(
|
|
23
|
+
seed: Seed,
|
|
24
|
+
payload: Record<string, unknown>,
|
|
25
|
+
options: {
|
|
26
|
+
allowNull?: boolean
|
|
27
|
+
operation?: 'create' | 'update'
|
|
28
|
+
requireAtLeastOneValidField?: boolean
|
|
29
|
+
enforceRequiredFields?: boolean
|
|
30
|
+
} = {}
|
|
31
|
+
): PublicSanitizeResult {
|
|
32
|
+
const operation = options.operation ?? 'create'
|
|
33
|
+
const result = validateAndSanitizeSeedPayload(seed, payload, {
|
|
34
|
+
allowNull: options.allowNull ?? false,
|
|
35
|
+
operation,
|
|
36
|
+
requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
|
|
37
|
+
enforceRequiredFields: options.enforceRequiredFields ?? true,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (result.dangerousFields.length > 0) {
|
|
41
|
+
const field = result.dangerousFields[0]
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
status: 422,
|
|
45
|
+
code: 'dangerous_content',
|
|
46
|
+
message: `Content rejected: dangerous markup detected in field '${field}'`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (result.details.length > 0) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
status: 400,
|
|
54
|
+
code: 'validation_failed',
|
|
55
|
+
message: 'Validation failed',
|
|
56
|
+
details: result.details,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
data: result.data,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
package/src/public/slug-utils.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { slugify, generateEntrySlug } from '@beechcms/core'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*/
|
|
7
|
-
export { slugify }
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*/
|
|
13
|
-
export { generateEntrySlug }
|
|
14
|
-
|
|
1
|
+
import { slugify, generateEntrySlug } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converts a string into a URL-safe slug.
|
|
5
|
+
* Logic moved to @beechcms/core for consistency between Dashboard and API.
|
|
6
|
+
*/
|
|
7
|
+
export { slugify }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generates a slug from a title/name or a UUID-like fallback.
|
|
11
|
+
* Logic moved to @beechcms/core for consistency between Dashboard and API.
|
|
12
|
+
*/
|
|
13
|
+
export { generateEntrySlug }
|
|
14
|
+
|
package/src/search-utils.ts
CHANGED
|
@@ -1,192 +1,192 @@
|
|
|
1
|
-
// apps/api/src/search-utils.ts
|
|
2
|
-
// Pure functions — zero Hono dependencies, importable from Vitest.
|
|
3
|
-
// v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
|
|
4
|
-
|
|
5
|
-
import type { Seed } from "@beechcms/core"
|
|
6
|
-
|
|
7
|
-
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
// Row returned by UNION ALL query across fts_{slug} JOIN content_{slug}
|
|
10
|
-
export interface FtsRow {
|
|
11
|
-
entry_id: string
|
|
12
|
-
schema_slug: string
|
|
13
|
-
slug: string | null
|
|
14
|
-
status: string
|
|
15
|
-
title: string | null
|
|
16
|
-
excerpt: string
|
|
17
|
-
rank: number
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface SearchResultItem {
|
|
21
|
-
id: string
|
|
22
|
-
schema_slug: string
|
|
23
|
-
slug: string | null
|
|
24
|
-
status: string
|
|
25
|
-
title: string
|
|
26
|
-
excerpt: string
|
|
27
|
-
data: Record<string, unknown>
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface SearchResponse {
|
|
31
|
-
items: SearchResultItem[]
|
|
32
|
-
nextCursor: string | null
|
|
33
|
-
total: number
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface SearchQueryParams {
|
|
37
|
-
q: string
|
|
38
|
-
schemaSlug: string | null
|
|
39
|
-
status: string | null
|
|
40
|
-
limit: number // already clamped 1–50 by handler
|
|
41
|
-
cursor: string | null
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ─── Cursor ──────────────────────────────────────────────────────────────────
|
|
45
|
-
|
|
46
|
-
export function encodeCursor(rank: number, entryId: string): string {
|
|
47
|
-
return btoa(`${rank}:${entryId}`)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function decodeCursor(cursor: string): { rank: number; entryId: string } | null {
|
|
51
|
-
try {
|
|
52
|
-
const decoded = atob(cursor)
|
|
53
|
-
const sep = decoded.lastIndexOf(":")
|
|
54
|
-
if (sep === -1) return null
|
|
55
|
-
return {
|
|
56
|
-
rank: parseFloat(decoded.slice(0, sep)),
|
|
57
|
-
entryId: decoded.slice(sep + 1),
|
|
58
|
-
}
|
|
59
|
-
} catch {
|
|
60
|
-
return null
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
function hasSearchableFts(seed: Seed): boolean {
|
|
67
|
-
return seed.branches.some(b =>
|
|
68
|
-
(b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false
|
|
69
|
-
)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function buildMatchExpr(q: string): string {
|
|
73
|
-
const MIN_PREFIX = 3
|
|
74
|
-
const safeQ = q.replace(/["*^()]/g, " ").trim()
|
|
75
|
-
const terms = safeQ.split(/\s+/).filter(t => t.length >= 2)
|
|
76
|
-
if (terms.length === 0) throw new Error("EMPTY_QUERY")
|
|
77
|
-
|
|
78
|
-
return terms
|
|
79
|
-
.map(t => {
|
|
80
|
-
if (/^\d+$/.test(t)) return `"${t}"`
|
|
81
|
-
if (t.length <= MIN_PREFIX) return `"${t}"*`
|
|
82
|
-
const prefixes: string[] = []
|
|
83
|
-
for (let i = MIN_PREFIX; i <= t.length; i++) {
|
|
84
|
-
prefixes.push(`"${t.slice(0, i)}"*`)
|
|
85
|
-
}
|
|
86
|
-
return `(${prefixes.join(" OR ")})`
|
|
87
|
-
})
|
|
88
|
-
.join(" ")
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ─── Query builder ───────────────────────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Builds a UNION ALL query across all per-seed fts_{slug} tables (v0.4.0).
|
|
95
|
-
* Each SELECT joins fts_{slug} with content_{slug} to fetch title, slug, status.
|
|
96
|
-
* Seeds param: full registry — filtered internally by schemaSlug and FTS availability.
|
|
97
|
-
*/
|
|
98
|
-
export function buildFtsQuery(
|
|
99
|
-
params: SearchQueryParams,
|
|
100
|
-
seeds: Seed[],
|
|
101
|
-
): {
|
|
102
|
-
sql: string
|
|
103
|
-
binds: unknown[]
|
|
104
|
-
countSql: string
|
|
105
|
-
countBinds: unknown[]
|
|
106
|
-
} {
|
|
107
|
-
const { q, schemaSlug, status, limit, cursor } = params
|
|
108
|
-
|
|
109
|
-
const matchExpr = buildMatchExpr(q) // throws EMPTY_QUERY if needed
|
|
110
|
-
|
|
111
|
-
const targetSeeds = seeds.filter(s =>
|
|
112
|
-
hasSearchableFts(s) && (schemaSlug === null || s.slug === schemaSlug)
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
if (targetSeeds.length === 0) {
|
|
116
|
-
return {
|
|
117
|
-
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",
|
|
118
|
-
binds: [],
|
|
119
|
-
countSql: "SELECT 0 as total",
|
|
120
|
-
countBinds: [],
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const decoded = cursor ? decodeCursor(cursor) : null
|
|
125
|
-
|
|
126
|
-
const parts: string[] = []
|
|
127
|
-
const binds: unknown[] = []
|
|
128
|
-
const countParts: string[] = []
|
|
129
|
-
const countBinds: unknown[] = []
|
|
130
|
-
|
|
131
|
-
for (const seed of targetSeeds) {
|
|
132
|
-
const fts = `fts_${seed.slug}`
|
|
133
|
-
const table = `content_${seed.slug}`
|
|
134
|
-
const title = seed.displayNameAlias
|
|
135
|
-
|
|
136
|
-
// Main query per seed
|
|
137
|
-
const where: string[] = [`${fts} MATCH ?`]
|
|
138
|
-
const lb: unknown[] = [matchExpr]
|
|
139
|
-
|
|
140
|
-
if (status) { where.push("ce.status = ?"); lb.push(status) }
|
|
141
|
-
|
|
142
|
-
if (decoded) {
|
|
143
|
-
where.push(`(bm25(${fts}) > ? OR (bm25(${fts}) = ? AND f.entry_id > ?))`)
|
|
144
|
-
lb.push(decoded.rank, decoded.rank, decoded.entryId)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
parts.push(
|
|
148
|
-
`SELECT f.entry_id, '${seed.slug}' AS schema_slug, ce.slug, ce.status,` +
|
|
149
|
-
` ce.${title} AS title,` +
|
|
150
|
-
` snippet(${fts}, 1, '<mark>', '</mark>', '…', 16) AS excerpt,` +
|
|
151
|
-
` bm25(${fts}) AS rank` +
|
|
152
|
-
` FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id` +
|
|
153
|
-
` WHERE ${where.join(' AND ')}`
|
|
154
|
-
)
|
|
155
|
-
binds.push(...lb)
|
|
156
|
-
|
|
157
|
-
// Count per seed (no cursor, no limit)
|
|
158
|
-
const cw: string[] = [`${fts} MATCH ?`]
|
|
159
|
-
const cb: unknown[] = [matchExpr]
|
|
160
|
-
if (status) { cw.push("ce.status = ?"); cb.push(status) }
|
|
161
|
-
|
|
162
|
-
countParts.push(
|
|
163
|
-
`SELECT COUNT(*) as c FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id WHERE ${cw.join(' AND ')}`
|
|
164
|
-
)
|
|
165
|
-
countBinds.push(...cb)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const sql = `${parts.join(' UNION ALL ')} ORDER BY rank, entry_id LIMIT ?`
|
|
169
|
-
binds.push(limit + 1)
|
|
170
|
-
|
|
171
|
-
const countSql = `SELECT SUM(c) as total FROM (${countParts.join(' UNION ALL ')})`
|
|
172
|
-
|
|
173
|
-
return { sql, binds, countSql, countBinds }
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ─── Mapper ──────────────────────────────────────────────────────────────────
|
|
177
|
-
|
|
178
|
-
function stripHtmlPreserveMark(html: string): string {
|
|
179
|
-
return html.replace(/<(?!\/?mark\b)[^>]*>/gi, ' ').replace(/\s+/g, ' ').trim()
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function mapFtsRow(row: FtsRow): SearchResultItem {
|
|
183
|
-
return {
|
|
184
|
-
id: row.entry_id,
|
|
185
|
-
schema_slug: row.schema_slug,
|
|
186
|
-
slug: row.slug,
|
|
187
|
-
status: row.status,
|
|
188
|
-
title: row.title ?? "",
|
|
189
|
-
excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
|
|
190
|
-
data: {},
|
|
191
|
-
}
|
|
192
|
-
}
|
|
1
|
+
// apps/api/src/search-utils.ts
|
|
2
|
+
// Pure functions — zero Hono dependencies, importable from Vitest.
|
|
3
|
+
// v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
|
|
4
|
+
|
|
5
|
+
import type { Seed } from "@beechcms/core"
|
|
6
|
+
|
|
7
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
// Row returned by UNION ALL query across fts_{slug} JOIN content_{slug}
|
|
10
|
+
export interface FtsRow {
|
|
11
|
+
entry_id: string
|
|
12
|
+
schema_slug: string
|
|
13
|
+
slug: string | null
|
|
14
|
+
status: string
|
|
15
|
+
title: string | null
|
|
16
|
+
excerpt: string
|
|
17
|
+
rank: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SearchResultItem {
|
|
21
|
+
id: string
|
|
22
|
+
schema_slug: string
|
|
23
|
+
slug: string | null
|
|
24
|
+
status: string
|
|
25
|
+
title: string
|
|
26
|
+
excerpt: string
|
|
27
|
+
data: Record<string, unknown>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SearchResponse {
|
|
31
|
+
items: SearchResultItem[]
|
|
32
|
+
nextCursor: string | null
|
|
33
|
+
total: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SearchQueryParams {
|
|
37
|
+
q: string
|
|
38
|
+
schemaSlug: string | null
|
|
39
|
+
status: string | null
|
|
40
|
+
limit: number // already clamped 1–50 by handler
|
|
41
|
+
cursor: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Cursor ──────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function encodeCursor(rank: number, entryId: string): string {
|
|
47
|
+
return btoa(`${rank}:${entryId}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function decodeCursor(cursor: string): { rank: number; entryId: string } | null {
|
|
51
|
+
try {
|
|
52
|
+
const decoded = atob(cursor)
|
|
53
|
+
const sep = decoded.lastIndexOf(":")
|
|
54
|
+
if (sep === -1) return null
|
|
55
|
+
return {
|
|
56
|
+
rank: parseFloat(decoded.slice(0, sep)),
|
|
57
|
+
entryId: decoded.slice(sep + 1),
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function hasSearchableFts(seed: Seed): boolean {
|
|
67
|
+
return seed.branches.some(b =>
|
|
68
|
+
(b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildMatchExpr(q: string): string {
|
|
73
|
+
const MIN_PREFIX = 3
|
|
74
|
+
const safeQ = q.replace(/["*^()]/g, " ").trim()
|
|
75
|
+
const terms = safeQ.split(/\s+/).filter(t => t.length >= 2)
|
|
76
|
+
if (terms.length === 0) throw new Error("EMPTY_QUERY")
|
|
77
|
+
|
|
78
|
+
return terms
|
|
79
|
+
.map(t => {
|
|
80
|
+
if (/^\d+$/.test(t)) return `"${t}"`
|
|
81
|
+
if (t.length <= MIN_PREFIX) return `"${t}"*`
|
|
82
|
+
const prefixes: string[] = []
|
|
83
|
+
for (let i = MIN_PREFIX; i <= t.length; i++) {
|
|
84
|
+
prefixes.push(`"${t.slice(0, i)}"*`)
|
|
85
|
+
}
|
|
86
|
+
return `(${prefixes.join(" OR ")})`
|
|
87
|
+
})
|
|
88
|
+
.join(" ")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Query builder ───────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Builds a UNION ALL query across all per-seed fts_{slug} tables (v0.4.0).
|
|
95
|
+
* Each SELECT joins fts_{slug} with content_{slug} to fetch title, slug, status.
|
|
96
|
+
* Seeds param: full registry — filtered internally by schemaSlug and FTS availability.
|
|
97
|
+
*/
|
|
98
|
+
export function buildFtsQuery(
|
|
99
|
+
params: SearchQueryParams,
|
|
100
|
+
seeds: Seed[],
|
|
101
|
+
): {
|
|
102
|
+
sql: string
|
|
103
|
+
binds: unknown[]
|
|
104
|
+
countSql: string
|
|
105
|
+
countBinds: unknown[]
|
|
106
|
+
} {
|
|
107
|
+
const { q, schemaSlug, status, limit, cursor } = params
|
|
108
|
+
|
|
109
|
+
const matchExpr = buildMatchExpr(q) // throws EMPTY_QUERY if needed
|
|
110
|
+
|
|
111
|
+
const targetSeeds = seeds.filter(s =>
|
|
112
|
+
hasSearchableFts(s) && (schemaSlug === null || s.slug === schemaSlug)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if (targetSeeds.length === 0) {
|
|
116
|
+
return {
|
|
117
|
+
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",
|
|
118
|
+
binds: [],
|
|
119
|
+
countSql: "SELECT 0 as total",
|
|
120
|
+
countBinds: [],
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const decoded = cursor ? decodeCursor(cursor) : null
|
|
125
|
+
|
|
126
|
+
const parts: string[] = []
|
|
127
|
+
const binds: unknown[] = []
|
|
128
|
+
const countParts: string[] = []
|
|
129
|
+
const countBinds: unknown[] = []
|
|
130
|
+
|
|
131
|
+
for (const seed of targetSeeds) {
|
|
132
|
+
const fts = `fts_${seed.slug}`
|
|
133
|
+
const table = `content_${seed.slug}`
|
|
134
|
+
const title = seed.displayNameAlias
|
|
135
|
+
|
|
136
|
+
// Main query per seed
|
|
137
|
+
const where: string[] = [`${fts} MATCH ?`]
|
|
138
|
+
const lb: unknown[] = [matchExpr]
|
|
139
|
+
|
|
140
|
+
if (status) { where.push("ce.status = ?"); lb.push(status) }
|
|
141
|
+
|
|
142
|
+
if (decoded) {
|
|
143
|
+
where.push(`(bm25(${fts}) > ? OR (bm25(${fts}) = ? AND f.entry_id > ?))`)
|
|
144
|
+
lb.push(decoded.rank, decoded.rank, decoded.entryId)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
parts.push(
|
|
148
|
+
`SELECT f.entry_id, '${seed.slug}' AS schema_slug, ce.slug, ce.status,` +
|
|
149
|
+
` ce.${title} AS title,` +
|
|
150
|
+
` snippet(${fts}, 1, '<mark>', '</mark>', '…', 16) AS excerpt,` +
|
|
151
|
+
` bm25(${fts}) AS rank` +
|
|
152
|
+
` FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id` +
|
|
153
|
+
` WHERE ${where.join(' AND ')}`
|
|
154
|
+
)
|
|
155
|
+
binds.push(...lb)
|
|
156
|
+
|
|
157
|
+
// Count per seed (no cursor, no limit)
|
|
158
|
+
const cw: string[] = [`${fts} MATCH ?`]
|
|
159
|
+
const cb: unknown[] = [matchExpr]
|
|
160
|
+
if (status) { cw.push("ce.status = ?"); cb.push(status) }
|
|
161
|
+
|
|
162
|
+
countParts.push(
|
|
163
|
+
`SELECT COUNT(*) as c FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id WHERE ${cw.join(' AND ')}`
|
|
164
|
+
)
|
|
165
|
+
countBinds.push(...cb)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const sql = `${parts.join(' UNION ALL ')} ORDER BY rank, entry_id LIMIT ?`
|
|
169
|
+
binds.push(limit + 1)
|
|
170
|
+
|
|
171
|
+
const countSql = `SELECT SUM(c) as total FROM (${countParts.join(' UNION ALL ')})`
|
|
172
|
+
|
|
173
|
+
return { sql, binds, countSql, countBinds }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Mapper ──────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
function stripHtmlPreserveMark(html: string): string {
|
|
179
|
+
return html.replace(/<(?!\/?mark\b)[^>]*>/gi, ' ').replace(/\s+/g, ' ').trim()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function mapFtsRow(row: FtsRow): SearchResultItem {
|
|
183
|
+
return {
|
|
184
|
+
id: row.entry_id,
|
|
185
|
+
schema_slug: row.schema_slug,
|
|
186
|
+
slug: row.slug,
|
|
187
|
+
status: row.status,
|
|
188
|
+
title: row.title ?? "",
|
|
189
|
+
excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
|
|
190
|
+
data: {},
|
|
191
|
+
}
|
|
192
|
+
}
|