@beechcms/api 0.4.3 → 0.6.0-preview.1

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 (207) hide show
  1. package/assets/dashboard/Searching.svg +1 -0
  2. package/assets/dashboard/assets/index-BSjk0Sx9.js +634 -0
  3. package/assets/dashboard/assets/index-kRCYVN2B.css +1 -0
  4. package/assets/dashboard/index.html +2 -2
  5. package/assets/dashboard/noResult.svg +43 -0
  6. package/assets/dashboard/working.svg +1 -0
  7. package/migrations/0000_v040_base.sql +27 -0
  8. package/migrations/0030_test_seeds.sql +125 -0
  9. package/package.json +14 -9
  10. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +4 -0
  11. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +4 -0
  12. package/src/auth/bcrypt-hash-provider.ts +6 -2
  13. package/src/auth/constants.ts +4 -0
  14. package/src/auth/generate-refresh-token.test.ts +8 -4
  15. package/src/auth/hash-provider.test.ts +14 -5
  16. package/src/auth/jose-token-service.ts +7 -0
  17. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  18. package/src/auth/login.test.ts +9 -1
  19. package/src/auth/login.ts +5 -1
  20. package/src/auth/refresh.ts +7 -1
  21. package/src/auth/token-service.test.ts +9 -1
  22. package/src/factory.ts +37 -16
  23. package/src/features/automations/__tests__/action-executors.test.ts +59 -72
  24. package/src/features/automations/__tests__/automation-runner.test.ts +4 -0
  25. package/src/features/automations/__tests__/automation-runner.utils.test.ts +4 -0
  26. package/src/features/automations/__tests__/automations.handler.test.ts +7 -3
  27. package/src/features/automations/__tests__/automations.repository.test.ts +4 -0
  28. package/src/features/automations/__tests__/automations.schema.test.ts +92 -2
  29. package/src/features/automations/__tests__/context-resolver.test.ts +4 -0
  30. package/src/features/automations/__tests__/cron-runner.test.ts +11 -7
  31. package/src/features/automations/__tests__/cron-runner.utils.test.ts +4 -0
  32. package/src/features/automations/__tests__/set-variable.executor.test.ts +77 -0
  33. package/src/features/automations/__tests__/template-grammar.test.ts +4 -0
  34. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  35. package/src/features/automations/__tests__/when-evaluator.test.ts +4 -0
  36. package/src/features/automations/__tests__/when-pushdown.test.ts +5 -1
  37. package/src/features/automations/action-executors/create-entry.executor.ts +4 -0
  38. package/src/features/automations/action-executors/edit-field.executor.ts +4 -0
  39. package/src/features/automations/action-executors/index.ts +5 -1
  40. package/src/features/automations/action-executors/send-mail.executor.ts +13 -2
  41. package/src/features/automations/action-executors/set-variable.executor.ts +15 -2
  42. package/src/features/automations/action-executors/webhook.executor.ts +55 -13
  43. package/src/features/automations/automation-runner.ts +4 -0
  44. package/src/features/automations/automation-runner.utils.ts +4 -0
  45. package/src/features/automations/automations.handler.ts +4 -0
  46. package/src/features/automations/automations.schema.ts +19 -2
  47. package/src/features/automations/context-resolver.ts +4 -0
  48. package/src/features/automations/cron-runner.ts +4 -0
  49. package/src/features/automations/cron-runner.utils.ts +4 -0
  50. package/src/features/automations/filter-translation.ts +4 -0
  51. package/src/features/automations/index.ts +4 -0
  52. package/src/features/automations/template-grammar.ts +4 -0
  53. package/src/features/automations/var-access-resolver.ts +4 -0
  54. package/src/features/automations/when-evaluator.ts +4 -0
  55. package/src/features/automations/when-pushdown.ts +4 -0
  56. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  57. package/src/features/backrefs/backrefs.handler.ts +168 -0
  58. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  59. package/src/features/backrefs/index.ts +5 -0
  60. package/src/features/content/constants.ts +6 -0
  61. package/src/features/content/handlers/bulk.handler.ts +185 -0
  62. package/src/features/content/handlers/create.ts +19 -55
  63. package/src/features/content/handlers/delete.ts +12 -37
  64. package/src/features/content/handlers/facets.ts +4 -0
  65. package/src/features/content/handlers/get.ts +4 -0
  66. package/src/features/content/handlers/helpers.test.ts +180 -0
  67. package/src/features/content/handlers/helpers.ts +93 -0
  68. package/src/features/content/handlers/list.ts +105 -19
  69. package/src/features/content/handlers/update.ts +19 -64
  70. package/src/features/content/index.ts +6 -0
  71. package/src/features/draft/draft.handler.ts +33 -8
  72. package/src/features/draft/draft.middleware.ts +4 -0
  73. package/src/features/draft/index.ts +4 -0
  74. package/src/features/email/email.provider.ts +4 -0
  75. package/src/features/email/email.service.ts +50 -45
  76. package/src/features/email/email.types.ts +12 -1
  77. package/src/features/email/index.ts +4 -0
  78. package/src/features/email/providers/resend.ts +4 -0
  79. package/src/features/email/providers/smtp.ts +55 -0
  80. package/src/features/email/templates/automation-mail.ts +4 -0
  81. package/src/features/email/templates/password-changed.ts +4 -0
  82. package/src/features/email/templates/password-reset.ts +4 -0
  83. package/src/features/email/templates/shell.ts +4 -0
  84. package/src/features/notifications/index.ts +4 -0
  85. package/src/features/notifications/notifications.handler.ts +4 -0
  86. package/src/features/password-reset/index.ts +5 -1
  87. package/src/features/password-reset/request.ts +12 -2
  88. package/src/features/password-reset/reset.ts +12 -2
  89. package/src/features/rotate-field/index.ts +4 -0
  90. package/src/features/rotate-field/rotate-field.handler.ts +4 -0
  91. package/src/features/rotate-field/rotate-field.schema.ts +4 -0
  92. package/src/features/schema/schema.handler.ts +118 -3
  93. package/src/features/seeds/index.ts +5 -0
  94. package/src/features/seeds/seeds.handler.test.ts +632 -0
  95. package/src/features/seeds/seeds.handler.ts +693 -0
  96. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  97. package/src/features/settings/settings.handler.ts +98 -8
  98. package/src/features/setup/index.ts +124 -11
  99. package/src/features/stats/index.ts +4 -0
  100. package/src/features/stats/stats.handler.ts +11 -21
  101. package/src/features/webhooks/index.ts +63 -0
  102. package/src/index.ts +28 -19
  103. package/src/media-utils.ts +4 -0
  104. package/src/middleware/auth-providers.middleware.ts +13 -0
  105. package/src/middleware/observability.middleware.ts +21 -3
  106. package/src/middleware/rate-limit.middleware.ts +4 -0
  107. package/src/middleware/repository.middleware.ts +25 -2
  108. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  109. package/src/middleware/seed-registry.middleware.ts +21 -0
  110. package/src/middleware/storage.middleware.ts +4 -0
  111. package/src/middleware.ts +5 -7
  112. package/src/public/access-policy.ts +4 -0
  113. package/src/public/api-key-middleware.ts +4 -0
  114. package/src/public/cache-utils.ts +38 -34
  115. package/src/public/entry-projection.ts +46 -42
  116. package/src/public/idempotency.ts +23 -19
  117. package/src/public/index.ts +4 -0
  118. package/src/public/problem-details.ts +71 -0
  119. package/src/public/public-add.ts +4 -0
  120. package/src/public/public-edit.ts +4 -0
  121. package/src/public/public-errors.ts +4 -0
  122. package/src/public/public-read.ts +4 -0
  123. package/src/public/public-routes.ts +4 -0
  124. package/src/public/query-builder.test.ts +4 -0
  125. package/src/public/query-builder.ts +4 -0
  126. package/src/public/rate-limit-middleware.ts +4 -0
  127. package/src/public/read-list.ts +54 -50
  128. package/src/public/read-single.ts +48 -44
  129. package/src/public/response-builder.ts +4 -0
  130. package/src/public/sanitize.ts +4 -0
  131. package/src/public/slug-utils.ts +4 -0
  132. package/src/rate-limit/cloudflare-rate-limiter.test.ts +4 -0
  133. package/src/rate-limit/cloudflare-rate-limiter.ts +4 -0
  134. package/src/rate-limit/in-memory-rate-limiter.test.ts +4 -0
  135. package/src/rate-limit/in-memory-rate-limiter.ts +4 -0
  136. package/src/rate-limit/no-op-rate-limiter.test.ts +4 -0
  137. package/src/rate-limit/no-op-rate-limiter.ts +4 -0
  138. package/src/search-utils.test.ts +4 -0
  139. package/src/search-utils.ts +5 -1
  140. package/src/search.ts +5 -1
  141. package/src/shared/apply-policies.test.ts +4 -0
  142. package/src/shared/apply-policies.ts +4 -0
  143. package/src/shared/automations.repository.d1.ts +4 -0
  144. package/src/shared/background-notification-service.test.ts +4 -0
  145. package/src/shared/background-notification-service.ts +4 -0
  146. package/src/shared/base.repository.d1.ts +4 -0
  147. package/src/shared/content-utils.test.ts +4 -0
  148. package/src/shared/content-utils.ts +4 -0
  149. package/src/shared/content.repository.d1.test.ts +147 -1
  150. package/src/shared/content.repository.d1.ts +535 -66
  151. package/src/shared/d1-activity-log.repository.test.ts +4 -0
  152. package/src/shared/d1-activity-log.repository.ts +4 -0
  153. package/src/shared/d1-activity-logger.test.ts +4 -0
  154. package/src/shared/d1-activity-logger.ts +4 -0
  155. package/src/shared/d1-analytics.repository.test.ts +4 -0
  156. package/src/shared/d1-analytics.repository.ts +4 -0
  157. package/src/shared/d1-content-scan.repository.test.ts +9 -5
  158. package/src/shared/d1-content-scan.repository.ts +15 -7
  159. package/src/shared/d1-notification.repository.test.ts +4 -0
  160. package/src/shared/d1-notification.repository.ts +4 -0
  161. package/src/shared/d1-password-reset-token.repository.test.ts +4 -0
  162. package/src/shared/d1-password-reset-token.repository.ts +4 -0
  163. package/src/shared/d1-search.repository.test.ts +4 -0
  164. package/src/shared/d1-search.repository.ts +4 -0
  165. package/src/shared/d1-session.repository.test.ts +4 -0
  166. package/src/shared/d1-session.repository.ts +4 -0
  167. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  168. package/src/shared/d1-user.repository.test.ts +8 -4
  169. package/src/shared/d1-user.repository.ts +15 -5
  170. package/src/shared/d1-widget.repository.test.ts +4 -0
  171. package/src/shared/d1-widget.repository.ts +4 -0
  172. package/src/shared/demo-data-sql.ts +54 -0
  173. package/src/shared/demo-data.repository.d1.ts +20 -0
  174. package/src/shared/execution-context-scheduler.ts +4 -0
  175. package/src/shared/fixed-clock.ts +4 -0
  176. package/src/shared/fts-sync.ts +4 -0
  177. package/src/shared/idempotency.repository.d1.test.ts +4 -0
  178. package/src/shared/idempotency.repository.d1.ts +4 -0
  179. package/src/shared/in-memory-activity-logger.ts +4 -0
  180. package/src/shared/in-memory-notification-service.ts +4 -0
  181. package/src/shared/in-memory-seed.repository.ts +51 -0
  182. package/src/shared/media.repository.d1.test.ts +4 -0
  183. package/src/shared/media.repository.d1.ts +4 -0
  184. package/src/shared/qstash-notification-service.test.ts +67 -0
  185. package/src/shared/qstash-notification-service.ts +55 -0
  186. package/src/shared/query-utils.ts +4 -0
  187. package/src/shared/request-utils.ts +4 -0
  188. package/src/shared/schema-mutator.d1.ts +64 -0
  189. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  190. package/src/shared/seed-layout.repository.d1.ts +62 -0
  191. package/src/shared/seed-registry-cache.test.ts +68 -0
  192. package/src/shared/seed-registry-cache.ts +49 -0
  193. package/src/shared/seed.repository.d1.test.ts +143 -0
  194. package/src/shared/seed.repository.d1.ts +98 -0
  195. package/src/shared/sequential-id-generator.ts +11 -0
  196. package/src/shared/site-settings.repository.d1.ts +53 -0
  197. package/src/shared/storage/factory.ts +16 -15
  198. package/src/shared/storage/s3-bucket.ts +34 -2
  199. package/src/shared/storage-utils.ts +4 -0
  200. package/src/shared/system-stats.repository.d1.test.ts +4 -0
  201. package/src/shared/system-stats.repository.d1.ts +5 -1
  202. package/src/types.ts +23 -3
  203. package/src/upload.ts +134 -123
  204. package/src/widget.ts +6 -2
  205. package/assets/dashboard/assets/index-BKWnlvnV.css +0 -1
  206. package/assets/dashboard/assets/index-BMkd1Irh.js +0 -629
  207. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -1,20 +1,47 @@
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
+
1
5
  /// <reference types="@cloudflare/workers-types" />
