@beechcms/api 0.4.3 → 0.6.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/migrations/0030_test_seeds.sql +125 -0
- package/package.json +14 -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 +38 -34
- package/src/public/entry-projection.ts +46 -42
- package/src/public/idempotency.ts +23 -19
- 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 +54 -50
- package/src/public/read-single.ts +48 -44
- 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 +5 -1
- package/src/search.ts +5 -1
- 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-BKWnlvnV.css +0 -1
- package/assets/dashboard/assets/index-BMkd1Irh.js +0 -629
- package/src/shared/storage/r2-binding-bucket.ts +0 -81
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
6
|
+
import { Hono } from 'hono'
|
|
7
|
+
import type { BackrefMap } from '@beechcms/core'
|
|
8
|
+
import { backrefsApp } from '../backrefs.handler'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Seed fixtures
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const teamSeed = {
|
|
15
|
+
slug: 'team',
|
|
16
|
+
label: 'Team Member',
|
|
17
|
+
labelPlural: 'Team Members',
|
|
18
|
+
displayNameAlias: 'name',
|
|
19
|
+
branches: [
|
|
20
|
+
{ id: 'br_01', alias: 'name', label: 'Name', type: 'text' as const },
|
|
21
|
+
],
|
|
22
|
+
allowPublicRead: false,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const articlesSeed = {
|
|
26
|
+
slug: 'articles',
|
|
27
|
+
label: 'Article',
|
|
28
|
+
labelPlural: 'Articles',
|
|
29
|
+
displayNameAlias: 'title',
|
|
30
|
+
branches: [
|
|
31
|
+
{ id: 'br_01', alias: 'title', label: 'Title', type: 'text' as const },
|
|
32
|
+
{ id: 'br_02', alias: 'author_id', label: 'Author', type: 'relation' as const, targetSeed: 'team' },
|
|
33
|
+
],
|
|
34
|
+
allowPublicRead: false,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tagsSeed = {
|
|
38
|
+
slug: 'tags',
|
|
39
|
+
label: 'Tag',
|
|
40
|
+
labelPlural: 'Tags',
|
|
41
|
+
displayNameAlias: 'name',
|
|
42
|
+
branches: [
|
|
43
|
+
{ id: 'br_01', alias: 'name', label: 'Name', type: 'text' as const },
|
|
44
|
+
],
|
|
45
|
+
allowPublicRead: false,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const articleTagsSeed = {
|
|
49
|
+
slug: 'articles',
|
|
50
|
+
label: 'Article',
|
|
51
|
+
labelPlural: 'Articles',
|
|
52
|
+
displayNameAlias: 'title',
|
|
53
|
+
branches: [
|
|
54
|
+
{ id: 'br_01', alias: 'title', label: 'Title', type: 'text' as const },
|
|
55
|
+
{ id: 'br_02', alias: 'tags', label: 'Tags', type: 'relation' as const, targetSeed: 'tags', multiple: true },
|
|
56
|
+
],
|
|
57
|
+
allowPublicRead: false,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// D1 mock helpers
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function makeD1First<T>(result: T | null) {
|
|
65
|
+
return vi.fn().mockResolvedValue(result)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeD1All<T>(results: T[]) {
|
|
69
|
+
return vi.fn().mockResolvedValue({ results })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface D1PrepareStub {
|
|
73
|
+
firstResult?: unknown
|
|
74
|
+
allResults?: unknown[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeD1(stubs: D1PrepareStub[] = []) {
|
|
78
|
+
let callCount = 0
|
|
79
|
+
return {
|
|
80
|
+
prepare: vi.fn().mockImplementation(() => {
|
|
81
|
+
const stub = stubs[callCount++] ?? { firstResult: null, allResults: [] }
|
|
82
|
+
return {
|
|
83
|
+
bind: vi.fn().mockReturnThis(),
|
|
84
|
+
first: makeD1First(stub.firstResult ?? null),
|
|
85
|
+
all: makeD1All((stub.allResults ?? []) as unknown[]),
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// App builder
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function buildApp({
|
|
96
|
+
db,
|
|
97
|
+
backrefMap,
|
|
98
|
+
getSeed,
|
|
99
|
+
}: {
|
|
100
|
+
db: ReturnType<typeof makeD1>
|
|
101
|
+
backrefMap: BackrefMap
|
|
102
|
+
getSeed: (slug: string) => unknown
|
|
103
|
+
}) {
|
|
104
|
+
const app = new Hono()
|
|
105
|
+
app.use('*', async (c, next) => {
|
|
106
|
+
;(c as any).env = { DB: db }
|
|
107
|
+
;(c as any).set('backrefMap', backrefMap)
|
|
108
|
+
;(c as any).set('getSeed', getSeed)
|
|
109
|
+
await next()
|
|
110
|
+
})
|
|
111
|
+
app.route('/', backrefsApp)
|
|
112
|
+
return app
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Tests
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
describe('GET /:targetSlug/:targetId/backrefs', () => {
|
|
120
|
+
describe('404 cases', () => {
|
|
121
|
+
it('returns 404 when target slug is unknown', async () => {
|
|
122
|
+
const db = makeD1()
|
|
123
|
+
const app = buildApp({
|
|
124
|
+
db,
|
|
125
|
+
backrefMap: new Map(),
|
|
126
|
+
getSeed: () => null,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const res = await app.request('/unknown-slug/id-1/backrefs')
|
|
130
|
+
expect(res.status).toBe(404)
|
|
131
|
+
const body = await res.json() as { type: string }
|
|
132
|
+
expect(body.type).toContain('not-found')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('returns 404 when target id does not exist in DB', async () => {
|
|
136
|
+
const db = makeD1([{ firstResult: null }])
|
|
137
|
+
const app = buildApp({
|
|
138
|
+
db,
|
|
139
|
+
backrefMap: new Map(),
|
|
140
|
+
getSeed: (s) => s === 'team' ? teamSeed : null,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const res = await app.request('/team/nonexistent-id/backrefs')
|
|
144
|
+
expect(res.status).toBe(404)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('no inbound relations', () => {
|
|
149
|
+
it('returns empty groups when backrefMap has no entry for target', async () => {
|
|
150
|
+
const db = makeD1([{ firstResult: { id: 'ada-1' } }])
|
|
151
|
+
const app = buildApp({
|
|
152
|
+
db,
|
|
153
|
+
backrefMap: new Map(),
|
|
154
|
+
getSeed: (s) => s === 'team' ? teamSeed : null,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const res = await app.request('/team/ada-1/backrefs')
|
|
158
|
+
expect(res.status).toBe(200)
|
|
159
|
+
const body = await res.json() as { groups: unknown[] }
|
|
160
|
+
expect(body.groups).toHaveLength(0)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('single-value source with 5 referencing rows', () => {
|
|
165
|
+
it('preview: returns 3 items, total=5, restricts=false', async () => {
|
|
166
|
+
const backrefMap: BackrefMap = new Map([
|
|
167
|
+
['team', [{
|
|
168
|
+
sourceSlug: 'articles',
|
|
169
|
+
branchAlias: 'author_id',
|
|
170
|
+
branchLabel: 'Author',
|
|
171
|
+
relationship: 'single',
|
|
172
|
+
restricts: false,
|
|
173
|
+
}]],
|
|
174
|
+
])
|
|
175
|
+
|
|
176
|
+
const previewItems = Array.from({ length: 3 }, (_, i) => ({
|
|
177
|
+
id: `art-${i + 1}`,
|
|
178
|
+
displayName: `Article ${i + 1}`,
|
|
179
|
+
status: 'published',
|
|
180
|
+
updated_at: 1000 + i,
|
|
181
|
+
}))
|
|
182
|
+
|
|
183
|
+
// prepare calls: 1) exists check, 2) rows query, 3) count query
|
|
184
|
+
const db = makeD1([
|
|
185
|
+
{ firstResult: { id: 'ada-1' } }, // exists
|
|
186
|
+
{ allResults: previewItems }, // rows
|
|
187
|
+
{ firstResult: { total: 5 } }, // count
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
const app = buildApp({
|
|
191
|
+
db,
|
|
192
|
+
backrefMap,
|
|
193
|
+
getSeed: (s) => {
|
|
194
|
+
if (s === 'team') return teamSeed
|
|
195
|
+
if (s === 'articles') return articlesSeed
|
|
196
|
+
return null
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const res = await app.request('/team/ada-1/backrefs')
|
|
201
|
+
expect(res.status).toBe(200)
|
|
202
|
+
const body = await res.json() as { groups: any[] }
|
|
203
|
+
expect(body.groups).toHaveLength(1)
|
|
204
|
+
const g = body.groups[0]
|
|
205
|
+
expect(g.total).toBe(5)
|
|
206
|
+
expect(g.items).toHaveLength(3)
|
|
207
|
+
expect(g.restricts).toBe(false)
|
|
208
|
+
expect(g.relationship).toBe('single')
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('multi-relation source with 12 referencing rows', () => {
|
|
213
|
+
it('preview: returns 3 items, total=12', async () => {
|
|
214
|
+
const backrefMap: BackrefMap = new Map([
|
|
215
|
+
['tags', [{
|
|
216
|
+
sourceSlug: 'articles',
|
|
217
|
+
branchAlias: 'tags',
|
|
218
|
+
branchLabel: 'Tags',
|
|
219
|
+
relationship: 'multi',
|
|
220
|
+
restricts: false,
|
|
221
|
+
}]],
|
|
222
|
+
])
|
|
223
|
+
|
|
224
|
+
const previewItems = Array.from({ length: 3 }, (_, i) => ({
|
|
225
|
+
id: `art-${i}`,
|
|
226
|
+
displayName: `Article ${i}`,
|
|
227
|
+
status: 'published',
|
|
228
|
+
updated_at: 1000,
|
|
229
|
+
}))
|
|
230
|
+
|
|
231
|
+
const db = makeD1([
|
|
232
|
+
{ firstResult: { id: 'tag-1' } }, // exists
|
|
233
|
+
{ allResults: previewItems }, // rows
|
|
234
|
+
{ firstResult: { total: 12 } }, // count
|
|
235
|
+
])
|
|
236
|
+
|
|
237
|
+
const app = buildApp({
|
|
238
|
+
db,
|
|
239
|
+
backrefMap,
|
|
240
|
+
getSeed: (s) => {
|
|
241
|
+
if (s === 'tags') return tagsSeed
|
|
242
|
+
if (s === 'articles') return articleTagsSeed
|
|
243
|
+
return null
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const res = await app.request('/tags/tag-1/backrefs')
|
|
248
|
+
expect(res.status).toBe(200)
|
|
249
|
+
const body = await res.json() as { groups: any[] }
|
|
250
|
+
const g = body.groups[0]
|
|
251
|
+
expect(g.total).toBe(12)
|
|
252
|
+
expect(g.items).toHaveLength(3)
|
|
253
|
+
expect(g.relationship).toBe('multi')
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('paginated ?group= request', () => {
|
|
258
|
+
it('page=2&limit=4 returns correct offset (rows 5–8)', async () => {
|
|
259
|
+
const backrefMap: BackrefMap = new Map([
|
|
260
|
+
['team', [{
|
|
261
|
+
sourceSlug: 'articles',
|
|
262
|
+
branchAlias: 'author_id',
|
|
263
|
+
branchLabel: 'Author',
|
|
264
|
+
relationship: 'single',
|
|
265
|
+
restricts: false,
|
|
266
|
+
}]],
|
|
267
|
+
])
|
|
268
|
+
|
|
269
|
+
const page2Items = Array.from({ length: 4 }, (_, i) => ({
|
|
270
|
+
id: `art-${i + 5}`,
|
|
271
|
+
displayName: `Article ${i + 5}`,
|
|
272
|
+
status: 'published',
|
|
273
|
+
updated_at: 1000,
|
|
274
|
+
}))
|
|
275
|
+
|
|
276
|
+
const db = makeD1([
|
|
277
|
+
{ firstResult: { id: 'ada-1' } }, // exists
|
|
278
|
+
{ allResults: page2Items }, // rows (page 2, limit 4 → offset 4)
|
|
279
|
+
{ firstResult: { total: 12 } }, // count
|
|
280
|
+
])
|
|
281
|
+
|
|
282
|
+
const app = buildApp({
|
|
283
|
+
db,
|
|
284
|
+
backrefMap,
|
|
285
|
+
getSeed: (s) => {
|
|
286
|
+
if (s === 'team') return teamSeed
|
|
287
|
+
if (s === 'articles') return articlesSeed
|
|
288
|
+
return null
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const res = await app.request('/team/ada-1/backrefs?group=articles:author_id&page=2&limit=4')
|
|
293
|
+
expect(res.status).toBe(200)
|
|
294
|
+
const body = await res.json() as { groups: any[] }
|
|
295
|
+
expect(body.groups).toHaveLength(1)
|
|
296
|
+
const g = body.groups[0]
|
|
297
|
+
expect(g.items).toHaveLength(4)
|
|
298
|
+
expect(g.items[0].id).toBe('art-5')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('returns 404 for unknown group key', async () => {
|
|
302
|
+
const backrefMap: BackrefMap = new Map([
|
|
303
|
+
['team', [{
|
|
304
|
+
sourceSlug: 'articles',
|
|
305
|
+
branchAlias: 'author_id',
|
|
306
|
+
branchLabel: 'Author',
|
|
307
|
+
relationship: 'single',
|
|
308
|
+
restricts: false,
|
|
309
|
+
}]],
|
|
310
|
+
])
|
|
311
|
+
|
|
312
|
+
const db = makeD1([{ firstResult: { id: 'ada-1' } }])
|
|
313
|
+
|
|
314
|
+
const app = buildApp({
|
|
315
|
+
db,
|
|
316
|
+
backrefMap,
|
|
317
|
+
getSeed: (s) => s === 'team' ? teamSeed : null,
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
const res = await app.request('/team/ada-1/backrefs?group=articles:unknown_field')
|
|
321
|
+
expect(res.status).toBe(404)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe('restricts flag', () => {
|
|
326
|
+
it('exposes restricts=true when branch has onDelete RESTRICT', async () => {
|
|
327
|
+
const backrefMap: BackrefMap = new Map([
|
|
328
|
+
['team', [{
|
|
329
|
+
sourceSlug: 'articles',
|
|
330
|
+
branchAlias: 'author_id',
|
|
331
|
+
branchLabel: 'Author',
|
|
332
|
+
relationship: 'single',
|
|
333
|
+
restricts: true,
|
|
334
|
+
}]],
|
|
335
|
+
])
|
|
336
|
+
|
|
337
|
+
const db = makeD1([
|
|
338
|
+
{ firstResult: { id: 'ada-1' } },
|
|
339
|
+
{ allResults: [{ id: 'art-1', displayName: 'Art', status: 'published', updated_at: 1000 }] },
|
|
340
|
+
{ firstResult: { total: 1 } },
|
|
341
|
+
])
|
|
342
|
+
|
|
343
|
+
const app = buildApp({
|
|
344
|
+
db,
|
|
345
|
+
backrefMap,
|
|
346
|
+
getSeed: (s) => {
|
|
347
|
+
if (s === 'team') return teamSeed
|
|
348
|
+
if (s === 'articles') return articlesSeed
|
|
349
|
+
return null
|
|
350
|
+
},
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const res = await app.request('/team/ada-1/backrefs')
|
|
354
|
+
expect(res.status).toBe(200)
|
|
355
|
+
const body = await res.json() as { groups: any[] }
|
|
356
|
+
expect(body.groups[0].restricts).toBe(true)
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
6
|
+
import { Hono } from 'hono'
|
|
7
|
+
import { resolvePolicies } from '@beechcms/core'
|
|
8
|
+
import { publicProblem } from '../../public/problem-details'
|
|
9
|
+
import type { AppEnv } from '../../types'
|
|
10
|
+
import { D1BackrefRepository, type BackrefItem } from './d1-backref.repository'
|
|
11
|
+
|
|
12
|
+
const PREVIEW_LIMIT = 3
|
|
13
|
+
const DEFAULT_PAGE_LIMIT = 20
|
|
14
|
+
const MAX_PAGE_LIMIT = 100
|
|
15
|
+
|
|
16
|
+
export const backrefsApp = new Hono<AppEnv>()
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// GET /content/:targetSlug/:targetId/backrefs
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
backrefsApp.get('/:targetSlug/:targetId/backrefs', async (c) => {
|
|
23
|
+
const targetSlug = c.req.param('targetSlug')
|
|
24
|
+
const targetId = c.req.param('targetId')
|
|
25
|
+
const groupParam = c.req.query('group') // '<sourceSlug>:<branchAlias>'
|
|
26
|
+
const pageParam = c.req.query('page')
|
|
27
|
+
const limitParam = c.req.query('limit')
|
|
28
|
+
|
|
29
|
+
const getSeed = c.get('getSeed')
|
|
30
|
+
const backrefMap = c.get('backrefMap')
|
|
31
|
+
const backrefRepository = new D1BackrefRepository(c.env.DB)
|
|
32
|
+
|
|
33
|
+
// 1. Resolve target seed
|
|
34
|
+
const targetSeed = getSeed(targetSlug)
|
|
35
|
+
if (!targetSeed) {
|
|
36
|
+
return publicProblem(c, {
|
|
37
|
+
type: 'not-found',
|
|
38
|
+
title: 'Not Found',
|
|
39
|
+
status: 404,
|
|
40
|
+
detail: `Seed '${targetSlug}' does not exist`,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Verify targetId exists
|
|
45
|
+
const exists = await backrefRepository.entryExists(targetSlug, targetId)
|
|
46
|
+
if (!exists) {
|
|
47
|
+
return publicProblem(c, {
|
|
48
|
+
type: 'not-found',
|
|
49
|
+
title: 'Not Found',
|
|
50
|
+
status: 404,
|
|
51
|
+
detail: `Entry '${targetId}' not found in '${targetSlug}'`,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. Get sources for this target
|
|
56
|
+
const sources = backrefMap.get(targetSlug)
|
|
57
|
+
if (!sources || sources.length === 0) {
|
|
58
|
+
return c.json({ groups: [] })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 4. Pagination
|
|
62
|
+
const page = Math.max(1, Number.parseInt(pageParam ?? '1', 10) || 1)
|
|
63
|
+
const rawLimit = Number.parseInt(limitParam ?? String(DEFAULT_PAGE_LIMIT), 10) || DEFAULT_PAGE_LIMIT
|
|
64
|
+
const limit = Math.min(Math.max(1, rawLimit), MAX_PAGE_LIMIT)
|
|
65
|
+
const offset = (page - 1) * limit
|
|
66
|
+
|
|
67
|
+
// 5. Paginated single-group request
|
|
68
|
+
if (groupParam) {
|
|
69
|
+
const colonIdx = groupParam.indexOf(':')
|
|
70
|
+
if (colonIdx === -1) {
|
|
71
|
+
return publicProblem(c, {
|
|
72
|
+
type: 'bad-request',
|
|
73
|
+
title: 'Bad Request',
|
|
74
|
+
status: 400,
|
|
75
|
+
detail: `'group' must be '<sourceSlug>:<branchAlias>'`,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
const sourceSlug = groupParam.slice(0, colonIdx)
|
|
79
|
+
const branchAlias = groupParam.slice(colonIdx + 1)
|
|
80
|
+
|
|
81
|
+
const source = sources.find(
|
|
82
|
+
s => s.sourceSlug === sourceSlug && s.branchAlias === branchAlias
|
|
83
|
+
)
|
|
84
|
+
if (!source) {
|
|
85
|
+
return publicProblem(c, {
|
|
86
|
+
type: 'not-found',
|
|
87
|
+
title: 'Not Found',
|
|
88
|
+
status: 404,
|
|
89
|
+
detail: `Back-ref group '${groupParam}' not found for target '${targetSlug}'`,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const sourceSeed = getSeed(sourceSlug)
|
|
94
|
+
if (!sourceSeed) {
|
|
95
|
+
return c.json({ groups: [] })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { items, total } = await backrefRepository.queryGroup(source, sourceSeed, targetId, limit, offset)
|
|
99
|
+
const filteredItems = filterVisibility(items, sourceSeed)
|
|
100
|
+
|
|
101
|
+
return c.json({
|
|
102
|
+
groups: [{
|
|
103
|
+
sourceSlug: source.sourceSlug,
|
|
104
|
+
sourceLabel: sourceSeed.labelPlural ?? sourceSeed.label,
|
|
105
|
+
branchAlias: source.branchAlias,
|
|
106
|
+
branchLabel: source.branchLabel,
|
|
107
|
+
relationship: source.relationship,
|
|
108
|
+
restricts: source.restricts,
|
|
109
|
+
total,
|
|
110
|
+
items: filteredItems,
|
|
111
|
+
}],
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 6. Preview mode — all groups, PREVIEW_LIMIT each
|
|
116
|
+
const groupResults = await Promise.all(
|
|
117
|
+
sources.map(async (source) => {
|
|
118
|
+
const sourceSeed = getSeed(source.sourceSlug)
|
|
119
|
+
if (!sourceSeed) return null
|
|
120
|
+
|
|
121
|
+
const { items, total } = await backrefRepository.queryGroup(source, sourceSeed, targetId, PREVIEW_LIMIT, 0)
|
|
122
|
+
const filteredItems = filterVisibility(items, sourceSeed)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
sourceSlug: source.sourceSlug,
|
|
126
|
+
sourceLabel: sourceSeed.labelPlural ?? sourceSeed.label,
|
|
127
|
+
branchAlias: source.branchAlias,
|
|
128
|
+
branchLabel: source.branchLabel,
|
|
129
|
+
relationship: source.relationship,
|
|
130
|
+
restricts: source.restricts,
|
|
131
|
+
total,
|
|
132
|
+
items: filteredItems,
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const groups = groupResults.filter(
|
|
138
|
+
(g): g is NonNullable<typeof g> => g !== null && g.total > 0
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return c.json({ groups })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Internal helpers
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
function filterVisibility(
|
|
149
|
+
items: BackrefItem[],
|
|
150
|
+
sourceSeed: import('@beechcms/core').Seed
|
|
151
|
+
): BackrefItem[] {
|
|
152
|
+
const displayBranch = sourceSeed.branches.find(
|
|
153
|
+
b => b.alias === sourceSeed.displayNameAlias
|
|
154
|
+
)
|
|
155
|
+
if (!displayBranch) return items
|
|
156
|
+
|
|
157
|
+
const { visibility } = resolvePolicies(displayBranch)
|
|
158
|
+
if (visibility === 'hidden') {
|
|
159
|
+
return items.map(item => ({ ...item, displayName: null }))
|
|
160
|
+
}
|
|
161
|
+
if (visibility === 'masked') {
|
|
162
|
+
return items.map(item => ({
|
|
163
|
+
...item,
|
|
164
|
+
displayName: item.displayName ? '••••••••' : null,
|
|
165
|
+
}))
|
|
166
|
+
}
|
|
167
|
+
return items
|
|
168
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
6
|
+
import type { BackrefSource, Seed } from '@beechcms/core'
|
|
7
|
+
|
|
8
|
+
export interface BackrefItem {
|
|
9
|
+
id: string
|
|
10
|
+
displayName: string | null
|
|
11
|
+
status: string
|
|
12
|
+
updated_at: number | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface QueryGroupResult {
|
|
16
|
+
items: BackrefItem[]
|
|
17
|
+
total: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class D1BackrefRepository {
|
|
21
|
+
constructor(private readonly db: D1Database) {}
|
|
22
|
+
|
|
23
|
+
async entryExists(slug: string, id: string): Promise<boolean> {
|
|
24
|
+
const row = await this.db
|
|
25
|
+
.prepare(`SELECT id FROM content_${slug} WHERE id = ? LIMIT 1`)
|
|
26
|
+
.bind(id)
|
|
27
|
+
.first<{ id: string }>()
|
|
28
|
+
return row !== null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async queryGroup(
|
|
32
|
+
source: BackrefSource,
|
|
33
|
+
sourceSeed: Seed,
|
|
34
|
+
targetId: string,
|
|
35
|
+
limit: number,
|
|
36
|
+
offset: number,
|
|
37
|
+
): Promise<QueryGroupResult> {
|
|
38
|
+
const displayCol = sourceSeed.displayNameAlias
|
|
39
|
+
|
|
40
|
+
if (source.relationship === 'single') {
|
|
41
|
+
const [rowsResult, countResult] = await Promise.all([
|
|
42
|
+
this.db
|
|
43
|
+
.prepare(
|
|
44
|
+
`SELECT id, status, updated_at, ${displayCol} AS displayName
|
|
45
|
+
FROM content_${source.sourceSlug}
|
|
46
|
+
WHERE ${source.branchAlias} = ?
|
|
47
|
+
ORDER BY updated_at DESC
|
|
48
|
+
LIMIT ? OFFSET ?`,
|
|
49
|
+
)
|
|
50
|
+
.bind(targetId, limit, offset)
|
|
51
|
+
.all<BackrefItem>(),
|
|
52
|
+
this.db
|
|
53
|
+
.prepare(
|
|
54
|
+
`SELECT COUNT(*) AS total FROM content_${source.sourceSlug} WHERE ${source.branchAlias} = ?`,
|
|
55
|
+
)
|
|
56
|
+
.bind(targetId)
|
|
57
|
+
.first<{ total: number }>(),
|
|
58
|
+
])
|
|
59
|
+
return { items: rowsResult.results ?? [], total: countResult?.total ?? 0 }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const joinTable = `rel_${source.sourceSlug}_${source.branchAlias}`
|
|
63
|
+
const [rowsResult, countResult] = await Promise.all([
|
|
64
|
+
this.db
|
|
65
|
+
.prepare(
|
|
66
|
+
`SELECT c.id, c.status, c.updated_at, c.${displayCol} AS displayName
|
|
67
|
+
FROM content_${source.sourceSlug} c
|
|
68
|
+
JOIN ${joinTable} r ON r.parent_id = c.id
|
|
69
|
+
WHERE r.target_id = ?
|
|
70
|
+
ORDER BY c.updated_at DESC
|
|
71
|
+
LIMIT ? OFFSET ?`,
|
|
72
|
+
)
|
|
73
|
+
.bind(targetId, limit, offset)
|
|
74
|
+
.all<BackrefItem>(),
|
|
75
|
+
this.db
|
|
76
|
+
.prepare(
|
|
77
|
+
`SELECT COUNT(DISTINCT parent_id) AS total FROM ${joinTable} WHERE target_id = ?`,
|
|
78
|
+
)
|
|
79
|
+
.bind(targetId)
|
|
80
|
+
.first<{ total: number }>(),
|
|
81
|
+
])
|
|
82
|
+
return { items: rowsResult.results ?? [], total: countResult?.total ?? 0 }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
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
|
export const CONTENT_ERRORS = {
|
|
2
6
|
INVALID_SLUG: 'Invalid slug',
|
|
3
7
|
INVALID_SLUG_OR_ID: 'Invalid slug or id',
|
|
@@ -7,4 +11,6 @@ export const CONTENT_ERRORS = {
|
|
|
7
11
|
DATABASE_ERROR: 'Database error',
|
|
8
12
|
SLUG_CONFLICT: 'Slug already exists for this schema',
|
|
9
13
|
SENSITIVE_FIELD_EDIT: 'Cannot edit sensitive fields',
|
|
14
|
+
BULK_SIZE_EXCEEDED: 'Cannot edit more than 500 entries at once',
|
|
15
|
+
FIELD_NOT_BULK_EDITABLE: 'Field cannot be bulk-edited',
|
|
10
16
|
} as const
|