@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.
Files changed (222) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  4. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  5. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  6. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  7. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  8. package/assets/dashboard/beechLogoDark.svg +48 -48
  9. package/assets/dashboard/index.html +18 -18
  10. package/assets/dashboard/noResult.svg +42 -42
  11. package/assets/dashboard/sol.svg +3 -3
  12. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  13. package/migrations/0000_v040_base.sql +265 -265
  14. package/migrations/0029_automations.sql +14 -14
  15. package/migrations/0030_test_seeds.sql +125 -125
  16. package/package.json +2 -2
  17. package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
  18. package/src/auth/__fixtures__/static-token-service.ts +22 -22
  19. package/src/auth/bcrypt-hash-provider.ts +24 -24
  20. package/src/auth/constants.ts +14 -14
  21. package/src/auth/generate-refresh-token.test.ts +23 -23
  22. package/src/auth/hash-provider.test.ts +55 -55
  23. package/src/auth/jose-token-service.ts +62 -62
  24. package/src/auth/jwt-claims-passthrough.test.ts +87 -87
  25. package/src/auth/login.test.ts +100 -100
  26. package/src/auth/login.ts +78 -78
  27. package/src/auth/refresh.ts +11 -11
  28. package/src/auth/token-service.test.ts +90 -90
  29. package/src/factory.ts +370 -368
  30. package/src/features/automations/__tests__/action-executors.test.ts +255 -255
  31. package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
  32. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
  33. package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
  34. package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
  35. package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
  36. package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
  37. package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
  38. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
  39. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
  40. package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
  41. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
  42. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
  43. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
  44. package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
  45. package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
  46. package/src/features/automations/action-executors/index.ts +37 -37
  47. package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
  48. package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
  49. package/src/features/automations/action-executors/webhook.executor.ts +67 -67
  50. package/src/features/automations/automation-runner.ts +85 -85
  51. package/src/features/automations/automation-runner.utils.ts +47 -47
  52. package/src/features/automations/automations.handler.ts +197 -197
  53. package/src/features/automations/automations.schema.ts +177 -177
  54. package/src/features/automations/context-resolver.ts +152 -152
  55. package/src/features/automations/cron-runner.ts +140 -140
  56. package/src/features/automations/cron-runner.utils.ts +44 -44
  57. package/src/features/automations/filter-translation.ts +46 -46
  58. package/src/features/automations/index.ts +16 -16
  59. package/src/features/automations/template-grammar.ts +245 -245
  60. package/src/features/automations/var-access-resolver.ts +140 -140
  61. package/src/features/automations/when-evaluator.ts +87 -87
  62. package/src/features/automations/when-pushdown.ts +57 -57
  63. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
  64. package/src/features/backrefs/backrefs.handler.ts +168 -168
  65. package/src/features/backrefs/d1-backref.repository.ts +84 -84
  66. package/src/features/backrefs/index.ts +5 -5
  67. package/src/features/content/constants.ts +16 -16
  68. package/src/features/content/handlers/bulk.handler.ts +185 -185
  69. package/src/features/content/handlers/create.ts +129 -129
  70. package/src/features/content/handlers/delete.ts +62 -62
  71. package/src/features/content/handlers/facets.ts +49 -49
  72. package/src/features/content/handlers/get.ts +120 -120
  73. package/src/features/content/handlers/helpers.test.ts +180 -180
  74. package/src/features/content/handlers/helpers.ts +93 -93
  75. package/src/features/content/handlers/list.ts +174 -174
  76. package/src/features/content/handlers/update.ts +174 -174
  77. package/src/features/content/index.ts +26 -26
  78. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  79. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  80. package/src/features/dashboard-layout/index.ts +5 -0
  81. package/src/features/draft/draft.handler.ts +195 -195
  82. package/src/features/draft/draft.middleware.ts +66 -66
  83. package/src/features/draft/index.ts +5 -5
  84. package/src/features/email/email.provider.ts +42 -42
  85. package/src/features/email/email.service.ts +98 -98
  86. package/src/features/email/email.types.ts +119 -119
  87. package/src/features/email/index.ts +33 -33
  88. package/src/features/email/providers/resend.ts +67 -67
  89. package/src/features/email/providers/smtp.ts +55 -55
  90. package/src/features/email/templates/automation-mail.ts +19 -19
  91. package/src/features/email/templates/password-changed.ts +63 -63
  92. package/src/features/email/templates/password-reset.ts +68 -68
  93. package/src/features/email/templates/shell.ts +96 -96
  94. package/src/features/notifications/index.ts +5 -5
  95. package/src/features/notifications/notifications.handler.ts +105 -105
  96. package/src/features/password-reset/index.ts +19 -19
  97. package/src/features/password-reset/request.ts +92 -92
  98. package/src/features/password-reset/reset.ts +102 -102
  99. package/src/features/rotate-field/index.ts +5 -5
  100. package/src/features/rotate-field/rotate-field.handler.ts +132 -132
  101. package/src/features/rotate-field/rotate-field.schema.ts +17 -17
  102. package/src/features/schema/schema.handler.ts +131 -131
  103. package/src/features/seeds/index.ts +5 -5
  104. package/src/features/seeds/seeds.handler.test.ts +1199 -632
  105. package/src/features/seeds/seeds.handler.ts +558 -693
  106. package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
  107. package/src/features/settings/settings.handler.ts +392 -392
  108. package/src/features/setup/index.ts +288 -204
  109. package/src/features/stats/index.ts +5 -5
  110. package/src/features/stats/stats.handler.ts +420 -420
  111. package/src/features/webhooks/index.ts +63 -63
  112. package/src/index.ts +65 -65
  113. package/src/media-utils.ts +82 -82
  114. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  115. package/src/middleware/auth-providers.middleware.ts +45 -45
  116. package/src/middleware/observability.middleware.test.ts +93 -0
  117. package/src/middleware/observability.middleware.ts +70 -70
  118. package/src/middleware/rate-limit.middleware.ts +45 -45
  119. package/src/middleware/repository.middleware.ts +117 -114
  120. package/src/middleware/seed-registry.middleware.test.ts +97 -97
  121. package/src/middleware/seed-registry.middleware.ts +21 -21
  122. package/src/middleware/storage.middleware.ts +25 -25
  123. package/src/middleware.ts +45 -45
  124. package/src/public/access-policy.ts +27 -27
  125. package/src/public/api-key-middleware.ts +57 -57
  126. package/src/public/cache-utils.ts +38 -38
  127. package/src/public/entry-projection.ts +46 -46
  128. package/src/public/idempotency.ts +23 -23
  129. package/src/public/index.ts +16 -16
  130. package/src/public/problem-details.ts +124 -124
  131. package/src/public/public-add.ts +114 -114
  132. package/src/public/public-edit.ts +163 -163
  133. package/src/public/public-errors.ts +19 -19
  134. package/src/public/public-read.ts +63 -63
  135. package/src/public/public-routes.ts +88 -88
  136. package/src/public/query-builder.test.ts +224 -224
  137. package/src/public/query-builder.ts +156 -156
  138. package/src/public/rate-limit-middleware.ts +34 -34
  139. package/src/public/read-list.ts +54 -54
  140. package/src/public/read-single.ts +48 -48
  141. package/src/public/response-builder.ts +30 -30
  142. package/src/public/sanitize.ts +69 -69
  143. package/src/public/slug-utils.ts +18 -18
  144. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
  145. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
  146. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
  147. package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
  148. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
  149. package/src/rate-limit/no-op-rate-limiter.ts +11 -11
  150. package/src/search-utils.test.ts +211 -211
  151. package/src/search-utils.ts +213 -213
  152. package/src/search.ts +65 -65
  153. package/src/shared/apply-policies.test.ts +81 -81
  154. package/src/shared/apply-policies.ts +67 -67
  155. package/src/shared/automations.repository.d1.ts +150 -150
  156. package/src/shared/background-notification-service.test.ts +62 -62
  157. package/src/shared/background-notification-service.ts +52 -52
  158. package/src/shared/base.repository.d1.ts +32 -32
  159. package/src/shared/content-utils.test.ts +165 -165
  160. package/src/shared/content-utils.ts +86 -86
  161. package/src/shared/content.repository.d1.test.ts +507 -458
  162. package/src/shared/content.repository.d1.ts +851 -851
  163. package/src/shared/d1-activity-log.repository.test.ts +140 -140
  164. package/src/shared/d1-activity-log.repository.ts +105 -105
  165. package/src/shared/d1-activity-logger.test.ts +86 -86
  166. package/src/shared/d1-activity-logger.ts +67 -67
  167. package/src/shared/d1-analytics.repository.test.ts +78 -78
  168. package/src/shared/d1-analytics.repository.ts +85 -85
  169. package/src/shared/d1-content-scan.repository.test.ts +80 -80
  170. package/src/shared/d1-content-scan.repository.ts +37 -37
  171. package/src/shared/d1-notification.repository.test.ts +128 -128
  172. package/src/shared/d1-notification.repository.ts +118 -118
  173. package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
  174. package/src/shared/d1-password-reset-token.repository.ts +56 -56
  175. package/src/shared/d1-search.repository.test.ts +87 -87
  176. package/src/shared/d1-search.repository.ts +88 -88
  177. package/src/shared/d1-session.repository.test.ts +125 -125
  178. package/src/shared/d1-session.repository.ts +102 -102
  179. package/src/shared/d1-setup-checklist.repository.ts +36 -36
  180. package/src/shared/d1-user.repository.test.ts +151 -151
  181. package/src/shared/d1-user.repository.ts +119 -119
  182. package/src/shared/d1-widget.repository.test.ts +267 -221
  183. package/src/shared/d1-widget.repository.ts +385 -341
  184. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  185. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  186. package/src/shared/demo-data-sql.ts +216 -54
  187. package/src/shared/demo-data.repository.d1.ts +20 -20
  188. package/src/shared/execution-context-scheduler.ts +13 -13
  189. package/src/shared/fixed-clock.ts +25 -25
  190. package/src/shared/fts-sync.ts +8 -8
  191. package/src/shared/idempotency.repository.d1.test.ts +83 -83
  192. package/src/shared/idempotency.repository.d1.ts +60 -60
  193. package/src/shared/in-memory-activity-logger.ts +19 -19
  194. package/src/shared/in-memory-notification-service.ts +19 -19
  195. package/src/shared/in-memory-seed.repository.ts +58 -51
  196. package/src/shared/media.repository.d1.test.ts +107 -107
  197. package/src/shared/media.repository.d1.ts +68 -68
  198. package/src/shared/qstash-notification-service.test.ts +67 -67
  199. package/src/shared/qstash-notification-service.ts +55 -55
  200. package/src/shared/query-utils.ts +141 -141
  201. package/src/shared/request-utils.ts +26 -26
  202. package/src/shared/schema-mutator.d1.test.ts +142 -0
  203. package/src/shared/schema-mutator.d1.ts +64 -64
  204. package/src/shared/seed-layout.repository.d1.test.ts +114 -114
  205. package/src/shared/seed-layout.repository.d1.ts +62 -62
  206. package/src/shared/seed-registry-cache.test.ts +68 -68
  207. package/src/shared/seed-registry-cache.ts +49 -49
  208. package/src/shared/seed.repository.d1.test.ts +143 -143
  209. package/src/shared/seed.repository.d1.ts +98 -98
  210. package/src/shared/sequential-id-generator.ts +33 -33
  211. package/src/shared/site-settings.repository.d1.ts +53 -53
  212. package/src/shared/storage/factory.ts +41 -41
  213. package/src/shared/storage/s3-bucket.ts +195 -195
  214. package/src/shared/storage-utils.ts +40 -40
  215. package/src/shared/system-stats.repository.d1.test.ts +58 -58
  216. package/src/shared/system-stats.repository.d1.ts +48 -48
  217. package/src/types.ts +90 -87
  218. package/src/upload.ts +198 -198
  219. package/src/widget.test.ts +85 -0
  220. package/src/widget.ts +251 -212
  221. package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
  222. package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
