@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.
Files changed (98) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -0
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -0
  3. package/assets/dashboard/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/assets/dashboard/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/assets/dashboard/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/assets/dashboard/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/assets/dashboard/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/assets/dashboard/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/assets/dashboard/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/assets/dashboard/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/assets/dashboard/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/assets/dashboard/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/assets/dashboard/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/assets/dashboard/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/assets/dashboard/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/assets/dashboard/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/assets/dashboard/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/assets/dashboard/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/assets/dashboard/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/assets/dashboard/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/assets/dashboard/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/assets/dashboard/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/assets/dashboard/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/assets/dashboard/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/assets/dashboard/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/assets/dashboard/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/assets/dashboard/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/assets/dashboard/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/assets/dashboard/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/assets/dashboard/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/assets/dashboard/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/assets/dashboard/assets/index-9Ch2xWJr.js +554 -0
  63. package/assets/dashboard/assets/index-ye3325L9.css +1 -0
  64. package/assets/dashboard/beechLogoDark.svg +48 -0
  65. package/assets/dashboard/index.html +18 -0
  66. package/assets/dashboard/sol.svg +3 -0
  67. package/assets/dashboard/undraw_clouds_bmtk.svg +1 -0
  68. package/assets/dashboard/undraw_enter_nwx3.svg +36 -0
  69. package/assets/dashboard/vite.svg +1 -0
  70. package/package.json +4 -3
  71. package/src/factory.ts +290 -21
  72. package/src/features/content/constants.ts +10 -0
  73. package/src/features/content/handlers/create.ts +163 -0
  74. package/src/features/content/handlers/delete.ts +85 -0
  75. package/src/features/content/handlers/facets.ts +45 -0
  76. package/src/features/content/handlers/get.ts +116 -0
  77. package/src/features/content/handlers/list.ts +88 -0
  78. package/src/features/content/handlers/update.ts +216 -0
  79. package/src/features/content/index.ts +20 -0
  80. package/src/features/draft/draft.handler.ts +203 -129
  81. package/src/features/password-reset/request.ts +1 -1
  82. package/src/features/schema/schema.handler.ts +16 -0
  83. package/src/features/settings/settings.handler.ts +18 -0
  84. package/src/index.ts +17 -337
  85. package/src/middleware/repository.middleware.ts +18 -0
  86. package/src/public/public-add.ts +72 -89
  87. package/src/public/public-edit.ts +51 -76
  88. package/src/public/public-read.ts +113 -114
  89. package/src/public/query-builder.ts +47 -136
  90. package/src/search-utils.ts +5 -1
  91. package/src/shared/base.repository.d1.ts +28 -0
  92. package/src/shared/content.repository.d1.ts +382 -0
  93. package/src/shared/idempotency.repository.d1.ts +45 -0
  94. package/src/types.ts +7 -1
  95. package/src/upload.ts +3 -7
  96. package/src/content.ts +0 -502
  97. package/src/features/draft/draft.test.ts +0 -315
  98. 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
- 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 } {
@@ -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
+ }