@beechcms/api 0.4.3 → 0.6.0-preview.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 (207) hide show
  1. package/assets/dashboard/Searching.svg +1 -0
  2. package/assets/dashboard/assets/index-BSjk0Sx9.js +634 -0
  3. package/assets/dashboard/assets/index-kRCYVN2B.css +1 -0
  4. package/assets/dashboard/index.html +2 -2
  5. package/assets/dashboard/noResult.svg +43 -0
  6. package/assets/dashboard/working.svg +1 -0
  7. package/migrations/0000_v040_base.sql +27 -0
  8. package/migrations/0030_test_seeds.sql +125 -0
  9. package/package.json +14 -9
  10. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +4 -0
  11. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +4 -0
  12. package/src/auth/bcrypt-hash-provider.ts +6 -2
  13. package/src/auth/constants.ts +4 -0
  14. package/src/auth/generate-refresh-token.test.ts +8 -4
  15. package/src/auth/hash-provider.test.ts +14 -5
  16. package/src/auth/jose-token-service.ts +7 -0
  17. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  18. package/src/auth/login.test.ts +9 -1
  19. package/src/auth/login.ts +5 -1
  20. package/src/auth/refresh.ts +7 -1
  21. package/src/auth/token-service.test.ts +9 -1
  22. package/src/factory.ts +37 -16
  23. package/src/features/automations/__tests__/action-executors.test.ts +59 -72
  24. package/src/features/automations/__tests__/automation-runner.test.ts +4 -0
  25. package/src/features/automations/__tests__/automation-runner.utils.test.ts +4 -0
  26. package/src/features/automations/__tests__/automations.handler.test.ts +7 -3
  27. package/src/features/automations/__tests__/automations.repository.test.ts +4 -0
  28. package/src/features/automations/__tests__/automations.schema.test.ts +92 -2
  29. package/src/features/automations/__tests__/context-resolver.test.ts +4 -0
  30. package/src/features/automations/__tests__/cron-runner.test.ts +11 -7
  31. package/src/features/automations/__tests__/cron-runner.utils.test.ts +4 -0
  32. package/src/features/automations/__tests__/set-variable.executor.test.ts +77 -0
  33. package/src/features/automations/__tests__/template-grammar.test.ts +4 -0
  34. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  35. package/src/features/automations/__tests__/when-evaluator.test.ts +4 -0
  36. package/src/features/automations/__tests__/when-pushdown.test.ts +5 -1
  37. package/src/features/automations/action-executors/create-entry.executor.ts +4 -0
  38. package/src/features/automations/action-executors/edit-field.executor.ts +4 -0
  39. package/src/features/automations/action-executors/index.ts +5 -1
  40. package/src/features/automations/action-executors/send-mail.executor.ts +13 -2
  41. package/src/features/automations/action-executors/set-variable.executor.ts +15 -2
  42. package/src/features/automations/action-executors/webhook.executor.ts +55 -13
  43. package/src/features/automations/automation-runner.ts +4 -0
  44. package/src/features/automations/automation-runner.utils.ts +4 -0
  45. package/src/features/automations/automations.handler.ts +4 -0
  46. package/src/features/automations/automations.schema.ts +19 -2
  47. package/src/features/automations/context-resolver.ts +4 -0
  48. package/src/features/automations/cron-runner.ts +4 -0
  49. package/src/features/automations/cron-runner.utils.ts +4 -0
  50. package/src/features/automations/filter-translation.ts +4 -0
  51. package/src/features/automations/index.ts +4 -0
  52. package/src/features/automations/template-grammar.ts +4 -0
  53. package/src/features/automations/var-access-resolver.ts +4 -0
  54. package/src/features/automations/when-evaluator.ts +4 -0
  55. package/src/features/automations/when-pushdown.ts +4 -0
  56. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  57. package/src/features/backrefs/backrefs.handler.ts +168 -0
  58. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  59. package/src/features/backrefs/index.ts +5 -0
  60. package/src/features/content/constants.ts +6 -0
  61. package/src/features/content/handlers/bulk.handler.ts +185 -0
  62. package/src/features/content/handlers/create.ts +19 -55
  63. package/src/features/content/handlers/delete.ts +12 -37
  64. package/src/features/content/handlers/facets.ts +4 -0
  65. package/src/features/content/handlers/get.ts +4 -0
  66. package/src/features/content/handlers/helpers.test.ts +180 -0
  67. package/src/features/content/handlers/helpers.ts +93 -0
  68. package/src/features/content/handlers/list.ts +105 -19
  69. package/src/features/content/handlers/update.ts +19 -64
  70. package/src/features/content/index.ts +6 -0
  71. package/src/features/draft/draft.handler.ts +33 -8
  72. package/src/features/draft/draft.middleware.ts +4 -0
  73. package/src/features/draft/index.ts +4 -0
  74. package/src/features/email/email.provider.ts +4 -0
  75. package/src/features/email/email.service.ts +50 -45
  76. package/src/features/email/email.types.ts +12 -1
  77. package/src/features/email/index.ts +4 -0
  78. package/src/features/email/providers/resend.ts +4 -0
  79. package/src/features/email/providers/smtp.ts +55 -0
  80. package/src/features/email/templates/automation-mail.ts +4 -0
  81. package/src/features/email/templates/password-changed.ts +4 -0
  82. package/src/features/email/templates/password-reset.ts +4 -0
  83. package/src/features/email/templates/shell.ts +4 -0
  84. package/src/features/notifications/index.ts +4 -0
  85. package/src/features/notifications/notifications.handler.ts +4 -0
  86. package/src/features/password-reset/index.ts +5 -1
  87. package/src/features/password-reset/request.ts +12 -2
  88. package/src/features/password-reset/reset.ts +12 -2
  89. package/src/features/rotate-field/index.ts +4 -0
  90. package/src/features/rotate-field/rotate-field.handler.ts +4 -0
  91. package/src/features/rotate-field/rotate-field.schema.ts +4 -0
  92. package/src/features/schema/schema.handler.ts +118 -3
  93. package/src/features/seeds/index.ts +5 -0
  94. package/src/features/seeds/seeds.handler.test.ts +632 -0
  95. package/src/features/seeds/seeds.handler.ts +693 -0
  96. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  97. package/src/features/settings/settings.handler.ts +98 -8
  98. package/src/features/setup/index.ts +124 -11
  99. package/src/features/stats/index.ts +4 -0
  100. package/src/features/stats/stats.handler.ts +11 -21
  101. package/src/features/webhooks/index.ts +63 -0
  102. package/src/index.ts +28 -19
  103. package/src/media-utils.ts +4 -0
  104. package/src/middleware/auth-providers.middleware.ts +13 -0
  105. package/src/middleware/observability.middleware.ts +21 -3
  106. package/src/middleware/rate-limit.middleware.ts +4 -0
  107. package/src/middleware/repository.middleware.ts +25 -2
  108. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  109. package/src/middleware/seed-registry.middleware.ts +21 -0
  110. package/src/middleware/storage.middleware.ts +4 -0
  111. package/src/middleware.ts +5 -7
  112. package/src/public/access-policy.ts +4 -0
  113. package/src/public/api-key-middleware.ts +4 -0
  114. package/src/public/cache-utils.ts +38 -34
  115. package/src/public/entry-projection.ts +46 -42
  116. package/src/public/idempotency.ts +23 -19
  117. package/src/public/index.ts +4 -0
  118. package/src/public/problem-details.ts +71 -0
  119. package/src/public/public-add.ts +4 -0
  120. package/src/public/public-edit.ts +4 -0
  121. package/src/public/public-errors.ts +4 -0
  122. package/src/public/public-read.ts +4 -0
  123. package/src/public/public-routes.ts +4 -0
  124. package/src/public/query-builder.test.ts +4 -0
  125. package/src/public/query-builder.ts +4 -0
  126. package/src/public/rate-limit-middleware.ts +4 -0
  127. package/src/public/read-list.ts +54 -50
  128. package/src/public/read-single.ts +48 -44
  129. package/src/public/response-builder.ts +4 -0
  130. package/src/public/sanitize.ts +4 -0
  131. package/src/public/slug-utils.ts +4 -0
  132. package/src/rate-limit/cloudflare-rate-limiter.test.ts +4 -0
  133. package/src/rate-limit/cloudflare-rate-limiter.ts +4 -0
  134. package/src/rate-limit/in-memory-rate-limiter.test.ts +4 -0
  135. package/src/rate-limit/in-memory-rate-limiter.ts +4 -0
  136. package/src/rate-limit/no-op-rate-limiter.test.ts +4 -0
  137. package/src/rate-limit/no-op-rate-limiter.ts +4 -0
  138. package/src/search-utils.test.ts +4 -0
  139. package/src/search-utils.ts +5 -1
  140. package/src/search.ts +5 -1
  141. package/src/shared/apply-policies.test.ts +4 -0
  142. package/src/shared/apply-policies.ts +4 -0
  143. package/src/shared/automations.repository.d1.ts +4 -0
  144. package/src/shared/background-notification-service.test.ts +4 -0
  145. package/src/shared/background-notification-service.ts +4 -0
  146. package/src/shared/base.repository.d1.ts +4 -0
  147. package/src/shared/content-utils.test.ts +4 -0
  148. package/src/shared/content-utils.ts +4 -0
  149. package/src/shared/content.repository.d1.test.ts +147 -1
  150. package/src/shared/content.repository.d1.ts +535 -66
  151. package/src/shared/d1-activity-log.repository.test.ts +4 -0
  152. package/src/shared/d1-activity-log.repository.ts +4 -0
  153. package/src/shared/d1-activity-logger.test.ts +4 -0
  154. package/src/shared/d1-activity-logger.ts +4 -0
  155. package/src/shared/d1-analytics.repository.test.ts +4 -0
  156. package/src/shared/d1-analytics.repository.ts +4 -0
  157. package/src/shared/d1-content-scan.repository.test.ts +9 -5
  158. package/src/shared/d1-content-scan.repository.ts +15 -7
  159. package/src/shared/d1-notification.repository.test.ts +4 -0
  160. package/src/shared/d1-notification.repository.ts +4 -0
  161. package/src/shared/d1-password-reset-token.repository.test.ts +4 -0
  162. package/src/shared/d1-password-reset-token.repository.ts +4 -0
  163. package/src/shared/d1-search.repository.test.ts +4 -0
  164. package/src/shared/d1-search.repository.ts +4 -0
  165. package/src/shared/d1-session.repository.test.ts +4 -0
  166. package/src/shared/d1-session.repository.ts +4 -0
  167. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  168. package/src/shared/d1-user.repository.test.ts +8 -4
  169. package/src/shared/d1-user.repository.ts +15 -5
  170. package/src/shared/d1-widget.repository.test.ts +4 -0
  171. package/src/shared/d1-widget.repository.ts +4 -0
  172. package/src/shared/demo-data-sql.ts +54 -0
  173. package/src/shared/demo-data.repository.d1.ts +20 -0
  174. package/src/shared/execution-context-scheduler.ts +4 -0
  175. package/src/shared/fixed-clock.ts +4 -0
  176. package/src/shared/fts-sync.ts +4 -0
  177. package/src/shared/idempotency.repository.d1.test.ts +4 -0
  178. package/src/shared/idempotency.repository.d1.ts +4 -0
  179. package/src/shared/in-memory-activity-logger.ts +4 -0
  180. package/src/shared/in-memory-notification-service.ts +4 -0
  181. package/src/shared/in-memory-seed.repository.ts +51 -0
  182. package/src/shared/media.repository.d1.test.ts +4 -0
  183. package/src/shared/media.repository.d1.ts +4 -0
  184. package/src/shared/qstash-notification-service.test.ts +67 -0
  185. package/src/shared/qstash-notification-service.ts +55 -0
  186. package/src/shared/query-utils.ts +4 -0
  187. package/src/shared/request-utils.ts +4 -0
  188. package/src/shared/schema-mutator.d1.ts +64 -0
  189. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  190. package/src/shared/seed-layout.repository.d1.ts +62 -0
  191. package/src/shared/seed-registry-cache.test.ts +68 -0
  192. package/src/shared/seed-registry-cache.ts +49 -0
  193. package/src/shared/seed.repository.d1.test.ts +143 -0
  194. package/src/shared/seed.repository.d1.ts +98 -0
  195. package/src/shared/sequential-id-generator.ts +11 -0
  196. package/src/shared/site-settings.repository.d1.ts +53 -0
  197. package/src/shared/storage/factory.ts +16 -15
  198. package/src/shared/storage/s3-bucket.ts +34 -2
  199. package/src/shared/storage-utils.ts +4 -0
  200. package/src/shared/system-stats.repository.d1.test.ts +4 -0
  201. package/src/shared/system-stats.repository.d1.ts +5 -1
  202. package/src/types.ts +23 -3
  203. package/src/upload.ts +134 -123
  204. package/src/widget.ts +6 -2
  205. package/assets/dashboard/assets/index-BKWnlvnV.css +0 -1
  206. package/assets/dashboard/assets/index-BMkd1Irh.js +0 -629
  207. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -1,50 +1,54 @@
