@beechcms/api 0.4.0-preview.9 → 0.4.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/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CH13idU1.js +554 -0
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +10 -10
- package/src/auth/generate-refresh-token.test.ts +19 -0
- package/src/auth/hash-provider.test.ts +46 -0
- package/src/auth/in-memory-hash-provider.ts +13 -0
- package/src/auth/jose-token-service.ts +55 -0
- package/src/auth/login.test.ts +92 -0
- package/src/auth/login.ts +74 -91
- package/src/auth/refresh.ts +5 -127
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +339 -303
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +157 -0
- package/src/features/content/handlers/delete.ts +80 -0
- package/src/features/content/handlers/facets.ts +45 -0
- package/src/features/content/handlers/get.ts +116 -0
- package/src/features/content/handlers/list.ts +88 -0
- package/src/features/content/handlers/update.ts +211 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +283 -198
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +101 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +82 -88
- package/src/features/password-reset/reset.ts +92 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +128 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +301 -249
- package/src/features/setup/index.ts +91 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +430 -395
- package/src/index.ts +24 -11
- package/src/media-utils.ts +78 -78
- package/src/middleware/auth-providers.middleware.ts +32 -0
- package/src/middleware/observability.middleware.ts +52 -0
- package/src/middleware/rate-limit.middleware.ts +41 -0
- package/src/middleware/repository.middleware.ts +60 -0
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +47 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +156 -183
- package/src/public/public-edit.ts +159 -183
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -217
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/query-builder.ts +152 -241
- package/src/public/rate-limit-middleware.ts +30 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
- package/src/rate-limit/no-op-rate-limiter.ts +7 -0
- package/src/search-utils.test.ts +207 -0
- package/src/search-utils.ts +209 -192
- package/src/search.ts +61 -72
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/d1-activity-log.repository.test.ts +136 -0
- package/src/shared/d1-activity-log.repository.ts +101 -0
- package/src/shared/d1-activity-logger.test.ts +82 -0
- package/src/shared/d1-activity-logger.ts +63 -0
- package/src/shared/d1-analytics.repository.test.ts +74 -0
- package/src/shared/d1-analytics.repository.ts +81 -0
- package/src/shared/d1-content-scan.repository.ts +29 -0
- package/src/shared/d1-notification.repository.test.ts +124 -0
- package/src/shared/d1-notification.repository.ts +114 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
- package/src/shared/d1-password-reset-token.repository.ts +52 -0
- package/src/shared/d1-search.repository.test.ts +83 -0
- package/src/shared/d1-search.repository.ts +84 -0
- package/src/shared/d1-session.repository.test.ts +121 -0
- package/src/shared/d1-session.repository.ts +98 -0
- package/src/shared/d1-user.repository.test.ts +147 -0
- package/src/shared/d1-user.repository.ts +109 -0
- package/src/shared/d1-widget.repository.test.ts +217 -0
- package/src/shared/d1-widget.repository.ts +337 -0
- package/src/shared/fixed-clock.ts +21 -0
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/idempotency.repository.d1.ts +56 -0
- package/src/shared/in-memory-activity-logger.ts +15 -0
- package/src/shared/in-memory-notification-service.ts +15 -0
- package/src/shared/media.repository.d1.test.ts +103 -0
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +63 -36
- package/src/upload.ts +186 -335
- package/src/widget.ts +208 -349
- package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
- package/assets/dashboard/assets/index-CQODXprH.css +0 -1
- package/src/content.ts +0 -502
- package/src/features/draft/draft.test.ts +0 -315
- package/src/features/rotate-field/rotate-field.test.ts +0 -297
- package/src/shared/activity-logger.ts +0 -79
- package/src/shared/notification-service.ts +0 -56
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { D1ContentRepository } from './content.repository.d1'
|
|
3
|
+
import { EntryNotFoundError, SlugConflictError } from '@beechcms/core'
|
|
4
|
+
import type { Seed } from '@beechcms/core'
|
|
5
|
+
|
|
6
|
+
const SEED = {
|
|
7
|
+
slug: 'posts',
|
|
8
|
+
displayNameAlias: 'title',
|
|
9
|
+
allowDrafts: true,
|
|
10
|
+
branches: [
|
|
11
|
+
{ id: 'br_01', alias: 'title', type: 'text' },
|
|
12
|
+
{ id: 'br_02', alias: 'body', type: 'text' },
|
|
13
|
+
],
|
|
14
|
+
} as unknown as Seed
|
|
15
|
+
|
|
16
|
+
const NO_DRAFT_SEED = {
|
|
17
|
+
slug: 'produtos',
|
|
18
|
+
displayNameAlias: 'name',
|
|
19
|
+
allowDrafts: false,
|
|
20
|
+
branches: [{ id: 'br_01', alias: 'name', type: 'text' }],
|
|
21
|
+
} as unknown as Seed
|
|
22
|
+
|
|
23
|
+
function makeMockDb(opts: {
|
|
24
|
+
firstResult?: unknown
|
|
25
|
+
allResults?: unknown[]
|
|
26
|
+
runChanges?: number
|
|
27
|
+
batchResults?: unknown[]
|
|
28
|
+
} = {}) {
|
|
29
|
+
const { firstResult = null, allResults = [], runChanges = 1, batchResults = [] } = opts
|
|
30
|
+
const runMock = vi.fn().mockResolvedValue({ success: true, meta: { changes: runChanges } })
|
|
31
|
+
const firstMock = vi.fn().mockResolvedValue(firstResult)
|
|
32
|
+
const allMock = vi.fn().mockResolvedValue({ results: allResults })
|
|
33
|
+
const bindMock = vi.fn(() => ({ run: runMock, first: firstMock, all: allMock }))
|
|
34
|
+
const stmt = { bind: bindMock, run: runMock, first: firstMock, all: allMock }
|
|
35
|
+
const prepareMock = vi.fn(() => stmt)
|
|
36
|
+
const batchMock = vi.fn().mockResolvedValue(batchResults)
|
|
37
|
+
return { db: { prepare: prepareMock, batch: batchMock } as any, prepareMock, bindMock, runMock, firstMock, allMock, batchMock }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── findMany ─────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
describe('D1ContentRepository', () => {
|
|
43
|
+
describe('findMany', () => {
|
|
44
|
+
it('returns mapped items and total from batch results', async () => {
|
|
45
|
+
const row = { id: 'e1', slug: 'post-1', status: 'published', title: 'Hello', body: null, created_at: 1000, updated_at: 1000 }
|
|
46
|
+
const { db } = makeMockDb({
|
|
47
|
+
batchResults: [
|
|
48
|
+
{ results: [row] },
|
|
49
|
+
{ results: [{ total: 1 }] },
|
|
50
|
+
],
|
|
51
|
+
})
|
|
52
|
+
const result = await new D1ContentRepository(db).findMany(SEED, { pagination: { limit: 10, offset: 0 } })
|
|
53
|
+
expect(result.items).toHaveLength(1)
|
|
54
|
+
expect(result.items[0].id).toBe('e1')
|
|
55
|
+
expect(result.total).toBe(1)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns empty items and zero total when table is empty', async () => {
|
|
59
|
+
const { db } = makeMockDb({
|
|
60
|
+
batchResults: [{ results: [] }, { results: [{ total: 0 }] }],
|
|
61
|
+
})
|
|
62
|
+
const result = await new D1ContentRepository(db).findMany(SEED, {})
|
|
63
|
+
expect(result.items).toHaveLength(0)
|
|
64
|
+
expect(result.total).toBe(0)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('wraps D1 errors in RepositoryError', async () => {
|
|
68
|
+
const { db } = makeMockDb()
|
|
69
|
+
db.batch = vi.fn().mockRejectedValue(new Error('D1 error'))
|
|
70
|
+
await expect(new D1ContentRepository(db).findMany(SEED, {})).rejects.toThrow('findMany')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// ─── findById ───────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe('findById', () => {
|
|
77
|
+
it('returns the entry when found', async () => {
|
|
78
|
+
const row = { id: 'e1', slug: 'p', status: 'draft', title: 'T', body: null, created_at: 0, updated_at: 0 }
|
|
79
|
+
const { db } = makeMockDb({ firstResult: row })
|
|
80
|
+
const result = await new D1ContentRepository(db).findById(SEED, 'e1')
|
|
81
|
+
expect(result.id).toBe('e1')
|
|
82
|
+
expect(result.title).toBe('T')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('throws EntryNotFoundError when the row does not exist', async () => {
|
|
86
|
+
const { db } = makeMockDb({ firstResult: null })
|
|
87
|
+
await expect(new D1ContentRepository(db).findById(SEED, 'missing')).rejects.toBeInstanceOf(EntryNotFoundError)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('queries the correct content table', async () => {
|
|
91
|
+
const { db, prepareMock } = makeMockDb({ firstResult: { id: 'e1', slug: null, status: 'draft', title: null, body: null } })
|
|
92
|
+
await new D1ContentRepository(db).findById(SEED, 'e1')
|
|
93
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('content_posts'))
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// ─── findBySlug ─────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe('findBySlug', () => {
|
|
100
|
+
it('returns the entry when the slug is found', async () => {
|
|
101
|
+
const row = { id: 'e1', slug: 'my-post', status: 'published', title: 'T', body: null }
|
|
102
|
+
const { db } = makeMockDb({ firstResult: row })
|
|
103
|
+
const result = await new D1ContentRepository(db).findBySlug(SEED, 'my-post')
|
|
104
|
+
expect(result.slug).toBe('my-post')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('throws EntryNotFoundError when slug does not exist', async () => {
|
|
108
|
+
const { db } = makeMockDb({ firstResult: null })
|
|
109
|
+
await expect(new D1ContentRepository(db).findBySlug(SEED, 'ghost')).rejects.toBeInstanceOf(EntryNotFoundError)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ─── getFacets ───────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe('getFacets', () => {
|
|
116
|
+
it('returns status counts mapped from status GROUP BY results', async () => {
|
|
117
|
+
const { db, allMock } = makeMockDb()
|
|
118
|
+
allMock.mockResolvedValueOnce({ results: [{ status: 'published', count: 3 }, { status: 'draft', count: 1 }] })
|
|
119
|
+
const result = await new D1ContentRepository(db).getFacets(SEED)
|
|
120
|
+
expect(result.statuses).toEqual({ published: 3, draft: 1 })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('returns empty tagsByColumn when seed has no tag branches', async () => {
|
|
124
|
+
const { db, allMock } = makeMockDb()
|
|
125
|
+
allMock.mockResolvedValueOnce({ results: [] })
|
|
126
|
+
const result = await new D1ContentRepository(db).getFacets(SEED)
|
|
127
|
+
expect(result.tagsByColumn).toEqual({})
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// ─── existsSlug ─────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
describe('existsSlug', () => {
|
|
134
|
+
it('returns true when the slug row is found', async () => {
|
|
135
|
+
const { db } = makeMockDb({ firstResult: { 1: 1 } })
|
|
136
|
+
expect(await new D1ContentRepository(db).existsSlug(SEED, 'existing-slug')).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('returns false when the slug row is not found', async () => {
|
|
140
|
+
const { db } = makeMockDb({ firstResult: null })
|
|
141
|
+
expect(await new D1ContentRepository(db).existsSlug(SEED, 'new-slug')).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('appends AND id != ? clause when excludeId is provided', async () => {
|
|
145
|
+
const { db, bindMock } = makeMockDb({ firstResult: null })
|
|
146
|
+
await new D1ContentRepository(db).existsSlug(SEED, 'slug', 'exclude-me')
|
|
147
|
+
expect(bindMock).toHaveBeenCalledWith('slug', 'exclude-me')
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// ─── create ─────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
describe('create', () => {
|
|
154
|
+
it('calls INSERT INTO the content table', async () => {
|
|
155
|
+
const { db, prepareMock } = makeMockDb({ firstResult: null }) // existsSlug → null
|
|
156
|
+
await new D1ContentRepository(db).create(SEED, 'new-id', 'my-slug', 'draft', { title: 'Hello' })
|
|
157
|
+
const calls = prepareMock.mock.calls.map(c => c[0] as string)
|
|
158
|
+
expect(calls.some(sql => sql.includes('INSERT INTO content_posts'))).toBe(true)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('throws SlugConflictError when the slug already exists', async () => {
|
|
162
|
+
const { db } = makeMockDb({ firstResult: { 1: 1 } }) // existsSlug → found
|
|
163
|
+
await expect(
|
|
164
|
+
new D1ContentRepository(db).create(SEED, 'id', 'taken-slug', 'draft', {}),
|
|
165
|
+
).rejects.toBeInstanceOf(SlugConflictError)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// ─── update ─────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
describe('update', () => {
|
|
172
|
+
it('calls UPDATE with provided status and branch data', async () => {
|
|
173
|
+
const { db, prepareMock, runMock } = makeMockDb({ runChanges: 1 })
|
|
174
|
+
runMock.mockResolvedValue({ success: true, meta: { changes: 1 } })
|
|
175
|
+
await new D1ContentRepository(db).update(SEED, 'e1', { title: 'New' }, 'published')
|
|
176
|
+
const calls = prepareMock.mock.calls.map(c => c[0] as string)
|
|
177
|
+
expect(calls.some(sql => sql.includes('UPDATE content_posts'))).toBe(true)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('throws EntryNotFoundError when no rows are updated', async () => {
|
|
181
|
+
const { db } = makeMockDb({ runChanges: 0 })
|
|
182
|
+
await expect(new D1ContentRepository(db).update(SEED, 'ghost', { title: 'X' })).rejects.toBeInstanceOf(EntryNotFoundError)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('returns early without a DB call when there is nothing to update', async () => {
|
|
186
|
+
const { db, prepareMock } = makeMockDb()
|
|
187
|
+
await new D1ContentRepository(db).update(SEED, 'e1', {}) // no data, no status
|
|
188
|
+
// existsSlug is not called; only prepare calls would be for UPDATE which shouldn't happen
|
|
189
|
+
const updateCalls = prepareMock.mock.calls.filter(c => (c[0] as string).includes('UPDATE content_posts'))
|
|
190
|
+
expect(updateCalls).toHaveLength(0)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// ─── delete ─────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
describe('delete', () => {
|
|
197
|
+
it('returns the row data and calls DELETE', async () => {
|
|
198
|
+
const row = { id: 'e1', slug: 'p', status: 'published', title: 'T', body: null }
|
|
199
|
+
const { db, prepareMock } = makeMockDb({ firstResult: row })
|
|
200
|
+
const result = await new D1ContentRepository(db).delete(SEED, 'e1')
|
|
201
|
+
expect(result.row.id).toBe('e1')
|
|
202
|
+
const sqls = prepareMock.mock.calls.map(c => c[0] as string)
|
|
203
|
+
expect(sqls.some(s => s.includes('DELETE FROM content_posts'))).toBe(true)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('throws EntryNotFoundError when the entry does not exist', async () => {
|
|
207
|
+
const { db } = makeMockDb({ firstResult: null })
|
|
208
|
+
await expect(new D1ContentRepository(db).delete(SEED, 'missing')).rejects.toBeInstanceOf(EntryNotFoundError)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// ─── saveDraft ───────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
describe('saveDraft', () => {
|
|
215
|
+
it('calls INSERT OR REPLACE into the draft table', async () => {
|
|
216
|
+
const { db, prepareMock } = makeMockDb()
|
|
217
|
+
await new D1ContentRepository(db).saveDraft(SEED, 'e1', { title: 'Draft title' })
|
|
218
|
+
const sqls = prepareMock.mock.calls.map(c => c[0] as string)
|
|
219
|
+
expect(sqls.some(s => s.includes('content_posts_drafts'))).toBe(true)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('throws RepositoryError when seed does not allow drafts', async () => {
|
|
223
|
+
const { db } = makeMockDb()
|
|
224
|
+
await expect(new D1ContentRepository(db).saveDraft(NO_DRAFT_SEED, 'e1', {})).rejects.toThrow('Drafts not allowed')
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// ─── getDraft ────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
describe('getDraft', () => {
|
|
231
|
+
it('returns null when seed does not allow drafts', async () => {
|
|
232
|
+
const { db } = makeMockDb()
|
|
233
|
+
expect(await new D1ContentRepository(db).getDraft(NO_DRAFT_SEED, 'e1')).toBeNull()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('returns null when no draft row exists', async () => {
|
|
237
|
+
const { db } = makeMockDb({ firstResult: null })
|
|
238
|
+
expect(await new D1ContentRepository(db).getDraft(SEED, 'e1')).toBeNull()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('returns deserialized draft data for non-null branch values', async () => {
|
|
242
|
+
const draftRow = { entry_id: 'e1', title: 'Draft T', body: null }
|
|
243
|
+
const { db } = makeMockDb({ firstResult: draftRow })
|
|
244
|
+
const result = await new D1ContentRepository(db).getDraft(SEED, 'e1')
|
|
245
|
+
expect(result).not.toBeNull()
|
|
246
|
+
expect(result!.title).toBe('Draft T')
|
|
247
|
+
expect(result).not.toHaveProperty('body') // null branch values are omitted
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// ─── hasDraft ─────────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
describe('hasDraft', () => {
|
|
254
|
+
it('returns false immediately when seed does not allow drafts', async () => {
|
|
255
|
+
const { db, prepareMock } = makeMockDb()
|
|
256
|
+
expect(await new D1ContentRepository(db).hasDraft(NO_DRAFT_SEED, 'e1')).toBe(false)
|
|
257
|
+
expect(prepareMock).not.toHaveBeenCalled()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('returns true when a draft exists', async () => {
|
|
261
|
+
const { db } = makeMockDb({ firstResult: { 1: 1 } })
|
|
262
|
+
expect(await new D1ContentRepository(db).hasDraft(SEED, 'e1')).toBe(true)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('returns false when no draft exists', async () => {
|
|
266
|
+
const { db } = makeMockDb({ firstResult: null })
|
|
267
|
+
expect(await new D1ContentRepository(db).hasDraft(SEED, 'e1')).toBe(false)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// ─── publishDraft ─────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
describe('publishDraft', () => {
|
|
274
|
+
it('returns early when seed does not allow drafts', async () => {
|
|
275
|
+
const { db, batchMock } = makeMockDb()
|
|
276
|
+
await new D1ContentRepository(db).publishDraft(NO_DRAFT_SEED, 'e1')
|
|
277
|
+
expect(batchMock).not.toHaveBeenCalled()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('throws EntryNotFoundError when no draft exists', async () => {
|
|
281
|
+
const { db } = makeMockDb({ firstResult: null })
|
|
282
|
+
await expect(new D1ContentRepository(db).publishDraft(SEED, 'e1')).rejects.toBeInstanceOf(EntryNotFoundError)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('calls batch with UPDATE and DELETE statements when draft exists', async () => {
|
|
286
|
+
const draftRow = { entry_id: 'e1', title: 'Draft', body: null }
|
|
287
|
+
const { db, batchMock, firstMock } = makeMockDb()
|
|
288
|
+
firstMock.mockResolvedValueOnce(draftRow)
|
|
289
|
+
batchMock.mockResolvedValueOnce([{}, {}])
|
|
290
|
+
await new D1ContentRepository(db).publishDraft(SEED, 'e1')
|
|
291
|
+
expect(batchMock).toHaveBeenCalledTimes(1)
|
|
292
|
+
const batchArgs: any[] = batchMock.mock.calls[0][0]
|
|
293
|
+
expect(batchArgs).toHaveLength(2)
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// ─── deleteDraft ─────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
describe('deleteDraft', () => {
|
|
300
|
+
it('returns early when seed does not allow drafts', async () => {
|
|
301
|
+
const { db, prepareMock } = makeMockDb()
|
|
302
|
+
await new D1ContentRepository(db).deleteDraft(NO_DRAFT_SEED, 'e1')
|
|
303
|
+
expect(prepareMock).not.toHaveBeenCalled()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('calls DELETE on the draft table when drafts are allowed', async () => {
|
|
307
|
+
const { db, prepareMock } = makeMockDb()
|
|
308
|
+
await new D1ContentRepository(db).deleteDraft(SEED, 'e1')
|
|
309
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('content_posts_drafts'))
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
})
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import {
|
|
3
|
+
ContentRepository,
|
|
4
|
+
EntryNotFoundError,
|
|
5
|
+
RepositoryError,
|
|
6
|
+
SlugConflictError,
|
|
7
|
+
Seed,
|
|
8
|
+
SelectOptions,
|
|
9
|
+
buildSelectQuery,
|
|
10
|
+
deserializeFromDb,
|
|
11
|
+
serializeForDb,
|
|
12
|
+
} from '@beechcms/core'
|
|
13
|
+
import { BaseD1Repository } from './base.repository.d1'
|
|
14
|
+
|
|
15
|
+
export class D1ContentRepository extends BaseD1Repository implements ContentRepository {
|
|
16
|
+
/**
|
|
17
|
+
* Helper to deserialize a DB row using the Seed's branch definitions.
|
|
18
|
+
*/
|
|
19
|
+
private rowToData(seed: Seed, row: any): Record<string, any> {
|
|
20
|
+
const data: Record<string, any> = {
|
|
21
|
+
id: row.id,
|
|
22
|
+
slug: row.slug,
|
|
23
|
+
status: row.status,
|
|
24
|
+
created_at: row.created_at,
|
|
25
|
+
updated_at: row.updated_at,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const branch of seed.branches) {
|
|
29
|
+
if (Object.hasOwn(row, branch.alias)) {
|
|
30
|
+
data[branch.alias] = deserializeFromDb(branch, row[branch.alias])
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return data
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async findMany(
|
|
38
|
+
seed: Seed,
|
|
39
|
+
options: SelectOptions
|
|
40
|
+
): Promise<{ items: Record<string, any>[]; total: number }> {
|
|
41
|
+
try {
|
|
42
|
+
const { sql, bindings } = buildSelectQuery(seed, options)
|
|
43
|
+
|
|
44
|
+
// We need the total count for pagination.
|
|
45
|
+
// We build a count query by replacing the SELECT part.
|
|
46
|
+
// Note: buildSelectQuery might have joins and where clauses.
|
|
47
|
+
const countSql = sql
|
|
48
|
+
.replace(/SELECT .* FROM/, 'SELECT COUNT(*) as total FROM')
|
|
49
|
+
.replace(/ ORDER BY .*$/, '')
|
|
50
|
+
.replace(/ LIMIT \? OFFSET \?$/, '')
|
|
51
|
+
|
|
52
|
+
const countBindings = bindings.slice(0, bindings.length - (options.pagination ? 2 : 0))
|
|
53
|
+
|
|
54
|
+
const [batchResults, totalCountResult] = await this.database.batch([
|
|
55
|
+
this.database.prepare(sql).bind(...bindings),
|
|
56
|
+
this.database.prepare(countSql).bind(...countBindings)
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
const contentEntries = (batchResults.results || []).map((entryRow) => this.rowToData(seed, entryRow))
|
|
60
|
+
const totalEntriesCount = (totalCountResult.results?.[0] as any)?.total || 0
|
|
61
|
+
|
|
62
|
+
return { items: contentEntries, total: totalEntriesCount }
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw this.mapError(error, `findMany(${seed.slug})`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async findById(seed: Seed, id: string): Promise<Record<string, any>> {
|
|
69
|
+
try {
|
|
70
|
+
const tableName = this.getTableName(seed.slug)
|
|
71
|
+
const entryRow = await this.database
|
|
72
|
+
.prepare(`SELECT * FROM ${tableName} WHERE id = ? LIMIT 1`)
|
|
73
|
+
.bind(id)
|
|
74
|
+
.first()
|
|
75
|
+
|
|
76
|
+
if (!entryRow) {
|
|
77
|
+
throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return this.rowToData(seed, entryRow)
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof EntryNotFoundError) throw error
|
|
83
|
+
throw this.mapError(error, `findById(${seed.slug}, ${id})`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async findBySlug(seed: Seed, slug: string): Promise<Record<string, any>> {
|
|
88
|
+
try {
|
|
89
|
+
const tableName = this.getTableName(seed.slug)
|
|
90
|
+
const entryRow = await this.database
|
|
91
|
+
.prepare(`SELECT * FROM ${tableName} WHERE slug = ? LIMIT 1`)
|
|
92
|
+
.bind(slug)
|
|
93
|
+
.first()
|
|
94
|
+
|
|
95
|
+
if (!entryRow) {
|
|
96
|
+
throw new EntryNotFoundError(`Entry with slug "${slug}" not found in ${seed.slug}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return this.rowToData(seed, entryRow)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof EntryNotFoundError) throw error
|
|
102
|
+
throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async getFacets(seed: Seed): Promise<{
|
|
107
|
+
statuses: Record<string, number>
|
|
108
|
+
tagsByColumn: Record<string, string[]>
|
|
109
|
+
}> {
|
|
110
|
+
try {
|
|
111
|
+
const tableName = this.getTableName(seed.slug)
|
|
112
|
+
|
|
113
|
+
// Retrieve count per status
|
|
114
|
+
const statusResults = await this.database
|
|
115
|
+
.prepare(`SELECT status, COUNT(*) as count FROM ${tableName} GROUP BY status`)
|
|
116
|
+
.all()
|
|
117
|
+
|
|
118
|
+
const statusesCount: Record<string, number> = {}
|
|
119
|
+
for (const statusRow of statusResults.results || []) {
|
|
120
|
+
statusesCount[statusRow.status as string] = statusRow.count as number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Collect unique tags for branches of type 'tags'
|
|
124
|
+
const tagsByColumn: Record<string, string[]> = {}
|
|
125
|
+
const tagBranches = seed.branches.filter(branch => branch.type === 'tags')
|
|
126
|
+
|
|
127
|
+
for (const branch of tagBranches) {
|
|
128
|
+
// Use SQLite json_each to expand tags stored as JSON arrays
|
|
129
|
+
const tagResults = await this.database
|
|
130
|
+
.prepare(`SELECT DISTINCT value FROM ${tableName}, json_each(${tableName}.${branch.alias}) WHERE value IS NOT NULL`)
|
|
131
|
+
.all()
|
|
132
|
+
tagsByColumn[branch.alias] = (tagResults.results || []).map(row => row.value as string)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { statuses: statusesCount, tagsByColumn }
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw this.mapError(error, `getFacets(${seed.slug})`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async existsSlug(seed: Seed, slug: string, excludeId?: string): Promise<boolean> {
|
|
142
|
+
try {
|
|
143
|
+
const tableName = this.getTableName(seed.slug)
|
|
144
|
+
let sql = `SELECT 1 FROM ${tableName} WHERE slug = ?`
|
|
145
|
+
const queryBindings: any[] = [slug]
|
|
146
|
+
|
|
147
|
+
if (excludeId) {
|
|
148
|
+
sql += ` AND id != ?`
|
|
149
|
+
queryBindings.push(excludeId)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const entryExistsResult = await this.database.prepare(sql).bind(...queryBindings).first()
|
|
153
|
+
return entryExistsResult !== null
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw this.mapError(error, `existsSlug(${seed.slug}, ${slug})`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async create(
|
|
160
|
+
seed: Seed,
|
|
161
|
+
id: string,
|
|
162
|
+
slug: string,
|
|
163
|
+
status: string,
|
|
164
|
+
data: Record<string, any>
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
try {
|
|
167
|
+
if (await this.existsSlug(seed, slug)) {
|
|
168
|
+
throw new SlugConflictError(`Slug "${slug}" already exists for ${seed.slug}`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const tableName = this.getTableName(seed.slug)
|
|
172
|
+
const columnNames = ['id', 'slug', 'status']
|
|
173
|
+
const placeholders = ['?', '?', '?']
|
|
174
|
+
const queryBindings: any[] = [id, slug, status]
|
|
175
|
+
|
|
176
|
+
for (const branch of seed.branches) {
|
|
177
|
+
if (Object.hasOwn(data, branch.alias)) {
|
|
178
|
+
columnNames.push(branch.alias)
|
|
179
|
+
placeholders.push('?')
|
|
180
|
+
queryBindings.push(serializeForDb(branch, data[branch.alias]))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const sql = `INSERT INTO ${tableName} (${columnNames.join(', ')}) VALUES (${placeholders.join(', ')})`
|
|
185
|
+
await this.database.prepare(sql).bind(...queryBindings).run()
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (error instanceof SlugConflictError) throw error
|
|
188
|
+
throw this.mapError(error, `create(${seed.slug})`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async update(
|
|
193
|
+
seed: Seed,
|
|
194
|
+
id: string,
|
|
195
|
+
data: Record<string, any>,
|
|
196
|
+
status?: string
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
try {
|
|
199
|
+
const tableName = this.getTableName(seed.slug)
|
|
200
|
+
const updateClauses: string[] = []
|
|
201
|
+
const queryBindings: any[] = []
|
|
202
|
+
|
|
203
|
+
if (status) {
|
|
204
|
+
updateClauses.push('status = ?')
|
|
205
|
+
queryBindings.push(status)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const branch of seed.branches) {
|
|
209
|
+
if (Object.hasOwn(data, branch.alias)) {
|
|
210
|
+
updateClauses.push(`${branch.alias} = ?`)
|
|
211
|
+
queryBindings.push(serializeForDb(branch, data[branch.alias]))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (updateClauses.length === 0) return
|
|
216
|
+
|
|
217
|
+
updateClauses.push('updated_at = (unixepoch())')
|
|
218
|
+
|
|
219
|
+
const sql = `UPDATE ${tableName} SET ${updateClauses.join(', ')} WHERE id = ?`
|
|
220
|
+
queryBindings.push(id)
|
|
221
|
+
|
|
222
|
+
const updateResult = await this.database.prepare(sql).bind(...queryBindings).run()
|
|
223
|
+
if (updateResult.meta.changes === 0) {
|
|
224
|
+
throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error instanceof EntryNotFoundError) throw error
|
|
228
|
+
throw this.mapError(error, `update(${seed.slug}, ${id})`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async delete(seed: Seed, id: string): Promise<{ row: Record<string, any> }> {
|
|
233
|
+
try {
|
|
234
|
+
const tableName = this.getTableName(seed.slug)
|
|
235
|
+
|
|
236
|
+
// Retrieve the row before deletion to allow for potential cleanup (e.g., media files in R2)
|
|
237
|
+
const entryRow = await this.database
|
|
238
|
+
.prepare(`SELECT * FROM ${tableName} WHERE id = ?`)
|
|
239
|
+
.bind(id)
|
|
240
|
+
.first()
|
|
241
|
+
|
|
242
|
+
if (!entryRow) {
|
|
243
|
+
throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await this.database.prepare(`DELETE FROM ${tableName} WHERE id = ?`).bind(id).run()
|
|
247
|
+
|
|
248
|
+
return { row: this.rowToData(seed, entryRow) }
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (error instanceof EntryNotFoundError) throw error
|
|
251
|
+
throw this.mapError(error, `delete(${seed.slug}, ${id})`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async saveDraft(seed: Seed, entryId: string, data: Record<string, any>): Promise<void> {
|
|
256
|
+
try {
|
|
257
|
+
if (!seed.allowDrafts) {
|
|
258
|
+
throw new RepositoryError(`Drafts not allowed for ${seed.slug}`)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const draftTableName = this.getTableName(seed.slug, true)
|
|
262
|
+
const columnNames = ['entry_id']
|
|
263
|
+
const placeholders = ['?']
|
|
264
|
+
const queryBindings: any[] = [entryId]
|
|
265
|
+
const updateClauses: string[] = []
|
|
266
|
+
|
|
267
|
+
for (const branch of seed.branches) {
|
|
268
|
+
if (Object.hasOwn(data, branch.alias)) {
|
|
269
|
+
const serializedValue = serializeForDb(branch, data[branch.alias])
|
|
270
|
+
columnNames.push(branch.alias)
|
|
271
|
+
placeholders.push('?')
|
|
272
|
+
queryBindings.push(serializedValue)
|
|
273
|
+
updateClauses.push(`${branch.alias} = EXCLUDED.${branch.alias}`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
updateClauses.push('updated_at = (unixepoch())')
|
|
278
|
+
|
|
279
|
+
const sql = `
|
|
280
|
+
INSERT INTO ${draftTableName} (${columnNames.join(', ')})
|
|
281
|
+
VALUES (${placeholders.join(', ')})
|
|
282
|
+
ON CONFLICT(entry_id) DO UPDATE SET ${updateClauses.join(', ')}
|
|
283
|
+
`
|
|
284
|
+
await this.database.prepare(sql).bind(...queryBindings).run()
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async getDraft(seed: Seed, entryId: string): Promise<Record<string, any> | null> {
|
|
291
|
+
try {
|
|
292
|
+
if (!seed.allowDrafts) return null
|
|
293
|
+
|
|
294
|
+
const draftTableName = this.getTableName(seed.slug, true)
|
|
295
|
+
const draftRow = await this.database
|
|
296
|
+
.prepare(`SELECT * FROM ${draftTableName} WHERE entry_id = ?`)
|
|
297
|
+
.bind(entryId)
|
|
298
|
+
.first()
|
|
299
|
+
|
|
300
|
+
if (!draftRow) return null
|
|
301
|
+
|
|
302
|
+
// Filter out nulls from draft row (only include explicitly provided fields)
|
|
303
|
+
const draftData: Record<string, any> = {}
|
|
304
|
+
for (const branch of seed.branches) {
|
|
305
|
+
if (draftRow[branch.alias] !== null) {
|
|
306
|
+
draftData[branch.alias] = deserializeFromDb(branch, draftRow[branch.alias])
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return draftData
|
|
311
|
+
} catch (error) {
|
|
312
|
+
throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async hasDraft(seed: Seed, entryId: string): Promise<boolean> {
|
|
317
|
+
try {
|
|
318
|
+
if (!seed.allowDrafts) return false
|
|
319
|
+
const draftTableName = this.getTableName(seed.slug, true)
|
|
320
|
+
const draftExistsResult = await this.database
|
|
321
|
+
.prepare(`SELECT 1 FROM ${draftTableName} WHERE entry_id = ? LIMIT 1`)
|
|
322
|
+
.bind(entryId)
|
|
323
|
+
.first()
|
|
324
|
+
return draftExistsResult !== null
|
|
325
|
+
} catch (error) {
|
|
326
|
+
throw this.mapError(error, `hasDraft(${seed.slug}, ${entryId})`)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async publishDraft(seed: Seed, entryId: string): Promise<void> {
|
|
331
|
+
try {
|
|
332
|
+
if (!seed.allowDrafts) return
|
|
333
|
+
|
|
334
|
+
const draftTableName = this.getTableName(seed.slug, true)
|
|
335
|
+
const liveTableName = this.getTableName(seed.slug)
|
|
336
|
+
|
|
337
|
+
const draftRow = await this.database
|
|
338
|
+
.prepare(`SELECT * FROM ${draftTableName} WHERE entry_id = ?`)
|
|
339
|
+
.bind(entryId)
|
|
340
|
+
.first()
|
|
341
|
+
|
|
342
|
+
if (!draftRow) {
|
|
343
|
+
throw new EntryNotFoundError(`No draft found for ${entryId} in ${seed.slug}`)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Build UPDATE clause for the live table using data from the draft
|
|
347
|
+
const updateClauses: string[] = []
|
|
348
|
+
const queryBindings: any[] = []
|
|
349
|
+
|
|
350
|
+
for (const branch of seed.branches) {
|
|
351
|
+
if (draftRow[branch.alias] !== null) {
|
|
352
|
+
updateClauses.push(`${branch.alias} = ?`)
|
|
353
|
+
queryBindings.push(draftRow[branch.alias])
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
updateClauses.push('updated_at = (unixepoch())')
|
|
358
|
+
|
|
359
|
+
const updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(', ')} WHERE id = ?`
|
|
360
|
+
queryBindings.push(entryId)
|
|
361
|
+
|
|
362
|
+
// Execute batch to atomically update live table and delete draft
|
|
363
|
+
await this.database.batch([
|
|
364
|
+
this.database.prepare(updateSql).bind(...queryBindings),
|
|
365
|
+
this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId)
|
|
366
|
+
])
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error instanceof EntryNotFoundError) throw error
|
|
369
|
+
throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async deleteDraft(seed: Seed, entryId: string): Promise<void> {
|
|
374
|
+
try {
|
|
375
|
+
if (!seed.allowDrafts) return
|
|
376
|
+
const draftTableName = this.getTableName(seed.slug, true)
|
|
377
|
+
await this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId).run()
|
|
378
|
+
} catch (error) {
|
|
379
|
+
throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|