2
6
  import {
3
7
  ContentRepository,
4
8
  EntryNotFoundError,
5
9
  RepositoryError,
6
10
  SlugConflictError,
7
- Seed,
8
- SelectOptions,
11
+ RelationTargetNotFoundError,
12
+ type BulkFieldUpdate,
13
+ type DraftSummary,
14
+ type Seed,
15
+ type Branch,
16
+ type SelectOptions,
9
17
  buildSelectQuery,
10
18
  deserializeFromDb,
11
19
  serializeForDb,
12
20
  } from '@beechcms/core'
13
21
  import { BaseD1Repository } from './base.repository.d1'
14
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
+
15
41
  export class D1ContentRepository extends BaseD1Repository implements ContentRepository {
16
42
  /**
17
- * Helper to deserialize a DB row using the Seed's branch definitions.
43
+ * Deserializes a DB row using the Seed's branch definitions.
44
+ * Skips multi-relation branches — their values come from junction tables.
18
45
  */
19
46
  private rowToData(seed: Seed, row: any): Record<string, any> {
20
47
  const data: Record<string, any> = {
@@ -26,6 +53,8 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
26
53
  }
27
54
 
28
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
29
58
  if (Object.hasOwn(row, branch.alias)) {
30
59
  data[branch.alias] = deserializeFromDb(branch, row[branch.alias])
31
60
  }
@@ -34,31 +63,92 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
34
63
  return data
35
64
  }
36
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
+
37
128
  async findMany(
38
129
  seed: Seed,
39
130
  options: SelectOptions
40
131
  ): Promise<{ items: Record<string, any>[]; total: number }> {
41
132
  try {
42
133
  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.
134
+
47
135
  const countSql = sql
48
136
  .replace(/SELECT .* FROM/, 'SELECT COUNT(*) as total FROM')
49
137
  .replace(/ ORDER BY .*$/, '')
50
138
  .replace(/ LIMIT \? OFFSET \?$/, '')
51
-
139
+
52
140
  const countBindings = bindings.slice(0, bindings.length - (options.pagination ? 2 : 0))
53
141
 
54
142
  const [batchResults, totalCountResult] = await this.database.batch([
55
143
  this.database.prepare(sql).bind(...bindings),
56
- this.database.prepare(countSql).bind(...countBindings)
144
+ this.database.prepare(countSql).bind(...countBindings),
57
145
  ])
58
146
 
59
- const contentEntries = (batchResults.results || []).map((entryRow) => this.rowToData(seed, entryRow))
147
+ const contentEntries = (batchResults.results || []).map(row => this.rowToData(seed, row))
60
148
  const totalEntriesCount = (totalCountResult.results?.[0] as any)?.total || 0
61
149
 
150
+ await this.attachMultiRelationsMany(seed, contentEntries)
151
+
62
152
  return { items: contentEntries, total: totalEntriesCount }
63
153
  } catch (error) {
64
154
  throw this.mapError(error, `findMany(${seed.slug})`)
@@ -77,7 +167,9 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
77
167
  throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
78
168
  }
79
169
 
80
- return this.rowToData(seed, entryRow)
170
+ const data = this.rowToData(seed, entryRow)
171
+ await this.attachMultiRelations(seed, id, data)
172
+ return data
81
173
  } catch (error) {
82
174
  if (error instanceof EntryNotFoundError) throw error
83
175
  throw this.mapError(error, `findById(${seed.slug}, ${id})`)
@@ -96,7 +188,9 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
96
188
  throw new EntryNotFoundError(`Entry with slug "${slug}" not found in ${seed.slug}`)
97
189
  }
98
190
 
99
- return this.rowToData(seed, entryRow)
191
+ const data = this.rowToData(seed, entryRow)
192
+ await this.attachMultiRelations(seed, entryRow.id as string, data)
193
+ return data
100
194
  } catch (error) {
101
195
  if (error instanceof EntryNotFoundError) throw error
102
196
  throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`)
@@ -109,23 +203,20 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
109
203
  }> {
110
204
  try {
111
205
  const tableName = this.getTableName(seed.slug)
112
-
113
- // Retrieve count per status
206
+
114
207
  const statusResults = await this.database
115
208
  .prepare(`SELECT status, COUNT(*) as count FROM ${tableName} GROUP BY status`)
116
209
  .all()
117
-
210
+
118
211
  const statusesCount: Record<string, number> = {}
119
212
  for (const statusRow of statusResults.results || []) {
120
213
  statusesCount[statusRow.status as string] = statusRow.count as number
121
214
  }
122
215
 
123
- // Collect unique tags for branches of type 'tags'
124
216
  const tagsByColumn: Record<string, string[]> = {}
125
217
  const tagBranches = seed.branches.filter(branch => branch.type === 'tags')
126
-
218
+
127
219
  for (const branch of tagBranches) {
128
- // Use SQLite json_each to expand tags stored as JSON arrays
129
220
  const tagResults = await this.database
130
221
  .prepare(`SELECT DISTINCT value FROM ${tableName}, json_each(${tableName}.${branch.alias}) WHERE value IS NOT NULL`)
131
222
  .all()
@@ -143,12 +234,12 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
143
234
  const tableName = this.getTableName(seed.slug)
144
235
  let sql = `SELECT 1 FROM ${tableName} WHERE slug = ?`
145
236
  const queryBindings: any[] = [slug]
146
-
237
+
147
238
  if (excludeId) {
148
239
  sql += ` AND id != ?`
149
240
  queryBindings.push(excludeId)
150
241
  }
151
-
242
+
152
243
  const entryExistsResult = await this.database.prepare(sql).bind(...queryBindings).first()
153
244
  return entryExistsResult !== null
154
245
  } catch (error) {
@@ -156,6 +247,231 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
156
247
  }
157
248
  }
158
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
+
159
475
  async create(
160
476
  seed: Seed,
161
477
  id: string,
@@ -168,21 +484,21 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
168
484
  throw new SlugConflictError(`Slug "${slug}" already exists for ${seed.slug}`)
169
485
  }
170
486
 
171
- const tableName = this.getTableName(seed.slug)
172
- const columnNames = ['id', 'slug', 'status']
173
- const placeholders = ['?', '?', '?']
174
- const queryBindings: any[] = [id, slug, status]
487
+ const batchStmts: D1PreparedStatement[] = [
488
+ this.buildCreateMainStmt(seed, id, slug, status, data),
489
+ ]
175
490
 
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
- }
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))
182
495
  }
183
496
 
184
- const sql = `INSERT INTO ${tableName} (${columnNames.join(', ')}) VALUES (${placeholders.join(', ')})`
185
- await this.database.prepare(sql).bind(...queryBindings).run()
497
+ if (batchStmts.length === 1) {
498
+ await batchStmts[0].run()
499
+ } else {
500
+ await this.database.batch(batchStmts)
501
+ }
186
502
  } catch (error) {
187
503
  if (error instanceof SlugConflictError) throw error
188
504
  throw this.mapError(error, `create(${seed.slug})`)
@@ -196,32 +512,30 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
196
512
  status?: string
197
513
  ): Promise<void> {
198
514
  try {
199
- const tableName = this.getTableName(seed.slug)
200
- const updateClauses: string[] = []
201
- const queryBindings: any[] = []
515
+ const { stmt, junctionUpdates } = this.buildUpdateMainStmt(seed, id, data, status)
516
+
517
+ if (!stmt && junctionUpdates.length === 0) return
202
518
 
203
- if (status) {
204
- updateClauses.push('status = ?')
205
- queryBindings.push(status)
206
- }
519
+ const batchStmts: D1PreparedStatement[] = []
520
+ if (stmt) batchStmts.push(stmt)
207
521
 
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
- }
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))
213
527
  }
214
528
 
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}`)
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
+ }
225
539
  }
