@beechcms/api 0.6.0-preview.1 → 0.6.0-preview.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  4. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  5. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  6. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  7. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  8. package/assets/dashboard/beechLogoDark.svg +48 -48
  9. package/assets/dashboard/index.html +18 -18
  10. package/assets/dashboard/noResult.svg +42 -42
  11. package/assets/dashboard/sol.svg +3 -3
  12. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  13. package/migrations/0000_v040_base.sql +265 -265
  14. package/migrations/0029_automations.sql +14 -14
  15. package/migrations/0030_test_seeds.sql +125 -125
  16. package/package.json +2 -2
  17. package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
  18. package/src/auth/__fixtures__/static-token-service.ts +22 -22
  19. package/src/auth/bcrypt-hash-provider.ts +24 -24
  20. package/src/auth/constants.ts +14 -14
  21. package/src/auth/generate-refresh-token.test.ts +23 -23
  22. package/src/auth/hash-provider.test.ts +55 -55
  23. package/src/auth/jose-token-service.ts +62 -62
  24. package/src/auth/jwt-claims-passthrough.test.ts +87 -87
  25. package/src/auth/login.test.ts +100 -100
  26. package/src/auth/login.ts +78 -78
  27. package/src/auth/refresh.ts +11 -11
  28. package/src/auth/token-service.test.ts +90 -90
  29. package/src/factory.ts +370 -368
  30. package/src/features/automations/__tests__/action-executors.test.ts +255 -255
  31. package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
  32. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
  33. package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
  34. package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
  35. package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
  36. package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
  37. package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
  38. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
  39. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
  40. package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
  41. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
  42. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
  43. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
  44. package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
  45. package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
  46. package/src/features/automations/action-executors/index.ts +37 -37
  47. package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
  48. package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
  49. package/src/features/automations/action-executors/webhook.executor.ts +67 -67
  50. package/src/features/automations/automation-runner.ts +85 -85
  51. package/src/features/automations/automation-runner.utils.ts +47 -47
  52. package/src/features/automations/automations.handler.ts +197 -197
  53. package/src/features/automations/automations.schema.ts +177 -177
  54. package/src/features/automations/context-resolver.ts +152 -152
  55. package/src/features/automations/cron-runner.ts +140 -140
  56. package/src/features/automations/cron-runner.utils.ts +44 -44
  57. package/src/features/automations/filter-translation.ts +46 -46
  58. package/src/features/automations/index.ts +16 -16
  59. package/src/features/automations/template-grammar.ts +245 -245
  60. package/src/features/automations/var-access-resolver.ts +140 -140
  61. package/src/features/automations/when-evaluator.ts +87 -87
  62. package/src/features/automations/when-pushdown.ts +57 -57
  63. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
  64. package/src/features/backrefs/backrefs.handler.ts +168 -168
  65. package/src/features/backrefs/d1-backref.repository.ts +84 -84
  66. package/src/features/backrefs/index.ts +5 -5
  67. package/src/features/content/constants.ts +16 -16
  68. package/src/features/content/handlers/bulk.handler.ts +185 -185
  69. package/src/features/content/handlers/create.ts +129 -129
  70. package/src/features/content/handlers/delete.ts +62 -62
  71. package/src/features/content/handlers/facets.ts +49 -49
  72. package/src/features/content/handlers/get.ts +120 -120
  73. package/src/features/content/handlers/helpers.test.ts +180 -180
  74. package/src/features/content/handlers/helpers.ts +93 -93
  75. package/src/features/content/handlers/list.ts +174 -174
  76. package/src/features/content/handlers/update.ts +174 -174
  77. package/src/features/content/index.ts +26 -26
  78. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  79. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  80. package/src/features/dashboard-layout/index.ts +5 -0
  81. package/src/features/draft/draft.handler.ts +195 -195
  82. package/src/features/draft/draft.middleware.ts +66 -66
  83. package/src/features/draft/index.ts +5 -5
  84. package/src/features/email/email.provider.ts +42 -42
  85. package/src/features/email/email.service.ts +98 -98
  86. package/src/features/email/email.types.ts +119 -119
  87. package/src/features/email/index.ts +33 -33
  88. package/src/features/email/providers/resend.ts +67 -67
  89. package/src/features/email/providers/smtp.ts +55 -55
  90. package/src/features/email/templates/automation-mail.ts +19 -19
  91. package/src/features/email/templates/password-changed.ts +63 -63
  92. package/src/features/email/templates/password-reset.ts +68 -68
  93. package/src/features/email/templates/shell.ts +96 -96
  94. package/src/features/notifications/index.ts +5 -5
  95. package/src/features/notifications/notifications.handler.ts +105 -105
  96. package/src/features/password-reset/index.ts +19 -19
  97. package/src/features/password-reset/request.ts +92 -92
  98. package/src/features/password-reset/reset.ts +102 -102
  99. package/src/features/rotate-field/index.ts +5 -5
  100. package/src/features/rotate-field/rotate-field.handler.ts +132 -132
  101. package/src/features/rotate-field/rotate-field.schema.ts +17 -17
  102. package/src/features/schema/schema.handler.ts +131 -131
  103. package/src/features/seeds/index.ts +5 -5
  104. package/src/features/seeds/seeds.handler.test.ts +1199 -632
  105. package/src/features/seeds/seeds.handler.ts +558 -693
  106. package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
  107. package/src/features/settings/settings.handler.ts +392 -392
  108. package/src/features/setup/index.ts +288 -204
  109. package/src/features/stats/index.ts +5 -5
  110. package/src/features/stats/stats.handler.ts +420 -420
  111. package/src/features/webhooks/index.ts +63 -63
  112. package/src/index.ts +65 -65
  113. package/src/media-utils.ts +82 -82
  114. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  115. package/src/middleware/auth-providers.middleware.ts +45 -45
  116. package/src/middleware/observability.middleware.test.ts +93 -0
  117. package/src/middleware/observability.middleware.ts +70 -70
  118. package/src/middleware/rate-limit.middleware.ts +45 -45
  119. package/src/middleware/repository.middleware.ts +117 -114
  120. package/src/middleware/seed-registry.middleware.test.ts +97 -97
  121. package/src/middleware/seed-registry.middleware.ts +21 -21
  122. package/src/middleware/storage.middleware.ts +25 -25
  123. package/src/middleware.ts +45 -45
  124. package/src/public/access-policy.ts +27 -27
  125. package/src/public/api-key-middleware.ts +57 -57
  126. package/src/public/cache-utils.ts +38 -38
  127. package/src/public/entry-projection.ts +46 -46
  128. package/src/public/idempotency.ts +23 -23
  129. package/src/public/index.ts +16 -16
  130. package/src/public/problem-details.ts +124 -124
  131. package/src/public/public-add.ts +114 -114
  132. package/src/public/public-edit.ts +163 -163
  133. package/src/public/public-errors.ts +19 -19
  134. package/src/public/public-read.ts +63 -63
  135. package/src/public/public-routes.ts +88 -88
  136. package/src/public/query-builder.test.ts +224 -224
  137. package/src/public/query-builder.ts +156 -156
  138. package/src/public/rate-limit-middleware.ts +34 -34
  139. package/src/public/read-list.ts +54 -54
  140. package/src/public/read-single.ts +48 -48
  141. package/src/public/response-builder.ts +30 -30
  142. package/src/public/sanitize.ts +69 -69
  143. package/src/public/slug-utils.ts +18 -18
  144. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
  145. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
  146. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
  147. package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
  148. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
  149. package/src/rate-limit/no-op-rate-limiter.ts +11 -11
  150. package/src/search-utils.test.ts +211 -211
  151. package/src/search-utils.ts +213 -213
  152. package/src/search.ts +65 -65
  153. package/src/shared/apply-policies.test.ts +81 -81
  154. package/src/shared/apply-policies.ts +67 -67
  155. package/src/shared/automations.repository.d1.ts +150 -150
  156. package/src/shared/background-notification-service.test.ts +62 -62
  157. package/src/shared/background-notification-service.ts +52 -52
  158. package/src/shared/base.repository.d1.ts +32 -32
  159. package/src/shared/content-utils.test.ts +165 -165
  160. package/src/shared/content-utils.ts +86 -86
  161. package/src/shared/content.repository.d1.test.ts +507 -458
  162. package/src/shared/content.repository.d1.ts +851 -851
  163. package/src/shared/d1-activity-log.repository.test.ts +140 -140
  164. package/src/shared/d1-activity-log.repository.ts +105 -105
  165. package/src/shared/d1-activity-logger.test.ts +86 -86
  166. package/src/shared/d1-activity-logger.ts +67 -67
  167. package/src/shared/d1-analytics.repository.test.ts +78 -78
  168. package/src/shared/d1-analytics.repository.ts +85 -85
  169. package/src/shared/d1-content-scan.repository.test.ts +80 -80
  170. package/src/shared/d1-content-scan.repository.ts +37 -37
  171. package/src/shared/d1-notification.repository.test.ts +128 -128
  172. package/src/shared/d1-notification.repository.ts +118 -118
  173. package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
  174. package/src/shared/d1-password-reset-token.repository.ts +56 -56
  175. package/src/shared/d1-search.repository.test.ts +87 -87
  176. package/src/shared/d1-search.repository.ts +88 -88
  177. package/src/shared/d1-session.repository.test.ts +125 -125
  178. package/src/shared/d1-session.repository.ts +102 -102
  179. package/src/shared/d1-setup-checklist.repository.ts +36 -36
  180. package/src/shared/d1-user.repository.test.ts +151 -151
  181. package/src/shared/d1-user.repository.ts +119 -119
  182. package/src/shared/d1-widget.repository.test.ts +267 -221
  183. package/src/shared/d1-widget.repository.ts +385 -341
  184. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  185. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  186. package/src/shared/demo-data-sql.ts +216 -54
  187. package/src/shared/demo-data.repository.d1.ts +20 -20
  188. package/src/shared/execution-context-scheduler.ts +13 -13
  189. package/src/shared/fixed-clock.ts +25 -25
  190. package/src/shared/fts-sync.ts +8 -8
  191. package/src/shared/idempotency.repository.d1.test.ts +83 -83
  192. package/src/shared/idempotency.repository.d1.ts +60 -60
  193. package/src/shared/in-memory-activity-logger.ts +19 -19
  194. package/src/shared/in-memory-notification-service.ts +19 -19
  195. package/src/shared/in-memory-seed.repository.ts +58 -51
  196. package/src/shared/media.repository.d1.test.ts +107 -107
  197. package/src/shared/media.repository.d1.ts +68 -68
  198. package/src/shared/qstash-notification-service.test.ts +67 -67
  199. package/src/shared/qstash-notification-service.ts +55 -55
  200. package/src/shared/query-utils.ts +141 -141
  201. package/src/shared/request-utils.ts +26 -26
  202. package/src/shared/schema-mutator.d1.test.ts +142 -0
  203. package/src/shared/schema-mutator.d1.ts +64 -64
  204. package/src/shared/seed-layout.repository.d1.test.ts +114 -114
  205. package/src/shared/seed-layout.repository.d1.ts +62 -62
  206. package/src/shared/seed-registry-cache.test.ts +68 -68
  207. package/src/shared/seed-registry-cache.ts +49 -49
  208. package/src/shared/seed.repository.d1.test.ts +143 -143
  209. package/src/shared/seed.repository.d1.ts +98 -98
  210. package/src/shared/sequential-id-generator.ts +33 -33
  211. package/src/shared/site-settings.repository.d1.ts +53 -53
  212. package/src/shared/storage/factory.ts +41 -41
  213. package/src/shared/storage/s3-bucket.ts +195 -195
  214. package/src/shared/storage-utils.ts +40 -40
  215. package/src/shared/system-stats.repository.d1.test.ts +58 -58
  216. package/src/shared/system-stats.repository.d1.ts +48 -48
  217. package/src/types.ts +90 -87
  218. package/src/upload.ts +198 -198
  219. package/src/widget.test.ts +85 -0
  220. package/src/widget.ts +251 -212
  221. package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
  222. package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
