@beechcms/api 0.4.0-preview.9 → 0.4.1

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 (140) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  4. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  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/bcrypt-hash-provider.ts +20 -0
  12. package/src/auth/constants.ts +10 -10
  13. package/src/auth/generate-refresh-token.test.ts +19 -0
  14. package/src/auth/hash-provider.test.ts +46 -0
  15. package/src/auth/in-memory-hash-provider.ts +13 -0
  16. package/src/auth/jose-token-service.ts +55 -0
  17. package/src/auth/login.test.ts +92 -0
  18. package/src/auth/login.ts +74 -91
  19. package/src/auth/refresh.ts +5 -127
  20. package/src/auth/static-token-service.ts +18 -0
  21. package/src/auth/token-service.test.ts +82 -0
  22. package/src/factory.ts +339 -303
  23. package/src/features/content/constants.ts +10 -0
  24. package/src/features/content/handlers/create.ts +157 -0
  25. package/src/features/content/handlers/delete.ts +80 -0
  26. package/src/features/content/handlers/facets.ts +45 -0
  27. package/src/features/content/handlers/get.ts +116 -0
  28. package/src/features/content/handlers/list.ts +88 -0
  29. package/src/features/content/handlers/update.ts +211 -0
  30. package/src/features/content/index.ts +20 -0
  31. package/src/features/draft/draft.handler.ts +283 -198
  32. package/src/features/draft/index.ts +1 -1
  33. package/src/features/email/email.provider.ts +38 -38
  34. package/src/features/email/email.service.ts +80 -80
  35. package/src/features/email/email.types.ts +98 -98
  36. package/src/features/email/index.ts +28 -28
  37. package/src/features/email/providers/resend.ts +63 -63
  38. package/src/features/email/templates/password-changed.ts +59 -59
  39. package/src/features/email/templates/password-reset.ts +64 -64
  40. package/src/features/email/templates/shell.ts +92 -93
  41. package/src/features/notifications/index.ts +1 -1
  42. package/src/features/notifications/notifications.handler.ts +101 -88
  43. package/src/features/password-reset/index.ts +15 -15
  44. package/src/features/password-reset/request.ts +82 -88
  45. package/src/features/password-reset/reset.ts +92 -110
  46. package/src/features/rotate-field/index.ts +1 -1
  47. package/src/features/rotate-field/rotate-field.handler.ts +128 -82
  48. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  49. package/src/features/schema/schema.handler.ts +16 -16
  50. package/src/features/settings/settings.handler.ts +301 -249
  51. package/src/features/setup/index.ts +91 -59
  52. package/src/features/stats/index.ts +1 -1
  53. package/src/features/stats/stats.handler.ts +430 -395
  54. package/src/index.ts +24 -11
  55. package/src/media-utils.ts +78 -78
  56. package/src/middleware/auth-providers.middleware.ts +32 -0
  57. package/src/middleware/observability.middleware.ts +52 -0
  58. package/src/middleware/rate-limit.middleware.ts +41 -0
  59. package/src/middleware/repository.middleware.ts +60 -0
  60. package/src/middleware/storage.middleware.ts +21 -0
  61. package/src/middleware.ts +47 -67
  62. package/src/public/access-policy.ts +23 -23
  63. package/src/public/api-key-middleware.ts +53 -53
  64. package/src/public/index.ts +12 -12
  65. package/src/public/problem-details.ts +48 -42
  66. package/src/public/public-add.ts +156 -183
  67. package/src/public/public-edit.ts +159 -183
  68. package/src/public/public-errors.ts +15 -15
  69. package/src/public/public-read.ts +216 -217
  70. package/src/public/public-routes.ts +84 -31
  71. package/src/public/query-builder.test.ts +220 -0
  72. package/src/public/query-builder.ts +152 -241
  73. package/src/public/rate-limit-middleware.ts +30 -42
  74. package/src/public/response-builder.ts +26 -26
  75. package/src/public/sanitize.ts +65 -65
  76. package/src/public/slug-utils.ts +14 -14
  77. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  78. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  79. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  80. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  81. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  82. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  83. package/src/search-utils.test.ts +207 -0
  84. package/src/search-utils.ts +209 -192
  85. package/src/search.ts +61 -72
  86. package/src/shared/apply-policies.test.ts +77 -0
  87. package/src/shared/apply-policies.ts +63 -63
  88. package/src/shared/background-notification-service.test.ts +58 -0
  89. package/src/shared/background-notification-service.ts +48 -0
  90. package/src/shared/base.repository.d1.ts +28 -0
  91. package/src/shared/content-utils.test.ts +161 -0
  92. package/src/shared/content-utils.ts +82 -108
  93. package/src/shared/content.repository.d1.test.ts +312 -0
  94. package/src/shared/content.repository.d1.ts +382 -0
  95. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  96. package/src/shared/d1-activity-log.repository.ts +101 -0
  97. package/src/shared/d1-activity-logger.test.ts +82 -0
  98. package/src/shared/d1-activity-logger.ts +63 -0
  99. package/src/shared/d1-analytics.repository.test.ts +74 -0
  100. package/src/shared/d1-analytics.repository.ts +81 -0
  101. package/src/shared/d1-content-scan.repository.ts +29 -0
  102. package/src/shared/d1-notification.repository.test.ts +124 -0
  103. package/src/shared/d1-notification.repository.ts +114 -0
  104. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  105. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  106. package/src/shared/d1-search.repository.test.ts +83 -0
  107. package/src/shared/d1-search.repository.ts +84 -0
  108. package/src/shared/d1-session.repository.test.ts +121 -0
  109. package/src/shared/d1-session.repository.ts +98 -0
  110. package/src/shared/d1-user.repository.test.ts +147 -0
  111. package/src/shared/d1-user.repository.ts +109 -0
  112. package/src/shared/d1-widget.repository.test.ts +217 -0
  113. package/src/shared/d1-widget.repository.ts +337 -0
  114. package/src/shared/fixed-clock.ts +21 -0
  115. package/src/shared/fts-sync.ts +4 -4
  116. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  117. package/src/shared/idempotency.repository.d1.ts +56 -0
  118. package/src/shared/in-memory-activity-logger.ts +15 -0
  119. package/src/shared/in-memory-notification-service.ts +15 -0
  120. package/src/shared/media.repository.d1.test.ts +103 -0
  121. package/src/shared/media.repository.d1.ts +64 -0
  122. package/src/shared/query-utils.ts +137 -137
  123. package/src/shared/request-utils.ts +22 -0
  124. package/src/shared/sequential-id-generator.ts +22 -0
  125. package/src/shared/storage/factory.ts +40 -0
  126. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  127. package/src/shared/storage/s3-bucket.ts +163 -0
  128. package/src/shared/storage-utils.ts +36 -36
  129. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  130. package/src/shared/system-stats.repository.d1.ts +44 -0
  131. package/src/types.ts +63 -36
  132. package/src/upload.ts +186 -335
  133. package/src/widget.ts +208 -349
  134. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  135. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  136. package/src/content.ts +0 -502
  137. package/src/features/draft/draft.test.ts +0 -315
  138. package/src/features/rotate-field/rotate-field.test.ts +0 -297
  139. package/src/shared/activity-logger.ts +0 -79
  140. package/src/shared/notification-service.ts +0 -56
