@beechcms/api 0.4.1 → 0.4.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 (67) hide show
  1. package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
  2. package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/migrations/0000_v040_base.sql +25 -0
  5. package/migrations/0029_automations.sql +14 -0
  6. package/package.json +4 -3
  7. package/src/factory.ts +12 -4
  8. package/src/features/automations/__tests__/action-executors.test.ts +268 -0
  9. package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
  10. package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
  11. package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
  12. package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
  13. package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
  14. package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
  15. package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
  16. package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
  17. package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
  18. package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
  19. package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
  20. package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
  21. package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
  22. package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
  23. package/src/features/automations/action-executors/index.ts +33 -0
  24. package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
  25. package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
  26. package/src/features/automations/action-executors/webhook.executor.ts +25 -0
  27. package/src/features/automations/automation-runner.ts +81 -0
  28. package/src/features/automations/automation-runner.utils.ts +43 -0
  29. package/src/features/automations/automations.handler.ts +193 -0
  30. package/src/features/automations/automations.schema.ts +160 -0
  31. package/src/features/automations/context-resolver.ts +148 -0
  32. package/src/features/automations/cron-runner.ts +136 -0
  33. package/src/features/automations/cron-runner.utils.ts +40 -0
  34. package/src/features/automations/filter-translation.ts +42 -0
  35. package/src/features/automations/index.ts +12 -0
  36. package/src/features/automations/template-grammar.ts +241 -0
  37. package/src/features/automations/var-access-resolver.ts +136 -0
  38. package/src/features/automations/when-evaluator.ts +83 -0
  39. package/src/features/automations/when-pushdown.ts +53 -0
  40. package/src/features/content/handlers/create.ts +8 -0
  41. package/src/features/content/handlers/delete.ts +8 -1
  42. package/src/features/content/handlers/update.ts +8 -0
  43. package/src/features/draft/draft.handler.ts +51 -164
  44. package/src/features/draft/draft.middleware.ts +62 -0
  45. package/src/features/email/email.service.ts +13 -0
  46. package/src/features/email/email.types.ts +10 -0
  47. package/src/features/email/index.ts +2 -1
  48. package/src/features/email/templates/automation-mail.ts +15 -0
  49. package/src/features/settings/settings.handler.ts +2 -1
  50. package/src/index.ts +40 -8
  51. package/src/middleware/repository.middleware.ts +32 -1
  52. package/src/public/cache-utils.ts +34 -0
  53. package/src/public/entry-projection.ts +42 -0
  54. package/src/public/idempotency.ts +19 -0
  55. package/src/public/problem-details.ts +5 -0
  56. package/src/public/public-add.ts +110 -156
  57. package/src/public/public-read.ts +59 -216
  58. package/src/public/read-list.ts +50 -0
  59. package/src/public/read-single.ts +44 -0
  60. package/src/shared/automations.repository.d1.ts +146 -0
  61. package/src/shared/d1-content-scan.repository.test.ts +76 -0
  62. package/src/shared/execution-context-scheduler.ts +9 -0
  63. package/src/shared/storage-utils.ts +3 -3
  64. package/src/types.ts +5 -1
  65. package/src/upload.ts +3 -2
  66. package/assets/dashboard/assets/index-CH13idU1.js +0 -554
  67. package/assets/dashboard/assets/index-CewtCjom.css +0 -1
@@ -0,0 +1,148 @@
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
+ }
@@ -0,0 +1,136 @@
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
+ }
@@ -0,0 +1,40 @@
1
+ export function cronMatches(expression: string | null, scheduledTime: number): boolean {
2
+ if (!expression) return false
3
+ const parts = expression.trim().split(/\s+/)
4
+ if (parts.length !== 5) return false
5
+
6
+ const d = new Date(scheduledTime)
7
+ const actual = [
8
+ d.getUTCMinutes(),
9
+ d.getUTCHours(),
10
+ d.getUTCDate(),
11
+ d.getUTCMonth() + 1,
12
+ d.getUTCDay(),
13
+ ] as const
14
+
15
+ return parts.every((field, i) => matchField(field, actual[i]))
16
+ }
17
+
18
+ function matchField(field: string, value: number): boolean {
19
+ if (field === '*') return true
20
+ if (field.includes(',')) {
21
+ return field.split(',').some((part) => matchField(part, value))
22
+ }
23
+ if (field.includes('/')) {
24
+ const [rangePart, stepPart] = field.split('/')
25
+ const step = Number(stepPart)
26
+ if (!Number.isFinite(step) || step <= 0) return false
27
+ if (rangePart === '*') return value % step === 0
28
+ if (rangePart.includes('-')) {
29
+ const [lo, hi] = rangePart.split('-').map(Number)
30
+ return value >= lo && value <= hi && (value - lo) % step === 0
31
+ }
32
+ return false
33
+ }
34
+ if (field.includes('-')) {
35
+ const [lo, hi] = field.split('-').map(Number)
36
+ return Number.isFinite(lo) && Number.isFinite(hi) && value >= lo && value <= hi
37
+ }
38
+ const n = Number(field)
39
+ return Number.isFinite(n) && n === value
40
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared helper: converts a TriggerCondition into a FilterGroup accepted by
3
+ * ContentRepository.findMany. Used by cron-runner and context-resolver.
4
+ *
5
+ * TODO Sprint 8 (Task 12): when-pushdown.ts will call extractPushdownFilters
6
+ * to convert safe WhenNode predicates to FilterGroup[] for SQL pre-filtering.
7
+ */
8
+ import type { BranchType, FilterGroup, FilterOperator, FilterType, Seed, TriggerCondition } from '@beechcms/core'
9
+
10
+ const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at'])
11
+
12
+ function mapBranchTypeToFilterType(type: BranchType): FilterType {
13
+ switch (type) {
14
+ case 'number': return 'number'
15
+ case 'boolean': return 'boolean'
16
+ case 'date': return 'date'
17
+ case 'tags': return 'tags'
18
+ case 'json': return 'json'
19
+ default: return 'text'
20
+ }
21
+ }
22
+
23
+ function mapOp(op: TriggerCondition['op']): FilterOperator {
24
+ switch (op) {
25
+ case 'isempty': return 'is_empty'
26
+ case 'isnotempty': return 'is_not_empty'
27
+ default: return op
28
+ }
29
+ }
30
+
31
+ export function conditionToFilterGroup(c: TriggerCondition, seed: Seed): FilterGroup {
32
+ const branch = seed.branches.find((b) => b.alias === c.field)
33
+ const type: FilterType = branch
34
+ ? mapBranchTypeToFilterType(branch.type)
35
+ : SYSTEM_COLUMNS.has(c.field) ? 'system' : 'text'
36
+
37
+ return {
38
+ column: c.field,
39
+ type,
40
+ conditions: [{ op: mapOp(c.op), value: c.value as string | number | boolean | null }],
41
+ }
42
+ }
@@ -0,0 +1,12 @@
1
+ export { AutomationRunner } from './automation-runner'
2
+ export type { AutomationRunnerDeps } from './automation-runner'
3
+ export { runCronAutomations } from './cron-runner'
4
+ export type { CronRunnerDeps } from './cron-runner'
5
+ export { automationsApp } from './automations.handler'
6
+ export {
7
+ createAutomationSchema,
8
+ updateAutomationSchema,
9
+ toggleAutomationSchema,
10
+ automationActionSchema,
11
+ } from './automations.schema'
12
+ export type { CreateAutomationBody, UpdateAutomationBody } from './automations.schema'
@@ -0,0 +1,241 @@
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
+ }