@beechcms/api 0.5.0 → 0.6.0-preview.2

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 (225) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/Searching.svg +1 -0
  4. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  5. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  6. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  7. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  8. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  9. package/assets/dashboard/beechLogoDark.svg +48 -48
  10. package/assets/dashboard/index.html +18 -18
  11. package/assets/dashboard/noResult.svg +43 -0
  12. package/assets/dashboard/sol.svg +3 -3
  13. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  14. package/assets/dashboard/working.svg +1 -0
  15. package/migrations/0000_v040_base.sql +265 -238
  16. package/migrations/0029_automations.sql +14 -14
  17. package/migrations/0030_test_seeds.sql +125 -125
  18. package/package.json +13 -9
  19. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
  20. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
  21. package/src/auth/bcrypt-hash-provider.ts +24 -20
  22. package/src/auth/constants.ts +14 -10
  23. package/src/auth/generate-refresh-token.test.ts +23 -19
  24. package/src/auth/hash-provider.test.ts +55 -46
  25. package/src/auth/jose-token-service.ts +62 -55
  26. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  27. package/src/auth/login.test.ts +100 -92
  28. package/src/auth/login.ts +78 -74
  29. package/src/auth/refresh.ts +11 -5
  30. package/src/auth/token-service.test.ts +90 -82
  31. package/src/factory.ts +370 -347
  32. package/src/features/automations/__tests__/action-executors.test.ts +255 -268
  33. package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
  34. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
  35. package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
  36. package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
  37. package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
  38. package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
  39. package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
  40. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
  41. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
  42. package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
  43. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  44. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
  45. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
  46. package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
  47. package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
  48. package/src/features/automations/action-executors/index.ts +37 -33
  49. package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
  50. package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
  51. package/src/features/automations/action-executors/webhook.executor.ts +67 -25
  52. package/src/features/automations/automation-runner.ts +85 -81
  53. package/src/features/automations/automation-runner.utils.ts +47 -43
  54. package/src/features/automations/automations.handler.ts +197 -193
  55. package/src/features/automations/automations.schema.ts +177 -160
  56. package/src/features/automations/context-resolver.ts +152 -148
  57. package/src/features/automations/cron-runner.ts +140 -136
  58. package/src/features/automations/cron-runner.utils.ts +44 -40
  59. package/src/features/automations/filter-translation.ts +46 -42
  60. package/src/features/automations/index.ts +16 -12
  61. package/src/features/automations/template-grammar.ts +245 -241
  62. package/src/features/automations/var-access-resolver.ts +140 -136
  63. package/src/features/automations/when-evaluator.ts +87 -83
  64. package/src/features/automations/when-pushdown.ts +57 -53
  65. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  66. package/src/features/backrefs/backrefs.handler.ts +168 -0
  67. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  68. package/src/features/backrefs/index.ts +5 -0
  69. package/src/features/content/constants.ts +16 -10
  70. package/src/features/content/handlers/bulk.handler.ts +185 -0
  71. package/src/features/content/handlers/create.ts +129 -165
  72. package/src/features/content/handlers/delete.ts +62 -87
  73. package/src/features/content/handlers/facets.ts +49 -45
  74. package/src/features/content/handlers/get.ts +120 -116
  75. package/src/features/content/handlers/helpers.test.ts +180 -0
  76. package/src/features/content/handlers/helpers.ts +93 -0
  77. package/src/features/content/handlers/list.ts +174 -88
  78. package/src/features/content/handlers/update.ts +174 -219
  79. package/src/features/content/index.ts +26 -20
  80. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  81. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  82. package/src/features/dashboard-layout/index.ts +5 -0
  83. package/src/features/draft/draft.handler.ts +195 -170
  84. package/src/features/draft/draft.middleware.ts +66 -62
  85. package/src/features/draft/index.ts +5 -1
  86. package/src/features/email/email.provider.ts +42 -38
  87. package/src/features/email/email.service.ts +98 -93
  88. package/src/features/email/email.types.ts +119 -108
  89. package/src/features/email/index.ts +33 -29
  90. package/src/features/email/providers/resend.ts +67 -63
  91. package/src/features/email/providers/smtp.ts +55 -0
  92. package/src/features/email/templates/automation-mail.ts +19 -15
  93. package/src/features/email/templates/password-changed.ts +63 -59
  94. package/src/features/email/templates/password-reset.ts +68 -64
  95. package/src/features/email/templates/shell.ts +96 -92
  96. package/src/features/notifications/index.ts +5 -1
  97. package/src/features/notifications/notifications.handler.ts +105 -101
  98. package/src/features/password-reset/index.ts +19 -15
  99. package/src/features/password-reset/request.ts +92 -82
  100. package/src/features/password-reset/reset.ts +102 -92
  101. package/src/features/rotate-field/index.ts +5 -1
  102. package/src/features/rotate-field/rotate-field.handler.ts +132 -128
  103. package/src/features/rotate-field/rotate-field.schema.ts +17 -13
  104. package/src/features/schema/schema.handler.ts +131 -16
  105. package/src/features/seeds/index.ts +5 -0
  106. package/src/features/seeds/seeds.handler.test.ts +1199 -0
  107. package/src/features/seeds/seeds.handler.ts +558 -0
  108. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  109. package/src/features/settings/settings.handler.ts +392 -302
  110. package/src/features/setup/index.ts +288 -91
  111. package/src/features/stats/index.ts +5 -1
  112. package/src/features/stats/stats.handler.ts +420 -430
  113. package/src/features/webhooks/index.ts +63 -0
  114. package/src/index.ts +65 -56
  115. package/src/media-utils.ts +82 -78
  116. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  117. package/src/middleware/auth-providers.middleware.ts +45 -32
  118. package/src/middleware/observability.middleware.test.ts +93 -0
  119. package/src/middleware/observability.middleware.ts +70 -52
  120. package/src/middleware/rate-limit.middleware.ts +45 -41
  121. package/src/middleware/repository.middleware.ts +117 -91
  122. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  123. package/src/middleware/seed-registry.middleware.ts +21 -0
  124. package/src/middleware/storage.middleware.ts +25 -21
  125. package/src/middleware.ts +45 -47
  126. package/src/public/access-policy.ts +27 -23
  127. package/src/public/api-key-middleware.ts +57 -53
  128. package/src/public/cache-utils.ts +38 -34
  129. package/src/public/entry-projection.ts +46 -42
  130. package/src/public/idempotency.ts +23 -19
  131. package/src/public/index.ts +16 -12
  132. package/src/public/problem-details.ts +124 -53
  133. package/src/public/public-add.ts +114 -110
  134. package/src/public/public-edit.ts +163 -159
  135. package/src/public/public-errors.ts +19 -15
  136. package/src/public/public-read.ts +63 -59
  137. package/src/public/public-routes.ts +88 -84
  138. package/src/public/query-builder.test.ts +224 -220
  139. package/src/public/query-builder.ts +156 -152
  140. package/src/public/rate-limit-middleware.ts +34 -30
  141. package/src/public/read-list.ts +54 -50
  142. package/src/public/read-single.ts +48 -44
  143. package/src/public/response-builder.ts +30 -26
  144. package/src/public/sanitize.ts +69 -65
  145. package/src/public/slug-utils.ts +18 -14
  146. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
  147. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
  148. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
  149. package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
  150. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
  151. package/src/rate-limit/no-op-rate-limiter.ts +11 -7
  152. package/src/search-utils.test.ts +211 -207
  153. package/src/search-utils.ts +213 -209
  154. package/src/search.ts +65 -61
  155. package/src/shared/apply-policies.test.ts +81 -77
  156. package/src/shared/apply-policies.ts +67 -63
  157. package/src/shared/automations.repository.d1.ts +150 -146
  158. package/src/shared/background-notification-service.test.ts +62 -58
  159. package/src/shared/background-notification-service.ts +52 -48
  160. package/src/shared/base.repository.d1.ts +32 -28
  161. package/src/shared/content-utils.test.ts +165 -161
  162. package/src/shared/content-utils.ts +86 -82
  163. package/src/shared/content.repository.d1.test.ts +507 -312
  164. package/src/shared/content.repository.d1.ts +851 -382
  165. package/src/shared/d1-activity-log.repository.test.ts +140 -136
  166. package/src/shared/d1-activity-log.repository.ts +105 -101
  167. package/src/shared/d1-activity-logger.test.ts +86 -82
  168. package/src/shared/d1-activity-logger.ts +67 -63
  169. package/src/shared/d1-analytics.repository.test.ts +78 -74
  170. package/src/shared/d1-analytics.repository.ts +85 -81
  171. package/src/shared/d1-content-scan.repository.test.ts +80 -76
  172. package/src/shared/d1-content-scan.repository.ts +37 -29
  173. package/src/shared/d1-notification.repository.test.ts +128 -124
  174. package/src/shared/d1-notification.repository.ts +118 -114
  175. package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
  176. package/src/shared/d1-password-reset-token.repository.ts +56 -52
  177. package/src/shared/d1-search.repository.test.ts +87 -83
  178. package/src/shared/d1-search.repository.ts +88 -84
  179. package/src/shared/d1-session.repository.test.ts +125 -121
  180. package/src/shared/d1-session.repository.ts +102 -98
  181. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  182. package/src/shared/d1-user.repository.test.ts +151 -147
  183. package/src/shared/d1-user.repository.ts +119 -109
  184. package/src/shared/d1-widget.repository.test.ts +267 -217
  185. package/src/shared/d1-widget.repository.ts +385 -337
  186. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  187. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  188. package/src/shared/demo-data-sql.ts +216 -0
  189. package/src/shared/demo-data.repository.d1.ts +20 -0
  190. package/src/shared/execution-context-scheduler.ts +13 -9
  191. package/src/shared/fixed-clock.ts +25 -21
  192. package/src/shared/fts-sync.ts +8 -4
  193. package/src/shared/idempotency.repository.d1.test.ts +83 -79
  194. package/src/shared/idempotency.repository.d1.ts +60 -56
  195. package/src/shared/in-memory-activity-logger.ts +19 -15
  196. package/src/shared/in-memory-notification-service.ts +19 -15
  197. package/src/shared/in-memory-seed.repository.ts +58 -0
  198. package/src/shared/media.repository.d1.test.ts +107 -103
  199. package/src/shared/media.repository.d1.ts +68 -64
  200. package/src/shared/qstash-notification-service.test.ts +67 -0
  201. package/src/shared/qstash-notification-service.ts +55 -0
  202. package/src/shared/query-utils.ts +141 -137
  203. package/src/shared/request-utils.ts +26 -22
  204. package/src/shared/schema-mutator.d1.test.ts +142 -0
  205. package/src/shared/schema-mutator.d1.ts +64 -0
  206. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  207. package/src/shared/seed-layout.repository.d1.ts +62 -0
  208. package/src/shared/seed-registry-cache.test.ts +68 -0
  209. package/src/shared/seed-registry-cache.ts +49 -0
  210. package/src/shared/seed.repository.d1.test.ts +143 -0
  211. package/src/shared/seed.repository.d1.ts +98 -0
  212. package/src/shared/sequential-id-generator.ts +33 -22
  213. package/src/shared/site-settings.repository.d1.ts +53 -0
  214. package/src/shared/storage/factory.ts +41 -40
  215. package/src/shared/storage/s3-bucket.ts +195 -163
  216. package/src/shared/storage-utils.ts +40 -36
  217. package/src/shared/system-stats.repository.d1.test.ts +58 -54
  218. package/src/shared/system-stats.repository.d1.ts +48 -44
  219. package/src/types.ts +90 -67
  220. package/src/upload.ts +198 -187
  221. package/src/widget.test.ts +85 -0
  222. package/src/widget.ts +251 -208
  223. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  224. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  225. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -1,382 +1,851 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import {
3
- ContentRepository,
4
- EntryNotFoundError,
5
- RepositoryError,
6
- SlugConflictError,
7
- Seed,
8
- SelectOptions,
9
- buildSelectQuery,
10
- deserializeFromDb,
11
- serializeForDb,
12
- } from '@beechcms/core'
13
- import { BaseD1Repository } from './base.repository.d1'
14
-
15
- export class D1ContentRepository extends BaseD1Repository implements ContentRepository {
16
- /**
17
- * Helper to deserialize a DB row using the Seed's branch definitions.
18
- */
19
- private rowToData(seed: Seed, row: any): Record<string, any> {
20
- const data: Record<string, any> = {
21
- id: row.id,
22
- slug: row.slug,
23
- status: row.status,
24
- created_at: row.created_at,
25
- updated_at: row.updated_at,
26
- }
27
-
28
- for (const branch of seed.branches) {
29
- if (Object.hasOwn(row, branch.alias)) {
30
- data[branch.alias] = deserializeFromDb(branch, row[branch.alias])
31
- }
32
- }
33
-
34
- return data
35
- }
36
-
37
- async findMany(
38
- seed: Seed,
39
- options: SelectOptions
40
- ): Promise<{ items: Record<string, any>[]; total: number }> {
41
- try {
42
- const { sql, bindings } = buildSelectQuery(seed, options)
43
-
44
- // We need the total count for pagination.
45
- // We build a count query by replacing the SELECT part.
46
- // Note: buildSelectQuery might have joins and where clauses.
47
- const countSql = sql
48
- .replace(/SELECT .* FROM/, 'SELECT COUNT(*) as total FROM')
49
- .replace(/ ORDER BY .*$/, '')
50
- .replace(/ LIMIT \? OFFSET \?$/, '')
51
-
52
- const countBindings = bindings.slice(0, bindings.length - (options.pagination ? 2 : 0))
53
-
54
- const [batchResults, totalCountResult] = await this.database.batch([
55
- this.database.prepare(sql).bind(...bindings),
56
- this.database.prepare(countSql).bind(...countBindings)
57
- ])
58
-
59
- const contentEntries = (batchResults.results || []).map((entryRow) => this.rowToData(seed, entryRow))
60
- const totalEntriesCount = (totalCountResult.results?.[0] as any)?.total || 0
61
-
62
- return { items: contentEntries, total: totalEntriesCount }
63
- } catch (error) {
64
- throw this.mapError(error, `findMany(${seed.slug})`)
65
- }
66
- }
67
-
68
- async findById(seed: Seed, id: string): Promise<Record<string, any>> {
69
- try {
70
- const tableName = this.getTableName(seed.slug)
71
- const entryRow = await this.database
72
- .prepare(`SELECT * FROM ${tableName} WHERE id = ? LIMIT 1`)
73
- .bind(id)
74
- .first()
75
-
76
- if (!entryRow) {
77
- throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
78
- }
79
-
80
- return this.rowToData(seed, entryRow)
81
- } catch (error) {
82
- if (error instanceof EntryNotFoundError) throw error
83
- throw this.mapError(error, `findById(${seed.slug}, ${id})`)
84
- }
85
- }
86
-
87
- async findBySlug(seed: Seed, slug: string): Promise<Record<string, any>> {
88
- try {
89
- const tableName = this.getTableName(seed.slug)
90
- const entryRow = await this.database
91
- .prepare(`SELECT * FROM ${tableName} WHERE slug = ? LIMIT 1`)
92
- .bind(slug)
93
- .first()
94
-
95
- if (!entryRow) {
96
- throw new EntryNotFoundError(`Entry with slug "${slug}" not found in ${seed.slug}`)
97
- }
98
-
99
- return this.rowToData(seed, entryRow)
100
- } catch (error) {
101
- if (error instanceof EntryNotFoundError) throw error
102
- throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`)
103
- }
104
- }
105
-
106
- async getFacets(seed: Seed): Promise<{
107
- statuses: Record<string, number>
108
- tagsByColumn: Record<string, string[]>
109
- }> {
110
- try {
111
- const tableName = this.getTableName(seed.slug)
112
-
113
- // Retrieve count per status
114
- const statusResults = await this.database
115
- .prepare(`SELECT status, COUNT(*) as count FROM ${tableName} GROUP BY status`)
116
- .all()
117
-
118
- const statusesCount: Record<string, number> = {}
119
- for (const statusRow of statusResults.results || []) {
120
- statusesCount[statusRow.status as string] = statusRow.count as number
121
- }
122
-
123
- // Collect unique tags for branches of type 'tags'
124
- const tagsByColumn: Record<string, string[]> = {}
125
- const tagBranches = seed.branches.filter(branch => branch.type === 'tags')
126
-
127
- for (const branch of tagBranches) {
128
- // Use SQLite json_each to expand tags stored as JSON arrays
129
- const tagResults = await this.database
130
- .prepare(`SELECT DISTINCT value FROM ${tableName}, json_each(${tableName}.${branch.alias}) WHERE value IS NOT NULL`)
131
- .all()
132
- tagsByColumn[branch.alias] = (tagResults.results || []).map(row => row.value as string)
133
- }
134
-
135
- return { statuses: statusesCount, tagsByColumn }
136
- } catch (error) {
137
- throw this.mapError(error, `getFacets(${seed.slug})`)
138
- }
139
- }
140
-
141
- async existsSlug(seed: Seed, slug: string, excludeId?: string): Promise<boolean> {
142
- try {
143
- const tableName = this.getTableName(seed.slug)
144
- let sql = `SELECT 1 FROM ${tableName} WHERE slug = ?`
145
- const queryBindings: any[] = [slug]
146
-
147
- if (excludeId) {
148
- sql += ` AND id != ?`
149
- queryBindings.push(excludeId)
150
- }
151
-
152
- const entryExistsResult = await this.database.prepare(sql).bind(...queryBindings).first()
153
- return entryExistsResult !== null
154
- } catch (error) {
155
- throw this.mapError(error, `existsSlug(${seed.slug}, ${slug})`)
156
- }
157
- }
158
-
159
- async create(
160
- seed: Seed,
161
- id: string,
162
- slug: string,
163
- status: string,
164
- data: Record<string, any>
165
- ): Promise<void> {
166
- try {
167
- if (await this.existsSlug(seed, slug)) {
168
- throw new SlugConflictError(`Slug "${slug}" already exists for ${seed.slug}`)
169
- }
170
-
171
- const tableName = this.getTableName(seed.slug)
172
- const columnNames = ['id', 'slug', 'status']
173
- const placeholders = ['?', '?', '?']
174
- const queryBindings: any[] = [id, slug, status]
175
-
176
- for (const branch of seed.branches) {
177
- if (Object.hasOwn(data, branch.alias)) {
178
- columnNames.push(branch.alias)
179
- placeholders.push('?')
180
- queryBindings.push(serializeForDb(branch, data[branch.alias]))
181
- }
182
- }
183
-
184
- const sql = `INSERT INTO ${tableName} (${columnNames.join(', ')}) VALUES (${placeholders.join(', ')})`
185
- await this.database.prepare(sql).bind(...queryBindings).run()
186
- } catch (error) {
187
- if (error instanceof SlugConflictError) throw error
188
- throw this.mapError(error, `create(${seed.slug})`)
189
- }
190
- }
191
-
192
- async update(
193
- seed: Seed,
194
- id: string,
195
- data: Record<string, any>,
196
- status?: string
197
- ): Promise<void> {
198
- try {
199
- const tableName = this.getTableName(seed.slug)
200
- const updateClauses: string[] = []
201
- const queryBindings: any[] = []
202
-
203
- if (status) {
204
- updateClauses.push('status = ?')
205
- queryBindings.push(status)
206
- }
207
-
208
- for (const branch of seed.branches) {
209
- if (Object.hasOwn(data, branch.alias)) {
210
- updateClauses.push(`${branch.alias} = ?`)
211
- queryBindings.push(serializeForDb(branch, data[branch.alias]))
212
- }
213
- }
214
-
215
- if (updateClauses.length === 0) return
216
-
217
- updateClauses.push('updated_at = (unixepoch())')
218
-
219
- const sql = `UPDATE ${tableName} SET ${updateClauses.join(', ')} WHERE id = ?`
220
- queryBindings.push(id)
221
-
222
- const updateResult = await this.database.prepare(sql).bind(...queryBindings).run()
223
- if (updateResult.meta.changes === 0) {
224
- throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
225
- }
226
- } catch (error) {
227
- if (error instanceof EntryNotFoundError) throw error
228
- throw this.mapError(error, `update(${seed.slug}, ${id})`)
229
- }
230
- }
231
-
232
- async delete(seed: Seed, id: string): Promise<{ row: Record<string, any> }> {
233
- try {
234
- const tableName = this.getTableName(seed.slug)
235
-
236
- // Retrieve the row before deletion to allow for potential cleanup (e.g., media files in R2)
237
- const entryRow = await this.database
238
- .prepare(`SELECT * FROM ${tableName} WHERE id = ?`)
239
- .bind(id)
240
- .first()
241
-
242
- if (!entryRow) {
243
- throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
244
- }
245
-
246
- await this.database.prepare(`DELETE FROM ${tableName} WHERE id = ?`).bind(id).run()
247
-
248
- return { row: this.rowToData(seed, entryRow) }
249
- } catch (error) {
250
- if (error instanceof EntryNotFoundError) throw error
251
- throw this.mapError(error, `delete(${seed.slug}, ${id})`)
252
- }
253
- }
254
-
255
- async saveDraft(seed: Seed, entryId: string, data: Record<string, any>): Promise<void> {
256
- try {
257
- if (!seed.allowDrafts) {
258
- throw new RepositoryError(`Drafts not allowed for ${seed.slug}`)
259
- }
260
-
261
- const draftTableName = this.getTableName(seed.slug, true)
262
- const columnNames = ['entry_id']
263
- const placeholders = ['?']
264
- const queryBindings: any[] = [entryId]
265
- const updateClauses: string[] = []
266
-
267
- for (const branch of seed.branches) {
268
- if (Object.hasOwn(data, branch.alias)) {
269
- const serializedValue = serializeForDb(branch, data[branch.alias])
270
- columnNames.push(branch.alias)
271
- placeholders.push('?')
272
- queryBindings.push(serializedValue)
273
- updateClauses.push(`${branch.alias} = EXCLUDED.${branch.alias}`)
274
- }
275
- }
276
-
277
- updateClauses.push('updated_at = (unixepoch())')
278
-
279
- const sql = `
280
- INSERT INTO ${draftTableName} (${columnNames.join(', ')})
281
- VALUES (${placeholders.join(', ')})
282
- ON CONFLICT(entry_id) DO UPDATE SET ${updateClauses.join(', ')}
283
- `
284
- await this.database.prepare(sql).bind(...queryBindings).run()
285
- } catch (error) {
286
- throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`)
287
- }
288
- }
289
-
290
- async getDraft(seed: Seed, entryId: string): Promise<Record<string, any> | null> {
291
- try {
292
- if (!seed.allowDrafts) return null
293
-
294
- const draftTableName = this.getTableName(seed.slug, true)
295
- const draftRow = await this.database
296
- .prepare(`SELECT * FROM ${draftTableName} WHERE entry_id = ?`)
297
- .bind(entryId)
298
- .first()
299
-
300
- if (!draftRow) return null
301
-
302
- // Filter out nulls from draft row (only include explicitly provided fields)
303
- const draftData: Record<string, any> = {}
304
- for (const branch of seed.branches) {
305
- if (draftRow[branch.alias] !== null) {
306
- draftData[branch.alias] = deserializeFromDb(branch, draftRow[branch.alias])
307
- }
308
- }
309
-
310
- return draftData
311
- } catch (error) {
312
- throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`)
313
- }
314
- }
315
-
316
- async hasDraft(seed: Seed, entryId: string): Promise<boolean> {
317
- try {
318
- if (!seed.allowDrafts) return false
319
- const draftTableName = this.getTableName(seed.slug, true)
320
- const draftExistsResult = await this.database
321
- .prepare(`SELECT 1 FROM ${draftTableName} WHERE entry_id = ? LIMIT 1`)
322
- .bind(entryId)
323
- .first()
324
- return draftExistsResult !== null
325
- } catch (error) {
326
- throw this.mapError(error, `hasDraft(${seed.slug}, ${entryId})`)
327
- }
328
- }
329
-
330
- async publishDraft(seed: Seed, entryId: string): Promise<void> {
331
- try {
332
- if (!seed.allowDrafts) return
333
-
334
- const draftTableName = this.getTableName(seed.slug, true)
335
- const liveTableName = this.getTableName(seed.slug)
336
-
337
- const draftRow = await this.database
338
- .prepare(`SELECT * FROM ${draftTableName} WHERE entry_id = ?`)
339
- .bind(entryId)
340
- .first()
341
-
342
- if (!draftRow) {
343
- throw new EntryNotFoundError(`No draft found for ${entryId} in ${seed.slug}`)
344
- }
345
-
346
- // Build UPDATE clause for the live table using data from the draft
347
- const updateClauses: string[] = []
348
- const queryBindings: any[] = []
349
-
350
- for (const branch of seed.branches) {
351
- if (draftRow[branch.alias] !== null) {
352
- updateClauses.push(`${branch.alias} = ?`)
353
- queryBindings.push(draftRow[branch.alias])
354
- }
355
- }
356
-
357
- updateClauses.push('updated_at = (unixepoch())')
358
-
359
- const updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(', ')} WHERE id = ?`
360
- queryBindings.push(entryId)
361
-
362
- // Execute batch to atomically update live table and delete draft
363
- await this.database.batch([
364
- this.database.prepare(updateSql).bind(...queryBindings),
365
- this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId)
366
- ])
367
- } catch (error) {
368
- if (error instanceof EntryNotFoundError) throw error
369
- throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`)
370
- }
371
- }
372
-
373
- async deleteDraft(seed: Seed, entryId: string): Promise<void> {
374
- try {
375
- if (!seed.allowDrafts) return
376
- const draftTableName = this.getTableName(seed.slug, true)
377
- await this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId).run()
378
- } catch (error) {
379
- throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`)
380
- }
381
- }
382
- }
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ /// <reference types="@cloudflare/workers-types" />
6
+ import {
7
+ ContentRepository,
8
+ EntryNotFoundError,
9
+ RepositoryError,
10
+ SlugConflictError,
11
+ RelationTargetNotFoundError,
12
+ type BulkFieldUpdate,
13
+ type DraftSummary,
14
+ type Seed,
15
+ type Branch,
16
+ type SelectOptions,
17
+ buildSelectQuery,
18
+ deserializeFromDb,
19
+ serializeForDb,
20
+ } from '@beechcms/core'
21
+ import { BaseD1Repository } from './base.repository.d1'
22
+
23
+ // ── Private helpers ───────────────────────────────────────────────────────────
24
+
25
+ function multiRelBranches(seed: Seed): Branch[] {
26
+ return seed.branches.filter(b => b.type === 'relation' && b.multiple === true)
27
+ }
28
+
29
+ function singleRelBranches(seed: Seed): Branch[] {
30
+ return seed.branches.filter(b => b.type === 'relation' && !b.multiple)
31
+ }
32
+
33
+ function jTable(seedSlug: string, branchAlias: string): string {
34
+ return `rel_${seedSlug}_${branchAlias}`
35
+ }
36
+
37
+ function jDraftTable(seedSlug: string, branchAlias: string): string {
38
+ return `rel_${seedSlug}_${branchAlias}_drafts`
39
+ }
40
+
41
+ export class D1ContentRepository extends BaseD1Repository implements ContentRepository {
42
+ /**
43
+ * Deserializes a DB row using the Seed's branch definitions.
44
+ * Skips multi-relation branches their values come from junction tables.
45
+ */
46
+ private rowToData(seed: Seed, row: any): Record<string, any> {
47
+ const data: Record<string, any> = {
48
+ id: row.id,
49
+ slug: row.slug,
50
+ status: row.status,
51
+ created_at: row.created_at,
52
+ updated_at: row.updated_at,
53
+ }
54
+
55
+ for (const branch of seed.branches) {
56
+ // Multi-relation values live in junction tables, not as columns on this table
57
+ if (branch.type === 'relation' && branch.multiple === true) continue
58
+ if (Object.hasOwn(row, branch.alias)) {
59
+ data[branch.alias] = deserializeFromDb(branch, row[branch.alias])
60
+ }
61
+ }
62
+
63
+ return data
64
+ }
65
+
66
+ /**
67
+ * Fetches multi-relation arrays for a single entry and attaches them to `data`.
68
+ */
69
+ private async attachMultiRelations(
70
+ seed: Seed,
71
+ entryId: string,
72
+ data: Record<string, any>,
73
+ ): Promise<void> {
74
+ const branches = multiRelBranches(seed)
75
+ if (branches.length === 0) return
76
+
77
+ const stmts = branches.map(b =>
78
+ this.database
79
+ .prepare(`SELECT target_id FROM ${jTable(seed.slug, b.alias)} WHERE parent_id = ? ORDER BY position ASC`)
80
+ .bind(entryId),
81
+ )
82
+
83
+ const results = await this.database.batch(stmts)
84
+ for (let i = 0; i < branches.length; i++) {
85
+ data[branches[i].alias] = (results[i].results ?? []).map((r: any) => r.target_id)
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Fetches multi-relation arrays for a list of entries and attaches them.
91
+ * One query per multi-relation branch (O(R) queries, R = branch count).
92
+ */
93
+ private async attachMultiRelationsMany(
94
+ seed: Seed,
95
+ entries: Record<string, any>[],
96
+ ): Promise<void> {
97
+ const branches = multiRelBranches(seed)
98
+ if (branches.length === 0 || entries.length === 0) return
99
+
100
+ const ids = entries.map(e => e.id)
101
+ const placeholders = ids.map(() => '?').join(', ')
102
+
103
+ const stmts = branches.map(b =>
104
+ this.database
105
+ .prepare(
106
+ `SELECT parent_id, target_id FROM ${jTable(seed.slug, b.alias)}` +
107
+ ` WHERE parent_id IN (${placeholders}) ORDER BY parent_id, position ASC`,
108
+ )
109
+ .bind(...ids),
110
+ )
111
+
112
+ const results = await this.database.batch(stmts)
113
+ for (let i = 0; i < branches.length; i++) {
114
+ const branch = branches[i]
115
+ const byParent = new Map<string, string[]>()
116
+ for (const row of results[i].results ?? []) {
117
+ const r = row as Record<string, unknown>
118
+ const pid = r.parent_id as string
119
+ if (!byParent.has(pid)) byParent.set(pid, [])
120
+ byParent.get(pid)!.push(r.target_id as string)
121
+ }
122
+ for (const entry of entries) {
123
+ entry[branch.alias] = byParent.get(entry.id) ?? []
124
+ }
125
+ }
126
+ }
127
+
128
+ async findMany(
129
+ seed: Seed,
130
+ options: SelectOptions
131
+ ): Promise<{ items: Record<string, any>[]; total: number }> {
132
+ try {
133
+ const { sql, bindings } = buildSelectQuery(seed, options)
134
+
135
+ const countSql = sql
136
+ .replace(/SELECT .* FROM/, 'SELECT COUNT(*) as total FROM')
137
+ .replace(/ ORDER BY .*$/, '')
138
+ .replace(/ LIMIT \? OFFSET \?$/, '')
139
+
140
+ const countBindings = bindings.slice(0, bindings.length - (options.pagination ? 2 : 0))
141
+
142
+ const [batchResults, totalCountResult] = await this.database.batch([
143
+ this.database.prepare(sql).bind(...bindings),
144
+ this.database.prepare(countSql).bind(...countBindings),
145
+ ])
146
+
147
+ const contentEntries = (batchResults.results || []).map(row => this.rowToData(seed, row))
148
+ const totalEntriesCount = (totalCountResult.results?.[0] as any)?.total || 0
149
+
150
+ await this.attachMultiRelationsMany(seed, contentEntries)
151
+
152
+ return { items: contentEntries, total: totalEntriesCount }
153
+ } catch (error) {
154
+ throw this.mapError(error, `findMany(${seed.slug})`)
155
+ }
156
+ }
157
+
158
+ async findById(seed: Seed, id: string): Promise<Record<string, any>> {
159
+ try {
160
+ const tableName = this.getTableName(seed.slug)
161
+ const entryRow = await this.database
162
+ .prepare(`SELECT * FROM ${tableName} WHERE id = ? LIMIT 1`)
163
+ .bind(id)
164
+ .first()
165
+
166
+ if (!entryRow) {
167
+ throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
168
+ }
169
+
170
+ const data = this.rowToData(seed, entryRow)
171
+ await this.attachMultiRelations(seed, id, data)
172
+ return data
173
+ } catch (error) {
174
+ if (error instanceof EntryNotFoundError) throw error
175
+ throw this.mapError(error, `findById(${seed.slug}, ${id})`)
176
+ }
177
+ }
178
+
179
+ async findBySlug(seed: Seed, slug: string): Promise<Record<string, any>> {
180
+ try {
181
+ const tableName = this.getTableName(seed.slug)
182
+ const entryRow = await this.database
183
+ .prepare(`SELECT * FROM ${tableName} WHERE slug = ? LIMIT 1`)
184
+ .bind(slug)
185
+ .first()
186
+
187
+ if (!entryRow) {
188
+ throw new EntryNotFoundError(`Entry with slug "${slug}" not found in ${seed.slug}`)
189
+ }
190
+
191
+ const data = this.rowToData(seed, entryRow)
192
+ await this.attachMultiRelations(seed, entryRow.id as string, data)
193
+ return data
194
+ } catch (error) {
195
+ if (error instanceof EntryNotFoundError) throw error
196
+ throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`)
197
+ }
198
+ }
199
+
200
+ async getFacets(seed: Seed): Promise<{
201
+ statuses: Record<string, number>
202
+ tagsByColumn: Record<string, string[]>
203
+ }> {
204
+ try {
205
+ const tableName = this.getTableName(seed.slug)
206
+
207
+ const statusResults = await this.database
208
+ .prepare(`SELECT status, COUNT(*) as count FROM ${tableName} GROUP BY status`)
209
+ .all()
210
+
211
+ const statusesCount: Record<string, number> = {}
212
+ for (const statusRow of statusResults.results || []) {
213
+ statusesCount[statusRow.status as string] = statusRow.count as number
214
+ }
215
+
216
+ const tagsByColumn: Record<string, string[]> = {}
217
+ const tagBranches = seed.branches.filter(branch => branch.type === 'tags')
218
+
219
+ for (const branch of tagBranches) {
220
+ const tagResults = await this.database
221
+ .prepare(`SELECT DISTINCT value FROM ${tableName}, json_each(${tableName}.${branch.alias}) WHERE value IS NOT NULL`)
222
+ .all()
223
+ tagsByColumn[branch.alias] = (tagResults.results || []).map(row => row.value as string)
224
+ }
225
+
226
+ return { statuses: statusesCount, tagsByColumn }
227
+ } catch (error) {
228
+ throw this.mapError(error, `getFacets(${seed.slug})`)
229
+ }
230
+ }
231
+
232
+ async existsSlug(seed: Seed, slug: string, excludeId?: string): Promise<boolean> {
233
+ try {
234
+ const tableName = this.getTableName(seed.slug)
235
+ let sql = `SELECT 1 FROM ${tableName} WHERE slug = ?`
236
+ const queryBindings: any[] = [slug]
237
+
238
+ if (excludeId) {
239
+ sql += ` AND id != ?`
240
+ queryBindings.push(excludeId)
241
+ }
242
+
243
+ const entryExistsResult = await this.database.prepare(sql).bind(...queryBindings).first()
244
+ return entryExistsResult !== null
245
+ } catch (error) {
246
+ throw this.mapError(error, `existsSlug(${seed.slug}, ${slug})`)
247
+ }
248
+ }
249
+
250
+
251
+ private buildCreateMainStmt(
252
+ seed: Seed,
253
+ id: string,
254
+ slug: string,
255
+ status: string,
256
+ data: Record<string, any>
257
+ ): D1PreparedStatement {
258
+ const tableName = this.getTableName(seed.slug)
259
+ const columnNames = ['id', 'slug', 'status']
260
+ const placeholders = ['?', '?', '?']
261
+ const queryBindings: any[] = [id, slug, status]
262
+
263
+ const mRelAliases = new Set(multiRelBranches(seed).map(b => b.alias))
264
+
265
+ for (const branch of seed.branches) {
266
+ if (mRelAliases.has(branch.alias)) continue
267
+ if (Object.hasOwn(data, branch.alias)) {
268
+ columnNames.push(branch.alias)
269
+ placeholders.push('?')
270
+ queryBindings.push(serializeForDb(branch, data[branch.alias]))
271
+ }
272
+ }
273
+
274
+ const mainSql = `INSERT INTO ${tableName} (${columnNames.join(', ')}) VALUES (${placeholders.join(', ')})`
275
+ return this.database.prepare(mainSql).bind(...queryBindings)
276
+ }
277
+
278
+ private buildUpdateMainStmt(
279
+ seed: Seed,
280
+ id: string,
281
+ data: Record<string, any>,
282
+ status?: string
283
+ ): { stmt: D1PreparedStatement | null; junctionUpdates: any[] } {
284
+ const tableName = this.getTableName(seed.slug)
285
+ const updateClauses: string[] = []
286
+ const queryBindings: any[] = []
287
+
288
+ const mRelBranches = multiRelBranches(seed)
289
+ const mRelAliases = new Set(mRelBranches.map(b => b.alias))
290
+
291
+ if (status) {
292
+ updateClauses.push('status = ?')
293
+ queryBindings.push(status)
294
+ }
295
+
296
+ for (const branch of seed.branches) {
297
+ if (mRelAliases.has(branch.alias)) continue
298
+ if (Object.hasOwn(data, branch.alias)) {
299
+ updateClauses.push(`${branch.alias} = ?`)
300
+ queryBindings.push(serializeForDb(branch, data[branch.alias]))
301
+ }
302
+ }
303
+
304
+ const junctionUpdates = mRelBranches.filter(b => Object.hasOwn(data, b.alias))
305
+
306
+ if (updateClauses.length === 0 && junctionUpdates.length === 0) {
307
+ return { stmt: null, junctionUpdates: [] }
308
+ }
309
+
310
+ updateClauses.push('updated_at = (unixepoch())')
311
+
312
+ const mainSql = `UPDATE ${tableName} SET ${updateClauses.join(', ')} WHERE id = ?`
313
+ queryBindings.push(id)
314
+
315
+ return {
316
+ stmt: this.database.prepare(mainSql).bind(...queryBindings),
317
+ junctionUpdates
318
+ }
319
+ }
320
+
321
+ private buildJunctionInserts(seedSlug: string, parentId: string, branchAlias: string, targetIds: string[]): D1PreparedStatement[] {
322
+ const jt = jTable(seedSlug, branchAlias)
323
+ return targetIds.map((targetId, i) =>
324
+ this.database
325
+ .prepare(`INSERT INTO ${jt} (parent_id, target_id, position) VALUES (?, ?, ?)`)
326
+ .bind(parentId, targetId, i),
327
+ )
328
+ }
329
+
330
+ private buildDraftJunctionInserts(seedSlug: string, entryId: string, branchAlias: string, targetIds: string[]): D1PreparedStatement[] {
331
+ const jdt = jDraftTable(seedSlug, branchAlias)
332
+ return targetIds.map((targetId, i) =>
333
+ this.database
334
+ .prepare(`INSERT INTO ${jdt} (entry_id, target_id, position) VALUES (?, ?, ?)`)
335
+ .bind(entryId, targetId, i),
336
+ )
337
+ }
338
+
339
+ private async validatePublishDraftRelations(seed: Seed, draftRow: Record<string, unknown>, entryId: string) {
340
+ for (const branch of singleRelBranches(seed)) {
341
+ const value = draftRow[branch.alias]
342
+ if (value == null) continue
343
+ const exists = await this.database
344
+ .prepare(`SELECT 1 FROM content_${branch.targetSeed} WHERE id = ? LIMIT 1`)
345
+ .bind(value)
346
+ .first()
347
+ if (!exists) {
348
+ throw new RelationTargetNotFoundError({
349
+ alias: branch.alias,
350
+ targetSeed: branch.targetSeed!,
351
+ value: typeof value === 'string' ? value : (JSON.stringify(value) ?? 'undefined'),
352
+ })
353
+ }
354
+ }
355
+
356
+ const mRelBranches = multiRelBranches(seed)
357
+ const mRelDraftIds = new Map<string, string[]>()
358
+
359
+ for (const branch of mRelBranches) {
360
+ const jdt = jDraftTable(seed.slug, branch.alias)
361
+ const rows = await this.database
362
+ .prepare(`SELECT target_id FROM ${jdt} WHERE entry_id = ? ORDER BY position ASC`)
363
+ .bind(entryId)
364
+ .all()
365
+ const targetIds = (rows.results ?? []).map((r: any) => String(r.target_id))
366
+
367
+ for (const targetId of targetIds) {
368
+ const exists = await this.database
369
+ .prepare(`SELECT 1 FROM content_${branch.targetSeed} WHERE id = ? LIMIT 1`)
370
+ .bind(targetId)
371
+ .first()
372
+ if (!exists) {
373
+ throw new RelationTargetNotFoundError({
374
+ alias: branch.alias,
375
+ targetSeed: branch.targetSeed!,
376
+ value: targetId,
377
+ })
378
+ }
379
+ }
380
+ mRelDraftIds.set(branch.alias, targetIds)
381
+ }
382
+
383
+ return { mRelBranches, mRelDraftIds }
384
+ }
385
+
386
+ private getBulkArrayUpdateStmts(seedSlug: string, id: string, alias: string, update: BulkFieldUpdate): D1PreparedStatement[] {
387
+ const jt = jTable(seedSlug, alias)
388
+ const stmts: D1PreparedStatement[] = []
389
+
390
+ if (update.kind === 'array_replace') {
391
+ const deleteStmt = this.database.prepare(`DELETE FROM ${jt} WHERE parent_id = ?`).bind(id)
392
+ const insertStmts = update.value.map((v, i) =>
393
+ this.database.prepare(`INSERT INTO ${jt} (parent_id, target_id, position) VALUES (?, ?, ?)`).bind(id, v, i)
394
+ )
395
+ stmts.push(deleteStmt, ...insertStmts)
396
+ } else if (update.kind === 'array_add') {
397
+ for (const targetId of update.value) {
398
+ stmts.push(
399
+ this.database
400
+ .prepare(
401
+ `INSERT OR IGNORE INTO ${jt} (parent_id, target_id, position) VALUES (?, ?, (SELECT COALESCE(MAX(position), -1) + 1 FROM ${jt} WHERE parent_id = ?))`,
402
+ )
403
+ .bind(id, targetId, id),
404
+ )
405
+ }
406
+ } else if (update.kind === 'array_remove' && update.value.length > 0) {
407
+ const placeholders = update.value.map(() => '?').join(', ')
408
+ stmts.push(
409
+ this.database
410
+ .prepare(`DELETE FROM ${jt} WHERE parent_id = ? AND target_id IN (${placeholders})`)
411
+ .bind(id, ...update.value),
412
+ )
413
+ }
414
+ return stmts
415
+ }
416
+
417
+ private async processBulkUpdateSingle(
418
+ seedSlug: string,
419
+ id: string,
420
+ fields: Record<string, BulkFieldUpdate>,
421
+ tableName: string
422
+ ): Promise<void> {
423
+ const stmts: D1PreparedStatement[] = []
424
+
425
+ const setClauses: string[] = ['updated_at = (unixepoch())']
426
+ const setBindings: unknown[] = []
427
+
428
+ for (const [alias, update] of Object.entries(fields)) {
429
+ if (update.kind === 'set') {
430
+ setClauses.unshift(`${alias} = ?`)
431
+ setBindings.push(update.value)
432
+ } else {
433
+ stmts.push(...this.getBulkArrayUpdateStmts(seedSlug, id, alias, update))
434
+ }
435
+ }
436
+
437
+ stmts.unshift(
438
+ this.database
439
+ .prepare(`UPDATE ${tableName} SET ${setClauses.join(', ')} WHERE id = ?`)
440
+ .bind(...setBindings, id),
441
+ )
442
+
443
+ const results = await this.database.batch(stmts)
444
+ if ((results[0].meta?.changes ?? 0) === 0) {
445
+ throw new Error('not-found')
446
+ }
447
+ }
448
+
449
+ private async processBulkUpdateChunk(
450
+ seedSlug: string,
451
+ chunk: string[],
452
+ fields: Record<string, BulkFieldUpdate>,
453
+ tableName: string
454
+ ): Promise<{ updated: number; failed: Array<{ id: string; reason: string }> }> {
455
+ let updated = 0
456
+ const failed: Array<{ id: string; reason: string }> = []
457
+
458
+ for (const id of chunk) {
459
+ try {
460
+ await this.processBulkUpdateSingle(seedSlug, id, fields, tableName)
461
+ updated++
462
+ } catch (err) {
463
+ const msg = err instanceof Error ? err.message : String(err)
464
+ if (msg === 'not-found') {
465
+ failed.push({ id, reason: 'not-found' })
466
+ } else {
467
+ const isFk = /FOREIGN KEY constraint failed/i.test(msg)
468
+ failed.push({ id, reason: isFk ? `relation-target-not-found:${id}` : `error:${msg}` })
469
+ }
470
+ }
471
+ }
472
+ return { updated, failed }
473
+ }
474
+
475
+ async create(
476
+ seed: Seed,
477
+ id: string,
478
+ slug: string,
479
+ status: string,
480
+ data: Record<string, any>
481
+ ): Promise<void> {
482
+ try {
483
+ if (await this.existsSlug(seed, slug)) {
484
+ throw new SlugConflictError(`Slug "${slug}" already exists for ${seed.slug}`)
485
+ }
486
+
487
+ const batchStmts: D1PreparedStatement[] = [
488
+ this.buildCreateMainStmt(seed, id, slug, status, data),
489
+ ]
490
+
491
+ for (const branch of multiRelBranches(seed)) {
492
+ const value = data[branch.alias]
493
+ if (!Array.isArray(value) || value.length === 0) continue
494
+ batchStmts.push(...this.buildJunctionInserts(seed.slug, id, branch.alias, value))
495
+ }
496
+
497
+ if (batchStmts.length === 1) {
498
+ await batchStmts[0].run()
499
+ } else {
500
+ await this.database.batch(batchStmts)
501
+ }
502
+ } catch (error) {
503
+ if (error instanceof SlugConflictError) throw error
504
+ throw this.mapError(error, `create(${seed.slug})`)
505
+ }
506
+ }
507
+
508
+ async update(
509
+ seed: Seed,
510
+ id: string,
511
+ data: Record<string, any>,
512
+ status?: string
513
+ ): Promise<void> {
514
+ try {
515
+ const { stmt, junctionUpdates } = this.buildUpdateMainStmt(seed, id, data, status)
516
+
517
+ if (!stmt && junctionUpdates.length === 0) return
518
+
519
+ const batchStmts: D1PreparedStatement[] = []
520
+ if (stmt) batchStmts.push(stmt)
521
+
522
+ for (const branch of junctionUpdates) {
523
+ const jt = jTable(seed.slug, branch.alias)
524
+ const value = (data[branch.alias] ?? []) as string[]
525
+ const deleteStmt = this.database.prepare(`DELETE FROM ${jt} WHERE parent_id = ?`).bind(id)
526
+ batchStmts.push(deleteStmt, ...this.buildJunctionInserts(seed.slug, id, branch.alias, value))
527
+ }
528
+
529
+ if (batchStmts.length === 1) {
530
+ const updateResult = await batchStmts[0].run()
531
+ if (updateResult.meta.changes === 0) {
532
+ throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
533
+ }
534
+ } else if (batchStmts.length > 1) {
535
+ const results = await this.database.batch(batchStmts)
536
+ if (stmt && (results[0].meta?.changes ?? 0) === 0) {
537
+ throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
538
+ }
539
+ }
540
+ } catch (error) {
541
+ if (error instanceof EntryNotFoundError) throw error
542
+ throw this.mapError(error, `update(${seed.slug}, ${id})`)
543
+ }
544
+ }
545
+
546
+ async delete(seed: Seed, id: string): Promise<{ row: Record<string, any> }> {
547
+ try {
548
+ const tableName = this.getTableName(seed.slug)
549
+
550
+ const entryRow = await this.database
551
+ .prepare(`SELECT * FROM ${tableName} WHERE id = ?`)
552
+ .bind(id)
553
+ .first()
554
+
555
+ if (!entryRow) {
556
+ throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
557
+ }
558
+
559
+ await this.database.prepare(`DELETE FROM ${tableName} WHERE id = ?`).bind(id).run()
560
+
561
+ return { row: this.rowToData(seed, entryRow) }
562
+ } catch (error) {
563
+ if (error instanceof EntryNotFoundError) throw error
564
+ throw this.mapError(error, `delete(${seed.slug}, ${id})`)
565
+ }
566
+ }
567
+
568
+ async saveDraft(seed: Seed, entryId: string, data: Record<string, any>): Promise<void> {
569
+ try {
570
+ if (!seed.allowDrafts) {
571
+ throw new RepositoryError(`Drafts not allowed for ${seed.slug}`)
572
+ }
573
+
574
+ const draftTableName = this.getTableName(seed.slug, true)
575
+ const mRelBranches = multiRelBranches(seed)
576
+ const mRelAliases = new Set(mRelBranches.map(b => b.alias))
577
+
578
+ const columnNames = ['entry_id']
579
+ const placeholders = ['?']
580
+ const queryBindings: any[] = [entryId]
581
+ const updateClauses: string[] = []
582
+
583
+ for (const branch of seed.branches) {
584
+ if (mRelAliases.has(branch.alias)) continue
585
+ if (Object.hasOwn(data, branch.alias)) {
586
+ const serializedValue = serializeForDb(branch, data[branch.alias])
587
+ columnNames.push(branch.alias)
588
+ placeholders.push('?')
589
+ queryBindings.push(serializedValue)
590
+ updateClauses.push(`${branch.alias} = EXCLUDED.${branch.alias}`)
591
+ }
592
+ }
593
+
594
+ updateClauses.push('updated_at = (unixepoch())')
595
+
596
+ const sql = `
597
+ INSERT INTO ${draftTableName} (${columnNames.join(', ')})
598
+ VALUES (${placeholders.join(', ')})
599
+ ON CONFLICT(entry_id) DO UPDATE SET ${updateClauses.join(', ')}
600
+ `
601
+
602
+ const junctionUpdates = mRelBranches.filter(b => Object.hasOwn(data, b.alias))
603
+
604
+ if (junctionUpdates.length === 0) {
605
+ await this.database.prepare(sql).bind(...queryBindings).run()
606
+ return
607
+ }
608
+
609
+ const batchStmts: D1PreparedStatement[] = [
610
+ this.database.prepare(sql).bind(...queryBindings),
611
+ ]
612
+
613
+ for (const branch of junctionUpdates) {
614
+ const jdt = jDraftTable(seed.slug, branch.alias)
615
+ const value = (data[branch.alias] ?? []) as string[]
616
+ batchStmts.push(
617
+ this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId),
618
+ )
619
+ for (let i = 0; i < value.length; i++) {
620
+ batchStmts.push(
621
+ this.database
622
+ .prepare(`INSERT INTO ${jdt} (entry_id, target_id, position) VALUES (?, ?, ?)`)
623
+ .bind(entryId, value[i], i),
624
+ )
625
+ }
626
+ }
627
+
628
+ await this.database.batch(batchStmts)
629
+ } catch (error) {
630
+ throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`)
631
+ }
632
+ }
633
+
634
+ async getDraft(seed: Seed, entryId: string): Promise<Record<string, any> | null> {
635
+ try {
636
+ if (!seed.allowDrafts) return null
637
+
638
+ const draftTableName = this.getTableName(seed.slug, true)
639
+ const draftRow = await this.database
640
+ .prepare(`SELECT * FROM ${draftTableName} WHERE entry_id = ?`)
641
+ .bind(entryId)
642
+ .first()
643
+
644
+ if (!draftRow) return null
645
+
646
+ const mRelBranches = multiRelBranches(seed)
647
+ const mRelAliases = new Set(mRelBranches.map(b => b.alias))
648
+
649
+ const draftData: Record<string, any> = {}
650
+ for (const branch of seed.branches) {
651
+ if (mRelAliases.has(branch.alias)) continue
652
+ if (draftRow[branch.alias] !== null) {
653
+ draftData[branch.alias] = deserializeFromDb(branch, draftRow[branch.alias])
654
+ }
655
+ }
656
+
657
+ // Fetch multi-relation draft arrays from junction draft tables
658
+ if (mRelBranches.length > 0) {
659
+ const stmts = mRelBranches.map(b =>
660
+ this.database
661
+ .prepare(`SELECT target_id FROM ${jDraftTable(seed.slug, b.alias)} WHERE entry_id = ? ORDER BY position ASC`)
662
+ .bind(entryId),
663
+ )
664
+ const results = await this.database.batch(stmts)
665
+ for (let i = 0; i < mRelBranches.length; i++) {
666
+ const ids = (results[i].results ?? []).map((r: any) => r.target_id as string)
667
+ if (ids.length > 0) {
668
+ draftData[mRelBranches[i].alias] = ids
669
+ }
670
+ }
671
+ }
672
+
673
+ return draftData
674
+ } catch (error) {
675
+ throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`)
676
+ }
677
+ }
678
+
679
+ async hasDraft(seed: Seed, entryId: string): Promise<boolean> {
680
+ try {
681
+ if (!seed.allowDrafts) return false
682
+ const draftTableName = this.getTableName(seed.slug, true)
683
+ const draftExistsResult = await this.database
684
+ .prepare(`SELECT 1 FROM ${draftTableName} WHERE entry_id = ? LIMIT 1`)
685
+ .bind(entryId)
686
+ .first()
687
+ return draftExistsResult !== null
688
+ } catch (error) {
689
+ throw this.mapError(error, `hasDraft(${seed.slug}, ${entryId})`)
690
+ }
691
+ }
692
+
693
+ async publishDraft(seed: Seed, entryId: string): Promise<void> {
694
+ try {
695
+ if (!seed.allowDrafts) return
696
+
697
+ const draftTableName = this.getTableName(seed.slug, true)
698
+ const liveTableName = this.getTableName(seed.slug)
699
+
700
+ const draftRow = await this.database
701
+ .prepare(`SELECT * FROM ${draftTableName} WHERE entry_id = ?`)
702
+ .bind(entryId)
703
+ .first<Record<string, unknown>>()
704
+
705
+ if (!draftRow) {
706
+ throw new EntryNotFoundError(`No draft found for ${entryId} in ${seed.slug}`)
707
+ }
708
+
709
+ const { mRelBranches, mRelDraftIds } = await this.validatePublishDraftRelations(seed, draftRow, entryId)
710
+
711
+ const mRelAliases = new Set(mRelBranches.map(b => b.alias))
712
+ const updateClauses: string[] = []
713
+ const queryBindings: any[] = []
714
+
715
+ for (const branch of seed.branches) {
716
+ if (mRelAliases.has(branch.alias)) continue
717
+ if (draftRow[branch.alias] !== null) {
718
+ updateClauses.push(`${branch.alias} = ?`)
719
+ queryBindings.push(draftRow[branch.alias])
720
+ }
721
+ }
722
+
723
+ updateClauses.push('updated_at = (unixepoch())')
724
+ const updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(', ')} WHERE id = ?`
725
+ queryBindings.push(entryId)
726
+
727
+ const batchStmts: D1PreparedStatement[] = [
728
+ this.database.prepare(updateSql).bind(...queryBindings),
729
+ this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId),
730
+ ]
731
+
732
+ for (const branch of mRelBranches) {
733
+ const lt = jTable(seed.slug, branch.alias)
734
+ const jdt = jDraftTable(seed.slug, branch.alias)
735
+ const targetIds = mRelDraftIds.get(branch.alias) ?? []
736
+
737
+ batchStmts.push(
738
+ this.database.prepare(`DELETE FROM ${lt} WHERE parent_id = ?`).bind(entryId),
739
+ this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId),
740
+ ...this.buildJunctionInserts(seed.slug, entryId, branch.alias, targetIds)
741
+ )
742
+ }
743
+
744
+ await this.database.batch(batchStmts)
745
+ } catch (error) {
746
+ if (error instanceof EntryNotFoundError) throw error
747
+ if (error instanceof RelationTargetNotFoundError) throw error
748
+ throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`)
749
+ }
750
+ }
751
+
752
+ async bulkUpdate(
753
+ seedSlug: string,
754
+ ids: string[],
755
+ fields: Record<string, BulkFieldUpdate>,
756
+ ): Promise<{ updated: number; failed: Array<{ id: string; reason: string }> }> {
757
+ const CHUNK = 50
758
+ const tableName = this.getTableName(seedSlug)
759
+ let updated = 0
760
+ const failed: Array<{ id: string; reason: string }> = []
761
+
762
+ for (let offset = 0; offset < ids.length; offset += CHUNK) {
763
+ const chunk = ids.slice(offset, offset + CHUNK)
764
+ const res = await this.processBulkUpdateChunk(seedSlug, chunk, fields, tableName)
765
+ updated += res.updated
766
+ failed.push(...res.failed)
767
+ }
768
+
769
+ return { updated, failed }
770
+ }
771
+
772
+ async deleteDraft(seed: Seed, entryId: string): Promise<void> {
773
+ try {
774
+ if (!seed.allowDrafts) return
775
+ const draftTableName = this.getTableName(seed.slug, true)
776
+ await this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId).run()
777
+ } catch (error) {
778
+ throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`)
779
+ }
780
+ }
781
+
782
+ async findPendingDrafts(seeds: Seed[]): Promise<DraftSummary[]> {
783
+ try {
784
+ if (seeds.length === 0) return []
785
+
786
+ const unionSelects: string[] = []
787
+ const bindings: (string | number)[] = []
788
+
789
+ for (const seed of seeds) {
790
+ const draftTable = `content_${seed.slug}_drafts`
791
+ const liveTable = `content_${seed.slug}`
792
+ const titleCol = seed.displayNameAlias
793
+ const seedLabel = seed.labelPlural ?? seed.label
794
+
795
+ unionSelects.push(`
796
+ SELECT
797
+ ? AS seed_slug,
798
+ ? AS seed_label,
799
+ d.entry_id AS id,
800
+ d.updated_at AS updated_at,
801
+ COALESCE(d.${titleCol}, l.${titleCol}) AS title
802
+ FROM ${draftTable} d
803
+ LEFT JOIN ${liveTable} l ON l.id = d.entry_id
804
+ `)
805
+ bindings.push(seed.slug, seedLabel)
806
+ }
807
+
808
+ const sql = `
809
+ WITH all_drafts AS (
810
+ ${unionSelects.join('\nUNION ALL\n')}
811
+ )
812
+ SELECT
813
+ ad.seed_slug AS seedSlug,
814
+ ad.seed_label AS seedLabel,
815
+ ad.id AS id,
816
+ ad.updated_at AS updatedAt,
817
+ ad.title AS title,
818
+ al.user_name AS lastName,
819
+ al.user_email AS lastEmail
820
+ FROM all_drafts ad
821
+ LEFT JOIN activity_logs al
822
+ ON al.id = (
823
+ SELECT id FROM activity_logs
824
+ WHERE entity_id = ad.id
825
+ AND entity_slug = ad.seed_slug
826
+ AND action = 'update'
827
+ AND json_extract(details, '$.note') = 'draft saved'
828
+ ORDER BY created_at DESC
829
+ LIMIT 1
830
+ )
831
+ ORDER BY ad.updated_at DESC
832
+ `
833
+
834
+ const { results } = await this.database.prepare(sql).bind(...bindings).all()
835
+
836
+ return (results ?? []).map((row: any): DraftSummary => ({
837
+ id: String(row.id),
838
+ seedSlug: String(row.seedSlug),
839
+ seedLabel: String(row.seedLabel),
840
+ title: row.title != null && String(row.title).trim() ? String(row.title) : String(row.id),
841
+ updatedAt: Number(row.updatedAt),
842
+ lastModifiedBy: {
843
+ name: row.lastName ?? null,
844
+ email: row.lastEmail ?? '',
845
+ },
846
+ }))
847
+ } catch (error) {
848
+ throw this.mapError(error, 'findPendingDrafts')
849
+ }
850
+ }
851
+ }