@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,37 +1,37 @@
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 { D1Database } from '@cloudflare/workers-types'
6
- import type { IContentScanRepository, Seed } from '@beechcms/core'
7
-
8
- export class D1ContentScanRepository implements IContentScanRepository {
9
- constructor(private readonly db: D1Database) {}
10
-
11
- async getReferencedMediaKeys(seeds: Seed[]): Promise<Set<string>> {
12
- const referencedMediaKeys = new Set<string>()
13
-
14
- for (const seed of seeds) {
15
- const mediaFields = seed.branches.filter(branch => branch.type === 'file')
16
- if (mediaFields.length === 0) continue
17
-
18
- const mediaColumns = mediaFields.map(field => field.alias).join(', ')
19
- try {
20
- const contentData = await this.db.prepare(
21
- `SELECT ${mediaColumns} FROM content_${seed.slug}`
22
- ).all<Record<string, string | null>>()
23
-
24
- for (const contentRow of contentData.results ?? []) {
25
- const rowContentString = Object.values(contentRow).filter(Boolean).join(' ')
26
- for (const keyMatch of rowContentString.matchAll(/\/api\/media\/([^"'\s\\,}\]]+)/g)) {
27
- referencedMediaKeys.add(decodeURIComponent(keyMatch[1]))
28
- }
29
- }
30
- } catch {
31
- // Table not yet created (seed:load not run) — skip this seed gracefully
32
- }
33
- }
34
-
35
- return referencedMediaKeys
36
- }
37
- }
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 { D1Database } from '@cloudflare/workers-types'
6
+ import type { IContentScanRepository, Seed } from '@beechcms/core'
7
+
8
+ export class D1ContentScanRepository implements IContentScanRepository {
9
+ constructor(private readonly db: D1Database) {}
10
+
11
+ async getReferencedMediaKeys(seeds: Seed[]): Promise<Set<string>> {
12
+ const referencedMediaKeys = new Set<string>()
13
+
14
+ for (const seed of seeds) {
15
+ const mediaFields = seed.branches.filter(branch => branch.type === 'file')
16
+ if (mediaFields.length === 0) continue
17
+
18
+ const mediaColumns = mediaFields.map(field => field.alias).join(', ')
19
+ try {
20
+ const contentData = await this.db.prepare(
21
+ `SELECT ${mediaColumns} FROM content_${seed.slug}`
22
+ ).all<Record<string, string | null>>()
23
+
24
+ for (const contentRow of contentData.results ?? []) {
25
+ const rowContentString = Object.values(contentRow).filter(Boolean).join(' ')
26
+ for (const keyMatch of rowContentString.matchAll(/\/api\/media\/([^"'\s\\,}\]]+)/g)) {
27
+ referencedMediaKeys.add(decodeURIComponent(keyMatch[1]))
28
+ }
29
+ }
30
+ } catch {
31
+ // Table not yet created (seed:load not run) — skip this seed gracefully
32
+ }
33
+ }
34
+
35
+ return referencedMediaKeys
36
+ }
37
+ }
@@ -1,128 +1,128 @@
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 { D1NotificationRepository } from './d1-notification.repository'
7
- import { FixedClock } from './fixed-clock'
8
- import { SequentialIdGenerator } from './sequential-id-generator'
9
-
10
- const clock = new FixedClock(1700000000_000)
11
- const makeIdGen = () => new SequentialIdGenerator()
12
-
13
- function makeMockDb(opts: { firstResult?: unknown; allResults?: unknown[] } = {}) {
14
- const { firstResult = null, allResults = [] } = opts
15
- const runMock = vi.fn().mockResolvedValue({ success: true })
16
- const firstMock = vi.fn().mockResolvedValue(firstResult)
17
- const allMock = vi.fn().mockResolvedValue({ results: allResults })
18
- const bindMock = vi.fn(() => ({ first: firstMock, all: allMock, run: runMock }))
19
- const prepareMock = vi.fn(() => ({
20
- bind: bindMock,
21
- first: firstMock,
22
- all: allMock,
23
- run: runMock,
24
- }))
25
- return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock, allMock }
26
- }
27
-
28
- describe('D1NotificationRepository', () => {
29
- describe('list', () => {
30
- it('maps rows to NotificationRecord with isRead boolean', async () => {
31
- const { db } = makeMockDb({
32
- allResults: [
33
- {
34
- id: 'n-1',
35
- title: 'T',
36
- message: 'M',
37
- type: 'success',
38
- is_read: 0,
39
- created_at: 100,
40
- },
41
- {
42
- id: 'n-2',
43
- title: 'T2',
44
- message: 'M2',
45
- type: 'info',
46
- is_read: 1,
47
- created_at: 99,
48
- },
49
- ],
50
- })
51
- const records = await new D1NotificationRepository(db, clock, makeIdGen()).list(10)
52
- expect(records).toEqual([
53
- { id: 'n-1', title: 'T', message: 'M', type: 'success', isRead: false, createdAt: 100 },
54
- { id: 'n-2', title: 'T2', message: 'M2', type: 'info', isRead: true, createdAt: 99 },
55
- ])
56
- })
57
-
58
- it('orders by created_at DESC with LIMIT bound', async () => {
59
- const { db, prepareMock, bindMock } = makeMockDb()
60
- await new D1NotificationRepository(db, clock, makeIdGen()).list(25)
61
- expect(prepareMock.mock.calls[0][0]).toMatch(/ORDER BY created_at DESC/)
62
- expect(bindMock).toHaveBeenCalledWith(25)
63
- })
64
- })
65
-
66
- describe('stats', () => {
67
- it('returns aggregate counters mapped to camelCase', async () => {
68
- const { db } = makeMockDb({
69
- firstResult: { total_count: 7, latest_created_at: 1234, read_count: 3 },
70
- })
71
- const stats = await new D1NotificationRepository(db, clock, makeIdGen()).stats()
72
- expect(stats).toEqual({ totalCount: 7, latestCreatedAt: 1234, readCount: 3 })
73
- })
74
-
75
- it('returns zeros when the table is empty', async () => {
76
- const { db } = makeMockDb({
77
- firstResult: { total_count: 0, latest_created_at: null, read_count: null },
78
- })
79
- const stats = await new D1NotificationRepository(db, clock, makeIdGen()).stats()
80
- expect(stats).toEqual({ totalCount: 0, latestCreatedAt: 0, readCount: 0 })
81
- })
82
- })
83
-
84
- describe('create', () => {
85
- it('inserts a new notification and returns the generated id', async () => {
86
- const { db, prepareMock, bindMock } = makeMockDb()
87
- const id = await new D1NotificationRepository(db, clock, makeIdGen()).create({
88
- title: 'Hello',
89
- message: 'World',
90
- type: 'info',
91
- })
92
- expect(typeof id).toBe('string')
93
- expect(id.length).toBeGreaterThan(0)
94
- expect(prepareMock.mock.calls[0][0]).toMatch(/INSERT INTO notifications/)
95
- expect(bindMock).toHaveBeenCalledWith(id, 'Hello', 'World', 'info')
96
- })
97
- })
98
-
99
- describe('markRead / markUnread / delete / markAllRead', () => {
100
- it('markRead binds the given id', async () => {
101
- const { db, prepareMock, bindMock } = makeMockDb()
102
- await new D1NotificationRepository(db, clock, makeIdGen()).markRead('n-1')
103
- expect(prepareMock.mock.calls[0][0]).toMatch(/UPDATE notifications SET is_read = 1 WHERE id = \?/)
104
- expect(bindMock).toHaveBeenCalledWith('n-1')
105
- })
106
-
107
- it('markUnread binds the given id', async () => {
108
- const { db, prepareMock, bindMock } = makeMockDb()
109
- await new D1NotificationRepository(db, clock, makeIdGen()).markUnread('n-2')
110
- expect(prepareMock.mock.calls[0][0]).toMatch(/UPDATE notifications SET is_read = 0 WHERE id = \?/)
111
- expect(bindMock).toHaveBeenCalledWith('n-2')
112
- })
113
-
114
- it('delete binds the given id', async () => {
115
- const { db, prepareMock, bindMock } = makeMockDb()
116
- await new D1NotificationRepository(db, clock, makeIdGen()).delete('n-3')
117
- expect(prepareMock.mock.calls[0][0]).toMatch(/DELETE FROM notifications WHERE id = \?/)
118
- expect(bindMock).toHaveBeenCalledWith('n-3')
119
- })
120
-
121
- it('markAllRead runs without bindings', async () => {
122
- const { db, prepareMock, runMock } = makeMockDb()
123
- await new D1NotificationRepository(db, clock, makeIdGen()).markAllRead()
124
- expect(prepareMock.mock.calls[0][0]).toMatch(/^UPDATE notifications SET is_read = 1$/)
125
- expect(runMock).toHaveBeenCalled()
126
- })
127
- })
128
- })
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 { D1NotificationRepository } from './d1-notification.repository'
7
+ import { FixedClock } from './fixed-clock'
8
+ import { SequentialIdGenerator } from './sequential-id-generator'
9
+
10
+ const clock = new FixedClock(1700000000_000)
11
+ const makeIdGen = () => new SequentialIdGenerator()
12
+
13
+ function makeMockDb(opts: { firstResult?: unknown; allResults?: unknown[] } = {}) {
14
+ const { firstResult = null, allResults = [] } = opts
15
+ const runMock = vi.fn().mockResolvedValue({ success: true })
16
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
17
+ const allMock = vi.fn().mockResolvedValue({ results: allResults })
18
+ const bindMock = vi.fn(() => ({ first: firstMock, all: allMock, run: runMock }))
19
+ const prepareMock = vi.fn(() => ({
20
+ bind: bindMock,
21
+ first: firstMock,
22
+ all: allMock,
23
+ run: runMock,
24
+ }))
25
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock, allMock }
26
+ }
27
+
28
+ describe('D1NotificationRepository', () => {
29
+ describe('list', () => {
30
+ it('maps rows to NotificationRecord with isRead boolean', async () => {
31
+ const { db } = makeMockDb({
32
+ allResults: [
33
+ {
34
+ id: 'n-1',
35
+ title: 'T',
36
+ message: 'M',
37
+ type: 'success',
38
+ is_read: 0,
39
+ created_at: 100,
40
+ },
41
+ {
42
+ id: 'n-2',
43
+ title: 'T2',
44
+ message: 'M2',
45
+ type: 'info',
46
+ is_read: 1,
47
+ created_at: 99,
48
+ },
49
+ ],
50
+ })
51
+ const records = await new D1NotificationRepository(db, clock, makeIdGen()).list(10)
52
+ expect(records).toEqual([
53
+ { id: 'n-1', title: 'T', message: 'M', type: 'success', isRead: false, createdAt: 100 },
54
+ { id: 'n-2', title: 'T2', message: 'M2', type: 'info', isRead: true, createdAt: 99 },
55
+ ])
56
+ })
57
+
58
+ it('orders by created_at DESC with LIMIT bound', async () => {
59
+ const { db, prepareMock, bindMock } = makeMockDb()
60
+ await new D1NotificationRepository(db, clock, makeIdGen()).list(25)
61
+ expect(prepareMock.mock.calls[0][0]).toMatch(/ORDER BY created_at DESC/)
62
+ expect(bindMock).toHaveBeenCalledWith(25)
63
+ })
64
+ })
65
+
66
+ describe('stats', () => {
67
+ it('returns aggregate counters mapped to camelCase', async () => {
68
+ const { db } = makeMockDb({
69
+ firstResult: { total_count: 7, latest_created_at: 1234, read_count: 3 },
70
+ })
71
+ const stats = await new D1NotificationRepository(db, clock, makeIdGen()).stats()
72
+ expect(stats).toEqual({ totalCount: 7, latestCreatedAt: 1234, readCount: 3 })
73
+ })
74
+
75
+ it('returns zeros when the table is empty', async () => {
76
+ const { db } = makeMockDb({
77
+ firstResult: { total_count: 0, latest_created_at: null, read_count: null },
78
+ })
79
+ const stats = await new D1NotificationRepository(db, clock, makeIdGen()).stats()
80
+ expect(stats).toEqual({ totalCount: 0, latestCreatedAt: 0, readCount: 0 })
81
+ })
82
+ })
83
+
84
+ describe('create', () => {
85
+ it('inserts a new notification and returns the generated id', async () => {
86
+ const { db, prepareMock, bindMock } = makeMockDb()
87
+ const id = await new D1NotificationRepository(db, clock, makeIdGen()).create({
88
+ title: 'Hello',
89
+ message: 'World',
90
+ type: 'info',
91
+ })
92
+ expect(typeof id).toBe('string')
93
+ expect(id.length).toBeGreaterThan(0)
94
+ expect(prepareMock.mock.calls[0][0]).toMatch(/INSERT INTO notifications/)
95
+ expect(bindMock).toHaveBeenCalledWith(id, 'Hello', 'World', 'info')
96
+ })
97
+ })
98
+
99
+ describe('markRead / markUnread / delete / markAllRead', () => {
100
+ it('markRead binds the given id', async () => {
101
+ const { db, prepareMock, bindMock } = makeMockDb()
102
+ await new D1NotificationRepository(db, clock, makeIdGen()).markRead('n-1')
103
+ expect(prepareMock.mock.calls[0][0]).toMatch(/UPDATE notifications SET is_read = 1 WHERE id = \?/)
104
+ expect(bindMock).toHaveBeenCalledWith('n-1')
105
+ })
106
+
107
+ it('markUnread binds the given id', async () => {
108
+ const { db, prepareMock, bindMock } = makeMockDb()
109
+ await new D1NotificationRepository(db, clock, makeIdGen()).markUnread('n-2')
110
+ expect(prepareMock.mock.calls[0][0]).toMatch(/UPDATE notifications SET is_read = 0 WHERE id = \?/)
111
+ expect(bindMock).toHaveBeenCalledWith('n-2')
112
+ })
113
+
114
+ it('delete binds the given id', async () => {
115
+ const { db, prepareMock, bindMock } = makeMockDb()
116
+ await new D1NotificationRepository(db, clock, makeIdGen()).delete('n-3')
117
+ expect(prepareMock.mock.calls[0][0]).toMatch(/DELETE FROM notifications WHERE id = \?/)
118
+ expect(bindMock).toHaveBeenCalledWith('n-3')
119
+ })
120
+
121
+ it('markAllRead runs without bindings', async () => {
122
+ const { db, prepareMock, runMock } = makeMockDb()
123
+ await new D1NotificationRepository(db, clock, makeIdGen()).markAllRead()
124
+ expect(prepareMock.mock.calls[0][0]).toMatch(/^UPDATE notifications SET is_read = 1$/)
125
+ expect(runMock).toHaveBeenCalled()
126
+ })
127
+ })
128
+ })
@@ -1,118 +1,118 @@
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
- /// <reference types="@cloudflare/workers-types" />
6
- import type {
7
- INotificationRepository,
8
- NotificationRecord,
9
- NotificationStats,
10
- NotificationType,
11
- IClock,
12
- IIdGenerator,
13
- } from '@beechcms/core'
14
-
15
- interface NotificationRow {
16
- id: string
17
- title: string
18
- message: string
19
- type: string
20
- is_read: number
21
- created_at: number
22
- }
23
-
24
- interface NotificationStatsRow {
25
- total_count: number | null
26
- latest_created_at: number | null
27
- read_count: number | null
28
- }
29
-
30
- /**
31
- * D1-backed implementation of {@link INotificationRepository}.
32
- */
33
- export class D1NotificationRepository implements INotificationRepository {
34
- constructor(
35
- private readonly database: D1Database,
36
- private readonly clock: IClock,
37
- private readonly idGenerator: IIdGenerator,
38
- ) {}
39
-
40
- async list(limit: number): Promise<NotificationRecord[]> {
41
- const queryResult = await this.database
42
- .prepare(
43
- `SELECT id, title, message, type, is_read, created_at
44
- FROM notifications
45
- ORDER BY created_at DESC
46
- LIMIT ?`
47
- )
48
- .bind(limit)
49
- .all<NotificationRow>()
50
-
51
- return (queryResult.results ?? []).map(mapRowToRecord)
52
- }
53
-
54
- async stats(): Promise<NotificationStats> {
55
- const statsRow = await this.database
56
- .prepare(
57
- `SELECT COUNT(*) as total_count,
58
- MAX(created_at) as latest_created_at,
59
- SUM(is_read) as read_count
60
- FROM notifications`
61
- )
62
- .first<NotificationStatsRow>()
63
-
64
- return {
65
- totalCount: statsRow?.total_count ?? 0,
66
- latestCreatedAt: statsRow?.latest_created_at ?? 0,
67
- readCount: statsRow?.read_count ?? 0,
68
- }
69
- }
70
-
71
- async create(record: Omit<NotificationRecord, 'id' | 'createdAt' | 'isRead'>): Promise<string> {
72
- const generatedId = this.idGenerator.uuid()
73
- await this.database
74
- .prepare(
75
- `INSERT INTO notifications (id, title, message, type)
76
- VALUES (?, ?, ?, ?)`
77
- )
78
- .bind(generatedId, record.title, record.message, record.type)
79
- .run()
80
- return generatedId
81
- }
82
-
83
- async markRead(notificationId: string): Promise<void> {
84
- await this.database
85
- .prepare('UPDATE notifications SET is_read = 1 WHERE id = ?')
86
- .bind(notificationId)
87
- .run()
88
- }
89
-
90
- async markUnread(notificationId: string): Promise<void> {
91
- await this.database
92
- .prepare('UPDATE notifications SET is_read = 0 WHERE id = ?')
93
- .bind(notificationId)
94
- .run()
95
- }
96
-
97
- async markAllRead(): Promise<void> {
98
- await this.database.prepare('UPDATE notifications SET is_read = 1').run()
99
- }
100
-
101
- async delete(notificationId: string): Promise<void> {
102
- await this.database
103
- .prepare('DELETE FROM notifications WHERE id = ?')
104
- .bind(notificationId)
105
- .run()
106
- }
107
- }
108
-
109
- function mapRowToRecord(row: NotificationRow): NotificationRecord {
110
- return {
111
- id: row.id,
112
- title: row.title,
113
- message: row.message,
114
- type: row.type as NotificationType,
115
- isRead: row.is_read === 1,
116
- createdAt: row.created_at,
117
- }
118
- }
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
+ /// <reference types="@cloudflare/workers-types" />
6
+ import type {
7
+ INotificationRepository,
8
+ NotificationRecord,
9
+ NotificationStats,
10
+ NotificationType,
11
+ IClock,
12
+ IIdGenerator,
13
+ } from '@beechcms/core'
14
+
15
+ interface NotificationRow {
16
+ id: string
17
+ title: string
18
+ message: string
19
+ type: string
20
+ is_read: number
21
+ created_at: number
22
+ }
23
+
24
+ interface NotificationStatsRow {
25
+ total_count: number | null
26
+ latest_created_at: number | null
27
+ read_count: number | null
28
+ }
29
+
30
+ /**
31
+ * D1-backed implementation of {@link INotificationRepository}.
32
+ */
33
+ export class D1NotificationRepository implements INotificationRepository {
34
+ constructor(
35
+ private readonly database: D1Database,
36
+ private readonly clock: IClock,
37
+ private readonly idGenerator: IIdGenerator,
38
+ ) {}
39
+
40
+ async list(limit: number): Promise<NotificationRecord[]> {
41
+ const queryResult = await this.database
42
+ .prepare(
43
+ `SELECT id, title, message, type, is_read, created_at
44
+ FROM notifications
45
+ ORDER BY created_at DESC
46
+ LIMIT ?`
47
+ )
48
+ .bind(limit)
49
+ .all<NotificationRow>()
50
+
51
+ return (queryResult.results ?? []).map(mapRowToRecord)
52
+ }
53
+
54
+ async stats(): Promise<NotificationStats> {
55
+ const statsRow = await this.database
56
+ .prepare(
57
+ `SELECT COUNT(*) as total_count,
58
+ MAX(created_at) as latest_created_at,
59
+ SUM(is_read) as read_count
60
+ FROM notifications`
61
+ )
62
+ .first<NotificationStatsRow>()
63
+
64
+ return {
65
+ totalCount: statsRow?.total_count ?? 0,
66
+ latestCreatedAt: statsRow?.latest_created_at ?? 0,
67
+ readCount: statsRow?.read_count ?? 0,
68
+ }
69
+ }
70
+
71
+ async create(record: Omit<NotificationRecord, 'id' | 'createdAt' | 'isRead'>): Promise<string> {
72
+ const generatedId = this.idGenerator.uuid()
73
+ await this.database
74
+ .prepare(
75
+ `INSERT INTO notifications (id, title, message, type)
76
+ VALUES (?, ?, ?, ?)`
77
+ )
78
+ .bind(generatedId, record.title, record.message, record.type)
79
+ .run()
80
+ return generatedId
81
+ }
82
+
83
+ async markRead(notificationId: string): Promise<void> {
84
+ await this.database
85
+ .prepare('UPDATE notifications SET is_read = 1 WHERE id = ?')
86
+ .bind(notificationId)
87
+ .run()
88
+ }
89
+
90
+ async markUnread(notificationId: string): Promise<void> {
91
+ await this.database
92
+ .prepare('UPDATE notifications SET is_read = 0 WHERE id = ?')
93
+ .bind(notificationId)
94
+ .run()
95
+ }
96
+
97
+ async markAllRead(): Promise<void> {
98
+ await this.database.prepare('UPDATE notifications SET is_read = 1').run()
99
+ }
100
+
101
+ async delete(notificationId: string): Promise<void> {
102
+ await this.database
103
+ .prepare('DELETE FROM notifications WHERE id = ?')
104
+ .bind(notificationId)
105
+ .run()
106
+ }
107
+ }
108
+
109
+ function mapRowToRecord(row: NotificationRow): NotificationRecord {
110
+ return {
111
+ id: row.id,
112
+ title: row.title,
113
+ message: row.message,
114
+ type: row.type as NotificationType,
115
+ isRead: row.is_read === 1,
116
+ createdAt: row.created_at,
117
+ }
118
+ }