@beechcms/api 0.5.0 → 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.
- package/assets/dashboard/Searching.svg +1 -0
- package/assets/dashboard/assets/index-BSjk0Sx9.js +634 -0
- package/assets/dashboard/assets/index-kRCYVN2B.css +1 -0
- package/assets/dashboard/index.html +2 -2
- package/assets/dashboard/noResult.svg +43 -0
- package/assets/dashboard/working.svg +1 -0
- package/migrations/0000_v040_base.sql +27 -0
- package/package.json +13 -9
- package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +4 -0
- package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +4 -0
- package/src/auth/bcrypt-hash-provider.ts +6 -2
- package/src/auth/constants.ts +4 -0
- package/src/auth/generate-refresh-token.test.ts +8 -4
- package/src/auth/hash-provider.test.ts +14 -5
- package/src/auth/jose-token-service.ts +7 -0
- package/src/auth/jwt-claims-passthrough.test.ts +87 -0
- package/src/auth/login.test.ts +9 -1
- package/src/auth/login.ts +5 -1
- package/src/auth/refresh.ts +7 -1
- package/src/auth/token-service.test.ts +9 -1
- package/src/factory.ts +37 -16
- package/src/features/automations/__tests__/action-executors.test.ts +59 -72
- package/src/features/automations/__tests__/automation-runner.test.ts +4 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +4 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +7 -3
- package/src/features/automations/__tests__/automations.repository.test.ts +4 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +92 -2
- package/src/features/automations/__tests__/context-resolver.test.ts +4 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +11 -7
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +4 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +77 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +4 -0
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +4 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +5 -1
- package/src/features/automations/action-executors/create-entry.executor.ts +4 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +4 -0
- package/src/features/automations/action-executors/index.ts +5 -1
- package/src/features/automations/action-executors/send-mail.executor.ts +13 -2
- package/src/features/automations/action-executors/set-variable.executor.ts +15 -2
- package/src/features/automations/action-executors/webhook.executor.ts +55 -13
- package/src/features/automations/automation-runner.ts +4 -0
- package/src/features/automations/automation-runner.utils.ts +4 -0
- package/src/features/automations/automations.handler.ts +4 -0
- package/src/features/automations/automations.schema.ts +19 -2
- package/src/features/automations/context-resolver.ts +4 -0
- package/src/features/automations/cron-runner.ts +4 -0
- package/src/features/automations/cron-runner.utils.ts +4 -0
- package/src/features/automations/filter-translation.ts +4 -0
- package/src/features/automations/index.ts +4 -0
- package/src/features/automations/template-grammar.ts +4 -0
- package/src/features/automations/var-access-resolver.ts +4 -0
- package/src/features/automations/when-evaluator.ts +4 -0
- package/src/features/automations/when-pushdown.ts +4 -0
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
- package/src/features/backrefs/backrefs.handler.ts +168 -0
- package/src/features/backrefs/d1-backref.repository.ts +84 -0
- package/src/features/backrefs/index.ts +5 -0
- package/src/features/content/constants.ts +6 -0
- package/src/features/content/handlers/bulk.handler.ts +185 -0
- package/src/features/content/handlers/create.ts +19 -55
- package/src/features/content/handlers/delete.ts +12 -37
- package/src/features/content/handlers/facets.ts +4 -0
- package/src/features/content/handlers/get.ts +4 -0
- package/src/features/content/handlers/helpers.test.ts +180 -0
- package/src/features/content/handlers/helpers.ts +93 -0
- package/src/features/content/handlers/list.ts +105 -19
- package/src/features/content/handlers/update.ts +19 -64
- package/src/features/content/index.ts +6 -0
- package/src/features/draft/draft.handler.ts +33 -8
- package/src/features/draft/draft.middleware.ts +4 -0
- package/src/features/draft/index.ts +4 -0
- package/src/features/email/email.provider.ts +4 -0
- package/src/features/email/email.service.ts +50 -45
- package/src/features/email/email.types.ts +12 -1
- package/src/features/email/index.ts +4 -0
- package/src/features/email/providers/resend.ts +4 -0
- package/src/features/email/providers/smtp.ts +55 -0
- package/src/features/email/templates/automation-mail.ts +4 -0
- package/src/features/email/templates/password-changed.ts +4 -0
- package/src/features/email/templates/password-reset.ts +4 -0
- package/src/features/email/templates/shell.ts +4 -0
- package/src/features/notifications/index.ts +4 -0
- package/src/features/notifications/notifications.handler.ts +4 -0
- package/src/features/password-reset/index.ts +5 -1
- package/src/features/password-reset/request.ts +12 -2
- package/src/features/password-reset/reset.ts +12 -2
- package/src/features/rotate-field/index.ts +4 -0
- package/src/features/rotate-field/rotate-field.handler.ts +4 -0
- package/src/features/rotate-field/rotate-field.schema.ts +4 -0
- package/src/features/schema/schema.handler.ts +118 -3
- package/src/features/seeds/index.ts +5 -0
- package/src/features/seeds/seeds.handler.test.ts +632 -0
- package/src/features/seeds/seeds.handler.ts +693 -0
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
- package/src/features/settings/settings.handler.ts +98 -8
- package/src/features/setup/index.ts +124 -11
- package/src/features/stats/index.ts +4 -0
- package/src/features/stats/stats.handler.ts +11 -21
- package/src/features/webhooks/index.ts +63 -0
- package/src/index.ts +28 -19
- package/src/media-utils.ts +4 -0
- package/src/middleware/auth-providers.middleware.ts +13 -0
- package/src/middleware/observability.middleware.ts +21 -3
- package/src/middleware/rate-limit.middleware.ts +4 -0
- package/src/middleware/repository.middleware.ts +25 -2
- package/src/middleware/seed-registry.middleware.test.ts +97 -0
- package/src/middleware/seed-registry.middleware.ts +21 -0
- package/src/middleware/storage.middleware.ts +4 -0
- package/src/middleware.ts +5 -7
- package/src/public/access-policy.ts +4 -0
- package/src/public/api-key-middleware.ts +4 -0
- package/src/public/cache-utils.ts +4 -0
- package/src/public/entry-projection.ts +4 -0
- package/src/public/idempotency.ts +4 -0
- package/src/public/index.ts +4 -0
- package/src/public/problem-details.ts +71 -0
- package/src/public/public-add.ts +4 -0
- package/src/public/public-edit.ts +4 -0
- package/src/public/public-errors.ts +4 -0
- package/src/public/public-read.ts +4 -0
- package/src/public/public-routes.ts +4 -0
- package/src/public/query-builder.test.ts +4 -0
- package/src/public/query-builder.ts +4 -0
- package/src/public/rate-limit-middleware.ts +4 -0
- package/src/public/read-list.ts +4 -0
- package/src/public/read-single.ts +4 -0
- package/src/public/response-builder.ts +4 -0
- package/src/public/sanitize.ts +4 -0
- package/src/public/slug-utils.ts +4 -0
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +4 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +4 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +4 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +4 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +4 -0
- package/src/rate-limit/no-op-rate-limiter.ts +4 -0
- package/src/search-utils.test.ts +4 -0
- package/src/search-utils.ts +4 -0
- package/src/search.ts +4 -0
- package/src/shared/apply-policies.test.ts +4 -0
- package/src/shared/apply-policies.ts +4 -0
- package/src/shared/automations.repository.d1.ts +4 -0
- package/src/shared/background-notification-service.test.ts +4 -0
- package/src/shared/background-notification-service.ts +4 -0
- package/src/shared/base.repository.d1.ts +4 -0
- package/src/shared/content-utils.test.ts +4 -0
- package/src/shared/content-utils.ts +4 -0
- package/src/shared/content.repository.d1.test.ts +147 -1
- package/src/shared/content.repository.d1.ts +535 -66
- package/src/shared/d1-activity-log.repository.test.ts +4 -0
- package/src/shared/d1-activity-log.repository.ts +4 -0
- package/src/shared/d1-activity-logger.test.ts +4 -0
- package/src/shared/d1-activity-logger.ts +4 -0
- package/src/shared/d1-analytics.repository.test.ts +4 -0
- package/src/shared/d1-analytics.repository.ts +4 -0
- package/src/shared/d1-content-scan.repository.test.ts +9 -5
- package/src/shared/d1-content-scan.repository.ts +15 -7
- package/src/shared/d1-notification.repository.test.ts +4 -0
- package/src/shared/d1-notification.repository.ts +4 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +4 -0
- package/src/shared/d1-password-reset-token.repository.ts +4 -0
- package/src/shared/d1-search.repository.test.ts +4 -0
- package/src/shared/d1-search.repository.ts +4 -0
- package/src/shared/d1-session.repository.test.ts +4 -0
- package/src/shared/d1-session.repository.ts +4 -0
- package/src/shared/d1-setup-checklist.repository.ts +36 -0
- package/src/shared/d1-user.repository.test.ts +8 -4
- package/src/shared/d1-user.repository.ts +15 -5
- package/src/shared/d1-widget.repository.test.ts +4 -0
- package/src/shared/d1-widget.repository.ts +4 -0
- package/src/shared/demo-data-sql.ts +54 -0
- package/src/shared/demo-data.repository.d1.ts +20 -0
- package/src/shared/execution-context-scheduler.ts +4 -0
- package/src/shared/fixed-clock.ts +4 -0
- package/src/shared/fts-sync.ts +4 -0
- package/src/shared/idempotency.repository.d1.test.ts +4 -0
- package/src/shared/idempotency.repository.d1.ts +4 -0
- package/src/shared/in-memory-activity-logger.ts +4 -0
- package/src/shared/in-memory-notification-service.ts +4 -0
- package/src/shared/in-memory-seed.repository.ts +51 -0
- package/src/shared/media.repository.d1.test.ts +4 -0
- package/src/shared/media.repository.d1.ts +4 -0
- package/src/shared/qstash-notification-service.test.ts +67 -0
- package/src/shared/qstash-notification-service.ts +55 -0
- package/src/shared/query-utils.ts +4 -0
- package/src/shared/request-utils.ts +4 -0
- package/src/shared/schema-mutator.d1.ts +64 -0
- package/src/shared/seed-layout.repository.d1.test.ts +114 -0
- package/src/shared/seed-layout.repository.d1.ts +62 -0
- package/src/shared/seed-registry-cache.test.ts +68 -0
- package/src/shared/seed-registry-cache.ts +49 -0
- package/src/shared/seed.repository.d1.test.ts +143 -0
- package/src/shared/seed.repository.d1.ts +98 -0
- package/src/shared/sequential-id-generator.ts +11 -0
- package/src/shared/site-settings.repository.d1.ts +53 -0
- package/src/shared/storage/factory.ts +16 -15
- package/src/shared/storage/s3-bucket.ts +34 -2
- package/src/shared/storage-utils.ts +4 -0
- package/src/shared/system-stats.repository.d1.test.ts +4 -0
- package/src/shared/system-stats.repository.d1.ts +5 -1
- package/src/types.ts +23 -3
- package/src/upload.ts +134 -123
- package/src/widget.ts +6 -2
- package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
- package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
- 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
|
-
|
|
8
|
-
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
515
|
+
const { stmt, junctionUpdates } = this.buildUpdateMainStmt(seed, id, data, status)
|
|
516
|
+
|
|
517
|
+
if (!stmt && junctionUpdates.length === 0) return
|
|
202
518
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
queryBindings.push(status)
|
|
206
|
-
}
|
|
519
|
+
const batchStmts: D1PreparedStatement[] = []
|
|
520
|
+
if (stmt) batchStmts.push(stmt)
|
|
207
521
|
|
|
208
|
-
for (const branch of
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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 (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|