@beechcms/api 0.6.0-preview.1 → 0.6.0-preview.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
- package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
- package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
- package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
- package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/noResult.svg +42 -42
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +265 -265
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +2 -2
- package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
- package/src/auth/__fixtures__/static-token-service.ts +22 -22
- package/src/auth/bcrypt-hash-provider.ts +24 -24
- package/src/auth/constants.ts +14 -14
- package/src/auth/generate-refresh-token.test.ts +23 -23
- package/src/auth/hash-provider.test.ts +55 -55
- package/src/auth/jose-token-service.ts +62 -62
- package/src/auth/jwt-claims-passthrough.test.ts +87 -87
- package/src/auth/login.test.ts +100 -100
- package/src/auth/login.ts +78 -78
- package/src/auth/refresh.ts +11 -11
- package/src/auth/token-service.test.ts +90 -90
- package/src/factory.ts +370 -368
- package/src/features/automations/__tests__/action-executors.test.ts +255 -255
- package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
- package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
- package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
- package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
- package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
- package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
- package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
- package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
- package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
- package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
- package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
- package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
- package/src/features/automations/action-executors/index.ts +37 -37
- package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
- package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
- package/src/features/automations/action-executors/webhook.executor.ts +67 -67
- package/src/features/automations/automation-runner.ts +85 -85
- package/src/features/automations/automation-runner.utils.ts +47 -47
- package/src/features/automations/automations.handler.ts +197 -197
- package/src/features/automations/automations.schema.ts +177 -177
- package/src/features/automations/context-resolver.ts +152 -152
- package/src/features/automations/cron-runner.ts +140 -140
- package/src/features/automations/cron-runner.utils.ts +44 -44
- package/src/features/automations/filter-translation.ts +46 -46
- package/src/features/automations/index.ts +16 -16
- package/src/features/automations/template-grammar.ts +245 -245
- package/src/features/automations/var-access-resolver.ts +140 -140
- package/src/features/automations/when-evaluator.ts +87 -87
- package/src/features/automations/when-pushdown.ts +57 -57
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
- package/src/features/backrefs/backrefs.handler.ts +168 -168
- package/src/features/backrefs/d1-backref.repository.ts +84 -84
- package/src/features/backrefs/index.ts +5 -5
- package/src/features/content/constants.ts +16 -16
- package/src/features/content/handlers/bulk.handler.ts +185 -185
- package/src/features/content/handlers/create.ts +129 -129
- package/src/features/content/handlers/delete.ts +62 -62
- package/src/features/content/handlers/facets.ts +49 -49
- package/src/features/content/handlers/get.ts +120 -120
- package/src/features/content/handlers/helpers.test.ts +180 -180
- package/src/features/content/handlers/helpers.ts +93 -93
- package/src/features/content/handlers/list.ts +174 -174
- package/src/features/content/handlers/update.ts +174 -174
- package/src/features/content/index.ts +26 -26
- package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
- package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
- package/src/features/dashboard-layout/index.ts +5 -0
- package/src/features/draft/draft.handler.ts +195 -195
- package/src/features/draft/draft.middleware.ts +66 -66
- package/src/features/draft/index.ts +5 -5
- package/src/features/email/email.provider.ts +42 -42
- package/src/features/email/email.service.ts +98 -98
- package/src/features/email/email.types.ts +119 -119
- package/src/features/email/index.ts +33 -33
- package/src/features/email/providers/resend.ts +67 -67
- package/src/features/email/providers/smtp.ts +55 -55
- package/src/features/email/templates/automation-mail.ts +19 -19
- package/src/features/email/templates/password-changed.ts +63 -63
- package/src/features/email/templates/password-reset.ts +68 -68
- package/src/features/email/templates/shell.ts +96 -96
- package/src/features/notifications/index.ts +5 -5
- package/src/features/notifications/notifications.handler.ts +105 -105
- package/src/features/password-reset/index.ts +19 -19
- package/src/features/password-reset/request.ts +92 -92
- package/src/features/password-reset/reset.ts +102 -102
- package/src/features/rotate-field/index.ts +5 -5
- package/src/features/rotate-field/rotate-field.handler.ts +132 -132
- package/src/features/rotate-field/rotate-field.schema.ts +17 -17
- package/src/features/schema/schema.handler.ts +131 -131
- package/src/features/seeds/index.ts +5 -5
- package/src/features/seeds/seeds.handler.test.ts +1199 -632
- package/src/features/seeds/seeds.handler.ts +558 -693
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
- package/src/features/settings/settings.handler.ts +392 -392
- package/src/features/setup/index.ts +288 -204
- package/src/features/stats/index.ts +5 -5
- package/src/features/stats/stats.handler.ts +420 -420
- package/src/features/webhooks/index.ts +63 -63
- package/src/index.ts +65 -65
- package/src/media-utils.ts +82 -82
- package/src/middleware/auth-providers.middleware.test.ts +85 -0
- package/src/middleware/auth-providers.middleware.ts +45 -45
- package/src/middleware/observability.middleware.test.ts +93 -0
- package/src/middleware/observability.middleware.ts +70 -70
- package/src/middleware/rate-limit.middleware.ts +45 -45
- package/src/middleware/repository.middleware.ts +117 -114
- package/src/middleware/seed-registry.middleware.test.ts +97 -97
- package/src/middleware/seed-registry.middleware.ts +21 -21
- package/src/middleware/storage.middleware.ts +25 -25
- package/src/middleware.ts +45 -45
- package/src/public/access-policy.ts +27 -27
- package/src/public/api-key-middleware.ts +57 -57
- package/src/public/cache-utils.ts +38 -38
- package/src/public/entry-projection.ts +46 -46
- package/src/public/idempotency.ts +23 -23
- package/src/public/index.ts +16 -16
- package/src/public/problem-details.ts +124 -124
- package/src/public/public-add.ts +114 -114
- package/src/public/public-edit.ts +163 -163
- package/src/public/public-errors.ts +19 -19
- package/src/public/public-read.ts +63 -63
- package/src/public/public-routes.ts +88 -88
- package/src/public/query-builder.test.ts +224 -224
- package/src/public/query-builder.ts +156 -156
- package/src/public/rate-limit-middleware.ts +34 -34
- package/src/public/read-list.ts +54 -54
- package/src/public/read-single.ts +48 -48
- package/src/public/response-builder.ts +30 -30
- package/src/public/sanitize.ts +69 -69
- package/src/public/slug-utils.ts +18 -18
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
- package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
- package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
- package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
- package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
- package/src/rate-limit/no-op-rate-limiter.ts +11 -11
- package/src/search-utils.test.ts +211 -211
- package/src/search-utils.ts +213 -213
- package/src/search.ts +65 -65
- package/src/shared/apply-policies.test.ts +81 -81
- package/src/shared/apply-policies.ts +67 -67
- package/src/shared/automations.repository.d1.ts +150 -150
- package/src/shared/background-notification-service.test.ts +62 -62
- package/src/shared/background-notification-service.ts +52 -52
- package/src/shared/base.repository.d1.ts +32 -32
- package/src/shared/content-utils.test.ts +165 -165
- package/src/shared/content-utils.ts +86 -86
- package/src/shared/content.repository.d1.test.ts +507 -458
- package/src/shared/content.repository.d1.ts +851 -851
- package/src/shared/d1-activity-log.repository.test.ts +140 -140
- package/src/shared/d1-activity-log.repository.ts +105 -105
- package/src/shared/d1-activity-logger.test.ts +86 -86
- package/src/shared/d1-activity-logger.ts +67 -67
- package/src/shared/d1-analytics.repository.test.ts +78 -78
- package/src/shared/d1-analytics.repository.ts +85 -85
- package/src/shared/d1-content-scan.repository.test.ts +80 -80
- package/src/shared/d1-content-scan.repository.ts +37 -37
- package/src/shared/d1-notification.repository.test.ts +128 -128
- package/src/shared/d1-notification.repository.ts +118 -118
- package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
- package/src/shared/d1-password-reset-token.repository.ts +56 -56
- package/src/shared/d1-search.repository.test.ts +87 -87
- package/src/shared/d1-search.repository.ts +88 -88
- package/src/shared/d1-session.repository.test.ts +125 -125
- package/src/shared/d1-session.repository.ts +102 -102
- package/src/shared/d1-setup-checklist.repository.ts +36 -36
- package/src/shared/d1-user.repository.test.ts +151 -151
- package/src/shared/d1-user.repository.ts +119 -119
- package/src/shared/d1-widget.repository.test.ts +267 -221
- package/src/shared/d1-widget.repository.ts +385 -341
- package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
- package/src/shared/dashboard-layout.repository.d1.ts +58 -0
- package/src/shared/demo-data-sql.ts +216 -54
- package/src/shared/demo-data.repository.d1.ts +20 -20
- package/src/shared/execution-context-scheduler.ts +13 -13
- package/src/shared/fixed-clock.ts +25 -25
- package/src/shared/fts-sync.ts +8 -8
- package/src/shared/idempotency.repository.d1.test.ts +83 -83
- package/src/shared/idempotency.repository.d1.ts +60 -60
- package/src/shared/in-memory-activity-logger.ts +19 -19
- package/src/shared/in-memory-notification-service.ts +19 -19
- package/src/shared/in-memory-seed.repository.ts +58 -51
- package/src/shared/media.repository.d1.test.ts +107 -107
- package/src/shared/media.repository.d1.ts +68 -68
- package/src/shared/qstash-notification-service.test.ts +67 -67
- package/src/shared/qstash-notification-service.ts +55 -55
- package/src/shared/query-utils.ts +141 -141
- package/src/shared/request-utils.ts +26 -26
- package/src/shared/schema-mutator.d1.test.ts +142 -0
- package/src/shared/schema-mutator.d1.ts +64 -64
- package/src/shared/seed-layout.repository.d1.test.ts +114 -114
- package/src/shared/seed-layout.repository.d1.ts +62 -62
- package/src/shared/seed-registry-cache.test.ts +68 -68
- package/src/shared/seed-registry-cache.ts +49 -49
- package/src/shared/seed.repository.d1.test.ts +143 -143
- package/src/shared/seed.repository.d1.ts +98 -98
- package/src/shared/sequential-id-generator.ts +33 -33
- package/src/shared/site-settings.repository.d1.ts +53 -53
- package/src/shared/storage/factory.ts +41 -41
- package/src/shared/storage/s3-bucket.ts +195 -195
- package/src/shared/storage-utils.ts +40 -40
- package/src/shared/system-stats.repository.d1.test.ts +58 -58
- package/src/shared/system-stats.repository.d1.ts +48 -48
- package/src/types.ts +90 -87
- package/src/upload.ts +198 -198
- package/src/widget.test.ts +85 -0
- package/src/widget.ts +251 -212
- package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
- package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
|
@@ -1,245 +1,245 @@
|
|
|
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
|
-
}
|
|
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
|
+
}
|