1
- import type { Seed, ContentRepository } from '@beechcms/core'
2
- import { cleanStr } from '../shared/query-utils'
3
- import { toFlatPublicEntry } from './entry-projection'
4
- import { buildPublicListMeta } from './response-builder'
5
- import { parsePublicFilter, parsePublicPagination, parseLatestCount, toEngineFilters } from './query-builder'
6
-
7
- type ReadListInput = {
8
- seed: Seed
9
- seedSlug: string
10
- repository: ContentRepository
11
- query: Record<string, string | undefined>
12
- publishedOnly: boolean
13
- }
14
-
15
- export async function readListEntries(input: ReadListInput) {
16
- const { seed, seedSlug, repository, query, publishedOnly } = input
17
-
18
- const parsedFilter = parsePublicFilter(query.filter)
19
- const allMode = cleanStr(query.all)?.toLowerCase() === 'true'
20
- const latestMode = cleanStr(query.latest) !== null
21
- const latestCount = latestMode ? parseLatestCount(query.latest ?? '') : null
22
- const pagination = allMode ? { page: 1, limit: 100 } : parsePublicPagination(query)
23
- const offset = (pagination.page - 1) * pagination.limit
24
- const search = cleanStr(query.search) ?? ''
25
- const engineFilters = toEngineFilters(seed, parsedFilter)
26
- const sortBy = cleanStr(query.orderBy) ?? 'created_at'
27
- const sortDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'
28
-
29
- const { items, total } = await repository.findMany(seed, {
30
- filters: engineFilters,
31
- search: search || undefined,
32
- status: publishedOnly ? 'published' : null,
33
- pagination: {
34
- limit: latestMode ? (latestCount ?? 10) : pagination.limit,
35
- offset: latestMode ? 0 : offset,
36
- },
37
- orderBy: latestMode ? { column: 'created_at', dir: 'DESC' } : { column: sortBy, dir: sortDir },
38
- })
39
-
40
- const data = items.map(item => toFlatPublicEntry(item, seed, query.fields))
41
-
42
- if (latestMode) {
43
- return { data, meta: { total, returned: data.length, seed: seedSlug } }
44
- }
45
-
46
- return {
47
- data,
48
- meta: buildPublicListMeta({ total, page: pagination.page, limit: pagination.limit, returned: data.length, seed: seedSlug }),
49
- }
50
- }
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
+ import type { Seed, ContentRepository } from '@beechcms/core'
6
+ import { cleanStr } from '../shared/query-utils'
7
+ import { toFlatPublicEntry } from './entry-projection'
8
+ import { buildPublicListMeta } from './response-builder'
9
+ import { parsePublicFilter, parsePublicPagination, parseLatestCount, toEngineFilters } from './query-builder'
10
+
11
+ type ReadListInput = {
12
+ seed: Seed
13
+ seedSlug: string
14
+ repository: ContentRepository
15
+ query: Record<string, string | undefined>
16
+ publishedOnly: boolean
17
+ }
18
+
19
+ export async function readListEntries(input: ReadListInput) {
20
+ const { seed, seedSlug, repository, query, publishedOnly } = input
21
+
22
+ const parsedFilter = parsePublicFilter(query.filter)
23
+ const allMode = cleanStr(query.all)?.toLowerCase() === 'true'
24
+ const latestMode = cleanStr(query.latest) !== null
25
+ const latestCount = latestMode ? parseLatestCount(query.latest ?? '') : null
26
+ const pagination = allMode ? { page: 1, limit: 100 } : parsePublicPagination(query)
27
+ const offset = (pagination.page - 1) * pagination.limit
28
+ const search = cleanStr(query.search) ?? ''
29
+ const engineFilters = toEngineFilters(seed, parsedFilter)
30
+ const sortBy = cleanStr(query.orderBy) ?? 'created_at'
31
+ const sortDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'
32
+
33
+ const { items, total } = await repository.findMany(seed, {
34
+ filters: engineFilters,
35
+ search: search || undefined,
36
+ status: publishedOnly ? 'published' : null,
37
+ pagination: {
38
+ limit: latestMode ? (latestCount ?? 10) : pagination.limit,
39
+ offset: latestMode ? 0 : offset,
40
+ },
41
+ orderBy: latestMode ? { column: 'created_at', dir: 'DESC' } : { column: sortBy, dir: sortDir },
42
+ })
43
+
44
+ const data = items.map(item => toFlatPublicEntry(item, seed, query.fields))
45
+
46
+ if (latestMode) {
47
+ return { data, meta: { total, returned: data.length, seed: seedSlug } }
48
+ }
49
+
50
+ return {
51
+ data,
52
+ meta: buildPublicListMeta({ total, page: pagination.page, limit: pagination.limit, returned: data.length, seed: seedSlug }),
53
+ }
54
+ }
@@ -1,44 +1,48 @@
1
- import { EntryNotFoundError } from '@beechcms/core'
2
- import type { Seed, ContentRepository } from '@beechcms/core'
3
- import { toFlatPublicEntry } from './entry-projection'
4
- import { buildPublicSingleMeta } from './response-builder'
5
-
6
- type ReadSingleInput = {
7
- seed: Seed
8
- seedSlug: string
9
- repository: ContentRepository
10
- id: string | null
11
- slug: string | null
12
- publishedOnly: boolean
13
- fieldsParam?: string
14
- }
15
-
16
- export type ReadSingleResult =
17
- | { ok: true; data: Record<string, unknown>; meta: { seed: string } }
18
- | { ok: false; detail: string }
19
-
20
- export async function readSingleEntry(input: ReadSingleInput): Promise<ReadSingleResult> {
21
- const { seed, seedSlug, repository, id, slug, publishedOnly, fieldsParam } = input
22
- const label = id ?? slug!
23
-
24
- try {
25
- const entry = id
26
- ? await repository.findById(seed, id)
27
- : await repository.findBySlug(seed, slug!)
28
-
29
- if (publishedOnly && entry.status !== 'published') {
30
- return { ok: false, detail: `Entry '${label}' not found or not published.` }
31
- }
32
-
33
- return {
34
- ok: true,
35
- data: toFlatPublicEntry(entry, seed, fieldsParam),
36
- meta: buildPublicSingleMeta(seedSlug),
37
- }
38
- } catch (error) {
39
- if (error instanceof EntryNotFoundError) {
40
- return { ok: false, detail: `Entry '${label}' not found for content type '${seedSlug}'.` }
41
- }
42
- throw error
43
- }
44
- }
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
+ import { EntryNotFoundError } from '@beechcms/core'
6
+ import type { Seed, ContentRepository } from '@beechcms/core'
7
+ import { toFlatPublicEntry } from './entry-projection'
8
+ import { buildPublicSingleMeta } from './response-builder'
9
+
10
+ type ReadSingleInput = {
11
+ seed: Seed
12
+ seedSlug: string
13
+ repository: ContentRepository
14
+ id: string | null
15
+ slug: string | null
16
+ publishedOnly: boolean
17
+ fieldsParam?: string
18
+ }
19
+
20
+ export type ReadSingleResult =
21
+ | { ok: true; data: Record<string, unknown>; meta: { seed: string } }
22
+ | { ok: false; detail: string }
23
+
24
+ export async function readSingleEntry(input: ReadSingleInput): Promise<ReadSingleResult> {
25
+ const { seed, seedSlug, repository, id, slug, publishedOnly, fieldsParam } = input
26
+ const label = id ?? slug!
27
+
28
+ try {
29
+ const entry = id
30
+ ? await repository.findById(seed, id)
31
+ : await repository.findBySlug(seed, slug!)
32
+
33
+ if (publishedOnly && entry.status !== 'published') {
34
+ return { ok: false, detail: `Entry '${label}' not found or not published.` }
35
+ }
36
+
37
+ return {
38
+ ok: true,
39
+ data: toFlatPublicEntry(entry, seed, fieldsParam),
40
+ meta: buildPublicSingleMeta(seedSlug),
41
+ }
42
+ } catch (error) {
43
+ if (error instanceof EntryNotFoundError) {
44
+ return { ok: false, detail: `Entry '${label}' not found for content type '${seedSlug}'.` }
45
+ }
46
+ throw error
47
+ }
48
+ }
@@ -1,3 +1,7 @@
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
+
1
5
  /**
2
6
  * Helper meta per risposta lista Public API.
3
7
  */
