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

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 (85) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  4. package/assets/dashboard/assets/{index-CTSuGxlX.js → index-FQ6JhvRH.js} +99 -99
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/constants.ts +10 -10
  12. package/src/auth/login.ts +91 -91
  13. package/src/auth/refresh.ts +127 -127
  14. package/src/factory.ts +347 -325
  15. package/src/features/content/constants.ts +10 -10
  16. package/src/features/content/handlers/create.ts +153 -163
  17. package/src/features/content/handlers/delete.ts +76 -85
  18. package/src/features/content/handlers/facets.ts +45 -45
  19. package/src/features/content/handlers/get.ts +116 -116
  20. package/src/features/content/handlers/list.ts +88 -88
  21. package/src/features/content/handlers/update.ts +207 -216
  22. package/src/features/content/index.ts +20 -20
  23. package/src/features/draft/draft.handler.ts +272 -272
  24. package/src/features/draft/index.ts +1 -1
  25. package/src/features/email/email.provider.ts +38 -38
  26. package/src/features/email/email.service.ts +80 -80
  27. package/src/features/email/email.types.ts +98 -98
  28. package/src/features/email/index.ts +28 -28
  29. package/src/features/email/providers/resend.ts +63 -63
  30. package/src/features/email/templates/password-changed.ts +59 -59
  31. package/src/features/email/templates/password-reset.ts +64 -64
  32. package/src/features/email/templates/shell.ts +92 -93
  33. package/src/features/notifications/index.ts +1 -1
  34. package/src/features/notifications/notifications.handler.ts +130 -88
  35. package/src/features/password-reset/index.ts +15 -15
  36. package/src/features/password-reset/request.ts +106 -88
  37. package/src/features/password-reset/reset.ts +128 -110
  38. package/src/features/rotate-field/index.ts +1 -1
  39. package/src/features/rotate-field/rotate-field.handler.ts +132 -82
  40. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  41. package/src/features/schema/schema.handler.ts +16 -16
  42. package/src/features/settings/settings.handler.ts +414 -267
  43. package/src/features/setup/index.ts +96 -59
  44. package/src/features/stats/index.ts +1 -1
  45. package/src/features/stats/stats.handler.ts +458 -395
  46. package/src/index.ts +24 -24
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -18
  49. package/src/middleware/storage.middleware.ts +21 -0
  50. package/src/middleware.ts +67 -67
  51. package/src/public/access-policy.ts +23 -23
  52. package/src/public/api-key-middleware.ts +53 -53
  53. package/src/public/index.ts +12 -12
  54. package/src/public/problem-details.ts +48 -42
  55. package/src/public/public-add.ts +166 -166
  56. package/src/public/public-edit.ts +158 -158
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -216
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -152
  61. package/src/public/rate-limit-middleware.ts +42 -42
  62. package/src/public/response-builder.ts +26 -26
  63. package/src/public/sanitize.ts +65 -65
  64. package/src/public/slug-utils.ts +14 -14
  65. package/src/search-utils.ts +192 -192
  66. package/src/search.ts +72 -72
  67. package/src/shared/activity-logger.ts +79 -79
  68. package/src/shared/apply-policies.ts +63 -63
  69. package/src/shared/base.repository.d1.ts +28 -28
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -382
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -45
  74. package/src/shared/media.repository.d1.ts +64 -0
  75. package/src/shared/notification-service.ts +56 -56
  76. package/src/shared/query-utils.ts +137 -137
  77. package/src/shared/storage/factory.ts +40 -0
  78. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  79. package/src/shared/storage/s3-bucket.ts +163 -0
  80. package/src/shared/storage-utils.ts +36 -36
  81. package/src/shared/system-stats.repository.d1.ts +44 -0
  82. package/src/types.ts +46 -41
  83. package/src/upload.ts +179 -331
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-ye3325L9.css +0 -1
@@ -1,79 +1,79 @@
1
- import { Context } from 'hono'
2
-
3
- export type ActivityAction = 'create' | 'update' | 'delete' | 'upload'
4
- export type EntityType = 'content' | 'media'
5
-
6
- export interface ActivityLogParams {
7
- action: ActivityAction
8
- entityType: EntityType
9
- entityId: string
10
- entitySlug?: string
11
- details?: Record<string, any>
12
- }
13
-
14
- /**
15
- * Logga un'azione utente asincronamente nel database D1.
16
- * Estrae le info dell'utente dal JWT payload presente nel contesto Hono.
17
- */
18
- export function logActivity(
19
- c: Context<any>,
20
- params: ActivityLogParams
21
- ): void {
22
- const db = c.env.DB as D1Database
23
- const user = c.get('jwtPayload') as { sub: string; email: string; name?: string } | undefined
24
-
25
- if (!db || !user) return
26
-
27
- const { action, entityType, entityId, entitySlug, details } = params
28
- const id = crypto.randomUUID()
29
-
30
- // Usa waitUntil per non bloccare la risposta al client
31
- let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
32
- try {
33
- executionCtx = c.executionCtx
34
- } catch {
35
- // In ambiente di test Hono lancia se non presente
36
- }
37
-
38
- if (executionCtx) {
39
- executionCtx.waitUntil((async () => {
40
- try {
41
- await db.prepare(
42
- `INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
43
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
44
- ).bind(
45
- id,
46
- user.sub,
47
- user.email || 'unknown',
48
- user.name || null,
49
- action,
50
- entityType,
51
- entityId,
52
- entitySlug || null,
53
- details ? JSON.stringify(details) : null
54
- ).run()
55
- } catch (err) {
56
- console.error('Failed to log activity:', err)
57
- }
58
- })())
59
- } else {
60
- try {
61
- db.prepare(
62
- `INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
63
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
64
- ).bind(
65
- id,
66
- user.sub,
67
- user.email || 'unknown',
68
- user.name || null,
69
- action,
70
- entityType,
71
- entityId,
72
- entitySlug || null,
73
- details ? JSON.stringify(details) : null
74
- ).run().catch(err => console.error('Failed to log activity (sync fallback):', err))
75
- } catch (err) {
76
- console.error('Failed to log activity (sync fallback):', err)
77
- }
78
- }
79
- }
1
+ import { Context } from 'hono'
2
+
3
+ export type ActivityAction = 'create' | 'update' | 'delete' | 'upload'
4
+ export type EntityType = 'content' | 'media'
5
+
6
+ export interface ActivityLogParams {
7
+ action: ActivityAction
8
+ entityType: EntityType
9
+ entityId: string
10
+ entitySlug?: string
11
+ details?: Record<string, any>
12
+ }
13
+
14
+ /**
15
+ * Logga un'azione utente asincronamente nel database D1.
16
+ * Estrae le info dell'utente dal JWT payload presente nel contesto Hono.
17
+ */
18
+ export function logActivity(
19
+ c: Context<any>,
20
+ params: ActivityLogParams
21
+ ): void {
22
+ const db = c.env.DB as D1Database
23
+ const user = c.get('jwtPayload') as { sub: string; email: string; name?: string } | undefined
24
+
25
+ if (!db || !user) return
26
+
27
+ const { action, entityType, entityId, entitySlug, details } = params
28
+ const id = crypto.randomUUID()
29
+
30
+ // Usa waitUntil per non bloccare la risposta al client
31
+ let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
32
+ try {
33
+ executionCtx = c.executionCtx
34
+ } catch {
35
+ // In ambiente di test Hono lancia se non presente
36
+ }
37
+
38
+ if (executionCtx) {
39
+ executionCtx.waitUntil((async () => {
40
+ try {
41
+ await db.prepare(
42
+ `INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
43
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
44
+ ).bind(
45
+ id,
46
+ user.sub,
47
+ user.email || 'unknown',
48
+ user.name || null,
49
+ action,
50
+ entityType,
51
+ entityId,
52
+ entitySlug || null,
53
+ details ? JSON.stringify(details) : null
54
+ ).run()
55
+ } catch (err) {
56
+ console.error('Failed to log activity:', err)
57
+ }
58
+ })())
59
+ } else {
60
+ try {
61
+ db.prepare(
62
+ `INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
63
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
64
+ ).bind(
65
+ id,
66
+ user.sub,
67
+ user.email || 'unknown',
68
+ user.name || null,
69
+ action,
70
+ entityType,
71
+ entityId,
72
+ entitySlug || null,
73
+ details ? JSON.stringify(details) : null
74
+ ).run().catch(err => console.error('Failed to log activity (sync fallback):', err))
75
+ } catch (err) {
76
+ console.error('Failed to log activity (sync fallback):', err)
77
+ }
78
+ }
79
+ }
@@ -1,63 +1,63 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { resolvePolicies, sha256hex } from '@beechcms/core'
3
- import type { Seed } from '@beechcms/core'
4
-
5
- class PrivacyPolicyError extends Error {
6
- readonly status = 501 as const
7
- constructor(message: string) {
8
- super(message)
9
- this.name = 'PrivacyPolicyError'
10
- }
11
- }
12
-
13
- export { PrivacyPolicyError }
14
-
15
- /** Applica la privacy policy ai campi del payload prima della scrittura su DB. */
16
- export async function applyPrivacy(
17
- data: Record<string, unknown>,
18
- seed: Seed,
19
- ): Promise<Record<string, unknown>> {
20
- const result: Record<string, unknown> = {}
21
- for (const [alias, value] of Object.entries(data)) {
22
- const branch = seed.branches.find((b) => b.alias === alias)
23
- if (!branch) {
24
- result[alias] = value
25
- continue
26
- }
27
- const { privacy } = resolvePolicies(branch)
28
- if (privacy === 'encrypt') {
29
- throw new PrivacyPolicyError(
30
- `Field '${alias}' uses 'encrypt' privacy which is not yet implemented.`,
31
- )
32
- }
33
- if (privacy === 'hash' && value != null) {
34
- result[alias] = await sha256hex(String(value))
35
- } else {
36
- result[alias] = value
37
- }
38
- }
39
- return result
40
- }
41
-
42
- /** Applica la visibility policy ai campi del payload in uscita verso il client. */
43
- export function applyVisibility(
44
- data: Record<string, unknown>,
45
- seed: Seed,
46
- ): Record<string, unknown> {
47
- const result: Record<string, unknown> = {}
48
- for (const [alias, value] of Object.entries(data)) {
49
- const branch = seed.branches.find((b) => b.alias === alias)
50
- if (!branch) {
51
- result[alias] = value
52
- continue
53
- }
54
- const { visibility } = resolvePolicies(branch)
55
- if (visibility === 'hidden') continue
56
- if (visibility === 'masked') {
57
- result[alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
58
- } else {
59
- result[alias] = value
60
- }
61
- }
62
- return result
63
- }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { resolvePolicies, sha256hex } from '@beechcms/core'
3
+ import type { Seed } from '@beechcms/core'
4
+
5
+ class PrivacyPolicyError extends Error {
6
+ readonly status = 501 as const
7
+ constructor(message: string) {
8
+ super(message)
9
+ this.name = 'PrivacyPolicyError'
10
+ }
11
+ }
12
+
13
+ export { PrivacyPolicyError }
14
+
15
+ /** Applica la privacy policy ai campi del payload prima della scrittura su DB. */
16
+ export async function applyPrivacy(
17
+ data: Record<string, unknown>,
18
+ seed: Seed,
19
+ ): Promise<Record<string, unknown>> {
20
+ const result: Record<string, unknown> = {}
21
+ for (const [alias, value] of Object.entries(data)) {
22
+ const branch = seed.branches.find((b) => b.alias === alias)
23
+ if (!branch) {
24
+ result[alias] = value
25
+ continue
26
+ }
27
+ const { privacy } = resolvePolicies(branch)
28
+ if (privacy === 'encrypt') {
29
+ throw new PrivacyPolicyError(
30
+ `Field '${alias}' uses 'encrypt' privacy which is not yet implemented.`,
31
+ )
32
+ }
33
+ if (privacy === 'hash' && value != null) {
34
+ result[alias] = await sha256hex(String(value))
35
+ } else {
36
+ result[alias] = value
37
+ }
38
+ }
39
+ return result
40
+ }
41
+
42
+ /** Applica la visibility policy ai campi del payload in uscita verso il client. */
43
+ export function applyVisibility(
44
+ data: Record<string, unknown>,
45
+ seed: Seed,
46
+ ): Record<string, unknown> {
47
+ const result: Record<string, unknown> = {}
48
+ for (const [alias, value] of Object.entries(data)) {
49
+ const branch = seed.branches.find((b) => b.alias === alias)
50
+ if (!branch) {
51
+ result[alias] = value
52
+ continue
53
+ }
54
+ const { visibility } = resolvePolicies(branch)
55
+ if (visibility === 'hidden') continue
56
+ if (visibility === 'masked') {
57
+ result[alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
58
+ } else {
59
+ result[alias] = value
60
+ }
61
+ }
62
+ return result
63
+ }
@@ -1,28 +1,28 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { RepositoryError } from '@beechcms/core'
3
-
4
- /**
5
- * Base class for D1-backed repositories.
6
- * Handles the D1 instance and provides common database utilities and error mapping.
7
- */
8
- export abstract class BaseD1Repository {
9
- constructor(protected readonly db: D1Database) {}
10
-
11
- /**
12
- * Utility method to extract a table name for a given seed.
13
- */
14
- protected getTableName(slug: string, isDraft = false): string {
15
- return isDraft ? `content_${slug}_drafts` : `content_${slug}`
16
- }
17
-
18
- /**
19
- * Helper to map generic D1 errors to Beech RepositoryErrors.
20
- * This centralizes error handling for all D1 repositories.
21
- */
22
- protected mapError(error: any, context: string): RepositoryError {
23
- const message = error?.message || 'Unknown database error'
24
- // In the future, we can add more specific SQLite error code checks here
25
- // (e.g., checking for UNIQUE constraint via string matching or codes if available)
26
- return new RepositoryError(`${context}: ${message}`, error)
27
- }
28
- }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { RepositoryError } from '@beechcms/core'
3
+
4
+ /**
5
+ * Base class for D1-backed repositories.
6
+ * Handles the D1 instance and provides common database utilities and error mapping.
7
+ */
8
+ export abstract class BaseD1Repository {
9
+ constructor(protected readonly database: D1Database) {}
10
+
11
+ /**
12
+ * Utility method to extract a table name for a given seed.
13
+ */
14
+ protected getTableName(slug: string, isDraft = false): string {
15
+ return isDraft ? `content_${slug}_drafts` : `content_${slug}`
16
+ }
17
+
18
+ /**
19
+ * Helper to map generic D1 errors to Beech RepositoryErrors.
20
+ * This centralizes error handling for all D1 repositories.
21
+ */
22
+ protected mapError(error: any, context: string): RepositoryError {
23
+ const message = error?.message || 'Unknown database error'
24
+ // In the future, we can add more specific SQLite error code checks here
25
+ // (e.g., checking for UNIQUE constraint via string matching or codes if available)
26
+ return new RepositoryError(`${context}: ${message}`, error)
27
+ }
28
+ }
@@ -1,108 +1,82 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { deserializeFromDb, serializeForDb } from '@beechcms/core'
3
- import type { Seed } from '@beechcms/core'
4
- import type { ContentEntry } from './query-utils'
5
-
6
- /** Deserializza ogni branch colonna di un DB row in formato API alias. */
7
- export function rowToApiData(seed: Seed, row: Record<string, unknown>): Record<string, unknown> {
8
- const data: Record<string, unknown> = {}
9
- for (const branch of seed.branches) {
10
- data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
11
- }
12
- return data
13
- }
14
-
15
- /** Converte un row della tabella content_{slug} in ContentEntry per le risposte API. */
16
- export function rowToEntry(
17
- seed: Seed,
18
- row: Record<string, unknown>,
19
- hasPendingDraft = false
20
- ): ContentEntry {
21
- return {
22
- id: row.id as string,
23
- schema_slug: seed.slug,
24
- slug: (row.slug as string | null) ?? null,
25
- status: (row.status as string) ?? 'draft',
26
- data: rowToApiData(seed, row),
27
- hasPendingDraft,
28
- created_at: (row.created_at as number | null) ?? null,
29
- updated_at: (row.updated_at as number | null) ?? null,
30
- }
31
- }
32
-
33
- export interface InsertBindings {
34
- cols: string[]
35
- placeholders: string[]
36
- bindings: (string | number | null)[]
37
- }
38
-
39
- /** Costruisce colonne, placeholders e bindings per INSERT INTO content_{slug}. */
40
- export function buildInsertBindings(seed: Seed, payload: Record<string, unknown>): InsertBindings {
41
- const cols: string[] = []
42
- const placeholders: string[] = []
43
- const bindings: (string | number | null)[] = []
44
- for (const branch of seed.branches) {
45
- if (Object.hasOwn(payload, branch.alias)) {
46
- cols.push(branch.alias)
47
- placeholders.push('?')
48
- bindings.push(serializeForDb(branch, payload[branch.alias]))
49
- }
50
- }
51
- return { cols, placeholders, bindings }
52
- }
53
-
54
- export interface UpdateBindings {
55
- setClause: string
56
- bindings: (string | number | null)[]
57
- }
58
-
59
- /** Costruisce SET clause e bindings per UPDATE content_{slug}. */
60
- export function buildUpdateBindings(seed: Seed, payload: Record<string, unknown>): UpdateBindings {
61
- const setParts: string[] = []
62
- const bindings: (string | number | null)[] = []
63
- for (const branch of seed.branches) {
64
- if (Object.hasOwn(payload, branch.alias)) {
65
- setParts.push(`${branch.alias} = ?`)
66
- bindings.push(serializeForDb(branch, payload[branch.alias]))
67
- }
68
- }
69
- return { setClause: setParts.join(', '), bindings }
70
- }
71
-
72
- /** Controlla se esiste un draft pendente in content_{slug}_drafts. */
73
- export async function hasDraft(db: D1Database, seed: Seed, entryId: string): Promise<boolean> {
74
- if (!seed.allowDrafts) return false
75
- const row = await db
76
- .prepare(`SELECT 1 FROM content_${seed.slug}_drafts WHERE entry_id = ? LIMIT 1`)
77
- .bind(entryId)
78
- .first()
79
- return row !== null
80
- }
81
-
82
- /** Scrive un evento CRUD in content_event_log per l'activity feed. */
83
- export async function logContentEvent(
84
- db: D1Database,
85
- opts: {
86
- action: 'create' | 'update' | 'delete'
87
- schemaSlug: string
88
- entryId: string
89
- userId?: string | null
90
- details?: Record<string, unknown>
91
- }
92
- ): Promise<void> {
93
- await db
94
- .prepare(
95
- `INSERT INTO content_event_log (id, schema_slug, entry_id, action, user_id, details, created_at)
96
- VALUES (?, ?, ?, ?, ?, ?, ?)`
97
- )
98
- .bind(
99
- crypto.randomUUID(),
100
- opts.schemaSlug,
101
- opts.entryId,
102
- opts.action,
103
- opts.userId ?? null,
104
- opts.details ? JSON.stringify(opts.details) : null,
105
- Math.floor(Date.now() / 1000)
106
- )
107
- .run()
108
- }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { deserializeFromDb, serializeForDb } from '@beechcms/core'
3
+ import type { Seed } from '@beechcms/core'
4
+ import type { ContentEntry } from './query-utils'
5
+
6
+ /** Deserializza ogni branch colonna di un DB row in formato API alias. */
7
+ export function rowToApiData(seed: Seed, row: Record<string, unknown>): Record<string, unknown> {
8
+ const data: Record<string, unknown> = {}
9
+ for (const branch of seed.branches) {
10
+ data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
11
+ }
12
+ return data
13
+ }
14
+
15
+ /** Converte un row della tabella content_{slug} in ContentEntry per le risposte API. */
16
+ export function rowToEntry(
17
+ seed: Seed,
18
+ row: Record<string, unknown>,
19
+ hasPendingDraft = false
20
+ ): ContentEntry {
21
+ return {
22
+ id: row.id as string,
23
+ schema_slug: seed.slug,
24
+ slug: (row.slug as string | null) ?? null,
25
+ status: (row.status as string) ?? 'draft',
26
+ data: rowToApiData(seed, row),
27
+ hasPendingDraft,
28
+ created_at: (row.created_at as number | null) ?? null,
29
+ updated_at: (row.updated_at as number | null) ?? null,
30
+ }
31
+ }
32
+
33
+ export interface InsertBindings {
34
+ cols: string[]
35
+ placeholders: string[]
36
+ bindings: (string | number | null)[]
37
+ }
38
+
39
+ /** Costruisce colonne, placeholders e bindings per INSERT INTO content_{slug}. */
40
+ export function buildInsertBindings(seed: Seed, payload: Record<string, unknown>): InsertBindings {
41
+ const cols: string[] = []
42
+ const placeholders: string[] = []
43
+ const bindings: (string | number | null)[] = []
44
+ for (const branch of seed.branches) {
45
+ if (Object.hasOwn(payload, branch.alias)) {
46
+ cols.push(branch.alias)
47
+ placeholders.push('?')
48
+ bindings.push(serializeForDb(branch, payload[branch.alias]))
49
+ }
50
+ }
51
+ return { cols, placeholders, bindings }
52
+ }
53
+
54
+ export interface UpdateBindings {
55
+ setClause: string
56
+ bindings: (string | number | null)[]
57
+ }
58
+
59
+ /** Costruisce SET clause e bindings per UPDATE content_{slug}. */
60
+ export function buildUpdateBindings(seed: Seed, payload: Record<string, unknown>): UpdateBindings {
61
+ const setParts: string[] = []
62
+ const bindings: (string | number | null)[] = []
63
+ for (const branch of seed.branches) {
64
+ if (Object.hasOwn(payload, branch.alias)) {
65
+ setParts.push(`${branch.alias} = ?`)
66
+ bindings.push(serializeForDb(branch, payload[branch.alias]))
67
+ }
68
+ }
69
+ return { setClause: setParts.join(', '), bindings }
70
+ }
71
+
72
+ /** Controlla se esiste un draft pendente in content_{slug}_drafts. */
73
+ export async function hasDraft(db: D1Database, seed: Seed, entryId: string): Promise<boolean> {
74
+ if (!seed.allowDrafts) return false
75
+ const row = await db
76
+ .prepare(`SELECT 1 FROM content_${seed.slug}_drafts WHERE entry_id = ? LIMIT 1`)
77
+ .bind(entryId)
78
+ .first()
79
+ return row !== null
80
+ }
81
+
82
+