226
540
  } catch (error) {
227
541
  if (error instanceof EntryNotFoundError) throw error
@@ -232,8 +546,7 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
232
546
  async delete(seed: Seed, id: string): Promise<{ row: Record<string, any> }> {
233
547
  try {
234
548
  const tableName = this.getTableName(seed.slug)
235
-
236
- // Retrieve the row before deletion to allow for potential cleanup (e.g., media files in R2)
549
+
237
550
  const entryRow = await this.database
238
551
  .prepare(`SELECT * FROM ${tableName} WHERE id = ?`)
239
552
  .bind(id)
@@ -259,12 +572,16 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
259
572
  }
260
573
 
261
574
  const draftTableName = this.getTableName(seed.slug, true)
575
+ const mRelBranches = multiRelBranches(seed)
576
+ const mRelAliases = new Set(mRelBranches.map(b => b.alias))
577
+
262
578
  const columnNames = ['entry_id']
263
579
  const placeholders = ['?']
264
580
  const queryBindings: any[] = [entryId]
265
581
  const updateClauses: string[] = []
266
582
 
267
583
  for (const branch of seed.branches) {
584
+ if (mRelAliases.has(branch.alias)) continue
268
585
  if (Object.hasOwn(data, branch.alias)) {
269
586
  const serializedValue = serializeForDb(branch, data[branch.alias])
270
587
  columnNames.push(branch.alias)
@@ -277,11 +594,38 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
277
594
  updateClauses.push('updated_at = (unixepoch())')
278
595
 
279
596
  const sql = `
280
- INSERT INTO ${draftTableName} (${columnNames.join(', ')})
597
+ INSERT INTO ${draftTableName} (${columnNames.join(', ')})
281
598
  VALUES (${placeholders.join(', ')})
282
599
  ON CONFLICT(entry_id) DO UPDATE SET ${updateClauses.join(', ')}
283
600
  `
284
- await this.database.prepare(sql).bind(...queryBindings).run()
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)
285
629
  } catch (error) {
286
630
  throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`)
287
631
  }
@@ -299,14 +643,33 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
299
643
 
300
644
  if (!draftRow) return null
301
645
 
302
- // Filter out nulls from draft row (only include explicitly provided fields)
646
+ const mRelBranches = multiRelBranches(seed)
647
+ const mRelAliases = new Set(mRelBranches.map(b => b.alias))
648
+
303
649
  const draftData: Record<string, any> = {}
304
650
  for (const branch of seed.branches) {
651
+ if (mRelAliases.has(branch.alias)) continue
305
652
  if (draftRow[branch.alias] !== null) {
306
653
  draftData[branch.alias] = deserializeFromDb(branch, draftRow[branch.alias])
307
654
  }
308
655
  }
309
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
+
310
673
  return draftData
311
674
  } catch (error) {
312
675
  throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`)
@@ -337,17 +700,20 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
337
700
  const draftRow = await this.database
338
701
  .prepare(`SELECT * FROM ${draftTableName} WHERE entry_id = ?`)
339
702
  .bind(entryId)
340
- .first()
703
+ .first<Record<string, unknown>>()
341
704
 
342
705
  if (!draftRow) {
343
706
  throw new EntryNotFoundError(`No draft found for ${entryId} in ${seed.slug}`)
344
707
  }
345
708
 
346
- // Build UPDATE clause for the live table using data from the draft
709
+ const { mRelBranches, mRelDraftIds } = await this.validatePublishDraftRelations(seed, draftRow, entryId)
710
+
711
+ const mRelAliases = new Set(mRelBranches.map(b => b.alias))
347
712
  const updateClauses: string[] = []
348
713
  const queryBindings: any[] = []
349
714
 
350
715
  for (const branch of seed.branches) {
716
+ if (mRelAliases.has(branch.alias)) continue
351
717
  if (draftRow[branch.alias] !== null) {
352
718
  updateClauses.push(`${branch.alias} = ?`)
353
719
  queryBindings.push(draftRow[branch.alias])
@@ -355,21 +721,54 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
355
721
  }
356
722
 
357
723
  updateClauses.push('updated_at = (unixepoch())')
358
-
359
724
  const updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(', ')} WHERE id = ?`
360
725
  queryBindings.push(entryId)
361
726
 
362
- // Execute batch to atomically update live table and delete draft
363
- await this.database.batch([
727
+ const batchStmts: D1PreparedStatement[] = [
364
728
  this.database.prepare(updateSql).bind(...queryBindings),
365
- this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId)
366
- ])
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)
367
745
  } catch (error) {
368
746
  if (error instanceof EntryNotFoundError) throw error
747
+ if (error instanceof RelationTargetNotFoundError) throw error
369
748
  throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`)
370
749
  }
371
750
  }
372
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
+
373
772
  async deleteDraft(seed: Seed, entryId: string): Promise<void> {
374
773
  try {
375
774
  if (!seed.allowDrafts) return
@@ -379,4 +778,74 @@ export class D1ContentRepository extends BaseD1Repository implements ContentRepo
379
778
  throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`)
380
779
  }
381
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
+ }
382
851
  }