@beechcms/core 0.9.4 → 0.9.5
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/content-lock.repository.d.ts +63 -0
- package/dist/content/content-lock.repository.d.ts.map +1 -0
- package/dist/content/content-lock.repository.js +15 -0
- package/dist/dashboard-layout/kanban/kanban-position.repository.d.ts +19 -0
- package/dist/dashboard-layout/kanban/kanban-position.repository.d.ts.map +1 -0
- package/dist/dashboard-layout/kanban/kanban-position.repository.js +3 -0
- package/dist/engine/ddl/seed-ddl-destructive.d.ts +40 -0
- package/dist/engine/ddl/seed-ddl-destructive.d.ts.map +1 -0
- package/dist/engine/ddl/seed-ddl-destructive.js +149 -0
- package/dist/engine/ddl.d.ts +216 -0
- package/dist/engine/ddl.d.ts.map +1 -0
- package/dist/engine/ddl.js +574 -0
- package/dist/engine/define-seed.d.ts +3 -0
- package/dist/engine/define-seed.d.ts.map +1 -0
- package/dist/engine/define-seed.js +5 -0
- package/dist/engine/introspection.d.ts +101 -0
- package/dist/engine/introspection.d.ts.map +1 -0
- package/dist/engine/introspection.js +127 -0
- package/dist/engine/policies.d.ts +58 -0
- package/dist/engine/policies.d.ts.map +1 -0
- package/dist/engine/policies.js +168 -0
- package/dist/engine/privacy.service.d.ts +65 -0
- package/dist/engine/privacy.service.d.ts.map +1 -0
- package/dist/engine/privacy.service.js +114 -0
- package/dist/engine/query/relations.d.ts +15 -0
- package/dist/engine/query/relations.d.ts.map +1 -1
- package/dist/engine/query.d.ts +23 -0
- package/dist/engine/query.d.ts.map +1 -0
- package/dist/engine/query.js +248 -0
- package/dist/engine/relations.d.ts +28 -0
- package/dist/engine/relations.d.ts.map +1 -0
- package/dist/engine/relations.js +25 -0
- package/dist/engine/schema-fingerprint.d.ts +71 -0
- package/dist/engine/schema-fingerprint.d.ts.map +1 -0
- package/dist/engine/schema-fingerprint.js +95 -0
- package/dist/engine/schema-mutator.d.ts +26 -0
- package/dist/engine/schema-mutator.d.ts.map +1 -0
- package/dist/engine/schema-mutator.js +3 -0
- package/dist/engine/seed-ddl-destructive.d.ts +40 -0
- package/dist/engine/seed-ddl-destructive.d.ts.map +1 -0
- package/dist/engine/seed-ddl-destructive.js +149 -0
- package/dist/engine/seed-ddl.d.ts +43 -0
- package/dist/engine/seed-ddl.d.ts.map +1 -0
- package/dist/engine/seed-ddl.js +115 -0
- package/dist/engine/seed-registry.d.ts +71 -0
- package/dist/engine/seed-registry.d.ts.map +1 -0
- package/dist/engine/seed-registry.js +167 -0
- package/dist/engine/seed-types-generator.d.ts +25 -0
- package/dist/engine/seed-types-generator.d.ts.map +1 -0
- package/dist/engine/seed-types-generator.js +117 -0
- package/dist/engine/seed-validation.d.ts +30 -0
- package/dist/engine/seed-validation.d.ts.map +1 -0
- package/dist/engine/seed-validation.js +329 -0
- package/dist/engine/seeds.d.ts +20 -0
- package/dist/engine/seeds.d.ts.map +1 -0
- package/dist/engine/seeds.js +21 -0
- package/dist/engine/serialize.d.ts +20 -0
- package/dist/engine/serialize.d.ts.map +1 -0
- package/dist/engine/serialize.js +160 -0
- package/dist/seed-types-generator.d.ts +10 -0
- package/dist/seed-types-generator.d.ts.map +1 -0
- package/dist/seed-types-generator.js +86 -0
- package/package.json +2 -2
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { resolvePolicies, resolveClassification } from './policies.js';
|
|
2
|
+
/**
|
|
3
|
+
* Helper function to determine if a branch requires a blind index column.
|
|
4
|
+
* Returns true if storage classification is 'encrypt' and filtering is not explicitly disabled.
|
|
5
|
+
*/
|
|
6
|
+
export function hasBlindIndex(branch) {
|
|
7
|
+
const resolved = resolveClassification(branch);
|
|
8
|
+
return resolved.storage === 'encrypt' && branch.policies?.filter !== false;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Mapping of core branch types to SQLite column types.
|
|
12
|
+
*/
|
|
13
|
+
const BRANCH_TYPE_SQL = {
|
|
14
|
+
text: { sqlType: 'TEXT' },
|
|
15
|
+
number: { sqlType: 'REAL' },
|
|
16
|
+
boolean: { sqlType: 'INTEGER' },
|
|
17
|
+
date: { sqlType: 'INTEGER' }, // Unix timestamp (seconds)
|
|
18
|
+
json: { sqlType: 'TEXT' }, // Serialized JSON string
|
|
19
|
+
richtext: { sqlType: 'TEXT' },
|
|
20
|
+
file: { sqlType: 'TEXT' }, // Single URL or serialized JSON array of URLs
|
|
21
|
+
tags: { sqlType: 'TEXT' }, // Serialized JSON array or object
|
|
22
|
+
relation: { sqlType: 'TEXT' }, // Foreign Key reference stored as TEXT (id of the target row)
|
|
23
|
+
repeater: { sqlType: 'TEXT' }, // Serialized JSON array of records
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* System-defined columns that exist on all content tables.
|
|
27
|
+
*/
|
|
28
|
+
export const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at', 'deleted_at']);
|
|
29
|
+
/**
|
|
30
|
+
* Returns the SQL table name for a given Seed.
|
|
31
|
+
*
|
|
32
|
+
* @param seed The seed definition.
|
|
33
|
+
* @returns The table name.
|
|
34
|
+
*/
|
|
35
|
+
export function tableName(seed) {
|
|
36
|
+
return `content_${seed.slug}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Returns the draft SQL table name for a given Seed.
|
|
40
|
+
*
|
|
41
|
+
* @param seed The seed definition.
|
|
42
|
+
* @returns The draft table name.
|
|
43
|
+
*/
|
|
44
|
+
function draftTableName(seed) {
|
|
45
|
+
return `content_${seed.slug}_drafts`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Returns the FTS (Full-Text Search) virtual table name for a given Seed.
|
|
49
|
+
*
|
|
50
|
+
* @param seed The seed definition.
|
|
51
|
+
* @returns The FTS table name.
|
|
52
|
+
*/
|
|
53
|
+
export function ftsTableName(seed) {
|
|
54
|
+
return `fts_${seed.slug}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Returns the vector table name for a given Seed.
|
|
58
|
+
*
|
|
59
|
+
* @param seed The seed definition.
|
|
60
|
+
* @returns The vector table name.
|
|
61
|
+
*/
|
|
62
|
+
export function vectorTableName(seed) {
|
|
63
|
+
return `vector_${seed.slug}`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Determines whether a column is valid for a given Seed (either system column or branch alias).
|
|
67
|
+
*
|
|
68
|
+
* @param seed The seed definition.
|
|
69
|
+
* @param col The column name to check.
|
|
70
|
+
* @returns True if the column is valid, false otherwise.
|
|
71
|
+
*/
|
|
72
|
+
export function isValidColumn(seed, col) {
|
|
73
|
+
if (col === 'deleted_at')
|
|
74
|
+
return seed.softDelete === true;
|
|
75
|
+
if (SYSTEM_COLUMNS.has(col))
|
|
76
|
+
return true;
|
|
77
|
+
return seed.branches.some(b => b.alias === col);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Filters the branches of a Seed to return only the ones that are indexable for full-text search.
|
|
81
|
+
*
|
|
82
|
+
* @param seed The seed definition.
|
|
83
|
+
* @returns An array of indexable branches.
|
|
84
|
+
*/
|
|
85
|
+
export function indexableSearchBranches(seed) {
|
|
86
|
+
return seed.branches.filter(b => {
|
|
87
|
+
if (b.type !== 'text' && b.type !== 'richtext')
|
|
88
|
+
return false;
|
|
89
|
+
const { search, public: isPublic } = resolvePolicies(b);
|
|
90
|
+
return search && isPublic;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The default ON DELETE referential integrity rule for single-value relations.
|
|
95
|
+
*/
|
|
96
|
+
const DEFAULT_ON_DELETE_RULE = 'SET NULL';
|
|
97
|
+
/**
|
|
98
|
+
* Builds the SQL foreign key clause for a single-value relation branch.
|
|
99
|
+
*
|
|
100
|
+
* Multi-relation branches (multiple: true) produce NO column on the parent table;
|
|
101
|
+
* they live in a dedicated junction table. Returns an empty string for them.
|
|
102
|
+
*
|
|
103
|
+
* @param branch The branch definition.
|
|
104
|
+
* @throws {Error} If the relation branch is missing the `targetSeed` parameter.
|
|
105
|
+
* @returns The foreign key SQL clause, or an empty string.
|
|
106
|
+
*/
|
|
107
|
+
function buildForeignKeyClause(branch) {
|
|
108
|
+
if (branch.type !== 'relation')
|
|
109
|
+
return '';
|
|
110
|
+
if (branch.multiple === true)
|
|
111
|
+
return '';
|
|
112
|
+
if (!branch.targetSeed) {
|
|
113
|
+
throw new Error(`Branch "${branch.alias}" is of type 'relation' but has no targetSeed`);
|
|
114
|
+
}
|
|
115
|
+
const onDeleteRule = branch.onDelete ?? DEFAULT_ON_DELETE_RULE;
|
|
116
|
+
return ` REFERENCES content_${branch.targetSeed}(id) ON DELETE ${onDeleteRule}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Generates the SQL `CREATE TABLE IF NOT EXISTS content_{slug}` statement
|
|
120
|
+
* with system columns and one column per branch.
|
|
121
|
+
*
|
|
122
|
+
* @param seed The seed definition.
|
|
123
|
+
* @returns The CREATE TABLE SQL statement.
|
|
124
|
+
*/
|
|
125
|
+
export function generateCreateTable(seed) {
|
|
126
|
+
const table = tableName(seed);
|
|
127
|
+
const lines = [
|
|
128
|
+
`CREATE TABLE IF NOT EXISTS ${table} (`,
|
|
129
|
+
` id TEXT NOT NULL PRIMARY KEY,`,
|
|
130
|
+
// Soft-delete tables carry uniqueness in a PARTIAL unique index instead, so a trashed
|
|
131
|
+
// row stops reserving its slug. An inline UNIQUE would become a sqlite_autoindex that
|
|
132
|
+
// no ALTER TABLE can drop later.
|
|
133
|
+
seed.softDelete
|
|
134
|
+
? ` slug TEXT NOT NULL,`
|
|
135
|
+
: ` slug TEXT NOT NULL UNIQUE,`,
|
|
136
|
+
` status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'review', 'published', 'archived')),`,
|
|
137
|
+
];
|
|
138
|
+
for (const branch of seed.branches) {
|
|
139
|
+
if (branch.type === 'relation' && branch.multiple === true)
|
|
140
|
+
continue;
|
|
141
|
+
const { sqlType } = BRANCH_TYPE_SQL[branch.type];
|
|
142
|
+
let col = ` ${branch.alias} ${sqlType}`;
|
|
143
|
+
if (branch.requiredOnCreate)
|
|
144
|
+
col += ' NOT NULL';
|
|
145
|
+
if (branch.type === 'boolean')
|
|
146
|
+
col += ` CHECK (${branch.alias} IN (0, 1))`;
|
|
147
|
+
col += buildForeignKeyClause(branch);
|
|
148
|
+
lines.push(col + ',');
|
|
149
|
+
if (hasBlindIndex(branch)) {
|
|
150
|
+
lines.push(` ${branch.alias}_bidx TEXT,`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
lines.push(` created_at INTEGER NOT NULL DEFAULT (unixepoch()),`);
|
|
154
|
+
lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())` + (seed.softDelete ? ',' : ''));
|
|
155
|
+
if (seed.softDelete)
|
|
156
|
+
lines.push(` deleted_at INTEGER`);
|
|
157
|
+
lines.push(`);`);
|
|
158
|
+
return lines.join('\n');
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Generates the SQL `CREATE TABLE IF NOT EXISTS content_{slug}_drafts` statement
|
|
162
|
+
* for seeds that allow drafts.
|
|
163
|
+
*
|
|
164
|
+
* @param seed The seed definition.
|
|
165
|
+
* @returns The draft table CREATE TABLE SQL statement, or null if drafts are disabled.
|
|
166
|
+
*/
|
|
167
|
+
export function generateDraftTable(seed) {
|
|
168
|
+
if (!seed.allowDrafts)
|
|
169
|
+
return null;
|
|
170
|
+
const table = `content_${seed.slug}_drafts`;
|
|
171
|
+
const mainTable = `content_${seed.slug}`;
|
|
172
|
+
const lines = [
|
|
173
|
+
`CREATE TABLE IF NOT EXISTS ${table} (`,
|
|
174
|
+
` entry_id TEXT NOT NULL PRIMARY KEY`,
|
|
175
|
+
` REFERENCES ${mainTable}(id) ON DELETE CASCADE,`,
|
|
176
|
+
];
|
|
177
|
+
for (const branch of seed.branches) {
|
|
178
|
+
if (branch.type === 'relation' && branch.multiple === true)
|
|
179
|
+
continue;
|
|
180
|
+
const { sqlType } = BRANCH_TYPE_SQL[branch.type];
|
|
181
|
+
let col = ` ${branch.alias} ${sqlType}`;
|
|
182
|
+
if (branch.type === 'boolean')
|
|
183
|
+
col += ` CHECK (${branch.alias} IN (0, 1))`;
|
|
184
|
+
lines.push(col + ',');
|
|
185
|
+
if (hasBlindIndex(branch)) {
|
|
186
|
+
lines.push(` ${branch.alias}_bidx TEXT,`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
lines.push(` _touched_fields TEXT,`);
|
|
190
|
+
// Live-row `updated_at` as it stood when this draft was created, written once and never re-based.
|
|
191
|
+
// Nullable on purpose: drafts created before optimistic-concurrency publish existed carry NULL and
|
|
192
|
+
// publish unchecked, so enabling this feature cannot block an in-flight draft.
|
|
193
|
+
lines.push(` live_snapshot_at INTEGER,`);
|
|
194
|
+
lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`);
|
|
195
|
+
lines.push(`);`);
|
|
196
|
+
return lines.join('\n');
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Generates the SQL `ALTER TABLE content_{slug} ADD COLUMN {alias} {type}` statement.
|
|
200
|
+
*
|
|
201
|
+
* @param seed The seed definition.
|
|
202
|
+
* @param branch The branch definition for the new column.
|
|
203
|
+
* @returns The ALTER TABLE SQL statement.
|
|
204
|
+
*/
|
|
205
|
+
export function generateAddColumn(seed, branch) {
|
|
206
|
+
const { sqlType } = BRANCH_TYPE_SQL[branch.type];
|
|
207
|
+
return `ALTER TABLE ${tableName(seed)} ADD COLUMN ${branch.alias} ${sqlType}${buildForeignKeyClause(branch)};`;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Generates the `ALTER TABLE content_{slug}_drafts ADD COLUMN live_snapshot_at INTEGER` statement
|
|
211
|
+
* for a draft table provisioned before the column existed.
|
|
212
|
+
*
|
|
213
|
+
* System column, not a branch — `generateAddColumn` iterates `seed.branches` and can never emit it.
|
|
214
|
+
* NOT idempotent: `ADD COLUMN` fails when the column is already present and `ISchemaMutator.execDdl`
|
|
215
|
+
* aborts the whole batch on the first failing statement, so the caller MUST check the physical
|
|
216
|
+
* columns first (see `planExtendSeed`).
|
|
217
|
+
*
|
|
218
|
+
* @param seed The seed definition. Caller must have verified `seed.allowDrafts`.
|
|
219
|
+
* @returns The ALTER TABLE statement.
|
|
220
|
+
*/
|
|
221
|
+
export function generateAddDraftSnapshotColumn(seed) {
|
|
222
|
+
return `ALTER TABLE content_${seed.slug}_drafts ADD COLUMN live_snapshot_at INTEGER;`;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Generates SQL index statements for system fields (status, created_at)
|
|
226
|
+
* and indexable branch columns.
|
|
227
|
+
*
|
|
228
|
+
* @param seed The seed definition.
|
|
229
|
+
* @returns An array of CREATE INDEX SQL statements.
|
|
230
|
+
*/
|
|
231
|
+
export function generateIndexes(seed) {
|
|
232
|
+
const table = tableName(seed);
|
|
233
|
+
const slug = seed.slug;
|
|
234
|
+
const indexes = [
|
|
235
|
+
`CREATE INDEX IF NOT EXISTS idx_${slug}_status ON ${table}(status);`,
|
|
236
|
+
`CREATE INDEX IF NOT EXISTS idx_${slug}_created_at ON ${table}(created_at);`,
|
|
237
|
+
];
|
|
238
|
+
for (const branch of seed.branches) {
|
|
239
|
+
if (branch.type === 'relation' && branch.multiple === true)
|
|
240
|
+
continue;
|
|
241
|
+
const isRelation = branch.type === 'relation';
|
|
242
|
+
if (!isRelation && !resolvePolicies(branch).filter)
|
|
243
|
+
continue;
|
|
244
|
+
if (['text', 'number', 'date', 'boolean', 'relation'].includes(branch.type)) {
|
|
245
|
+
indexes.push(`CREATE INDEX IF NOT EXISTS idx_${slug}_${branch.alias} ON ${table}(${branch.alias});`);
|
|
246
|
+
}
|
|
247
|
+
if (hasBlindIndex(branch)) {
|
|
248
|
+
indexes.push(`CREATE INDEX IF NOT EXISTS idx_${slug}_${branch.alias}_bidx ON ${table}(${branch.alias}_bidx);`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (seed.softDelete) {
|
|
252
|
+
indexes.push(`CREATE INDEX IF NOT EXISTS idx_${slug}_deleted_at ON ${table}(deleted_at);`);
|
|
253
|
+
// Active rows only: a trashed row releases its slug for reuse (feature brief §4).
|
|
254
|
+
indexes.push(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${slug}_slug_active ON ${table}(slug) WHERE deleted_at IS NULL;`);
|
|
255
|
+
}
|
|
256
|
+
return indexes;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Generates the FTS5 virtual table definition for indexable text/richtext branches.
|
|
260
|
+
*
|
|
261
|
+
* @param seed The seed definition.
|
|
262
|
+
* @returns The CREATE VIRTUAL TABLE SQL statement, or null if no branches are indexable.
|
|
263
|
+
*/
|
|
264
|
+
export function generateFtsTable(seed) {
|
|
265
|
+
const rtBranches = indexableSearchBranches(seed);
|
|
266
|
+
if (rtBranches.length === 0)
|
|
267
|
+
return null;
|
|
268
|
+
const ftsTable = ftsTableName(seed);
|
|
269
|
+
const cols = rtBranches.map(b => ` ${b.alias}`).join(',\n');
|
|
270
|
+
return [
|
|
271
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS ${ftsTable} USING fts5(`,
|
|
272
|
+
` entry_id UNINDEXED,`,
|
|
273
|
+
`${cols},`,
|
|
274
|
+
` tokenize = 'unicode61'`,
|
|
275
|
+
`);`,
|
|
276
|
+
].join('\n');
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Generates FTS5 triggers (insert, update, delete) to keep the FTS virtual table synchronized.
|
|
280
|
+
*
|
|
281
|
+
* @param seed The seed definition.
|
|
282
|
+
* @returns An array of CREATE TRIGGER SQL statements.
|
|
283
|
+
*/
|
|
284
|
+
export function generateFtsTriggers(seed) {
|
|
285
|
+
const rtBranches = indexableSearchBranches(seed);
|
|
286
|
+
if (rtBranches.length === 0)
|
|
287
|
+
return [];
|
|
288
|
+
const table = tableName(seed);
|
|
289
|
+
const ftsTable = ftsTableName(seed);
|
|
290
|
+
const slug = seed.slug;
|
|
291
|
+
const cols = rtBranches.map(b => b.alias);
|
|
292
|
+
const ftsColList = ['entry_id', ...cols].join(', ');
|
|
293
|
+
const newValList = ['new.id', ...cols.map(c => `new.${c}`)].join(', ');
|
|
294
|
+
return [
|
|
295
|
+
[
|
|
296
|
+
`CREATE TRIGGER IF NOT EXISTS fts_${slug}_insert`,
|
|
297
|
+
`AFTER INSERT ON ${table} BEGIN`,
|
|
298
|
+
` INSERT INTO ${ftsTable}(${ftsColList}) VALUES (${newValList});`,
|
|
299
|
+
`END;`,
|
|
300
|
+
].join('\n'),
|
|
301
|
+
[
|
|
302
|
+
`CREATE TRIGGER IF NOT EXISTS fts_${slug}_update`,
|
|
303
|
+
`AFTER UPDATE OF ${cols.join(', ')} ON ${table} BEGIN`,
|
|
304
|
+
` DELETE FROM ${ftsTable} WHERE entry_id = old.id;`,
|
|
305
|
+
` INSERT INTO ${ftsTable}(${ftsColList}) VALUES (${newValList});`,
|
|
306
|
+
`END;`,
|
|
307
|
+
].join('\n'),
|
|
308
|
+
[
|
|
309
|
+
`CREATE TRIGGER IF NOT EXISTS fts_${slug}_delete`,
|
|
310
|
+
`AFTER DELETE ON ${table} BEGIN`,
|
|
311
|
+
` DELETE FROM ${ftsTable} WHERE entry_id = old.id;`,
|
|
312
|
+
`END;`,
|
|
313
|
+
].join('\n'),
|
|
314
|
+
];
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Generates the SQL `CREATE TABLE IF NOT EXISTS vector_{slug}` statement
|
|
318
|
+
* for indexable text/richtext branches.
|
|
319
|
+
*
|
|
320
|
+
* @param seed The seed definition.
|
|
321
|
+
* @returns The CREATE TABLE SQL statement, or null if no branches are indexable.
|
|
322
|
+
*/
|
|
323
|
+
export function generateVectorTable(seed) {
|
|
324
|
+
const rtBranches = indexableSearchBranches(seed);
|
|
325
|
+
if (rtBranches.length === 0)
|
|
326
|
+
return null;
|
|
327
|
+
const table = vectorTableName(seed);
|
|
328
|
+
return [
|
|
329
|
+
`CREATE TABLE IF NOT EXISTS ${table} (`,
|
|
330
|
+
` entry_id TEXT NOT NULL PRIMARY KEY REFERENCES content_${seed.slug}(id) ON DELETE CASCADE,`,
|
|
331
|
+
` vector BLOB NOT NULL`,
|
|
332
|
+
`);`,
|
|
333
|
+
].join('\n');
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Returns the list of expected columns for a Seed's table.
|
|
337
|
+
* Used to compare current vs expected schema (diffing).
|
|
338
|
+
*
|
|
339
|
+
* @param seed The seed definition.
|
|
340
|
+
* @returns The array of expected schema columns.
|
|
341
|
+
*/
|
|
342
|
+
export function getExpectedColumns(seed) {
|
|
343
|
+
const branchCols = [];
|
|
344
|
+
for (const b of seed.branches) {
|
|
345
|
+
if (b.type === 'relation' && b.multiple === true)
|
|
346
|
+
continue;
|
|
347
|
+
branchCols.push({
|
|
348
|
+
name: b.alias,
|
|
349
|
+
sqlType: BRANCH_TYPE_SQL[b.type].sqlType,
|
|
350
|
+
notNull: b.requiredOnCreate ?? false,
|
|
351
|
+
isPk: false,
|
|
352
|
+
});
|
|
353
|
+
if (hasBlindIndex(b)) {
|
|
354
|
+
branchCols.push({
|
|
355
|
+
name: `${b.alias}_bidx`,
|
|
356
|
+
sqlType: 'TEXT',
|
|
357
|
+
notNull: false,
|
|
358
|
+
isPk: false,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return [
|
|
363
|
+
{ name: 'id', sqlType: 'TEXT', notNull: true, isPk: true },
|
|
364
|
+
{ name: 'slug', sqlType: 'TEXT', notNull: true, isPk: false },
|
|
365
|
+
{ name: 'status', sqlType: 'TEXT', notNull: true, isPk: false },
|
|
366
|
+
...branchCols,
|
|
367
|
+
{ name: 'created_at', sqlType: 'INTEGER', notNull: true, isPk: false },
|
|
368
|
+
{ name: 'updated_at', sqlType: 'INTEGER', notNull: true, isPk: false },
|
|
369
|
+
...(seed.softDelete
|
|
370
|
+
? [{ name: 'deleted_at', sqlType: 'INTEGER', notNull: false, isPk: false }]
|
|
371
|
+
: []),
|
|
372
|
+
];
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Returns the junction table name for a many-to-many relation branch.
|
|
376
|
+
* Format: `rel_<seedSlug>_<branchAlias>`.
|
|
377
|
+
*
|
|
378
|
+
* @param seedSlug The slug of the parent Seed.
|
|
379
|
+
* @param branchAlias The alias of the relation branch.
|
|
380
|
+
* @returns The junction table name.
|
|
381
|
+
*/
|
|
382
|
+
export function junctionTableName(seedSlug, branchAlias) {
|
|
383
|
+
return `rel_${seedSlug}_${branchAlias}`;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Generates `CREATE TABLE IF NOT EXISTS rel_<seed>_<alias>` statement for a multi-relation branch.
|
|
387
|
+
*
|
|
388
|
+
* @param seed The parent seed definition.
|
|
389
|
+
* @param branch The multi-relation branch definition.
|
|
390
|
+
* @throws {Error} If called on a non-multi-relation branch or if `targetSeed` is missing.
|
|
391
|
+
* @returns The CREATE TABLE SQL statement.
|
|
392
|
+
*/
|
|
393
|
+
export function generateJunctionTable(seed, branch) {
|
|
394
|
+
if (branch.type !== 'relation' || branch.multiple !== true) {
|
|
395
|
+
throw new Error(`Branch "${branch.alias}" is not a multi-relation`);
|
|
396
|
+
}
|
|
397
|
+
if (!branch.targetSeed) {
|
|
398
|
+
throw new Error(`Branch "${branch.alias}" has no targetSeed`);
|
|
399
|
+
}
|
|
400
|
+
const onDeleteRule = branch.onDelete ?? 'CASCADE';
|
|
401
|
+
const table = junctionTableName(seed.slug, branch.alias);
|
|
402
|
+
return [
|
|
403
|
+
`CREATE TABLE IF NOT EXISTS ${table} (`,
|
|
404
|
+
` parent_id TEXT NOT NULL REFERENCES content_${seed.slug}(id) ON DELETE CASCADE,`,
|
|
405
|
+
` target_id TEXT NOT NULL REFERENCES content_${branch.targetSeed}(id) ON DELETE ${onDeleteRule},`,
|
|
406
|
+
` position INTEGER NOT NULL DEFAULT 0,`,
|
|
407
|
+
` created_at INTEGER NOT NULL DEFAULT (unixepoch()),`,
|
|
408
|
+
` PRIMARY KEY (parent_id, target_id)`,
|
|
409
|
+
`);`,
|
|
410
|
+
].join('\n');
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Generates the two B-tree indexes for a junction table:
|
|
414
|
+
* one on `parent_id` (listing entries by parent) and one on `target_id` (handling cascade checks from target side).
|
|
415
|
+
*
|
|
416
|
+
* @param seed The parent seed definition.
|
|
417
|
+
* @param branch The multi-relation branch definition.
|
|
418
|
+
* @returns An array of CREATE INDEX SQL statements.
|
|
419
|
+
*/
|
|
420
|
+
export function generateJunctionIndexes(seed, branch) {
|
|
421
|
+
const table = junctionTableName(seed.slug, branch.alias);
|
|
422
|
+
return [
|
|
423
|
+
`CREATE INDEX IF NOT EXISTS idx_${table}_parent ON ${table}(parent_id);`,
|
|
424
|
+
`CREATE INDEX IF NOT EXISTS idx_${table}_target ON ${table}(target_id);`,
|
|
425
|
+
];
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Generates the drafts junction table `rel_<seed>_<alias>_drafts` for a many-to-many relation branch.
|
|
429
|
+
*
|
|
430
|
+
* @param seed The parent seed definition.
|
|
431
|
+
* @param branch The multi-relation branch definition.
|
|
432
|
+
* @returns The CREATE TABLE SQL statement, or null if drafts are disabled.
|
|
433
|
+
*/
|
|
434
|
+
export function generateJunctionDraftTable(seed, branch) {
|
|
435
|
+
if (!seed.allowDrafts)
|
|
436
|
+
return null;
|
|
437
|
+
if (branch.type !== 'relation' || branch.multiple !== true)
|
|
438
|
+
return null;
|
|
439
|
+
const table = `${junctionTableName(seed.slug, branch.alias)}_drafts`;
|
|
440
|
+
const mainDraftTable = `content_${seed.slug}_drafts`;
|
|
441
|
+
return [
|
|
442
|
+
`CREATE TABLE IF NOT EXISTS ${table} (`,
|
|
443
|
+
` entry_id TEXT NOT NULL REFERENCES ${mainDraftTable}(entry_id) ON DELETE CASCADE,`,
|
|
444
|
+
` target_id TEXT NOT NULL,`,
|
|
445
|
+
` position INTEGER NOT NULL DEFAULT 0,`,
|
|
446
|
+
` PRIMARY KEY (entry_id, target_id)`,
|
|
447
|
+
`);`,
|
|
448
|
+
].join('\n');
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Returns every `DROP TABLE IF EXISTS` needed to fully remove a content type:
|
|
452
|
+
* the main table, the drafts table (if drafts enabled), full-text search table/triggers, and junction tables.
|
|
453
|
+
*
|
|
454
|
+
* @param seed The seed definition.
|
|
455
|
+
* @returns An array of DROP TABLE / DROP TRIGGER SQL statements.
|
|
456
|
+
*/
|
|
457
|
+
export function generateDropTable(seed) {
|
|
458
|
+
const stmts = [];
|
|
459
|
+
for (const branch of seed.branches) {
|
|
460
|
+
if (branch.type !== 'relation' || branch.multiple !== true)
|
|
461
|
+
continue;
|
|
462
|
+
const jt = junctionTableName(seed.slug, branch.alias);
|
|
463
|
+
stmts.push(`DROP TABLE IF EXISTS ${jt}_drafts;`);
|
|
464
|
+
stmts.push(`DROP TABLE IF EXISTS ${jt};`);
|
|
465
|
+
}
|
|
466
|
+
if (seed.allowDrafts) {
|
|
467
|
+
stmts.push(`DROP TABLE IF EXISTS ${draftTableName(seed)};`);
|
|
468
|
+
}
|
|
469
|
+
if (indexableSearchBranches(seed).length > 0) {
|
|
470
|
+
const slug = seed.slug;
|
|
471
|
+
stmts.push(`DROP TRIGGER IF EXISTS fts_${slug}_insert;`);
|
|
472
|
+
stmts.push(`DROP TRIGGER IF EXISTS fts_${slug}_update;`);
|
|
473
|
+
stmts.push(`DROP TRIGGER IF EXISTS fts_${slug}_delete;`);
|
|
474
|
+
stmts.push(`DROP TABLE IF EXISTS ${ftsTableName(seed)};`);
|
|
475
|
+
stmts.push(`DROP TABLE IF EXISTS ${vectorTableName(seed)};`);
|
|
476
|
+
}
|
|
477
|
+
stmts.push(`DROP TABLE IF EXISTS ${tableName(seed)};`);
|
|
478
|
+
return stmts;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Returns the `DROP COLUMN` SQL statements for removing a single field.
|
|
482
|
+
* Drops the junction table for multi-relations, or alters the tables to drop the column.
|
|
483
|
+
*
|
|
484
|
+
* @param seed The seed definition.
|
|
485
|
+
* @param alias The alias of the branch to drop.
|
|
486
|
+
* @returns An array of SQL statements.
|
|
487
|
+
*/
|
|
488
|
+
export function generateDropColumn(seed, alias) {
|
|
489
|
+
const branch = seed.branches.find(b => b.alias === alias);
|
|
490
|
+
if (branch && branch.type === 'relation' && branch.multiple === true) {
|
|
491
|
+
const jt = junctionTableName(seed.slug, alias);
|
|
492
|
+
return [
|
|
493
|
+
`DROP TABLE IF EXISTS ${jt}_drafts;`,
|
|
494
|
+
`DROP TABLE IF EXISTS ${jt};`,
|
|
495
|
+
];
|
|
496
|
+
}
|
|
497
|
+
const stmts = [`ALTER TABLE ${tableName(seed)} DROP COLUMN ${alias};`];
|
|
498
|
+
if (branch && seed.allowDrafts) {
|
|
499
|
+
stmts.push(`ALTER TABLE ${draftTableName(seed)} DROP COLUMN ${alias};`);
|
|
500
|
+
}
|
|
501
|
+
return stmts;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Returns the `RENAME COLUMN` SQL statements for renaming a field's alias.
|
|
505
|
+
* For a multi-relation branch, renames the junction table (+ drafts junction table) instead.
|
|
506
|
+
*
|
|
507
|
+
* @param seed The seed definition.
|
|
508
|
+
* @param from The old alias.
|
|
509
|
+
* @param to The new alias.
|
|
510
|
+
* @returns An array of SQL statements.
|
|
511
|
+
*/
|
|
512
|
+
export function generateRenameColumn(seed, from, to) {
|
|
513
|
+
const branch = seed.branches.find(b => b.alias === from);
|
|
514
|
+
if (branch && branch.type === 'relation' && branch.multiple === true) {
|
|
515
|
+
const oldJt = junctionTableName(seed.slug, from);
|
|
516
|
+
const newJt = junctionTableName(seed.slug, to);
|
|
517
|
+
const stmts = [`ALTER TABLE ${oldJt} RENAME TO ${newJt};`];
|
|
518
|
+
if (seed.allowDrafts) {
|
|
519
|
+
stmts.push(`ALTER TABLE ${oldJt}_drafts RENAME TO ${newJt}_drafts;`);
|
|
520
|
+
}
|
|
521
|
+
return stmts;
|
|
522
|
+
}
|
|
523
|
+
const stmts = [`ALTER TABLE ${tableName(seed)} RENAME COLUMN ${from} TO ${to};`];
|
|
524
|
+
if (branch && seed.allowDrafts) {
|
|
525
|
+
stmts.push(`ALTER TABLE ${draftTableName(seed)} RENAME COLUMN ${from} TO ${to};`);
|
|
526
|
+
}
|
|
527
|
+
return stmts;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Returns the statements that change a column's SQL type in place using CAST, bypassing SQLite rebuild limitations.
|
|
531
|
+
*
|
|
532
|
+
* @param seed The seed definition.
|
|
533
|
+
* @param branch The target branch definition (carrying the new type).
|
|
534
|
+
* @throws {Error} If called on a multi-relation branch.
|
|
535
|
+
* @returns An array of SQL statements.
|
|
536
|
+
*/
|
|
537
|
+
export function generateRetypeColumn(seed, branch) {
|
|
538
|
+
if (branch.type === 'relation' && branch.multiple === true) {
|
|
539
|
+
throw new Error(`Branch "${branch.alias}" is a multi-relation and has no column to retype`);
|
|
540
|
+
}
|
|
541
|
+
const { sqlType } = BRANCH_TYPE_SQL[branch.type];
|
|
542
|
+
const alias = branch.alias;
|
|
543
|
+
const tmp = `__retype_${alias}`;
|
|
544
|
+
const rebuild = (table) => [
|
|
545
|
+
`ALTER TABLE ${table} ADD COLUMN ${tmp} ${sqlType};`,
|
|
546
|
+
`UPDATE ${table} SET ${tmp} = CAST(${alias} AS ${sqlType});`,
|
|
547
|
+
`ALTER TABLE ${table} DROP COLUMN ${alias};`,
|
|
548
|
+
`ALTER TABLE ${table} RENAME COLUMN ${tmp} TO ${alias};`,
|
|
549
|
+
];
|
|
550
|
+
const stmts = rebuild(tableName(seed));
|
|
551
|
+
if (seed.allowDrafts)
|
|
552
|
+
stmts.push(...rebuild(draftTableName(seed)));
|
|
553
|
+
return stmts;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Additive statements that turn soft delete on for a table that already exists.
|
|
557
|
+
*
|
|
558
|
+
* The partial unique index is emitted for correctness on tables that were CREATED with
|
|
559
|
+
* `softDelete: true`. On a pre-existing table the inline `slug … UNIQUE` survives as a
|
|
560
|
+
* sqlite_autoindex that SQLite cannot drop via ALTER; there the partial index is redundant
|
|
561
|
+
* and a trashed slug stays reserved until the table is rebuilt (see ROADMAP deferral).
|
|
562
|
+
* Returns [] when the seed does not opt in.
|
|
563
|
+
*/
|
|
564
|
+
export function generateEnableSoftDelete(seed) {
|
|
565
|
+
if (!seed.softDelete)
|
|
566
|
+
return [];
|
|
567
|
+
const table = tableName(seed);
|
|
568
|
+
const slug = seed.slug;
|
|
569
|
+
return [
|
|
570
|
+
`ALTER TABLE ${table} ADD COLUMN deleted_at INTEGER;`,
|
|
571
|
+
`CREATE INDEX IF NOT EXISTS idx_${slug}_deleted_at ON ${table}(deleted_at);`,
|
|
572
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_${slug}_slug_active ON ${table}(slug) WHERE deleted_at IS NULL;`,
|
|
573
|
+
];
|
|
574
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"define-seed.d.ts","sourceRoot":"","sources":["../../src/engine/define-seed.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAEtC,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,CAE3C"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module engine/introspection
|
|
3
|
+
* Reads live schema state out of D1 through an INJECTED executor.
|
|
4
|
+
*
|
|
5
|
+
* Executor-agnostic on purpose: the CLI reaches D1 through a synchronous shell/SQLite path
|
|
6
|
+
* (`queryD1`) and the Worker through its `D1Database` binding, and neither tier may import the
|
|
7
|
+
* other (`graphify path "createBeechApp" "queryD1"` must keep returning no path). `@beechcms/core`
|
|
8
|
+
* owns the STATEMENTS and the SHAPES; the caller owns the connection.
|
|
9
|
+
*
|
|
10
|
+
* Read-only by contract: every statement here is a `PRAGMA` or a `SELECT`. Schema mutation stays
|
|
11
|
+
* the exclusive business of the Botanical Engine's DDL path (`engine/seed-ddl.ts`).
|
|
12
|
+
*/
|
|
13
|
+
import type { Seed } from './types.js';
|
|
14
|
+
/**
|
|
15
|
+
* The one capability the introspection primitive needs from a database connection.
|
|
16
|
+
*
|
|
17
|
+
* Implementations MUST: execute the statement as-is, return one object per row with column names
|
|
18
|
+
* as keys, and reject/throw on failure. They MUST NOT cache, batch, rewrite or retry — a stale
|
|
19
|
+
* answer here becomes a wrong fingerprint, which becomes a false "your types are stale" error in
|
|
20
|
+
* someone else's production client.
|
|
21
|
+
*/
|
|
22
|
+
export interface SchemaQueryExecutor {
|
|
23
|
+
all<T extends Record<string, unknown>>(sql: string): Promise<T[]>;
|
|
24
|
+
}
|
|
25
|
+
/** Thrown when live state cannot be read or cannot be trusted. */
|
|
26
|
+
export declare class IntrospectionError extends Error {
|
|
27
|
+
readonly cause?: unknown | undefined;
|
|
28
|
+
constructor(message: string, cause?: unknown | undefined);
|
|
29
|
+
}
|
|
30
|
+
/** One physical column, as SQLite reports it. */
|
|
31
|
+
export interface LiveColumn {
|
|
32
|
+
name: string;
|
|
33
|
+
/** Declared storage type, upper-cased (`TEXT` | `INTEGER` | `REAL` | …). */
|
|
34
|
+
sqlType: string;
|
|
35
|
+
notNull: boolean;
|
|
36
|
+
isPk: boolean;
|
|
37
|
+
/** Declared DEFAULT expression verbatim, or null. */
|
|
38
|
+
defaultValue: string | null;
|
|
39
|
+
}
|
|
40
|
+
/** One foreign-key constraint on a physical table. */
|
|
41
|
+
export interface LiveForeignKey {
|
|
42
|
+
/** Local column carrying the reference. */
|
|
43
|
+
column: string;
|
|
44
|
+
/** Referenced table, e.g. `content_team`. */
|
|
45
|
+
targetTable: string;
|
|
46
|
+
/** Referenced column, e.g. `id`. */
|
|
47
|
+
targetColumn: string;
|
|
48
|
+
/** Upper-cased, e.g. `SET NULL`. */
|
|
49
|
+
onDelete: string;
|
|
50
|
+
/** Upper-cased, e.g. `NO ACTION`. */
|
|
51
|
+
onUpdate: string;
|
|
52
|
+
}
|
|
53
|
+
/** One index on a physical table. SQLite's implicit `sqlite_autoindex_*` entries are excluded. */
|
|
54
|
+
export interface LiveIndex {
|
|
55
|
+
name: string;
|
|
56
|
+
unique: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** The physical state of one table. `exists: false` means SQLite reports no such table. */
|
|
59
|
+
export interface LiveTable {
|
|
60
|
+
name: string;
|
|
61
|
+
exists: boolean;
|
|
62
|
+
/** Physical order, as returned by `PRAGMA table_info` (cid order). */
|
|
63
|
+
columns: LiveColumn[];
|
|
64
|
+
/** Sorted by `column`, then `targetTable`, for deterministic output. */
|
|
65
|
+
foreignKeys: LiveForeignKey[];
|
|
66
|
+
/** Sorted by `name`. */
|
|
67
|
+
indexes: LiveIndex[];
|
|
68
|
+
}
|
|
69
|
+
/** The physical state of a set of tables. `tables` is sorted by name. */
|
|
70
|
+
export interface LiveSchema {
|
|
71
|
+
tables: LiveTable[];
|
|
72
|
+
}
|
|
73
|
+
/** Returns `name` unchanged when it is a safe SQL identifier; throws otherwise. */
|
|
74
|
+
export declare function assertSafeIdentifier(name: string): string;
|
|
75
|
+
/**
|
|
76
|
+
* Physical state of one table. A table SQLite does not know returns `{ exists: false }` with empty
|
|
77
|
+
* lists rather than throwing: "missing" is a legitimate diff outcome, not an error.
|
|
78
|
+
*
|
|
79
|
+
* `PRAGMA table_info` answers an unknown table with zero rows on the SQLite path and raises on some
|
|
80
|
+
* wrangler paths, so both outcomes collapse to the same result.
|
|
81
|
+
*/
|
|
82
|
+
export declare function introspectTable(executor: SchemaQueryExecutor, table: string): Promise<LiveTable>;
|
|
83
|
+
/**
|
|
84
|
+
* Names of the physical tables SQLite holds, optionally restricted to a `LIKE` prefix
|
|
85
|
+
* (e.g. `content_`). Sorted, `sqlite_*` internals excluded.
|
|
86
|
+
*/
|
|
87
|
+
export declare function listTables(executor: SchemaQueryExecutor, prefix?: string): Promise<string[]>;
|
|
88
|
+
/**
|
|
89
|
+
* Physical state of several tables at once. With no explicit list, every `content_*` table is
|
|
90
|
+
* introspected — the set a schema export or a drift check cares about.
|
|
91
|
+
*/
|
|
92
|
+
export declare function introspectSchema(executor: SchemaQueryExecutor, tables?: string[]): Promise<LiveSchema>;
|
|
93
|
+
/**
|
|
94
|
+
* The DECLARED schema: every active seed definition, read live from D1.
|
|
95
|
+
*
|
|
96
|
+
* This — not `beech.schema.ts` — is the source of truth for generated types and for the schema
|
|
97
|
+
* fingerprint (feature brief, business rule 1). The manifest file is a desired-state artifact and
|
|
98
|
+
* may legitimately be ahead of, or behind, what is deployed.
|
|
99
|
+
*/
|
|
100
|
+
export declare function introspectSeedDefinitions(executor: SchemaQueryExecutor): Promise<Seed[]>;
|
|
101
|
+
//# sourceMappingURL=introspection.d.ts.map
|