@@ -1,141 +1,141 @@
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 { FilterGroup, FilterType } from '@beechcms/core'
6
-
7
- /** Entry parsata per le risposte API — contratto immutabile (C7). */
8
- export interface ContentEntry {
9
- id: string
10
- schema_slug: string
11
- slug: string | null
12
- status: string
13
- data: Record<string, unknown>
14
- hasPendingDraft: boolean
15
- created_at: number | null
16
- updated_at: number | null
17
- }
18
-
19
- export type QueryFilterType = FilterType
20
-
21
- export type QueryFilterOperator =
22
- | 'eq'
23
- | 'gt'
24
- | 'gte'
25
- | 'lt'
26
- | 'lte'
27
- | 'contains'
28
- | 'is_empty'
29
- | 'is_not_empty'
30
-
31
- export interface QueryFilterCondition {
32
- id?: string
33
- op: QueryFilterOperator
34
- value: string | number | boolean | null
35
- }
36
-
37
- /** Formato inviato dalla dashboard (usa columnId). */
38
- export interface QueryFilterGroup {
39
- columnId: string
40
- label?: string
41
- type: QueryFilterType
42
- conditions: QueryFilterCondition[]
43
- }
44
-
45
- const QUERY_FILTER_OPERATOR_SET = new Set<QueryFilterOperator>([
46
- 'eq', 'gt', 'gte', 'lt', 'lte', 'contains', 'is_empty', 'is_not_empty',
47
- ])
48
-
49
- function isQueryFilterOperator(value: unknown): value is QueryFilterOperator {
50
- return typeof value === 'string' && QUERY_FILTER_OPERATOR_SET.has(value as QueryFilterOperator)
51
- }
52
-
53
- export function cleanStr(val: unknown): string | null {
54
- return (typeof val === 'string' && val.trim()) || null
55
- }
56
-
57
- export function safeParseJson(data: unknown): Record<string, unknown> {
58
- const cleaned = cleanStr(data)
59
- if (!cleaned) return {}
60
- try {
61
- const parsed = JSON.parse(cleaned)
62
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
63
- ? (parsed as Record<string, unknown>)
64
- : {}
65
- } catch {
66
- return {}
67
- }
68
- }
69
-
70
- export function parsePositiveInt(value: string | undefined, fallback: number): number {
71
- if (!value) return fallback
72
- const parsed = Number.parseInt(value, 10)
73
- if (Number.isNaN(parsed) || parsed < 1) return fallback
74
- return parsed
75
- }
76
-
77
- function parseCondition(cond: unknown): QueryFilterCondition | null {
78
- if (!cond || typeof cond !== 'object') return null
79
- const candidate = cond as Record<string, unknown>
80
- if (!isQueryFilterOperator(candidate.op)) return null
81
- const rawValue = candidate.value
82
- let parsedValue: QueryFilterCondition['value'] = null
83
- if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
84
- parsedValue = rawValue
85
- }
86
- return {
87
- id: typeof candidate.id === 'string' ? candidate.id : undefined,
88
- op: candidate.op,
89
- value: parsedValue,
90
- }
91
- }
92
-
93
- function parseFilterGroup(group: unknown): QueryFilterGroup | null {
94
- if (!group || typeof group !== 'object') return null
95
- const { columnId, type, label, conditions: rawConds } = group as Partial<QueryFilterGroup>
96
- if (typeof columnId !== 'string' || typeof type !== 'string' || !Array.isArray(rawConds)) {
97
- return null
98
- }
99
- const validConditions: QueryFilterCondition[] = []
100
- for (const cond of rawConds) {
101
- const parsedCond = parseCondition(cond)
102
- if (parsedCond) validConditions.push(parsedCond)
103
- }
104
- if (validConditions.length === 0) return null
105
- return {
106
- columnId,
107
- label: typeof label === 'string' ? label : undefined,
108
- type,
109
- conditions: validConditions,
110
- }
111
- }
112
-
113
- /** Parse sicuro dei filtri da query-string (formato dashboard con columnId). */
114
- export function parseQueryFilters(raw: string | undefined): QueryFilterGroup[] {
115
- if (!raw) return []
116
- let parsed: unknown
117
- try {
118
- parsed = JSON.parse(raw)
119
- } catch {
120
- return []
121
- }
122
- if (!parsed || typeof parsed !== 'object') return []
123
- const result: QueryFilterGroup[] = []
124
- for (const group of Object.values(parsed as Record<string, unknown>)) {
125
- const parsedGroup = parseFilterGroup(group)
126
- if (parsedGroup) result.push(parsedGroup)
127
- }
128
- return result
129
- }
130
-
131
- /**
132
- * Converte QueryFilterGroup[] (formato dashboard, columnId) in FilterGroup[]
133
- * compatibile con buildSelectQuery del Botanical Engine.
134
- */
135
- export function toEngineFilters(groups: QueryFilterGroup[]): FilterGroup[] {
136
- return groups.map((g) => ({
137
- column: g.columnId,
138
- type: g.type,
139
- conditions: g.conditions,
140
- }))
141
- }
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 { FilterGroup, FilterType } from '@beechcms/core'
6
+
7
+ /** Entry parsata per le risposte API — contratto immutabile (C7). */
8
+ export interface ContentEntry {
9
+ id: string
10
+ schema_slug: string
11
+ slug: string | null
12
+ status: string
13
+ data: Record<string, unknown>
14
+ hasPendingDraft: boolean
15
+ created_at: number | null
16
+ updated_at: number | null
17
+ }
18
+
19
+ export type QueryFilterType = FilterType
20
+
21
+ export type QueryFilterOperator =
22
+ | 'eq'
23
+ | 'gt'
24
+ | 'gte'
25
+ | 'lt'
26
+ | 'lte'
27
+ | 'contains'
28
+ | 'is_empty'
29
+ | 'is_not_empty'
30
+
31
+ export interface QueryFilterCondition {
32
+ id?: string
33
+ op: QueryFilterOperator
34
+ value: string | number | boolean | null
35
+ }
36
+
37
+ /** Formato inviato dalla dashboard (usa columnId). */
38
+ export interface QueryFilterGroup {
39
+ columnId: string
40
+ label?: string
41
+ type: QueryFilterType
42
+ conditions: QueryFilterCondition[]
43
+ }
44
+
45
+ const QUERY_FILTER_OPERATOR_SET = new Set<QueryFilterOperator>([
46
+ 'eq', 'gt', 'gte', 'lt', 'lte', 'contains', 'is_empty', 'is_not_empty',
47
+ ])
48
+
49
+ function isQueryFilterOperator(value: unknown): value is QueryFilterOperator {
50
+ return typeof value === 'string' && QUERY_FILTER_OPERATOR_SET.has(value as QueryFilterOperator)
51
+ }
52
+
53
+ export function cleanStr(val: unknown): string | null {
54
+ return (typeof val === 'string' && val.trim()) || null
55
+ }
56
+
57
+ export function safeParseJson(data: unknown): Record<string, unknown> {
58
+ const cleaned = cleanStr(data)
59
+ if (!cleaned) return {}
60
+ try {
61
+ const parsed = JSON.parse(cleaned)
62
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
63
+ ? (parsed as Record<string, unknown>)
64
+ : {}
65
+ } catch {
66
+ return {}
67
+ }
68
+ }
69
+
70
+ export function parsePositiveInt(value: string | undefined, fallback: number): number {
71
+ if (!value) return fallback
72
+ const parsed = Number.parseInt(value, 10)
73
+ if (Number.isNaN(parsed) || parsed < 1) return fallback
74
+ return parsed
75
+ }
76
+
77
+ function parseCondition(cond: unknown): QueryFilterCondition | null {
78
+ if (!cond || typeof cond !== 'object') return null
79
+ const candidate = cond as Record<string, unknown>
80
+ if (!isQueryFilterOperator(candidate.op)) return null
81
+ const rawValue = candidate.value
82
+ let parsedValue: QueryFilterCondition['value'] = null
83
+ if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
84
+ parsedValue = rawValue
85
+ }
86
+ return {
87
+ id: typeof candidate.id === 'string' ? candidate.id : undefined,
88
+ op: candidate.op,
89
+ value: parsedValue,
90
+ }
91
+ }
92
+
93
+ function parseFilterGroup(group: unknown): QueryFilterGroup | null {
94
+ if (!group || typeof group !== 'object') return null
95
+ const { columnId, type, label, conditions: rawConds } = group as Partial<QueryFilterGroup>
96
+ if (typeof columnId !== 'string' || typeof type !== 'string' || !Array.isArray(rawConds)) {
97
+ return null
98
+ }
99
+ const validConditions: QueryFilterCondition[] = []
100
+ for (const cond of rawConds) {
101
+ const parsedCond = parseCondition(cond)
102
+ if (parsedCond) validConditions.push(parsedCond)
103
+ }
104
+ if (validConditions.length === 0) return null
105
+ return {
106
+ columnId,
107
+ label: typeof label === 'string' ? label : undefined,
108
+ type,
109
+ conditions: validConditions,
110
+ }
111
+ }
112
+
113
+ /** Parse sicuro dei filtri da query-string (formato dashboard con columnId). */
114
+ export function parseQueryFilters(raw: string | undefined): QueryFilterGroup[] {
115
+ if (!raw) return []
116
+ let parsed: unknown
117
+ try {
118
+ parsed = JSON.parse(raw)
119
+ } catch {
120
+ return []
121
+ }
122
+ if (!parsed || typeof parsed !== 'object') return []
123
+ const result: QueryFilterGroup[] = []
124
+ for (const group of Object.values(parsed as Record<string, unknown>)) {
125
+ const parsedGroup = parseFilterGroup(group)
126
+ if (parsedGroup) result.push(parsedGroup)
127
+ }
128
+ return result
129
+ }
130
+
131
+ /**
132
+ * Converte QueryFilterGroup[] (formato dashboard, columnId) in FilterGroup[]
133
+ * compatibile con buildSelectQuery del Botanical Engine.
134
+ */
135
+ export function toEngineFilters(groups: QueryFilterGroup[]): FilterGroup[] {
136
+ return groups.map((g) => ({
137
+ column: g.columnId,
138
+ type: g.type,
139
+ conditions: g.conditions,
140
+ }))
141
+ }
@@ -1,26 +1,26 @@
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 { HonoRequest } from "hono"
6
-
7
- /** The header Cloudflare sets on every incoming request to the Worker. */
8
- const CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip"
9
-
10
- /** The fallback value used when the IP header is absent (local dev, unit tests). */
11
- const UNKNOWN_IP = "unknown"
12
-
13
- /**
14
- * Extracts the real client IP address from a Cloudflare Worker request.
15
- *
16
- * Cloudflare injects the cf-connecting-ip header on every request that
17
- * passes through the edge. In local development (wrangler dev) or in
18
- * unit tests the header may be absent, in which case the string "unknown"
19
- * is returned so that rate-limiter keys remain non-empty and safe to use.
20
- *
21
- * Never derive security decisions from this value alone; treat it as a
22
- * best-effort hint for rate-limiting and logging purposes only.
23
- */
24
- export function getClientIp(request: HonoRequest): string {
25
- return request.raw.headers.get(CLOUDFLARE_CLIENT_IP_HEADER) ?? UNKNOWN_IP
26
- }
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 { HonoRequest } from "hono"
6
+
7
+ /** The header Cloudflare sets on every incoming request to the Worker. */
8
+ const CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip"
9
+
10
+ /** The fallback value used when the IP header is absent (local dev, unit tests). */
11
+ const UNKNOWN_IP = "unknown"
12
+
13
+ /**
14
+ * Extracts the real client IP address from a Cloudflare Worker request.
15
+ *
16
+ * Cloudflare injects the cf-connecting-ip header on every request that
17
+ * passes through the edge. In local development (wrangler dev) or in
18
+ * unit tests the header may be absent, in which case the string "unknown"
19
+ * is returned so that rate-limiter keys remain non-empty and safe to use.
20
+ *
21
+ * Never derive security decisions from this value alone; treat it as a
22
+ * best-effort hint for rate-limiting and logging purposes only.
23
+ */
24
+ export function getClientIp(request: HonoRequest): string {
25
+ return request.raw.headers.get(CLOUDFLARE_CLIENT_IP_HEADER) ?? UNKNOWN_IP
26
+ }
@@ -0,0 +1,142 @@
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 { describe, it, expect, vi } from 'vitest'
6
+ import { D1SchemaMutator } from './schema-mutator.d1'
7
+
8
+ function makeMockDb() {
9
+ const runMock = vi.fn().mockResolvedValue({ success: true })
10
+ const firstMock = vi.fn().mockResolvedValue(null)
11
+ const allMock = vi.fn().mockResolvedValue({ results: [] })
12
+ const bindMock = vi.fn(() => ({ run: runMock, first: firstMock, all: allMock }))
13
+ const stmt = { bind: bindMock, run: runMock, first: firstMock, all: allMock }
14
+ const prepareMock = vi.fn(() => stmt)
15
+ const batchMock = vi.fn().mockResolvedValue([])
16
+
17
+ return {
18
+ db: {
19
+ prepare: prepareMock,
20
+ batch: batchMock,
21
+ } as unknown as D1Database,
22
+ prepareMock,
23
+ bindMock,
24
+ runMock,
25
+ firstMock,
26
+ allMock,
27
+ batchMock,
28
+ }
29
+ }
30
+
31
+ describe('D1SchemaMutator', () => {
32
+ describe('getColumns', () => {
33
+ it('returns null if table has no columns (empty result)', async () => {
34
+ const { db, allMock } = makeMockDb()
35
+ allMock.mockResolvedValueOnce({ results: [] })
36
+ const mutator = new D1SchemaMutator(db)
37
+ const cols = await mutator.getColumns('users')
38
+ expect(cols).toBeNull()
39
+ })
40
+
41
+ it('returns set of column names when columns are found', async () => {
42
+ const { db, allMock, prepareMock } = makeMockDb()
43
+ allMock.mockResolvedValueOnce({
44
+ results: [{ name: 'id' }, { name: 'email' }],
45
+ })
46
+ const mutator = new D1SchemaMutator(db)
47
+ const cols = await mutator.getColumns('users')
48
+ expect(prepareMock).toHaveBeenCalledWith('PRAGMA table_info(users)')
49
+ expect(cols).toEqual(new Set(['id', 'email']))
50
+ })
51
+
52
+ it('throws error if table name is unsafe', async () => {
53
+ const { db } = makeMockDb()
54
+ const mutator = new D1SchemaMutator(db)
55
+ await expect(mutator.getColumns('users; DROP TABLE users;')).rejects.toThrow(
56
+ 'Unsafe table name',
57
+ )
58
+ })
59
+ })
60
+
61
+ describe('execDdl', () => {
62
+ it('returns early if statements are empty', async () => {
63
+ const { db, batchMock } = makeMockDb()
64
+ const mutator = new D1SchemaMutator(db)
65
+ await mutator.execDdl([])
66
+ expect(batchMock).not.toHaveBeenCalled()
67
+ })
68
+
69
+ it('prepares and executes batch DDL statements', async () => {
70
+ const { db, batchMock, prepareMock } = makeMockDb()
71
+ const mutator = new D1SchemaMutator(db)
72
+ await mutator.execDdl(['CREATE TABLE test (id TEXT)', 'CREATE INDEX idx ON test (id)'])
73
+ expect(prepareMock).toHaveBeenCalledTimes(2)
74
+ expect(batchMock).toHaveBeenCalled()
75
+ })
76
+ })
77
+
78
+ describe('dropTable', () => {
79
+ it('executes DROP TABLE IF EXISTS for a safe identifier', async () => {
80
+ const { db, prepareMock, runMock } = makeMockDb()
81
+ const mutator = new D1SchemaMutator(db)
82
+ await mutator.dropTable('articles')
83
+ expect(prepareMock).toHaveBeenCalledWith('DROP TABLE IF EXISTS articles')
84
+ expect(runMock).toHaveBeenCalled()
85
+ })
86
+
87
+ it('throws error on unsafe table name', async () => {
88
+ const { db } = makeMockDb()
89
+ const mutator = new D1SchemaMutator(db)
90
+ await expect(mutator.dropTable('articles; --')).rejects.toThrow('Unsafe identifier')
91
+ })
92
+ })
93
+
94
+ describe('dropColumn', () => {
95
+ it('executes ALTER TABLE DROP COLUMN for safe identifiers', async () => {
96
+ const { db, prepareMock, runMock } = makeMockDb()
97
+ const mutator = new D1SchemaMutator(db)
98
+ await mutator.dropColumn('articles', 'title')
99
+ expect(prepareMock).toHaveBeenCalledWith('ALTER TABLE articles DROP COLUMN title')
100
+ expect(runMock).toHaveBeenCalled()
101
+ })
102
+
103
+ it('throws error on unsafe column name', async () => {
104
+ const { db } = makeMockDb()
105
+ const mutator = new D1SchemaMutator(db)
106
+ await expect(mutator.dropColumn('articles', 'title;')).rejects.toThrow('Unsafe identifier')
107
+ })
108
+ })
109
+
110
+ describe('renameColumn', () => {
111
+ it('executes ALTER TABLE RENAME COLUMN for safe identifiers', async () => {
112
+ const { db, prepareMock, runMock } = makeMockDb()
113
+ const mutator = new D1SchemaMutator(db)
114
+ await mutator.renameColumn('articles', 'old_name', 'new_name')
115
+ expect(prepareMock).toHaveBeenCalledWith('ALTER TABLE articles RENAME COLUMN old_name TO new_name')
116
+ expect(runMock).toHaveBeenCalled()
117
+ })
118
+
119
+ it('throws error on unsafe parameters', async () => {
120
+ const { db } = makeMockDb()
121
+ const mutator = new D1SchemaMutator(db)
122
+ await expect(mutator.renameColumn('articles', 'old', 'new --')).rejects.toThrow('Unsafe identifier')
123
+ })
124
+ })
125
+
126
+ describe('execDestructive', () => {
127
+ it('returns early if statements are empty', async () => {
128
+ const { db, batchMock } = makeMockDb()
129
+ const mutator = new D1SchemaMutator(db)
130
+ await mutator.execDestructive([])
131
+ expect(batchMock).not.toHaveBeenCalled()
132
+ })
133
+
134
+ it('prepares and executes batch destructive statements', async () => {
135
+ const { db, batchMock, prepareMock } = makeMockDb()
136
+ const mutator = new D1SchemaMutator(db)
137
+ await mutator.execDestructive(['DROP TABLE test'])
138
+ expect(prepareMock).toHaveBeenCalledWith('DROP TABLE test')
139
+ expect(batchMock).toHaveBeenCalled()
140
+ })
141
+ })
142
+ })
@@ -1,64 +1,64 @@
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
- /// <reference types="@cloudflare/workers-types" />
6
- import type { ISchemaMutator } from '@beechcms/core'
7
-
8
- export class D1SchemaMutator implements ISchemaMutator {
9
- constructor(private readonly db: D1Database) {}
10
-
11
- async getColumns(table: string): Promise<Set<string> | null> {
12
- // PRAGMA table_info returns rows {cid,name,type,notnull,dflt_value,pk}.
13
- // Table name cannot be parameterized in PRAGMA — validate it is a safe identifier.
14
- if (!/^[A-Za-z0-9_]+$/.test(table)) throw new Error(`Unsafe table name: ${table}`)
15
- const rs = await this.db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>()
16
- const rows = rs.results ?? []
17
- if (rows.length === 0) return null // table absent
18
- return new Set(rows.map(r => r.name))
19
- }
20
-
21
- async execDdl(statements: string[]): Promise<void> {
22
- if (statements.length === 0) return
23
- // D1 batch is atomic per call; if one statement fails, the rest roll back.
24
- await this.db.batch(statements.map(s => this.db.prepare(s)))
25
- }
26
-
27
- // --- destructive (sprint 06 — Danger Zone) -------------------------------
28
- // These emit IRREVERSIBLE SQL. Identifiers cannot be parameterized in DDL, so
29
- // every table/column name is validated against the same `^[A-Za-z0-9_]+$`
30
- // guard getColumns already applies before interpolation.
31
-
32
- private static assertIdentifier(name: string): void {
33
- if (!/^[A-Za-z0-9_]+$/.test(name)) throw new Error(`Unsafe identifier: ${name}`)
34
- }
35
-
36
- async dropTable(table: string): Promise<void> {
37
- D1SchemaMutator.assertIdentifier(table)
38
- await this.db.prepare(`DROP TABLE IF EXISTS ${table}`).run()
39
- }
40
-
41
- async dropColumn(table: string, column: string): Promise<void> {
42
- D1SchemaMutator.assertIdentifier(table)
43
- D1SchemaMutator.assertIdentifier(column)
44
- await this.db.prepare(`ALTER TABLE ${table} DROP COLUMN ${column}`).run()
45
- }
46
-
47
- async renameColumn(table: string, from: string, to: string): Promise<void> {
48
- D1SchemaMutator.assertIdentifier(table)
49
- D1SchemaMutator.assertIdentifier(from)
50
- D1SchemaMutator.assertIdentifier(to)
51
- await this.db.prepare(`ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to}`).run()
52
- }
53
-
54
- async execDestructive(statements: string[]): Promise<void> {
55
- if (statements.length === 0) return
56
- // The statements come from the core destructive generators
57
- // (generateDropTable / generateRetypeColumn / planFtsRebuild), which only
58
- // interpolate slugs/aliases already validated by the seed registry
59
- // (`^[a-z0-9_]+$`). FTS trigger bodies legitimately contain internal
60
- // semicolons (BEGIN … ; END), so we do not split or reject on `;`.
61
- // D1 batch is atomic per call; if one statement fails, the rest roll back.
62
- await this.db.batch(statements.map(s => this.db.prepare(s)))
63
- }
64
- }
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
+ /// <reference types="@cloudflare/workers-types" />
6
+ import type { ISchemaMutator } from '@beechcms/core'
7
+
8
+ export class D1SchemaMutator implements ISchemaMutator {
9
+ constructor(private readonly db: D1Database) {}
10
+
11
+ async getColumns(table: string): Promise<Set<string> | null> {
12
+ // PRAGMA table_info returns rows {cid,name,type,notnull,dflt_value,pk}.
13
+ // Table name cannot be parameterized in PRAGMA — validate it is a safe identifier.
14
+ if (!/^[A-Za-z0-9_]+$/.test(table)) throw new Error(`Unsafe table name: ${table}`)
15
+ const rs = await this.db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>()
16
+ const rows = rs.results ?? []
17
+ if (rows.length === 0) return null // table absent
18
+ return new Set(rows.map(r => r.name))
19
+ }
20
+
21
+ async execDdl(statements: string[]): Promise<void> {
22
+ if (statements.length === 0) return
23
+ // D1 batch is atomic per call; if one statement fails, the rest roll back.
24
+ await this.db.batch(statements.map(s => this.db.prepare(s)))
25
+ }
26
+
27
+ // --- destructive (sprint 06 — Danger Zone) -------------------------------
28
+ // These emit IRREVERSIBLE SQL. Identifiers cannot be parameterized in DDL, so
29
+ // every table/column name is validated against the same `^[A-Za-z0-9_]+$`
30
+ // guard getColumns already applies before interpolation.
31
+
32
+ private static assertIdentifier(name: string): void {
33
+ if (!/^[A-Za-z0-9_]+$/.test(name)) throw new Error(`Unsafe identifier: ${name}`)
34
+ }
35
+
36
+ async dropTable(table: string): Promise<void> {
37
+ D1SchemaMutator.assertIdentifier(table)
38
+ await this.db.prepare(`DROP TABLE IF EXISTS ${table}`).run()
39
+ }
40
+
41
+ async dropColumn(table: string, column: string): Promise<void> {
42
+ D1SchemaMutator.assertIdentifier(table)
43
+ D1SchemaMutator.assertIdentifier(column)
44
+ await this.db.prepare(`ALTER TABLE ${table} DROP COLUMN ${column}`).run()
45
+ }
46
+
47
+ async renameColumn(table: string, from: string, to: string): Promise<void> {
48
+ D1SchemaMutator.assertIdentifier(table)
49
+ D1SchemaMutator.assertIdentifier(from)
50
+ D1SchemaMutator.assertIdentifier(to)
51
+ await this.db.prepare(`ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to}`).run()
52
+ }
53
+
54
+ async execDestructive(statements: string[]): Promise<void> {
55
+ if (statements.length === 0) return
56
+ // The statements come from the core destructive generators
57
+ // (generateDropTable / generateRetypeColumn / planFtsRebuild), which only
58
+ // interpolate slugs/aliases already validated by the seed registry
59
+ // (`^[a-z0-9_]+$`). FTS trigger bodies legitimately contain internal
60
+ // semicolons (BEGIN … ; END), so we do not split or reject on `;`.
61
+ // D1 batch is atomic per call; if one statement fails, the rest roll back.
62
+ await this.db.batch(statements.map(s => this.db.prepare(s)))
63
+ }
64
+ }