@beechcms/api 0.4.0-preview.12 → 0.4.0-preview.13

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.
Files changed (85) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  4. package/assets/dashboard/assets/{index-CTSuGxlX.js → index-FQ6JhvRH.js} +99 -99
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/constants.ts +10 -10
  12. package/src/auth/login.ts +91 -91
  13. package/src/auth/refresh.ts +127 -127
  14. package/src/factory.ts +347 -325
  15. package/src/features/content/constants.ts +10 -10
  16. package/src/features/content/handlers/create.ts +153 -163
  17. package/src/features/content/handlers/delete.ts +76 -85
  18. package/src/features/content/handlers/facets.ts +45 -45
  19. package/src/features/content/handlers/get.ts +116 -116
  20. package/src/features/content/handlers/list.ts +88 -88
  21. package/src/features/content/handlers/update.ts +207 -216
  22. package/src/features/content/index.ts +20 -20
  23. package/src/features/draft/draft.handler.ts +272 -272
  24. package/src/features/draft/index.ts +1 -1
  25. package/src/features/email/email.provider.ts +38 -38
  26. package/src/features/email/email.service.ts +80 -80
  27. package/src/features/email/email.types.ts +98 -98
  28. package/src/features/email/index.ts +28 -28
  29. package/src/features/email/providers/resend.ts +63 -63
  30. package/src/features/email/templates/password-changed.ts +59 -59
  31. package/src/features/email/templates/password-reset.ts +64 -64
  32. package/src/features/email/templates/shell.ts +92 -93
  33. package/src/features/notifications/index.ts +1 -1
  34. package/src/features/notifications/notifications.handler.ts +130 -88
  35. package/src/features/password-reset/index.ts +15 -15
  36. package/src/features/password-reset/request.ts +106 -88
  37. package/src/features/password-reset/reset.ts +128 -110
  38. package/src/features/rotate-field/index.ts +1 -1
  39. package/src/features/rotate-field/rotate-field.handler.ts +132 -82
  40. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  41. package/src/features/schema/schema.handler.ts +16 -16
  42. package/src/features/settings/settings.handler.ts +414 -267
  43. package/src/features/setup/index.ts +96 -59
  44. package/src/features/stats/index.ts +1 -1
  45. package/src/features/stats/stats.handler.ts +458 -395
  46. package/src/index.ts +24 -24
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -18
  49. package/src/middleware/storage.middleware.ts +21 -0
  50. package/src/middleware.ts +67 -67
  51. package/src/public/access-policy.ts +23 -23
  52. package/src/public/api-key-middleware.ts +53 -53
  53. package/src/public/index.ts +12 -12
  54. package/src/public/problem-details.ts +48 -42
  55. package/src/public/public-add.ts +166 -166
  56. package/src/public/public-edit.ts +158 -158
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -216
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -152
  61. package/src/public/rate-limit-middleware.ts +42 -42
  62. package/src/public/response-builder.ts +26 -26
  63. package/src/public/sanitize.ts +65 -65
  64. package/src/public/slug-utils.ts +14 -14
  65. package/src/search-utils.ts +192 -192
  66. package/src/search.ts +72 -72
  67. package/src/shared/activity-logger.ts +79 -79
  68. package/src/shared/apply-policies.ts +63 -63
  69. package/src/shared/base.repository.d1.ts +28 -28
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -382
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -45
  74. package/src/shared/media.repository.d1.ts +64 -0
  75. package/src/shared/notification-service.ts +56 -56
  76. package/src/shared/query-utils.ts +137 -137
  77. package/src/shared/storage/factory.ts +40 -0
  78. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  79. package/src/shared/storage/s3-bucket.ts +163 -0
  80. package/src/shared/storage-utils.ts +36 -36
  81. package/src/shared/system-stats.repository.d1.ts +44 -0
  82. package/src/types.ts +46 -41
  83. package/src/upload.ts +179 -331
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-ye3325L9.css +0 -1
@@ -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
+ }
package/src/search.ts CHANGED
@@ -1,72 +1,72 @@
1
- // apps/api/src/search.ts
2
-
3
- import { Hono } from "hono"
4
- import type { Env, Variables } from "./types"
5
- import { authMiddleware } from "./middleware"
6
- import {
7
- buildFtsQuery,
8
- encodeCursor,
9
- mapFtsRow,
10
- type FtsRow,
11
- type SearchResponse,
12
- } from "./search-utils"
13
-
14
- export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
15
-
16
- searchRouter.use("*", async (c, next) => {
17
- return authMiddleware(c.env.JWT_SECRET, {
18
- issuer: c.env.JWT_ISSUER,
19
- audience: c.env.JWT_AUDIENCE,
20
- })(c, next)
21
- })
22
-
23
- // GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
24
- searchRouter.get("/", async (c) => {
25
- const q = c.req.query("q")?.trim() ?? ""
26
- const schemaSlug = c.req.query("schema_slug") ?? null
27
- const status = c.req.query("status") ?? null
28
- const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
29
- const limit = Math.min(Math.max(rawLimit, 1), 50)
30
- const cursor = c.req.query("cursor") ?? null
31
-
32
- if (q.length < 2) {
33
- return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
34
- }
35
-
36
- const seeds = Object.values(c.get('seedRegistry'))
37
-
38
- let queryParts: ReturnType<typeof buildFtsQuery>
39
- try {
40
- queryParts = buildFtsQuery({ q, schemaSlug, status, limit, cursor }, seeds)
41
- } catch (e) {
42
- if ((e as Error).message === "EMPTY_QUERY") {
43
- return c.json({ items: [], nextCursor: null, total: 0 } satisfies SearchResponse)
44
- }
45
- throw e
46
- }
47
-
48
- const { sql, binds, countSql, countBinds } = queryParts
49
-
50
- const [ftsResult, countResult] = await Promise.all([
51
- c.env.DB.prepare(sql).bind(...binds).all<FtsRow>(),
52
- c.env.DB.prepare(countSql).bind(...countBinds).first<{ total: number }>(),
53
- ])
54
-
55
- const rows = ftsResult.results ?? []
56
- const total = countResult?.total ?? 0
57
-
58
- const hasMore = rows.length > limit
59
- const pageRows = hasMore ? rows.slice(0, limit) : rows
60
-
61
- const nextCursor = hasMore
62
- ? encodeCursor(pageRows.at(-1)!.rank, pageRows.at(-1)!.entry_id)
63
- : null
64
-
65
- if (pageRows.length === 0) {
66
- return c.json({ items: [], nextCursor: null, total } satisfies SearchResponse)
67
- }
68
-
69
- const items = pageRows.map(row => mapFtsRow(row))
70
-
71
- return c.json({ items, nextCursor, total } satisfies SearchResponse)
72
- })
1
+ // apps/api/src/search.ts
2
+
3
+ import { Hono } from "hono"
4
+ import type { Env, Variables } from "./types"
5
+ import { authMiddleware } from "./middleware"
6
+ import {
7
+ buildFtsQuery,
8
+ encodeCursor,
9
+ mapFtsRow,
10
+ type FtsRow,
11
+ type SearchResponse,
12
+ } from "./search-utils"
13
+
14
+ export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
15
+
16
+ searchRouter.use("*", async (c, next) => {
17
+ return authMiddleware(c.env.JWT_SECRET, {
18
+ issuer: c.env.JWT_ISSUER,
19
+ audience: c.env.JWT_AUDIENCE,
20
+ })(c, next)
21
+ })
22
+
23
+ // GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
24
+ searchRouter.get("/", async (c) => {
25
+ const q = c.req.query("q")?.trim() ?? ""
26
+ const schemaSlug = c.req.query("schema_slug") ?? null
27
+ const status = c.req.query("status") ?? null
28
+ const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
29
+ const limit = Math.min(Math.max(rawLimit, 1), 50)
30
+ const cursor = c.req.query("cursor") ?? null
31
+
32
+ if (q.length < 2) {
33
+ return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
34
+ }
35
+
36
+ const seeds = Object.values(c.get('seedRegistry'))
37
+
38
+ let queryParts: ReturnType<typeof buildFtsQuery>
39
+ try {
40
+ queryParts = buildFtsQuery({ q, schemaSlug, status, limit, cursor }, seeds)
41
+ } catch (e) {
42
+ if ((e as Error).message === "EMPTY_QUERY") {
43
+ return c.json({ items: [], nextCursor: null, total: 0 } satisfies SearchResponse)
44
+ }
45
+ throw e
46
+ }
47
+
48
+ const { sql, binds, countSql, countBinds } = queryParts
49
+
50
+ const [ftsResult, countResult] = await Promise.all([
51
+ c.env.DB.prepare(sql).bind(...binds).all<FtsRow>(),
52
+ c.env.DB.prepare(countSql).bind(...countBinds).first<{ total: number }>(),
53
+ ])
54
+
55
+ const rows = ftsResult.results ?? []
56
+ const total = countResult?.total ?? 0
57
+
58
+ const hasMore = rows.length > limit
59
+ const pageRows = hasMore ? rows.slice(0, limit) : rows
60
+
61
+ const nextCursor = hasMore
62
+ ? encodeCursor(pageRows.at(-1)!.rank, pageRows.at(-1)!.entry_id)
63
+ : null
64
+
65
+ if (pageRows.length === 0) {
66
+ return c.json({ items: [], nextCursor: null, total } satisfies SearchResponse)
67
+ }
68
+
69
+ const items = pageRows.map(row => mapFtsRow(row))
70
+
71
+ return c.json({ items, nextCursor, total } satisfies SearchResponse)
72
+ })