@beechcms/api 0.5.0 → 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 (225) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/Searching.svg +1 -0
  4. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  5. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  6. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  7. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  8. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  9. package/assets/dashboard/beechLogoDark.svg +48 -48
  10. package/assets/dashboard/index.html +18 -18
  11. package/assets/dashboard/noResult.svg +43 -0
  12. package/assets/dashboard/sol.svg +3 -3
  13. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  14. package/assets/dashboard/working.svg +1 -0
  15. package/migrations/0000_v040_base.sql +265 -238
  16. package/migrations/0029_automations.sql +14 -14
  17. package/migrations/0030_test_seeds.sql +125 -125
  18. package/package.json +13 -9
  19. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
  20. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
  21. package/src/auth/bcrypt-hash-provider.ts +24 -20
  22. package/src/auth/constants.ts +14 -10
  23. package/src/auth/generate-refresh-token.test.ts +23 -19
  24. package/src/auth/hash-provider.test.ts +55 -46
  25. package/src/auth/jose-token-service.ts +62 -55
  26. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  27. package/src/auth/login.test.ts +100 -92
  28. package/src/auth/login.ts +78 -74
  29. package/src/auth/refresh.ts +11 -5
  30. package/src/auth/token-service.test.ts +90 -82
  31. package/src/factory.ts +370 -347
  32. package/src/features/automations/__tests__/action-executors.test.ts +255 -268
  33. package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
  34. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
  35. package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
  36. package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
  37. package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
  38. package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
  39. package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
  40. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
  41. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
  42. package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
  43. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  44. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
  45. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
  46. package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
  47. package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
  48. package/src/features/automations/action-executors/index.ts +37 -33
  49. package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
  50. package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
  51. package/src/features/automations/action-executors/webhook.executor.ts +67 -25
  52. package/src/features/automations/automation-runner.ts +85 -81
  53. package/src/features/automations/automation-runner.utils.ts +47 -43
  54. package/src/features/automations/automations.handler.ts +197 -193
  55. package/src/features/automations/automations.schema.ts +177 -160
  56. package/src/features/automations/context-resolver.ts +152 -148
  57. package/src/features/automations/cron-runner.ts +140 -136
  58. package/src/features/automations/cron-runner.utils.ts +44 -40
  59. package/src/features/automations/filter-translation.ts +46 -42
  60. package/src/features/automations/index.ts +16 -12
  61. package/src/features/automations/template-grammar.ts +245 -241
  62. package/src/features/automations/var-access-resolver.ts +140 -136
  63. package/src/features/automations/when-evaluator.ts +87 -83
  64. package/src/features/automations/when-pushdown.ts +57 -53
  65. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  66. package/src/features/backrefs/backrefs.handler.ts +168 -0
  67. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  68. package/src/features/backrefs/index.ts +5 -0
  69. package/src/features/content/constants.ts +16 -10
  70. package/src/features/content/handlers/bulk.handler.ts +185 -0
  71. package/src/features/content/handlers/create.ts +129 -165
  72. package/src/features/content/handlers/delete.ts +62 -87
  73. package/src/features/content/handlers/facets.ts +49 -45
  74. package/src/features/content/handlers/get.ts +120 -116
  75. package/src/features/content/handlers/helpers.test.ts +180 -0
  76. package/src/features/content/handlers/helpers.ts +93 -0
  77. package/src/features/content/handlers/list.ts +174 -88
  78. package/src/features/content/handlers/update.ts +174 -219
  79. package/src/features/content/index.ts +26 -20
  80. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  81. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  82. package/src/features/dashboard-layout/index.ts +5 -0
  83. package/src/features/draft/draft.handler.ts +195 -170
  84. package/src/features/draft/draft.middleware.ts +66 -62
  85. package/src/features/draft/index.ts +5 -1
  86. package/src/features/email/email.provider.ts +42 -38
  87. package/src/features/email/email.service.ts +98 -93
  88. package/src/features/email/email.types.ts +119 -108
  89. package/src/features/email/index.ts +33 -29
  90. package/src/features/email/providers/resend.ts +67 -63
  91. package/src/features/email/providers/smtp.ts +55 -0
  92. package/src/features/email/templates/automation-mail.ts +19 -15
  93. package/src/features/email/templates/password-changed.ts +63 -59
  94. package/src/features/email/templates/password-reset.ts +68 -64
  95. package/src/features/email/templates/shell.ts +96 -92
  96. package/src/features/notifications/index.ts +5 -1
  97. package/src/features/notifications/notifications.handler.ts +105 -101
  98. package/src/features/password-reset/index.ts +19 -15
  99. package/src/features/password-reset/request.ts +92 -82
  100. package/src/features/password-reset/reset.ts +102 -92
  101. package/src/features/rotate-field/index.ts +5 -1
  102. package/src/features/rotate-field/rotate-field.handler.ts +132 -128
  103. package/src/features/rotate-field/rotate-field.schema.ts +17 -13
  104. package/src/features/schema/schema.handler.ts +131 -16
  105. package/src/features/seeds/index.ts +5 -0
  106. package/src/features/seeds/seeds.handler.test.ts +1199 -0
  107. package/src/features/seeds/seeds.handler.ts +558 -0
  108. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  109. package/src/features/settings/settings.handler.ts +392 -302
  110. package/src/features/setup/index.ts +288 -91
  111. package/src/features/stats/index.ts +5 -1
  112. package/src/features/stats/stats.handler.ts +420 -430
  113. package/src/features/webhooks/index.ts +63 -0
  114. package/src/index.ts +65 -56
  115. package/src/media-utils.ts +82 -78
  116. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  117. package/src/middleware/auth-providers.middleware.ts +45 -32
  118. package/src/middleware/observability.middleware.test.ts +93 -0
  119. package/src/middleware/observability.middleware.ts +70 -52
  120. package/src/middleware/rate-limit.middleware.ts +45 -41
  121. package/src/middleware/repository.middleware.ts +117 -91
  122. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  123. package/src/middleware/seed-registry.middleware.ts +21 -0
  124. package/src/middleware/storage.middleware.ts +25 -21
  125. package/src/middleware.ts +45 -47
  126. package/src/public/access-policy.ts +27 -23
  127. package/src/public/api-key-middleware.ts +57 -53
  128. package/src/public/cache-utils.ts +38 -34
  129. package/src/public/entry-projection.ts +46 -42
  130. package/src/public/idempotency.ts +23 -19
  131. package/src/public/index.ts +16 -12
  132. package/src/public/problem-details.ts +124 -53
  133. package/src/public/public-add.ts +114 -110
  134. package/src/public/public-edit.ts +163 -159
  135. package/src/public/public-errors.ts +19 -15
  136. package/src/public/public-read.ts +63 -59
  137. package/src/public/public-routes.ts +88 -84
  138. package/src/public/query-builder.test.ts +224 -220
  139. package/src/public/query-builder.ts +156 -152
  140. package/src/public/rate-limit-middleware.ts +34 -30
  141. package/src/public/read-list.ts +54 -50
  142. package/src/public/read-single.ts +48 -44
  143. package/src/public/response-builder.ts +30 -26
  144. package/src/public/sanitize.ts +69 -65
  145. package/src/public/slug-utils.ts +18 -14
  146. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
  147. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
  148. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
  149. package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
  150. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
  151. package/src/rate-limit/no-op-rate-limiter.ts +11 -7
  152. package/src/search-utils.test.ts +211 -207
  153. package/src/search-utils.ts +213 -209
  154. package/src/search.ts +65 -61
  155. package/src/shared/apply-policies.test.ts +81 -77
  156. package/src/shared/apply-policies.ts +67 -63
  157. package/src/shared/automations.repository.d1.ts +150 -146
  158. package/src/shared/background-notification-service.test.ts +62 -58
  159. package/src/shared/background-notification-service.ts +52 -48
  160. package/src/shared/base.repository.d1.ts +32 -28
  161. package/src/shared/content-utils.test.ts +165 -161
  162. package/src/shared/content-utils.ts +86 -82
  163. package/src/shared/content.repository.d1.test.ts +507 -312
  164. package/src/shared/content.repository.d1.ts +851 -382
  165. package/src/shared/d1-activity-log.repository.test.ts +140 -136
  166. package/src/shared/d1-activity-log.repository.ts +105 -101
  167. package/src/shared/d1-activity-logger.test.ts +86 -82
  168. package/src/shared/d1-activity-logger.ts +67 -63
  169. package/src/shared/d1-analytics.repository.test.ts +78 -74
  170. package/src/shared/d1-analytics.repository.ts +85 -81
  171. package/src/shared/d1-content-scan.repository.test.ts +80 -76
  172. package/src/shared/d1-content-scan.repository.ts +37 -29
  173. package/src/shared/d1-notification.repository.test.ts +128 -124
  174. package/src/shared/d1-notification.repository.ts +118 -114
  175. package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
  176. package/src/shared/d1-password-reset-token.repository.ts +56 -52
  177. package/src/shared/d1-search.repository.test.ts +87 -83
  178. package/src/shared/d1-search.repository.ts +88 -84
  179. package/src/shared/d1-session.repository.test.ts +125 -121
  180. package/src/shared/d1-session.repository.ts +102 -98
  181. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  182. package/src/shared/d1-user.repository.test.ts +151 -147
  183. package/src/shared/d1-user.repository.ts +119 -109
  184. package/src/shared/d1-widget.repository.test.ts +267 -217
  185. package/src/shared/d1-widget.repository.ts +385 -337
  186. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  187. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  188. package/src/shared/demo-data-sql.ts +216 -0
  189. package/src/shared/demo-data.repository.d1.ts +20 -0
  190. package/src/shared/execution-context-scheduler.ts +13 -9
  191. package/src/shared/fixed-clock.ts +25 -21
  192. package/src/shared/fts-sync.ts +8 -4
  193. package/src/shared/idempotency.repository.d1.test.ts +83 -79
  194. package/src/shared/idempotency.repository.d1.ts +60 -56
  195. package/src/shared/in-memory-activity-logger.ts +19 -15
  196. package/src/shared/in-memory-notification-service.ts +19 -15
  197. package/src/shared/in-memory-seed.repository.ts +58 -0
  198. package/src/shared/media.repository.d1.test.ts +107 -103
  199. package/src/shared/media.repository.d1.ts +68 -64
  200. package/src/shared/qstash-notification-service.test.ts +67 -0
  201. package/src/shared/qstash-notification-service.ts +55 -0
  202. package/src/shared/query-utils.ts +141 -137
  203. package/src/shared/request-utils.ts +26 -22
  204. package/src/shared/schema-mutator.d1.test.ts +142 -0
  205. package/src/shared/schema-mutator.d1.ts +64 -0
  206. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  207. package/src/shared/seed-layout.repository.d1.ts +62 -0
  208. package/src/shared/seed-registry-cache.test.ts +68 -0
  209. package/src/shared/seed-registry-cache.ts +49 -0
  210. package/src/shared/seed.repository.d1.test.ts +143 -0
  211. package/src/shared/seed.repository.d1.ts +98 -0
  212. package/src/shared/sequential-id-generator.ts +33 -22
  213. package/src/shared/site-settings.repository.d1.ts +53 -0
  214. package/src/shared/storage/factory.ts +41 -40
  215. package/src/shared/storage/s3-bucket.ts +195 -163
  216. package/src/shared/storage-utils.ts +40 -36
  217. package/src/shared/system-stats.repository.d1.test.ts +58 -54
  218. package/src/shared/system-stats.repository.d1.ts +48 -44
  219. package/src/types.ts +90 -67
  220. package/src/upload.ts +198 -187
  221. package/src/widget.test.ts +85 -0
  222. package/src/widget.ts +251 -208
  223. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  224. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  225. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -1,136 +1,140 @@
