@beechcms/api 0.6.0-preview.1 → 0.6.0-preview.2
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/chart-BrJFdsGZ.js +39 -0
- package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
- package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
- package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
- package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/noResult.svg +42 -42
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +265 -265
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +2 -2
- package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
- package/src/auth/__fixtures__/static-token-service.ts +22 -22
- package/src/auth/bcrypt-hash-provider.ts +24 -24
- package/src/auth/constants.ts +14 -14
- package/src/auth/generate-refresh-token.test.ts +23 -23
- package/src/auth/hash-provider.test.ts +55 -55
- package/src/auth/jose-token-service.ts +62 -62
- package/src/auth/jwt-claims-passthrough.test.ts +87 -87
- package/src/auth/login.test.ts +100 -100
- package/src/auth/login.ts +78 -78
- package/src/auth/refresh.ts +11 -11
- package/src/auth/token-service.test.ts +90 -90
- package/src/factory.ts +370 -368
- package/src/features/automations/__tests__/action-executors.test.ts +255 -255
- package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
- package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
- package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
- package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
- package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
- package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
- package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
- package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
- package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
- package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
- package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
- package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
- package/src/features/automations/action-executors/index.ts +37 -37
- package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
- package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
- package/src/features/automations/action-executors/webhook.executor.ts +67 -67
- package/src/features/automations/automation-runner.ts +85 -85
- package/src/features/automations/automation-runner.utils.ts +47 -47
- package/src/features/automations/automations.handler.ts +197 -197
- package/src/features/automations/automations.schema.ts +177 -177
- package/src/features/automations/context-resolver.ts +152 -152
- package/src/features/automations/cron-runner.ts +140 -140
- package/src/features/automations/cron-runner.utils.ts +44 -44
- package/src/features/automations/filter-translation.ts +46 -46
- package/src/features/automations/index.ts +16 -16
- package/src/features/automations/template-grammar.ts +245 -245
- package/src/features/automations/var-access-resolver.ts +140 -140
- package/src/features/automations/when-evaluator.ts +87 -87
- package/src/features/automations/when-pushdown.ts +57 -57
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
- package/src/features/backrefs/backrefs.handler.ts +168 -168
- package/src/features/backrefs/d1-backref.repository.ts +84 -84
- package/src/features/backrefs/index.ts +5 -5
- package/src/features/content/constants.ts +16 -16
- package/src/features/content/handlers/bulk.handler.ts +185 -185
- package/src/features/content/handlers/create.ts +129 -129
- package/src/features/content/handlers/delete.ts +62 -62
- package/src/features/content/handlers/facets.ts +49 -49
- package/src/features/content/handlers/get.ts +120 -120
- package/src/features/content/handlers/helpers.test.ts +180 -180
- package/src/features/content/handlers/helpers.ts +93 -93
- package/src/features/content/handlers/list.ts +174 -174
- package/src/features/content/handlers/update.ts +174 -174
- package/src/features/content/index.ts +26 -26
- package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
- package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
- package/src/features/dashboard-layout/index.ts +5 -0
- package/src/features/draft/draft.handler.ts +195 -195
- package/src/features/draft/draft.middleware.ts +66 -66
- package/src/features/draft/index.ts +5 -5
- package/src/features/email/email.provider.ts +42 -42
- package/src/features/email/email.service.ts +98 -98
- package/src/features/email/email.types.ts +119 -119
- package/src/features/email/index.ts +33 -33
- package/src/features/email/providers/resend.ts +67 -67
- package/src/features/email/providers/smtp.ts +55 -55
- package/src/features/email/templates/automation-mail.ts +19 -19
- package/src/features/email/templates/password-changed.ts +63 -63
- package/src/features/email/templates/password-reset.ts +68 -68
- package/src/features/email/templates/shell.ts +96 -96
- package/src/features/notifications/index.ts +5 -5
- package/src/features/notifications/notifications.handler.ts +105 -105
- package/src/features/password-reset/index.ts +19 -19
- package/src/features/password-reset/request.ts +92 -92
- package/src/features/password-reset/reset.ts +102 -102
- package/src/features/rotate-field/index.ts +5 -5
- package/src/features/rotate-field/rotate-field.handler.ts +132 -132
- package/src/features/rotate-field/rotate-field.schema.ts +17 -17
- package/src/features/schema/schema.handler.ts +131 -131
- package/src/features/seeds/index.ts +5 -5
- package/src/features/seeds/seeds.handler.test.ts +1199 -632
- package/src/features/seeds/seeds.handler.ts +558 -693
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
- package/src/features/settings/settings.handler.ts +392 -392
- package/src/features/setup/index.ts +288 -204
- package/src/features/stats/index.ts +5 -5
- package/src/features/stats/stats.handler.ts +420 -420
- package/src/features/webhooks/index.ts +63 -63
- package/src/index.ts +65 -65
- package/src/media-utils.ts +82 -82
- package/src/middleware/auth-providers.middleware.test.ts +85 -0
- package/src/middleware/auth-providers.middleware.ts +45 -45
- package/src/middleware/observability.middleware.test.ts +93 -0
- package/src/middleware/observability.middleware.ts +70 -70
- package/src/middleware/rate-limit.middleware.ts +45 -45
- package/src/middleware/repository.middleware.ts +117 -114
- package/src/middleware/seed-registry.middleware.test.ts +97 -97
- package/src/middleware/seed-registry.middleware.ts +21 -21
- package/src/middleware/storage.middleware.ts +25 -25
- package/src/middleware.ts +45 -45
- package/src/public/access-policy.ts +27 -27
- package/src/public/api-key-middleware.ts +57 -57
- package/src/public/cache-utils.ts +38 -38
- package/src/public/entry-projection.ts +46 -46
- package/src/public/idempotency.ts +23 -23
- package/src/public/index.ts +16 -16
- package/src/public/problem-details.ts +124 -124
- package/src/public/public-add.ts +114 -114
- package/src/public/public-edit.ts +163 -163
- package/src/public/public-errors.ts +19 -19
- package/src/public/public-read.ts +63 -63
- package/src/public/public-routes.ts +88 -88
- package/src/public/query-builder.test.ts +224 -224
- package/src/public/query-builder.ts +156 -156
- package/src/public/rate-limit-middleware.ts +34 -34
- package/src/public/read-list.ts +54 -54
- package/src/public/read-single.ts +48 -48
- package/src/public/response-builder.ts +30 -30
- package/src/public/sanitize.ts +69 -69
- package/src/public/slug-utils.ts +18 -18
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
- package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
- package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
- package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
- package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
- package/src/rate-limit/no-op-rate-limiter.ts +11 -11
- package/src/search-utils.test.ts +211 -211
- package/src/search-utils.ts +213 -213
- package/src/search.ts +65 -65
- package/src/shared/apply-policies.test.ts +81 -81
- package/src/shared/apply-policies.ts +67 -67
- package/src/shared/automations.repository.d1.ts +150 -150
- package/src/shared/background-notification-service.test.ts +62 -62
- package/src/shared/background-notification-service.ts +52 -52
- package/src/shared/base.repository.d1.ts +32 -32
- package/src/shared/content-utils.test.ts +165 -165
- package/src/shared/content-utils.ts +86 -86
- package/src/shared/content.repository.d1.test.ts +507 -458
- package/src/shared/content.repository.d1.ts +851 -851
- package/src/shared/d1-activity-log.repository.test.ts +140 -140
- package/src/shared/d1-activity-log.repository.ts +105 -105
- package/src/shared/d1-activity-logger.test.ts +86 -86
- package/src/shared/d1-activity-logger.ts +67 -67
- package/src/shared/d1-analytics.repository.test.ts +78 -78
- package/src/shared/d1-analytics.repository.ts +85 -85
- package/src/shared/d1-content-scan.repository.test.ts +80 -80
- package/src/shared/d1-content-scan.repository.ts +37 -37
- package/src/shared/d1-notification.repository.test.ts +128 -128
- package/src/shared/d1-notification.repository.ts +118 -118
- package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
- package/src/shared/d1-password-reset-token.repository.ts +56 -56
- package/src/shared/d1-search.repository.test.ts +87 -87
- package/src/shared/d1-search.repository.ts +88 -88
- package/src/shared/d1-session.repository.test.ts +125 -125
- package/src/shared/d1-session.repository.ts +102 -102
- package/src/shared/d1-setup-checklist.repository.ts +36 -36
- package/src/shared/d1-user.repository.test.ts +151 -151
- package/src/shared/d1-user.repository.ts +119 -119
- package/src/shared/d1-widget.repository.test.ts +267 -221
- package/src/shared/d1-widget.repository.ts +385 -341
- package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
- package/src/shared/dashboard-layout.repository.d1.ts +58 -0
- package/src/shared/demo-data-sql.ts +216 -54
- package/src/shared/demo-data.repository.d1.ts +20 -20
- package/src/shared/execution-context-scheduler.ts +13 -13
- package/src/shared/fixed-clock.ts +25 -25
- package/src/shared/fts-sync.ts +8 -8
- package/src/shared/idempotency.repository.d1.test.ts +83 -83
- package/src/shared/idempotency.repository.d1.ts +60 -60
- package/src/shared/in-memory-activity-logger.ts +19 -19
- package/src/shared/in-memory-notification-service.ts +19 -19
- package/src/shared/in-memory-seed.repository.ts +58 -51
- package/src/shared/media.repository.d1.test.ts +107 -107
- package/src/shared/media.repository.d1.ts +68 -68
- package/src/shared/qstash-notification-service.test.ts +67 -67
- package/src/shared/qstash-notification-service.ts +55 -55
- package/src/shared/query-utils.ts +141 -141
- package/src/shared/request-utils.ts +26 -26
- package/src/shared/schema-mutator.d1.test.ts +142 -0
- package/src/shared/schema-mutator.d1.ts +64 -64
- package/src/shared/seed-layout.repository.d1.test.ts +114 -114
- package/src/shared/seed-layout.repository.d1.ts +62 -62
- package/src/shared/seed-registry-cache.test.ts +68 -68
- package/src/shared/seed-registry-cache.ts +49 -49
- package/src/shared/seed.repository.d1.test.ts +143 -143
- package/src/shared/seed.repository.d1.ts +98 -98
- package/src/shared/sequential-id-generator.ts +33 -33
- package/src/shared/site-settings.repository.d1.ts +53 -53
- package/src/shared/storage/factory.ts +41 -41
- package/src/shared/storage/s3-bucket.ts +195 -195
- package/src/shared/storage-utils.ts +40 -40
- package/src/shared/system-stats.repository.d1.test.ts +58 -58
- package/src/shared/system-stats.repository.d1.ts +48 -48
- package/src/types.ts +90 -87
- package/src/upload.ts +198 -198
- package/src/widget.test.ts +85 -0
- package/src/widget.ts +251 -212
- package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
- package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
|
@@ -1,141 +1,141 @@
|
|
|
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 type { FilterGroup, FilterType } from '@beechcms/core'
|
|
6
|
-
|
|
7
|
-
/** Entry parsata per le risposte API — contratto immutabile (C7). */
|
|
8
|
-
export interface ContentEntry {
|
|
9
|
-
id: string
|
|
10
|
-
schema_slug: string
|
|
11
|
-
slug: string | null
|
|
12
|
-
status: string
|
|
13
|
-
data: Record<string, unknown>
|
|
14
|
-
hasPendingDraft: boolean
|
|
15
|
-
created_at: number | null
|
|
16
|
-
updated_at: number | null
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export type QueryFilterType = FilterType
|
|
20
|
-
|
|
21
|
-
export type QueryFilterOperator =
|
|
22
|
-
| 'eq'
|
|
23
|
-
| 'gt'
|
|
24
|
-
| 'gte'
|
|
25
|
-
| 'lt'
|
|
26
|
-
| 'lte'
|
|
27
|
-
| 'contains'
|
|
28
|
-
| 'is_empty'
|
|
29
|
-
| 'is_not_empty'
|
|
30
|
-
|
|
31
|
-
export interface QueryFilterCondition {
|
|
32
|
-
id?: string
|
|
33
|
-
op: QueryFilterOperator
|
|
34
|
-
value: string | number | boolean | null
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Formato inviato dalla dashboard (usa columnId). */
|
|
38
|
-
export interface QueryFilterGroup {
|
|
39
|
-
columnId: string
|
|
40
|
-
label?: string
|
|
41
|
-
type: QueryFilterType
|
|
42
|
-
conditions: QueryFilterCondition[]
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const QUERY_FILTER_OPERATOR_SET = new Set<QueryFilterOperator>([
|
|
46
|
-
'eq', 'gt', 'gte', 'lt', 'lte', 'contains', 'is_empty', 'is_not_empty',
|
|
47
|
-
])
|
|
48
|
-
|
|
49
|
-
function isQueryFilterOperator(value: unknown): value is QueryFilterOperator {
|
|
50
|
-
return typeof value === 'string' && QUERY_FILTER_OPERATOR_SET.has(value as QueryFilterOperator)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function cleanStr(val: unknown): string | null {
|
|
54
|
-
return (typeof val === 'string' && val.trim()) || null
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function safeParseJson(data: unknown): Record<string, unknown> {
|
|
58
|
-
const cleaned = cleanStr(data)
|
|
59
|
-
if (!cleaned) return {}
|
|
60
|
-
try {
|
|
61
|
-
const parsed = JSON.parse(cleaned)
|
|
62
|
-
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
63
|
-
? (parsed as Record<string, unknown>)
|
|
64
|
-
: {}
|
|
65
|
-
} catch {
|
|
66
|
-
return {}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function parsePositiveInt(value: string | undefined, fallback: number): number {
|
|
71
|
-
if (!value) return fallback
|
|
72
|
-
const parsed = Number.parseInt(value, 10)
|
|
73
|
-
if (Number.isNaN(parsed) || parsed < 1) return fallback
|
|
74
|
-
return parsed
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function parseCondition(cond: unknown): QueryFilterCondition | null {
|
|
78
|
-
if (!cond || typeof cond !== 'object') return null
|
|
79
|
-
const candidate = cond as Record<string, unknown>
|
|
80
|
-
if (!isQueryFilterOperator(candidate.op)) return null
|
|
81
|
-
const rawValue = candidate.value
|
|
82
|
-
let parsedValue: QueryFilterCondition['value'] = null
|
|
83
|
-
if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
|
|
84
|
-
parsedValue = rawValue
|
|
85
|
-
}
|
|
86
|
-
return {
|
|
87
|
-
id: typeof candidate.id === 'string' ? candidate.id : undefined,
|
|
88
|
-
op: candidate.op,
|
|
89
|
-
value: parsedValue,
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function parseFilterGroup(group: unknown): QueryFilterGroup | null {
|
|
94
|
-
if (!group || typeof group !== 'object') return null
|
|
95
|
-
const { columnId, type, label, conditions: rawConds } = group as Partial<QueryFilterGroup>
|
|
96
|
-
if (typeof columnId !== 'string' || typeof type !== 'string' || !Array.isArray(rawConds)) {
|
|
97
|
-
return null
|
|
98
|
-
}
|
|
99
|
-
const validConditions: QueryFilterCondition[] = []
|
|
100
|
-
for (const cond of rawConds) {
|
|
101
|
-
const parsedCond = parseCondition(cond)
|
|
102
|
-
if (parsedCond) validConditions.push(parsedCond)
|
|
103
|
-
}
|
|
104
|
-
if (validConditions.length === 0) return null
|
|
105
|
-
return {
|
|
106
|
-
columnId,
|
|
107
|
-
label: typeof label === 'string' ? label : undefined,
|
|
108
|
-
type,
|
|
109
|
-
conditions: validConditions,
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Parse sicuro dei filtri da query-string (formato dashboard con columnId). */
|
|
114
|
-
export function parseQueryFilters(raw: string | undefined): QueryFilterGroup[] {
|
|
115
|
-
if (!raw) return []
|
|
116
|
-
let parsed: unknown
|
|
117
|
-
try {
|
|
118
|
-
parsed = JSON.parse(raw)
|
|
119
|
-
} catch {
|
|
120
|
-
return []
|
|
121
|
-
}
|
|
122
|
-
if (!parsed || typeof parsed !== 'object') return []
|
|
123
|
-
const result: QueryFilterGroup[] = []
|
|
124
|
-
for (const group of Object.values(parsed as Record<string, unknown>)) {
|
|
125
|
-
const parsedGroup = parseFilterGroup(group)
|
|
126
|
-
if (parsedGroup) result.push(parsedGroup)
|
|
127
|
-
}
|
|
128
|
-
return result
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Converte QueryFilterGroup[] (formato dashboard, columnId) in FilterGroup[]
|
|
133
|
-
* compatibile con buildSelectQuery del Botanical Engine.
|
|
134
|
-
*/
|
|
135
|
-
export function toEngineFilters(groups: QueryFilterGroup[]): FilterGroup[] {
|
|
136
|
-
return groups.map((g) => ({
|
|
137
|
-
column: g.columnId,
|
|
138
|
-
type: g.type,
|
|
139
|
-
conditions: g.conditions,
|
|
140
|
-
}))
|
|
141
|
-
}
|
|
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 type { FilterGroup, FilterType } from '@beechcms/core'
|
|
6
|
+
|
|
7
|
+
/** Entry parsata per le risposte API — contratto immutabile (C7). */
|
|
8
|
+
export interface ContentEntry {
|
|
9
|
+
id: string
|
|
10
|
+
schema_slug: string
|
|
11
|
+
slug: string | null
|
|
12
|
+
status: string
|
|
13
|
+
data: Record<string, unknown>
|
|
14
|
+
hasPendingDraft: boolean
|
|
15
|
+
created_at: number | null
|
|
16
|
+
updated_at: number | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type QueryFilterType = FilterType
|
|
20
|
+
|
|
21
|
+
export type QueryFilterOperator =
|
|
22
|
+
| 'eq'
|
|
23
|
+
| 'gt'
|
|
24
|
+
| 'gte'
|
|
25
|
+
| 'lt'
|
|
26
|
+
| 'lte'
|
|
27
|
+
| 'contains'
|
|
28
|
+
| 'is_empty'
|
|
29
|
+
| 'is_not_empty'
|
|
30
|
+
|
|
31
|
+
export interface QueryFilterCondition {
|
|
32
|
+
id?: string
|
|
33
|
+
op: QueryFilterOperator
|
|
34
|
+
value: string | number | boolean | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Formato inviato dalla dashboard (usa columnId). */
|
|
38
|
+
export interface QueryFilterGroup {
|
|
39
|
+
columnId: string
|
|
40
|
+
label?: string
|
|
41
|
+
type: QueryFilterType
|
|
42
|
+
conditions: QueryFilterCondition[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const QUERY_FILTER_OPERATOR_SET = new Set<QueryFilterOperator>([
|
|
46
|
+
'eq', 'gt', 'gte', 'lt', 'lte', 'contains', 'is_empty', 'is_not_empty',
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
function isQueryFilterOperator(value: unknown): value is QueryFilterOperator {
|
|
50
|
+
return typeof value === 'string' && QUERY_FILTER_OPERATOR_SET.has(value as QueryFilterOperator)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function cleanStr(val: unknown): string | null {
|
|
54
|
+
return (typeof val === 'string' && val.trim()) || null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function safeParseJson(data: unknown): Record<string, unknown> {
|
|
58
|
+
const cleaned = cleanStr(data)
|
|
59
|
+
if (!cleaned) return {}
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(cleaned)
|
|
62
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
63
|
+
? (parsed as Record<string, unknown>)
|
|
64
|
+
: {}
|
|
65
|
+
} catch {
|
|
66
|
+
return {}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parsePositiveInt(value: string | undefined, fallback: number): number {
|
|
71
|
+
if (!value) return fallback
|
|
72
|
+
const parsed = Number.parseInt(value, 10)
|
|
73
|
+
if (Number.isNaN(parsed) || parsed < 1) return fallback
|
|
74
|
+
return parsed
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseCondition(cond: unknown): QueryFilterCondition | null {
|
|
78
|
+
if (!cond || typeof cond !== 'object') return null
|
|
79
|
+
const candidate = cond as Record<string, unknown>
|
|
80
|
+
if (!isQueryFilterOperator(candidate.op)) return null
|
|
81
|
+
const rawValue = candidate.value
|
|
82
|
+
let parsedValue: QueryFilterCondition['value'] = null
|
|
83
|
+
if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
|
|
84
|
+
parsedValue = rawValue
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
id: typeof candidate.id === 'string' ? candidate.id : undefined,
|
|
88
|
+
op: candidate.op,
|
|
89
|
+
value: parsedValue,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseFilterGroup(group: unknown): QueryFilterGroup | null {
|
|
94
|
+
if (!group || typeof group !== 'object') return null
|
|
95
|
+
const { columnId, type, label, conditions: rawConds } = group as Partial<QueryFilterGroup>
|
|
96
|
+
if (typeof columnId !== 'string' || typeof type !== 'string' || !Array.isArray(rawConds)) {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
const validConditions: QueryFilterCondition[] = []
|
|
100
|
+
for (const cond of rawConds) {
|
|
101
|
+
const parsedCond = parseCondition(cond)
|
|
102
|
+
if (parsedCond) validConditions.push(parsedCond)
|
|
103
|
+
}
|
|
104
|
+
if (validConditions.length === 0) return null
|
|
105
|
+
return {
|
|
106
|
+
columnId,
|
|
107
|
+
label: typeof label === 'string' ? label : undefined,
|
|
108
|
+
type,
|
|
109
|
+
conditions: validConditions,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Parse sicuro dei filtri da query-string (formato dashboard con columnId). */
|
|
114
|
+
export function parseQueryFilters(raw: string | undefined): QueryFilterGroup[] {
|
|
115
|
+
if (!raw) return []
|
|
116
|
+
let parsed: unknown
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(raw)
|
|
119
|
+
} catch {
|
|
120
|
+
return []
|
|
121
|
+
}
|
|
122
|
+
if (!parsed || typeof parsed !== 'object') return []
|
|
123
|
+
const result: QueryFilterGroup[] = []
|
|
124
|
+
for (const group of Object.values(parsed as Record<string, unknown>)) {
|
|
125
|
+
const parsedGroup = parseFilterGroup(group)
|
|
126
|
+
if (parsedGroup) result.push(parsedGroup)
|
|
127
|
+
}
|
|
128
|
+
return result
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Converte QueryFilterGroup[] (formato dashboard, columnId) in FilterGroup[]
|
|
133
|
+
* compatibile con buildSelectQuery del Botanical Engine.
|
|
134
|
+
*/
|
|
135
|
+
export function toEngineFilters(groups: QueryFilterGroup[]): FilterGroup[] {
|
|
136
|
+
return groups.map((g) => ({
|
|
137
|
+
column: g.columnId,
|
|
138
|
+
type: g.type,
|
|
139
|
+
conditions: g.conditions,
|
|
140
|
+
}))
|
|
141
|
+
}
|
|
@@ -1,26 +1,26 @@
|
|
|
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 type { HonoRequest } from "hono"
|
|
6
|
-
|
|
7
|
-
/** The header Cloudflare sets on every incoming request to the Worker. */
|
|
8
|
-
const CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip"
|
|
9
|
-
|
|
10
|
-
/** The fallback value used when the IP header is absent (local dev, unit tests). */
|
|
11
|
-
const UNKNOWN_IP = "unknown"
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Extracts the real client IP address from a Cloudflare Worker request.
|
|
15
|
-
*
|
|
16
|
-
* Cloudflare injects the cf-connecting-ip header on every request that
|
|
17
|
-
* passes through the edge. In local development (wrangler dev) or in
|
|
18
|
-
* unit tests the header may be absent, in which case the string "unknown"
|
|
19
|
-
* is returned so that rate-limiter keys remain non-empty and safe to use.
|
|
20
|
-
*
|
|
21
|
-
* Never derive security decisions from this value alone; treat it as a
|
|
22
|
-
* best-effort hint for rate-limiting and logging purposes only.
|
|
23
|
-
*/
|
|
24
|
-
export function getClientIp(request: HonoRequest): string {
|
|
25
|
-
return request.raw.headers.get(CLOUDFLARE_CLIENT_IP_HEADER) ?? UNKNOWN_IP
|
|
26
|
-
}
|
|
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 type { HonoRequest } from "hono"
|
|
6
|
+
|
|
7
|
+
/** The header Cloudflare sets on every incoming request to the Worker. */
|
|
8
|
+
const CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip"
|
|
9
|
+
|
|
10
|
+
/** The fallback value used when the IP header is absent (local dev, unit tests). */
|
|
11
|
+
const UNKNOWN_IP = "unknown"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extracts the real client IP address from a Cloudflare Worker request.
|
|
15
|
+
*
|
|
16
|
+
* Cloudflare injects the cf-connecting-ip header on every request that
|
|
17
|
+
* passes through the edge. In local development (wrangler dev) or in
|
|
18
|
+
* unit tests the header may be absent, in which case the string "unknown"
|
|
19
|
+
* is returned so that rate-limiter keys remain non-empty and safe to use.
|
|
20
|
+
*
|
|
21
|
+
* Never derive security decisions from this value alone; treat it as a
|
|
22
|
+
* best-effort hint for rate-limiting and logging purposes only.
|
|
23
|
+
*/
|
|
24
|
+
export function getClientIp(request: HonoRequest): string {
|
|
25
|
+
return request.raw.headers.get(CLOUDFLARE_CLIENT_IP_HEADER) ?? UNKNOWN_IP
|
|
26
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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 } from 'vitest'
|
|
6
|
+
import { D1SchemaMutator } from './schema-mutator.d1'
|
|
7
|
+
|
|
8
|
+
function makeMockDb() {
|
|
9
|
+
const runMock = vi.fn().mockResolvedValue({ success: true })
|
|
10
|
+
const firstMock = vi.fn().mockResolvedValue(null)
|
|
11
|
+
const allMock = vi.fn().mockResolvedValue({ results: [] })
|
|
12
|
+
const bindMock = vi.fn(() => ({ run: runMock, first: firstMock, all: allMock }))
|
|
13
|
+
const stmt = { bind: bindMock, run: runMock, first: firstMock, all: allMock }
|
|
14
|
+
const prepareMock = vi.fn(() => stmt)
|
|
15
|
+
const batchMock = vi.fn().mockResolvedValue([])
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
db: {
|
|
19
|
+
prepare: prepareMock,
|
|
20
|
+
batch: batchMock,
|
|
21
|
+
} as unknown as D1Database,
|
|
22
|
+
prepareMock,
|
|
23
|
+
bindMock,
|
|
24
|
+
runMock,
|
|
25
|
+
firstMock,
|
|
26
|
+
allMock,
|
|
27
|
+
batchMock,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('D1SchemaMutator', () => {
|
|
32
|
+
describe('getColumns', () => {
|
|
33
|
+
it('returns null if table has no columns (empty result)', async () => {
|
|
34
|
+
const { db, allMock } = makeMockDb()
|
|
35
|
+
allMock.mockResolvedValueOnce({ results: [] })
|
|
36
|
+
const mutator = new D1SchemaMutator(db)
|
|
37
|
+
const cols = await mutator.getColumns('users')
|
|
38
|
+
expect(cols).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns set of column names when columns are found', async () => {
|
|
42
|
+
const { db, allMock, prepareMock } = makeMockDb()
|
|
43
|
+
allMock.mockResolvedValueOnce({
|
|
44
|
+
results: [{ name: 'id' }, { name: 'email' }],
|
|
45
|
+
})
|
|
46
|
+
const mutator = new D1SchemaMutator(db)
|
|
47
|
+
const cols = await mutator.getColumns('users')
|
|
48
|
+
expect(prepareMock).toHaveBeenCalledWith('PRAGMA table_info(users)')
|
|
49
|
+
expect(cols).toEqual(new Set(['id', 'email']))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('throws error if table name is unsafe', async () => {
|
|
53
|
+
const { db } = makeMockDb()
|
|
54
|
+
const mutator = new D1SchemaMutator(db)
|
|
55
|
+
await expect(mutator.getColumns('users; DROP TABLE users;')).rejects.toThrow(
|
|
56
|
+
'Unsafe table name',
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('execDdl', () => {
|
|
62
|
+
it('returns early if statements are empty', async () => {
|
|
63
|
+
const { db, batchMock } = makeMockDb()
|
|
64
|
+
const mutator = new D1SchemaMutator(db)
|
|
65
|
+
await mutator.execDdl([])
|
|
66
|
+
expect(batchMock).not.toHaveBeenCalled()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('prepares and executes batch DDL statements', async () => {
|
|
70
|
+
const { db, batchMock, prepareMock } = makeMockDb()
|
|
71
|
+
const mutator = new D1SchemaMutator(db)
|
|
72
|
+
await mutator.execDdl(['CREATE TABLE test (id TEXT)', 'CREATE INDEX idx ON test (id)'])
|
|
73
|
+
expect(prepareMock).toHaveBeenCalledTimes(2)
|
|
74
|
+
expect(batchMock).toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('dropTable', () => {
|
|
79
|
+
it('executes DROP TABLE IF EXISTS for a safe identifier', async () => {
|
|
80
|
+
const { db, prepareMock, runMock } = makeMockDb()
|
|
81
|
+
const mutator = new D1SchemaMutator(db)
|
|
82
|
+
await mutator.dropTable('articles')
|
|
83
|
+
expect(prepareMock).toHaveBeenCalledWith('DROP TABLE IF EXISTS articles')
|
|
84
|
+
expect(runMock).toHaveBeenCalled()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('throws error on unsafe table name', async () => {
|
|
88
|
+
const { db } = makeMockDb()
|
|
89
|
+
const mutator = new D1SchemaMutator(db)
|
|
90
|
+
await expect(mutator.dropTable('articles; --')).rejects.toThrow('Unsafe identifier')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('dropColumn', () => {
|
|
95
|
+
it('executes ALTER TABLE DROP COLUMN for safe identifiers', async () => {
|
|
96
|
+
const { db, prepareMock, runMock } = makeMockDb()
|
|
97
|
+
const mutator = new D1SchemaMutator(db)
|
|
98
|
+
await mutator.dropColumn('articles', 'title')
|
|
99
|
+
expect(prepareMock).toHaveBeenCalledWith('ALTER TABLE articles DROP COLUMN title')
|
|
100
|
+
expect(runMock).toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('throws error on unsafe column name', async () => {
|
|
104
|
+
const { db } = makeMockDb()
|
|
105
|
+
const mutator = new D1SchemaMutator(db)
|
|
106
|
+
await expect(mutator.dropColumn('articles', 'title;')).rejects.toThrow('Unsafe identifier')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('renameColumn', () => {
|
|
111
|
+
it('executes ALTER TABLE RENAME COLUMN for safe identifiers', async () => {
|
|
112
|
+
const { db, prepareMock, runMock } = makeMockDb()
|
|
113
|
+
const mutator = new D1SchemaMutator(db)
|
|
114
|
+
await mutator.renameColumn('articles', 'old_name', 'new_name')
|
|
115
|
+
expect(prepareMock).toHaveBeenCalledWith('ALTER TABLE articles RENAME COLUMN old_name TO new_name')
|
|
116
|
+
expect(runMock).toHaveBeenCalled()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('throws error on unsafe parameters', async () => {
|
|
120
|
+
const { db } = makeMockDb()
|
|
121
|
+
const mutator = new D1SchemaMutator(db)
|
|
122
|
+
await expect(mutator.renameColumn('articles', 'old', 'new --')).rejects.toThrow('Unsafe identifier')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('execDestructive', () => {
|
|
127
|
+
it('returns early if statements are empty', async () => {
|
|
128
|
+
const { db, batchMock } = makeMockDb()
|
|
129
|
+
const mutator = new D1SchemaMutator(db)
|
|
130
|
+
await mutator.execDestructive([])
|
|
131
|
+
expect(batchMock).not.toHaveBeenCalled()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('prepares and executes batch destructive statements', async () => {
|
|
135
|
+
const { db, batchMock, prepareMock } = makeMockDb()
|
|
136
|
+
const mutator = new D1SchemaMutator(db)
|
|
137
|
+
await mutator.execDestructive(['DROP TABLE test'])
|
|
138
|
+
expect(prepareMock).toHaveBeenCalledWith('DROP TABLE test')
|
|
139
|
+
expect(batchMock).toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -1,64 +1,64 @@
|
|
|
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 { ISchemaMutator } from '@beechcms/core'
|
|
7
|
-
|
|
8
|
-
export class D1SchemaMutator implements ISchemaMutator {
|
|
9
|
-
constructor(private readonly db: D1Database) {}
|
|
10
|
-
|
|
11
|
-
async getColumns(table: string): Promise<Set<string> | null> {
|
|
12
|
-
// PRAGMA table_info returns rows {cid,name,type,notnull,dflt_value,pk}.
|
|
13
|
-
// Table name cannot be parameterized in PRAGMA — validate it is a safe identifier.
|
|
14
|
-
if (!/^[A-Za-z0-9_]+$/.test(table)) throw new Error(`Unsafe table name: ${table}`)
|
|
15
|
-
const rs = await this.db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>()
|
|
16
|
-
const rows = rs.results ?? []
|
|
17
|
-
if (rows.length === 0) return null // table absent
|
|
18
|
-
return new Set(rows.map(r => r.name))
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async execDdl(statements: string[]): Promise<void> {
|
|
22
|
-
if (statements.length === 0) return
|
|
23
|
-
// D1 batch is atomic per call; if one statement fails, the rest roll back.
|
|
24
|
-
await this.db.batch(statements.map(s => this.db.prepare(s)))
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// --- destructive (sprint 06 — Danger Zone) -------------------------------
|
|
28
|
-
// These emit IRREVERSIBLE SQL. Identifiers cannot be parameterized in DDL, so
|
|
29
|
-
// every table/column name is validated against the same `^[A-Za-z0-9_]+$`
|
|
30
|
-
// guard getColumns already applies before interpolation.
|
|
31
|
-
|
|
32
|
-
private static assertIdentifier(name: string): void {
|
|
33
|
-
if (!/^[A-Za-z0-9_]+$/.test(name)) throw new Error(`Unsafe identifier: ${name}`)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async dropTable(table: string): Promise<void> {
|
|
37
|
-
D1SchemaMutator.assertIdentifier(table)
|
|
38
|
-
await this.db.prepare(`DROP TABLE IF EXISTS ${table}`).run()
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async dropColumn(table: string, column: string): Promise<void> {
|
|
42
|
-
D1SchemaMutator.assertIdentifier(table)
|
|
43
|
-
D1SchemaMutator.assertIdentifier(column)
|
|
44
|
-
await this.db.prepare(`ALTER TABLE ${table} DROP COLUMN ${column}`).run()
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async renameColumn(table: string, from: string, to: string): Promise<void> {
|
|
48
|
-
D1SchemaMutator.assertIdentifier(table)
|
|
49
|
-
D1SchemaMutator.assertIdentifier(from)
|
|
50
|
-
D1SchemaMutator.assertIdentifier(to)
|
|
51
|
-
await this.db.prepare(`ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to}`).run()
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async execDestructive(statements: string[]): Promise<void> {
|
|
55
|
-
if (statements.length === 0) return
|
|
56
|
-
// The statements come from the core destructive generators
|
|
57
|
-
// (generateDropTable / generateRetypeColumn / planFtsRebuild), which only
|
|
58
|
-
// interpolate slugs/aliases already validated by the seed registry
|
|
59
|
-
// (`^[a-z0-9_]+$`). FTS trigger bodies legitimately contain internal
|
|
60
|
-
// semicolons (BEGIN … ; END), so we do not split or reject on `;`.
|
|
61
|
-
// D1 batch is atomic per call; if one statement fails, the rest roll back.
|
|
62
|
-
await this.db.batch(statements.map(s => this.db.prepare(s)))
|
|
63
|
-
}
|
|
64
|
-
}
|
|
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 { ISchemaMutator } from '@beechcms/core'
|
|
7
|
+
|
|
8
|
+
export class D1SchemaMutator implements ISchemaMutator {
|
|
9
|
+
constructor(private readonly db: D1Database) {}
|
|
10
|
+
|
|
11
|
+
async getColumns(table: string): Promise<Set<string> | null> {
|
|
12
|
+
// PRAGMA table_info returns rows {cid,name,type,notnull,dflt_value,pk}.
|
|
13
|
+
// Table name cannot be parameterized in PRAGMA — validate it is a safe identifier.
|
|
14
|
+
if (!/^[A-Za-z0-9_]+$/.test(table)) throw new Error(`Unsafe table name: ${table}`)
|
|
15
|
+
const rs = await this.db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>()
|
|
16
|
+
const rows = rs.results ?? []
|
|
17
|
+
if (rows.length === 0) return null // table absent
|
|
18
|
+
return new Set(rows.map(r => r.name))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async execDdl(statements: string[]): Promise<void> {
|
|
22
|
+
if (statements.length === 0) return
|
|
23
|
+
// D1 batch is atomic per call; if one statement fails, the rest roll back.
|
|
24
|
+
await this.db.batch(statements.map(s => this.db.prepare(s)))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- destructive (sprint 06 — Danger Zone) -------------------------------
|
|
28
|
+
// These emit IRREVERSIBLE SQL. Identifiers cannot be parameterized in DDL, so
|
|
29
|
+
// every table/column name is validated against the same `^[A-Za-z0-9_]+$`
|
|
30
|
+
// guard getColumns already applies before interpolation.
|
|
31
|
+
|
|
32
|
+
private static assertIdentifier(name: string): void {
|
|
33
|
+
if (!/^[A-Za-z0-9_]+$/.test(name)) throw new Error(`Unsafe identifier: ${name}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async dropTable(table: string): Promise<void> {
|
|
37
|
+
D1SchemaMutator.assertIdentifier(table)
|
|
38
|
+
await this.db.prepare(`DROP TABLE IF EXISTS ${table}`).run()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async dropColumn(table: string, column: string): Promise<void> {
|
|
42
|
+
D1SchemaMutator.assertIdentifier(table)
|
|
43
|
+
D1SchemaMutator.assertIdentifier(column)
|
|
44
|
+
await this.db.prepare(`ALTER TABLE ${table} DROP COLUMN ${column}`).run()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async renameColumn(table: string, from: string, to: string): Promise<void> {
|
|
48
|
+
D1SchemaMutator.assertIdentifier(table)
|
|
49
|
+
D1SchemaMutator.assertIdentifier(from)
|
|
50
|
+
D1SchemaMutator.assertIdentifier(to)
|
|
51
|
+
await this.db.prepare(`ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to}`).run()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async execDestructive(statements: string[]): Promise<void> {
|
|
55
|
+
if (statements.length === 0) return
|
|
56
|
+
// The statements come from the core destructive generators
|
|
57
|
+
// (generateDropTable / generateRetypeColumn / planFtsRebuild), which only
|
|
58
|
+
// interpolate slugs/aliases already validated by the seed registry
|
|
59
|
+
// (`^[a-z0-9_]+$`). FTS trigger bodies legitimately contain internal
|
|
60
|
+
// semicolons (BEGIN … ; END), so we do not split or reject on `;`.
|
|
61
|
+
// D1 batch is atomic per call; if one statement fails, the rest roll back.
|
|
62
|
+
await this.db.batch(statements.map(s => this.db.prepare(s)))
|
|
63
|
+
}
|
|
64
|
+
}
|