@beechcms/api 0.4.0 → 0.4.1
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.
- package/assets/dashboard/assets/index-CH13idU1.js +554 -0
- package/assets/dashboard/index.html +1 -1
- package/package.json +2 -2
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +3 -3
- package/src/auth/generate-refresh-token.test.ts +19 -0
- package/src/auth/hash-provider.test.ts +46 -0
- package/src/auth/in-memory-hash-provider.ts +13 -0
- package/src/auth/jose-token-service.ts +55 -0
- package/src/auth/login.test.ts +92 -0
- package/src/auth/login.ts +15 -32
- package/src/auth/refresh.ts +0 -122
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +70 -78
- package/src/features/content/handlers/create.ts +14 -10
- package/src/features/content/handlers/delete.ts +13 -9
- package/src/features/content/handlers/update.ts +13 -9
- package/src/features/draft/draft.handler.ts +23 -12
- package/src/features/notifications/notifications.handler.ts +25 -54
- package/src/features/password-reset/request.ts +17 -41
- package/src/features/password-reset/reset.ts +18 -54
- package/src/features/rotate-field/rotate-field.handler.ts +15 -19
- package/src/features/schema/schema.handler.ts +1 -1
- package/src/features/settings/settings.handler.ts +62 -175
- package/src/features/setup/index.ts +12 -17
- package/src/features/stats/stats.handler.ts +110 -138
- package/src/middleware/auth-providers.middleware.ts +32 -0
- package/src/middleware/observability.middleware.ts +52 -0
- package/src/middleware/rate-limit.middleware.ts +41 -0
- package/src/middleware/repository.middleware.ts +41 -5
- package/src/middleware.ts +15 -35
- package/src/public/public-add.ts +5 -15
- package/src/public/public-edit.ts +4 -3
- package/src/public/public-read.ts +3 -3
- package/src/public/public-routes.ts +2 -2
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/rate-limit-middleware.ts +7 -19
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
- package/src/rate-limit/no-op-rate-limiter.ts +7 -0
- package/src/search-utils.test.ts +207 -0
- package/src/search-utils.ts +18 -1
- package/src/search.ts +24 -35
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/d1-activity-log.repository.test.ts +136 -0
- package/src/shared/d1-activity-log.repository.ts +101 -0
- package/src/shared/d1-activity-logger.test.ts +82 -0
- package/src/shared/d1-activity-logger.ts +63 -0
- package/src/shared/d1-analytics.repository.test.ts +74 -0
- package/src/shared/d1-analytics.repository.ts +81 -0
- package/src/shared/d1-content-scan.repository.ts +29 -0
- package/src/shared/d1-notification.repository.test.ts +124 -0
- package/src/shared/d1-notification.repository.ts +114 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
- package/src/shared/d1-password-reset-token.repository.ts +52 -0
- package/src/shared/d1-search.repository.test.ts +83 -0
- package/src/shared/d1-search.repository.ts +84 -0
- package/src/shared/d1-session.repository.test.ts +121 -0
- package/src/shared/d1-session.repository.ts +98 -0
- package/src/shared/d1-user.repository.test.ts +147 -0
- package/src/shared/d1-user.repository.ts +109 -0
- package/src/shared/d1-widget.repository.test.ts +217 -0
- package/src/shared/d1-widget.repository.ts +337 -0
- package/src/shared/fixed-clock.ts +21 -0
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/in-memory-activity-logger.ts +15 -0
- package/src/shared/in-memory-notification-service.ts +15 -0
- package/src/shared/media.repository.d1.test.ts +103 -0
- package/src/shared/media.repository.d1.ts +1 -1
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/types.ts +20 -3
- package/src/upload.ts +14 -7
- package/src/widget.ts +112 -253
- package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
- package/src/shared/activity-logger.ts +0 -79
- package/src/shared/notification-service.ts +0 -56
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import type { Seed } from '@beechcms/core'
|
|
3
|
+
import { D1WidgetRepository } from './d1-widget.repository'
|
|
4
|
+
|
|
5
|
+
function makeMockDb(allResults: unknown[] = [], firstResult: unknown = null) {
|
|
6
|
+
const allMock = vi.fn().mockResolvedValue({ results: allResults })
|
|
7
|
+
const firstMock = vi.fn().mockResolvedValue(firstResult)
|
|
8
|
+
const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
|
|
9
|
+
const prepareMock = vi.fn(() => ({ all: allMock, first: firstMock, bind: bindMock }))
|
|
10
|
+
return { db: { prepare: prepareMock } as any, prepareMock, bindMock, allMock, firstMock }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const seed: Seed = {
|
|
14
|
+
slug: 'posts',
|
|
15
|
+
label: 'Post',
|
|
16
|
+
displayNameAlias: 'title',
|
|
17
|
+
branches: [
|
|
18
|
+
{ id: 'br_01', alias: 'title', type: 'text', label: 'Title' },
|
|
19
|
+
{ id: 'br_02', alias: 'price', type: 'number', label: 'Price' },
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('D1WidgetRepository', () => {
|
|
24
|
+
describe('aggregate', () => {
|
|
25
|
+
it('returns 0 when no rows match', async () => {
|
|
26
|
+
const { db } = makeMockDb([], null)
|
|
27
|
+
const value = await new D1WidgetRepository(db).aggregate(seed, { op: 'count' }, 'all')
|
|
28
|
+
expect(value).toBe(0)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('builds COUNT(*) for op=count', async () => {
|
|
32
|
+
const { db, prepareMock } = makeMockDb([], { computed_value: 7 })
|
|
33
|
+
const value = await new D1WidgetRepository(db).aggregate(seed, { op: 'count' }, 'week')
|
|
34
|
+
expect(value).toBe(7)
|
|
35
|
+
const sql = prepareMock.mock.calls[0]![0] as string
|
|
36
|
+
expect(sql).toMatch(/COUNT\(\*\)/)
|
|
37
|
+
expect(sql).toMatch(/FROM content_posts/)
|
|
38
|
+
expect(sql).toMatch(/created_at > unixepoch\('now', '-7 days'\)/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('throws UNSAFE_COLUMN when column alias is unknown', async () => {
|
|
42
|
+
const { db } = makeMockDb()
|
|
43
|
+
await expect(
|
|
44
|
+
new D1WidgetRepository(db).aggregate(seed, { op: 'sum', column: 'unknown' }, 'all'),
|
|
45
|
+
).rejects.toThrow('UNSAFE_COLUMN')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('builds SUM(CAST(... AS REAL)) for valid column', async () => {
|
|
49
|
+
const { db, prepareMock } = makeMockDb([], { computed_value: 42 })
|
|
50
|
+
await new D1WidgetRepository(db).aggregate(seed, { op: 'sum', column: 'price' }, 'all')
|
|
51
|
+
expect(prepareMock.mock.calls[0]![0] as string).toMatch(/SUM\(CAST\(price AS REAL\)\)/)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('growth', () => {
|
|
56
|
+
it('runs two queries with current and previous filters', async () => {
|
|
57
|
+
const allMock = vi.fn().mockResolvedValue({ results: [] })
|
|
58
|
+
const firstMock = vi
|
|
59
|
+
.fn()
|
|
60
|
+
.mockResolvedValueOnce({ computed_value: 10 })
|
|
61
|
+
.mockResolvedValueOnce({ computed_value: 5 })
|
|
62
|
+
const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
|
|
63
|
+
const prepareMock = vi.fn(() => ({ all: allMock, first: firstMock, bind: bindMock }))
|
|
64
|
+
const db = { prepare: prepareMock } as any
|
|
65
|
+
|
|
66
|
+
const result = await new D1WidgetRepository(db).growth(seed, { op: 'count' }, 'month')
|
|
67
|
+
expect(result).toEqual({ currentValue: 10, previousValue: 5 })
|
|
68
|
+
expect(prepareMock).toHaveBeenCalledTimes(2)
|
|
69
|
+
const sql0 = prepareMock.mock.calls[0]![0] as string
|
|
70
|
+
const sql1 = prepareMock.mock.calls[1]![0] as string
|
|
71
|
+
expect(sql0).toMatch(/-1 month/)
|
|
72
|
+
expect(sql1).toMatch(/-2 months/)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns zeros when both queries are empty', async () => {
|
|
76
|
+
const { db } = makeMockDb([], null)
|
|
77
|
+
const result = await new D1WidgetRepository(db).growth(seed, { op: 'count' }, 'all')
|
|
78
|
+
expect(result).toEqual({ currentValue: 0, previousValue: 0 })
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('leaderboard', () => {
|
|
83
|
+
it('uses ORDER BY DESC by default and binds only the limit', async () => {
|
|
84
|
+
const { db, prepareMock, bindMock } = makeMockDb([
|
|
85
|
+
{ id: 'a', label: 'A', score: 9 },
|
|
86
|
+
])
|
|
87
|
+
const entries = await new D1WidgetRepository(db).leaderboard(seed, {
|
|
88
|
+
scoreColumn: 'price',
|
|
89
|
+
limit: 5,
|
|
90
|
+
orderDirection: 'DESC',
|
|
91
|
+
})
|
|
92
|
+
expect(entries).toEqual([{ id: 'a', label: 'A', score: 9 }])
|
|
93
|
+
const sql = prepareMock.mock.calls[0]![0] as string
|
|
94
|
+
expect(sql).toMatch(/ORDER BY CAST\(price AS REAL\) DESC/)
|
|
95
|
+
expect(bindMock).toHaveBeenCalledWith(5)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('uses ORDER BY ASC when requested', async () => {
|
|
99
|
+
const { db, prepareMock } = makeMockDb([])
|
|
100
|
+
await new D1WidgetRepository(db).leaderboard(seed, {
|
|
101
|
+
scoreColumn: 'price',
|
|
102
|
+
limit: 3,
|
|
103
|
+
orderDirection: 'ASC',
|
|
104
|
+
})
|
|
105
|
+
expect(prepareMock.mock.calls[0]![0] as string).toMatch(/ORDER BY CAST\(price AS REAL\) ASC/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('falls back to id when label is null', async () => {
|
|
109
|
+
const { db } = makeMockDb([{ id: 'x', label: null, score: 1 }])
|
|
110
|
+
const [entry] = await new D1WidgetRepository(db).leaderboard(seed, {
|
|
111
|
+
scoreColumn: 'price',
|
|
112
|
+
limit: 1,
|
|
113
|
+
orderDirection: 'DESC',
|
|
114
|
+
})
|
|
115
|
+
expect(entry).toEqual({ id: 'x', label: 'x', score: 1 })
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('list', () => {
|
|
120
|
+
it('returns raw rows and totalCount', async () => {
|
|
121
|
+
const allMock = vi.fn().mockResolvedValue({ results: [{ id: '1', slug: 's', status: 'published', created_at: 1, updated_at: 2, title: 'T', price: 10 }] })
|
|
122
|
+
const firstMock = vi.fn().mockResolvedValue({ total: 7 })
|
|
123
|
+
const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
|
|
124
|
+
const prepareMock = vi.fn(() => ({ all: allMock, first: firstMock, bind: bindMock }))
|
|
125
|
+
const db = { prepare: prepareMock } as any
|
|
126
|
+
|
|
127
|
+
const result = await new D1WidgetRepository(db).list(seed, { limit: 10, offset: 0 })
|
|
128
|
+
expect(result.totalCount).toBe(7)
|
|
129
|
+
expect(result.entries[0]?.title).toBe('T')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('appends search LIKE filter against displayNameAlias column', async () => {
|
|
133
|
+
const { db, prepareMock, bindMock } = makeMockDb([], { total: 0 })
|
|
134
|
+
await new D1WidgetRepository(db).list(seed, { limit: 5, offset: 0, search: 'hello' })
|
|
135
|
+
const sql = (prepareMock.mock.calls[1]?.[0] ?? prepareMock.mock.calls[0]![0]) as string
|
|
136
|
+
expect(sql).toMatch(/title LIKE \?/)
|
|
137
|
+
expect(bindMock).toHaveBeenCalledWith('%hello%', 5, 0)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('skips filters with unknown operator', async () => {
|
|
141
|
+
const { db, prepareMock } = makeMockDb([], { total: 0 })
|
|
142
|
+
await new D1WidgetRepository(db).list(seed, {
|
|
143
|
+
limit: 5,
|
|
144
|
+
offset: 0,
|
|
145
|
+
filters: [{ column: 'price', op: 'haxx', value: 1 }],
|
|
146
|
+
})
|
|
147
|
+
const dataSql = prepareMock.mock.calls[1]![0] as string
|
|
148
|
+
expect(dataSql).not.toMatch(/haxx/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('skips filters with unsafe column', async () => {
|
|
152
|
+
const { db, prepareMock } = makeMockDb([], { total: 0 })
|
|
153
|
+
await new D1WidgetRepository(db).list(seed, {
|
|
154
|
+
limit: 5,
|
|
155
|
+
offset: 0,
|
|
156
|
+
filters: [{ column: 'evil', op: 'eq', value: 1 }],
|
|
157
|
+
})
|
|
158
|
+
const dataSql = prepareMock.mock.calls[1]![0] as string
|
|
159
|
+
expect(dataSql).not.toMatch(/evil/)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('uses ORDER BY DESC branch when requested', async () => {
|
|
163
|
+
const { db, prepareMock } = makeMockDb([], { total: 0 })
|
|
164
|
+
await new D1WidgetRepository(db).list(seed, {
|
|
165
|
+
limit: 5,
|
|
166
|
+
offset: 0,
|
|
167
|
+
orderByColumn: 'price',
|
|
168
|
+
orderDirection: 'DESC',
|
|
169
|
+
})
|
|
170
|
+
const dataSql = prepareMock.mock.calls[1]![0] as string
|
|
171
|
+
expect(dataSql).toMatch(/ORDER BY price DESC/)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('falls back to created_at when orderByColumn is unsafe', async () => {
|
|
175
|
+
const { db, prepareMock } = makeMockDb([], { total: 0 })
|
|
176
|
+
await new D1WidgetRepository(db).list(seed, {
|
|
177
|
+
limit: 5,
|
|
178
|
+
offset: 0,
|
|
179
|
+
orderByColumn: 'evil',
|
|
180
|
+
})
|
|
181
|
+
const dataSql = prepareMock.mock.calls[1]![0] as string
|
|
182
|
+
expect(dataSql).toMatch(/ORDER BY created_at/)
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('timeseries', () => {
|
|
187
|
+
it('groups by date bucket on created_at', async () => {
|
|
188
|
+
const { db, prepareMock } = makeMockDb([
|
|
189
|
+
{ bucket_label: '2026-01-01', bucket_value: 3 },
|
|
190
|
+
])
|
|
191
|
+
const points = await new D1WidgetRepository(db).timeseries(
|
|
192
|
+
seed,
|
|
193
|
+
{ op: 'count' },
|
|
194
|
+
'all',
|
|
195
|
+
'created_at',
|
|
196
|
+
)
|
|
197
|
+
expect(points).toEqual([{ label: '2026-01-01', value: 3 }])
|
|
198
|
+
const sql = prepareMock.mock.calls[0]![0] as string
|
|
199
|
+
expect(sql).toMatch(/strftime\('%Y-%m-%d', created_at, 'unixepoch'\)/)
|
|
200
|
+
expect(sql).toMatch(/GROUP BY bucket_label/)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('CASTs non-system group columns as INTEGER', async () => {
|
|
204
|
+
const { db, prepareMock } = makeMockDb([])
|
|
205
|
+
await new D1WidgetRepository(db).timeseries(seed, { op: 'count' }, 'all', 'price')
|
|
206
|
+
const sql = prepareMock.mock.calls[0]![0] as string
|
|
207
|
+
expect(sql).toMatch(/CAST\(price AS INTEGER\)/)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('throws UNSAFE_COLUMN for invalid groupColumn', async () => {
|
|
211
|
+
const { db } = makeMockDb()
|
|
212
|
+
await expect(
|
|
213
|
+
new D1WidgetRepository(db).timeseries(seed, { op: 'count' }, 'all', 'evil'),
|
|
214
|
+
).rejects.toThrow('UNSAFE_COLUMN')
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import type {
|
|
3
|
+
Seed,
|
|
4
|
+
IWidgetRepository,
|
|
5
|
+
AggregateFormula,
|
|
6
|
+
TimeWindow,
|
|
7
|
+
LeaderboardEntry,
|
|
8
|
+
LeaderboardOptions,
|
|
9
|
+
TimeseriesPoint,
|
|
10
|
+
WidgetListOptions,
|
|
11
|
+
WidgetListResult,
|
|
12
|
+
GrowthResult,
|
|
13
|
+
} from '@beechcms/core'
|
|
14
|
+
|
|
15
|
+
const SYSTEM_COLUMNS: ReadonlySet<string> = new Set([
|
|
16
|
+
'id',
|
|
17
|
+
'slug',
|
|
18
|
+
'status',
|
|
19
|
+
'created_at',
|
|
20
|
+
'updated_at',
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
const ALLOWED_FILTER_OPERATORS: ReadonlySet<string> = new Set([
|
|
24
|
+
'eq',
|
|
25
|
+
'=',
|
|
26
|
+
'neq',
|
|
27
|
+
'!=',
|
|
28
|
+
'like',
|
|
29
|
+
'gt',
|
|
30
|
+
'>',
|
|
31
|
+
'lt',
|
|
32
|
+
'<',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
const UNSAFE_COLUMN_ERROR = 'UNSAFE_COLUMN'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* D1-backed implementation of {@link IWidgetRepository}.
|
|
39
|
+
*
|
|
40
|
+
* All user-supplied values are bound via parameterised statements. Column
|
|
41
|
+
* aliases originating from query strings are validated against the seed
|
|
42
|
+
* before being composed into the SQL string, preventing column-name
|
|
43
|
+
* injection. SQL keywords (aggregate function names, ORDER direction) are
|
|
44
|
+
* selected via hardcoded branches.
|
|
45
|
+
*/
|
|
46
|
+
export class D1WidgetRepository implements IWidgetRepository {
|
|
47
|
+
constructor(private readonly database: D1Database) {}
|
|
48
|
+
|
|
49
|
+
async aggregate(seed: Seed, formula: AggregateFormula, window: TimeWindow): Promise<number> {
|
|
50
|
+
const aggregateExpression = this.buildAggregateExpression(seed, formula)
|
|
51
|
+
const timeWindowFilter = this.buildTimeWindowFilter(window)
|
|
52
|
+
const tableName = `content_${seed.slug}`
|
|
53
|
+
|
|
54
|
+
const sql =
|
|
55
|
+
`SELECT ${aggregateExpression} as computed_value
|
|
56
|
+
FROM ${tableName}
|
|
57
|
+
WHERE ${timeWindowFilter}`
|
|
58
|
+
|
|
59
|
+
const row = await this.database.prepare(sql).first<{ computed_value: number | null }>()
|
|
60
|
+
return row?.computed_value ?? 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async growth(
|
|
64
|
+
seed: Seed,
|
|
65
|
+
formula: AggregateFormula,
|
|
66
|
+
window: TimeWindow,
|
|
67
|
+
): Promise<GrowthResult> {
|
|
68
|
+
const aggregateExpression = this.buildAggregateExpression(seed, formula)
|
|
69
|
+
const { currentFilter, previousFilter } = this.buildPreviousWindowFilter(window)
|
|
70
|
+
const tableName = `content_${seed.slug}`
|
|
71
|
+
|
|
72
|
+
const [currentRow, previousRow] = await Promise.all([
|
|
73
|
+
this.database
|
|
74
|
+
.prepare(`SELECT ${aggregateExpression} as computed_value FROM ${tableName} WHERE ${currentFilter}`)
|
|
75
|
+
.first<{ computed_value: number | null }>(),
|
|
76
|
+
this.database
|
|
77
|
+
.prepare(`SELECT ${aggregateExpression} as computed_value FROM ${tableName} WHERE ${previousFilter}`)
|
|
78
|
+
.first<{ computed_value: number | null }>(),
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
currentValue: currentRow?.computed_value ?? 0,
|
|
83
|
+
previousValue: previousRow?.computed_value ?? 0,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async leaderboard(seed: Seed, options: LeaderboardOptions): Promise<LeaderboardEntry[]> {
|
|
88
|
+
const scoreColumn = this.resolveColumnExpression(seed, options.scoreColumn)
|
|
89
|
+
const labelColumn = this.resolveColumnExpression(seed, seed.displayNameAlias)
|
|
90
|
+
const tableName = `content_${seed.slug}`
|
|
91
|
+
const orderClause = options.orderDirection === 'ASC'
|
|
92
|
+
? `ORDER BY CAST(${scoreColumn} AS REAL) ASC`
|
|
93
|
+
: `ORDER BY CAST(${scoreColumn} AS REAL) DESC`
|
|
94
|
+
|
|
95
|
+
const sql =
|
|
96
|
+
`SELECT id, ${labelColumn} as label, ${scoreColumn} as score
|
|
97
|
+
FROM ${tableName}
|
|
98
|
+
WHERE ${scoreColumn} IS NOT NULL
|
|
99
|
+
${orderClause}
|
|
100
|
+
LIMIT ?`
|
|
101
|
+
|
|
102
|
+
const rows = await this.database
|
|
103
|
+
.prepare(sql)
|
|
104
|
+
.bind(options.limit)
|
|
105
|
+
.all<{ id: string; label: string | null; score: number | string | null }>()
|
|
106
|
+
|
|
107
|
+
return (rows.results ?? []).map(row => ({
|
|
108
|
+
id: row.id,
|
|
109
|
+
label: row.label ?? row.id,
|
|
110
|
+
score: row.score ?? 0,
|
|
111
|
+
}))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async list(seed: Seed, options: WidgetListOptions): Promise<WidgetListResult> {
|
|
115
|
+
const tableName = `content_${seed.slug}`
|
|
116
|
+
const conditions: string[] = []
|
|
117
|
+
const bindings: unknown[] = []
|
|
118
|
+
|
|
119
|
+
const trimmedSearch = options.search?.trim() ?? ''
|
|
120
|
+
if (trimmedSearch.length > 0) {
|
|
121
|
+
const displayColumn = this.resolveColumnExpression(seed, seed.displayNameAlias)
|
|
122
|
+
conditions.push(`${displayColumn} LIKE ?`)
|
|
123
|
+
bindings.push(`%${trimmedSearch}%`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const filter of options.filters ?? []) {
|
|
127
|
+
this.appendFilterCondition(seed, filter, conditions, bindings)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const whereSql = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
131
|
+
const orderClause = this.buildListOrderClause(seed, options)
|
|
132
|
+
const branchColumns = seed.branches.map(branch => branch.alias).join(', ')
|
|
133
|
+
const selectColumns = branchColumns.length > 0
|
|
134
|
+
? `id, slug, status, created_at, updated_at, ${branchColumns}`
|
|
135
|
+
: `id, slug, status, created_at, updated_at`
|
|
136
|
+
|
|
137
|
+
const dataSql =
|
|
138
|
+
`SELECT ${selectColumns}
|
|
139
|
+
FROM ${tableName}
|
|
140
|
+
${whereSql}
|
|
141
|
+
${orderClause}
|
|
142
|
+
LIMIT ? OFFSET ?`
|
|
143
|
+
|
|
144
|
+
const countSql = `SELECT COUNT(*) as total FROM ${tableName} ${whereSql}`
|
|
145
|
+
|
|
146
|
+
const [countRow, listRows] = await Promise.all([
|
|
147
|
+
this.database.prepare(countSql).bind(...bindings).first<{ total: number }>(),
|
|
148
|
+
this.database
|
|
149
|
+
.prepare(dataSql)
|
|
150
|
+
.bind(...bindings, options.limit, options.offset)
|
|
151
|
+
.all<Record<string, unknown>>(),
|
|
152
|
+
])
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
entries: listRows.results ?? [],
|
|
156
|
+
totalCount: countRow?.total ?? 0,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async timeseries(
|
|
161
|
+
seed: Seed,
|
|
162
|
+
formula: AggregateFormula,
|
|
163
|
+
window: TimeWindow,
|
|
164
|
+
groupColumn: string,
|
|
165
|
+
): Promise<TimeseriesPoint[]> {
|
|
166
|
+
const groupColumnExpression = this.resolveColumnExpression(seed, groupColumn)
|
|
167
|
+
const aggregateExpression = this.buildAggregateExpression(seed, formula)
|
|
168
|
+
const timeWindowFilter = this.buildTimeWindowFilter(window)
|
|
169
|
+
const tableName = `content_${seed.slug}`
|
|
170
|
+
|
|
171
|
+
const dateBucketExpression = groupColumnExpression === 'created_at'
|
|
172
|
+
? `strftime('%Y-%m-%d', created_at, 'unixepoch')`
|
|
173
|
+
: `strftime('%Y-%m-%d', CAST(${groupColumnExpression} AS INTEGER), 'unixepoch')`
|
|
174
|
+
|
|
175
|
+
const sql =
|
|
176
|
+
`SELECT ${dateBucketExpression} as bucket_label,
|
|
177
|
+
${aggregateExpression} as bucket_value
|
|
178
|
+
FROM ${tableName}
|
|
179
|
+
WHERE ${timeWindowFilter}
|
|
180
|
+
GROUP BY bucket_label
|
|
181
|
+
ORDER BY bucket_label ASC`
|
|
182
|
+
|
|
183
|
+
const rows = await this.database
|
|
184
|
+
.prepare(sql)
|
|
185
|
+
.all<{ bucket_label: string | null; bucket_value: number | null }>()
|
|
186
|
+
|
|
187
|
+
return (rows.results ?? []).map(row => ({
|
|
188
|
+
label: row.bucket_label ?? '',
|
|
189
|
+
value: row.bucket_value ?? 0,
|
|
190
|
+
}))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Throws UNSAFE_COLUMN if the alias is neither a system column nor a
|
|
195
|
+
* declared seed branch, preventing SQL injection via column names.
|
|
196
|
+
*/
|
|
197
|
+
private resolveColumnExpression(seed: Seed, alias: string): string {
|
|
198
|
+
if (SYSTEM_COLUMNS.has(alias)) return alias
|
|
199
|
+
const branch = seed.branches.find(candidate => candidate.alias === alias)
|
|
200
|
+
if (!branch) throw new Error(UNSAFE_COLUMN_ERROR)
|
|
201
|
+
return branch.alias
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private buildAggregateExpression(seed: Seed, formula: AggregateFormula): string {
|
|
205
|
+
switch (formula.op) {
|
|
206
|
+
case 'count':
|
|
207
|
+
return 'COUNT(*)'
|
|
208
|
+
case 'sum': {
|
|
209
|
+
const column = this.resolveColumnExpression(seed, formula.column)
|
|
210
|
+
return `SUM(CAST(${column} AS REAL))`
|
|
211
|
+
}
|
|
212
|
+
case 'avg': {
|
|
213
|
+
const column = this.resolveColumnExpression(seed, formula.column)
|
|
214
|
+
return `AVG(CAST(${column} AS REAL))`
|
|
215
|
+
}
|
|
216
|
+
case 'min': {
|
|
217
|
+
const column = this.resolveColumnExpression(seed, formula.column)
|
|
218
|
+
return `MIN(CAST(${column} AS REAL))`
|
|
219
|
+
}
|
|
220
|
+
case 'max': {
|
|
221
|
+
const column = this.resolveColumnExpression(seed, formula.column)
|
|
222
|
+
return `MAX(CAST(${column} AS REAL))`
|
|
223
|
+
}
|
|
224
|
+
case 'countWhere':
|
|
225
|
+
return this.buildCountWhereExpression(seed, formula.column, formula.value)
|
|
226
|
+
case 'percentageOf': {
|
|
227
|
+
const numerator = this.resolveColumnExpression(seed, formula.numeratorColumn)
|
|
228
|
+
const denominator = this.resolveColumnExpression(seed, formula.denominatorColumn)
|
|
229
|
+
return `CASE WHEN SUM(CAST(${denominator} AS REAL)) = 0 THEN 0 ELSE (SUM(CAST(${numerator} AS REAL)) * 100.0 / SUM(CAST(${denominator} AS REAL))) END`
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private buildCountWhereExpression(seed: Seed, alias: string, value: unknown): string {
|
|
235
|
+
const column = this.resolveColumnExpression(seed, alias)
|
|
236
|
+
if (value === null) return `COUNT(CASE WHEN ${column} IS NULL THEN 1 END)`
|
|
237
|
+
if (typeof value === 'boolean') {
|
|
238
|
+
const numericValue = value ? 1 : 0
|
|
239
|
+
return `COUNT(CASE WHEN ${column} = ${numericValue} THEN 1 END)`
|
|
240
|
+
}
|
|
241
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
242
|
+
return `COUNT(CASE WHEN CAST(${column} AS REAL) = ${value} THEN 1 END)`
|
|
243
|
+
}
|
|
244
|
+
const escaped = String(value).replace(/'/g, "''")
|
|
245
|
+
return `COUNT(CASE WHEN ${column} = '${escaped}' THEN 1 END)`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private buildTimeWindowFilter(window: TimeWindow): string {
|
|
249
|
+
switch (window) {
|
|
250
|
+
case 'week': return "created_at > unixepoch('now', '-7 days')"
|
|
251
|
+
case 'month': return "created_at > unixepoch('now', '-1 month')"
|
|
252
|
+
case 'year': return "created_at > unixepoch('now', '-1 year')"
|
|
253
|
+
case 'all': return '1=1'
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private buildPreviousWindowFilter(
|
|
258
|
+
window: TimeWindow,
|
|
259
|
+
): { currentFilter: string; previousFilter: string } {
|
|
260
|
+
switch (window) {
|
|
261
|
+
case 'week':
|
|
262
|
+
return {
|
|
263
|
+
currentFilter: "created_at > unixepoch('now', '-7 days')",
|
|
264
|
+
previousFilter: "created_at > unixepoch('now', '-14 days') AND created_at <= unixepoch('now', '-7 days')",
|
|
265
|
+
}
|
|
266
|
+
case 'month':
|
|
267
|
+
return {
|
|
268
|
+
currentFilter: "created_at > unixepoch('now', '-1 month')",
|
|
269
|
+
previousFilter: "created_at > unixepoch('now', '-2 months') AND created_at <= unixepoch('now', '-1 month')",
|
|
270
|
+
}
|
|
271
|
+
case 'year':
|
|
272
|
+
return {
|
|
273
|
+
currentFilter: "created_at > unixepoch('now', '-1 year')",
|
|
274
|
+
previousFilter: "created_at > unixepoch('now', '-2 years') AND created_at <= unixepoch('now', '-1 year')",
|
|
275
|
+
}
|
|
276
|
+
case 'all':
|
|
277
|
+
return { currentFilter: '1=1', previousFilter: '1=0' }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private appendFilterCondition(
|
|
282
|
+
seed: Seed,
|
|
283
|
+
filter: { column: string; op: string; value: unknown },
|
|
284
|
+
conditions: string[],
|
|
285
|
+
bindings: unknown[],
|
|
286
|
+
): void {
|
|
287
|
+
if (!ALLOWED_FILTER_OPERATORS.has(filter.op)) return
|
|
288
|
+
|
|
289
|
+
let column: string
|
|
290
|
+
try {
|
|
291
|
+
column = this.resolveColumnExpression(seed, filter.column)
|
|
292
|
+
} catch {
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
switch (filter.op) {
|
|
297
|
+
case 'eq':
|
|
298
|
+
case '=':
|
|
299
|
+
conditions.push(`${column} = ?`)
|
|
300
|
+
bindings.push(filter.value)
|
|
301
|
+
return
|
|
302
|
+
case 'neq':
|
|
303
|
+
case '!=':
|
|
304
|
+
conditions.push(`${column} != ?`)
|
|
305
|
+
bindings.push(filter.value)
|
|
306
|
+
return
|
|
307
|
+
case 'like':
|
|
308
|
+
conditions.push(`${column} LIKE ?`)
|
|
309
|
+
bindings.push(filter.value)
|
|
310
|
+
return
|
|
311
|
+
case 'gt':
|
|
312
|
+
case '>':
|
|
313
|
+
conditions.push(`CAST(${column} AS REAL) > ?`)
|
|
314
|
+
bindings.push(filter.value)
|
|
315
|
+
return
|
|
316
|
+
case 'lt':
|
|
317
|
+
case '<':
|
|
318
|
+
conditions.push(`CAST(${column} AS REAL) < ?`)
|
|
319
|
+
bindings.push(filter.value)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private buildListOrderClause(seed: Seed, options: WidgetListOptions): string {
|
|
325
|
+
let orderColumn = 'created_at'
|
|
326
|
+
if (options.orderByColumn) {
|
|
327
|
+
try {
|
|
328
|
+
orderColumn = this.resolveColumnExpression(seed, options.orderByColumn)
|
|
329
|
+
} catch {
|
|
330
|
+
orderColumn = 'created_at'
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return options.orderDirection === 'DESC'
|
|
334
|
+
? `ORDER BY ${orderColumn} DESC`
|
|
335
|
+
: `ORDER BY ${orderColumn} ASC`
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { IClock } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
const MILLISECONDS_PER_SECOND = 1000
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Test-only IClock implementation that returns a frozen timestamp on every
|
|
7
|
+
* call. Lets specs assert exact values for `createdAt`, `iat`, and
|
|
8
|
+
* day-bucket computations without resorting to vi.useFakeTimers or
|
|
9
|
+
* patching the global Date constructor.
|
|
10
|
+
*/
|
|
11
|
+
export class FixedClock implements IClock {
|
|
12
|
+
constructor(private readonly fixedNowMs: number) {}
|
|
13
|
+
|
|
14
|
+
now(): number {
|
|
15
|
+
return this.fixedNowMs
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
nowSeconds(): number {
|
|
19
|
+
return Math.floor(this.fixedNowMs / MILLISECONDS_PER_SECOND)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|