@beechcms/api 0.4.0-preview.10 → 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/assets/{index-CFTJe1vb.js → index-9Ch2xWJr.js} +122 -122
- package/assets/dashboard/assets/index-ye3325L9.css +1 -0
- package/assets/dashboard/index.html +2 -2
- package/package.json +2 -2
- package/src/factory.ts +96 -85
- 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/settings/settings.handler.ts +18 -0
- package/src/index.ts +16 -3
- 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/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 +6 -1
- package/src/upload.ts +3 -7
- 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
|
@@ -1,31 +1,17 @@
|
|
|
1
|
-
import { resolvePolicies } from '@beechcms/core'
|
|
1
|
+
import { resolvePolicies, EntryNotFoundError } from '@beechcms/core'
|
|
2
2
|
import type { Seed } from '@beechcms/core'
|
|
3
3
|
import type { Context } from 'hono'
|
|
4
4
|
import { cleanStr } from '../shared/query-utils'
|
|
5
|
-
import { rowToApiData } from '../shared/content-utils'
|
|
6
5
|
import { checkPublicOperation } from './access-policy'
|
|
7
6
|
import { publicProblem } from './problem-details'
|
|
8
7
|
import { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
|
|
9
8
|
import {
|
|
10
|
-
buildPublicFilterWhereClause,
|
|
11
9
|
parseLatestCount,
|
|
12
10
|
parsePublicFilter,
|
|
13
11
|
parsePublicPagination,
|
|
12
|
+
toEngineFilters,
|
|
14
13
|
} from './query-builder'
|
|
15
|
-
|
|
16
|
-
type Bindings = {
|
|
17
|
-
DB: D1Database
|
|
18
|
-
PUBLIC_READ_API_KEY?: string
|
|
19
|
-
PUBLIC_WRITE_API_KEY?: string
|
|
20
|
-
PUBLIC_PUBLISHED_ONLY?: string
|
|
21
|
-
ENV?: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
type Variables = {
|
|
25
|
-
jwtPayload: { sub: string; email?: string }
|
|
26
|
-
getSeed: (slug: string) => Seed | null
|
|
27
|
-
seedRegistry: Record<string, Seed>
|
|
28
|
-
}
|
|
14
|
+
import { AppEnv } from '../types'
|
|
29
15
|
|
|
30
16
|
function buildSeedNotFoundMessage(seed: string, seedRegistry: Record<string, Seed>): string {
|
|
31
17
|
const available = Object.keys(seedRegistry).join(', ')
|
|
@@ -33,10 +19,17 @@ function buildSeedNotFoundMessage(seed: string, seedRegistry: Record<string, See
|
|
|
33
19
|
}
|
|
34
20
|
|
|
35
21
|
/** Applica policy public/visibility per la Public API. */
|
|
36
|
-
function applyPublicPolicies(
|
|
22
|
+
function applyPublicPolicies(data: Record<string, unknown>, seed: Seed): Record<string, unknown> {
|
|
37
23
|
const result: Record<string, unknown> = {}
|
|
24
|
+
|
|
25
|
+
// System fields are mapped to top-level for public API
|
|
26
|
+
const system = ['id', 'slug', 'status', 'created_at', 'updated_at']
|
|
27
|
+
for (const key of system) {
|
|
28
|
+
if (key in data) result[key] = data[key]
|
|
29
|
+
}
|
|
30
|
+
|
|
38
31
|
for (const branch of seed.branches) {
|
|
39
|
-
const value =
|
|
32
|
+
const value = data[branch.alias]
|
|
40
33
|
const { public: isPublic, visibility } = resolvePolicies(branch)
|
|
41
34
|
if (!isPublic) continue
|
|
42
35
|
if (visibility === 'hidden') continue
|
|
@@ -49,22 +42,24 @@ function applyPublicPolicies(aliasData: Record<string, unknown>, seed: Seed): Re
|
|
|
49
42
|
return result
|
|
50
43
|
}
|
|
51
44
|
|
|
52
|
-
function toFlatPublicEntry(
|
|
53
|
-
const aliasData = applyPublicPolicies(
|
|
54
|
-
const base: Record<string, unknown> = {
|
|
55
|
-
id: row.id,
|
|
56
|
-
slug: row.slug,
|
|
57
|
-
status: row.status,
|
|
58
|
-
created_at: row.created_at,
|
|
59
|
-
updated_at: row.updated_at,
|
|
60
|
-
}
|
|
45
|
+
function toFlatPublicEntry(data: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
|
|
46
|
+
const aliasData = applyPublicPolicies(data, seed)
|
|
61
47
|
const requestedFields = (fieldsParam ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
|
62
|
-
|
|
48
|
+
|
|
49
|
+
if (requestedFields.length === 0) return aliasData
|
|
50
|
+
|
|
63
51
|
const filteredData: Record<string, unknown> = {}
|
|
64
52
|
for (const field of requestedFields) {
|
|
65
53
|
if (field in aliasData) filteredData[field] = aliasData[field]
|
|
66
54
|
}
|
|
67
|
-
|
|
55
|
+
|
|
56
|
+
// Always include basic identity fields if they exist in filtered data or if requested
|
|
57
|
+
const identity = ['id', 'slug']
|
|
58
|
+
for (const key of identity) {
|
|
59
|
+
if (key in aliasData && !filteredData[key]) filteredData[key] = aliasData[key]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return filteredData
|
|
68
63
|
}
|
|
69
64
|
|
|
70
65
|
function withCache(
|
|
@@ -82,74 +77,85 @@ function withCache(
|
|
|
82
77
|
return response
|
|
83
78
|
}
|
|
84
79
|
|
|
85
|
-
function buildInternalErrorMessage(
|
|
86
|
-
if (
|
|
80
|
+
function buildInternalErrorMessage(context: Context<AppEnv>, error: unknown): string {
|
|
81
|
+
if (context.env.ENV !== 'production' && error instanceof Error) return error.message
|
|
87
82
|
return 'An unexpected error occurred.'
|
|
88
83
|
}
|
|
89
84
|
|
|
90
|
-
function
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const orderDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase()
|
|
94
|
-
if (orderBy === 'created_at' || orderBy === 'updated_at') {
|
|
95
|
-
return `ORDER BY ${orderBy} ${orderDir === 'asc' ? 'ASC' : 'DESC'}`
|
|
96
|
-
}
|
|
97
|
-
// Branch column — always a real column in v0.4.0
|
|
98
|
-
const branch = seed.branches.find((b) => b.alias === orderBy)
|
|
99
|
-
if (branch) {
|
|
100
|
-
const dir = orderDir === 'asc' ? 'ASC' : 'DESC'
|
|
101
|
-
return `ORDER BY ${branch.alias} ${dir} NULLS LAST`
|
|
102
|
-
}
|
|
103
|
-
return 'ORDER BY created_at DESC'
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export async function publicReadHandler(c: Context<{ Bindings: Bindings; Variables: Variables }>) {
|
|
107
|
-
const seedSlug = c.req.param('seed') ?? ''
|
|
108
|
-
const seed = c.get('getSeed')(seedSlug)
|
|
85
|
+
export async function publicReadHandler(context: Context<AppEnv>) {
|
|
86
|
+
const seedSlug = context.req.param('seed') ?? ''
|
|
87
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
109
88
|
if (!seed) {
|
|
110
|
-
return publicProblem(
|
|
111
|
-
type: 'seed-not-found',
|
|
112
|
-
|
|
89
|
+
return publicProblem(context, {
|
|
90
|
+
type: 'seed-not-found',
|
|
91
|
+
title: 'Seed Not Found',
|
|
92
|
+
status: 404,
|
|
93
|
+
detail: buildSeedNotFoundMessage(seedSlug, context.get('seedRegistry')),
|
|
113
94
|
})
|
|
114
95
|
}
|
|
96
|
+
|
|
115
97
|
const access = checkPublicOperation(seed, 'read')
|
|
116
98
|
if (!access.ok) {
|
|
117
|
-
return publicProblem(
|
|
99
|
+
return publicProblem(context, {
|
|
100
|
+
type: 'operation-not-allowed',
|
|
101
|
+
title: access.error.error,
|
|
102
|
+
status: 403,
|
|
103
|
+
detail: access.error.message
|
|
104
|
+
})
|
|
118
105
|
}
|
|
119
106
|
|
|
120
107
|
let cache: Cache | undefined
|
|
121
108
|
let executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined
|
|
122
109
|
try {
|
|
123
110
|
cache = caches.default
|
|
124
|
-
executionCtx =
|
|
111
|
+
executionCtx = context.executionCtx as typeof executionCtx
|
|
125
112
|
} catch {}
|
|
126
113
|
|
|
127
|
-
const cacheKey =
|
|
114
|
+
const cacheKey = context.req.raw
|
|
128
115
|
if (cache) {
|
|
129
116
|
const hit = await cache.match(cacheKey)
|
|
130
117
|
if (hit) return hit
|
|
131
118
|
}
|
|
132
119
|
|
|
133
|
-
const query =
|
|
120
|
+
const query = context.req.query()
|
|
134
121
|
const id = cleanStr(query.id)
|
|
135
|
-
const
|
|
136
|
-
const publishedOnly =
|
|
122
|
+
const slug = cleanStr(query.slug)
|
|
123
|
+
const publishedOnly = context.env.PUBLIC_PUBLISHED_ONLY !== 'false'
|
|
124
|
+
const repository = context.get('repository')
|
|
137
125
|
|
|
138
126
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
127
|
+
if (id || slug) {
|
|
128
|
+
try {
|
|
129
|
+
const entry = id
|
|
130
|
+
? await repository.findById(seed, id)
|
|
131
|
+
: await repository.findBySlug(seed, slug!)
|
|
132
|
+
|
|
133
|
+
if (publishedOnly && entry.status !== 'published') {
|
|
134
|
+
return publicProblem(context, {
|
|
135
|
+
type: 'entry-not-found',
|
|
136
|
+
title: 'Not Found',
|
|
137
|
+
status: 404,
|
|
138
|
+
detail: `Entry '${id || slug}' not found or not published.`
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return withCache(cache, executionCtx, cacheKey,
|
|
143
|
+
context.json({
|
|
144
|
+
data: toFlatPublicEntry(entry, seed, query.fields),
|
|
145
|
+
meta: buildPublicSingleMeta(seedSlug)
|
|
146
|
+
}, 200)
|
|
147
|
+
)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error instanceof EntryNotFoundError) {
|
|
150
|
+
return publicProblem(context, {
|
|
151
|
+
type: 'entry-not-found',
|
|
152
|
+
title: 'Not Found',
|
|
153
|
+
status: 404,
|
|
154
|
+
detail: `Entry '${id || slug}' not found for content type '${seedSlug}'.`
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
throw error
|
|
148
158
|
}
|
|
149
|
-
|
|
150
|
-
return withCache(cache, executionCtx, cacheKey,
|
|
151
|
-
c.json({ data: toFlatPublicEntry(row, seed, query.fields), meta: buildPublicSingleMeta(seedSlug) }, 200)
|
|
152
|
-
)
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
const parsedFilter = parsePublicFilter(query.filter)
|
|
@@ -160,58 +166,51 @@ export async function publicReadHandler(c: Context<{ Bindings: Bindings; Variabl
|
|
|
160
166
|
const offset = (pagination.page - 1) * pagination.limit
|
|
161
167
|
const search = cleanStr(query.search) ?? ''
|
|
162
168
|
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (filterClause.clause) {
|
|
178
|
-
whereParts.push(`(${filterClause.clause})`)
|
|
179
|
-
whereBindings.push(...filterClause.bindings)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''
|
|
183
|
-
const countRow = await DB.prepare(`SELECT COUNT(*) as total FROM ${table} ${whereSql}`)
|
|
184
|
-
.bind(...whereBindings)
|
|
185
|
-
.first<{ total: number }>()
|
|
186
|
-
const total = countRow?.total ?? 0
|
|
187
|
-
|
|
188
|
-
const orderSql = buildOrderSql(seed, query, latestMode)
|
|
189
|
-
const effectiveLimit = latestMode ? (latestCount ?? 10) : pagination.limit
|
|
190
|
-
const effectiveOffset = latestMode ? 0 : offset
|
|
191
|
-
|
|
192
|
-
const rowsResult = await DB.prepare(`SELECT * FROM ${table} ${whereSql} ${orderSql} LIMIT ? OFFSET ?`)
|
|
193
|
-
.bind(...whereBindings, effectiveLimit, effectiveOffset)
|
|
194
|
-
.all<Record<string, unknown>>()
|
|
169
|
+
const engineFilters = toEngineFilters(seed, parsedFilter)
|
|
170
|
+
const sortBy = cleanStr(query.orderBy) ?? 'created_at'
|
|
171
|
+
const sortDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'
|
|
172
|
+
|
|
173
|
+
const { items, total } = await repository.findMany(seed, {
|
|
174
|
+
filters: engineFilters,
|
|
175
|
+
search: search || undefined,
|
|
176
|
+
status: publishedOnly ? 'published' : null,
|
|
177
|
+
pagination: {
|
|
178
|
+
limit: latestMode ? (latestCount ?? 10) : pagination.limit,
|
|
179
|
+
offset: latestMode ? 0 : offset
|
|
180
|
+
},
|
|
181
|
+
orderBy: latestMode ? { column: 'created_at', dir: 'DESC' } : { column: sortBy, dir: sortDir }
|
|
182
|
+
})
|
|
195
183
|
|
|
196
|
-
const
|
|
197
|
-
const data = rows.map((row) => toFlatPublicEntry(row, seed, query.fields))
|
|
184
|
+
const data = items.map((item) => toFlatPublicEntry(item, seed, query.fields))
|
|
198
185
|
|
|
199
186
|
if (latestMode) {
|
|
200
187
|
return withCache(cache, executionCtx, cacheKey,
|
|
201
|
-
|
|
188
|
+
context.json({ data, meta: { total, returned: data.length, seed: seedSlug } }, 200)
|
|
202
189
|
)
|
|
203
190
|
}
|
|
204
191
|
|
|
205
192
|
return withCache(cache, executionCtx, cacheKey,
|
|
206
|
-
|
|
193
|
+
context.json({
|
|
207
194
|
data,
|
|
208
|
-
meta: buildPublicListMeta({
|
|
195
|
+
meta: buildPublicListMeta({
|
|
196
|
+
total,
|
|
197
|
+
page: pagination.page,
|
|
198
|
+
limit: latestMode ? (latestCount ?? 10) : pagination.limit,
|
|
199
|
+
returned: data.length,
|
|
200
|
+
seed: seedSlug
|
|
201
|
+
}),
|
|
209
202
|
}, 200)
|
|
210
203
|
)
|
|
211
|
-
} catch (
|
|
212
|
-
if (
|
|
213
|
-
return publicProblem(
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error instanceof Error && error.message.startsWith('Invalid filter:')) {
|
|
206
|
+
return publicProblem(context, { type: 'invalid-filter', title: 'Bad Request', status: 400, detail: error.message })
|
|
214
207
|
}
|
|
215
|
-
|
|
208
|
+
console.error('Public read error:', error)
|
|
209
|
+
return publicProblem(context, {
|
|
210
|
+
type: 'internal-server-error',
|
|
211
|
+
title: 'Internal Server Error',
|
|
212
|
+
status: 500,
|
|
213
|
+
detail: buildInternalErrorMessage(context, error)
|
|
214
|
+
})
|
|
216
215
|
}
|
|
217
216
|
}
|
|
@@ -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 } {
|
|
@@ -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
|
+
}
|