@beechcms/api 0.4.0-preview.10 → 0.4.0-preview.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/assets/dashboard/assets/index-CTSuGxlX.js +554 -0
  2. package/assets/dashboard/assets/index-ye3325L9.css +1 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/package.json +2 -2
  5. package/src/factory.ts +96 -85
  6. package/src/features/content/constants.ts +10 -0
  7. package/src/features/content/handlers/create.ts +163 -0
  8. package/src/features/content/handlers/delete.ts +85 -0
  9. package/src/features/content/handlers/facets.ts +45 -0
  10. package/src/features/content/handlers/get.ts +116 -0
  11. package/src/features/content/handlers/list.ts +88 -0
  12. package/src/features/content/handlers/update.ts +216 -0
  13. package/src/features/content/index.ts +20 -0
  14. package/src/features/draft/draft.handler.ts +203 -129
  15. package/src/features/settings/settings.handler.ts +18 -0
  16. package/src/index.ts +16 -3
  17. package/src/middleware/repository.middleware.ts +18 -0
  18. package/src/public/public-add.ts +72 -89
  19. package/src/public/public-edit.ts +51 -76
  20. package/src/public/public-read.ts +113 -114
  21. package/src/public/query-builder.ts +47 -136
  22. package/src/shared/base.repository.d1.ts +28 -0
  23. package/src/shared/content.repository.d1.ts +382 -0
  24. package/src/shared/idempotency.repository.d1.ts +45 -0
  25. package/src/types.ts +6 -1
  26. package/src/upload.ts +3 -7
  27. package/assets/dashboard/assets/index-CFTJe1vb.js +0 -554
  28. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  29. package/src/content.ts +0 -502
  30. package/src/features/draft/draft.test.ts +0 -315
  31. 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(aliasData: Record<string, unknown>, seed: Seed): Record<string, unknown> {
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 = aliasData[branch.alias]
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(row: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
53
- const aliasData = applyPublicPolicies(rowToApiData(seed, row), seed)
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
- if (requestedFields.length === 0) return { ...base, ...aliasData }
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
- return { ...base, ...filteredData }
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(c: Context<{ Bindings: Bindings; Variables: Variables }>, err: unknown): string {
86
- if (c.env.ENV !== 'production' && err instanceof Error) return err.message
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 buildOrderSql(seed: Seed, query: Record<string, string | undefined>, hasLatest: boolean): string {
91
- if (hasLatest) return 'ORDER BY created_at DESC'
92
- const orderBy = cleanStr(query.orderBy) ?? ''
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(c, {
111
- type: 'seed-not-found', title: 'Seed Not Found', status: 404,
112
- detail: buildSeedNotFoundMessage(seedSlug, c.get('seedRegistry')),
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(c, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
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 = c.executionCtx as typeof executionCtx
111
+ executionCtx = context.executionCtx as typeof executionCtx
125
112
  } catch {}
126
113
 
127
- const cacheKey = c.req.raw
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 = c.req.query()
120
+ const query = context.req.query()
134
121
  const id = cleanStr(query.id)
135
- const table = `content_${seedSlug}`
136
- const publishedOnly = c.env.PUBLIC_PUBLISHED_ONLY !== 'false'
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
- const { DB } = c.env
140
-
141
- if (id) {
142
- const row = await DB.prepare(
143
- `SELECT * FROM ${table} WHERE id = ? ${publishedOnly ? "AND status = 'published'" : ''} LIMIT 1`
144
- ).bind(id).first<Record<string, unknown>>()
145
-
146
- if (!row) {
147
- return publicProblem(c, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
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 whereParts: string[] = []
164
- const whereBindings: Array<string | number> = []
165
- if (publishedOnly) whereParts.push("status = 'published'")
166
-
167
- if (search) {
168
- // Search against slug and text/richtext columns
169
- const searchableBranches = seed.branches.filter((b) => ['text', 'richtext'].includes(b.type))
170
- const term = `%${search}%`
171
- const searchExprs = ['slug LIKE ?', ...searchableBranches.map((b) => `${b.alias} LIKE ?`)]
172
- whereParts.push(`(${searchExprs.join(' OR ')})`)
173
- whereBindings.push(term, ...searchableBranches.map(() => term))
174
- }
175
-
176
- const filterClause = buildPublicFilterWhereClause(seed, parsedFilter)
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 rows = rowsResult.results ?? []
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
- c.json({ data, meta: { total, returned: data.length, seed: seedSlug } }, 200)
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
- c.json({
193
+ context.json({
207
194
  data,
208
- meta: buildPublicListMeta({ total, page: pagination.page, limit: effectiveLimit, returned: data.length, seed: seedSlug }),
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 (err) {
212
- if (err instanceof Error && err.message.startsWith('Invalid filter:')) {
213
- return publicProblem(c, { type: 'invalid-filter', title: 'Bad Request', status: 400, detail: err.message })
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
- return publicProblem(c, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: buildInternalErrorMessage(c, err) })
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
- function ensureValueArray(value: unknown, op: PublicFilterOperator, field: string): unknown[] {
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 buildSetOperation(
130
- op: PublicFilterOperator,
131
- expr: string,
132
- field: string,
133
- value: unknown
134
- ): { clause: string; bindings: Array<string | number> } {
135
- const values = ensureValueArray(value, op, field)
136
- const placeholders = values.map(() => '?').join(',')
137
- return {
138
- clause: `${expr} ${op === 'in' ? 'IN' : 'NOT IN'} (${placeholders})`,
139
- bindings: values as Array<string | number>,
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
- function buildTagOperation(
144
- op: PublicFilterOperator,
145
- expr: string,
146
- field: string,
147
- value: unknown
148
- ): { clause: string; bindings: Array<string | number> } {
149
- const tags = op === 'has_tag' ? [value] : ensureValueArray(value, op, field)
150
- const cleaned = tags.map((item) => asString(item)).filter((item): item is string => item !== null)
151
- if (cleaned.length === 0) {
152
- throw new TypeError(`Invalid filter: operator '${op}' for field '${field}' requires tag string values`)
153
- }
154
- if (op === 'has_tag' || op === 'has_any_tag') {
155
- const placeholders = cleaned.map(() => '?').join(',')
156
- return {
157
- clause: `EXISTS (SELECT 1 FROM json_each(${expr}) je WHERE CAST(je.value AS TEXT) IN (${placeholders}))`,
158
- bindings: cleaned,
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
- clause: `LOWER(TRIM(CAST(${expr} AS TEXT))) ${op === 'eq' ? '=' : '!='} LOWER(TRIM(?))`,
196
- bindings: [textValue],
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
+ }