@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,277 +1,281 @@
1
- import { describe, it, expect } from 'vitest'
2
- import type { WhenNode, Seed } from '@beechcms/core'
3
- import { extractPushdownFilters } from '../when-pushdown'
4
-
5
- // ---------------------------------------------------------------------------
6
- // Minimal Seed
7
- // ---------------------------------------------------------------------------
8
-
9
- function makeSeed(aliases: string[] = []): Seed {
10
- return {
11
- slug: 'orders',
12
- label: 'Orders',
13
- displayNameAlias: aliases[0] ?? 'id',
14
- branches: aliases.map((alias) => ({ alias, label: alias, type: 'text' as const })),
15
- }
16
- }
17
-
18
- const seed = makeSeed(['status', 'total', 'name', 'tag'])
19
-
20
- // ---------------------------------------------------------------------------
21
- // null / empty
22
- // ---------------------------------------------------------------------------
23
-
24
- describe('extractPushdownFilters — null/empty', () => {
25
- it('null returns []', () => {
26
- expect(extractPushdownFilters(null, seed)).toEqual([])
27
- })
28
-
29
- it('empty AND group returns []', () => {
30
- const node: WhenNode = { kind: 'group', op: 'AND', children: [] }
31
- expect(extractPushdownFilters(node, seed)).toEqual([])
32
- })
33
-
34
- it('empty OR group returns []', () => {
35
- const node: WhenNode = { kind: 'group', op: 'OR', children: [] }
36
- expect(extractPushdownFilters(node, seed)).toEqual([])
37
- })
38
- })
39
-
40
- // ---------------------------------------------------------------------------
41
- // Safe single-predicate push-down
42
- // ---------------------------------------------------------------------------
43
-
44
- describe('extractPushdownFilters — safe predicates', () => {
45
- it('root eq predicate is pushed', () => {
46
- const node: WhenNode = {
47
- kind: 'predicate',
48
- left: { kind: 'ref', key: 'this.status' },
49
- op: 'eq',
50
- right: { kind: 'literal', value: 'paid' },
51
- }
52
- const result = extractPushdownFilters(node, seed)
53
- expect(result).toHaveLength(1)
54
- expect(result[0].column).toBe('status')
55
- expect(result[0].conditions[0].op).toBe('eq')
56
- expect(result[0].conditions[0].value).toBe('paid')
57
- })
58
-
59
- it('root neq predicate is pushed', () => {
60
- const node: WhenNode = {
61
- kind: 'predicate',
62
- left: { kind: 'ref', key: 'this.status' },
63
- op: 'neq',
64
- right: { kind: 'literal', value: 'cancelled' },
65
- }
66
- const result = extractPushdownFilters(node, seed)
67
- expect(result).toHaveLength(1)
68
- expect(result[0].conditions[0].op).toBe('neq')
69
- })
70
-
71
- it('root gt predicate is pushed', () => {
72
- const node: WhenNode = {
73
- kind: 'predicate',
74
- left: { kind: 'ref', key: 'this.total' },
75
- op: 'gt',
76
- right: { kind: 'literal', value: 100 },
77
- }
78
- const result = extractPushdownFilters(node, seed)
79
- expect(result).toHaveLength(1)
80
- expect(result[0].conditions[0].op).toBe('gt')
81
- })
82
-
83
- it('root lt predicate is pushed', () => {
84
- const node: WhenNode = {
85
- kind: 'predicate',
86
- left: { kind: 'ref', key: 'this.total' },
87
- op: 'lt',
88
- right: { kind: 'literal', value: 500 },
89
- }
90
- const result = extractPushdownFilters(node, seed)
91
- expect(result).toHaveLength(1)
92
- expect(result[0].conditions[0].op).toBe('lt')
93
- })
94
-
95
- it('root contains predicate is pushed', () => {
96
- const node: WhenNode = {
97
- kind: 'predicate',
98
- left: { kind: 'ref', key: 'this.name' },
99
- op: 'contains',
100
- right: { kind: 'literal', value: 'Alice' },
101
- }
102
- const result = extractPushdownFilters(node, seed)
103
- expect(result).toHaveLength(1)
104
- expect(result[0].conditions[0].op).toBe('contains')
105
- })
106
-
107
- it('root isempty predicate is pushed (no right operand)', () => {
108
- const node: WhenNode = {
109
- kind: 'predicate',
110
- left: { kind: 'ref', key: 'this.name' },
111
- op: 'isempty',
112
- }
113
- const result = extractPushdownFilters(node, seed)
114
- expect(result).toHaveLength(1)
115
- expect(result[0].conditions[0].op).toBe('is_empty')
116
- })
117
-
118
- it('root isnotempty predicate is pushed (no right operand)', () => {
119
- const node: WhenNode = {
120
- kind: 'predicate',
121
- left: { kind: 'ref', key: 'this.name' },
122
- op: 'isnotempty',
123
- }
124
- const result = extractPushdownFilters(node, seed)
125
- expect(result).toHaveLength(1)
126
- expect(result[0].conditions[0].op).toBe('is_not_empty')
127
- })
128
- })
129
-
130
- // ---------------------------------------------------------------------------
131
- // AND group — only direct safe predicate children are pushed
132
- // ---------------------------------------------------------------------------
133
-
134
- describe('extractPushdownFilters — AND group', () => {
135
- it('all safe AND children are pushed', () => {
136
- const node: WhenNode = {
137
- kind: 'group',
138
- op: 'AND',
139
- children: [
140
- { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
141
- { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
142
- ],
143
- }
144
- const result = extractPushdownFilters(node, seed)
145
- expect(result).toHaveLength(2)
146
- expect(result.map((r) => r.column)).toContain('status')
147
- expect(result.map((r) => r.column)).toContain('total')
148
- })
149
-
150
- it('mixed AND: unsafe children skipped, safe ones pushed', () => {
151
- const node: WhenNode = {
152
- kind: 'group',
153
- op: 'AND',
154
- children: [
155
- { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
156
- // gte is not in PUSHDOWN_OPS → skipped
157
- { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gte', right: { kind: 'literal', value: 100 } },
158
- ],
159
- }
160
- const result = extractPushdownFilters(node, seed)
161
- expect(result).toHaveLength(1)
162
- expect(result[0].column).toBe('status')
163
- })
164
- })
165
-
166
- // ---------------------------------------------------------------------------
167
- // Unsafe operators stay in-memory
168
- // ---------------------------------------------------------------------------
169
-
170
- describe('extractPushdownFilters — unsafe operators stay in-memory', () => {
171
- for (const op of ['gte', 'lte', 'startswith', 'endswith', 'matches', 'in', 'notin'] as const) {
172
- it(`${op} is NOT pushed`, () => {
173
- const node: WhenNode = {
174
- kind: 'predicate',
175
- left: { kind: 'ref', key: 'this.status' },
176
- op,
177
- right: { kind: 'literal', value: 'x' },
178
- }
179
- expect(extractPushdownFilters(node, seed)).toHaveLength(0)
180
- })
181
- }
182
- })
183
-
184
- // ---------------------------------------------------------------------------
185
- // OR branches stay in-memory
186
- // ---------------------------------------------------------------------------
187
-
188
- describe('extractPushdownFilters — OR branches stay in-memory', () => {
189
- it('OR root group returns []', () => {
190
- const node: WhenNode = {
191
- kind: 'group',
192
- op: 'OR',
193
- children: [
194
- { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
195
- { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
196
- ],
197
- }
198
- expect(extractPushdownFilters(node, seed)).toHaveLength(0)
199
- })
200
-
201
- it('AND group with nested OR child: only direct predicate siblings are pushed', () => {
202
- const node: WhenNode = {
203
- kind: 'group',
204
- op: 'AND',
205
- children: [
206
- { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
207
- {
208
- kind: 'group',
209
- op: 'OR',
210
- children: [
211
- { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
212
- { kind: 'predicate', left: { kind: 'ref', key: 'this.tag' }, op: 'eq', right: { kind: 'literal', value: 'gold' } },
213
- ],
214
- },
215
- ],
216
- }
217
- const result = extractPushdownFilters(node, seed)
218
- // Only the direct 'status' predicate is pushed; the nested OR group is skipped
219
- expect(result).toHaveLength(1)
220
- expect(result[0].column).toBe('status')
221
- })
222
- })
223
-
224
- // ---------------------------------------------------------------------------
225
- // Negated group stays in-memory
226
- // ---------------------------------------------------------------------------
227
-
228
- describe('extractPushdownFilters — negated AND group stays in-memory', () => {
229
- it('negated AND root returns []', () => {
230
- const node: WhenNode = {
231
- kind: 'group',
232
- op: 'AND',
233
- negate: true,
234
- children: [
235
- { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
236
- ],
237
- }
238
- expect(extractPushdownFilters(node, seed)).toHaveLength(0)
239
- })
240
- })
241
-
242
- // ---------------------------------------------------------------------------
243
- // Cross-seed refs stay in-memory
244
- // ---------------------------------------------------------------------------
245
-
246
- describe('extractPushdownFilters — cross-seed refs stay in-memory', () => {
247
- it('ref not starting with this. is NOT pushed', () => {
248
- const node: WhenNode = {
249
- kind: 'predicate',
250
- left: { kind: 'ref', key: 'customers:lastone:status' },
251
- op: 'eq',
252
- right: { kind: 'literal', value: 'active' },
253
- }
254
- expect(extractPushdownFilters(node, seed)).toHaveLength(0)
255
- })
256
-
257
- it('right operand is a ref (not literal) → NOT pushed', () => {
258
- const node: WhenNode = {
259
- kind: 'predicate',
260
- left: { kind: 'ref', key: 'this.status' },
261
- op: 'eq',
262
- right: { kind: 'ref', key: 'customers:lastone:status' },
263
- }
264
- expect(extractPushdownFilters(node, seed)).toHaveLength(0)
265
- })
266
-
267
- it('literal left operand is NOT pushed', () => {
268
- const node: WhenNode = {
269
- kind: 'predicate',
270
- left: { kind: 'literal', value: 'paid' },
271
- op: 'eq',
272
- right: { kind: 'literal', value: 'paid' },
273
- }
274
- expect(extractPushdownFilters(node, seed)).toHaveLength(0)
275
- })
276
- })
277
-
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 { describe, it, expect } from 'vitest'
6
+ import type { WhenNode, Seed } from '@beechcms/core'
7
+ import { extractPushdownFilters } from '../when-pushdown'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Minimal Seed
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function makeSeed(aliases: string[] = []): Seed {
14
+ return {
15
+ slug: 'orders',
16
+ label: 'Orders',
17
+ displayNameAlias: aliases[0] ?? 'id',
18
+ branches: aliases.map((alias, i) => ({ id: `br_0${i + 1}`, alias, label: alias, type: 'text' as const })),
19
+ }
20
+ }
21
+
22
+ const seed = makeSeed(['status', 'total', 'name', 'tag'])
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // null / empty
26
+ // ---------------------------------------------------------------------------
27
+
28
+ describe('extractPushdownFilters — null/empty', () => {
29
+ it('null returns []', () => {
30
+ expect(extractPushdownFilters(null, seed)).toEqual([])
31
+ })
32
+
33
+ it('empty AND group returns []', () => {
34
+ const node: WhenNode = { kind: 'group', op: 'AND', children: [] }
35
+ expect(extractPushdownFilters(node, seed)).toEqual([])
36
+ })
37
+
38
+ it('empty OR group returns []', () => {
39
+ const node: WhenNode = { kind: 'group', op: 'OR', children: [] }
40
+ expect(extractPushdownFilters(node, seed)).toEqual([])
41
+ })
42
+ })
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Safe single-predicate push-down
46
+ // ---------------------------------------------------------------------------
47
+
48
+ describe('extractPushdownFilters safe predicates', () => {
49
+ it('root eq predicate is pushed', () => {
50
+ const node: WhenNode = {
51
+ kind: 'predicate',
52
+ left: { kind: 'ref', key: 'this.status' },
53
+ op: 'eq',
54
+ right: { kind: 'literal', value: 'paid' },
55
+ }
56
+ const result = extractPushdownFilters(node, seed)
57
+ expect(result).toHaveLength(1)
58
+ expect(result[0].column).toBe('status')
59
+ expect(result[0].conditions[0].op).toBe('eq')
60
+ expect(result[0].conditions[0].value).toBe('paid')
61
+ })
62
+
63
+ it('root neq predicate is pushed', () => {
64
+ const node: WhenNode = {
65
+ kind: 'predicate',
66
+ left: { kind: 'ref', key: 'this.status' },
67
+ op: 'neq',
68
+ right: { kind: 'literal', value: 'cancelled' },
69
+ }
70
+ const result = extractPushdownFilters(node, seed)
71
+ expect(result).toHaveLength(1)
72
+ expect(result[0].conditions[0].op).toBe('neq')
73
+ })
74
+
75
+ it('root gt predicate is pushed', () => {
76
+ const node: WhenNode = {
77
+ kind: 'predicate',
78
+ left: { kind: 'ref', key: 'this.total' },
79
+ op: 'gt',
80
+ right: { kind: 'literal', value: 100 },
81
+ }
82
+ const result = extractPushdownFilters(node, seed)
83
+ expect(result).toHaveLength(1)
84
+ expect(result[0].conditions[0].op).toBe('gt')
85
+ })
86
+
87
+ it('root lt predicate is pushed', () => {
88
+ const node: WhenNode = {
89
+ kind: 'predicate',
90
+ left: { kind: 'ref', key: 'this.total' },
91
+ op: 'lt',
92
+ right: { kind: 'literal', value: 500 },
93
+ }
94
+ const result = extractPushdownFilters(node, seed)
95
+ expect(result).toHaveLength(1)
96
+ expect(result[0].conditions[0].op).toBe('lt')
97
+ })
98
+
99
+ it('root contains predicate is pushed', () => {
100
+ const node: WhenNode = {
101
+ kind: 'predicate',
102
+ left: { kind: 'ref', key: 'this.name' },
103
+ op: 'contains',
104
+ right: { kind: 'literal', value: 'Alice' },
105
+ }
106
+ const result = extractPushdownFilters(node, seed)
107
+ expect(result).toHaveLength(1)
108
+ expect(result[0].conditions[0].op).toBe('contains')
109
+ })
110
+
111
+ it('root isempty predicate is pushed (no right operand)', () => {
112
+ const node: WhenNode = {
113
+ kind: 'predicate',
114
+ left: { kind: 'ref', key: 'this.name' },
115
+ op: 'isempty',
116
+ }
117
+ const result = extractPushdownFilters(node, seed)
118
+ expect(result).toHaveLength(1)
119
+ expect(result[0].conditions[0].op).toBe('is_empty')
120
+ })
121
+
122
+ it('root isnotempty predicate is pushed (no right operand)', () => {
123
+ const node: WhenNode = {
124
+ kind: 'predicate',
125
+ left: { kind: 'ref', key: 'this.name' },
126
+ op: 'isnotempty',
127
+ }
128
+ const result = extractPushdownFilters(node, seed)
129
+ expect(result).toHaveLength(1)
130
+ expect(result[0].conditions[0].op).toBe('is_not_empty')
131
+ })
132
+ })
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // AND group — only direct safe predicate children are pushed
136
+ // ---------------------------------------------------------------------------
137
+
138
+ describe('extractPushdownFilters AND group', () => {
139
+ it('all safe AND children are pushed', () => {
140
+ const node: WhenNode = {
141
+ kind: 'group',
142
+ op: 'AND',
143
+ children: [
144
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
145
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
146
+ ],
147
+ }
148
+ const result = extractPushdownFilters(node, seed)
149
+ expect(result).toHaveLength(2)
150
+ expect(result.map((r) => r.column)).toContain('status')
151
+ expect(result.map((r) => r.column)).toContain('total')
152
+ })
153
+
154
+ it('mixed AND: unsafe children skipped, safe ones pushed', () => {
155
+ const node: WhenNode = {
156
+ kind: 'group',
157
+ op: 'AND',
158
+ children: [
159
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
160
+ // gte is not in PUSHDOWN_OPS → skipped
161
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gte', right: { kind: 'literal', value: 100 } },
162
+ ],
163
+ }
164
+ const result = extractPushdownFilters(node, seed)
165
+ expect(result).toHaveLength(1)
166
+ expect(result[0].column).toBe('status')
167
+ })
168
+ })
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Unsafe operators stay in-memory
172
+ // ---------------------------------------------------------------------------
173
+
174
+ describe('extractPushdownFilters — unsafe operators stay in-memory', () => {
175
+ for (const op of ['gte', 'lte', 'startswith', 'endswith', 'matches', 'in', 'notin'] as const) {
176
+ it(`${op} is NOT pushed`, () => {
177
+ const node: WhenNode = {
178
+ kind: 'predicate',
179
+ left: { kind: 'ref', key: 'this.status' },
180
+ op,
181
+ right: { kind: 'literal', value: 'x' },
182
+ }
183
+ expect(extractPushdownFilters(node, seed)).toHaveLength(0)
184
+ })
185
+ }
186
+ })
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // OR branches stay in-memory
190
+ // ---------------------------------------------------------------------------
191
+
192
+ describe('extractPushdownFilters OR branches stay in-memory', () => {
193
+ it('OR root group returns []', () => {
194
+ const node: WhenNode = {
195
+ kind: 'group',
196
+ op: 'OR',
197
+ children: [
198
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
199
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
200
+ ],
201
+ }
202
+ expect(extractPushdownFilters(node, seed)).toHaveLength(0)
203
+ })
204
+
205
+ it('AND group with nested OR child: only direct predicate siblings are pushed', () => {
206
+ const node: WhenNode = {
207
+ kind: 'group',
208
+ op: 'AND',
209
+ children: [
210
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
211
+ {
212
+ kind: 'group',
213
+ op: 'OR',
214
+ children: [
215
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
216
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.tag' }, op: 'eq', right: { kind: 'literal', value: 'gold' } },
217
+ ],
218
+ },
219
+ ],
220
+ }
221
+ const result = extractPushdownFilters(node, seed)
222
+ // Only the direct 'status' predicate is pushed; the nested OR group is skipped
223
+ expect(result).toHaveLength(1)
224
+ expect(result[0].column).toBe('status')
225
+ })
226
+ })
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Negated group stays in-memory
230
+ // ---------------------------------------------------------------------------
231
+
232
+ describe('extractPushdownFilters — negated AND group stays in-memory', () => {
233
+ it('negated AND root returns []', () => {
234
+ const node: WhenNode = {
235
+ kind: 'group',
236
+ op: 'AND',
237
+ negate: true,
238
+ children: [
239
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
240
+ ],
241
+ }
242
+ expect(extractPushdownFilters(node, seed)).toHaveLength(0)
243
+ })
244
+ })
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Cross-seed refs stay in-memory
248
+ // ---------------------------------------------------------------------------
249
+
250
+ describe('extractPushdownFilters cross-seed refs stay in-memory', () => {
251
+ it('ref not starting with this. is NOT pushed', () => {
252
+ const node: WhenNode = {
253
+ kind: 'predicate',
254
+ left: { kind: 'ref', key: 'customers:lastone:status' },
255
+ op: 'eq',
256
+ right: { kind: 'literal', value: 'active' },
257
+ }
258
+ expect(extractPushdownFilters(node, seed)).toHaveLength(0)
259
+ })
260
+
261
+ it('right operand is a ref (not literal) → NOT pushed', () => {
262
+ const node: WhenNode = {
263
+ kind: 'predicate',
264
+ left: { kind: 'ref', key: 'this.status' },
265
+ op: 'eq',
266
+ right: { kind: 'ref', key: 'customers:lastone:status' },
267
+ }
268
+ expect(extractPushdownFilters(node, seed)).toHaveLength(0)
269
+ })
270
+
271
+ it('literal left operand is NOT pushed', () => {
272
+ const node: WhenNode = {
273
+ kind: 'predicate',
274
+ left: { kind: 'literal', value: 'paid' },
275
+ op: 'eq',
276
+ right: { kind: 'literal', value: 'paid' },
277
+ }
278
+ expect(extractPushdownFilters(node, seed)).toHaveLength(0)
279
+ })
280
+ })
281
+
@@ -1,23 +1,27 @@
1
- import type { AutomationAction, ContentRepository, Seed, IIdGenerator } from '@beechcms/core'
2
-
3
- type CreateEntryAction = Extract<AutomationAction, { type: 'create_entry' }>
4
- type SeedResolver = (slug: string) => Seed | null
5
-
6
- export async function executeCreateEntry(
7
- action: CreateEntryAction,
8
- entry: Record<string, unknown>,
9
- repository: ContentRepository,
10
- getSeed: SeedResolver,
11
- idGenerator: IIdGenerator,
12
- ): Promise<void> {
13
- const targetSeed = getSeed(action.seed_slug)
14
- if (!targetSeed) throw new Error(`create_entry: unknown seed ${action.seed_slug}`)
15
-
16
- const payload: Record<string, unknown> = {}
17
- for (const [targetField, sourceField] of Object.entries(action.field_map)) {
18
- payload[targetField] = (sourceField as string) in entry ? entry[sourceField as string] : sourceField
19
- }
20
-
21
- const newId = idGenerator.uuid()
22
- await repository.create(targetSeed, newId, newId, 'draft', payload)
23
- }
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 { AutomationAction, ContentRepository, Seed, IIdGenerator } from '@beechcms/core'
6
+
7
+ type CreateEntryAction = Extract<AutomationAction, { type: 'create_entry' }>
8
+ type SeedResolver = (slug: string) => Seed | null
9
+
10
+ export async function executeCreateEntry(
11
+ action: CreateEntryAction,
12
+ entry: Record<string, unknown>,
13
+ repository: ContentRepository,
14
+ getSeed: SeedResolver,
15
+ idGenerator: IIdGenerator,
16
+ ): Promise<void> {
17
+ const targetSeed = getSeed(action.seed_slug)
18
+ if (!targetSeed) throw new Error(`create_entry: unknown seed ${action.seed_slug}`)
19
+
20
+ const payload: Record<string, unknown> = {}
21
+ for (const [targetField, sourceField] of Object.entries(action.field_map)) {
22
+ payload[targetField] = (sourceField as string) in entry ? entry[sourceField as string] : sourceField
23
+ }
24
+
25
+ const newId = idGenerator.uuid()
26
+ await repository.create(targetSeed, newId, newId, 'draft', payload)
27
+ }