@@ -1,42 +1,30 @@
1
- import type { Context, Next } from 'hono'
2
- import { publicProblem } from './problem-details'
3
-
4
- type PublicBindings = {
5
- PUBLIC_READ_RATE_LIMITER?: RateLimit
6
- PUBLIC_WRITE_RATE_LIMITER?: RateLimit
7
- }
8
-
9
- function isReadMethod(method: string): boolean {
10
- return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
11
- }
12
-
13
- function getClientIp(headers: Headers): string {
14
- return headers.get('cf-connecting-ip') ?? 'unknown'
15
- }
16
-
17
- export function publicRateLimitMiddleware() {
18
- return async (c: Context, next: Next): Promise<Response | void> => {
19
- const env = c.env as PublicBindings
20
- const readMethod = isReadMethod(c.req.method)
21
- const limiter = readMethod ? env.PUBLIC_READ_RATE_LIMITER : env.PUBLIC_WRITE_RATE_LIMITER
22
- if (!limiter) {
23
- await next()
24
- return
25
- }
26
-
27
- const seed = c.req.param('seed') ?? 'no-seed'
28
- const key = `${getClientIp(c.req.raw.headers)}:${seed}:${readMethod ? 'read' : 'write'}`
29
- const { success } = await limiter.limit({ key })
30
-
31
- if (!success) {
32
- return publicProblem(c, {
33
- type: 'rate-limit-exceeded',
34
- title: 'Too Many Requests',
35
- status: 429,
36
- detail: 'Too many requests',
37
- })
38
- }
39
-
40
- await next()
41
- }
42
- }
1
+ import type { Context, Next } from 'hono'
2
+ import type { AppEnv } from '../types'
3
+ import { publicProblem } from './problem-details'
4
+ import { getClientIp } from '../shared/request-utils'
5
+
6
+ function isReadMethod(method: string): boolean {
7
+ return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
8
+ }
9
+
10
+ export function publicRateLimitMiddleware() {
11
+ return async (c: Context<AppEnv>, next: Next): Promise<Response | void> => {
12
+ const readMethod = isReadMethod(c.req.method)
13
+ const limiterName = readMethod ? ('publicApiRead' as const) : ('publicApiWrite' as const)
14
+
15
+ const seed = c.req.param('seed') ?? 'no-seed'
16
+ const key = `${getClientIp(c.req)}:${seed}:${limiterName}`
17
+ const result = await c.get('rateLimiters').getLimiter(limiterName).checkLimit(key)
18
+
19
+ if (!result.isAllowed) {
20
+ return publicProblem(c, {
21
+ type: 'rate-limit-exceeded',
22
+ title: 'Too Many Requests',
23
+ status: 429,
24
+ detail: 'Too many requests',
25
+ })
26
+ }
27
+
28
+ await next()
29
+ }
30
+ }
@@ -1,26 +1,26 @@
1
- /**
2
- * Helper meta per risposta lista Public API.
3
- */
4
- export function buildPublicListMeta(input: {
5
- total: number
6
- page: number
7
- limit: number
8
- returned: number
9
- seed: string
10
- }) {
11
- return {
12
- total: input.total,
13
- page: input.page,
14
- limit: input.limit,
15
- returned: input.returned,
16
- seed: input.seed,
17
- }
18
- }
19
-
20
- /**
21
- * Helper meta per risposta singolo elemento Public API.
22
- */
23
- export function buildPublicSingleMeta(seed: string) {
24
- return { seed }
25
- }
26
-
1
+ /**
2
+ * Helper meta per risposta lista Public API.
3
+ */
4
+ export function buildPublicListMeta(input: {
5
+ total: number
6
+ page: number
7
+ limit: number
8
+ returned: number
9
+ seed: string
10
+ }) {
11
+ return {
12
+ total: input.total,
13
+ page: input.page,
14
+ limit: input.limit,
15
+ returned: input.returned,
16
+ seed: input.seed,
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Helper meta per risposta singolo elemento Public API.
22
+ */
23
+ export function buildPublicSingleMeta(seed: string) {
24
+ return { seed }
25
+ }
26
+
@@ -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
- * Adapter Public API: usa la foundation del core e mappa errori nel formato sprint.
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
+
@@ -1,14 +1,14 @@
1
- import { slugify, generateEntrySlug } from '@beechcms/core'
2
-
3
- /**
4
- * Converte stringa in slug URL-safe.
5
- * Logica spostata in @beechcms/core per consistenza tra Dashboard e API.
6
- */
7
- export { slugify }
8
-
9
- /**
10
- * Genera slug da title/name o fallback UUID-like.
11
- * Logica spostata in @beechcms/core per consistenza tra Dashboard e API.
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
+
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { CloudflareRateLimiter } from './cloudflare-rate-limiter'
3
+
4
+ describe('CloudflareRateLimiter', () => {
5
+ it('returns isAllowed: true when the Cloudflare binding grants the request', async () => {
6
+ const mockBinding = { limit: vi.fn().mockResolvedValue({ success: true }) }
7
+ const limiter = new CloudflareRateLimiter(mockBinding as any)
8
+ const result = await limiter.checkLimit('192.168.1.1:login')
9
+ expect(result.isAllowed).toBe(true)
10
+ })
11
+
12
+ it('returns isAllowed: false when the Cloudflare binding blocks the request', async () => {
13
+ const mockBinding = { limit: vi.fn().mockResolvedValue({ success: false }) }
14
+ const limiter = new CloudflareRateLimiter(mockBinding as any)
15
+ const result = await limiter.checkLimit('192.168.1.1:login')
16
+ expect(result.isAllowed).toBe(false)
17
+ })
18
+
19
+ it('forwards the exact key to the binding so per-key accounting is correct', async () => {
20
+ const mockBinding = { limit: vi.fn().mockResolvedValue({ success: true }) }
21
+ const limiter = new CloudflareRateLimiter(mockBinding as any)
22
+ const key = '10.0.0.1:some-seed:publicApiRead'
23
+ await limiter.checkLimit(key)
24
+ expect(mockBinding.limit).toHaveBeenCalledWith({ key })
25
+ })
26
+ })
@@ -0,0 +1,11 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { IRateLimiter, RateLimitResult } from '@beechcms/core'
3
+
4
+ export class CloudflareRateLimiter implements IRateLimiter {
5
+ constructor(private readonly binding: RateLimit) {}
6
+
7
+ async checkLimit(key: string): Promise<RateLimitResult> {
8
+ const { success } = await this.binding.limit({ key })
9
+ return { isAllowed: success }
10
+ }
11
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { InMemoryRateLimiter } from './in-memory-rate-limiter'
3
+
4
+ describe('InMemoryRateLimiter', () => {
5
+ it('allows requests up to the configured maximum hit count', async () => {
6
+ const limiter = new InMemoryRateLimiter(3)
7
+ expect((await limiter.checkLimit('key')).isAllowed).toBe(true)
8
+ expect((await limiter.checkLimit('key')).isAllowed).toBe(true)
9
+ expect((await limiter.checkLimit('key')).isAllowed).toBe(true)
10
+ })
11
+
12
+ it('blocks the very next request after maxAllowedHits is reached', async () => {
13
+ const limiter = new InMemoryRateLimiter(2)
14
+ await limiter.checkLimit('key')
15
+ await limiter.checkLimit('key')
16
+ expect((await limiter.checkLimit('key')).isAllowed).toBe(false)
17
+ })
18
+
19
+ it('tracks hit counts independently for each key', async () => {
20
+ const limiter = new InMemoryRateLimiter(1)
21
+ expect((await limiter.checkLimit('ip-a')).isAllowed).toBe(true)
22
+ expect((await limiter.checkLimit('ip-b')).isAllowed).toBe(true)
23
+ // Both keys exhausted independently
24
+ expect((await limiter.checkLimit('ip-a')).isAllowed).toBe(false)
25
+ expect((await limiter.checkLimit('ip-b')).isAllowed).toBe(false)
26
+ })
27
+
28
+ it('maxAllowedHits = 1 allows exactly one request before blocking', async () => {
29
+ const limiter = new InMemoryRateLimiter(1)
30
+ expect((await limiter.checkLimit('k')).isAllowed).toBe(true)
31
+ expect((await limiter.checkLimit('k')).isAllowed).toBe(false)
32
+ })
33
+ })
@@ -0,0 +1,13 @@
1
+ import type { IRateLimiter, RateLimitResult } from '@beechcms/core'
2
+
3
+ export class InMemoryRateLimiter implements IRateLimiter {
4
+ private readonly hitCounts = new Map<string, number>()
5
+
6
+ constructor(private readonly maxAllowedHits: number) {}
7
+
8
+ async checkLimit(key: string): Promise<RateLimitResult> {
9
+ const currentHits = (this.hitCounts.get(key) ?? 0) + 1
10
+ this.hitCounts.set(key, currentHits)
11
+ return { isAllowed: currentHits <= this.maxAllowedHits }
12
+ }
13
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { NoOpRateLimiter } from './no-op-rate-limiter'
3
+
4
+ describe('NoOpRateLimiter', () => {
5
+ it('always returns isAllowed: true regardless of the key', async () => {
6
+ const limiter = new NoOpRateLimiter()
7
+ for (let i = 0; i < 100; i++) {
8
+ expect((await limiter.checkLimit('any-key')).isAllowed).toBe(true)
9
+ }
10
+ })
11
+
12
+ it('allows all keys without accumulating state', async () => {
13
+ const limiter = new NoOpRateLimiter()
14
+ expect((await limiter.checkLimit('ip-a')).isAllowed).toBe(true)
15
+ expect((await limiter.checkLimit('ip-b')).isAllowed).toBe(true)
16
+ expect((await limiter.checkLimit('ip-a')).isAllowed).toBe(true)
17
+ })
18
+ })
@@ -0,0 +1,7 @@
1
+ import type { IRateLimiter, RateLimitResult } from '@beechcms/core'
2
+
3
+ export class NoOpRateLimiter implements IRateLimiter {
4
+ async checkLimit(_key: string): Promise<RateLimitResult> {
5
+ return { isAllowed: true }
6
+ }
7
+ }
@@ -0,0 +1,207 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { encodeCursor, decodeCursor, buildFtsQuery, mapFtsRow } from './search-utils'
3
+ import type { Seed } from '@beechcms/core'
4
+
5
+ const TEXT_SEED = {
6
+ slug: 'articoli',
7
+ displayNameAlias: 'title',
8
+ branches: [
9
+ { id: 'br_01', alias: 'title', type: 'text', policies: { search: true } },
10
+ ],
11
+ } as unknown as Seed
12
+
13
+ const SECOND_SEED = {
14
+ slug: 'team',
15
+ displayNameAlias: 'name',
16
+ branches: [
17
+ { id: 'br_01', alias: 'name', type: 'text', policies: { search: true } },
18
+ ],
19
+ } as unknown as Seed
20
+
21
+ const NO_FTS_SEED = {
22
+ slug: 'prodotti',
23
+ displayNameAlias: 'nome',
24
+ branches: [
25
+ { id: 'br_01', alias: 'price', type: 'number' },
26
+ ],
27
+ } as unknown as Seed
28
+
29
+ // ─── encodeCursor / decodeCursor ─────────────────────────────────────────────
30
+
31
+ describe('encodeCursor / decodeCursor', () => {
32
+ it('roundtrip preserves rank and entryId', () => {
33
+ const cursor = encodeCursor(-1.23456, 'entry-abc')
34
+ const decoded = decodeCursor(cursor)
35
+ expect(decoded?.rank).toBeCloseTo(-1.23456)
36
+ expect(decoded?.entryId).toBe('entry-abc')
37
+ })
38
+
39
+ it('entryId without colons roundtrips correctly (UUID-style IDs)', () => {
40
+ const entryId = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
41
+ const cursor = encodeCursor(-3.7, entryId)
42
+ const decoded = decodeCursor(cursor)
43
+ expect(decoded?.rank).toBeCloseTo(-3.7)
44
+ expect(decoded?.entryId).toBe(entryId)
45
+ })
46
+
47
+ it('returns null for invalid base64', () => {
48
+ expect(decodeCursor('!!!not-base64!!!')).toBeNull()
49
+ })
50
+
51
+ it('returns null when decoded string has no colon separator', () => {
52
+ expect(decodeCursor(btoa('noseparator'))).toBeNull()
53
+ })
54
+ })
55
+
56
+ // ─── buildFtsQuery ───────────────────────────────────────────────────────────
57
+
58
+ describe('buildFtsQuery', () => {
59
+ it('returns empty-result query when no seed has a searchable FTS branch', () => {
60
+ const result = buildFtsQuery(
61
+ { q: 'hello', schemaSlug: null, status: null, limit: 20, cursor: null },
62
+ [NO_FTS_SEED],
63
+ )
64
+ expect(result.sql).toContain('WHERE 1=0')
65
+ expect(result.binds).toHaveLength(0)
66
+ expect(result.countSql).toContain('SELECT 0 as total')
67
+ })
68
+
69
+ it('throws EMPTY_QUERY when all terms are stripped or too short (single chars)', () => {
70
+ expect(() =>
71
+ buildFtsQuery({ q: 'a b', schemaSlug: null, status: null, limit: 20, cursor: null }, [TEXT_SEED]),
72
+ ).toThrow('EMPTY_QUERY')
73
+ })
74
+
75
+ it('generates a query referencing the seed FTS and content tables', () => {
76
+ const result = buildFtsQuery(
77
+ { q: 'hello', schemaSlug: null, status: null, limit: 20, cursor: null },
78
+ [TEXT_SEED],
79
+ )
80
+ expect(result.sql).toContain('fts_articoli')
81
+ expect(result.sql).toContain('content_articoli')
82
+ expect(result.sql).toContain('LIMIT ?')
83
+ expect(result.binds.at(-1)).toBe(21) // limit + 1 for has-more detection
84
+ })
85
+
86
+ it('UNION ALLs multiple seeds when no schemaSlug filter is set', () => {
87
+ const result = buildFtsQuery(
88
+ { q: 'test', schemaSlug: null, status: null, limit: 10, cursor: null },
89
+ [TEXT_SEED, SECOND_SEED],
90
+ )
91
+ expect(result.sql).toContain('UNION ALL')
92
+ expect(result.sql).toContain('fts_articoli')
93
+ expect(result.sql).toContain('fts_team')
94
+ })
95
+
96
+ it('limits query to the requested schemaSlug when provided', () => {
97
+ const result = buildFtsQuery(
98
+ { q: 'test', schemaSlug: 'articoli', status: null, limit: 20, cursor: null },
99
+ [TEXT_SEED, SECOND_SEED],
100
+ )
101
+ expect(result.sql).toContain('fts_articoli')
102
+ expect(result.sql).not.toContain('fts_team')
103
+ })
104
+
105
+ it('adds status filter to WHERE clause and bind values', () => {
106
+ const result = buildFtsQuery(
107
+ { q: 'hello', schemaSlug: null, status: 'published', limit: 20, cursor: null },
108
+ [TEXT_SEED],
109
+ )
110
+ expect(result.sql).toContain('ce.status = ?')
111
+ expect(result.binds).toContain('published')
112
+ })
113
+
114
+ it('adds cursor-based pagination condition when cursor is valid', () => {
115
+ const cursor = encodeCursor(-1.5, 'entry-123')
116
+ const result = buildFtsQuery(
117
+ { q: 'hello', schemaSlug: null, status: null, limit: 20, cursor },
118
+ [TEXT_SEED],
119
+ )
120
+ expect(result.sql).toContain('bm25')
121
+ expect(result.binds).toContain('entry-123')
122
+ })
123
+
124
+ it('ignores an invalid cursor and produces no pagination condition', () => {
125
+ const result = buildFtsQuery(
126
+ { q: 'hello', schemaSlug: null, status: null, limit: 20, cursor: 'bad-cursor' },
127
+ [TEXT_SEED],
128
+ )
129
+ expect(result.binds).not.toContain('entry-123')
130
+ })
131
+
132
+ it('countSql wraps each seed count in a SUM', () => {
133
+ const result = buildFtsQuery(
134
+ { q: 'test', schemaSlug: null, status: null, limit: 10, cursor: null },
135
+ [TEXT_SEED, SECOND_SEED],
136
+ )
137
+ expect(result.countSql).toContain('SUM')
138
+ expect(result.countSql).toContain('fts_articoli')
139
+ expect(result.countSql).toContain('fts_team')
140
+ })
141
+
142
+ it('count binds do not include the limit+1 sentinel', () => {
143
+ const result = buildFtsQuery(
144
+ { q: 'hello', schemaSlug: null, status: null, limit: 5, cursor: null },
145
+ [TEXT_SEED],
146
+ )
147
+ expect(result.countBinds).not.toContain(6) // limit + 1 must not appear in count binds
148
+ })
149
+
150
+ it('single-character terms (length < 2) are filtered out', () => {
151
+ // 'a b c' — single-char terms discarded; result depends on remaining terms
152
+ expect(() =>
153
+ buildFtsQuery({ q: 'a b c', schemaSlug: null, status: null, limit: 20, cursor: null }, [TEXT_SEED]),
154
+ ).toThrow('EMPTY_QUERY')
155
+ })
156
+
157
+ it('numeric terms are quoted without prefix expansion', () => {
158
+ const result = buildFtsQuery(
159
+ { q: '2024', schemaSlug: null, status: null, limit: 20, cursor: null },
160
+ [TEXT_SEED],
161
+ )
162
+ expect(result.binds[0]).toContain('"2024"')
163
+ })
164
+ })
165
+
166
+ // ─── mapFtsRow ───────────────────────────────────────────────────────────────
167
+
168
+ describe('mapFtsRow', () => {
169
+ it('maps all FtsRow fields to SearchResultItem', () => {
170
+ const row = {
171
+ entry_id: 'e1', schema_slug: 'articoli', slug: 'my-post',
172
+ status: 'published', title: 'My Post', excerpt: 'A snippet', rank: -1,
173
+ }
174
+ const result = mapFtsRow(row)
175
+ expect(result.id).toBe('e1')
176
+ expect(result.schema_slug).toBe('articoli')
177
+ expect(result.slug).toBe('my-post')
178
+ expect(result.status).toBe('published')
179
+ expect(result.title).toBe('My Post')
180
+ expect(result.excerpt).toBe('A snippet')
181
+ expect(result.data).toEqual({})
182
+ })
183
+
184
+ it('strips HTML tags from excerpt but preserves <mark> and </mark>', () => {
185
+ const row = {
186
+ entry_id: 'e1', schema_slug: 'a', slug: null, status: 'draft',
187
+ title: null, excerpt: '<p>A <mark>word</mark> here</p>', rank: 0,
188
+ }
189
+ expect(mapFtsRow(row).excerpt).toBe('A <mark>word</mark> here')
190
+ })
191
+
192
+ it('collapses multiple whitespace characters in excerpt', () => {
193
+ const row = {
194
+ entry_id: 'e1', schema_slug: 'a', slug: null, status: 'draft',
195
+ title: null, excerpt: '<p> lots of space </p>', rank: 0,
196
+ }
197
+ expect(mapFtsRow(row).excerpt).toBe('lots of space')
198
+ })
199
+
200
+ it('returns empty string for null title', () => {
201
+ const row = {
202
+ entry_id: 'e1', schema_slug: 'a', slug: null, status: 'draft',
203
+ title: null, excerpt: '', rank: 0,
204
+ }
205
+ expect(mapFtsRow(row).title).toBe('')
206
+ })
207
+ })