@beechcms/api 0.4.0-preview.1 → 0.4.0-preview.11
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 -0
- package/assets/dashboard/BeechLogoLIght.svg +48 -0
- package/assets/dashboard/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/assets/dashboard/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/assets/dashboard/assets/index-9Ch2xWJr.js +554 -0
- package/assets/dashboard/assets/index-ye3325L9.css +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -0
- package/assets/dashboard/index.html +18 -0
- package/assets/dashboard/sol.svg +3 -0
- package/assets/dashboard/undraw_clouds_bmtk.svg +1 -0
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -0
- package/assets/dashboard/vite.svg +1 -0
- package/package.json +4 -3
- package/src/factory.ts +290 -21
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +163 -0
- package/src/features/content/handlers/delete.ts +85 -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 +216 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +203 -129
- package/src/features/password-reset/request.ts +1 -1
- package/src/features/schema/schema.handler.ts +16 -0
- package/src/features/settings/settings.handler.ts +18 -0
- package/src/index.ts +17 -337
- package/src/middleware/repository.middleware.ts +18 -0
- package/src/public/public-add.ts +72 -89
- package/src/public/public-edit.ts +51 -76
- package/src/public/public-read.ts +113 -114
- package/src/public/query-builder.ts +47 -136
- package/src/search-utils.ts +5 -1
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/idempotency.repository.d1.ts +45 -0
- package/src/types.ts +7 -1
- package/src/upload.ts +3 -7
- 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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Seed } from '@beechcms/core'
|
|
1
|
+
import type { Seed, FilterGroup, FilterOperator, FilterType, BranchType } from '@beechcms/core'
|
|
2
2
|
import { parsePositiveInt } from '../shared/query-utils'
|
|
3
3
|
|
|
4
4
|
export type PublicQueryInput = {
|
|
@@ -7,8 +7,8 @@ export type PublicQueryInput = {
|
|
|
7
7
|
latest?: string
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
type PublicFilterLogic = 'AND' | 'OR'
|
|
11
|
-
type PublicFilterOperator =
|
|
10
|
+
export type PublicFilterLogic = 'AND' | 'OR'
|
|
11
|
+
export type PublicFilterOperator =
|
|
12
12
|
| 'eq'
|
|
13
13
|
| 'neq'
|
|
14
14
|
| 'gt'
|
|
@@ -27,13 +27,13 @@ type PublicFilterOperator =
|
|
|
27
27
|
| 'has_any_tag'
|
|
28
28
|
| 'has_all_tags'
|
|
29
29
|
|
|
30
|
-
type PublicFilterCondition = {
|
|
30
|
+
export type PublicFilterCondition = {
|
|
31
31
|
field: string
|
|
32
32
|
op: PublicFilterOperator
|
|
33
33
|
value?: unknown
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
type ParsedPublicFilter = {
|
|
36
|
+
export type ParsedPublicFilter = {
|
|
37
37
|
where: PublicFilterCondition[]
|
|
38
38
|
logic: PublicFilterLogic
|
|
39
39
|
}
|
|
@@ -45,29 +45,10 @@ const PUBLIC_FILTER_OPERATORS = new Set<PublicFilterOperator>([
|
|
|
45
45
|
'has_tag', 'has_any_tag', 'has_all_tags',
|
|
46
46
|
])
|
|
47
47
|
|
|
48
|
-
const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at'])
|
|
49
|
-
|
|
50
48
|
function asString(value: unknown): string | null {
|
|
51
49
|
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
/**
|
|
55
|
-
* Risolve il nome colonna SQL per un campo.
|
|
56
|
-
* In v0.4.0 il nome colonna = branch.alias (nessun json_extract).
|
|
57
|
-
*/
|
|
58
|
-
function resolveFieldExpression(
|
|
59
|
-
seed: Seed | null,
|
|
60
|
-
field: string
|
|
61
|
-
): { expr: string; fieldType: string } | null {
|
|
62
|
-
if (SYSTEM_COLUMNS.has(field)) {
|
|
63
|
-
const type = field === 'created_at' || field === 'updated_at' ? 'number' : 'text'
|
|
64
|
-
return { expr: field, fieldType: type }
|
|
65
|
-
}
|
|
66
|
-
const branch = seed?.branches.find((b) => b.alias === field)
|
|
67
|
-
if (!branch) return null
|
|
68
|
-
return { expr: branch.alias, fieldType: branch.type }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
52
|
function validateLogic(logicRaw: unknown): PublicFilterLogic {
|
|
72
53
|
if (logicRaw === undefined) return 'AND'
|
|
73
54
|
if (typeof logicRaw !== 'string') throw new TypeError("Invalid filter: 'logic' must be 'AND' or 'OR'")
|
|
@@ -105,124 +86,54 @@ export function parsePublicFilter(raw: string | undefined): ParsedPublicFilter |
|
|
|
105
86
|
return { where, logic }
|
|
106
87
|
}
|
|
107
88
|
|
|
108
|
-
|
|
109
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
110
|
-
throw new TypeError(`Invalid filter: operator '${op}' for field '${field}' requires a non-empty array`)
|
|
111
|
-
}
|
|
112
|
-
return value
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function buildTextOperation(
|
|
116
|
-
op: PublicFilterOperator,
|
|
117
|
-
expr: string,
|
|
118
|
-
field: string,
|
|
119
|
-
value: unknown
|
|
120
|
-
): { clause: string; bindings: Array<string | number> } {
|
|
121
|
-
const str = asString(value)
|
|
122
|
-
if (!str) throw new TypeError(`Invalid filter: operator '${op}' for field '${field}' requires a string value`)
|
|
123
|
-
if (op === 'contains') return { clause: `LOWER(CAST(${expr} AS TEXT)) LIKE LOWER(?)`, bindings: [`%${str}%`] }
|
|
124
|
-
if (op === 'not_contains') return { clause: `LOWER(CAST(${expr} AS TEXT)) NOT LIKE LOWER(?)`, bindings: [`%${str}%`] }
|
|
125
|
-
if (op === 'starts_with') return { clause: `LOWER(CAST(${expr} AS TEXT)) LIKE LOWER(?)`, bindings: [`${str}%`] }
|
|
126
|
-
return { clause: `LOWER(CAST(${expr} AS TEXT)) LIKE LOWER(?)`, bindings: [`%${str}`] }
|
|
127
|
-
}
|
|
89
|
+
const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at'])
|
|
128
90
|
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
91
|
+
function mapBranchToFilterType(type: BranchType): FilterType {
|
|
92
|
+
switch (type) {
|
|
93
|
+
case 'richtext':
|
|
94
|
+
case 'file':
|
|
95
|
+
return 'text'
|
|
96
|
+
case 'json':
|
|
97
|
+
return 'json'
|
|
98
|
+
case 'tags':
|
|
99
|
+
return 'tags'
|
|
100
|
+
case 'number':
|
|
101
|
+
return 'number'
|
|
102
|
+
case 'boolean':
|
|
103
|
+
return 'boolean'
|
|
104
|
+
case 'date':
|
|
105
|
+
return 'date'
|
|
106
|
+
case 'text':
|
|
107
|
+
default:
|
|
108
|
+
return 'text'
|
|
140
109
|
}
|
|
141
110
|
}
|
|
142
111
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
const allParts = cleaned.map(() => `EXISTS (SELECT 1 FROM json_each(${expr}) je WHERE CAST(je.value AS TEXT) = ?)`)
|
|
162
|
-
return { clause: allParts.join(' AND '), bindings: cleaned }
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function buildConditionClause(
|
|
166
|
-
condition: PublicFilterCondition,
|
|
167
|
-
seed: Seed | null
|
|
168
|
-
): { clause: string; bindings: Array<string | number> } | null {
|
|
169
|
-
const fieldMeta = resolveFieldExpression(seed, condition.field)
|
|
170
|
-
if (!fieldMeta) return null
|
|
171
|
-
const { expr, fieldType } = fieldMeta
|
|
172
|
-
const { op, value, field } = condition
|
|
173
|
-
|
|
174
|
-
if (op === 'is_empty') return { clause: `(${expr} IS NULL OR TRIM(CAST(${expr} AS TEXT)) = '')`, bindings: [] }
|
|
175
|
-
if (op === 'is_not_empty') return { clause: `(${expr} IS NOT NULL AND TRIM(CAST(${expr} AS TEXT)) <> '')`, bindings: [] }
|
|
176
|
-
if (op === 'contains' || op === 'not_contains' || op === 'starts_with' || op === 'ends_with') {
|
|
177
|
-
return buildTextOperation(op, expr, field, value)
|
|
178
|
-
}
|
|
179
|
-
if (op === 'in' || op === 'not_in') return buildSetOperation(op, expr, field, value)
|
|
180
|
-
if (op === 'has_tag' || op === 'has_any_tag' || op === 'has_all_tags') {
|
|
181
|
-
if (fieldType !== 'json') throw new TypeError(`Invalid filter: operator '${op}' requires a json field`)
|
|
182
|
-
return buildTagOperation(op, expr, field, value)
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (op === 'eq' || op === 'neq') {
|
|
186
|
-
if (typeof value === 'boolean') {
|
|
187
|
-
return { clause: `${expr} ${op === 'eq' ? '=' : '!='} ?`, bindings: [value ? 1 : 0] }
|
|
188
|
-
}
|
|
189
|
-
if (typeof value === 'number' && !Number.isNaN(value)) {
|
|
190
|
-
return { clause: `${expr} ${op === 'eq' ? '=' : '!='} ?`, bindings: [value] }
|
|
191
|
-
}
|
|
192
|
-
const textValue = asString(value)
|
|
193
|
-
if (!textValue) throw new TypeError(`Invalid filter: operator '${op}' for field '${field}' requires a scalar value`)
|
|
112
|
+
/**
|
|
113
|
+
* Trasforma il filtro pubblico in FilterGroup[] per il Repository.
|
|
114
|
+
* Zero SQL: la logica di generazione query risiede esclusivamente nel Repository/Engine core.
|
|
115
|
+
*/
|
|
116
|
+
export function toEngineFilters(seed: Seed, parsedFilter: ParsedPublicFilter | null): FilterGroup[] {
|
|
117
|
+
if (!parsedFilter || parsedFilter.where.length === 0) return []
|
|
118
|
+
|
|
119
|
+
// Note: Repository currently joins groups with AND.
|
|
120
|
+
// Public API supports logic: OR but the core engine currently defaults to AND for top-level groups.
|
|
121
|
+
// We map each condition to a group for maximum compatibility with the engine's buildFilterCondition.
|
|
122
|
+
return parsedFilter.where.map((cond) => {
|
|
123
|
+
const branch = seed.branches.find(b => b.alias === cond.field)
|
|
124
|
+
const type: FilterType = branch
|
|
125
|
+
? mapBranchToFilterType(branch.type)
|
|
126
|
+
: (SYSTEM_COLUMNS.has(cond.field) ? 'system' : 'text')
|
|
127
|
+
|
|
194
128
|
return {
|
|
195
|
-
|
|
196
|
-
|
|
129
|
+
column: cond.field,
|
|
130
|
+
type,
|
|
131
|
+
conditions: [{
|
|
132
|
+
op: cond.op as FilterOperator,
|
|
133
|
+
value: cond.value as any
|
|
134
|
+
}]
|
|
197
135
|
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const numberValue = typeof value === 'number' && !Number.isNaN(value) ? value : null
|
|
201
|
-
const textValue = asString(value)
|
|
202
|
-
const mathOp = op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : op === 'lte' ? '<=' : null
|
|
203
|
-
if (!mathOp) throw new TypeError(`Invalid filter: operator '${op}' is not supported`)
|
|
204
|
-
if ((fieldType === 'number' || field === 'created_at' || field === 'updated_at') && numberValue !== null) {
|
|
205
|
-
return { clause: `${expr} ${mathOp} ?`, bindings: [numberValue] }
|
|
206
|
-
}
|
|
207
|
-
if (textValue) return { clause: `CAST(${expr} AS TEXT) ${mathOp} ?`, bindings: [textValue] }
|
|
208
|
-
throw new TypeError(`Invalid filter: operator '${op}' for field '${field}' requires a compatible value`)
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function buildPublicFilterWhereClause(
|
|
212
|
-
seed: Seed | null,
|
|
213
|
-
parsedFilter: ParsedPublicFilter | null
|
|
214
|
-
): { clause: string; bindings: Array<string | number> } {
|
|
215
|
-
if (!parsedFilter || parsedFilter.where.length === 0) return { clause: '', bindings: [] }
|
|
216
|
-
const clauses: string[] = []
|
|
217
|
-
const bindings: Array<string | number> = []
|
|
218
|
-
for (const condition of parsedFilter.where) {
|
|
219
|
-
const built = buildConditionClause(condition, seed)
|
|
220
|
-
if (!built) continue
|
|
221
|
-
clauses.push(`(${built.clause})`)
|
|
222
|
-
bindings.push(...built.bindings)
|
|
223
|
-
}
|
|
224
|
-
if (clauses.length === 0) return { clause: '', bindings: [] }
|
|
225
|
-
return { clause: clauses.join(` ${parsedFilter.logic} `), bindings }
|
|
136
|
+
})
|
|
226
137
|
}
|
|
227
138
|
|
|
228
139
|
export function parsePublicPagination(input: PublicQueryInput): { page: number; limit: number } {
|
package/src/search-utils.ts
CHANGED
|
@@ -175,6 +175,10 @@ export function buildFtsQuery(
|
|
|
175
175
|
|
|
176
176
|
// ─── Mapper ──────────────────────────────────────────────────────────────────
|
|
177
177
|
|
|
178
|
+
function stripHtmlPreserveMark(html: string): string {
|
|
179
|
+
return html.replace(/<(?!\/?mark\b)[^>]*>/gi, ' ').replace(/\s+/g, ' ').trim()
|
|
180
|
+
}
|
|
181
|
+
|
|
178
182
|
export function mapFtsRow(row: FtsRow): SearchResultItem {
|
|
179
183
|
return {
|
|
180
184
|
id: row.entry_id,
|
|
@@ -182,7 +186,7 @@ export function mapFtsRow(row: FtsRow): SearchResultItem {
|
|
|
182
186
|
slug: row.slug,
|
|
183
187
|
status: row.status,
|
|
184
188
|
title: row.title ?? "",
|
|
185
|
-
excerpt: row.excerpt ?? "",
|
|
189
|
+
excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
|
|
186
190
|
data: {},
|
|
187
191
|
}
|
|
188
192
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { RepositoryError } from '@beechcms/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base class for D1-backed repositories.
|
|
6
|
+
* Handles the D1 instance and provides common database utilities and error mapping.
|
|
7
|
+
*/
|
|
8
|
+
export abstract class BaseD1Repository {
|
|
9
|
+
constructor(protected readonly db: D1Database) {}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Utility method to extract a table name for a given seed.
|
|
13
|
+
*/
|
|
14
|
+
protected getTableName(slug: string, isDraft = false): string {
|
|
15
|
+
return isDraft ? `content_${slug}_drafts` : `content_${slug}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Helper to map generic D1 errors to Beech RepositoryErrors.
|
|
20
|
+
* This centralizes error handling for all D1 repositories.
|
|
21
|
+
*/
|
|
22
|
+
protected mapError(error: any, context: string): RepositoryError {
|
|
23
|
+
const message = error?.message || 'Unknown database error'
|
|
24
|
+
// In the future, we can add more specific SQLite error code checks here
|
|
25
|
+
// (e.g., checking for UNIQUE constraint via string matching or codes if available)
|
|
26
|
+
return new RepositoryError(`${context}: ${message}`, error)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -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 [results, countResult] = await this.db.batch([
|
|
55
|
+
this.db.prepare(sql).bind(...bindings),
|
|
56
|
+
this.db.prepare(countSql).bind(...countBindings)
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
const items = (results.results || []).map((row) => this.rowToData(seed, row))
|
|
60
|
+
const total = (countResult.results?.[0] as any)?.total || 0
|
|
61
|
+
|
|
62
|
+
return { items, total }
|
|
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 table = this.getTableName(seed.slug)
|
|
71
|
+
const row = await this.db
|
|
72
|
+
.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`)
|
|
73
|
+
.bind(id)
|
|
74
|
+
.first()
|
|
75
|
+
|
|
76
|
+
if (!row) {
|
|
77
|
+
throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return this.rowToData(seed, row)
|
|
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 table = this.getTableName(seed.slug)
|
|
90
|
+
const row = await this.db
|
|
91
|
+
.prepare(`SELECT * FROM ${table} WHERE slug = ? LIMIT 1`)
|
|
92
|
+
.bind(slug)
|
|
93
|
+
.first()
|
|
94
|
+
|
|
95
|
+
if (!row) {
|
|
96
|
+
throw new EntryNotFoundError(`Entry with slug "${slug}" not found in ${seed.slug}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return this.rowToData(seed, row)
|
|
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 table = this.getTableName(seed.slug)
|
|
112
|
+
|
|
113
|
+
// Status counts
|
|
114
|
+
const statusResults = await this.db
|
|
115
|
+
.prepare(`SELECT status, COUNT(*) as count FROM ${table} GROUP BY status`)
|
|
116
|
+
.all()
|
|
117
|
+
|
|
118
|
+
const statuses: Record<string, number> = {}
|
|
119
|
+
for (const row of statusResults.results || []) {
|
|
120
|
+
statuses[row.status as string] = row.count as number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Tags facets (for branches of type 'tags')
|
|
124
|
+
const tagsByColumn: Record<string, string[]> = {}
|
|
125
|
+
const tagBranches = seed.branches.filter(b => b.type === 'tags')
|
|
126
|
+
|
|
127
|
+
for (const branch of tagBranches) {
|
|
128
|
+
// SQLite json_each for tags stored as JSON arrays
|
|
129
|
+
const tagResults = await this.db
|
|
130
|
+
.prepare(`SELECT DISTINCT value FROM ${table}, json_each(${table}.${branch.alias}) WHERE value IS NOT NULL`)
|
|
131
|
+
.all()
|
|
132
|
+
tagsByColumn[branch.alias] = (tagResults.results || []).map(r => r.value as string)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { statuses, 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 table = this.getTableName(seed.slug)
|
|
144
|
+
let sql = `SELECT 1 FROM ${table} WHERE slug = ?`
|
|
145
|
+
const bindings: any[] = [slug]
|
|
146
|
+
|
|
147
|
+
if (excludeId) {
|
|
148
|
+
sql += ` AND id != ?`
|
|
149
|
+
bindings.push(excludeId)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const row = await this.db.prepare(sql).bind(...bindings).first()
|
|
153
|
+
return row !== 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 table = this.getTableName(seed.slug)
|
|
172
|
+
const cols = ['id', 'slug', 'status']
|
|
173
|
+
const placeholders = ['?', '?', '?']
|
|
174
|
+
const bindings: any[] = [id, slug, status]
|
|
175
|
+
|
|
176
|
+
for (const branch of seed.branches) {
|
|
177
|
+
if (Object.hasOwn(data, branch.alias)) {
|
|
178
|
+
cols.push(branch.alias)
|
|
179
|
+
placeholders.push('?')
|
|
180
|
+
bindings.push(serializeForDb(branch, data[branch.alias]))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const sql = `INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders.join(', ')})`
|
|
185
|
+
await this.db.prepare(sql).bind(...bindings).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 table = this.getTableName(seed.slug)
|
|
200
|
+
const setParts: string[] = []
|
|
201
|
+
const bindings: any[] = []
|
|
202
|
+
|
|
203
|
+
if (status) {
|
|
204
|
+
setParts.push('status = ?')
|
|
205
|
+
bindings.push(status)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const branch of seed.branches) {
|
|
209
|
+
if (Object.hasOwn(data, branch.alias)) {
|
|
210
|
+
setParts.push(`${branch.alias} = ?`)
|
|
211
|
+
bindings.push(serializeForDb(branch, data[branch.alias]))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (setParts.length === 0) return
|
|
216
|
+
|
|
217
|
+
setParts.push('updated_at = (unixepoch())')
|
|
218
|
+
|
|
219
|
+
const sql = `UPDATE ${table} SET ${setParts.join(', ')} WHERE id = ?`
|
|
220
|
+
bindings.push(id)
|
|
221
|
+
|
|
222
|
+
const result = await this.db.prepare(sql).bind(...bindings).run()
|
|
223
|
+
if (result.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 table = this.getTableName(seed.slug)
|
|
235
|
+
|
|
236
|
+
// We need to return the row for R2 cleanup
|
|
237
|
+
const row = await this.db
|
|
238
|
+
.prepare(`SELECT * FROM ${table} WHERE id = ?`)
|
|
239
|
+
.bind(id)
|
|
240
|
+
.first()
|
|
241
|
+
|
|
242
|
+
if (!row) {
|
|
243
|
+
throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await this.db.prepare(`DELETE FROM ${table} WHERE id = ?`).bind(id).run()
|
|
247
|
+
|
|
248
|
+
return { row: this.rowToData(seed, row) }
|
|
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 table = this.getTableName(seed.slug, true)
|
|
262
|
+
const cols = ['entry_id']
|
|
263
|
+
const placeholders = ['?']
|
|
264
|
+
const bindings: any[] = [entryId]
|
|
265
|
+
const updateParts: string[] = []
|
|
266
|
+
|
|
267
|
+
for (const branch of seed.branches) {
|
|
268
|
+
if (Object.hasOwn(data, branch.alias)) {
|
|
269
|
+
const val = serializeForDb(branch, data[branch.alias])
|
|
270
|
+
cols.push(branch.alias)
|
|
271
|
+
placeholders.push('?')
|
|
272
|
+
bindings.push(val)
|
|
273
|
+
updateParts.push(`${branch.alias} = EXCLUDED.${branch.alias}`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
updateParts.push('updated_at = (unixepoch())')
|
|
278
|
+
|
|
279
|
+
const sql = `
|
|
280
|
+
INSERT INTO ${table} (${cols.join(', ')})
|
|
281
|
+
VALUES (${placeholders.join(', ')})
|
|
282
|
+
ON CONFLICT(entry_id) DO UPDATE SET ${updateParts.join(', ')}
|
|
283
|
+
`
|
|
284
|
+
await this.db.prepare(sql).bind(...bindings).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 table = this.getTableName(seed.slug, true)
|
|
295
|
+
const row = await this.db
|
|
296
|
+
.prepare(`SELECT * FROM ${table} WHERE entry_id = ?`)
|
|
297
|
+
.bind(entryId)
|
|
298
|
+
.first()
|
|
299
|
+
|
|
300
|
+
if (!row) return null
|
|
301
|
+
|
|
302
|
+
// Filter out nulls from draft row (only include provided fields)
|
|
303
|
+
const data: Record<string, any> = {}
|
|
304
|
+
for (const branch of seed.branches) {
|
|
305
|
+
if (row[branch.alias] !== null) {
|
|
306
|
+
data[branch.alias] = deserializeFromDb(branch, row[branch.alias])
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return data
|
|
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 table = this.getTableName(seed.slug, true)
|
|
320
|
+
const row = await this.db
|
|
321
|
+
.prepare(`SELECT 1 FROM ${table} WHERE entry_id = ? LIMIT 1`)
|
|
322
|
+
.bind(entryId)
|
|
323
|
+
.first()
|
|
324
|
+
return row !== 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 draftTable = this.getTableName(seed.slug, true)
|
|
335
|
+
const liveTable = this.getTableName(seed.slug)
|
|
336
|
+
|
|
337
|
+
const draftRow = await this.db
|
|
338
|
+
.prepare(`SELECT * FROM ${draftTable} 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 for live table
|
|
347
|
+
const setParts: string[] = []
|
|
348
|
+
const bindings: any[] = []
|
|
349
|
+
|
|
350
|
+
for (const branch of seed.branches) {
|
|
351
|
+
if (draftRow[branch.alias] !== null) {
|
|
352
|
+
setParts.push(`${branch.alias} = ?`)
|
|
353
|
+
bindings.push(draftRow[branch.alias])
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
setParts.push('updated_at = (unixepoch())')
|
|
358
|
+
|
|
359
|
+
const updateSql = `UPDATE ${liveTable} SET ${setParts.join(', ')} WHERE id = ?`
|
|
360
|
+
bindings.push(entryId)
|
|
361
|
+
|
|
362
|
+
// Atomic batch
|
|
363
|
+
await this.db.batch([
|
|
364
|
+
this.db.prepare(updateSql).bind(...bindings),
|
|
365
|
+
this.db.prepare(`DELETE FROM ${draftTable} 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 table = this.getTableName(seed.slug, true)
|
|
377
|
+
await this.db.prepare(`DELETE FROM ${table} WHERE entry_id = ?`).bind(entryId).run()
|
|
378
|
+
} catch (error) {
|
|
379
|
+
throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|