@beechcms/core 0.4.0-preview.1 → 0.4.0-preview.11
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/dist/content.repository.d.ts +95 -0
- package/dist/content.repository.d.ts.map +1 -0
- package/dist/content.repository.js +29 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +76 -12
- package/dist/idempotency.repository.d.ts +13 -0
- package/dist/idempotency.repository.d.ts.map +1 -0
- package/dist/idempotency.repository.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/seeds.d.ts +8 -24
- package/dist/seeds.d.ts.map +1 -1
- package/dist/seeds.js +12 -172
- package/dist/types.d.ts +25 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +8 -2
- package/package.json +4 -1
- package/src/define-seed.ts +0 -5
- package/src/engine.ts +0 -465
- package/src/index.ts +0 -19
- package/src/policies.test.ts +0 -127
- package/src/policies.ts +0 -32
- package/src/richtext-render.ts +0 -87
- package/src/richtext.ts +0 -16
- package/src/seeds.ts +0 -194
- package/src/slug-utils.ts +0 -33
- package/src/types.ts +0 -121
- package/src/validation.ts +0 -667
- package/tsconfig.json +0 -14
- package/tsconfig.tsbuildinfo +0 -1
package/dist/validation.js
CHANGED
|
@@ -278,7 +278,8 @@ function buildBranchSchema(branch, options) {
|
|
|
278
278
|
.refine((value) => isIsoDateString(value), { message: 'Expected date(ISO)' });
|
|
279
279
|
return nullable ? z.union([schema, nullable]) : schema;
|
|
280
280
|
}
|
|
281
|
-
case 'json':
|
|
281
|
+
case 'json':
|
|
282
|
+
case 'tags': {
|
|
282
283
|
const schema = jsonObjectOrArraySchema;
|
|
283
284
|
return nullable ? z.union([schema, nullable]) : schema;
|
|
284
285
|
}
|
|
@@ -316,6 +317,8 @@ function buildBranchSchema(branch, options) {
|
|
|
316
317
|
.pipe(z.url());
|
|
317
318
|
return nullable ? z.union([schema, nullable]) : schema;
|
|
318
319
|
}
|
|
320
|
+
default:
|
|
321
|
+
throw new Error(`Unhandled branch type: ${branch.type}`);
|
|
319
322
|
}
|
|
320
323
|
}
|
|
321
324
|
const compiledSchemaCache = new Map();
|
|
@@ -422,7 +425,8 @@ function validateBranchValue(branch, alias, rawValue, options) {
|
|
|
422
425
|
}
|
|
423
426
|
return { ok: true, value };
|
|
424
427
|
}
|
|
425
|
-
case 'json':
|
|
428
|
+
case 'json':
|
|
429
|
+
case 'tags': {
|
|
426
430
|
const isValidJsonLike = (typeof rawValue === 'object' && rawValue !== null) || Array.isArray(rawValue);
|
|
427
431
|
if (!isValidJsonLike || typeof rawValue === 'string') {
|
|
428
432
|
return { ok: false, detail: makeDetail(alias, 'object|array', rawValue) };
|
|
@@ -443,6 +447,8 @@ function validateBranchValue(branch, alias, rawValue, options) {
|
|
|
443
447
|
}
|
|
444
448
|
return { ok: true, value: singleUrl };
|
|
445
449
|
}
|
|
450
|
+
default:
|
|
451
|
+
throw new Error(`Unhandled branch type: ${branch.type}`);
|
|
446
452
|
}
|
|
447
453
|
}
|
|
448
454
|
/**
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beechcms/core",
|
|
3
|
-
"version": "0.4.0-preview.
|
|
3
|
+
"version": "0.4.0-preview.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
8
11
|
"exports": {
|
|
9
12
|
".": {
|
|
10
13
|
"import": "./dist/index.js",
|
package/src/define-seed.ts
DELETED
package/src/engine.ts
DELETED
|
@@ -1,465 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Botanical Engine: Schema Compiler + Query Builder.
|
|
3
|
-
*
|
|
4
|
-
* In v0.4.0 ogni Seed ha una tabella SQL dedicata (`content_{slug}`) con colonne
|
|
5
|
-
* reali tipizzate. Questo modulo genera il DDL e costruisce query parametrizzate.
|
|
6
|
-
* Non conosce HTTP, auth o UI — è una libreria Node.js pura (C6).
|
|
7
|
-
*/
|
|
8
|
-
import type {
|
|
9
|
-
Seed,
|
|
10
|
-
Branch,
|
|
11
|
-
BranchType,
|
|
12
|
-
FilterGroup,
|
|
13
|
-
FilterType,
|
|
14
|
-
FilterCondition,
|
|
15
|
-
SelectOptions,
|
|
16
|
-
ParameterizedQuery,
|
|
17
|
-
} from './types.js'
|
|
18
|
-
|
|
19
|
-
// ---- SQL type mapping ----
|
|
20
|
-
|
|
21
|
-
interface BranchSqlDef {
|
|
22
|
-
sqlType: 'TEXT' | 'REAL' | 'INTEGER'
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const BRANCH_TYPE_SQL: Record<BranchType, BranchSqlDef> = {
|
|
26
|
-
text: { sqlType: 'TEXT' },
|
|
27
|
-
number: { sqlType: 'REAL' },
|
|
28
|
-
boolean: { sqlType: 'INTEGER' },
|
|
29
|
-
date: { sqlType: 'INTEGER' }, // Unix timestamp (seconds)
|
|
30
|
-
json: { sqlType: 'TEXT' }, // JSON serializzato
|
|
31
|
-
richtext: { sqlType: 'TEXT' },
|
|
32
|
-
file: { sqlType: 'TEXT' }, // URL singolo o JSON array di URL
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at'])
|
|
36
|
-
|
|
37
|
-
// ---- Private helpers ----
|
|
38
|
-
|
|
39
|
-
function tableName(seed: Seed): string {
|
|
40
|
-
return `content_${seed.slug}`
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function ftsTableName(seed: Seed): string {
|
|
44
|
-
return `fts_${seed.slug}`
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function isValidColumn(seed: Seed, col: string): boolean {
|
|
48
|
-
if (SYSTEM_COLUMNS.has(col)) return true
|
|
49
|
-
return seed.branches.some(b => b.alias === col)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function indexableSearchBranches(seed: Seed): Branch[] {
|
|
53
|
-
return seed.branches.filter(b =>
|
|
54
|
-
(b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function isAssetListBranch(branch: Branch): boolean {
|
|
59
|
-
return branch.type === 'file' && (branch.multiple === true || branch.format === 'asset-list')
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function normalizeHttpUrl(value: unknown): string | null {
|
|
63
|
-
if (typeof value !== 'string') return null
|
|
64
|
-
const cleaned = value.trim()
|
|
65
|
-
if (!cleaned) return null
|
|
66
|
-
try {
|
|
67
|
-
const parsed = new URL(cleaned)
|
|
68
|
-
return parsed.protocol.startsWith('http') ? cleaned : null
|
|
69
|
-
} catch {
|
|
70
|
-
return null
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function parseJsonSafe(value: string): unknown {
|
|
75
|
-
try { return JSON.parse(value) } catch { return value }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function normalizeAssetListValue(rawValue: unknown): string[] {
|
|
79
|
-
const input = typeof rawValue === 'string' ? parseJsonSafe(rawValue) : rawValue
|
|
80
|
-
const values = Array.isArray(input) ? input : [input]
|
|
81
|
-
const normalized: string[] = []
|
|
82
|
-
for (const item of values) {
|
|
83
|
-
if (item == null) continue
|
|
84
|
-
const direct = normalizeHttpUrl(item)
|
|
85
|
-
if (direct) { normalized.push(direct); continue }
|
|
86
|
-
if (typeof item === 'object' && !Array.isArray(item)) {
|
|
87
|
-
const fromObj = normalizeHttpUrl((item as Record<string, unknown>).url)
|
|
88
|
-
if (fromObj) normalized.push(fromObj)
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return [...new Set(normalized)]
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ---- DDL Generators ----
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Genera `CREATE TABLE IF NOT EXISTS content_{slug}` con colonne di sistema
|
|
98
|
-
* + una colonna per ogni Branch. Funzione pura: stesso Seed → stesso SQL.
|
|
99
|
-
*/
|
|
100
|
-
export function generateCreateTable(seed: Seed): string {
|
|
101
|
-
const table = tableName(seed)
|
|
102
|
-
const lines: string[] = [
|
|
103
|
-
`CREATE TABLE IF NOT EXISTS ${table} (`,
|
|
104
|
-
` id TEXT NOT NULL PRIMARY KEY,`,
|
|
105
|
-
` slug TEXT NOT NULL UNIQUE,`,
|
|
106
|
-
` status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'review', 'published', 'archived')),`,
|
|
107
|
-
]
|
|
108
|
-
|
|
109
|
-
for (const branch of seed.branches) {
|
|
110
|
-
const { sqlType } = BRANCH_TYPE_SQL[branch.type]
|
|
111
|
-
let col = ` ${branch.alias} ${sqlType}`
|
|
112
|
-
if (branch.requiredOnCreate) col += ' NOT NULL'
|
|
113
|
-
if (branch.type === 'boolean') col += ` CHECK (${branch.alias} IN (0, 1))`
|
|
114
|
-
lines.push(col + ',')
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
lines.push(` created_at INTEGER NOT NULL DEFAULT (unixepoch()),`)
|
|
118
|
-
lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`)
|
|
119
|
-
lines.push(`);`)
|
|
120
|
-
|
|
121
|
-
return lines.join('\n')
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Genera la tabella bozze `content_{slug}_drafts` per i Seed con `allowDrafts: true`.
|
|
126
|
-
* Tutte le colonne branch sono nullable (le bozze sono parziali).
|
|
127
|
-
* Ritorna null se il Seed non ha `allowDrafts: true`.
|
|
128
|
-
*/
|
|
129
|
-
export function generateDraftTable(seed: Seed): string | null {
|
|
130
|
-
if (!seed.allowDrafts) return null
|
|
131
|
-
|
|
132
|
-
const table = `content_${seed.slug}_drafts`
|
|
133
|
-
const mainTable = `content_${seed.slug}`
|
|
134
|
-
const lines: string[] = [
|
|
135
|
-
`CREATE TABLE IF NOT EXISTS ${table} (`,
|
|
136
|
-
` entry_id TEXT NOT NULL PRIMARY KEY`,
|
|
137
|
-
` REFERENCES ${mainTable}(id) ON DELETE CASCADE,`,
|
|
138
|
-
]
|
|
139
|
-
|
|
140
|
-
for (const branch of seed.branches) {
|
|
141
|
-
const { sqlType } = BRANCH_TYPE_SQL[branch.type]
|
|
142
|
-
let col = ` ${branch.alias} ${sqlType}`
|
|
143
|
-
// boolean CHECK: in SQLite, NULL IN (0,1) → NULL, che passa il CHECK (solo FALSE lo fallisce)
|
|
144
|
-
if (branch.type === 'boolean') col += ` CHECK (${branch.alias} IN (0, 1))`
|
|
145
|
-
lines.push(col + ',')
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`)
|
|
149
|
-
lines.push(`);`)
|
|
150
|
-
|
|
151
|
-
return lines.join('\n')
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Genera `ALTER TABLE content_{slug} ADD COLUMN {alias} {type}`.
|
|
156
|
-
* Nuove colonne sono sempre nullable (limite SQLite su ALTER TABLE).
|
|
157
|
-
*/
|
|
158
|
-
export function generateAddColumn(seed: Seed, branch: Branch): string {
|
|
159
|
-
const { sqlType } = BRANCH_TYPE_SQL[branch.type]
|
|
160
|
-
return `ALTER TABLE ${tableName(seed)} ADD COLUMN ${branch.alias} ${sqlType};`
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Genera indici B-tree per status, created_at e ogni Branch filtrabile
|
|
165
|
-
* con tipo indicizzabile (text, number, date, boolean).
|
|
166
|
-
*/
|
|
167
|
-
export function generateIndexes(seed: Seed): string[] {
|
|
168
|
-
const table = tableName(seed)
|
|
169
|
-
const slug = seed.slug
|
|
170
|
-
const indexes: string[] = [
|
|
171
|
-
`CREATE INDEX IF NOT EXISTS idx_${slug}_status ON ${table}(status);`,
|
|
172
|
-
`CREATE INDEX IF NOT EXISTS idx_${slug}_created_at ON ${table}(created_at);`,
|
|
173
|
-
]
|
|
174
|
-
|
|
175
|
-
for (const branch of seed.branches) {
|
|
176
|
-
if (branch.policies?.filter === false) continue
|
|
177
|
-
if (['text', 'number', 'date', 'boolean'].includes(branch.type)) {
|
|
178
|
-
indexes.push(
|
|
179
|
-
`CREATE INDEX IF NOT EXISTS idx_${slug}_${branch.alias} ON ${table}(${branch.alias});`
|
|
180
|
-
)
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return indexes
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Genera la virtual table FTS5 per i Branch text/richtext indicizzabili.
|
|
189
|
-
* Ritorna null se il Seed non ha branch con search abilitato.
|
|
190
|
-
*/
|
|
191
|
-
export function generateFtsTable(seed: Seed): string | null {
|
|
192
|
-
const rtBranches = indexableSearchBranches(seed)
|
|
193
|
-
if (rtBranches.length === 0) return null
|
|
194
|
-
|
|
195
|
-
const ftsTable = ftsTableName(seed)
|
|
196
|
-
const cols = rtBranches.map(b => ` ${b.alias}`).join(',\n')
|
|
197
|
-
|
|
198
|
-
return [
|
|
199
|
-
`CREATE VIRTUAL TABLE IF NOT EXISTS ${ftsTable} USING fts5(`,
|
|
200
|
-
` entry_id UNINDEXED,`,
|
|
201
|
-
`${cols},`,
|
|
202
|
-
` tokenize = 'unicode61'`,
|
|
203
|
-
`);`,
|
|
204
|
-
].join('\n')
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Genera i 3 trigger SQLite (insert/update/delete) che mantengono la FTS
|
|
209
|
-
* sincronizzata automaticamente — elimina la necessità di syncFts manuale.
|
|
210
|
-
* Ritorna array vuoto se il Seed non ha branch indicizzabili.
|
|
211
|
-
*/
|
|
212
|
-
export function generateFtsTriggers(seed: Seed): string[] {
|
|
213
|
-
const rtBranches = indexableSearchBranches(seed)
|
|
214
|
-
if (rtBranches.length === 0) return []
|
|
215
|
-
|
|
216
|
-
const table = tableName(seed)
|
|
217
|
-
const ftsTable = ftsTableName(seed)
|
|
218
|
-
const slug = seed.slug
|
|
219
|
-
const cols = rtBranches.map(b => b.alias)
|
|
220
|
-
const ftsColList = ['entry_id', ...cols].join(', ')
|
|
221
|
-
const newValList = ['new.id', ...cols.map(c => `new.${c}`)].join(', ')
|
|
222
|
-
|
|
223
|
-
return [
|
|
224
|
-
[
|
|
225
|
-
`CREATE TRIGGER IF NOT EXISTS fts_${slug}_insert`,
|
|
226
|
-
`AFTER INSERT ON ${table} BEGIN`,
|
|
227
|
-
` INSERT INTO ${ftsTable}(${ftsColList}) VALUES (${newValList});`,
|
|
228
|
-
`END;`,
|
|
229
|
-
].join('\n'),
|
|
230
|
-
[
|
|
231
|
-
`CREATE TRIGGER IF NOT EXISTS fts_${slug}_update`,
|
|
232
|
-
`AFTER UPDATE OF ${cols.join(', ')} ON ${table} BEGIN`,
|
|
233
|
-
` DELETE FROM ${ftsTable} WHERE entry_id = old.id;`,
|
|
234
|
-
` INSERT INTO ${ftsTable}(${ftsColList}) VALUES (${newValList});`,
|
|
235
|
-
`END;`,
|
|
236
|
-
].join('\n'),
|
|
237
|
-
[
|
|
238
|
-
`CREATE TRIGGER IF NOT EXISTS fts_${slug}_delete`,
|
|
239
|
-
`AFTER DELETE ON ${table} BEGIN`,
|
|
240
|
-
` DELETE FROM ${ftsTable} WHERE entry_id = old.id;`,
|
|
241
|
-
`END;`,
|
|
242
|
-
].join('\n'),
|
|
243
|
-
]
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ---- Query Builder ----
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Costruisce una SELECT parametrizzata su `content_{slug}`.
|
|
250
|
-
* Non usa mai json_extract — ogni colonna è una colonna reale.
|
|
251
|
-
* Colonne sconosciute nei filtri/orderBy vengono ignorate (fail-closed).
|
|
252
|
-
*/
|
|
253
|
-
export function buildSelectQuery(seed: Seed, options: SelectOptions = {}): ParameterizedQuery {
|
|
254
|
-
const table = tableName(seed)
|
|
255
|
-
const { filters = [], orderBy, pagination, status, search, fields } = options
|
|
256
|
-
const bindings: (string | number | boolean | null)[] = []
|
|
257
|
-
const whereClauses: string[] = []
|
|
258
|
-
let joinClause = ''
|
|
259
|
-
|
|
260
|
-
// FTS JOIN — solo se il seed ha branch indicizzabili
|
|
261
|
-
const rtBranches = indexableSearchBranches(seed)
|
|
262
|
-
if (search && rtBranches.length > 0) {
|
|
263
|
-
const ftsTable = ftsTableName(seed)
|
|
264
|
-
joinClause = `INNER JOIN ${ftsTable} ON ${ftsTable}.entry_id = ${table}.id`
|
|
265
|
-
whereClauses.push(`${ftsTable} MATCH ?`)
|
|
266
|
-
// FTS5: prefix match con quote per caratteri speciali
|
|
267
|
-
bindings.push(`"${search.replace(/"/g, '""')}"*`)
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Filtro status
|
|
271
|
-
if (status !== undefined && status !== null) {
|
|
272
|
-
whereClauses.push(`${table}.status = ?`)
|
|
273
|
-
bindings.push(status)
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Filtri utente
|
|
277
|
-
for (const group of filters) {
|
|
278
|
-
if (!isValidColumn(seed, group.column)) continue
|
|
279
|
-
const col = SYSTEM_COLUMNS.has(group.column)
|
|
280
|
-
? `${table}.${group.column}`
|
|
281
|
-
: group.column
|
|
282
|
-
|
|
283
|
-
for (const cond of group.conditions) {
|
|
284
|
-
const clause = buildFilterCondition(col, group.type, cond, bindings)
|
|
285
|
-
if (clause) whereClauses.push(clause)
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Proiezione colonne
|
|
290
|
-
let selectCols = `${table}.*`
|
|
291
|
-
if (fields && fields.length > 0) {
|
|
292
|
-
const valid = fields.filter(f => isValidColumn(seed, f))
|
|
293
|
-
if (valid.length > 0) {
|
|
294
|
-
selectCols = valid
|
|
295
|
-
.map(f => (SYSTEM_COLUMNS.has(f) ? `${table}.${f}` : f))
|
|
296
|
-
.join(', ')
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
let sql = `SELECT ${selectCols} FROM ${table}`
|
|
301
|
-
if (joinClause) sql += ` ${joinClause}`
|
|
302
|
-
if (whereClauses.length > 0) sql += ` WHERE ${whereClauses.join(' AND ')}`
|
|
303
|
-
|
|
304
|
-
// ORDER BY
|
|
305
|
-
if (orderBy && isValidColumn(seed, orderBy.column)) {
|
|
306
|
-
const dir = orderBy.dir === 'DESC' ? 'DESC' : 'ASC'
|
|
307
|
-
const col = SYSTEM_COLUMNS.has(orderBy.column)
|
|
308
|
-
? `${table}.${orderBy.column}`
|
|
309
|
-
: orderBy.column
|
|
310
|
-
sql += ` ORDER BY ${col} ${dir}`
|
|
311
|
-
} else {
|
|
312
|
-
sql += ` ORDER BY ${table}.created_at DESC`
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Paginazione
|
|
316
|
-
if (pagination) {
|
|
317
|
-
sql += ` LIMIT ? OFFSET ?`
|
|
318
|
-
bindings.push(pagination.limit, pagination.offset)
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
return { sql, bindings }
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function buildFilterCondition(
|
|
325
|
-
col: string,
|
|
326
|
-
type: FilterType,
|
|
327
|
-
cond: FilterCondition,
|
|
328
|
-
bindings: (string | number | boolean | null)[]
|
|
329
|
-
): string | null {
|
|
330
|
-
const { op, value } = cond
|
|
331
|
-
|
|
332
|
-
if (op === 'is_empty') {
|
|
333
|
-
return type === 'text' ? `(${col} IS NULL OR ${col} = '')` : `${col} IS NULL`
|
|
334
|
-
}
|
|
335
|
-
if (op === 'is_not_empty') {
|
|
336
|
-
return type === 'text' ? `(${col} IS NOT NULL AND ${col} != '')` : `${col} IS NOT NULL`
|
|
337
|
-
}
|
|
338
|
-
if (value === null || value === undefined) return null
|
|
339
|
-
|
|
340
|
-
if (op === 'eq') {
|
|
341
|
-
bindings.push(type === 'boolean' ? (value ? 1 : 0) : (value as string | number))
|
|
342
|
-
return `${col} = ?`
|
|
343
|
-
}
|
|
344
|
-
if (op === 'contains') {
|
|
345
|
-
if (type === 'tags') {
|
|
346
|
-
bindings.push(String(value))
|
|
347
|
-
return `EXISTS (SELECT 1 FROM json_each(${col}) WHERE value = ?)`
|
|
348
|
-
}
|
|
349
|
-
bindings.push(`%${String(value)}%`)
|
|
350
|
-
return `${col} LIKE ?`
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const mathOps: Record<string, string> = { gt: '>', gte: '>=', lt: '<', lte: '<=' }
|
|
354
|
-
if (mathOps[op]) {
|
|
355
|
-
bindings.push(value as number)
|
|
356
|
-
return `${col} ${mathOps[op]} ?`
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return null
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// ---- Schema introspection ----
|
|
363
|
-
|
|
364
|
-
export interface SchemaColumn {
|
|
365
|
-
name: string
|
|
366
|
-
sqlType: 'TEXT' | 'REAL' | 'INTEGER'
|
|
367
|
-
notNull: boolean
|
|
368
|
-
isPk: boolean
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Ritorna la lista di colonne attese per la tabella di un Seed.
|
|
373
|
-
* Usato da `beech seed:load --diff` per confrontare schema attuale vs atteso.
|
|
374
|
-
*/
|
|
375
|
-
export function getExpectedColumns(seed: Seed): SchemaColumn[] {
|
|
376
|
-
return [
|
|
377
|
-
{ name: 'id', sqlType: 'TEXT', notNull: true, isPk: true },
|
|
378
|
-
{ name: 'slug', sqlType: 'TEXT', notNull: true, isPk: false },
|
|
379
|
-
{ name: 'status', sqlType: 'TEXT', notNull: true, isPk: false },
|
|
380
|
-
...seed.branches.map(b => ({
|
|
381
|
-
name: b.alias,
|
|
382
|
-
sqlType: BRANCH_TYPE_SQL[b.type].sqlType,
|
|
383
|
-
notNull: b.requiredOnCreate ?? false,
|
|
384
|
-
isPk: false,
|
|
385
|
-
})),
|
|
386
|
-
{ name: 'created_at', sqlType: 'INTEGER', notNull: true, isPk: false },
|
|
387
|
-
{ name: 'updated_at', sqlType: 'INTEGER', notNull: true, isPk: false },
|
|
388
|
-
]
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// ---- Serialization / Deserialization ----
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Serializza un valore per la scrittura nel DB.
|
|
395
|
-
* boolean → 0/1 | date → Unix timestamp | json/asset-list → JSON string
|
|
396
|
-
*/
|
|
397
|
-
export function serializeForDb(branch: Branch, value: unknown): string | number | null {
|
|
398
|
-
if (value === null || value === undefined) return null
|
|
399
|
-
|
|
400
|
-
switch (branch.type) {
|
|
401
|
-
case 'boolean':
|
|
402
|
-
return value ? 1 : 0
|
|
403
|
-
|
|
404
|
-
case 'json':
|
|
405
|
-
case 'richtext':
|
|
406
|
-
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
407
|
-
|
|
408
|
-
case 'date': {
|
|
409
|
-
if (typeof value === 'number') return value
|
|
410
|
-
if (typeof value === 'string') {
|
|
411
|
-
const d = new Date(value)
|
|
412
|
-
if (isNaN(d.getTime())) return null
|
|
413
|
-
if (branch.format === 'date') {
|
|
414
|
-
const midnight = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
|
|
415
|
-
return Math.floor(midnight / 1000)
|
|
416
|
-
}
|
|
417
|
-
return Math.floor(d.getTime() / 1000)
|
|
418
|
-
}
|
|
419
|
-
return null
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
case 'file':
|
|
423
|
-
if (isAssetListBranch(branch)) {
|
|
424
|
-
return Array.isArray(value) ? JSON.stringify(value) : typeof value === 'string' ? value : null
|
|
425
|
-
}
|
|
426
|
-
return typeof value === 'string' ? value : null
|
|
427
|
-
|
|
428
|
-
default:
|
|
429
|
-
return typeof value === 'string' ? value : typeof value === 'number' ? value : null
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Deserializza un valore letto dal DB per la risposta API.
|
|
435
|
-
* 0/1 → boolean | Unix timestamp → ISO 8601 | JSON string → object
|
|
436
|
-
*/
|
|
437
|
-
export function deserializeFromDb(branch: Branch, value: unknown): unknown {
|
|
438
|
-
if (value === null || value === undefined) return null
|
|
439
|
-
|
|
440
|
-
switch (branch.type) {
|
|
441
|
-
case 'boolean':
|
|
442
|
-
return value === 1 || value === true
|
|
443
|
-
|
|
444
|
-
case 'json':
|
|
445
|
-
case 'richtext': {
|
|
446
|
-
if (typeof value === 'string') {
|
|
447
|
-
try { return JSON.parse(value) } catch { return value }
|
|
448
|
-
}
|
|
449
|
-
return value
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
case 'date': {
|
|
453
|
-
if (typeof value !== 'number') return null
|
|
454
|
-
const d = new Date(value * 1000)
|
|
455
|
-
return branch.format === 'date' ? d.toISOString().slice(0, 10) : d.toISOString()
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
case 'file':
|
|
459
|
-
if (isAssetListBranch(branch)) return normalizeAssetListValue(value)
|
|
460
|
-
return typeof value === 'string' ? value : null
|
|
461
|
-
|
|
462
|
-
default:
|
|
463
|
-
return value
|
|
464
|
-
}
|
|
465
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @beechcms/core - Botanical Engine
|
|
3
|
-
*
|
|
4
|
-
* Pacchetto condiviso del monorepo Beech CMS.
|
|
5
|
-
* In v0.4.0 il Botanical Engine è un compilatore di schema SQL: legge i Seed
|
|
6
|
-
* TypeScript e genera DDL deterministico + query parametrizzate.
|
|
7
|
-
*
|
|
8
|
-
* @module @beechcms/core
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export * from './types.js'
|
|
12
|
-
export * from './define-seed.js'
|
|
13
|
-
export * from './seeds.js'
|
|
14
|
-
export * from './engine.js'
|
|
15
|
-
export * from './validation.js'
|
|
16
|
-
export * from './richtext.js'
|
|
17
|
-
export * from './richtext-render.js'
|
|
18
|
-
export * from './slug-utils.js'
|
|
19
|
-
export * from './policies.js'
|
package/src/policies.test.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { resolvePolicies, verifyHashField } from './policies'
|
|
3
|
-
import type { Branch } from './types'
|
|
4
|
-
|
|
5
|
-
const baseBranch: Branch = {
|
|
6
|
-
id: 'br_01',
|
|
7
|
-
alias: 'field',
|
|
8
|
-
label: 'Field',
|
|
9
|
-
type: 'text',
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe('resolvePolicies', () => {
|
|
13
|
-
it('returns all defaults when policies is undefined', () => {
|
|
14
|
-
const result = resolvePolicies(baseBranch)
|
|
15
|
-
expect(result).toEqual({
|
|
16
|
-
privacy: 'plain',
|
|
17
|
-
visibility: 'full',
|
|
18
|
-
search: true,
|
|
19
|
-
filter: true,
|
|
20
|
-
sort: true,
|
|
21
|
-
public: true,
|
|
22
|
-
})
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('returns all defaults when policies is an empty object', () => {
|
|
26
|
-
const result = resolvePolicies({ ...baseBranch, policies: {} })
|
|
27
|
-
expect(result).toEqual({
|
|
28
|
-
privacy: 'plain',
|
|
29
|
-
visibility: 'full',
|
|
30
|
-
search: true,
|
|
31
|
-
filter: true,
|
|
32
|
-
sort: true,
|
|
33
|
-
public: true,
|
|
34
|
-
})
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
it('privacy: hash defaults visibility to hidden', () => {
|
|
38
|
-
const result = resolvePolicies({ ...baseBranch, policies: { privacy: 'hash' } })
|
|
39
|
-
expect(result.privacy).toBe('hash')
|
|
40
|
-
expect(result.visibility).toBe('hidden')
|
|
41
|
-
expect(result.search).toBe(true)
|
|
42
|
-
expect(result.filter).toBe(true)
|
|
43
|
-
expect(result.sort).toBe(true)
|
|
44
|
-
expect(result.public).toBe(true)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('privacy: hash with explicit visibility overrides the default', () => {
|
|
48
|
-
const result = resolvePolicies({ ...baseBranch, policies: { privacy: 'hash', visibility: 'masked' } })
|
|
49
|
-
expect(result.privacy).toBe('hash')
|
|
50
|
-
expect(result.visibility).toBe('masked')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('overrides visibility independently without affecting other defaults', () => {
|
|
54
|
-
const result = resolvePolicies({ ...baseBranch, policies: { visibility: 'masked' } })
|
|
55
|
-
expect(result.visibility).toBe('masked')
|
|
56
|
-
expect(result.privacy).toBe('plain')
|
|
57
|
-
expect(result.search).toBe(true)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('overrides search: false independently', () => {
|
|
61
|
-
const result = resolvePolicies({ ...baseBranch, policies: { search: false } })
|
|
62
|
-
expect(result.search).toBe(false)
|
|
63
|
-
expect(result.filter).toBe(true)
|
|
64
|
-
expect(result.sort).toBe(true)
|
|
65
|
-
expect(result.public).toBe(true)
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('overrides filter: false independently', () => {
|
|
69
|
-
const result = resolvePolicies({ ...baseBranch, policies: { filter: false } })
|
|
70
|
-
expect(result.filter).toBe(false)
|
|
71
|
-
expect(result.search).toBe(true)
|
|
72
|
-
expect(result.sort).toBe(true)
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('overrides sort: false independently', () => {
|
|
76
|
-
const result = resolvePolicies({ ...baseBranch, policies: { sort: false } })
|
|
77
|
-
expect(result.sort).toBe(false)
|
|
78
|
-
expect(result.filter).toBe(true)
|
|
79
|
-
expect(result.search).toBe(true)
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('overrides public: false independently', () => {
|
|
83
|
-
const result = resolvePolicies({ ...baseBranch, policies: { public: false } })
|
|
84
|
-
expect(result.public).toBe(false)
|
|
85
|
-
expect(result.visibility).toBe('full')
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('handles multiple overrides simultaneously', () => {
|
|
89
|
-
const result = resolvePolicies({
|
|
90
|
-
...baseBranch,
|
|
91
|
-
policies: { privacy: 'hash', visibility: 'hidden', search: false, public: false },
|
|
92
|
-
})
|
|
93
|
-
expect(result.privacy).toBe('hash')
|
|
94
|
-
expect(result.visibility).toBe('hidden')
|
|
95
|
-
expect(result.search).toBe(false)
|
|
96
|
-
expect(result.filter).toBe(true)
|
|
97
|
-
expect(result.sort).toBe(true)
|
|
98
|
-
expect(result.public).toBe(false)
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('handles visibility: hidden correctly', () => {
|
|
102
|
-
const result = resolvePolicies({ ...baseBranch, policies: { visibility: 'hidden' } })
|
|
103
|
-
expect(result.visibility).toBe('hidden')
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('privacy: encrypt defaults visibility to hidden', () => {
|
|
107
|
-
const result = resolvePolicies({ ...baseBranch, policies: { privacy: 'encrypt' } })
|
|
108
|
-
expect(result.privacy).toBe('encrypt')
|
|
109
|
-
expect(result.visibility).toBe('hidden')
|
|
110
|
-
})
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
describe('verifyHashField', () => {
|
|
114
|
-
it('returns true when candidate matches stored hash', async () => {
|
|
115
|
-
const candidate = 'mysecretpassword'
|
|
116
|
-
// pre-compute the expected hash
|
|
117
|
-
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(candidate))
|
|
118
|
-
const stored = Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
119
|
-
expect(await verifyHashField(stored, candidate)).toBe(true)
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
it('returns false when candidate does not match stored hash', async () => {
|
|
123
|
-
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('correct'))
|
|
124
|
-
const stored = Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
125
|
-
expect(await verifyHashField(stored, 'wrong')).toBe(false)
|
|
126
|
-
})
|
|
127
|
-
})
|