@@ -1,3 +1,7 @@
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
+
1
5
  import { validateAndSanitizeSeedPayload } from '@beechcms/core'
2
6
  import type { Seed, ValidationDetail } from '@beechcms/core'
3
7
 
@@ -1,3 +1,7 @@
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
+
1
5
  import { slugify, generateEntrySlug } from '@beechcms/core'
2
6
 
3
7
  /**
@@ -1,3 +1,7 @@
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
+
1
5
  import { describe, it, expect, vi } from 'vitest'
2
6
  import { CloudflareRateLimiter } from './cloudflare-rate-limiter'
3
7
 
@@ -1,3 +1,7 @@
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
+
1
5
  /// <reference types="@cloudflare/workers-types" />
2
6
  import type { IRateLimiter, RateLimitResult } from '@beechcms/core'
3
7
 
@@ -1,3 +1,7 @@
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
+
1
5
  import { describe, it, expect } from 'vitest'
2
6
  import { InMemoryRateLimiter } from './in-memory-rate-limiter'
3
7
 
@@ -1,3 +1,7 @@
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
+
1
5
  import type { IRateLimiter, RateLimitResult } from '@beechcms/core'
2
6
 
3
7
  export class InMemoryRateLimiter implements IRateLimiter {
@@ -1,3 +1,7 @@
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
+
1
5
  import { describe, it, expect } from 'vitest'
2
6
  import { NoOpRateLimiter } from './no-op-rate-limiter'
3
7
 
@@ -1,3 +1,7 @@
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
+
1
5
  import type { IRateLimiter, RateLimitResult } from '@beechcms/core'
2
6
 
3
7
  export class NoOpRateLimiter implements IRateLimiter {
@@ -1,3 +1,7 @@
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
+
1
5
  import { describe, it, expect } from 'vitest'
2
6
  import { encodeCursor, decodeCursor, buildFtsQuery, mapFtsRow } from './search-utils'
3
7
  import type { Seed } from '@beechcms/core'
@@ -1,3 +1,7 @@
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
+
1
5
  // apps/api/src/search-utils.ts
2
6
  // Pure functions — zero Hono dependencies, importable from Vitest.
3
7
  // v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
@@ -53,7 +57,7 @@ export function decodeCursor(cursor: string): { rank: number; entryId: string }
53
57
  const sep = decoded.lastIndexOf(":")
54
58
  if (sep === -1) return null
55
59
  return {
56
- rank: parseFloat(decoded.slice(0, sep)),
60
+ rank: Number.parseFloat(decoded.slice(0, sep)),
57
61
  entryId: decoded.slice(sep + 1),
58
62
  }
59
63
  } catch {
package/src/search.ts CHANGED
@@ -1,3 +1,7 @@
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
+
1
5
  // apps/api/src/search.ts
2
6
 
3
7
  import { Hono } from "hono"
@@ -18,7 +22,7 @@ searchRouter.get("/", async (c) => {
18
22
  const queryText = c.req.query("q")?.trim() ?? ""
19
23
  const schemaSlug = c.req.query("schema_slug") ?? null
20
24
  const status = c.req.query("status") ?? null
21
- const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
25
+ const rawLimit = Number.parseInt(c.req.query("limit") ?? "20", 10)
22
26
  const limit = Math.min(Math.max(rawLimit, 1), 50)
23
27
  const cursor = c.req.query("cursor") ?? null
24
28
 
@@ -1,3 +1,7 @@
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
+
1
5
  import { describe, it, expect } from 'vitest'
2
6
  import { applyPrivacy, applyVisibility, PrivacyPolicyError } from './apply-policies'
3
7
  import type { Seed } from '@beechcms/core'
@@ -1,3 +1,7 @@
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
+
1
5
  /// <reference types="@cloudflare/workers-types" />
2
6
  import { resolvePolicies, sha256hex } from '@beechcms/core'
3
7
  import type { Seed } from '@beechcms/core'
@@ -1,3 +1,7 @@
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
+
1
5
  import type {
2
6
  IAutomationRepository,
3
7
  Automation,
@@ -1,3 +1,7 @@
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
+
1
5
  import { describe, it, expect, vi } from 'vitest'
2
6
  import { BackgroundNotificationService } from './background-notification-service'
3
7
  import type { INotificationRepository, NotificationRecord } from '@beechcms/core'
@@ -1,3 +1,7 @@
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
+
1
5
  import type {
2
6
  INotificationService,
3
7
  CreateNotificationInput,
@@ -1,3 +1,7 @@
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
+
1
5
  /// <reference types="@cloudflare/workers-types" />
2
6
  import { RepositoryError } from '@beechcms/core'
3
7
 
@@ -1,3 +1,7 @@
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
+
1
5
  import { describe, it, expect, vi } from 'vitest'
2
6
  import { rowToApiData, rowToEntry, buildInsertBindings, buildUpdateBindings, hasDraft } from './content-utils'
3
7
  import type { Seed } from '@beechcms/core'
@@ -1,3 +1,7 @@
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
+
1
5
  /// <reference types="@cloudflare/workers-types" />
2
6
  import { deserializeFromDb, serializeForDb } from '@beechcms/core'
3
7
  import type { Seed } from '@beechcms/core'
@@ -1,6 +1,10 @@
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
+
1
5
  import { describe, it, expect, vi } from 'vitest'
2
6
  import { D1ContentRepository } from './content.repository.d1'
3
- import { EntryNotFoundError, SlugConflictError } from '@beechcms/core'
7
+ import { EntryNotFoundError, SlugConflictError, RelationTargetNotFoundError } from '@beechcms/core'
4
8
  import type { Seed } from '@beechcms/core'
5
9
 
6
10
  const SEED = {
@@ -20,6 +24,17 @@ const NO_DRAFT_SEED = {
20
24
  branches: [{ id: 'br_01', alias: 'name', type: 'text' }],
21
25
  } as unknown as Seed
22
26
 
27
+ // Seed with a multi-relation branch
28
+ const M2M_SEED = {
29
+ slug: 'articles',
30
+ displayNameAlias: 'title',
31
+ allowDrafts: true,
32
+ branches: [
33
+ { id: 'br_01', alias: 'title', type: 'text' },
34
+ { id: 'br_02', alias: 'tags', type: 'relation', multiple: true, targetSeed: 'tag' },
35
+ ],
36
+ } as unknown as Seed
37
+
23
38
  function makeMockDb(opts: {
24
39
  firstResult?: unknown
25
40
  allResults?: unknown[]
@@ -309,4 +324,135 @@ describe('D1ContentRepository', () => {
309
324
  expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('content_posts_drafts'))
310
325
  })
311
326
  })
327
+
328
+ // ─── many-to-many (Sprint 5) ──────────────────────────────────────────────────
329
+
330
+ describe('many-to-many relations', () => {
331
+ it('create: multi-relation values go to junction table, not main INSERT', async () => {
332
+ const { db, prepareMock, batchMock } = makeMockDb({ firstResult: null })
333
+ batchMock.mockResolvedValue([{ success: true, meta: { changes: 1 } }])
334
+
335
+ await new D1ContentRepository(db).create(
336
+ M2M_SEED, 'e1', 'art-1', 'draft',
337
+ { title: 'Hello', tags: ['tag-1', 'tag-2'] },
338
+ )
339
+
340
+ const sqls = prepareMock.mock.calls.map((c: any[]) => c[0] as string)
341
+ // Main INSERT must not contain 'tags'
342
+ const insertSql = sqls.find(s => s.includes('INSERT INTO content_articles '))
343
+ expect(insertSql).toBeDefined()
344
+ expect(insertSql).not.toContain('tags')
345
+ // Junction INSERTs must exist
346
+ const junctionInserts = sqls.filter(s => s.includes('rel_articles_tags'))
347
+ expect(junctionInserts.length).toBeGreaterThan(0)
348
+ })
349
+
350
+ it('create: uses batch when multi-relation values present', async () => {
351
+ const { db, batchMock } = makeMockDb({ firstResult: null })
352
+ batchMock.mockResolvedValue([{ success: true }])
353
+
354
+ await new D1ContentRepository(db).create(
355
+ M2M_SEED, 'e1', 'art-1', 'draft',
356
+ { title: 'Hello', tags: ['tag-1'] },
357
+ )
358
+
359
+ expect(batchMock).toHaveBeenCalled()
360
+ })
361
+
362
+ it('update: replaces junction rows (DELETE + INSERT) in batch', async () => {
363
+ const { db, prepareMock, batchMock } = makeMockDb({ runChanges: 1 })
364
+ batchMock.mockResolvedValue([
365
+ { meta: { changes: 1 } },
366
+ { success: true },
367
+ { success: true },
368
+ ])
369
+
370
+ await new D1ContentRepository(db).update(
371
+ M2M_SEED, 'e1',
372
+ { tags: ['tag-3', 'tag-4'] },
373
+ )
374
+
375
+ const sqls = prepareMock.mock.calls.map((c: any[]) => c[0] as string)
376
+ expect(sqls.some(s => s.includes('DELETE FROM rel_articles_tags WHERE parent_id'))).toBe(true)
377
+ expect(sqls.some(s => s.includes('INSERT INTO rel_articles_tags'))).toBe(true)
378
+ })
379
+
380
+ it('update: empty array deletes all junction rows', async () => {
381
+ const { db, prepareMock, batchMock } = makeMockDb({ runChanges: 1 })
382
+ batchMock.mockResolvedValue([{ meta: { changes: 1 } }, { success: true }])
383
+
384
+ await new D1ContentRepository(db).update(M2M_SEED, 'e1', { tags: [] })
385
+
386
+ const sqls = prepareMock.mock.calls.map((c: any[]) => c[0] as string)
387
+ expect(sqls.some(s => s.includes('DELETE FROM rel_articles_tags WHERE parent_id'))).toBe(true)
388
+ // No INSERT because array is empty
389
+ expect(sqls.filter(s => s.includes('INSERT INTO rel_articles_tags'))).toHaveLength(0)
390
+ })
391
+
392
+ it('findById: attaches multi-relation ids from junction table', async () => {
393
+ const row = { id: 'e1', slug: 'art-1', status: 'published', title: 'Hello', created_at: 0, updated_at: 0 }
394
+ const { db, firstMock, batchMock } = makeMockDb({ firstResult: row })
395
+ // First call: main SELECT; batch: junction query
396
+ batchMock.mockResolvedValueOnce([{ results: [{ target_id: 'tag-1' }, { target_id: 'tag-2' }] }])
397
+
398
+ const result = await new D1ContentRepository(db).findById(M2M_SEED, 'e1')
399
+ expect(result.tags).toEqual(['tag-1', 'tag-2'])
400
+ })
401
+
402
+ it('findById: attaches empty array when no junction rows', async () => {
403
+ const row = { id: 'e1', slug: 'art-1', status: 'published', title: 'Hello', created_at: 0, updated_at: 0 }
404
+ const { db, batchMock } = makeMockDb({ firstResult: row })
405
+ batchMock.mockResolvedValueOnce([{ results: [] }])
406
+
407
+ const result = await new D1ContentRepository(db).findById(M2M_SEED, 'e1')
408
+ expect(result.tags).toEqual([])
409
+ })
410
+
411
+ it('findMany: attaches multi-relation ids per entry', async () => {
412
+ const rows = [
413
+ { id: 'e1', slug: 'art-1', status: 'published', title: 'A', created_at: 0, updated_at: 0 },
414
+ { id: 'e2', slug: 'art-2', status: 'published', title: 'B', created_at: 0, updated_at: 0 },
415
+ ]
416
+ const { db, batchMock } = makeMockDb()
417
+ // First batch: main select + count; second batch: junction query
418
+ batchMock
419
+ .mockResolvedValueOnce([{ results: rows }, { results: [{ total: 2 }] }])
420
+ .mockResolvedValueOnce([{
421
+ results: [
422
+ { parent_id: 'e1', target_id: 'tag-1' },
423
+ { parent_id: 'e1', target_id: 'tag-2' },
424
+ { parent_id: 'e2', target_id: 'tag-3' },
425
+ ],
426
+ }])
427
+
428
+ const result = await new D1ContentRepository(db).findMany(M2M_SEED, {})
429
+ expect(result.items[0].tags).toEqual(['tag-1', 'tag-2'])
430
+ expect(result.items[1].tags).toEqual(['tag-3'])
431
+ })
432
+
433
+ it('saveDraft: multi-relation values go to junction draft table', async () => {
434
+ const { db, prepareMock, batchMock } = makeMockDb()
435
+ batchMock.mockResolvedValue([{ success: true }])
436
+
437
+ await new D1ContentRepository(db).saveDraft(M2M_SEED, 'e1', { tags: ['tag-1', 'tag-2'] })
438
+
439
+ const sqls = prepareMock.mock.calls.map((c: any[]) => c[0] as string)
440
+ expect(sqls.some(s => s.includes('rel_articles_tags_drafts'))).toBe(true)
441
+ expect(batchMock).toHaveBeenCalled()
442
+ })
443
+
444
+ it('publishDraft: throws RelationTargetNotFoundError when draft multi-rel target missing', async () => {
445
+ const draftRow = { entry_id: 'e1', title: 'Hello' }
446
+ const { db, firstMock, allMock, batchMock } = makeMockDb()
447
+ firstMock
448
+ .mockResolvedValueOnce(draftRow) // getDraft row
449
+ .mockResolvedValueOnce(null) // target existence check → not found
450
+ allMock.mockResolvedValueOnce({ results: [{ target_id: 'missing-tag' }] })
451
+ batchMock.mockResolvedValue([{}])
452
+
453
+ await expect(
454
+ new D1ContentRepository(db).publishDraft(M2M_SEED, 'e1'),
455
+ ).rejects.toBeInstanceOf(RelationTargetNotFoundError)
456
+ })
457
+ })
312
458
  })