@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,107 +1,107 @@
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
- })
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
+ })
@@ -1,68 +1,68 @@
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 { MediaRepository, MediaObject } from '@beechcms/core'
6
-
7
- /**
8
- * Implementation of MediaRepository using Cloudflare D1.
9
- * Provides methods to track media uploads, retrieve media metadata, and manage the media library state in the database.
10
- */
11
- export class D1MediaRepository implements MediaRepository {
12
- constructor(private database: D1Database) {}
13
-
14
- /**
15
- * Registers a new media upload in the database.
16
- */
17
- async trackUpload(mediaObject: Omit<MediaObject, 'created_at'>): Promise<void> {
18
- await this.database.prepare(
19
- 'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
20
- ).bind(
21
- mediaObject.key,
22
- mediaObject.filename,
23
- mediaObject.mime_type,
24
- mediaObject.size_bytes,
25
- mediaObject.uploaded_by
26
- ).run()
27
- }
28
-
29
- /**
30
- * Retrieves a media object's metadata by its unique key.
31
- */
32
- async getByKey(mediaKey: string): Promise<MediaObject | null> {
33
- return await this.database.prepare(
34
- 'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects WHERE key = ?'
35
- ).bind(mediaKey).first<MediaObject>()
36
- }
37
-
38
- /**
39
- * Removes the metadata tracking for a specific media key.
40
- */
41
- async untrack(mediaKey: string): Promise<void> {
42
- await this.database.prepare('DELETE FROM media_objects WHERE key = ?').bind(mediaKey).run()
43
- }
44
-
45
- /**
46
- * Lists media objects with pagination support.
47
- */
48
- async list(paginationOptions: { limit: number; offset: number }): Promise<{ items: MediaObject[]; total: number }> {
49
- const { results: mediaItems } = await this.database.prepare(
50
- 'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects ORDER BY created_at DESC LIMIT ? OFFSET ?'
51
- ).bind(paginationOptions.limit, paginationOptions.offset).all<MediaObject>()
52
-
53
- const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
54
-
55
- return {
56
- items: mediaItems ?? [],
57
- total: totalCountRecord?.total ?? 0
58
- }
59
- }
60
-
61
- /**
62
- * Returns the total number of media objects in the database.
63
- */
64
- async count(): Promise<number> {
65
- const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
66
- return totalCountRecord?.total ?? 0
67
- }
68
- }
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 { MediaRepository, MediaObject } from '@beechcms/core'
6
+
7
+ /**
8
+ * Implementation of MediaRepository using Cloudflare D1.
9
+ * Provides methods to track media uploads, retrieve media metadata, and manage the media library state in the database.
10
+ */
11
+ export class D1MediaRepository implements MediaRepository {
12
+ constructor(private database: D1Database) {}
13
+
14
+ /**
15
+ * Registers a new media upload in the database.
16
+ */
17
+ async trackUpload(mediaObject: Omit<MediaObject, 'created_at'>): Promise<void> {
18
+ await this.database.prepare(
19
+ 'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
20
+ ).bind(
21
+ mediaObject.key,
22
+ mediaObject.filename,
23
+ mediaObject.mime_type,
24
+ mediaObject.size_bytes,
25
+ mediaObject.uploaded_by
26
+ ).run()
27
+ }
28
+
29
+ /**
30
+ * Retrieves a media object's metadata by its unique key.
31
+ */
32
+ async getByKey(mediaKey: string): Promise<MediaObject | null> {
33
+ return await this.database.prepare(
34
+ 'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects WHERE key = ?'
35
+ ).bind(mediaKey).first<MediaObject>()
36
+ }
37
+
38
+ /**
39
+ * Removes the metadata tracking for a specific media key.
40
+ */
41
+ async untrack(mediaKey: string): Promise<void> {
42
+ await this.database.prepare('DELETE FROM media_objects WHERE key = ?').bind(mediaKey).run()
43
+ }
44
+
45
+ /**
46
+ * Lists media objects with pagination support.
47
+ */
48
+ async list(paginationOptions: { limit: number; offset: number }): Promise<{ items: MediaObject[]; total: number }> {
49
+ const { results: mediaItems } = await this.database.prepare(
50
+ 'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects ORDER BY created_at DESC LIMIT ? OFFSET ?'
51
+ ).bind(paginationOptions.limit, paginationOptions.offset).all<MediaObject>()
52
+
53
+ const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
54
+
55
+ return {
56
+ items: mediaItems ?? [],
57
+ total: totalCountRecord?.total ?? 0
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Returns the total number of media objects in the database.
63
+ */
64
+ async count(): Promise<number> {
65
+ const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
66
+ return totalCountRecord?.total ?? 0
67
+ }
68
+ }
@@ -1,67 +1,67 @@
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, beforeEach } from 'vitest'
6
- import { QStashNotificationService } from './qstash-notification-service'
7
- import { Client } from '@upstash/qstash'
8
-
9
- // Mock the @upstash/qstash Client
10
- vi.mock('@upstash/qstash', () => {
11
- const publishJSON = vi.fn().mockResolvedValue({ messageId: 'msg_123' })
12
- return {
13
- Client: vi.fn().mockImplementation(function () {
14
- return { publishJSON }
15
- }),
16
- }
17
- })
18
-
19
- describe('QStashNotificationService', () => {
20
- beforeEach(() => {
21
- vi.clearAllMocks()
22
- })
23
-
24
- it('delegates publishing to the QStash client with correct URL', async () => {
25
- const service = new QStashNotificationService('fake_token', 'https://beechcms.test/')
26
- await service.notify({
27
- title: 'Hello',
28
- message: 'World',
29
- type: 'success',
30
- })
31
-
32
- const clientInstance = vi.mocked(Client).mock.results[0].value
33
- expect(clientInstance.publishJSON).toHaveBeenCalledWith({
34
- url: 'https://beechcms.test/api/webhooks/qstash',
35
- body: {
36
- title: 'Hello',
37
- message: 'World',
38
- type: 'success',
39
- },
40
- })
41
- })
42
-
43
- it('delegates to the background scheduler when provided', () => {
44
- const schedule = vi.fn()
45
- const service = new QStashNotificationService('fake_token', 'https://beechcms.test', schedule)
46
-
47
- service.notify({ title: 'T', message: 'M' })
48
-
49
- expect(schedule).toHaveBeenCalledTimes(1)
50
- expect(schedule.mock.calls[0][0]).toBeInstanceOf(Promise)
51
- })
52
-
53
- it('never throws to the caller when the publish write fails', async () => {
54
- // Override the mock for this specific test
55
- const mockPublish = vi.fn().mockRejectedValue(new Error('network error'))
56
- vi.mocked(Client).mockImplementationOnce(function () {
57
- return { publishJSON: mockPublish }
58
- } as any)
59
-
60
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
61
- const service = new QStashNotificationService('fake_token', 'https://beechcms.test')
62
-
63
- await expect(service.notify({ title: 'T', message: 'M' })).resolves.toBeUndefined()
64
- expect(consoleSpy).toHaveBeenCalled()
65
- consoleSpy.mockRestore()
66
- })
67
- })
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, beforeEach } from 'vitest'
6
+ import { QStashNotificationService } from './qstash-notification-service'
7
+ import { Client } from '@upstash/qstash'
8
+
9
+ // Mock the @upstash/qstash Client
10
+ vi.mock('@upstash/qstash', () => {
11
+ const publishJSON = vi.fn().mockResolvedValue({ messageId: 'msg_123' })
12
+ return {
13
+ Client: vi.fn().mockImplementation(function () {
14
+ return { publishJSON }
15
+ }),
16
+ }
17
+ })
18
+
19
+ describe('QStashNotificationService', () => {
20
+ beforeEach(() => {
21
+ vi.clearAllMocks()
22
+ })
23
+
24
+ it('delegates publishing to the QStash client with correct URL', async () => {
25
+ const service = new QStashNotificationService('fake_token', 'https://beechcms.test/')
26
+ await service.notify({
27
+ title: 'Hello',
28
+ message: 'World',
29
+ type: 'success',
30
+ })
31
+
32
+ const clientInstance = vi.mocked(Client).mock.results[0].value
33
+ expect(clientInstance.publishJSON).toHaveBeenCalledWith({
34
+ url: 'https://beechcms.test/api/webhooks/qstash',
35
+ body: {
36
+ title: 'Hello',
37
+ message: 'World',
38
+ type: 'success',
39
+ },
40
+ })
41
+ })
42
+
43
+ it('delegates to the background scheduler when provided', () => {
44
+ const schedule = vi.fn()
45
+ const service = new QStashNotificationService('fake_token', 'https://beechcms.test', schedule)
46
+
47
+ service.notify({ title: 'T', message: 'M' })
48
+
49
+ expect(schedule).toHaveBeenCalledTimes(1)
50
+ expect(schedule.mock.calls[0][0]).toBeInstanceOf(Promise)
51
+ })
52
+
53
+ it('never throws to the caller when the publish write fails', async () => {
54
+ // Override the mock for this specific test
55
+ const mockPublish = vi.fn().mockRejectedValue(new Error('network error'))
56
+ vi.mocked(Client).mockImplementationOnce(function () {
57
+ return { publishJSON: mockPublish }
58
+ } as any)
59
+
60
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
61
+ const service = new QStashNotificationService('fake_token', 'https://beechcms.test')
62
+
63
+ await expect(service.notify({ title: 'T', message: 'M' })).resolves.toBeUndefined()
64
+ expect(consoleSpy).toHaveBeenCalled()
65
+ consoleSpy.mockRestore()
66
+ })
67
+ })
@@ -1,55 +1,55 @@
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
- import { Client } from '@upstash/qstash'
7
-
8
- type ScheduleBackgroundTask = (task: Promise<unknown>) => void
9
-
10
- /**
11
- * QStash implementation of {@link INotificationService}.
12
- *
13
- * This service acts as a publisher, sending the notification payload to Upstash QStash,
14
- * which guarantees delivery and retries. QStash will then forward the payload to the
15
- * local webhook endpoint where it is actually inserted into D1.
16
- */
17
- export class QStashNotificationService implements INotificationService {
18
- private readonly client: Client
19
- private readonly webhookUrl: string
20
-
21
- constructor(
22
- token: string,
23
- appUrl: string,
24
- private readonly scheduleBackgroundTask?: ScheduleBackgroundTask,
25
- qstashUrl?: string
26
- ) {
27
- this.client = new Client({
28
- token,
29
- ...(qstashUrl ? { baseUrl: qstashUrl } : {})
30
- })
31
- this.webhookUrl = `${appUrl.replace(/\/$/, '')}/api/webhooks/qstash`
32
- }
33
-
34
- notify(input: CreateNotificationInput): Promise<void> | void {
35
- const publishPromise = this.runPublish(input)
36
-
37
- if (this.scheduleBackgroundTask) {
38
- this.scheduleBackgroundTask(publishPromise)
39
- return
40
- }
41
-
42
- return publishPromise
43
- }
44
-
45
- private async runPublish(input: CreateNotificationInput): Promise<void> {
46
- try {
47
- await this.client.publishJSON({
48
- url: this.webhookUrl,
49
- body: input,
50
- })
51
- } catch (error) {
52
- console.error('QStashNotificationService: failed to publish notification', error)
53
- }
54
- }
55
- }
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
+ import { Client } from '@upstash/qstash'
7
+
8
+ type ScheduleBackgroundTask = (task: Promise<unknown>) => void
9
+
10
+ /**
11
+ * QStash implementation of {@link INotificationService}.
12
+ *
13
+ * This service acts as a publisher, sending the notification payload to Upstash QStash,
14
+ * which guarantees delivery and retries. QStash will then forward the payload to the
15
+ * local webhook endpoint where it is actually inserted into D1.
16
+ */
17
+ export class QStashNotificationService implements INotificationService {
18
+ private readonly client: Client
19
+ private readonly webhookUrl: string
20
+
21
+ constructor(
22
+ token: string,
23
+ appUrl: string,
24
+ private readonly scheduleBackgroundTask?: ScheduleBackgroundTask,
25
+ qstashUrl?: string
26
+ ) {
27
+ this.client = new Client({
28
+ token,
29
+ ...(qstashUrl ? { baseUrl: qstashUrl } : {})
30
+ })
31
+ this.webhookUrl = `${appUrl.replace(/\/$/, '')}/api/webhooks/qstash`
32
+ }
33
+
34
+ notify(input: CreateNotificationInput): Promise<void> | void {
35
+ const publishPromise = this.runPublish(input)
36
+
37
+ if (this.scheduleBackgroundTask) {
38
+ this.scheduleBackgroundTask(publishPromise)
39
+ return
40
+ }
41
+
42
+ return publishPromise
43
+ }
44
+
45
+ private async runPublish(input: CreateNotificationInput): Promise<void> {
46
+ try {
47
+ await this.client.publishJSON({
48
+ url: this.webhookUrl,
49
+ body: input,
50
+ })
51
+ } catch (error) {
52
+ console.error('QStashNotificationService: failed to publish notification', error)
53
+ }
54
+ }
55
+ }