@beechcms/api 0.6.0-preview.1 → 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 (222) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  4. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  5. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  6. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  7. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  8. package/assets/dashboard/beechLogoDark.svg +48 -48
  9. package/assets/dashboard/index.html +18 -18
  10. package/assets/dashboard/noResult.svg +42 -42
  11. package/assets/dashboard/sol.svg +3 -3
  12. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  13. package/migrations/0000_v040_base.sql +265 -265
  14. package/migrations/0029_automations.sql +14 -14
  15. package/migrations/0030_test_seeds.sql +125 -125
  16. package/package.json +2 -2
  17. package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
  18. package/src/auth/__fixtures__/static-token-service.ts +22 -22
  19. package/src/auth/bcrypt-hash-provider.ts +24 -24
  20. package/src/auth/constants.ts +14 -14
  21. package/src/auth/generate-refresh-token.test.ts +23 -23
  22. package/src/auth/hash-provider.test.ts +55 -55
  23. package/src/auth/jose-token-service.ts +62 -62
  24. package/src/auth/jwt-claims-passthrough.test.ts +87 -87
  25. package/src/auth/login.test.ts +100 -100
  26. package/src/auth/login.ts +78 -78
  27. package/src/auth/refresh.ts +11 -11
  28. package/src/auth/token-service.test.ts +90 -90
  29. package/src/factory.ts +370 -368
  30. package/src/features/automations/__tests__/action-executors.test.ts +255 -255
  31. package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
  32. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
  33. package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
  34. package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
  35. package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
  36. package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
  37. package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
  38. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
  39. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
  40. package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
  41. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
  42. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
  43. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
  44. package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
  45. package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
  46. package/src/features/automations/action-executors/index.ts +37 -37
  47. package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
  48. package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
  49. package/src/features/automations/action-executors/webhook.executor.ts +67 -67
  50. package/src/features/automations/automation-runner.ts +85 -85
  51. package/src/features/automations/automation-runner.utils.ts +47 -47
  52. package/src/features/automations/automations.handler.ts +197 -197
  53. package/src/features/automations/automations.schema.ts +177 -177
  54. package/src/features/automations/context-resolver.ts +152 -152
  55. package/src/features/automations/cron-runner.ts +140 -140
  56. package/src/features/automations/cron-runner.utils.ts +44 -44
  57. package/src/features/automations/filter-translation.ts +46 -46
  58. package/src/features/automations/index.ts +16 -16
  59. package/src/features/automations/template-grammar.ts +245 -245
  60. package/src/features/automations/var-access-resolver.ts +140 -140
  61. package/src/features/automations/when-evaluator.ts +87 -87
  62. package/src/features/automations/when-pushdown.ts +57 -57
  63. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
  64. package/src/features/backrefs/backrefs.handler.ts +168 -168
  65. package/src/features/backrefs/d1-backref.repository.ts +84 -84
  66. package/src/features/backrefs/index.ts +5 -5
  67. package/src/features/content/constants.ts +16 -16
  68. package/src/features/content/handlers/bulk.handler.ts +185 -185
  69. package/src/features/content/handlers/create.ts +129 -129
  70. package/src/features/content/handlers/delete.ts +62 -62
  71. package/src/features/content/handlers/facets.ts +49 -49
  72. package/src/features/content/handlers/get.ts +120 -120
  73. package/src/features/content/handlers/helpers.test.ts +180 -180
  74. package/src/features/content/handlers/helpers.ts +93 -93
  75. package/src/features/content/handlers/list.ts +174 -174
  76. package/src/features/content/handlers/update.ts +174 -174
  77. package/src/features/content/index.ts +26 -26
  78. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  79. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  80. package/src/features/dashboard-layout/index.ts +5 -0
  81. package/src/features/draft/draft.handler.ts +195 -195
  82. package/src/features/draft/draft.middleware.ts +66 -66
  83. package/src/features/draft/index.ts +5 -5
  84. package/src/features/email/email.provider.ts +42 -42
  85. package/src/features/email/email.service.ts +98 -98
  86. package/src/features/email/email.types.ts +119 -119
  87. package/src/features/email/index.ts +33 -33
  88. package/src/features/email/providers/resend.ts +67 -67
  89. package/src/features/email/providers/smtp.ts +55 -55
  90. package/src/features/email/templates/automation-mail.ts +19 -19
  91. package/src/features/email/templates/password-changed.ts +63 -63
  92. package/src/features/email/templates/password-reset.ts +68 -68
  93. package/src/features/email/templates/shell.ts +96 -96
  94. package/src/features/notifications/index.ts +5 -5
  95. package/src/features/notifications/notifications.handler.ts +105 -105
  96. package/src/features/password-reset/index.ts +19 -19
  97. package/src/features/password-reset/request.ts +92 -92
  98. package/src/features/password-reset/reset.ts +102 -102
  99. package/src/features/rotate-field/index.ts +5 -5
  100. package/src/features/rotate-field/rotate-field.handler.ts +132 -132
  101. package/src/features/rotate-field/rotate-field.schema.ts +17 -17
  102. package/src/features/schema/schema.handler.ts +131 -131
  103. package/src/features/seeds/index.ts +5 -5
  104. package/src/features/seeds/seeds.handler.test.ts +1199 -632
  105. package/src/features/seeds/seeds.handler.ts +558 -693
  106. package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
  107. package/src/features/settings/settings.handler.ts +392 -392
  108. package/src/features/setup/index.ts +288 -204
  109. package/src/features/stats/index.ts +5 -5
  110. package/src/features/stats/stats.handler.ts +420 -420
  111. package/src/features/webhooks/index.ts +63 -63
  112. package/src/index.ts +65 -65
  113. package/src/media-utils.ts +82 -82
  114. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  115. package/src/middleware/auth-providers.middleware.ts +45 -45
  116. package/src/middleware/observability.middleware.test.ts +93 -0
  117. package/src/middleware/observability.middleware.ts +70 -70
  118. package/src/middleware/rate-limit.middleware.ts +45 -45
  119. package/src/middleware/repository.middleware.ts +117 -114
  120. package/src/middleware/seed-registry.middleware.test.ts +97 -97
  121. package/src/middleware/seed-registry.middleware.ts +21 -21
  122. package/src/middleware/storage.middleware.ts +25 -25
  123. package/src/middleware.ts +45 -45
  124. package/src/public/access-policy.ts +27 -27
  125. package/src/public/api-key-middleware.ts +57 -57
  126. package/src/public/cache-utils.ts +38 -38
  127. package/src/public/entry-projection.ts +46 -46
  128. package/src/public/idempotency.ts +23 -23
  129. package/src/public/index.ts +16 -16
  130. package/src/public/problem-details.ts +124 -124
  131. package/src/public/public-add.ts +114 -114
  132. package/src/public/public-edit.ts +163 -163
  133. package/src/public/public-errors.ts +19 -19
  134. package/src/public/public-read.ts +63 -63
  135. package/src/public/public-routes.ts +88 -88
  136. package/src/public/query-builder.test.ts +224 -224
  137. package/src/public/query-builder.ts +156 -156
  138. package/src/public/rate-limit-middleware.ts +34 -34
  139. package/src/public/read-list.ts +54 -54
  140. package/src/public/read-single.ts +48 -48
  141. package/src/public/response-builder.ts +30 -30
  142. package/src/public/sanitize.ts +69 -69
  143. package/src/public/slug-utils.ts +18 -18
  144. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
  145. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
  146. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
  147. package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
  148. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
  149. package/src/rate-limit/no-op-rate-limiter.ts +11 -11
  150. package/src/search-utils.test.ts +211 -211
  151. package/src/search-utils.ts +213 -213
  152. package/src/search.ts +65 -65
  153. package/src/shared/apply-policies.test.ts +81 -81
  154. package/src/shared/apply-policies.ts +67 -67
  155. package/src/shared/automations.repository.d1.ts +150 -150
  156. package/src/shared/background-notification-service.test.ts +62 -62
  157. package/src/shared/background-notification-service.ts +52 -52
  158. package/src/shared/base.repository.d1.ts +32 -32
  159. package/src/shared/content-utils.test.ts +165 -165
  160. package/src/shared/content-utils.ts +86 -86
  161. package/src/shared/content.repository.d1.test.ts +507 -458
  162. package/src/shared/content.repository.d1.ts +851 -851
  163. package/src/shared/d1-activity-log.repository.test.ts +140 -140
  164. package/src/shared/d1-activity-log.repository.ts +105 -105
  165. package/src/shared/d1-activity-logger.test.ts +86 -86
  166. package/src/shared/d1-activity-logger.ts +67 -67
  167. package/src/shared/d1-analytics.repository.test.ts +78 -78
  168. package/src/shared/d1-analytics.repository.ts +85 -85
  169. package/src/shared/d1-content-scan.repository.test.ts +80 -80
  170. package/src/shared/d1-content-scan.repository.ts +37 -37
  171. package/src/shared/d1-notification.repository.test.ts +128 -128
  172. package/src/shared/d1-notification.repository.ts +118 -118
  173. package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
  174. package/src/shared/d1-password-reset-token.repository.ts +56 -56
  175. package/src/shared/d1-search.repository.test.ts +87 -87
  176. package/src/shared/d1-search.repository.ts +88 -88
  177. package/src/shared/d1-session.repository.test.ts +125 -125
  178. package/src/shared/d1-session.repository.ts +102 -102
  179. package/src/shared/d1-setup-checklist.repository.ts +36 -36
  180. package/src/shared/d1-user.repository.test.ts +151 -151
  181. package/src/shared/d1-user.repository.ts +119 -119
  182. package/src/shared/d1-widget.repository.test.ts +267 -221
  183. package/src/shared/d1-widget.repository.ts +385 -341
  184. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  185. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  186. package/src/shared/demo-data-sql.ts +216 -54
  187. package/src/shared/demo-data.repository.d1.ts +20 -20
  188. package/src/shared/execution-context-scheduler.ts +13 -13
  189. package/src/shared/fixed-clock.ts +25 -25
  190. package/src/shared/fts-sync.ts +8 -8
  191. package/src/shared/idempotency.repository.d1.test.ts +83 -83
  192. package/src/shared/idempotency.repository.d1.ts +60 -60
  193. package/src/shared/in-memory-activity-logger.ts +19 -19
  194. package/src/shared/in-memory-notification-service.ts +19 -19
  195. package/src/shared/in-memory-seed.repository.ts +58 -51
  196. package/src/shared/media.repository.d1.test.ts +107 -107
  197. package/src/shared/media.repository.d1.ts +68 -68
  198. package/src/shared/qstash-notification-service.test.ts +67 -67
  199. package/src/shared/qstash-notification-service.ts +55 -55
  200. package/src/shared/query-utils.ts +141 -141
  201. package/src/shared/request-utils.ts +26 -26
  202. package/src/shared/schema-mutator.d1.test.ts +142 -0
  203. package/src/shared/schema-mutator.d1.ts +64 -64
  204. package/src/shared/seed-layout.repository.d1.test.ts +114 -114
  205. package/src/shared/seed-layout.repository.d1.ts +62 -62
  206. package/src/shared/seed-registry-cache.test.ts +68 -68
  207. package/src/shared/seed-registry-cache.ts +49 -49
  208. package/src/shared/seed.repository.d1.test.ts +143 -143
  209. package/src/shared/seed.repository.d1.ts +98 -98
  210. package/src/shared/sequential-id-generator.ts +33 -33
  211. package/src/shared/site-settings.repository.d1.ts +53 -53
  212. package/src/shared/storage/factory.ts +41 -41
  213. package/src/shared/storage/s3-bucket.ts +195 -195
  214. package/src/shared/storage-utils.ts +40 -40
  215. package/src/shared/system-stats.repository.d1.test.ts +58 -58
  216. package/src/shared/system-stats.repository.d1.ts +48 -48
  217. package/src/types.ts +90 -87
  218. package/src/upload.ts +198 -198
  219. package/src/widget.test.ts +85 -0
  220. package/src/widget.ts +251 -212
  221. package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
  222. package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
@@ -1,851 +1,851 @@
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
- }
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
+ }