@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,241 +1,245 @@
1
- /**
2
- * Pure template-key parser for the automation template engine.
3
- *
4
- * Grammar (Sprint 6):
5
- * simple: {{title}}, {{author.name}}
6
- * scoped: {{<scope>:<selector>:<field>}}
7
- *
8
- * Grammar (Sprint 7):
9
- * var_access: {{varname.firstone.campo}}, {{varname.count.(status=paid)}}
10
- * {{varname.array[id1,id2].count}}
11
- *
12
- * Scopes: this | batch | <seedSlug> | <contextKey>
13
- * Selectors: lastone | firstone | all | byid(<id>) | where(<alias>=<value>)
14
- * Fields: branch alias, system column, or aggregate (count/sum/avg/min/max/pluck)
15
- *
16
- * Sugar:
17
- * {{batch:count}} → batch:all:count
18
- * {{<scope>:<field>}} → <scope>:lastone:<field> (default selector)
19
- *
20
- * TODO Sprint 8: extend ParsedKey to support WhenOperand refs once the
21
- * recursive when-group evaluator (Tasks 10-16) is implemented.
22
- */
23
-
24
- export { AUTOMATION_RESERVED_WORDS } from '@beechcms/core'
25
-
26
- // ---------------------------------------------------------------------------
27
- // Sprint 7 types: var_access parsed key
28
- // ---------------------------------------------------------------------------
29
-
30
- export type VarStep =
31
- | { type: 'field'; name: string }
32
- | { type: 'nav'; nav: 'firstone' | 'lastone' }
33
- | { type: 'agg'; op: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'pluck'; field?: string }
34
- | { type: 'array'; ids: string[] }
35
-
36
- export interface InlineCondition {
37
- column: string
38
- op: '=' | '!=' | '<' | '>' | '<=' | '>='
39
- value: string
40
- }
41
-
42
- // ---------------------------------------------------------------------------
43
-
44
- export type AutomationContextSelector =
45
- | { kind: 'lastone' }
46
- | { kind: 'firstone' }
47
- | { kind: 'all' }
48
- | { kind: 'byid'; id: string }
49
- | { kind: 'where'; alias: string; value: string }
50
-
51
- export type AggregateOp = 'count' | 'sum' | 'avg' | 'min' | 'max' | 'pluck'
52
-
53
- export type ParsedKey =
54
- | { kind: 'simple'; path: string }
55
- | {
56
- kind: 'scoped'
57
- scope: string
58
- selector: AutomationContextSelector
59
- op: 'field' | AggregateOp
60
- field: string | null
61
- }
62
- | {
63
- kind: 'var_access'
64
- name: string
65
- steps: VarStep[]
66
- conditions: InlineCondition[]
67
- }
68
-
69
- const SLUG_RE = /^[a-zA-Z0-9_-]+$/
70
- const AGGREGATE_OPS = new Set<string>(['count', 'sum', 'avg', 'min', 'max', 'pluck'])
71
- const NAV_OPS = new Set<string>(['firstone', 'lastone'])
72
- const INLINE_COND_RE = /^\(([\w]+)\s*(=|!=|<=|>=|<|>)\s*([^)]+)\)$/
73
-
74
- /**
75
- * Split `raw` on `:` but treat content inside `(...)` as atomic.
76
- */
77
- function splitKey(raw: string): string[] {
78
- const tokens: string[] = []
79
- let depth = 0
80
- let current = ''
81
-
82
- for (const ch of raw) {
83
- if (ch === '(') {
84
- depth++
85
- current += ch
86
- } else if (ch === ')') {
87
- depth--
88
- current += ch
89
- } else if (ch === ':' && depth === 0) {
90
- tokens.push(current)
91
- current = ''
92
- } else {
93
- current += ch
94
- }
95
- }
96
- if (current) tokens.push(current)
97
- return tokens
98
- }
99
-
100
- /**
101
- * Split `raw` on `.` treating `[...]` and `(...)` as atomic blocks.
102
- */
103
- function splitDot(raw: string): string[] {
104
- const tokens: string[] = []
105
- let depth = 0
106
- let current = ''
107
-
108
- for (const ch of raw) {
109
- if (ch === '[' || ch === '(') {
110
- depth++
111
- current += ch
112
- } else if (ch === ']' || ch === ')') {
113
- depth--
114
- current += ch
115
- } else if (ch === '.' && depth === 0) {
116
- tokens.push(current)
117
- current = ''
118
- } else {
119
- current += ch
120
- }
121
- }
122
- if (current) tokens.push(current)
123
- return tokens
124
- }
125
-
126
- function parseSelector(token: string): AutomationContextSelector | null {
127
- if (token === 'lastone') return { kind: 'lastone' }
128
- if (token === 'firstone') return { kind: 'firstone' }
129
- if (token === 'all') return { kind: 'all' }
130
-
131
- const byidMatch = token.match(/^byid\((.+)\)$/)
132
- if (byidMatch) return { kind: 'byid', id: byidMatch[1] }
133
-
134
- const whereMatch = token.match(/^where\(([^=]+)=(.+)\)$/)
135
- if (whereMatch) return { kind: 'where', alias: whereMatch[1], value: whereMatch[2] }
136
-
137
- return null
138
- }
139
-
140
- function isKnownScope(token: string): boolean {
141
- return token === 'this' || token === 'batch' || SLUG_RE.test(token)
142
- }
143
-
144
- export function parseTemplateKey(raw: string): ParsedKey | null {
145
- if (!raw) return null
146
-
147
- const trimmed = raw.trim()
148
-
149
- // Step 1: if contains `:` at depth 0 → existing scoped / sugar logic
150
- const colonTokens = splitKey(trimmed)
151
- if (colonTokens.length > 1) {
152
- const [scopeToken, ...rest] = colonTokens
153
- if (!isKnownScope(scopeToken)) return null
154
-
155
- if (rest.length === 1) {
156
- const fieldOrAggregate = rest[0]
157
- if (AGGREGATE_OPS.has(fieldOrAggregate)) {
158
- return { kind: 'scoped', scope: scopeToken, selector: { kind: 'all' }, op: fieldOrAggregate as AggregateOp, field: null }
159
- }
160
- return { kind: 'scoped', scope: scopeToken, selector: { kind: 'lastone' }, op: 'field', field: fieldOrAggregate }
161
- }
162
-
163
- const selectorToken = rest[0]
164
- const selector = parseSelector(selectorToken)
165
- if (!selector) return null
166
-
167
- const fieldTokens = rest.slice(1)
168
- if (fieldTokens.length === 0) return null
169
-
170
- const opToken = fieldTokens[0]
171
- if (AGGREGATE_OPS.has(opToken)) {
172
- if (selector.kind !== 'all') return null
173
- const subField = fieldTokens[1] ?? null
174
- return { kind: 'scoped', scope: scopeToken, selector, op: opToken as AggregateOp, field: subField }
175
- }
176
-
177
- return { kind: 'scoped', scope: scopeToken, selector, op: 'field', field: opToken }
178
- }
179
-
180
- // Step 2: tokenise on `.` at depth 0
181
- const dotTokens = splitDot(trimmed)
182
-
183
- // Step 3: if no token contains `[` or `(`, return simple (fast path)
184
- if (!dotTokens.some((t) => t.includes('[') || t.includes('('))) {
185
- return { kind: 'simple', path: trimmed }
186
- }
187
-
188
- // Step 4: build var_access
189
- const [nameToken, ...stepTokens] = dotTokens
190
- const steps: VarStep[] = []
191
- const conditions: InlineCondition[] = []
192
-
193
- let i = 0
194
- while (i < stepTokens.length) {
195
- const token = stepTokens[i]
196
-
197
- // Inline condition: (col op val)
198
- const condMatch = token.match(INLINE_COND_RE)
199
- if (condMatch) {
200
- conditions.push({ column: condMatch[1], op: condMatch[2] as InlineCondition['op'], value: condMatch[3].trim() })
201
- i++
202
- continue
203
- }
204
-
205
- // Array selector: array[id1,id2,...]
206
- const arrayMatch = token.match(/^array\[([^\]]*)\]$/)
207
- if (arrayMatch) {
208
- const ids = arrayMatch[1].split(',').map((s) => s.trim()).filter(Boolean)
209
- steps.push({ type: 'array', ids })
210
- i++
211
- continue
212
- }
213
-
214
- // Nav: firstone / lastone
215
- if (NAV_OPS.has(token)) {
216
- steps.push({ type: 'nav', nav: token as 'firstone' | 'lastone' })
217
- i++
218
- continue
219
- }
220
-
221
- // Aggregate: count | sum | avg | min | max | pluck
222
- if (AGGREGATE_OPS.has(token)) {
223
- const nextToken = stepTokens[i + 1]
224
- // Consume the next token as field if it's a plain identifier (no special chars)
225
- if (nextToken && !nextToken.includes('[') && !nextToken.includes('(') && !AGGREGATE_OPS.has(nextToken) && !NAV_OPS.has(nextToken)) {
226
- steps.push({ type: 'agg', op: token as VarStep & { type: 'agg' } extends { op: infer O } ? O : never, field: nextToken })
227
- i += 2
228
- } else {
229
- steps.push({ type: 'agg', op: token as any })
230
- i++
231
- }
232
- continue
233
- }
234
-
235
- // Plain field
236
- steps.push({ type: 'field', name: token })
237
- i++
238
- }
239
-
240
- return { kind: 'var_access', name: nameToken, steps, conditions }
241
- }
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
+ /**
6
+ * Pure template-key parser for the automation template engine.
7
+ *
8
+ * Grammar (Sprint 6):
9
+ * simple: {{title}}, {{author.name}}
10
+ * scoped: {{<scope>:<selector>:<field>}}
11
+ *
12
+ * Grammar (Sprint 7):
13
+ * var_access: {{varname.firstone.campo}}, {{varname.count.(status=paid)}}
14
+ * {{varname.array[id1,id2].count}}
15
+ *
16
+ * Scopes: this | batch | <seedSlug> | <contextKey>
17
+ * Selectors: lastone | firstone | all | byid(<id>) | where(<alias>=<value>)
18
+ * Fields: branch alias, system column, or aggregate (count/sum/avg/min/max/pluck)
19
+ *
20
+ * Sugar:
21
+ * {{batch:count}} → batch:all:count
22
+ * {{<scope>:<field>}} → <scope>:lastone:<field> (default selector)
23
+ *
24
+ * TODO Sprint 8: extend ParsedKey to support WhenOperand refs once the
25
+ * recursive when-group evaluator (Tasks 10-16) is implemented.
26
+ */
27
+
28
+ export { AUTOMATION_RESERVED_WORDS } from '@beechcms/core'
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Sprint 7 types: var_access parsed key
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export type VarStep =
35
+ | { type: 'field'; name: string }
36
+ | { type: 'nav'; nav: 'firstone' | 'lastone' }
37
+ | { type: 'agg'; op: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'pluck'; field?: string }
38
+ | { type: 'array'; ids: string[] }
39
+
40
+ export interface InlineCondition {
41
+ column: string
42
+ op: '=' | '!=' | '<' | '>' | '<=' | '>='
43
+ value: string
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export type AutomationContextSelector =
49
+ | { kind: 'lastone' }
50
+ | { kind: 'firstone' }
51
+ | { kind: 'all' }
52
+ | { kind: 'byid'; id: string }
53
+ | { kind: 'where'; alias: string; value: string }
54
+
55
+ export type AggregateOp = 'count' | 'sum' | 'avg' | 'min' | 'max' | 'pluck'
56
+
57
+ export type ParsedKey =
58
+ | { kind: 'simple'; path: string }
59
+ | {
60
+ kind: 'scoped'
61
+ scope: string
62
+ selector: AutomationContextSelector
63
+ op: 'field' | AggregateOp
64
+ field: string | null
65
+ }
66
+ | {
67
+ kind: 'var_access'
68
+ name: string
69
+ steps: VarStep[]
70
+ conditions: InlineCondition[]
71
+ }
72
+
73
+ const SLUG_RE = /^[a-zA-Z0-9_-]+$/
74
+ const AGGREGATE_OPS = new Set<string>(['count', 'sum', 'avg', 'min', 'max', 'pluck'])
75
+ const NAV_OPS = new Set<string>(['firstone', 'lastone'])
76
+ const INLINE_COND_RE = /^\(([\w]+)\s*(=|!=|<=|>=|<|>)\s*([^)]+)\)$/
77
+
78
+ /**
79
+ * Split `raw` on `:` but treat content inside `(...)` as atomic.
80
+ */
81
+ function splitKey(raw: string): string[] {
82
+ const tokens: string[] = []
83
+ let depth = 0
84
+ let current = ''
85
+
86
+ for (const ch of raw) {
87
+ if (ch === '(') {
88
+ depth++
89
+ current += ch
90
+ } else if (ch === ')') {
91
+ depth--
92
+ current += ch
93
+ } else if (ch === ':' && depth === 0) {
94
+ tokens.push(current)
95
+ current = ''
96
+ } else {
97
+ current += ch
98
+ }
99
+ }
100
+ if (current) tokens.push(current)
101
+ return tokens
102
+ }
103
+
104
+ /**
105
+ * Split `raw` on `.` treating `[...]` and `(...)` as atomic blocks.
106
+ */
107
+ function splitDot(raw: string): string[] {
108
+ const tokens: string[] = []
109
+ let depth = 0
110
+ let current = ''
111
+
112
+ for (const ch of raw) {
113
+ if (ch === '[' || ch === '(') {
114
+ depth++
115
+ current += ch
116
+ } else if (ch === ']' || ch === ')') {
117
+ depth--
118
+ current += ch
119
+ } else if (ch === '.' && depth === 0) {
120
+ tokens.push(current)
121
+ current = ''
122
+ } else {
123
+ current += ch
124
+ }
125
+ }
126
+ if (current) tokens.push(current)
127
+ return tokens
128
+ }
129
+
130
+ function parseSelector(token: string): AutomationContextSelector | null {
131
+ if (token === 'lastone') return { kind: 'lastone' }
132
+ if (token === 'firstone') return { kind: 'firstone' }
133
+ if (token === 'all') return { kind: 'all' }
134
+
135
+ const byidMatch = token.match(/^byid\((.+)\)$/)
136
+ if (byidMatch) return { kind: 'byid', id: byidMatch[1] }
137
+
138
+ const whereMatch = token.match(/^where\(([^=]+)=(.+)\)$/)
139
+ if (whereMatch) return { kind: 'where', alias: whereMatch[1], value: whereMatch[2] }
140
+
141
+ return null
142
+ }
143
+
144
+ function isKnownScope(token: string): boolean {
145
+ return token === 'this' || token === 'batch' || SLUG_RE.test(token)
146
+ }
147
+
148
+ export function parseTemplateKey(raw: string): ParsedKey | null {
149
+ if (!raw) return null
150
+
151
+ const trimmed = raw.trim()
152
+
153
+ // Step 1: if contains `:` at depth 0 → existing scoped / sugar logic
154
+ const colonTokens = splitKey(trimmed)
155
+ if (colonTokens.length > 1) {
156
+ const [scopeToken, ...rest] = colonTokens
157
+ if (!isKnownScope(scopeToken)) return null
158
+
159
+ if (rest.length === 1) {
160
+ const fieldOrAggregate = rest[0]
161
+ if (AGGREGATE_OPS.has(fieldOrAggregate)) {
162
+ return { kind: 'scoped', scope: scopeToken, selector: { kind: 'all' }, op: fieldOrAggregate as AggregateOp, field: null }
163
+ }
164
+ return { kind: 'scoped', scope: scopeToken, selector: { kind: 'lastone' }, op: 'field', field: fieldOrAggregate }
165
+ }
166
+
167
+ const selectorToken = rest[0]
168
+ const selector = parseSelector(selectorToken)
169
+ if (!selector) return null
170
+
171
+ const fieldTokens = rest.slice(1)
172
+ if (fieldTokens.length === 0) return null
173
+
174
+ const opToken = fieldTokens[0]
175
+ if (AGGREGATE_OPS.has(opToken)) {
176
+ if (selector.kind !== 'all') return null
177
+ const subField = fieldTokens[1] ?? null
178
+ return { kind: 'scoped', scope: scopeToken, selector, op: opToken as AggregateOp, field: subField }
179
+ }
180
+
181
+ return { kind: 'scoped', scope: scopeToken, selector, op: 'field', field: opToken }
182
+ }
183
+
184
+ // Step 2: tokenise on `.` at depth 0
185
+ const dotTokens = splitDot(trimmed)
186
+
187
+ // Step 3: if no token contains `[` or `(`, return simple (fast path)
188
+ if (!dotTokens.some((t) => t.includes('[') || t.includes('('))) {
189
+ return { kind: 'simple', path: trimmed }
190
+ }
191
+
192
+ // Step 4: build var_access
193
+ const [nameToken, ...stepTokens] = dotTokens
194
+ const steps: VarStep[] = []
195
+ const conditions: InlineCondition[] = []
196
+
197
+ let i = 0
198
+ while (i < stepTokens.length) {
199
+ const token = stepTokens[i]
200
+
201
+ // Inline condition: (col op val)
202
+ const condMatch = token.match(INLINE_COND_RE)
203
+ if (condMatch) {
204
+ conditions.push({ column: condMatch[1], op: condMatch[2] as InlineCondition['op'], value: condMatch[3].trim() })
205
+ i++
206
+ continue
207
+ }
208
+
209
+ // Array selector: array[id1,id2,...]
210
+ const arrayMatch = token.match(/^array\[([^\]]*)\]$/)
211
+ if (arrayMatch) {
212
+ const ids = arrayMatch[1].split(',').map((s) => s.trim()).filter(Boolean)
213
+ steps.push({ type: 'array', ids })
214
+ i++
215
+ continue
216
+ }
217
+
218
+ // Nav: firstone / lastone
219
+ if (NAV_OPS.has(token)) {
220
+ steps.push({ type: 'nav', nav: token as 'firstone' | 'lastone' })
221
+ i++
222
+ continue
223
+ }
224
+
225
+ // Aggregate: count | sum | avg | min | max | pluck
226
+ if (AGGREGATE_OPS.has(token)) {
227
+ const nextToken = stepTokens[i + 1]
228
+ // Consume the next token as field if it's a plain identifier (no special chars)
229
+ if (nextToken && !nextToken.includes('[') && !nextToken.includes('(') && !AGGREGATE_OPS.has(nextToken) && !NAV_OPS.has(nextToken)) {
230
+ steps.push({ type: 'agg', op: token as VarStep & { type: 'agg' } extends { op: infer O } ? O : never, field: nextToken })
231
+ i += 2
232
+ } else {
233
+ steps.push({ type: 'agg', op: token as any })
234
+ i++
235
+ }
236
+ continue
237
+ }
238
+
239
+ // Plain field
240
+ steps.push({ type: 'field', name: token })
241
+ i++
242
+ }
243
+
244
+ return { kind: 'var_access', name: nameToken, steps, conditions }
245
+ }