@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,148 +1,152 @@
1
- import type { Automation } from '@beechcms/core'
2
- import type { AutomationContextSelector, ParsedKey } from './template-grammar'
3
- import { resolvePath } from './automation-runner.utils'
4
-
5
- export interface ResolvedContext {
6
- lookup(key: ParsedKey, onMissing?: (field: string) => void): unknown
7
- triggerEntry: Record<string, unknown> | null
8
- }
9
-
10
- const MAX_PLUCK = 100
11
- const AGGREGATE_OPS = new Set(['count', 'sum', 'avg', 'min', 'max', 'pluck'])
12
-
13
- function applyAggregate(
14
- op: string,
15
- field: string | null,
16
- rows: Array<Record<string, unknown>>,
17
- onMissing?: (f: string) => void,
18
- ): unknown {
19
- switch (op) {
20
- case 'count':
21
- return rows.length
22
-
23
- case 'sum': {
24
- if (!field) { if (onMissing) onMissing('sum:field'); return 0 }
25
- return rows.reduce((acc, r) => {
26
- const v = Number(r[field])
27
- if (Number.isNaN(v)) { if (onMissing) onMissing(`sum:${field}:NaN`); return acc }
28
- return acc + v
29
- }, 0)
30
- }
31
-
32
- case 'avg': {
33
- if (!field || rows.length === 0) { if (onMissing && !field) onMissing('avg:field'); return 0 }
34
- const total = rows.reduce((acc, r) => {
35
- const v = Number(r[field])
36
- if (Number.isNaN(v)) { if (onMissing) onMissing(`avg:${field}:NaN`); return acc }
37
- return acc + v
38
- }, 0)
39
- return rows.length > 0 ? total / rows.length : 0
40
- }
41
-
42
- case 'min': {
43
- if (!field || rows.length === 0) { if (onMissing && !field) onMissing('min:field'); return null }
44
- return rows.reduce<number | null>((acc, r) => {
45
- const v = Number(r[field])
46
- if (Number.isNaN(v)) return acc
47
- return acc === null || v < acc ? v : acc
48
- }, null)
49
- }
50
-
51
- case 'max': {
52
- if (!field || rows.length === 0) { if (onMissing && !field) onMissing('max:field'); return null }
53
- return rows.reduce<number | null>((acc, r) => {
54
- const v = Number(r[field])
55
- if (Number.isNaN(v)) return acc
56
- return acc === null || v > acc ? v : acc
57
- }, null)
58
- }
59
-
60
- case 'pluck': {
61
- if (!field) { if (onMissing) onMissing('pluck:field'); return '' }
62
- const values = rows.slice(0, MAX_PLUCK).map((r) => String(r[field] ?? ''))
63
- const truncated = rows.length > MAX_PLUCK
64
- return values.join(', ') + (truncated ? ' …' : '')
65
- }
66
-
67
- default:
68
- return undefined
69
- }
70
- }
71
-
72
- export async function resolveAutomationContext(
73
- _automation: Automation,
74
- triggerEntry: Record<string, unknown> | null,
75
- batchEntries: Array<Record<string, unknown>>,
76
- ): Promise<ResolvedContext> {
77
- function lookup(parsed: ParsedKey, onMissing?: (field: string) => void): unknown {
78
- if (parsed.kind === 'simple') {
79
- if (parsed.path.startsWith('this.')) {
80
- const field = parsed.path.slice(5)
81
- const val = resolvePath(triggerEntry ?? {}, field)
82
- if (val === undefined && onMissing) onMissing(parsed.path)
83
- return val
84
- }
85
- const val = resolvePath(triggerEntry ?? {}, parsed.path)
86
- if (val === undefined && onMissing) onMissing(parsed.path)
87
- return val
88
- }
89
-
90
- if (parsed.kind !== 'scoped') {
91
- if (onMissing) onMissing(parsed.kind === 'var_access' ? parsed.name : 'unknown')
92
- return undefined
93
- }
94
-
95
- const { scope, selector: _selector, op, field } = parsed
96
-
97
- if (scope === 'this') {
98
- if (op !== 'field' || !field) { if (onMissing) onMissing(`this.${field}`); return undefined }
99
- const val = resolvePath(triggerEntry ?? {}, field)
100
- if (val === undefined && onMissing) onMissing(`this.${field}`)
101
- return val
102
- }
103
-
104
- if (scope === 'batch') {
105
- if (op === 'field') {
106
- if (!field) { if (onMissing) onMissing('batch.field'); return undefined }
107
- const val = resolvePath(batchEntries[0] ?? {}, field)
108
- if (val === undefined && onMissing) onMissing(`batch.${field}`)
109
- return val
110
- }
111
- return applyAggregate(op, field, batchEntries, onMissing)
112
- }
113
-
114
- if (onMissing) onMissing(scope)
115
- return undefined
116
- }
117
-
118
- return { lookup, triggerEntry }
119
- }
120
-
121
- export function deriveEntryContext(
122
- base: ResolvedContext,
123
- entry: Record<string, unknown>,
124
- ): ResolvedContext {
125
- return {
126
- lookup(parsed: ParsedKey, onMissing?: (field: string) => void): unknown {
127
- if (parsed.kind === 'simple') {
128
- if (parsed.path.startsWith('this.')) {
129
- const field = parsed.path.slice(5)
130
- const val = resolvePath(entry, field)
131
- if (val === undefined && onMissing) onMissing(parsed.path)
132
- return val
133
- }
134
- const val = resolvePath(entry, parsed.path)
135
- if (val === undefined && onMissing) onMissing(parsed.path)
136
- return val
137
- }
138
- if (parsed.kind === 'scoped' && parsed.scope === 'this') {
139
- if (parsed.op !== 'field' || !parsed.field) { if (onMissing) onMissing('this.field'); return undefined }
140
- const val = resolvePath(entry, parsed.field)
141
- if (val === undefined && onMissing) onMissing(`this.${parsed.field}`)
142
- return val
143
- }
144
- return base.lookup(parsed, onMissing)
145
- },
146
- triggerEntry: entry,
147
- }
148
- }
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 { Automation } from '@beechcms/core'
6
+ import type { AutomationContextSelector, ParsedKey } from './template-grammar'
7
+ import { resolvePath } from './automation-runner.utils'
8
+
9
+ export interface ResolvedContext {
10
+ lookup(key: ParsedKey, onMissing?: (field: string) => void): unknown
11
+ triggerEntry: Record<string, unknown> | null
12
+ }
13
+
14
+ const MAX_PLUCK = 100
15
+ const AGGREGATE_OPS = new Set(['count', 'sum', 'avg', 'min', 'max', 'pluck'])
16
+
17
+ function applyAggregate(
18
+ op: string,
19
+ field: string | null,
20
+ rows: Array<Record<string, unknown>>,
21
+ onMissing?: (f: string) => void,
22
+ ): unknown {
23
+ switch (op) {
24
+ case 'count':
25
+ return rows.length
26
+
27
+ case 'sum': {
28
+ if (!field) { if (onMissing) onMissing('sum:field'); return 0 }
29
+ return rows.reduce((acc, r) => {
30
+ const v = Number(r[field])
31
+ if (Number.isNaN(v)) { if (onMissing) onMissing(`sum:${field}:NaN`); return acc }
32
+ return acc + v
33
+ }, 0)
34
+ }
35
+
36
+ case 'avg': {
37
+ if (!field || rows.length === 0) { if (onMissing && !field) onMissing('avg:field'); return 0 }
38
+ const total = rows.reduce((acc, r) => {
39
+ const v = Number(r[field])
40
+ if (Number.isNaN(v)) { if (onMissing) onMissing(`avg:${field}:NaN`); return acc }
41
+ return acc + v
42
+ }, 0)
43
+ return rows.length > 0 ? total / rows.length : 0
44
+ }
45
+
46
+ case 'min': {
47
+ if (!field || rows.length === 0) { if (onMissing && !field) onMissing('min:field'); return null }
48
+ return rows.reduce<number | null>((acc, r) => {
49
+ const v = Number(r[field])
50
+ if (Number.isNaN(v)) return acc
51
+ return acc === null || v < acc ? v : acc
52
+ }, null)
53
+ }
54
+
55
+ case 'max': {
56
+ if (!field || rows.length === 0) { if (onMissing && !field) onMissing('max:field'); return null }
57
+ return rows.reduce<number | null>((acc, r) => {
58
+ const v = Number(r[field])
59
+ if (Number.isNaN(v)) return acc
60
+ return acc === null || v > acc ? v : acc
61
+ }, null)
62
+ }
63
+
64
+ case 'pluck': {
65
+ if (!field) { if (onMissing) onMissing('pluck:field'); return '' }
66
+ const values = rows.slice(0, MAX_PLUCK).map((r) => String(r[field] ?? ''))
67
+ const truncated = rows.length > MAX_PLUCK
68
+ return values.join(', ') + (truncated ? ' …' : '')
69
+ }
70
+
71
+ default:
72
+ return undefined
73
+ }
74
+ }
75
+
76
+ export async function resolveAutomationContext(
77
+ _automation: Automation,
78
+ triggerEntry: Record<string, unknown> | null,
79
+ batchEntries: Array<Record<string, unknown>>,
80
+ ): Promise<ResolvedContext> {
81
+ function lookup(parsed: ParsedKey, onMissing?: (field: string) => void): unknown {
82
+ if (parsed.kind === 'simple') {
83
+ if (parsed.path.startsWith('this.')) {
84
+ const field = parsed.path.slice(5)
85
+ const val = resolvePath(triggerEntry ?? {}, field)
86
+ if (val === undefined && onMissing) onMissing(parsed.path)
87
+ return val
88
+ }
89
+ const val = resolvePath(triggerEntry ?? {}, parsed.path)
90
+ if (val === undefined && onMissing) onMissing(parsed.path)
91
+ return val
92
+ }
93
+
94
+ if (parsed.kind !== 'scoped') {
95
+ if (onMissing) onMissing(parsed.kind === 'var_access' ? parsed.name : 'unknown')
96
+ return undefined
97
+ }
98
+
99
+ const { scope, selector: _selector, op, field } = parsed
100
+
101
+ if (scope === 'this') {
102
+ if (op !== 'field' || !field) { if (onMissing) onMissing(`this.${field}`); return undefined }
103
+ const val = resolvePath(triggerEntry ?? {}, field)
104
+ if (val === undefined && onMissing) onMissing(`this.${field}`)
105
+ return val
106
+ }
107
+
108
+ if (scope === 'batch') {
109
+ if (op === 'field') {
110
+ if (!field) { if (onMissing) onMissing('batch.field'); return undefined }
111
+ const val = resolvePath(batchEntries[0] ?? {}, field)
112
+ if (val === undefined && onMissing) onMissing(`batch.${field}`)
113
+ return val
114
+ }
115
+ return applyAggregate(op, field, batchEntries, onMissing)
116
+ }
117
+
118
+ if (onMissing) onMissing(scope)
119
+ return undefined
120
+ }
121
+
122
+ return { lookup, triggerEntry }
123
+ }
124
+
125
+ export function deriveEntryContext(
126
+ base: ResolvedContext,
127
+ entry: Record<string, unknown>,
128
+ ): ResolvedContext {
129
+ return {
130
+ lookup(parsed: ParsedKey, onMissing?: (field: string) => void): unknown {
131
+ if (parsed.kind === 'simple') {
132
+ if (parsed.path.startsWith('this.')) {
133
+ const field = parsed.path.slice(5)
134
+ const val = resolvePath(entry, field)
135
+ if (val === undefined && onMissing) onMissing(parsed.path)
136
+ return val
137
+ }
138
+ const val = resolvePath(entry, parsed.path)
139
+ if (val === undefined && onMissing) onMissing(parsed.path)
140
+ return val
141
+ }
142
+ if (parsed.kind === 'scoped' && parsed.scope === 'this') {
143
+ if (parsed.op !== 'field' || !parsed.field) { if (onMissing) onMissing('this.field'); return undefined }
144
+ const val = resolvePath(entry, parsed.field)
145
+ if (val === undefined && onMissing) onMissing(`this.${parsed.field}`)
146
+ return val
147
+ }
148
+ return base.lookup(parsed, onMissing)
149
+ },
150
+ triggerEntry: entry,
151
+ }
152
+ }
@@ -1,136 +1,140 @@
1
- import type {
2
- Automation,
3
- ContentRepository,
4
- FilterGroup,
5
- IAutomationRepository,
6
- IIdGenerator,
7
- Seed,
8
- } from '@beechcms/core'
9
- import { cronMatches } from './cron-runner.utils'
10
- import { executeAction } from './action-executors'
11
- import { resolveAutomationContext, deriveEntryContext } from './context-resolver'
12
- import { extractPushdownFilters } from './when-pushdown'
13
- import { evaluateWhen } from './when-evaluator'
14
-
15
- export interface CronRunnerDeps {
16
- automationRepository: IAutomationRepository
17
- contentRepository: ContentRepository
18
- getSeed: (slug: string) => Seed | null
19
- env: Record<string, string | undefined>
20
- idGenerator: IIdGenerator
21
- }
22
-
23
- // These actions mutate individual entries and must run once per entry.
24
- // All other actions (send_mail, webhook) run once per automation with a batch context.
25
- const PER_ENTRY_ACTIONS = new Set(['edit_field', 'create_entry'])
26
-
27
- export async function runCronAutomations(
28
- deps: CronRunnerDeps,
29
- scheduledTime: number,
30
- ): Promise<void> {
31
- console.log(`[cron] Starting runCronAutomations at scheduledTime: ${new Date(scheduledTime).toISOString()}`)
32
-
33
- let automations: Automation[]
34
- try {
35
- automations = await deps.automationRepository.findActive('*', 'cron')
36
- } catch (err) {
37
- console.error('[cron] Failed to fetch automations — is migration 0029_automations.sql applied?', err)
38
- return
39
- }
40
-
41
- console.log(`[cron] Found ${automations.length} active cron automation(s)`)
42
-
43
- for (const automation of automations) {
44
- const cronTrigger = automation.triggers.find((t) => t.event === 'cron')
45
- if (!cronMatches(cronTrigger?.cron ?? null, scheduledTime)) {
46
- console.log(`[cron] Skipping "${automation.name}": cron expression "${cronTrigger?.cron}" does not match.`)
47
- continue
48
- }
49
-
50
- const seed = deps.getSeed(automation.seed_slug)
51
- if (!seed) {
52
- console.warn('[cron] unknown seed', { automationId: automation.id, seedSlug: automation.seed_slug })
53
- continue
54
- }
55
-
56
- let entries: Array<Record<string, unknown>> = []
57
- try {
58
- entries = await fetchMatchingEntries(deps.contentRepository, seed, automation)
59
- console.log(`[cron] Fetched ${entries.length} matching entries for automation "${automation.name}"`)
60
- } catch (err) {
61
- console.error('[cron] fetch entries failed', { automationId: automation.id, err })
62
- continue
63
- }
64
-
65
- if (entries.length === 0) continue
66
-
67
- // Build the base ResolvedContext once per automation (shared seed-query cache).
68
- // triggerEntry = first entry; batchEntries = full SQL-filtered list.
69
- const batchResolved = await resolveAutomationContext(
70
- automation,
71
- entries[0] ?? null,
72
- entries,
73
- )
74
-
75
- const variables: Record<string, unknown> = {}
76
- const baseCtx = {
77
- env: deps.env,
78
- repository: deps.contentRepository,
79
- getSeed: deps.getSeed,
80
- seed,
81
- idGenerator: deps.idGenerator,
82
- variables,
83
- }
84
-
85
- for (const action of automation.actions) {
86
- if (PER_ENTRY_ACTIONS.has(action.type)) {
87
- for (const entry of entries) {
88
- // Per-entry: derive a lightweight context whose `this` is the current entry.
89
- // Seed-query cache is reused from batchResolved.
90
- const entryResolved = deriveEntryContext(batchResolved, entry)
91
- // In-memory per-entry condition check (Task 13)
92
- if (!evaluateWhen(automation.trigger_conditions, entryResolved)) continue
93
- try {
94
- await executeAction(action, { ...baseCtx, entry, context: entryResolved })
95
- } catch (err) {
96
- console.error('[cron] entry action failed', {
97
- automationId: automation.id,
98
- entryId: entry['id'],
99
- actionType: action.type,
100
- err,
101
- })
102
- }
103
- }
104
- } else {
105
- // Batch actions run once per automation with the shared batch context.
106
- try {
107
- await executeAction(action, { ...baseCtx, entry: entries[0] ?? {}, context: batchResolved })
108
- } catch (err) {
109
- console.error('[cron] batch action failed', {
110
- automationId: automation.id,
111
- actionType: action.type,
112
- err,
113
- })
114
- }
115
- }
116
- }
117
- }
118
- }
119
-
120
- async function fetchMatchingEntries(
121
- repository: ContentRepository,
122
- seed: Seed,
123
- automation: Automation,
124
- ): Promise<Array<Record<string, unknown>>> {
125
- // Task 12: push down only safe predicates to SQL (this.<field> = literal, AND group only).
126
- // OR branches, cross-seed refs, and gte/lte/etc. ops are evaluated in-memory by evaluateWhen().
127
- const filters: FilterGroup[] = automation.trigger_conditions
128
- ? extractPushdownFilters(automation.trigger_conditions, seed)
129
- : []
130
- const result = await repository.findMany(seed, {
131
- filters,
132
- status: null,
133
- pagination: { limit: 1000, offset: 0 },
134
- })
135
- return result.items
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 {
6
+ Automation,
7
+ ContentRepository,
8
+ FilterGroup,
9
+ IAutomationRepository,
10
+ IIdGenerator,
11
+ Seed,
12
+ } from '@beechcms/core'
13
+ import { cronMatches } from './cron-runner.utils'
14
+ import { executeAction } from './action-executors'
15
+ import { resolveAutomationContext, deriveEntryContext } from './context-resolver'
16
+ import { extractPushdownFilters } from './when-pushdown'
17
+ import { evaluateWhen } from './when-evaluator'
18
+
19
+ export interface CronRunnerDeps {
20
+ automationRepository: IAutomationRepository
21
+ contentRepository: ContentRepository
22
+ getSeed: (slug: string) => Seed | null
23
+ env: Record<string, string | undefined>
24
+ idGenerator: IIdGenerator
25
+ }
26
+
27
+ // These actions mutate individual entries and must run once per entry.
28
+ // All other actions (send_mail, webhook) run once per automation with a batch context.
29
+ const PER_ENTRY_ACTIONS = new Set(['edit_field', 'create_entry'])
30
+
31
+ export async function runCronAutomations(
32
+ deps: CronRunnerDeps,
33
+ scheduledTime: number,
34
+ ): Promise<void> {
35
+ console.log(`[cron] Starting runCronAutomations at scheduledTime: ${new Date(scheduledTime).toISOString()}`)
36
+
37
+ let automations: Automation[]
38
+ try {
39
+ automations = await deps.automationRepository.findActive('*', 'cron')
40
+ } catch (err) {
41
+ console.error('[cron] Failed to fetch automations — is migration 0029_automations.sql applied?', err)
42
+ return
43
+ }
44
+
45
+ console.log(`[cron] Found ${automations.length} active cron automation(s)`)
46
+
47
+ for (const automation of automations) {
48
+ const cronTrigger = automation.triggers.find((t) => t.event === 'cron')
49
+ if (!cronMatches(cronTrigger?.cron ?? null, scheduledTime)) {
50
+ console.log(`[cron] Skipping "${automation.name}": cron expression "${cronTrigger?.cron}" does not match.`)
51
+ continue
52
+ }
53
+
54
+ const seed = deps.getSeed(automation.seed_slug)
55
+ if (!seed) {
56
+ console.warn('[cron] unknown seed', { automationId: automation.id, seedSlug: automation.seed_slug })
57
+ continue
58
+ }
59
+
60
+ let entries: Array<Record<string, unknown>> = []
61
+ try {
62
+ entries = await fetchMatchingEntries(deps.contentRepository, seed, automation)
63
+ console.log(`[cron] Fetched ${entries.length} matching entries for automation "${automation.name}"`)
64
+ } catch (err) {
65
+ console.error('[cron] fetch entries failed', { automationId: automation.id, err })
66
+ continue
67
+ }
68
+
69
+ if (entries.length === 0) continue
70
+
71
+ // Build the base ResolvedContext once per automation (shared seed-query cache).
72
+ // triggerEntry = first entry; batchEntries = full SQL-filtered list.
73
+ const batchResolved = await resolveAutomationContext(
74
+ automation,
75
+ entries[0] ?? null,
76
+ entries,
77
+ )
78
+
79
+ const variables: Record<string, unknown> = {}
80
+ const baseCtx = {
81
+ env: deps.env,
82
+ repository: deps.contentRepository,
83
+ getSeed: deps.getSeed,
84
+ seed,
85
+ idGenerator: deps.idGenerator,
86
+ variables,
87
+ }
88
+
89
+ for (const action of automation.actions) {
90
+ if (PER_ENTRY_ACTIONS.has(action.type)) {
91
+ for (const entry of entries) {
92
+ // Per-entry: derive a lightweight context whose `this` is the current entry.
93
+ // Seed-query cache is reused from batchResolved.
94
+ const entryResolved = deriveEntryContext(batchResolved, entry)
95
+ // In-memory per-entry condition check (Task 13)
96
+ if (!evaluateWhen(automation.trigger_conditions, entryResolved)) continue
97
+ try {
98
+ await executeAction(action, { ...baseCtx, entry, context: entryResolved })
99
+ } catch (err) {
100
+ console.error('[cron] entry action failed', {
101
+ automationId: automation.id,
102
+ entryId: entry['id'],
103
+ actionType: action.type,
104
+ err,
105
+ })
106
+ }
107
+ }
108
+ } else {
109
+ // Batch actions run once per automation with the shared batch context.
110
+ try {
111
+ await executeAction(action, { ...baseCtx, entry: entries[0] ?? {}, context: batchResolved })
112
+ } catch (err) {
113
+ console.error('[cron] batch action failed', {
114
+ automationId: automation.id,
115
+ actionType: action.type,
116
+ err,
117
+ })
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ async function fetchMatchingEntries(
125
+ repository: ContentRepository,
126
+ seed: Seed,
127
+ automation: Automation,
128
+ ): Promise<Array<Record<string, unknown>>> {
129
+ // Task 12: push down only safe predicates to SQL (this.<field> = literal, AND group only).
130
+ // OR branches, cross-seed refs, and gte/lte/etc. ops are evaluated in-memory by evaluateWhen().
131
+ const filters: FilterGroup[] = automation.trigger_conditions
132
+ ? extractPushdownFilters(automation.trigger_conditions, seed)
133
+ : []
134
+ const result = await repository.findMany(seed, {
135
+ filters,
136
+ status: null,
137
+ pagination: { limit: 1000, offset: 0 },
138
+ })
139
+ return result.items
140
+ }