@@ -1,308 +1,308 @@
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 { parseTemplateKey } from '../template-grammar'
7
- import type { ParsedKey, VarStep, InlineCondition } from '../template-grammar'
8
-
9
- describe('parseTemplateKey', () => {
10
- describe('simple keys', () => {
11
- it('returns simple for a plain field name', () => {
12
- expect(parseTemplateKey('title')).toEqual<ParsedKey>({ kind: 'simple', path: 'title' })
13
- })
14
-
15
- it('returns simple for a dot-path', () => {
16
- expect(parseTemplateKey('author.name')).toEqual<ParsedKey>({ kind: 'simple', path: 'author.name' })
17
- })
18
-
19
- it('returns simple for _count (legacy)', () => {
20
- expect(parseTemplateKey('_count')).toEqual<ParsedKey>({ kind: 'simple', path: '_count' })
21
- })
22
-
23
- it('returns null for empty string', () => {
24
- expect(parseTemplateKey('')).toBeNull()
25
- })
26
- })
27
-
28
- describe('two-token sugar: <scope>:<field>', () => {
29
- it('defaults to lastone selector for <seedSlug>:<field>', () => {
30
- expect(parseTemplateKey('customers:email')).toEqual<ParsedKey>({
31
- kind: 'scoped',
32
- scope: 'customers',
33
- selector: { kind: 'lastone' },
34
- op: 'field',
35
- field: 'email',
36
- })
37
- })
38
-
39
- it('defaults to lastone selector for this:<field>', () => {
40
- expect(parseTemplateKey('this:email')).toEqual<ParsedKey>({
41
- kind: 'scoped',
42
- scope: 'this',
43
- selector: { kind: 'lastone' },
44
- op: 'field',
45
- field: 'email',
46
- })
47
- })
48
-
49
- it('sugar: batch:count → batch:all:count', () => {
50
- expect(parseTemplateKey('batch:count')).toEqual<ParsedKey>({
51
- kind: 'scoped',
52
- scope: 'batch',
53
- selector: { kind: 'all' },
54
- op: 'count',
55
- field: null,
56
- })
57
- })
58
-
59
- it('sugar: customers:sum → customers:all:sum (aggregate sugar without subfield)', () => {
60
- const result = parseTemplateKey('customers:sum')
61
- expect(result).toMatchObject({ kind: 'scoped', scope: 'customers', selector: { kind: 'all' }, op: 'sum' })
62
- })
63
- })
64
-
65
- describe('three-token: <scope>:<selector>:<field>', () => {
66
- it('lastone selector', () => {
67
- expect(parseTemplateKey('customers:lastone:email')).toEqual<ParsedKey>({
68
- kind: 'scoped',
69
- scope: 'customers',
70
- selector: { kind: 'lastone' },
71
- op: 'field',
72
- field: 'email',
73
- })
74
- })
75
-
76
- it('firstone selector', () => {
77
- expect(parseTemplateKey('customers:firstone:name')).toEqual<ParsedKey>({
78
- kind: 'scoped',
79
- scope: 'customers',
80
- selector: { kind: 'firstone' },
81
- op: 'field',
82
- field: 'name',
83
- })
84
- })
85
-
86
- it('byid selector', () => {
87
- expect(parseTemplateKey('customers:byid(c_42):name')).toEqual<ParsedKey>({
88
- kind: 'scoped',
89
- scope: 'customers',
90
- selector: { kind: 'byid', id: 'c_42' },
91
- op: 'field',
92
- field: 'name',
93
- })
94
- })
95
-
96
- it('where selector', () => {
97
- expect(parseTemplateKey('orders:where(status=paid):total')).toEqual<ParsedKey>({
98
- kind: 'scoped',
99
- scope: 'orders',
100
- selector: { kind: 'where', alias: 'status', value: 'paid' },
101
- op: 'field',
102
- field: 'total',
103
- })
104
- })
105
-
106
- it('all:count aggregate', () => {
107
- expect(parseTemplateKey('orders:all:count')).toEqual<ParsedKey>({
108
- kind: 'scoped',
109
- scope: 'orders',
110
- selector: { kind: 'all' },
111
- op: 'count',
112
- field: null,
113
- })
114
- })
115
-
116
- it('all:sum:<subfield> aggregate', () => {
117
- expect(parseTemplateKey('orders:all:sum:total')).toEqual<ParsedKey>({
118
- kind: 'scoped',
119
- scope: 'orders',
120
- selector: { kind: 'all' },
121
- op: 'sum',
122
- field: 'total',
123
- })
124
- })
125
-
126
- it('all:avg aggregate', () => {
127
- expect(parseTemplateKey('orders:all:avg:amount')).toMatchObject({
128
- kind: 'scoped', scope: 'orders', op: 'avg', field: 'amount',
129
- })
130
- })
131
-
132
- it('all:min aggregate', () => {
133
- expect(parseTemplateKey('orders:all:min:price')).toMatchObject({
134
- kind: 'scoped', scope: 'orders', op: 'min', field: 'price',
135
- })
136
- })
137
-
138
- it('all:max aggregate', () => {
139
- expect(parseTemplateKey('orders:all:max:score')).toMatchObject({
140
- kind: 'scoped', scope: 'orders', op: 'max', field: 'score',
141
- })
142
- })
143
-
144
- it('all:pluck:<subfield> aggregate', () => {
145
- expect(parseTemplateKey('customers:all:pluck:email')).toEqual<ParsedKey>({
146
- kind: 'scoped',
147
- scope: 'customers',
148
- selector: { kind: 'all' },
149
- op: 'pluck',
150
- field: 'email',
151
- })
152
- })
153
-
154
- it('batch:all:count', () => {
155
- expect(parseTemplateKey('batch:all:count')).toEqual<ParsedKey>({
156
- kind: 'scoped',
157
- scope: 'batch',
158
- selector: { kind: 'all' },
159
- op: 'count',
160
- field: null,
161
- })
162
- })
163
-
164
- it('batch:all:pluck:email', () => {
165
- expect(parseTemplateKey('batch:all:pluck:email')).toMatchObject({
166
- kind: 'scoped', scope: 'batch', op: 'pluck', field: 'email',
167
- })
168
- })
169
- })
170
-
171
- describe('rejection rules', () => {
172
- it('returns null when aggregate used without all selector', () => {
173
- expect(parseTemplateKey('orders:lastone:sum')).toBeNull()
174
- })
175
-
176
- it('returns null when selector token is unrecognised', () => {
177
- expect(parseTemplateKey('orders:unknown:field')).toBeNull()
178
- })
179
-
180
- it('returns null when there are no field tokens after selector', () => {
181
- expect(parseTemplateKey('orders:lastone')).toEqual<ParsedKey>({
182
- kind: 'scoped',
183
- scope: 'orders',
184
- selector: { kind: 'lastone' },
185
- op: 'field',
186
- field: 'lastone',
187
- })
188
- })
189
-
190
- it('colons inside parens do not split tokens', () => {
191
- const result = parseTemplateKey('customers:byid(ns:id-123):name')
192
- expect(result).toMatchObject({
193
- kind: 'scoped',
194
- scope: 'customers',
195
- selector: { kind: 'byid', id: 'ns:id-123' },
196
- op: 'field',
197
- field: 'name',
198
- })
199
- })
200
- })
201
-
202
- describe('scoped with this scope', () => {
203
- it('this:lastone:field', () => {
204
- expect(parseTemplateKey('this:lastone:name')).toMatchObject({
205
- kind: 'scoped', scope: 'this', selector: { kind: 'lastone' }, op: 'field', field: 'name',
206
- })
207
- })
208
- })
209
- })
210
-
211
- // ── Sprint 07 var_access cases ─────────────────────────────────────────────────
212
-
213
- describe('parseTemplateKey — var_access (Sprint 07)', () => {
214
- it('1 — ordini.array[a,b].count → var_access with array + agg:count', () => {
215
- const result = parseTemplateKey('ordini.array[a,b].count')
216
- expect(result).toEqual<ParsedKey>({
217
- kind: 'var_access',
218
- name: 'ordini',
219
- steps: [
220
- { type: 'array', ids: ['a', 'b'] },
221
- { type: 'agg', op: 'count' },
222
- ],
223
- conditions: [],
224
- })
225
- })
226
-
227
- it('2 — ordini.count.(status=paid) → var_access with agg:count + condition', () => {
228
- const result = parseTemplateKey('ordini.count.(status=paid)')
229
- expect(result).toEqual<ParsedKey>({
230
- kind: 'var_access',
231
- name: 'ordini',
232
- steps: [{ type: 'agg', op: 'count' }],
233
- conditions: [{ column: 'status', op: '=', value: 'paid' }],
234
- })
235
- })
236
-
237
- it('3 — ordini.sum.total.(amount>100) → var_access with agg:sum:total + condition', () => {
238
- const result = parseTemplateKey('ordini.sum.total.(amount>100)')
239
- expect(result).toEqual<ParsedKey>({
240
- kind: 'var_access',
241
- name: 'ordini',
242
- steps: [{ type: 'agg', op: 'sum', field: 'total' }],
243
- conditions: [{ column: 'amount', op: '>', value: '100' }],
244
- })
245
- })
246
-
247
- it('4 — ordini.firstone.id.(status=paid) → nav + field + condition', () => {
248
- const result = parseTemplateKey('ordini.firstone.id.(status=paid)')
249
- expect(result).toEqual<ParsedKey>({
250
- kind: 'var_access',
251
- name: 'ordini',
252
- steps: [
253
- { type: 'nav', nav: 'firstone' },
254
- { type: 'field', name: 'id' },
255
- ],
256
- conditions: [{ column: 'status', op: '=', value: 'paid' }],
257
- })
258
- })
259
-
260
- it('5 — ordini.array[a,b].(status=paid).count → array + condition + agg', () => {
261
- const result = parseTemplateKey('ordini.array[a,b].(status=paid).count')
262
- expect(result).toMatchObject({
263
- kind: 'var_access',
264
- name: 'ordini',
265
- steps: [
266
- { type: 'array', ids: ['a', 'b'] },
267
- { type: 'agg', op: 'count' },
268
- ],
269
- conditions: [{ column: 'status', op: '=', value: 'paid' }],
270
- })
271
- })
272
-
273
- it('6 — cliente.email → falls through to simple (fast path)', () => {
274
- const result = parseTemplateKey('cliente.email')
275
- expect(result).toEqual<ParsedKey>({ kind: 'simple', path: 'cliente.email' })
276
- })
277
-
278
- it('handles multiple inline conditions (AND-chained)', () => {
279
- const result = parseTemplateKey('ordini.count.(status=paid).(amount>10)')
280
- expect(result).toMatchObject({
281
- kind: 'var_access',
282
- conditions: [
283
- { column: 'status', op: '=', value: 'paid' },
284
- { column: 'amount', op: '>', value: '10' },
285
- ],
286
- })
287
- })
288
-
289
- it('handles != operator', () => {
290
- const result = parseTemplateKey('ordini.count.(status!=draft)')
291
- expect(result).toMatchObject({
292
- kind: 'var_access',
293
- conditions: [{ column: 'status', op: '!=', value: 'draft' }],
294
- })
295
- })
296
-
297
- it('handles <= and >= operators', () => {
298
- const r1 = parseTemplateKey('ordini.count.(amount<=100)')
299
- const r2 = parseTemplateKey('ordini.count.(amount>=50)')
300
- expect(r1).toMatchObject({ conditions: [{ op: '<=' }] })
301
- expect(r2).toMatchObject({ conditions: [{ op: '>=' }] })
302
- })
303
-
304
- it('plain dot-path without special chars stays simple', () => {
305
- expect(parseTemplateKey('ordini.count')).toEqual({ kind: 'simple', path: 'ordini.count' })
306
- expect(parseTemplateKey('cliente.nome')).toEqual({ kind: 'simple', path: 'cliente.nome' })
307
- })
308
- })
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 { parseTemplateKey } from '../template-grammar'
7
+ import type { ParsedKey, VarStep, InlineCondition } from '../template-grammar'
8
+
9
+ describe('parseTemplateKey', () => {
10
+ describe('simple keys', () => {
11
+ it('returns simple for a plain field name', () => {
12
+ expect(parseTemplateKey('title')).toEqual<ParsedKey>({ kind: 'simple', path: 'title' })
13
+ })
14
+
15
+ it('returns simple for a dot-path', () => {
16
+ expect(parseTemplateKey('author.name')).toEqual<ParsedKey>({ kind: 'simple', path: 'author.name' })
17
+ })
18
+
19
+ it('returns simple for _count (legacy)', () => {
20
+ expect(parseTemplateKey('_count')).toEqual<ParsedKey>({ kind: 'simple', path: '_count' })
21
+ })
22
+
23
+ it('returns null for empty string', () => {
24
+ expect(parseTemplateKey('')).toBeNull()
25
+ })
26
+ })
27
+
28
+ describe('two-token sugar: <scope>:<field>', () => {
29
+ it('defaults to lastone selector for <seedSlug>:<field>', () => {
30
+ expect(parseTemplateKey('customers:email')).toEqual<ParsedKey>({
31
+ kind: 'scoped',
32
+ scope: 'customers',
33
+ selector: { kind: 'lastone' },
34
+ op: 'field',
35
+ field: 'email',
36
+ })
37
+ })
38
+
39
+ it('defaults to lastone selector for this:<field>', () => {
40
+ expect(parseTemplateKey('this:email')).toEqual<ParsedKey>({
41
+ kind: 'scoped',
42
+ scope: 'this',
43
+ selector: { kind: 'lastone' },
44
+ op: 'field',
45
+ field: 'email',
46
+ })
47
+ })
48
+
49
+ it('sugar: batch:count → batch:all:count', () => {
50
+ expect(parseTemplateKey('batch:count')).toEqual<ParsedKey>({
51
+ kind: 'scoped',
52
+ scope: 'batch',
53
+ selector: { kind: 'all' },
54
+ op: 'count',
55
+ field: null,
56
+ })
57
+ })
58
+
59
+ it('sugar: customers:sum → customers:all:sum (aggregate sugar without subfield)', () => {
60
+ const result = parseTemplateKey('customers:sum')
61
+ expect(result).toMatchObject({ kind: 'scoped', scope: 'customers', selector: { kind: 'all' }, op: 'sum' })
62
+ })
63
+ })
64
+
65
+ describe('three-token: <scope>:<selector>:<field>', () => {
66
+ it('lastone selector', () => {
67
+ expect(parseTemplateKey('customers:lastone:email')).toEqual<ParsedKey>({
68
+ kind: 'scoped',
69
+ scope: 'customers',
70
+ selector: { kind: 'lastone' },
71
+ op: 'field',
72
+ field: 'email',
73
+ })
74
+ })
75
+
76
+ it('firstone selector', () => {
77
+ expect(parseTemplateKey('customers:firstone:name')).toEqual<ParsedKey>({
78
+ kind: 'scoped',
79
+ scope: 'customers',
80
+ selector: { kind: 'firstone' },
81
+ op: 'field',
82
+ field: 'name',
83
+ })
84
+ })
85
+
86
+ it('byid selector', () => {
87
+ expect(parseTemplateKey('customers:byid(c_42):name')).toEqual<ParsedKey>({
88
+ kind: 'scoped',
89
+ scope: 'customers',
90
+ selector: { kind: 'byid', id: 'c_42' },
91
+ op: 'field',
92
+ field: 'name',
93
+ })
94
+ })
95
+
96
+ it('where selector', () => {
97
+ expect(parseTemplateKey('orders:where(status=paid):total')).toEqual<ParsedKey>({
98
+ kind: 'scoped',
99
+ scope: 'orders',
100
+ selector: { kind: 'where', alias: 'status', value: 'paid' },
101
+ op: 'field',
102
+ field: 'total',
103
+ })
104
+ })
105
+
106
+ it('all:count aggregate', () => {
107
+ expect(parseTemplateKey('orders:all:count')).toEqual<ParsedKey>({
108
+ kind: 'scoped',
109
+ scope: 'orders',
110
+ selector: { kind: 'all' },
111
+ op: 'count',
112
+ field: null,
113
+ })
114
+ })
115
+
116
+ it('all:sum:<subfield> aggregate', () => {
117
+ expect(parseTemplateKey('orders:all:sum:total')).toEqual<ParsedKey>({
118
+ kind: 'scoped',
119
+ scope: 'orders',
120
+ selector: { kind: 'all' },
121
+ op: 'sum',
122
+ field: 'total',
123
+ })
124
+ })
125
+
126
+ it('all:avg aggregate', () => {
127
+ expect(parseTemplateKey('orders:all:avg:amount')).toMatchObject({
128
+ kind: 'scoped', scope: 'orders', op: 'avg', field: 'amount',
129
+ })
130
+ })
131
+
132
+ it('all:min aggregate', () => {
133
+ expect(parseTemplateKey('orders:all:min:price')).toMatchObject({
134
+ kind: 'scoped', scope: 'orders', op: 'min', field: 'price',
135
+ })
136
+ })
137
+
138
+ it('all:max aggregate', () => {
139
+ expect(parseTemplateKey('orders:all:max:score')).toMatchObject({
140
+ kind: 'scoped', scope: 'orders', op: 'max', field: 'score',
141
+ })
142
+ })
143
+
144
+ it('all:pluck:<subfield> aggregate', () => {
145
+ expect(parseTemplateKey('customers:all:pluck:email')).toEqual<ParsedKey>({
146
+ kind: 'scoped',
147
+ scope: 'customers',
148
+ selector: { kind: 'all' },
149
+ op: 'pluck',
150
+ field: 'email',
151
+ })
152
+ })
153
+
154
+ it('batch:all:count', () => {
155
+ expect(parseTemplateKey('batch:all:count')).toEqual<ParsedKey>({
156
+ kind: 'scoped',
157
+ scope: 'batch',
158
+ selector: { kind: 'all' },
159
+ op: 'count',
160
+ field: null,
161
+ })
162
+ })
163
+
164
+ it('batch:all:pluck:email', () => {
165
+ expect(parseTemplateKey('batch:all:pluck:email')).toMatchObject({
166
+ kind: 'scoped', scope: 'batch', op: 'pluck', field: 'email',
167
+ })
168
+ })
169
+ })
170
+
171
+ describe('rejection rules', () => {
172
+ it('returns null when aggregate used without all selector', () => {
173
+ expect(parseTemplateKey('orders:lastone:sum')).toBeNull()
174
+ })
175
+
176
+ it('returns null when selector token is unrecognised', () => {
177
+ expect(parseTemplateKey('orders:unknown:field')).toBeNull()
178
+ })
179
+
180
+ it('returns null when there are no field tokens after selector', () => {
181
+ expect(parseTemplateKey('orders:lastone')).toEqual<ParsedKey>({
182
+ kind: 'scoped',
183
+ scope: 'orders',
184
+ selector: { kind: 'lastone' },
185
+ op: 'field',
186
+ field: 'lastone',
187
+ })
188
+ })
189
+
190
+ it('colons inside parens do not split tokens', () => {
191
+ const result = parseTemplateKey('customers:byid(ns:id-123):name')
192
+ expect(result).toMatchObject({
193
+ kind: 'scoped',
194
+ scope: 'customers',
195
+ selector: { kind: 'byid', id: 'ns:id-123' },
196
+ op: 'field',
197
+ field: 'name',
198
+ })
199
+ })
200
+ })
201
+
202
+ describe('scoped with this scope', () => {
203
+ it('this:lastone:field', () => {
204
+ expect(parseTemplateKey('this:lastone:name')).toMatchObject({
205
+ kind: 'scoped', scope: 'this', selector: { kind: 'lastone' }, op: 'field', field: 'name',
206
+ })
207
+ })
208
+ })
209
+ })
210
+
211
+ // ── Sprint 07 var_access cases ─────────────────────────────────────────────────
212
+
213
+ describe('parseTemplateKey — var_access (Sprint 07)', () => {
214
+ it('1 — ordini.array[a,b].count → var_access with array + agg:count', () => {
215
+ const result = parseTemplateKey('ordini.array[a,b].count')
216
+ expect(result).toEqual<ParsedKey>({
217
+ kind: 'var_access',
218
+ name: 'ordini',
219
+ steps: [
220
+ { type: 'array', ids: ['a', 'b'] },
221
+ { type: 'agg', op: 'count' },
222
+ ],
223
+ conditions: [],
224
+ })
225
+ })
226
+
227
+ it('2 — ordini.count.(status=paid) → var_access with agg:count + condition', () => {
228
+ const result = parseTemplateKey('ordini.count.(status=paid)')
229
+ expect(result).toEqual<ParsedKey>({
230
+ kind: 'var_access',
231
+ name: 'ordini',
232
+ steps: [{ type: 'agg', op: 'count' }],
233
+ conditions: [{ column: 'status', op: '=', value: 'paid' }],
234
+ })
235
+ })
236
+
237
+ it('3 — ordini.sum.total.(amount>100) → var_access with agg:sum:total + condition', () => {
238
+ const result = parseTemplateKey('ordini.sum.total.(amount>100)')
239
+ expect(result).toEqual<ParsedKey>({
240
+ kind: 'var_access',
241
+ name: 'ordini',
242
+ steps: [{ type: 'agg', op: 'sum', field: 'total' }],
243
+ conditions: [{ column: 'amount', op: '>', value: '100' }],
244
+ })
245
+ })
246
+
247
+ it('4 — ordini.firstone.id.(status=paid) → nav + field + condition', () => {
248
+ const result = parseTemplateKey('ordini.firstone.id.(status=paid)')
249
+ expect(result).toEqual<ParsedKey>({
250
+ kind: 'var_access',
251
+ name: 'ordini',
252
+ steps: [
253
+ { type: 'nav', nav: 'firstone' },
254
+ { type: 'field', name: 'id' },
255
+ ],
256
+ conditions: [{ column: 'status', op: '=', value: 'paid' }],
257
+ })
258
+ })
259
+
260
+ it('5 — ordini.array[a,b].(status=paid).count → array + condition + agg', () => {
261
+ const result = parseTemplateKey('ordini.array[a,b].(status=paid).count')
262
+ expect(result).toMatchObject({
263
+ kind: 'var_access',
264
+ name: 'ordini',
265
+ steps: [
266
+ { type: 'array', ids: ['a', 'b'] },
267
+ { type: 'agg', op: 'count' },
268
+ ],
269
+ conditions: [{ column: 'status', op: '=', value: 'paid' }],
270
+ })
271
+ })
272
+
273
+ it('6 — cliente.email → falls through to simple (fast path)', () => {
274
+ const result = parseTemplateKey('cliente.email')
275
+ expect(result).toEqual<ParsedKey>({ kind: 'simple', path: 'cliente.email' })
276
+ })
277
+
278
+ it('handles multiple inline conditions (AND-chained)', () => {
279
+ const result = parseTemplateKey('ordini.count.(status=paid).(amount>10)')
280
+ expect(result).toMatchObject({
281
+ kind: 'var_access',
282
+ conditions: [
283
+ { column: 'status', op: '=', value: 'paid' },
284
+ { column: 'amount', op: '>', value: '10' },
285
+ ],
286
+ })
287
+ })
288
+
289
+ it('handles != operator', () => {
290
+ const result = parseTemplateKey('ordini.count.(status!=draft)')
291
+ expect(result).toMatchObject({
292
+ kind: 'var_access',
293
+ conditions: [{ column: 'status', op: '!=', value: 'draft' }],
294
+ })
295
+ })
296
+
297
+ it('handles <= and >= operators', () => {
298
+ const r1 = parseTemplateKey('ordini.count.(amount<=100)')
299
+ const r2 = parseTemplateKey('ordini.count.(amount>=50)')
300
+ expect(r1).toMatchObject({ conditions: [{ op: '<=' }] })
301
+ expect(r2).toMatchObject({ conditions: [{ op: '>=' }] })
302
+ })
303
+
304
+ it('plain dot-path without special chars stays simple', () => {
305
+ expect(parseTemplateKey('ordini.count')).toEqual({ kind: 'simple', path: 'ordini.count' })
306
+ expect(parseTemplateKey('cliente.nome')).toEqual({ kind: 'simple', path: 'cliente.nome' })
307
+ })
308
+ })