1
- import type { Seed } from '@beechcms/core'
2
- import type { ParsedKey, InlineCondition } from './template-grammar'
3
- import { materializeCollection } from './action-executors/set-variable.executor'
4
-
5
- type VarAccessParsed = Extract<ParsedKey, { kind: 'var_access' }>
6
-
7
- function toNumeric(v: unknown): number {
8
- const n = Number(v)
9
- if (Number.isFinite(n)) return n
10
- if (typeof v === 'string') {
11
- const d = Date.parse(v)
12
- if (!Number.isNaN(d)) return d / 1000
13
- }
14
- return NaN
15
- }
16
-
17
- function numericCoerce(a: unknown, b: unknown): { left: unknown; right: unknown } {
18
- const na = toNumeric(a)
19
- const nb = toNumeric(b)
20
- const numeric = Number.isFinite(na) || Number.isFinite(nb)
21
- return { left: numeric ? na : a, right: numeric ? nb : b }
22
- }
23
-
24
- function applyCondition(record: Record<string, unknown>, cond: InlineCondition): boolean {
25
- const raw = record[cond.column]
26
- const { left, right } = numericCoerce(raw, cond.value)
27
- switch (cond.op) {
28
- case '=': return left === right
29
- case '!=': return left !== right
30
- case '<': return (left as number) < (right as number)
31
- case '>': return (left as number) > (right as number)
32
- case '<=': return (left as number) <= (right as number)
33
- case '>=': return (left as number) >= (right as number)
34
- }
35
- }
36
-
37
- function applyConditions(
38
- record: Record<string, unknown>,
39
- conditions: InlineCondition[],
40
- ): boolean {
41
- return conditions.every((c) => applyCondition(record, c))
42
- }
43
-
44
- function filterItems(
45
- items: Array<Record<string, unknown>>,
46
- conditions: InlineCondition[],
47
- ): Array<Record<string, unknown>> {
48
- if (conditions.length === 0) return items
49
- return items.filter((r) => applyConditions(r, conditions))
50
- }
51
-
52
- function getItems(value: unknown): Array<Record<string, unknown>> | null {
53
- if (!value || typeof value !== 'object') return null
54
- const items = (value as any)['_items']
55
- return Array.isArray(items) ? items : null
56
- }
57
-
58
- const EMPTY_SEED = { branches: [] } as unknown as Seed
59
-
60
- export function resolveVarAccess(
61
- parsed: VarAccessParsed,
62
- variables: Record<string, unknown>,
63
- onMissing?: (field: string) => void,
64
- ): unknown {
65
- if (!(parsed.name in variables)) {
66
- onMissing?.(parsed.name)
67
- return undefined
68
- }
69
-
70
- let current: unknown = variables[parsed.name]
71
- const { steps, conditions } = parsed
72
-
73
- for (let i = 0; i < steps.length; i++) {
74
- const step = steps[i]
75
-
76
- // ── array selector ─────────────────────────────────────────────────────
77
- if (step.type === 'array') {
78
- const items = getItems(current) ?? (Array.isArray(current) ? current as Array<Record<string, unknown>> : null)
79
- if (!items) return undefined
80
- const idSet = new Set(step.ids)
81
- const subset = items.filter((r) => idSet.has(String(r['id'] ?? '')))
82
- // Apply conditions to the subset before building the collection
83
- const filtered = filterItems(subset, conditions)
84
- current = materializeCollection(EMPTY_SEED, filtered, null)
85
- // Conditions consumed no post-filter needed
86
- continue
87
- }
88
-
89
- // ── nav: firstone / lastone ────────────────────────────────────────────
90
- if (step.type === 'nav') {
91
- // Nav moves us from a collection to a single record.
92
- // Conditions are a guard on that record, NOT a pre-filter on _items.
93
- const record = (current as any)?.[step.nav] as Record<string, unknown> | null | undefined
94
- if (!record) { current = null; continue }
95
- // Apply conditions to the single record (post-filter)
96
- if (conditions.length > 0 && !applyConditions(record, conditions)) return undefined
97
- current = record
98
- continue
99
- }
100
-
101
- // ── aggregate ──────────────────────────────────────────────────────────
102
- if (step.type === 'agg') {
103
- // Apply conditions to _items before aggregating
104
- const items = getItems(current)
105
- if (items !== null) {
106
- const filtered = filterItems(items, conditions)
107
- current = materializeCollection(EMPTY_SEED, filtered, null)
108
- }
109
- if (step.op === 'count') {
110
- current = (current as any)?.['count']
111
- } else if (step.field) {
112
- const sub = (current as any)?.[step.op]
113
- current = typeof sub === 'object' && sub !== null ? sub[step.field] : sub
114
- } else {
115
- current = (current as any)?.[step.op]
116
- }
117
- // Conditions consumed by filtering _items
118
- return current
119
- }
120
-
121
- // ── field access ───────────────────────────────────────────────────────
122
- if (step.type === 'field') {
123
- current = (current as any)?.[step.name]
124
- }
125
- }
126
-
127
- // Post-filter on single record (conditions not consumed by agg or array)
128
- if (conditions.length > 0 && current !== null && current !== undefined) {
129
- if (typeof current === 'object' && !Array.isArray(current)) {
130
- if (!applyConditions(current as Record<string, unknown>, conditions)) return undefined
131
- }
132
- // For scalar values: conditions cannot be checked pass through
133
- }
134
-
135
- return current
136
- }
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 } from '@beechcms/core'
6
+ import type { ParsedKey, InlineCondition } from './template-grammar'
7
+ import { materializeCollection } from './action-executors/set-variable.executor'
8
+
9
+ type VarAccessParsed = Extract<ParsedKey, { kind: 'var_access' }>
10
+
11
+ function toNumeric(v: unknown): number {
12
+ const n = Number(v)
13
+ if (Number.isFinite(n)) return n
14
+ if (typeof v === 'string') {
15
+ const d = Date.parse(v)
16
+ if (!Number.isNaN(d)) return d / 1000
17
+ }
18
+ return NaN
19
+ }
20
+
21
+ function numericCoerce(a: unknown, b: unknown): { left: unknown; right: unknown } {
22
+ const na = toNumeric(a)
23
+ const nb = toNumeric(b)
24
+ const numeric = Number.isFinite(na) || Number.isFinite(nb)
25
+ return { left: numeric ? na : a, right: numeric ? nb : b }
26
+ }
27
+
28
+ function applyCondition(record: Record<string, unknown>, cond: InlineCondition): boolean {
29
+ const raw = record[cond.column]
30
+ const { left, right } = numericCoerce(raw, cond.value)
31
+ switch (cond.op) {
32
+ case '=': return left === right
33
+ case '!=': return left !== right
34
+ case '<': return (left as number) < (right as number)
35
+ case '>': return (left as number) > (right as number)
36
+ case '<=': return (left as number) <= (right as number)
37
+ case '>=': return (left as number) >= (right as number)
38
+ }
39
+ }
40
+
41
+ function applyConditions(
42
+ record: Record<string, unknown>,
43
+ conditions: InlineCondition[],
44
+ ): boolean {
45
+ return conditions.every((c) => applyCondition(record, c))
46
+ }
47
+
48
+ function filterItems(
49
+ items: Array<Record<string, unknown>>,
50
+ conditions: InlineCondition[],
51
+ ): Array<Record<string, unknown>> {
52
+ if (conditions.length === 0) return items
53
+ return items.filter((r) => applyConditions(r, conditions))
54
+ }
55
+
56
+ function getItems(value: unknown): Array<Record<string, unknown>> | null {
57
+ if (!value || typeof value !== 'object') return null
58
+ const items = (value as any)['_items']
59
+ return Array.isArray(items) ? items : null
60
+ }
61
+
62
+ const EMPTY_SEED = { branches: [] } as unknown as Seed
63
+
64
+ export function resolveVarAccess(
65
+ parsed: VarAccessParsed,
66
+ variables: Record<string, unknown>,
67
+ onMissing?: (field: string) => void,
68
+ ): unknown {
69
+ if (!(parsed.name in variables)) {
70
+ onMissing?.(parsed.name)
71
+ return undefined
72
+ }
73
+
74
+ let current: unknown = variables[parsed.name]
75
+ const { steps, conditions } = parsed
76
+
77
+ for (let i = 0; i < steps.length; i++) {
78
+ const step = steps[i]
79
+
80
+ // ── array selector ─────────────────────────────────────────────────────
81
+ if (step.type === 'array') {
82
+ const items = getItems(current) ?? (Array.isArray(current) ? current as Array<Record<string, unknown>> : null)
83
+ if (!items) return undefined
84
+ const idSet = new Set(step.ids)
85
+ const subset = items.filter((r) => idSet.has(String(r['id'] ?? '')))
86
+ // Apply conditions to the subset before building the collection
87
+ const filtered = filterItems(subset, conditions)
88
+ current = materializeCollection(EMPTY_SEED, filtered, null)
89
+ // Conditions consumed no post-filter needed
90
+ continue
91
+ }
92
+
93
+ // ── nav: firstone / lastone ────────────────────────────────────────────
94
+ if (step.type === 'nav') {
95
+ // Nav moves us from a collection to a single record.
96
+ // Conditions are a guard on that record, NOT a pre-filter on _items.
97
+ const record = (current as any)?.[step.nav] as Record<string, unknown> | null | undefined
98
+ if (!record) { current = null; continue }
99
+ // Apply conditions to the single record (post-filter)
100
+ if (conditions.length > 0 && !applyConditions(record, conditions)) return undefined
101
+ current = record
102
+ continue
103
+ }
104
+
105
+ // ── aggregate ──────────────────────────────────────────────────────────
106
+ if (step.type === 'agg') {
107
+ // Apply conditions to _items before aggregating
108
+ const items = getItems(current)
109
+ if (items !== null) {
110
+ const filtered = filterItems(items, conditions)
111
+ current = materializeCollection(EMPTY_SEED, filtered, null)
112
+ }
113
+ if (step.op === 'count') {
114
+ current = (current as any)?.['count']
115
+ } else if (step.field) {
116
+ const sub = (current as any)?.[step.op]
117
+ current = typeof sub === 'object' && sub !== null ? sub[step.field] : sub
118
+ } else {
119
+ current = (current as any)?.[step.op]
120
+ }
121
+ // Conditions consumed by filtering _items
122
+ return current
123
+ }
124
+
125
+ // ── field access ───────────────────────────────────────────────────────
126
+ if (step.type === 'field') {
127
+ current = (current as any)?.[step.name]
128
+ }
129
+ }
130
+
131
+ // Post-filter on single record (conditions not consumed by agg or array)
132
+ if (conditions.length > 0 && current !== null && current !== undefined) {
133
+ if (typeof current === 'object' && !Array.isArray(current)) {
134
+ if (!applyConditions(current as Record<string, unknown>, conditions)) return undefined
135
+ }
136
+ // For scalar values: conditions cannot be checked — pass through
137
+ }
138
+
139
+ return current
140
+ }
@@ -1,83 +1,87 @@
1
- import type { WhenNode, WhenPredicate, WhenGroup, WhenOperand } from '@beechcms/core'
2
- import type { ResolvedContext } from './context-resolver'
3
- import { parseTemplateKey } from './template-grammar'
4
-
5
- export function evaluateWhen(
6
- node: WhenNode | null,
7
- context: ResolvedContext,
8
- ): boolean {
9
- if (!node) return true
10
- if (node.kind === 'predicate') return evalPredicate(node, context)
11
- return evalGroup(node, context)
12
- }
13
-
14
- function evalGroup(group: WhenGroup, context: ResolvedContext): boolean {
15
- if (group.children.length === 0) {
16
- const empty = group.op === 'AND'
17
- return group.negate ? !empty : empty
18
- }
19
-
20
- for (const child of group.children) {
21
- const r = evaluateWhen(child, context)
22
- if (group.op === 'AND' && !r) return group.negate ? true : false
23
- if (group.op === 'OR' && r) return group.negate ? false : true
24
- }
25
-
26
- const combined = group.op === 'AND'
27
- return group.negate ? !combined : combined
28
- }
29
-
30
- function resolveOperand(operand: WhenOperand, context: ResolvedContext): unknown {
31
- if (operand.kind === 'literal') return operand.value
32
-
33
- // this.field (dot notation from UI) → this:field (colon, parsed as scoped key)
34
- const key = operand.key.startsWith('this.') ? 'this:' + operand.key.slice(5) : operand.key
35
- const parsed = parseTemplateKey(key)
36
- if (!parsed) return null
37
-
38
- const val = context.lookup(parsed)
39
- return val ?? null
40
- }
41
-
42
- function evalPredicate(pred: WhenPredicate, context: ResolvedContext): boolean {
43
- const left = resolveOperand(pred.left, context)
44
- const right = pred.right !== undefined ? resolveOperand(pred.right, context) : undefined
45
- return compare(pred.op, left, right)
46
- }
47
-
48
- function compare(op: string, left: unknown, right: unknown): boolean {
49
- switch (op) {
50
- case 'isempty': return left == null || left === ''
51
- case 'isnotempty': return left != null && left !== ''
52
-
53
- case 'eq': return coerceEqual(left, right)
54
- case 'neq': return !coerceEqual(left, right)
55
-
56
- case 'gt': return Number(left) > Number(right)
57
- case 'gte': return Number(left) >= Number(right)
58
- case 'lt': return Number(left) < Number(right)
59
- case 'lte': return Number(left) <= Number(right)
60
-
61
- case 'contains': return typeof left === 'string' && typeof right === 'string' && left.includes(right)
62
- case 'startswith': return typeof left === 'string' && typeof right === 'string' && left.startsWith(right)
63
- case 'endswith': return typeof left === 'string' && typeof right === 'string' && left.endsWith(right)
64
-
65
- case 'in': return Array.isArray(right) && right.some((r) => coerceEqual(left, r))
66
- case 'notin': return !Array.isArray(right) || !right.some((r) => coerceEqual(left, r))
67
-
68
- case 'matches': {
69
- if (typeof left !== 'string' || typeof right !== 'string') return false
70
- try { return new RegExp(right).test(left) } catch { return false }
71
- }
72
-
73
- default: return false
74
- }
75
- }
76
-
77
- function coerceEqual(a: unknown, b: unknown): boolean {
78
- if (a === null && b === null) return true
79
- if (a === null || b === null) return false
80
- if (typeof a === 'number' && typeof b === 'string' && !Number.isNaN(Number(b))) return a === Number(b)
81
- if (typeof b === 'number' && typeof a === 'string' && !Number.isNaN(Number(a))) return Number(a) === b
82
- return a === b
83
- }
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 { WhenNode, WhenPredicate, WhenGroup, WhenOperand } from '@beechcms/core'
6
+ import type { ResolvedContext } from './context-resolver'
7
+ import { parseTemplateKey } from './template-grammar'
8
+
9
+ export function evaluateWhen(
10
+ node: WhenNode | null,
11
+ context: ResolvedContext,
12
+ ): boolean {
13
+ if (!node) return true
14
+ if (node.kind === 'predicate') return evalPredicate(node, context)
15
+ return evalGroup(node, context)
16
+ }
17
+
18
+ function evalGroup(group: WhenGroup, context: ResolvedContext): boolean {
19
+ if (group.children.length === 0) {
20
+ const empty = group.op === 'AND'
21
+ return group.negate ? !empty : empty
22
+ }
23
+
24
+ for (const child of group.children) {
25
+ const r = evaluateWhen(child, context)
26
+ if (group.op === 'AND' && !r) return group.negate ? true : false
27
+ if (group.op === 'OR' && r) return group.negate ? false : true
28
+ }
29
+
30
+ const combined = group.op === 'AND'
31
+ return group.negate ? !combined : combined
32
+ }
33
+
34
+ function resolveOperand(operand: WhenOperand, context: ResolvedContext): unknown {
35
+ if (operand.kind === 'literal') return operand.value
36
+
37
+ // this.field (dot notation from UI) → this:field (colon, parsed as scoped key)
38
+ const key = operand.key.startsWith('this.') ? 'this:' + operand.key.slice(5) : operand.key
39
+ const parsed = parseTemplateKey(key)
40
+ if (!parsed) return null
41
+
42
+ const val = context.lookup(parsed)
43
+ return val ?? null
44
+ }
45
+
46
+ function evalPredicate(pred: WhenPredicate, context: ResolvedContext): boolean {
47
+ const left = resolveOperand(pred.left, context)
48
+ const right = pred.right !== undefined ? resolveOperand(pred.right, context) : undefined
49
+ return compare(pred.op, left, right)
50
+ }
51
+
52
+ function compare(op: string, left: unknown, right: unknown): boolean {
53
+ switch (op) {
54
+ case 'isempty': return left == null || left === ''
55
+ case 'isnotempty': return left != null && left !== ''
56
+
57
+ case 'eq': return coerceEqual(left, right)
58
+ case 'neq': return !coerceEqual(left, right)
59
+
60
+ case 'gt': return Number(left) > Number(right)
61
+ case 'gte': return Number(left) >= Number(right)
62
+ case 'lt': return Number(left) < Number(right)
63
+ case 'lte': return Number(left) <= Number(right)
64
+
65
+ case 'contains': return typeof left === 'string' && typeof right === 'string' && left.includes(right)
66
+ case 'startswith': return typeof left === 'string' && typeof right === 'string' && left.startsWith(right)
67
+ case 'endswith': return typeof left === 'string' && typeof right === 'string' && left.endsWith(right)
68
+
69
+ case 'in': return Array.isArray(right) && right.some((r) => coerceEqual(left, r))
70
+ case 'notin': return !Array.isArray(right) || !right.some((r) => coerceEqual(left, r))
71
+
72
+ case 'matches': {
73
+ if (typeof left !== 'string' || typeof right !== 'string') return false
74
+ try { return new RegExp(right).test(left) } catch { return false }
75
+ }
76
+
77
+ default: return false
78
+ }
79
+ }
80
+
81
+ function coerceEqual(a: unknown, b: unknown): boolean {
82
+ if (a === null && b === null) return true
83
+ if (a === null || b === null) return false
84
+ if (typeof a === 'number' && typeof b === 'string' && !Number.isNaN(Number(b))) return a === Number(b)
85
+ if (typeof b === 'number' && typeof a === 'string' && !Number.isNaN(Number(a))) return Number(a) === b
86
+ return a === b
87
+ }
@@ -1,53 +1,57 @@
1
- import type { WhenNode, WhenPredicate, TriggerCondition, FilterGroup, Seed } from '@beechcms/core'
2
- import { conditionToFilterGroup } from './filter-translation'
3
-
4
- const PUSHDOWN_OPS = new Set<string>(['eq', 'neq', 'contains', 'gt', 'lt', 'isempty', 'isnotempty'])
5
-
6
- export function extractPushdownFilters(
7
- node: WhenNode | null,
8
- seed: Seed,
9
- ): FilterGroup[] {
10
- if (!node) return []
11
-
12
- // Single predicate at root
13
- if (node.kind === 'predicate') {
14
- const f = tryPushdown(node, seed)
15
- return f ? [f] : []
16
- }
17
-
18
- // Only extract direct predicate children of the outermost AND group (non-negated)
19
- if (node.kind === 'group' && node.op === 'AND' && !node.negate) {
20
- return node.children.flatMap((child) => {
21
- if (child.kind !== 'predicate') return []
22
- const f = tryPushdown(child, seed)
23
- return f ? [f] : []
24
- })
25
- }
26
-
27
- return []
28
- }
29
-
30
- function tryPushdown(pred: WhenPredicate, seed: Seed): FilterGroup | null {
31
- if (pred.left.kind !== 'ref') return null
32
-
33
- const key = pred.left.key
34
- if (!key.startsWith('this.')) return null
35
- const field = key.slice(5)
36
- if (!field) return null
37
-
38
- if (!PUSHDOWN_OPS.has(pred.op)) return null
39
-
40
- if (pred.op === 'isempty' || pred.op === 'isnotempty') {
41
- return conditionToFilterGroup(
42
- { field, op: pred.op as TriggerCondition['op'], value: null },
43
- seed,
44
- )
45
- }
46
-
47
- if (!pred.right || pred.right.kind !== 'literal') return null
48
-
49
- return conditionToFilterGroup(
50
- { field, op: pred.op as TriggerCondition['op'], value: pred.right.value },
51
- seed,
52
- )
53
- }
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 { WhenNode, WhenPredicate, TriggerCondition, FilterGroup, Seed } from '@beechcms/core'
6
+ import { conditionToFilterGroup } from './filter-translation'
7
+
8
+ const PUSHDOWN_OPS = new Set<string>(['eq', 'neq', 'contains', 'gt', 'lt', 'isempty', 'isnotempty'])
9
+
10
+ export function extractPushdownFilters(
11
+ node: WhenNode | null,
12
+ seed: Seed,
13
+ ): FilterGroup[] {
14
+ if (!node) return []
15
+
16
+ // Single predicate at root
17
+ if (node.kind === 'predicate') {
18
+ const f = tryPushdown(node, seed)
19
+ return f ? [f] : []
20
+ }
21
+
22
+ // Only extract direct predicate children of the outermost AND group (non-negated)
23
+ if (node.kind === 'group' && node.op === 'AND' && !node.negate) {
24
+ return node.children.flatMap((child) => {
25
+ if (child.kind !== 'predicate') return []
26
+ const f = tryPushdown(child, seed)
27
+ return f ? [f] : []
28
+ })
29
+ }
30
+
31
+ return []
32
+ }
33
+
34
+ function tryPushdown(pred: WhenPredicate, seed: Seed): FilterGroup | null {
35
+ if (pred.left.kind !== 'ref') return null
36
+
37
+ const key = pred.left.key
38
+ if (!key.startsWith('this.')) return null
39
+ const field = key.slice(5)
40
+ if (!field) return null
41
+
42
+ if (!PUSHDOWN_OPS.has(pred.op)) return null
43
+
44
+ if (pred.op === 'isempty' || pred.op === 'isnotempty') {
45
+ return conditionToFilterGroup(
46
+ { field, op: pred.op as TriggerCondition['op'], value: null },
47
+ seed,
48
+ )
49
+ }
50
+
51
+ if (!pred.right || pred.right.kind !== 'literal') return null
52
+
53
+ return conditionToFilterGroup(
54
+ { field, op: pred.op as TriggerCondition['op'], value: pred.right.value },
55
+ seed,
56
+ )
57
+ }