@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,79 +1,83 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { D1IdempotencyRepository } from './idempotency.repository.d1'
3
-
4
- function makeMockDb(opts: { firstResult?: unknown } = {}) {
5
- const { firstResult = null } = opts
6
- const runMock = vi.fn().mockResolvedValue({ success: true })
7
- const firstMock = vi.fn().mockResolvedValue(firstResult)
8
- const bindMock = vi.fn(() => ({ run: runMock, first: firstMock }))
9
- const prepareMock = vi.fn(() => ({ bind: bindMock }))
10
- return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock }
11
- }
12
-
13
- describe('D1IdempotencyRepository', () => {
14
- describe('lookup', () => {
15
- it('returns a mapped IdempotencyRecord when the key is found', async () => {
16
- const row = {
17
- idempotency_key: 'idem-1',
18
- request_fingerprint: 'fp-abc',
19
- response_status: 200,
20
- response_body: '{"ok":true}',
21
- expires_at: 9999,
22
- }
23
- const { db } = makeMockDb({ firstResult: row })
24
- const result = await new D1IdempotencyRepository(db).lookup('idem-1')
25
- expect(result).toEqual({
26
- key: 'idem-1',
27
- fingerprint: 'fp-abc',
28
- responseStatus: 200,
29
- responseBody: '{"ok":true}',
30
- expiresAt: 9999,
31
- })
32
- })
33
-
34
- it('returns null when the key is not found', async () => {
35
- const { db } = makeMockDb({ firstResult: null })
36
- expect(await new D1IdempotencyRepository(db).lookup('missing')).toBeNull()
37
- })
38
-
39
- it('queries the correct table', async () => {
40
- const { db, prepareMock } = makeMockDb()
41
- await new D1IdempotencyRepository(db).lookup('k')
42
- expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('public_idempotency_keys'))
43
- })
44
- })
45
-
46
- describe('store', () => {
47
- it('calls INSERT INTO public_idempotency_keys with upsert on conflict', async () => {
48
- const { db, prepareMock } = makeMockDb()
49
- await new D1IdempotencyRepository(db).store({
50
- key: 'idem-1', fingerprint: 'fp', responseStatus: 201, responseBody: '{}', expiresAt: 9999,
51
- })
52
- const sql = prepareMock.mock.calls[0][0] as string
53
- expect(sql).toContain('INSERT INTO public_idempotency_keys')
54
- expect(sql).toContain('ON CONFLICT')
55
- })
56
-
57
- it('binds key, fingerprint, status, body, and expiresAt', async () => {
58
- const { db, bindMock } = makeMockDb()
59
- await new D1IdempotencyRepository(db).store({
60
- key: 'idem-1', fingerprint: 'fp-x', responseStatus: 200, responseBody: '{"ok":1}', expiresAt: 8888,
61
- })
62
- const args = bindMock.mock.calls[0] as unknown[]
63
- expect(args).toContain('idem-1')
64
- expect(args).toContain('fp-x')
65
- expect(args).toContain(200)
66
- expect(args).toContain('{"ok":1}')
67
- expect(args).toContain(8888)
68
- })
69
- })
70
-
71
- describe('cleanup', () => {
72
- it('calls DELETE with the expiration timestamp', async () => {
73
- const { db, prepareMock, bindMock } = makeMockDb()
74
- await new D1IdempotencyRepository(db).cleanup(12345)
75
- expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM public_idempotency_keys'))
76
- expect(bindMock).toHaveBeenCalledWith(12345)
77
- })
78
- })
79
- })
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, vi } from 'vitest'
6
+ import { D1IdempotencyRepository } from './idempotency.repository.d1'
7
+
8
+ function makeMockDb(opts: { firstResult?: unknown } = {}) {
9
+ const { firstResult = null } = opts
10
+ const runMock = vi.fn().mockResolvedValue({ success: true })
11
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
12
+ const bindMock = vi.fn(() => ({ run: runMock, first: firstMock }))
13
+ const prepareMock = vi.fn(() => ({ bind: bindMock }))
14
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock }
15
+ }
16
+
17
+ describe('D1IdempotencyRepository', () => {
18
+ describe('lookup', () => {
19
+ it('returns a mapped IdempotencyRecord when the key is found', async () => {
20
+ const row = {
21
+ idempotency_key: 'idem-1',
22
+ request_fingerprint: 'fp-abc',
23
+ response_status: 200,
24
+ response_body: '{"ok":true}',
25
+ expires_at: 9999,
26
+ }
27
+ const { db } = makeMockDb({ firstResult: row })
28
+ const result = await new D1IdempotencyRepository(db).lookup('idem-1')
29
+ expect(result).toEqual({
30
+ key: 'idem-1',
31
+ fingerprint: 'fp-abc',
32
+ responseStatus: 200,
33
+ responseBody: '{"ok":true}',
34
+ expiresAt: 9999,
35
+ })
36
+ })
37
+
38
+ it('returns null when the key is not found', async () => {
39
+ const { db } = makeMockDb({ firstResult: null })
40
+ expect(await new D1IdempotencyRepository(db).lookup('missing')).toBeNull()
41
+ })
42
+
43
+ it('queries the correct table', async () => {
44
+ const { db, prepareMock } = makeMockDb()
45
+ await new D1IdempotencyRepository(db).lookup('k')
46
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('public_idempotency_keys'))
47
+ })
48
+ })
49
+
50
+ describe('store', () => {
51
+ it('calls INSERT INTO public_idempotency_keys with upsert on conflict', async () => {
52
+ const { db, prepareMock } = makeMockDb()
53
+ await new D1IdempotencyRepository(db).store({
54
+ key: 'idem-1', fingerprint: 'fp', responseStatus: 201, responseBody: '{}', expiresAt: 9999,
55
+ })
56
+ const sql = prepareMock.mock.calls[0][0] as string
57
+ expect(sql).toContain('INSERT INTO public_idempotency_keys')
58
+ expect(sql).toContain('ON CONFLICT')
59
+ })
60
+
61
+ it('binds key, fingerprint, status, body, and expiresAt', async () => {
62
+ const { db, bindMock } = makeMockDb()
63
+ await new D1IdempotencyRepository(db).store({
64
+ key: 'idem-1', fingerprint: 'fp-x', responseStatus: 200, responseBody: '{"ok":1}', expiresAt: 8888,
65
+ })
66
+ const args = bindMock.mock.calls[0] as unknown[]
67
+ expect(args).toContain('idem-1')
68
+ expect(args).toContain('fp-x')
69
+ expect(args).toContain(200)
70
+ expect(args).toContain('{"ok":1}')
71
+ expect(args).toContain(8888)
72
+ })
73
+ })
74
+
75
+ describe('cleanup', () => {
76
+ it('calls DELETE with the expiration timestamp', async () => {
77
+ const { db, prepareMock, bindMock } = makeMockDb()
78
+ await new D1IdempotencyRepository(db).cleanup(12345)
79
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM public_idempotency_keys'))
80
+ expect(bindMock).toHaveBeenCalledWith(12345)
81
+ })
82
+ })
83
+ })
@@ -1,56 +1,60 @@
1
- import { IdempotencyRepository, IdempotencyRecord } from '@beechcms/core'
2
- import { BaseD1Repository } from './base.repository.d1.js'
3
-
4
- export class D1IdempotencyRepository extends BaseD1Repository implements IdempotencyRepository {
5
- /**
6
- * Looks up an idempotency record by its key.
7
- */
8
- async lookup(idempotencyKey: string): Promise<IdempotencyRecord | null> {
9
- const idempotencyRecordResult = await this.database.prepare(
10
- `SELECT idempotency_key, request_fingerprint, response_status, response_body, expires_at
11
- FROM public_idempotency_keys WHERE idempotency_key = ? LIMIT 1`
12
- ).bind(idempotencyKey).first<any>()
13
-
14
- if (!idempotencyRecordResult) {
15
- return null
16
- }
17
-
18
- return {
19
- key: idempotencyRecordResult.idempotency_key,
20
- fingerprint: idempotencyRecordResult.request_fingerprint,
21
- responseStatus: idempotencyRecordResult.response_status,
22
- responseBody: idempotencyRecordResult.response_body,
23
- expiresAt: idempotencyRecordResult.expires_at
24
- }
25
- }
26
-
27
- /**
28
- * Stores or updates an idempotency record.
29
- */
30
- async store(idempotencyData: IdempotencyRecord): Promise<void> {
31
- await this.database.prepare(
32
- `INSERT INTO public_idempotency_keys (idempotency_key, request_fingerprint, response_status, response_body, created_at, expires_at)
33
- VALUES (?, ?, ?, ?, ?, ?)
34
- ON CONFLICT(idempotency_key) DO UPDATE SET
35
- request_fingerprint = excluded.request_fingerprint,
36
- response_status = excluded.response_status,
37
- response_body = excluded.response_body,
38
- created_at = excluded.created_at,
39
- expires_at = excluded.expires_at`
40
- ).bind(
41
- idempotencyData.key,
42
- idempotencyData.fingerprint,
43
- idempotencyData.responseStatus,
44
- idempotencyData.responseBody,
45
- Math.floor(Date.now() / 1000),
46
- idempotencyData.expiresAt
47
- ).run()
48
- }
49
-
50
- /**
51
- * Cleans up expired idempotency records.
52
- */
53
- async cleanup(expirationTimestamp: number): Promise<void> {
54
- await this.database.prepare(`DELETE FROM public_idempotency_keys WHERE expires_at < ?`).bind(expirationTimestamp).run()
55
- }
56
- }
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 { IdempotencyRepository, IdempotencyRecord } from '@beechcms/core'
6
+ import { BaseD1Repository } from './base.repository.d1.js'
7
+
8
+ export class D1IdempotencyRepository extends BaseD1Repository implements IdempotencyRepository {
9
+ /**
10
+ * Looks up an idempotency record by its key.
11
+ */
12
+ async lookup(idempotencyKey: string): Promise<IdempotencyRecord | null> {
13
+ const idempotencyRecordResult = await this.database.prepare(
14
+ `SELECT idempotency_key, request_fingerprint, response_status, response_body, expires_at
15
+ FROM public_idempotency_keys WHERE idempotency_key = ? LIMIT 1`
16
+ ).bind(idempotencyKey).first<any>()
17
+
18
+ if (!idempotencyRecordResult) {
19
+ return null
20
+ }
21
+
22
+ return {
23
+ key: idempotencyRecordResult.idempotency_key,
24
+ fingerprint: idempotencyRecordResult.request_fingerprint,
25
+ responseStatus: idempotencyRecordResult.response_status,
26
+ responseBody: idempotencyRecordResult.response_body,
27
+ expiresAt: idempotencyRecordResult.expires_at
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Stores or updates an idempotency record.
33
+ */
34
+ async store(idempotencyData: IdempotencyRecord): Promise<void> {
35
+ await this.database.prepare(
36
+ `INSERT INTO public_idempotency_keys (idempotency_key, request_fingerprint, response_status, response_body, created_at, expires_at)
37
+ VALUES (?, ?, ?, ?, ?, ?)
38
+ ON CONFLICT(idempotency_key) DO UPDATE SET
39
+ request_fingerprint = excluded.request_fingerprint,
40
+ response_status = excluded.response_status,
41
+ response_body = excluded.response_body,
42
+ created_at = excluded.created_at,
43
+ expires_at = excluded.expires_at`
44
+ ).bind(
45
+ idempotencyData.key,
46
+ idempotencyData.fingerprint,
47
+ idempotencyData.responseStatus,
48
+ idempotencyData.responseBody,
49
+ Math.floor(Date.now() / 1000),
50
+ idempotencyData.expiresAt
51
+ ).run()
52
+ }
53
+
54
+ /**
55
+ * Cleans up expired idempotency records.
56
+ */
57
+ async cleanup(expirationTimestamp: number): Promise<void> {
58
+ await this.database.prepare(`DELETE FROM public_idempotency_keys WHERE expires_at < ?`).bind(expirationTimestamp).run()
59
+ }
60
+ }
@@ -1,15 +1,19 @@
1
- import type { IActivityLogger, ActivityLogEntry } from '@beechcms/core'
2
-
3
- /**
4
- * Test double for {@link IActivityLogger}.
5
- *
6
- * Captures every call into a public array so test assertions can verify
7
- * audit-trail side effects without touching D1. Preserves insertion order.
8
- */
9
- export class InMemoryActivityLogger implements IActivityLogger {
10
- public readonly entries: ActivityLogEntry[] = []
11
-
12
- log(entry: ActivityLogEntry): void {
13
- this.entries.push(entry)
14
- }
15
- }
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 { IActivityLogger, ActivityLogEntry } from '@beechcms/core'
6
+
7
+ /**
8
+ * Test double for {@link IActivityLogger}.
9
+ *
10
+ * Captures every call into a public array so test assertions can verify
11
+ * audit-trail side effects without touching D1. Preserves insertion order.
12
+ */
13
+ export class InMemoryActivityLogger implements IActivityLogger {
14
+ public readonly entries: ActivityLogEntry[] = []
15
+
16
+ log(entry: ActivityLogEntry): void {
17
+ this.entries.push(entry)
18
+ }
19
+ }
@@ -1,15 +1,19 @@
1
- import type { INotificationService, CreateNotificationInput } from '@beechcms/core'
2
-
3
- /**
4
- * Test double for {@link INotificationService}.
5
- *
6
- * Captures every call into a public array so tests can assert what would have
7
- * been delivered without touching D1.
8
- */
9
- export class InMemoryNotificationService implements INotificationService {
10
- public readonly receivedNotifications: CreateNotificationInput[] = []
11
-
12
- notify(input: CreateNotificationInput): void {
13
- this.receivedNotifications.push(input)
14
- }
15
- }
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 { INotificationService, CreateNotificationInput } from '@beechcms/core'
6
+
7
+ /**
8
+ * Test double for {@link INotificationService}.
9
+ *
10
+ * Captures every call into a public array so tests can assert what would have
11
+ * been delivered without touching D1.
12
+ */
13
+ export class InMemoryNotificationService implements INotificationService {
14
+ public readonly receivedNotifications: CreateNotificationInput[] = []
15
+
16
+ notify(input: CreateNotificationInput): void {
17
+ this.receivedNotifications.push(input)
18
+ }
19
+ }
@@ -0,0 +1,58 @@
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 { ISeedRepository, Seed, SeedRecord } from '@beechcms/core'
6
+
7
+ /**
8
+ * In-memory ISeedRepository for testing and for the config.seeds back-compat shim.
9
+ * Returns a fixed seed list with a static version token.
10
+ */
11
+ export class InMemorySeedRepository implements ISeedRepository {
12
+ private readonly seeds: Seed[]
13
+ private version = 1
14
+
15
+ constructor(seeds: Seed[]) {
16
+ this.seeds = seeds.filter(s => s && typeof s === 'object' && 'slug' in s)
17
+ }
18
+
19
+ async listActive(): Promise<Seed[]> {
20
+ return this.seeds
21
+ }
22
+
23
+ async listAll(): Promise<SeedRecord[]> {
24
+ return this.seeds.map(s => ({
25
+ slug: s.slug,
26
+ definition: s,
27
+ status: 'active' as const,
28
+ source: 'code' as const,
29
+ createdAt: 0,
30
+ updatedAt: 0,
31
+ }))
32
+ }
33
+
34
+ async get(slug: string): Promise<SeedRecord | null> {
35
+ const s = this.seeds.find(s => s.slug === slug)
36
+ if (!s) return null
37
+ return { slug: s.slug, definition: s, status: 'active', source: 'code', createdAt: 0, updatedAt: 0 }
38
+ }
39
+ upsert(_slug: string, _definition: Seed, _source?: 'code' | 'runtime'): Promise<void> {
40
+ return Promise.resolve()
41
+ }
42
+
43
+ softDelete(_slug: string): Promise<void> {
44
+ return Promise.resolve()
45
+ }
46
+
47
+ hardDelete(_slug: string): Promise<void> {
48
+ return Promise.resolve()
49
+ }
50
+
51
+ async getRegistryVersion(): Promise<number> {
52
+ return this.version
53
+ }
54
+
55
+ async bumpRegistryVersion(): Promise<number> {
56
+ return ++this.version
57
+ }
58
+ }
@@ -1,103 +1,107 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { D1MediaRepository } from './media.repository.d1'
3
-
4
- function makeMockDb(opts: { firstResult?: unknown; allResults?: unknown[] } = {}) {
5
- const { firstResult = null, allResults = [] } = opts
6
- const runMock = vi.fn().mockResolvedValue({ success: true })
7
- const firstMock = vi.fn().mockResolvedValue(firstResult)
8
- const allMock = vi.fn().mockResolvedValue({ results: allResults })
9
- // bind() returns the same interface so chains like prepare().bind().all() work.
10
- // The statement also exposes first/all/run directly for queries that skip bind().
11
- const bindMock = vi.fn(() => ({ run: runMock, first: firstMock, all: allMock }))
12
- const stmt = { bind: bindMock, run: runMock, first: firstMock, all: allMock }
13
- const prepareMock = vi.fn(() => stmt)
14
- return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock, allMock }
15
- }
16
-
17
- describe('D1MediaRepository', () => {
18
- describe('trackUpload', () => {
19
- it('calls INSERT INTO media_objects with correct bound values', async () => {
20
- const { db, prepareMock, bindMock } = makeMockDb()
21
- const obj = { key: 'k1', filename: 'img.png', mime_type: 'image/png', size_bytes: 1024, uploaded_by: 'user-1' }
22
- await new D1MediaRepository(db).trackUpload(obj)
23
- expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO media_objects'))
24
- expect(bindMock).toHaveBeenCalledWith('k1', 'img.png', 'image/png', 1024, 'user-1')
25
- })
26
- })
27
-
28
- describe('getByKey', () => {
29
- it('returns the media object when found', async () => {
30
- const row = { key: 'k1', filename: 'img.png', mime_type: 'image/png', size_bytes: 512, uploaded_by: 'u1', created_at: 1000 }
31
- const { db } = makeMockDb({ firstResult: row })
32
- const result = await new D1MediaRepository(db).getByKey('k1')
33
- expect(result).toEqual(row)
34
- })
35
-
36
- it('returns null when the key is not found', async () => {
37
- const { db } = makeMockDb({ firstResult: null })
38
- expect(await new D1MediaRepository(db).getByKey('ghost')).toBeNull()
39
- })
40
-
41
- it('passes the key as bound value', async () => {
42
- const { db, bindMock } = makeMockDb()
43
- await new D1MediaRepository(db).getByKey('my-key')
44
- expect(bindMock).toHaveBeenCalledWith('my-key')
45
- })
46
- })
47
-
48
- describe('untrack', () => {
49
- it('calls DELETE FROM media_objects with the key', async () => {
50
- const { db, prepareMock, bindMock } = makeMockDb()
51
- await new D1MediaRepository(db).untrack('k1')
52
- expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM media_objects'))
53
- expect(bindMock).toHaveBeenCalledWith('k1')
54
- })
55
- })
56
-
57
- describe('list', () => {
58
- it('returns items and total from D1', async () => {
59
- const rows = [
60
- { key: 'k1', filename: 'a.png', mime_type: 'image/png', size_bytes: 100, uploaded_by: 'u1', created_at: 1000 },
61
- ]
62
- const { db, allMock, firstMock } = makeMockDb()
63
- // list() calls all() for items then first() for count (without bind)
64
- allMock.mockResolvedValueOnce({ results: rows })
65
- firstMock.mockResolvedValueOnce({ total: 1 })
66
-
67
- const result = await new D1MediaRepository(db).list({ limit: 10, offset: 0 })
68
- expect(result.items).toHaveLength(1)
69
- expect(result.total).toBe(1)
70
- })
71
-
72
- it('returns empty items and 0 total when D1 returns nothing', async () => {
73
- const { db, allMock, firstMock } = makeMockDb()
74
- allMock.mockResolvedValueOnce({ results: [] })
75
- firstMock.mockResolvedValueOnce(null) // count row is null → 0
76
-
77
- const result = await new D1MediaRepository(db).list({ limit: 10, offset: 0 })
78
- expect(result.items).toEqual([])
79
- expect(result.total).toBe(0)
80
- })
81
-
82
- it('passes limit and offset as bound values for the list query', async () => {
83
- const { db, bindMock, allMock, firstMock } = makeMockDb()
84
- allMock.mockResolvedValueOnce({ results: [] })
85
- firstMock.mockResolvedValueOnce({ total: 0 })
86
-
87
- await new D1MediaRepository(db).list({ limit: 5, offset: 10 })
88
- expect(bindMock).toHaveBeenCalledWith(5, 10)
89
- })
90
- })
91
-
92
- describe('count', () => {
93
- it('returns the count from D1', async () => {
94
- const { db } = makeMockDb({ firstResult: { total: 42 } })
95
- expect(await new D1MediaRepository(db).count()).toBe(42)
96
- })
97
-
98
- it('returns 0 when D1 returns null', async () => {
99
- const { db } = makeMockDb({ firstResult: null })
100
- expect(await new D1MediaRepository(db).count()).toBe(0)
101
- })
102
- })
103
- })
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, vi } from 'vitest'
6
+ import { D1MediaRepository } from './media.repository.d1'
7
+
8
+ function makeMockDb(opts: { firstResult?: unknown; allResults?: unknown[] } = {}) {
9
+ const { firstResult = null, allResults = [] } = opts
10
+ const runMock = vi.fn().mockResolvedValue({ success: true })
11
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
12
+ const allMock = vi.fn().mockResolvedValue({ results: allResults })
13
+ // bind() returns the same interface so chains like prepare().bind().all() work.
14
+ // The statement also exposes first/all/run directly for queries that skip bind().
15
+ const bindMock = vi.fn(() => ({ run: runMock, first: firstMock, all: allMock }))
16
+ const stmt = { bind: bindMock, run: runMock, first: firstMock, all: allMock }
17
+ const prepareMock = vi.fn(() => stmt)
18
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock, allMock }
19
+ }
20
+
21
+ describe('D1MediaRepository', () => {
22
+ describe('trackUpload', () => {
23
+ it('calls INSERT INTO media_objects with correct bound values', async () => {
24
+ const { db, prepareMock, bindMock } = makeMockDb()
25
+ const obj = { key: 'k1', filename: 'img.png', mime_type: 'image/png', size_bytes: 1024, uploaded_by: 'user-1' }
26
+ await new D1MediaRepository(db).trackUpload(obj)
27
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO media_objects'))
28
+ expect(bindMock).toHaveBeenCalledWith('k1', 'img.png', 'image/png', 1024, 'user-1')
29
+ })
30
+ })
31
+
32
+ describe('getByKey', () => {
33
+ it('returns the media object when found', async () => {
34
+ const row = { key: 'k1', filename: 'img.png', mime_type: 'image/png', size_bytes: 512, uploaded_by: 'u1', created_at: 1000 }
35
+ const { db } = makeMockDb({ firstResult: row })
36
+ const result = await new D1MediaRepository(db).getByKey('k1')
37
+ expect(result).toEqual(row)
38
+ })
39
+
40
+ it('returns null when the key is not found', async () => {
41
+ const { db } = makeMockDb({ firstResult: null })
42
+ expect(await new D1MediaRepository(db).getByKey('ghost')).toBeNull()
43
+ })
44
+
45
+ it('passes the key as bound value', async () => {
46
+ const { db, bindMock } = makeMockDb()
47
+ await new D1MediaRepository(db).getByKey('my-key')
48
+ expect(bindMock).toHaveBeenCalledWith('my-key')
49
+ })
50
+ })
51
+
52
+ describe('untrack', () => {
53
+ it('calls DELETE FROM media_objects with the key', async () => {
54
+ const { db, prepareMock, bindMock } = makeMockDb()
55
+ await new D1MediaRepository(db).untrack('k1')
56
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM media_objects'))
57
+ expect(bindMock).toHaveBeenCalledWith('k1')
58
+ })
59
+ })
60
+
61
+ describe('list', () => {
62
+ it('returns items and total from D1', async () => {
63
+ const rows = [
64
+ { key: 'k1', filename: 'a.png', mime_type: 'image/png', size_bytes: 100, uploaded_by: 'u1', created_at: 1000 },
65
+ ]
66
+ const { db, allMock, firstMock } = makeMockDb()
67
+ // list() calls all() for items then first() for count (without bind)
68
+ allMock.mockResolvedValueOnce({ results: rows })
69
+ firstMock.mockResolvedValueOnce({ total: 1 })
70
+
71
+ const result = await new D1MediaRepository(db).list({ limit: 10, offset: 0 })
72
+ expect(result.items).toHaveLength(1)
73
+ expect(result.total).toBe(1)
74
+ })
75
+
76
+ it('returns empty items and 0 total when D1 returns nothing', async () => {
77
+ const { db, allMock, firstMock } = makeMockDb()
78
+ allMock.mockResolvedValueOnce({ results: [] })
79
+ firstMock.mockResolvedValueOnce(null) // count row is null → 0
80
+
81
+ const result = await new D1MediaRepository(db).list({ limit: 10, offset: 0 })
82
+ expect(result.items).toEqual([])
83
+ expect(result.total).toBe(0)
84
+ })
85
+
86
+ it('passes limit and offset as bound values for the list query', async () => {
87
+ const { db, bindMock, allMock, firstMock } = makeMockDb()
88
+ allMock.mockResolvedValueOnce({ results: [] })
89
+ firstMock.mockResolvedValueOnce({ total: 0 })
90
+
91
+ await new D1MediaRepository(db).list({ limit: 5, offset: 10 })
92
+ expect(bindMock).toHaveBeenCalledWith(5, 10)
93
+ })
94
+ })
95
+
96
+ describe('count', () => {
97
+ it('returns the count from D1', async () => {
98
+ const { db } = makeMockDb({ firstResult: { total: 42 } })
99
+ expect(await new D1MediaRepository(db).count()).toBe(42)
100
+ })
101
+
102
+ it('returns 0 when D1 returns null', async () => {
103
+ const { db } = makeMockDb({ firstResult: null })
104
+ expect(await new D1MediaRepository(db).count()).toBe(0)
105
+ })
106
+ })
107
+ })