@beechcms/api 0.4.0-preview.8 → 0.4.0
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/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/constants.ts +10 -10
- package/src/auth/login.ts +91 -91
- package/src/auth/refresh.ts +127 -127
- package/src/factory.ts +347 -303
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +153 -0
- package/src/features/content/handlers/delete.ts +76 -0
- package/src/features/content/handlers/facets.ts +45 -0
- package/src/features/content/handlers/get.ts +116 -0
- package/src/features/content/handlers/list.ts +88 -0
- package/src/features/content/handlers/update.ts +207 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +272 -198
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +130 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +106 -88
- package/src/features/password-reset/reset.ts +128 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +132 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +414 -249
- package/src/features/setup/index.ts +96 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +458 -395
- package/src/index.ts +24 -11
- package/src/media-utils.ts +78 -78
- package/src/middleware/repository.middleware.ts +24 -0
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +67 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +166 -183
- package/src/public/public-edit.ts +158 -183
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -217
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.ts +152 -241
- package/src/public/rate-limit-middleware.ts +42 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- package/src/search-utils.ts +192 -192
- package/src/search.ts +72 -72
- package/src/shared/activity-logger.ts +79 -79
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.ts +56 -0
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/notification-service.ts +56 -56
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +46 -36
- package/src/upload.ts +179 -335
- package/src/widget.ts +349 -349
- package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
- package/assets/dashboard/assets/index-CQODXprH.css +0 -1
- package/src/content.ts +0 -502
- package/src/features/draft/draft.test.ts +0 -315
- package/src/features/rotate-field/rotate-field.test.ts +0 -297
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import {
|
|
3
|
+
ContentRepository,
|
|
4
|
+
EntryNotFoundError,
|
|
5
|
+
RepositoryError,
|
|
6
|
+
SlugConflictError,
|
|
7
|
+
Seed,
|
|
8
|
+
SelectOptions,
|
|
9
|
+
buildSelectQuery,
|
|
10
|
+
deserializeFromDb,
|
|
11
|
+
serializeForDb,
|
|
12
|
+
} from '@beechcms/core'
|
|
13
|
+
import { BaseD1Repository } from './base.repository.d1'
|
|
14
|
+
|
|
15
|
+
export class D1ContentRepository extends BaseD1Repository implements ContentRepository {
|
|
16
|
+
/**
|
|
17
|
+
* Helper to deserialize a DB row using the Seed's branch definitions.
|
|
18
|
+
*/
|
|
19
|
+
private rowToData(seed: Seed, row: any): Record<string, any> {
|
|
20
|
+
const data: Record<string, any> = {
|
|
21
|
+
id: row.id,
|
|
22
|
+
slug: row.slug,
|
|
23
|
+
status: row.status,
|
|
24
|
+
created_at: row.created_at,
|
|
25
|
+
updated_at: row.updated_at,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const branch of seed.branches) {
|
|
29
|
+
if (Object.hasOwn(row, branch.alias)) {
|
|
30
|
+
data[branch.alias] = deserializeFromDb(branch, row[branch.alias])
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return data
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async findMany(
|
|
38
|
+
seed: Seed,
|
|
39
|
+
options: SelectOptions
|
|
40
|
+
): Promise<{ items: Record<string, any>[]; total: number }> {
|
|
41
|
+
try {
|
|
42
|
+
const { sql, bindings } = buildSelectQuery(seed, options)
|
|
43
|
+
|
|
44
|
+
// We need the total count for pagination.
|
|
45
|
+
// We build a count query by replacing the SELECT part.
|
|
46
|
+
// Note: buildSelectQuery might have joins and where clauses.
|
|
47
|
+
const countSql = sql
|
|
48
|
+
.replace(/SELECT .* FROM/, 'SELECT COUNT(*) as total FROM')
|
|
49
|
+
.replace(/ ORDER BY .*$/, '')
|
|
50
|
+
.replace(/ LIMIT \? OFFSET \?$/, '')
|
|
51
|
+
|
|
52
|
+
const countBindings = bindings.slice(0, bindings.length - (options.pagination ? 2 : 0))
|
|
53
|
+
|
|
54
|
+
const [batchResults, totalCountResult] = await this.database.batch([
|
|
55
|
+
this.database.prepare(sql).bind(...bindings),
|
|
56
|
+
this.database.prepare(countSql).bind(...countBindings)
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
const contentEntries = (batchResults.results || []).map((entryRow) => this.rowToData(seed, entryRow))
|
|
60
|
+
const totalEntriesCount = (totalCountResult.results?.[0] as any)?.total || 0
|
|
61
|
+
|
|
62
|
+
return { items: contentEntries, total: totalEntriesCount }
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw this.mapError(error, `findMany(${seed.slug})`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async findById(seed: Seed, id: string): Promise<Record<string, any>> {
|
|
69
|
+
try {
|
|
70
|
+
const tableName = this.getTableName(seed.slug)
|
|
71
|
+
const entryRow = await this.database
|
|
72
|
+
.prepare(`SELECT * FROM ${tableName} WHERE id = ? LIMIT 1`)
|
|
73
|
+
.bind(id)
|
|
74
|
+
.first()
|
|
75
|
+
|
|
76
|
+
if (!entryRow) {
|
|
77
|
+
throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return this.rowToData(seed, entryRow)
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof EntryNotFoundError) throw error
|
|
83
|
+
throw this.mapError(error, `findById(${seed.slug}, ${id})`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async findBySlug(seed: Seed, slug: string): Promise<Record<string, any>> {
|
|
88
|
+
try {
|
|
89
|
+
const tableName = this.getTableName(seed.slug)
|
|
90
|
+
const entryRow = await this.database
|
|
91
|
+
.prepare(`SELECT * FROM ${tableName} WHERE slug = ? LIMIT 1`)
|
|
92
|
+
.bind(slug)
|
|
93
|
+
.first()
|
|
94
|
+
|
|
95
|
+
if (!entryRow) {
|
|
96
|
+
throw new EntryNotFoundError(`Entry with slug "${slug}" not found in ${seed.slug}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return this.rowToData(seed, entryRow)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof EntryNotFoundError) throw error
|
|
102
|
+
throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async getFacets(seed: Seed): Promise<{
|
|
107
|
+
statuses: Record<string, number>
|
|
108
|
+
tagsByColumn: Record<string, string[]>
|
|
109
|
+
}> {
|
|
110
|
+
try {
|
|
111
|
+
const tableName = this.getTableName(seed.slug)
|
|
112
|
+
|
|
113
|
+
// Retrieve count per status
|
|
114
|
+
const statusResults = await this.database
|
|
115
|
+
.prepare(`SELECT status, COUNT(*) as count FROM ${tableName} GROUP BY status`)
|
|
116
|
+
.all()
|
|
117
|
+
|
|
118
|
+
const statusesCount: Record<string, number> = {}
|
|
119
|
+
for (const statusRow of statusResults.results || []) {
|
|
120
|
+
statusesCount[statusRow.status as string] = statusRow.count as number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Collect unique tags for branches of type 'tags'
|
|
124
|
+
const tagsByColumn: Record<string, string[]> = {}
|
|
125
|
+
const tagBranches = seed.branches.filter(branch => branch.type === 'tags')
|
|
126
|
+
|
|
127
|
+
for (const branch of tagBranches) {
|
|
128
|
+
// Use SQLite json_each to expand tags stored as JSON arrays
|
|
129
|
+
const tagResults = await this.database
|
|
130
|
+
.prepare(`SELECT DISTINCT value FROM ${tableName}, json_each(${tableName}.${branch.alias}) WHERE value IS NOT NULL`)
|
|
131
|
+
.all()
|
|
132
|
+
tagsByColumn[branch.alias] = (tagResults.results || []).map(row => row.value as string)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { statuses: statusesCount, tagsByColumn }
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw this.mapError(error, `getFacets(${seed.slug})`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async existsSlug(seed: Seed, slug: string, excludeId?: string): Promise<boolean> {
|
|
142
|
+
try {
|
|
143
|
+
const tableName = this.getTableName(seed.slug)
|
|
144
|
+
let sql = `SELECT 1 FROM ${tableName} WHERE slug = ?`
|
|
145
|
+
const queryBindings: any[] = [slug]
|
|
146
|
+
|
|
147
|
+
if (excludeId) {
|
|
148
|
+
sql += ` AND id != ?`
|
|
149
|
+
queryBindings.push(excludeId)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const entryExistsResult = await this.database.prepare(sql).bind(...queryBindings).first()
|
|
153
|
+
return entryExistsResult !== null
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw this.mapError(error, `existsSlug(${seed.slug}, ${slug})`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async create(
|
|
160
|
+
seed: Seed,
|
|
161
|
+
id: string,
|
|
162
|
+
slug: string,
|
|
163
|
+
status: string,
|
|
164
|
+
data: Record<string, any>
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
try {
|
|
167
|
+
if (await this.existsSlug(seed, slug)) {
|
|
168
|
+
throw new SlugConflictError(`Slug "${slug}" already exists for ${seed.slug}`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const tableName = this.getTableName(seed.slug)
|
|
172
|
+
const columnNames = ['id', 'slug', 'status']
|
|
173
|
+
const placeholders = ['?', '?', '?']
|
|
174
|
+
const queryBindings: any[] = [id, slug, status]
|
|
175
|
+
|
|
176
|
+
for (const branch of seed.branches) {
|
|
177
|
+
if (Object.hasOwn(data, branch.alias)) {
|
|
178
|
+
columnNames.push(branch.alias)
|
|
179
|
+
placeholders.push('?')
|
|
180
|
+
queryBindings.push(serializeForDb(branch, data[branch.alias]))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const sql = `INSERT INTO ${tableName} (${columnNames.join(', ')}) VALUES (${placeholders.join(', ')})`
|
|
185
|
+
await this.database.prepare(sql).bind(...queryBindings).run()
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (error instanceof SlugConflictError) throw error
|
|
188
|
+
throw this.mapError(error, `create(${seed.slug})`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async update(
|
|
193
|
+
seed: Seed,
|
|
194
|
+
id: string,
|
|
195
|
+
data: Record<string, any>,
|
|
196
|
+
status?: string
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
try {
|
|
199
|
+
const tableName = this.getTableName(seed.slug)
|
|
200
|
+
const updateClauses: string[] = []
|
|
201
|
+
const queryBindings: any[] = []
|
|
202
|
+
|
|
203
|
+
if (status) {
|
|
204
|
+
updateClauses.push('status = ?')
|
|
205
|
+
queryBindings.push(status)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const branch of seed.branches) {
|
|
209
|
+
if (Object.hasOwn(data, branch.alias)) {
|
|
210
|
+
updateClauses.push(`${branch.alias} = ?`)
|
|
211
|
+
queryBindings.push(serializeForDb(branch, data[branch.alias]))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (updateClauses.length === 0) return
|
|
216
|
+
|
|
217
|
+
updateClauses.push('updated_at = (unixepoch())')
|
|
218
|
+
|
|
219
|
+
const sql = `UPDATE ${tableName} SET ${updateClauses.join(', ')} WHERE id = ?`
|
|
220
|
+
queryBindings.push(id)
|
|
221
|
+
|
|
222
|
+
const updateResult = await this.database.prepare(sql).bind(...queryBindings).run()
|
|
223
|
+
if (updateResult.meta.changes === 0) {
|
|
224
|
+
throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error instanceof EntryNotFoundError) throw error
|
|
228
|
+
throw this.mapError(error, `update(${seed.slug}, ${id})`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async delete(seed: Seed, id: string): Promise<{ row: Record<string, any> }> {
|
|
233
|
+
try {
|
|
234
|
+
const tableName = this.getTableName(seed.slug)
|
|
235
|
+
|
|
236
|
+
// Retrieve the row before deletion to allow for potential cleanup (e.g., media files in R2)
|
|
237
|
+
const entryRow = await this.database
|
|
238
|
+
.prepare(`SELECT * FROM ${tableName} WHERE id = ?`)
|
|
239
|
+
.bind(id)
|
|
240
|
+
.first()
|
|
241
|
+
|
|
242
|
+
if (!entryRow) {
|
|
243
|
+
throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await this.database.prepare(`DELETE FROM ${tableName} WHERE id = ?`).bind(id).run()
|
|
247
|
+
|
|
248
|
+
return { row: this.rowToData(seed, entryRow) }
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (error instanceof EntryNotFoundError) throw error
|
|
251
|
+
throw this.mapError(error, `delete(${seed.slug}, ${id})`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async saveDraft(seed: Seed, entryId: string, data: Record<string, any>): Promise<void> {
|
|
256
|
+
try {
|
|
257
|
+
if (!seed.allowDrafts) {
|
|
258
|
+
throw new RepositoryError(`Drafts not allowed for ${seed.slug}`)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const draftTableName = this.getTableName(seed.slug, true)
|
|
262
|
+
const columnNames = ['entry_id']
|
|
263
|
+
const placeholders = ['?']
|
|
264
|
+
const queryBindings: any[] = [entryId]
|
|
265
|
+
const updateClauses: string[] = []
|
|
266
|
+
|
|
267
|
+
for (const branch of seed.branches) {
|
|
268
|
+
if (Object.hasOwn(data, branch.alias)) {
|
|
269
|
+
const serializedValue = serializeForDb(branch, data[branch.alias])
|
|
270
|
+
columnNames.push(branch.alias)
|
|
271
|
+
placeholders.push('?')
|
|
272
|
+
queryBindings.push(serializedValue)
|
|
273
|
+
updateClauses.push(`${branch.alias} = EXCLUDED.${branch.alias}`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
updateClauses.push('updated_at = (unixepoch())')
|
|
278
|
+
|
|
279
|
+
const sql = `
|
|
280
|
+
INSERT INTO ${draftTableName} (${columnNames.join(', ')})
|
|
281
|
+
VALUES (${placeholders.join(', ')})
|
|
282
|
+
ON CONFLICT(entry_id) DO UPDATE SET ${updateClauses.join(', ')}
|
|
283
|
+
`
|
|
284
|
+
await this.database.prepare(sql).bind(...queryBindings).run()
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async getDraft(seed: Seed, entryId: string): Promise<Record<string, any> | null> {
|
|
291
|
+
try {
|
|
292
|
+
if (!seed.allowDrafts) return null
|
|
293
|
+
|
|
294
|
+
const draftTableName = this.getTableName(seed.slug, true)
|
|
295
|
+
const draftRow = await this.database
|
|
296
|
+
.prepare(`SELECT * FROM ${draftTableName} WHERE entry_id = ?`)
|
|
297
|
+
.bind(entryId)
|
|
298
|
+
.first()
|
|
299
|
+
|
|
300
|
+
if (!draftRow) return null
|
|
301
|
+
|
|
302
|
+
// Filter out nulls from draft row (only include explicitly provided fields)
|
|
303
|
+
const draftData: Record<string, any> = {}
|
|
304
|
+
for (const branch of seed.branches) {
|
|
305
|
+
if (draftRow[branch.alias] !== null) {
|
|
306
|
+
draftData[branch.alias] = deserializeFromDb(branch, draftRow[branch.alias])
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return draftData
|
|
311
|
+
} catch (error) {
|
|
312
|
+
throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async hasDraft(seed: Seed, entryId: string): Promise<boolean> {
|
|
317
|
+
try {
|
|
318
|
+
if (!seed.allowDrafts) return false
|
|
319
|
+
const draftTableName = this.getTableName(seed.slug, true)
|
|
320
|
+
const draftExistsResult = await this.database
|
|
321
|
+
.prepare(`SELECT 1 FROM ${draftTableName} WHERE entry_id = ? LIMIT 1`)
|
|
322
|
+
.bind(entryId)
|
|
323
|
+
.first()
|
|
324
|
+
return draftExistsResult !== null
|
|
325
|
+
} catch (error) {
|
|
326
|
+
throw this.mapError(error, `hasDraft(${seed.slug}, ${entryId})`)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async publishDraft(seed: Seed, entryId: string): Promise<void> {
|
|
331
|
+
try {
|
|
332
|
+
if (!seed.allowDrafts) return
|
|
333
|
+
|
|
334
|
+
const draftTableName = this.getTableName(seed.slug, true)
|
|
335
|
+
const liveTableName = this.getTableName(seed.slug)
|
|
336
|
+
|
|
337
|
+
const draftRow = await this.database
|
|
338
|
+
.prepare(`SELECT * FROM ${draftTableName} WHERE entry_id = ?`)
|
|
339
|
+
.bind(entryId)
|
|
340
|
+
.first()
|
|
341
|
+
|
|
342
|
+
if (!draftRow) {
|
|
343
|
+
throw new EntryNotFoundError(`No draft found for ${entryId} in ${seed.slug}`)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Build UPDATE clause for the live table using data from the draft
|
|
347
|
+
const updateClauses: string[] = []
|
|
348
|
+
const queryBindings: any[] = []
|
|
349
|
+
|
|
350
|
+
for (const branch of seed.branches) {
|
|
351
|
+
if (draftRow[branch.alias] !== null) {
|
|
352
|
+
updateClauses.push(`${branch.alias} = ?`)
|
|
353
|
+
queryBindings.push(draftRow[branch.alias])
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
updateClauses.push('updated_at = (unixepoch())')
|
|
358
|
+
|
|
359
|
+
const updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(', ')} WHERE id = ?`
|
|
360
|
+
queryBindings.push(entryId)
|
|
361
|
+
|
|
362
|
+
// Execute batch to atomically update live table and delete draft
|
|
363
|
+
await this.database.batch([
|
|
364
|
+
this.database.prepare(updateSql).bind(...queryBindings),
|
|
365
|
+
this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId)
|
|
366
|
+
])
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error instanceof EntryNotFoundError) throw error
|
|
369
|
+
throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async deleteDraft(seed: Seed, entryId: string): Promise<void> {
|
|
374
|
+
try {
|
|
375
|
+
if (!seed.allowDrafts) return
|
|
376
|
+
const draftTableName = this.getTableName(seed.slug, true)
|
|
377
|
+
await this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId).run()
|
|
378
|
+
} catch (error) {
|
|
379
|
+
throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
package/src/shared/fts-sync.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// v0.4.0: FTS sync handled automatically by SQL triggers (generateFtsTriggers).
|
|
2
|
-
// syncFts/deleteFts eliminated. This file kept as empty module to avoid import errors
|
|
3
|
-
// during the Phase 4 → Phase 6 transition.
|
|
4
|
-
export {}
|
|
1
|
+
// v0.4.0: FTS sync handled automatically by SQL triggers (generateFtsTriggers).
|
|
2
|
+
// syncFts/deleteFts eliminated. This file kept as empty module to avoid import errors
|
|
3
|
+
// during the Phase 4 → Phase 6 transition.
|
|
4
|
+
export {}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { IdempotencyRepository, IdempotencyRecord } from '@beechcms/core'
|
|
2
|
+
import { BaseD1Repository } from './base.repository.d1.js'
|
|
3
|
+
|
|
4
|
+
export class D1IdempotencyRepository extends BaseD1Repository implements IdempotencyRepository {
|
|
5
|
+
/**
|
|
6
|
+
* Looks up an idempotency record by its key.
|
|
7
|
+
*/
|
|
8
|
+
async lookup(idempotencyKey: string): Promise<IdempotencyRecord | null> {
|
|
9
|
+
const idempotencyRecordResult = await this.database.prepare(
|
|
10
|
+
`SELECT idempotency_key, request_fingerprint, response_status, response_body, expires_at
|
|
11
|
+
FROM public_idempotency_keys WHERE idempotency_key = ? LIMIT 1`
|
|
12
|
+
).bind(idempotencyKey).first<any>()
|
|
13
|
+
|
|
14
|
+
if (!idempotencyRecordResult) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
key: idempotencyRecordResult.idempotency_key,
|
|
20
|
+
fingerprint: idempotencyRecordResult.request_fingerprint,
|
|
21
|
+
responseStatus: idempotencyRecordResult.response_status,
|
|
22
|
+
responseBody: idempotencyRecordResult.response_body,
|
|
23
|
+
expiresAt: idempotencyRecordResult.expires_at
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stores or updates an idempotency record.
|
|
29
|
+
*/
|
|
30
|
+
async store(idempotencyData: IdempotencyRecord): Promise<void> {
|
|
31
|
+
await this.database.prepare(
|
|
32
|
+
`INSERT INTO public_idempotency_keys (idempotency_key, request_fingerprint, response_status, response_body, created_at, expires_at)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
34
|
+
ON CONFLICT(idempotency_key) DO UPDATE SET
|
|
35
|
+
request_fingerprint = excluded.request_fingerprint,
|
|
36
|
+
response_status = excluded.response_status,
|
|
37
|
+
response_body = excluded.response_body,
|
|
38
|
+
created_at = excluded.created_at,
|
|
39
|
+
expires_at = excluded.expires_at`
|
|
40
|
+
).bind(
|
|
41
|
+
idempotencyData.key,
|
|
42
|
+
idempotencyData.fingerprint,
|
|
43
|
+
idempotencyData.responseStatus,
|
|
44
|
+
idempotencyData.responseBody,
|
|
45
|
+
Math.floor(Date.now() / 1000),
|
|
46
|
+
idempotencyData.expiresAt
|
|
47
|
+
).run()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cleans up expired idempotency records.
|
|
52
|
+
*/
|
|
53
|
+
async cleanup(expirationTimestamp: number): Promise<void> {
|
|
54
|
+
await this.database.prepare(`DELETE FROM public_idempotency_keys WHERE expires_at < ?`).bind(expirationTimestamp).run()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { MediaRepository, MediaObject } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Implementation of MediaRepository using Cloudflare D1.
|
|
5
|
+
* Provides methods to track media uploads, retrieve media metadata, and manage the media library state in the database.
|
|
6
|
+
*/
|
|
7
|
+
export class D1MediaRepository implements MediaRepository {
|
|
8
|
+
constructor(private database: D1Database) {}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Registers a new media upload in the database.
|
|
12
|
+
*/
|
|
13
|
+
async trackUpload(mediaObject: MediaObject): Promise<void> {
|
|
14
|
+
await this.database.prepare(
|
|
15
|
+
'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
|
|
16
|
+
).bind(
|
|
17
|
+
mediaObject.key,
|
|
18
|
+
mediaObject.filename,
|
|
19
|
+
mediaObject.mime_type,
|
|
20
|
+
mediaObject.size_bytes,
|
|
21
|
+
mediaObject.uploaded_by
|
|
22
|
+
).run()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Retrieves a media object's metadata by its unique key.
|
|
27
|
+
*/
|
|
28
|
+
async getByKey(mediaKey: string): Promise<MediaObject | null> {
|
|
29
|
+
return await this.database.prepare(
|
|
30
|
+
'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects WHERE key = ?'
|
|
31
|
+
).bind(mediaKey).first<MediaObject>()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Removes the metadata tracking for a specific media key.
|
|
36
|
+
*/
|
|
37
|
+
async untrack(mediaKey: string): Promise<void> {
|
|
38
|
+
await this.database.prepare('DELETE FROM media_objects WHERE key = ?').bind(mediaKey).run()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lists media objects with pagination support.
|
|
43
|
+
*/
|
|
44
|
+
async list(paginationOptions: { limit: number; offset: number }): Promise<{ items: MediaObject[]; total: number }> {
|
|
45
|
+
const { results: mediaItems } = await this.database.prepare(
|
|
46
|
+
'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects ORDER BY created_at DESC LIMIT ? OFFSET ?'
|
|
47
|
+
).bind(paginationOptions.limit, paginationOptions.offset).all<MediaObject>()
|
|
48
|
+
|
|
49
|
+
const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
items: mediaItems ?? [],
|
|
53
|
+
total: totalCountRecord?.total ?? 0
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns the total number of media objects in the database.
|
|
59
|
+
*/
|
|
60
|
+
async count(): Promise<number> {
|
|
61
|
+
const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
|
|
62
|
+
return totalCountRecord?.total ?? 0
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
import { Context } from 'hono'
|
|
2
|
-
|
|
3
|
-
export interface NotificationParams {
|
|
4
|
-
title: string
|
|
5
|
-
message: string
|
|
6
|
-
type?: 'info' | 'success' | 'warning' | 'error'
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Crea una notifica persistente nel database D1.
|
|
11
|
-
* Usata principalmente per segnalare azioni provenienti dalle Public API.
|
|
12
|
-
*/
|
|
13
|
-
export async function createNotification(
|
|
14
|
-
c: Context<any>,
|
|
15
|
-
params: NotificationParams
|
|
16
|
-
): Promise<void> {
|
|
17
|
-
const db = c.env.DB as D1Database
|
|
18
|
-
if (!db) return
|
|
19
|
-
|
|
20
|
-
const { title, message, type = 'info' } = params
|
|
21
|
-
const id = crypto.randomUUID()
|
|
22
|
-
|
|
23
|
-
// Usiamo waitUntil per non bloccare la richiesta dell'utente esterno
|
|
24
|
-
let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
|
|
25
|
-
try {
|
|
26
|
-
executionCtx = c.executionCtx
|
|
27
|
-
} catch {
|
|
28
|
-
// In ambiente di test Hono lancia se non presente
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (executionCtx) {
|
|
32
|
-
executionCtx.waitUntil((async () => {
|
|
33
|
-
try {
|
|
34
|
-
await db.prepare(
|
|
35
|
-
`INSERT INTO notifications (id, title, message, type)
|
|
36
|
-
VALUES (?, ?, ?, ?)`
|
|
37
|
-
).bind(id, title, message, type).run()
|
|
38
|
-
} catch (err) {
|
|
39
|
-
console.error('Failed to create notification:', err)
|
|
40
|
-
}
|
|
41
|
-
})())
|
|
42
|
-
} else {
|
|
43
|
-
// Fallback sync per test o ambienti senza executionCtx (se vogliamo che le notifiche siano create)
|
|
44
|
-
// Oppure semplicemente ignoriamo. In questo caso, per i test, meglio tentare di crearle
|
|
45
|
-
// ma dato che è un'azione opzionale "background", in test possiamo saltarla o farla sync.
|
|
46
|
-
// Facciamola sync se non c'è executionCtx per garantire che i test che verificano le notifiche (se ce ne sono) passino.
|
|
47
|
-
try {
|
|
48
|
-
await db.prepare(
|
|
49
|
-
`INSERT INTO notifications (id, title, message, type)
|
|
50
|
-
VALUES (?, ?, ?, ?)`
|
|
51
|
-
).bind(id, title, message, type).run()
|
|
52
|
-
} catch (err) {
|
|
53
|
-
console.error('Failed to create notification (sync fallback):', err)
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
1
|
+
import { Context } from 'hono'
|
|
2
|
+
|
|
3
|
+
export interface NotificationParams {
|
|
4
|
+
title: string
|
|
5
|
+
message: string
|
|
6
|
+
type?: 'info' | 'success' | 'warning' | 'error'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Crea una notifica persistente nel database D1.
|
|
11
|
+
* Usata principalmente per segnalare azioni provenienti dalle Public API.
|
|
12
|
+
*/
|
|
13
|
+
export async function createNotification(
|
|
14
|
+
c: Context<any>,
|
|
15
|
+
params: NotificationParams
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
const db = c.env.DB as D1Database
|
|
18
|
+
if (!db) return
|
|
19
|
+
|
|
20
|
+
const { title, message, type = 'info' } = params
|
|
21
|
+
const id = crypto.randomUUID()
|
|
22
|
+
|
|
23
|
+
// Usiamo waitUntil per non bloccare la richiesta dell'utente esterno
|
|
24
|
+
let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
|
|
25
|
+
try {
|
|
26
|
+
executionCtx = c.executionCtx
|
|
27
|
+
} catch {
|
|
28
|
+
// In ambiente di test Hono lancia se non presente
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (executionCtx) {
|
|
32
|
+
executionCtx.waitUntil((async () => {
|
|
33
|
+
try {
|
|
34
|
+
await db.prepare(
|
|
35
|
+
`INSERT INTO notifications (id, title, message, type)
|
|
36
|
+
VALUES (?, ?, ?, ?)`
|
|
37
|
+
).bind(id, title, message, type).run()
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error('Failed to create notification:', err)
|
|
40
|
+
}
|
|
41
|
+
})())
|
|
42
|
+
} else {
|
|
43
|
+
// Fallback sync per test o ambienti senza executionCtx (se vogliamo che le notifiche siano create)
|
|
44
|
+
// Oppure semplicemente ignoriamo. In questo caso, per i test, meglio tentare di crearle
|
|
45
|
+
// ma dato che è un'azione opzionale "background", in test possiamo saltarla o farla sync.
|
|
46
|
+
// Facciamola sync se non c'è executionCtx per garantire che i test che verificano le notifiche (se ce ne sono) passino.
|
|
47
|
+
try {
|
|
48
|
+
await db.prepare(
|
|
49
|
+
`INSERT INTO notifications (id, title, message, type)
|
|
50
|
+
VALUES (?, ?, ?, ?)`
|
|
51
|
+
).bind(id, title, message, type).run()
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error('Failed to create notification (sync fallback):', err)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|