@beechcms/core 0.9.3 → 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/dist/webhooks/webhook-validation.d.ts +5 -1
- package/dist/webhooks/webhook-validation.d.ts.map +1 -1
- package/dist/webhooks/webhook-validation.js +84 -2
- package/package.json +2 -2
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
import { junctionTableName, generateFtsTable, generateFtsTriggers } from './engine.js';
|
|
4
|
+
const BRANCH_SQL_TYPE = {
|
|
5
|
+
text: 'TEXT',
|
|
6
|
+
number: 'REAL',
|
|
7
|
+
boolean: 'INTEGER',
|
|
8
|
+
date: 'INTEGER',
|
|
9
|
+
json: 'TEXT',
|
|
10
|
+
richtext: 'TEXT',
|
|
11
|
+
file: 'TEXT',
|
|
12
|
+
tags: 'TEXT',
|
|
13
|
+
relation: 'TEXT',
|
|
14
|
+
repeater: 'TEXT',
|
|
15
|
+
};
|
|
16
|
+
/** Branch types that receive a B-tree index in generateIndexes (used by retype to drop/recreate). */
|
|
17
|
+
const INDEXABLE_TYPES = new Set(['text', 'number', 'date', 'boolean', 'relation']);
|
|
18
|
+
function searchBranches(seed) {
|
|
19
|
+
return seed.branches.filter(b => (b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false);
|
|
20
|
+
}
|
|
21
|
+
function ftsTriggerDrops(slug) {
|
|
22
|
+
return [
|
|
23
|
+
`DROP TRIGGER IF EXISTS fts_${slug}_insert;`,
|
|
24
|
+
`DROP TRIGGER IF EXISTS fts_${slug}_update;`,
|
|
25
|
+
`DROP TRIGGER IF EXISTS fts_${slug}_delete;`,
|
|
26
|
+
`DROP TABLE IF EXISTS fts_${slug};`,
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Ordered DROP TABLE statements for a full hard-delete of a seed's tables.
|
|
31
|
+
* Order: junction drafts → junctions → FTS triggers → FTS table → drafts → main table.
|
|
32
|
+
* All statements use IF EXISTS (idempotent).
|
|
33
|
+
*/
|
|
34
|
+
export function generateDropTable(seed) {
|
|
35
|
+
const stmts = [];
|
|
36
|
+
const { slug } = seed;
|
|
37
|
+
for (const branch of seed.branches) {
|
|
38
|
+
if (branch.type !== 'relation' || branch.multiple !== true)
|
|
39
|
+
continue;
|
|
40
|
+
const jt = junctionTableName(slug, branch.alias);
|
|
41
|
+
stmts.push(`DROP TABLE IF EXISTS ${jt}_drafts;`);
|
|
42
|
+
stmts.push(`DROP TABLE IF EXISTS ${jt};`);
|
|
43
|
+
}
|
|
44
|
+
stmts.push(...ftsTriggerDrops(slug));
|
|
45
|
+
stmts.push(`DROP TABLE IF EXISTS content_${slug}_drafts;`);
|
|
46
|
+
stmts.push(`DROP TABLE IF EXISTS content_${slug};`);
|
|
47
|
+
return stmts;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* DROP COLUMN statements for a single branch (by alias).
|
|
51
|
+
* For multi-relation branches: drops the junction tables (no column on parent).
|
|
52
|
+
* For regular branches: drops from main table + drafts table (if allowDrafts).
|
|
53
|
+
* Does NOT include FTS handling — callers must prepend ftsTriggerDrops() and
|
|
54
|
+
* append planFtsRebuild() when the branch is searchable.
|
|
55
|
+
*/
|
|
56
|
+
export function generateDropColumn(seed, alias) {
|
|
57
|
+
const stmts = [];
|
|
58
|
+
const { slug } = seed;
|
|
59
|
+
const branch = seed.branches.find(b => b.alias === alias);
|
|
60
|
+
if (!branch)
|
|
61
|
+
throw new Error(`Branch '${alias}' not found in seed '${slug}'`);
|
|
62
|
+
if (branch.type === 'relation' && branch.multiple === true) {
|
|
63
|
+
const jt = junctionTableName(slug, alias);
|
|
64
|
+
if (seed.allowDrafts)
|
|
65
|
+
stmts.push(`DROP TABLE IF EXISTS ${jt}_drafts;`);
|
|
66
|
+
stmts.push(`DROP TABLE IF EXISTS ${jt};`);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
stmts.push(`ALTER TABLE content_${slug} DROP COLUMN ${alias};`);
|
|
70
|
+
if (seed.allowDrafts)
|
|
71
|
+
stmts.push(`ALTER TABLE content_${slug}_drafts DROP COLUMN ${alias};`);
|
|
72
|
+
}
|
|
73
|
+
return stmts;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* RENAME COLUMN statements for a branch alias change.
|
|
77
|
+
* Updates main table + drafts (if allowDrafts).
|
|
78
|
+
* Does NOT handle FTS — callers must rebuild FTS separately (SQLite auto-updates
|
|
79
|
+
* trigger bodies on RENAME but the FTS virtual-table columns keep the old name).
|
|
80
|
+
*/
|
|
81
|
+
export function generateRenameColumn(seed, fromAlias, toAlias) {
|
|
82
|
+
const stmts = [];
|
|
83
|
+
const { slug } = seed;
|
|
84
|
+
stmts.push(`ALTER TABLE content_${slug} RENAME COLUMN ${fromAlias} TO ${toAlias};`);
|
|
85
|
+
if (seed.allowDrafts) {
|
|
86
|
+
stmts.push(`ALTER TABLE content_${slug}_drafts RENAME COLUMN ${fromAlias} TO ${toAlias};`);
|
|
87
|
+
}
|
|
88
|
+
return stmts;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Full FTS rebuild: drop triggers + FTS table, then recreate them, then backfill.
|
|
92
|
+
* Returns an empty array if the seed has no searchable branches.
|
|
93
|
+
* The drop steps use IF EXISTS so this is safe to call even if FTS was already dropped.
|
|
94
|
+
*/
|
|
95
|
+
export function planFtsRebuild(seed) {
|
|
96
|
+
const rtBranches = searchBranches(seed);
|
|
97
|
+
if (rtBranches.length === 0)
|
|
98
|
+
return [];
|
|
99
|
+
const { slug } = seed;
|
|
100
|
+
const stmts = [...ftsTriggerDrops(slug)];
|
|
101
|
+
const ftsTable = generateFtsTable(seed);
|
|
102
|
+
if (ftsTable)
|
|
103
|
+
stmts.push(ftsTable);
|
|
104
|
+
stmts.push(...generateFtsTriggers(seed));
|
|
105
|
+
const cols = rtBranches.map(b => b.alias);
|
|
106
|
+
stmts.push(`INSERT INTO fts_${slug}(entry_id, ${cols.join(', ')}) SELECT id, ${cols.join(', ')} FROM content_${slug};`);
|
|
107
|
+
return stmts;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Generates statements to change a branch's SQL storage type using the
|
|
111
|
+
* add-new-column / copy / drop-old / rename approach.
|
|
112
|
+
* Includes FTS rebuild statements when the branch is (or becomes) searchable.
|
|
113
|
+
* `updatedSeed` must already reflect the new type on the renamed branch.
|
|
114
|
+
*/
|
|
115
|
+
export function planRetypeColumn(seed, alias, newType, updatedSeed) {
|
|
116
|
+
const branch = seed.branches.find(b => b.alias === alias);
|
|
117
|
+
if (!branch)
|
|
118
|
+
throw new Error(`Branch '${alias}' not found in seed '${seed.slug}'`);
|
|
119
|
+
const { slug } = seed;
|
|
120
|
+
const newSqlType = BRANCH_SQL_TYPE[newType];
|
|
121
|
+
const tmpAlias = `${alias}_migrate`;
|
|
122
|
+
const stmts = [];
|
|
123
|
+
// Always drop FTS first to avoid "column referenced in trigger" errors on DROP COLUMN.
|
|
124
|
+
stmts.push(...ftsTriggerDrops(slug));
|
|
125
|
+
// Drop the branch index if it exists on the old column (required before DROP COLUMN).
|
|
126
|
+
if (INDEXABLE_TYPES.has(branch.type) && branch.policies?.filter !== false) {
|
|
127
|
+
stmts.push(`DROP INDEX IF EXISTS idx_${slug}_${alias};`);
|
|
128
|
+
}
|
|
129
|
+
// Main table: add migration column, copy data, drop old, rename.
|
|
130
|
+
stmts.push(`ALTER TABLE content_${slug} ADD COLUMN ${tmpAlias} ${newSqlType};`);
|
|
131
|
+
stmts.push(`UPDATE content_${slug} SET ${tmpAlias} = CAST(${alias} AS ${newSqlType});`);
|
|
132
|
+
stmts.push(`ALTER TABLE content_${slug} DROP COLUMN ${alias};`);
|
|
133
|
+
stmts.push(`ALTER TABLE content_${slug} RENAME COLUMN ${tmpAlias} TO ${alias};`);
|
|
134
|
+
// Recreate index for the renamed column if the new type warrants it.
|
|
135
|
+
if (INDEXABLE_TYPES.has(newType) && branch.policies?.filter !== false) {
|
|
136
|
+
stmts.push(`CREATE INDEX IF NOT EXISTS idx_${slug}_${alias} ON content_${slug}(${alias});`);
|
|
137
|
+
}
|
|
138
|
+
// Drafts table (no indexes to worry about).
|
|
139
|
+
if (seed.allowDrafts) {
|
|
140
|
+
stmts.push(`ALTER TABLE content_${slug}_drafts ADD COLUMN ${tmpAlias} ${newSqlType};`);
|
|
141
|
+
stmts.push(`UPDATE content_${slug}_drafts SET ${tmpAlias} = CAST(${alias} AS ${newSqlType});`);
|
|
142
|
+
stmts.push(`ALTER TABLE content_${slug}_drafts DROP COLUMN ${alias};`);
|
|
143
|
+
stmts.push(`ALTER TABLE content_${slug}_drafts RENAME COLUMN ${tmpAlias} TO ${alias};`);
|
|
144
|
+
}
|
|
145
|
+
// Rebuild FTS if the seed has searchable branches in the updated definition.
|
|
146
|
+
const rebuild = planFtsRebuild(updatedSeed);
|
|
147
|
+
stmts.push(...rebuild);
|
|
148
|
+
return { statements: stmts, ftsRebuildNeeded: rebuild.length > 0 };
|
|
149
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Seed } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Full create-from-scratch statement set for a seed. Mirrors the CLI's buildStatements.
|
|
4
|
+
* Order: parent table → indexes → draft table → FTS table → FTS triggers →
|
|
5
|
+
* per multi-relation: junction table → junction indexes → junction draft table.
|
|
6
|
+
* Callers that create several seeds must order seeds with sortSeedsByDependencies
|
|
7
|
+
* first so relation FK targets exist.
|
|
8
|
+
*/
|
|
9
|
+
export declare function planCreateSeed(seed: Seed): string[];
|
|
10
|
+
export interface ExtendPlan {
|
|
11
|
+
statements: string[];
|
|
12
|
+
ftsRebuildNeeded: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Additive extension: given the columns that already exist on content_{slug}
|
|
16
|
+
* (from PRAGMA table_info, passed in by the caller), return ONLY the statements
|
|
17
|
+
* needed to add new branches — ADD COLUMN + indexes, plus junction tables for
|
|
18
|
+
* new multi-relation branches. Never drops or renames.
|
|
19
|
+
*
|
|
20
|
+
* FTS: SQLite cannot ALTER an fts5 table's columns. If a new text/richtext
|
|
21
|
+
* searchable branch was added, ftsRebuildNeeded=true signals the caller
|
|
22
|
+
* (sprint 03) to handle it — no DROP is emitted.
|
|
23
|
+
*
|
|
24
|
+
* Draft tables: pass `existingDraftColumns` (PRAGMA table_info on content_{slug}_drafts) to have the
|
|
25
|
+
* `live_snapshot_at` system column added retroactively; omit it to skip that check entirely.
|
|
26
|
+
*/
|
|
27
|
+
export declare function planExtendSeed(seed: Seed, existingColumns: Set<string>, existingDraftColumns?: Set<string> | null): ExtendPlan;
|
|
28
|
+
/**
|
|
29
|
+
* Destructive FTS rebuild (sprint 06). SQLite cannot ALTER an fts5 table's
|
|
30
|
+
* columns, so when a searchable branch is added, renamed, retyped, or dropped
|
|
31
|
+
* the only correct fix is to drop and recreate the `fts_{slug}` virtual table
|
|
32
|
+
* (and its insert/update/delete triggers), then backfill from `content_{slug}`.
|
|
33
|
+
*
|
|
34
|
+
* Returns an empty array when the seed has no searchable branches — in that
|
|
35
|
+
* case the caller should instead drop any leftover FTS table via
|
|
36
|
+
* generateDropTable's FTS statements. The returned statements are destructive
|
|
37
|
+
* and must run through ISchemaMutator.execDestructive, never execDdl.
|
|
38
|
+
*
|
|
39
|
+
* Order: drop triggers → drop fts table → recreate table → recreate triggers →
|
|
40
|
+
* backfill existing rows.
|
|
41
|
+
*/
|
|
42
|
+
export declare function planFtsRebuild(seed: Seed): string[];
|
|
43
|
+
//# sourceMappingURL=seed-ddl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seed-ddl.d.ts","sourceRoot":"","sources":["../../src/engine/seed-ddl.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAgBtC;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,CAanD;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,gBAAgB,EAAE,OAAO,CAAA;CAC1B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,IAAI,EACV,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,oBAAoB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACxC,UAAU,CAmCZ;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,CA0BnD"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
import { generateCreateTable, generateDraftTable, generateIndexes, generateFtsTable, generateFtsTriggers, generateAddColumn, generateAddDraftSnapshotColumn, generateJunctionTable, generateJunctionIndexes, generateJunctionDraftTable, generateEnableSoftDelete, indexableSearchBranches, } from './engine.js';
|
|
4
|
+
/**
|
|
5
|
+
* Full create-from-scratch statement set for a seed. Mirrors the CLI's buildStatements.
|
|
6
|
+
* Order: parent table → indexes → draft table → FTS table → FTS triggers →
|
|
7
|
+
* per multi-relation: junction table → junction indexes → junction draft table.
|
|
8
|
+
* Callers that create several seeds must order seeds with sortSeedsByDependencies
|
|
9
|
+
* first so relation FK targets exist.
|
|
10
|
+
*/
|
|
11
|
+
export function planCreateSeed(seed) {
|
|
12
|
+
const stmts = [generateCreateTable(seed), ...generateIndexes(seed)];
|
|
13
|
+
const draft = generateDraftTable(seed);
|
|
14
|
+
if (draft)
|
|
15
|
+
stmts.push(draft);
|
|
16
|
+
const fts = generateFtsTable(seed);
|
|
17
|
+
if (fts)
|
|
18
|
+
stmts.push(fts, ...generateFtsTriggers(seed));
|
|
19
|
+
for (const branch of seed.branches) {
|
|
20
|
+
if (branch.type !== 'relation' || branch.multiple !== true)
|
|
21
|
+
continue;
|
|
22
|
+
stmts.push(generateJunctionTable(seed, branch), ...generateJunctionIndexes(seed, branch));
|
|
23
|
+
const dj = generateJunctionDraftTable(seed, branch);
|
|
24
|
+
if (dj)
|
|
25
|
+
stmts.push(dj);
|
|
26
|
+
}
|
|
27
|
+
return stmts;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Additive extension: given the columns that already exist on content_{slug}
|
|
31
|
+
* (from PRAGMA table_info, passed in by the caller), return ONLY the statements
|
|
32
|
+
* needed to add new branches — ADD COLUMN + indexes, plus junction tables for
|
|
33
|
+
* new multi-relation branches. Never drops or renames.
|
|
34
|
+
*
|
|
35
|
+
* FTS: SQLite cannot ALTER an fts5 table's columns. If a new text/richtext
|
|
36
|
+
* searchable branch was added, ftsRebuildNeeded=true signals the caller
|
|
37
|
+
* (sprint 03) to handle it — no DROP is emitted.
|
|
38
|
+
*
|
|
39
|
+
* Draft tables: pass `existingDraftColumns` (PRAGMA table_info on content_{slug}_drafts) to have the
|
|
40
|
+
* `live_snapshot_at` system column added retroactively; omit it to skip that check entirely.
|
|
41
|
+
*/
|
|
42
|
+
export function planExtendSeed(seed, existingColumns, existingDraftColumns) {
|
|
43
|
+
const statements = [];
|
|
44
|
+
let ftsRebuildNeeded = false;
|
|
45
|
+
for (const branch of seed.branches) {
|
|
46
|
+
if (branch.type === 'relation' && branch.multiple === true) {
|
|
47
|
+
// junction table is CREATE IF NOT EXISTS — safe to re-emit
|
|
48
|
+
statements.push(generateJunctionTable(seed, branch), ...generateJunctionIndexes(seed, branch));
|
|
49
|
+
const dj = generateJunctionDraftTable(seed, branch);
|
|
50
|
+
if (dj)
|
|
51
|
+
statements.push(dj);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (existingColumns.has(branch.alias))
|
|
55
|
+
continue;
|
|
56
|
+
statements.push(generateAddColumn(seed, branch));
|
|
57
|
+
if ((branch.type === 'text' || branch.type === 'richtext') && branch.policies?.search !== false) {
|
|
58
|
+
ftsRebuildNeeded = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// System column, not a branch: the branch loop above can never emit it.
|
|
62
|
+
if (seed.softDelete && !existingColumns.has('deleted_at')) {
|
|
63
|
+
statements.push(...generateEnableSoftDelete(seed));
|
|
64
|
+
}
|
|
65
|
+
// Draft-table system column. Emitted only when the caller actually introspected
|
|
66
|
+
// content_{slug}_drafts: `undefined` means "not introspected" and `null` means "table absent",
|
|
67
|
+
// and in both cases emitting a non-idempotent ADD COLUMN would abort the caller's whole DDL batch.
|
|
68
|
+
if (seed.allowDrafts &&
|
|
69
|
+
existingDraftColumns != null &&
|
|
70
|
+
!existingDraftColumns.has('live_snapshot_at')) {
|
|
71
|
+
statements.push(generateAddDraftSnapshotColumn(seed));
|
|
72
|
+
}
|
|
73
|
+
// CREATE INDEX IF NOT EXISTS — idempotent, safe to re-run
|
|
74
|
+
statements.push(...generateIndexes(seed));
|
|
75
|
+
return { statements, ftsRebuildNeeded };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Destructive FTS rebuild (sprint 06). SQLite cannot ALTER an fts5 table's
|
|
79
|
+
* columns, so when a searchable branch is added, renamed, retyped, or dropped
|
|
80
|
+
* the only correct fix is to drop and recreate the `fts_{slug}` virtual table
|
|
81
|
+
* (and its insert/update/delete triggers), then backfill from `content_{slug}`.
|
|
82
|
+
*
|
|
83
|
+
* Returns an empty array when the seed has no searchable branches — in that
|
|
84
|
+
* case the caller should instead drop any leftover FTS table via
|
|
85
|
+
* generateDropTable's FTS statements. The returned statements are destructive
|
|
86
|
+
* and must run through ISchemaMutator.execDestructive, never execDdl.
|
|
87
|
+
*
|
|
88
|
+
* Order: drop triggers → drop fts table → recreate table → recreate triggers →
|
|
89
|
+
* backfill existing rows.
|
|
90
|
+
*/
|
|
91
|
+
export function planFtsRebuild(seed) {
|
|
92
|
+
const branches = indexableSearchBranches(seed);
|
|
93
|
+
if (branches.length === 0)
|
|
94
|
+
return [];
|
|
95
|
+
const slug = seed.slug;
|
|
96
|
+
const ftsTable = `fts_${slug}`;
|
|
97
|
+
const contentTable = `content_${slug}`;
|
|
98
|
+
const cols = branches.map(b => b.alias);
|
|
99
|
+
const ftsTableDdl = generateFtsTable(seed);
|
|
100
|
+
const triggers = generateFtsTriggers(seed);
|
|
101
|
+
// generateFtsTable/Triggers return non-null here (branches.length > 0 checked).
|
|
102
|
+
if (!ftsTableDdl)
|
|
103
|
+
return [];
|
|
104
|
+
const ftsColList = ['entry_id', ...cols].join(', ');
|
|
105
|
+
const selectList = ['id', ...cols].join(', ');
|
|
106
|
+
return [
|
|
107
|
+
`DROP TRIGGER IF EXISTS fts_${slug}_insert;`,
|
|
108
|
+
`DROP TRIGGER IF EXISTS fts_${slug}_update;`,
|
|
109
|
+
`DROP TRIGGER IF EXISTS fts_${slug}_delete;`,
|
|
110
|
+
`DROP TABLE IF EXISTS ${ftsTable};`,
|
|
111
|
+
ftsTableDdl,
|
|
112
|
+
...triggers,
|
|
113
|
+
`INSERT INTO ${ftsTable}(${ftsColList}) SELECT ${selectList} FROM ${contentTable};`,
|
|
114
|
+
];
|
|
115
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Seed, Branch } from './types.js';
|
|
2
|
+
export interface ISeedRegistry {
|
|
3
|
+
/**
|
|
4
|
+
* Returns all seeds as a flat array, preserving insertion order.
|
|
5
|
+
* Decouples callers from the internal storage shape so the registry
|
|
6
|
+
* implementation can change without touching every route handler.
|
|
7
|
+
*/
|
|
8
|
+
all(): Seed[];
|
|
9
|
+
/**
|
|
10
|
+
* Returns the seed with the given slug, or null if not found.
|
|
11
|
+
* Provides a single lookup point that can be overridden in tests
|
|
12
|
+
* without rebuilding the full registry object.
|
|
13
|
+
*/
|
|
14
|
+
get(slug: string): Seed | null;
|
|
15
|
+
/**
|
|
16
|
+
* Returns seeds that are visible in the dashboard sidebar.
|
|
17
|
+
* A seed is visible when dashboard.hidden is not explicitly true.
|
|
18
|
+
* Eliminates the seeds.filter(s => !s.dashboard?.hidden) pattern
|
|
19
|
+
* that would otherwise be duplicated across route handlers.
|
|
20
|
+
*/
|
|
21
|
+
visibleInDashboard(): Seed[];
|
|
22
|
+
/**
|
|
23
|
+
* Returns seeds that have allowPublicRead enabled.
|
|
24
|
+
* Eliminates the seeds.filter(s => s.allowPublicRead) pattern.
|
|
25
|
+
*/
|
|
26
|
+
publicReadable(): Seed[];
|
|
27
|
+
/**
|
|
28
|
+
* Returns seeds that have the draft workflow enabled.
|
|
29
|
+
* Eliminates the seeds.filter(s => s.allowDrafts) pattern.
|
|
30
|
+
*/
|
|
31
|
+
draftEnabled(): Seed[];
|
|
32
|
+
}
|
|
33
|
+
export declare class SeedRegistry implements ISeedRegistry {
|
|
34
|
+
private readonly seedMap;
|
|
35
|
+
private readonly orderedSeeds;
|
|
36
|
+
constructor(seeds: Seed[]);
|
|
37
|
+
all(): Seed[];
|
|
38
|
+
get(slug: string): Seed | null;
|
|
39
|
+
visibleInDashboard(): Seed[];
|
|
40
|
+
publicReadable(): Seed[];
|
|
41
|
+
draftEnabled(): Seed[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Named subclass of SeedRegistry for use in test suites.
|
|
45
|
+
* Gives tests a semantic name without requiring any dependency on factory.ts.
|
|
46
|
+
*/
|
|
47
|
+
export declare class InMemorySeedRegistry extends SeedRegistry {
|
|
48
|
+
}
|
|
49
|
+
/** Finds a branch by its stable id within a Seed. Returns null if not found. */
|
|
50
|
+
export declare function findBranchById(seed: Seed, id: string): Branch | null;
|
|
51
|
+
/**
|
|
52
|
+
* Returns the next free branch id for a seed in the form `br_NN` (zero-padded to 2,
|
|
53
|
+
* growing as needed). Scans existing ids, never reuses a number already present —
|
|
54
|
+
* even if a branch was removed, its id is not recycled (ids must be globally stable
|
|
55
|
+
* for the life of the seed so layouts/automations/FTS triggers never collide).
|
|
56
|
+
*/
|
|
57
|
+
export declare function nextBranchId(seed: Pick<Seed, 'branches'>): string;
|
|
58
|
+
/**
|
|
59
|
+
* Returns the input seeds reordered so that every seed appears AFTER all the
|
|
60
|
+
* seeds it depends on (its `relation` branch targets). Pure function — does
|
|
61
|
+
* not read from any global registry.
|
|
62
|
+
*
|
|
63
|
+
* Uses Kahn's algorithm (BFS on in-degree) for cycle detection and topological
|
|
64
|
+
* ordering. Sprint 3 (CLI / migration runner) consumes this to create tables in
|
|
65
|
+
* the correct order without disabling SQLite FK constraints.
|
|
66
|
+
*
|
|
67
|
+
* @throws {Error} When a relation targets an unknown slug not in `seeds`.
|
|
68
|
+
* @throws {Error} When a cyclic dependency is detected, listing involved slugs.
|
|
69
|
+
*/
|
|
70
|
+
export declare function sortSeedsByDependencies(seeds: ReadonlyArray<Seed>): Seed[];
|
|
71
|
+
//# sourceMappingURL=seed-registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seed-registry.d.ts","sourceRoot":"","sources":["../../src/engine/seed-registry.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAI9C,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,GAAG,IAAI,IAAI,EAAE,CAAA;IAEb;;;;OAIG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAA;IAE9B;;;;;OAKG;IACH,kBAAkB,IAAI,IAAI,EAAE,CAAA;IAE5B;;;OAGG;IACH,cAAc,IAAI,IAAI,EAAE,CAAA;IAExB;;;OAGG;IACH,YAAY,IAAI,IAAI,EAAE,CAAA;CACvB;AAID,qBAAa,YAAa,YAAW,aAAa;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAC3C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;gBAEzB,KAAK,EAAE,IAAI,EAAE;IAkCzB,GAAG,IAAI,IAAI,EAAE;IAIb,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAI9B,kBAAkB,IAAI,IAAI,EAAE;IAI5B,cAAc,IAAI,IAAI,EAAE;IAIxB,YAAY,IAAI,IAAI,EAAE;CAGvB;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,YAAY;CAAG;AAEzD,gFAAgF;AAChF,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEpE;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,GAAG,MAAM,CAQjE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,aAAa,CAAC,IAAI,CAAC,GAAG,IAAI,EAAE,CA0F1E"}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
import { AUTOMATION_RESERVED_WORDS } from '../automations/automations-grammar-words.js';
|
|
4
|
+
import { SQL_RESERVED_WORDS } from './sql-reserved-words.js';
|
|
5
|
+
const BRANCH_ID_RE = /^br_[A-Za-z0-9]+$/;
|
|
6
|
+
export class SeedRegistry {
|
|
7
|
+
seedMap;
|
|
8
|
+
orderedSeeds;
|
|
9
|
+
constructor(seeds) {
|
|
10
|
+
for (const seed of seeds) {
|
|
11
|
+
const idsInSeed = new Set();
|
|
12
|
+
for (const branch of seed.branches) {
|
|
13
|
+
if (AUTOMATION_RESERVED_WORDS.has(branch.alias)) {
|
|
14
|
+
throw new Error(`Seed "${seed.slug}" uses reserved alias "${branch.alias}". `
|
|
15
|
+
+ `Pick a different alias — this word is used by the automation template grammar.`);
|
|
16
|
+
}
|
|
17
|
+
if (SQL_RESERVED_WORDS.has(branch.alias.toLowerCase())) {
|
|
18
|
+
throw new Error(`Seed "${seed.slug}" uses SQL reserved keyword "${branch.alias}". `
|
|
19
|
+
+ `Please pick a different alias to prevent database errors.`);
|
|
20
|
+
}
|
|
21
|
+
if (!branch.id || !BRANCH_ID_RE.test(branch.id)) {
|
|
22
|
+
throw new Error(`Seed "${seed.slug}" branch "${branch.alias}" has invalid id "${branch.id}". `
|
|
23
|
+
+ `Expected format ^br_[A-Za-z0-9]+$.`);
|
|
24
|
+
}
|
|
25
|
+
if (idsInSeed.has(branch.id)) {
|
|
26
|
+
throw new Error(`Seed "${seed.slug}" has duplicate branch id "${branch.id}".`);
|
|
27
|
+
}
|
|
28
|
+
idsInSeed.add(branch.id);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
this.orderedSeeds = seeds;
|
|
32
|
+
this.seedMap = new Map(seeds.map(seed => [seed.slug, seed]));
|
|
33
|
+
}
|
|
34
|
+
all() {
|
|
35
|
+
return this.orderedSeeds;
|
|
36
|
+
}
|
|
37
|
+
get(slug) {
|
|
38
|
+
return this.seedMap.get(slug) ?? null;
|
|
39
|
+
}
|
|
40
|
+
visibleInDashboard() {
|
|
41
|
+
return this.orderedSeeds.filter(seed => seed.dashboard?.hidden !== true);
|
|
42
|
+
}
|
|
43
|
+
publicReadable() {
|
|
44
|
+
return this.orderedSeeds.filter(seed => seed.allowPublicRead === true);
|
|
45
|
+
}
|
|
46
|
+
draftEnabled() {
|
|
47
|
+
return this.orderedSeeds.filter(seed => seed.allowDrafts === true);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Named subclass of SeedRegistry for use in test suites.
|
|
52
|
+
* Gives tests a semantic name without requiring any dependency on factory.ts.
|
|
53
|
+
*/
|
|
54
|
+
export class InMemorySeedRegistry extends SeedRegistry {
|
|
55
|
+
}
|
|
56
|
+
/** Finds a branch by its stable id within a Seed. Returns null if not found. */
|
|
57
|
+
export function findBranchById(seed, id) {
|
|
58
|
+
return seed.branches.find(b => b.id === id) ?? null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Returns the next free branch id for a seed in the form `br_NN` (zero-padded to 2,
|
|
62
|
+
* growing as needed). Scans existing ids, never reuses a number already present —
|
|
63
|
+
* even if a branch was removed, its id is not recycled (ids must be globally stable
|
|
64
|
+
* for the life of the seed so layouts/automations/FTS triggers never collide).
|
|
65
|
+
*/
|
|
66
|
+
export function nextBranchId(seed) {
|
|
67
|
+
let max = 0;
|
|
68
|
+
for (const b of seed.branches) {
|
|
69
|
+
const m = /^br_0*([0-9]+)$/.exec(b.id);
|
|
70
|
+
if (m)
|
|
71
|
+
max = Math.max(max, parseInt(m[1], 10));
|
|
72
|
+
}
|
|
73
|
+
const n = max + 1;
|
|
74
|
+
return `br_${String(n).padStart(2, '0')}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Returns the input seeds reordered so that every seed appears AFTER all the
|
|
78
|
+
* seeds it depends on (its `relation` branch targets). Pure function — does
|
|
79
|
+
* not read from any global registry.
|
|
80
|
+
*
|
|
81
|
+
* Uses Kahn's algorithm (BFS on in-degree) for cycle detection and topological
|
|
82
|
+
* ordering. Sprint 3 (CLI / migration runner) consumes this to create tables in
|
|
83
|
+
* the correct order without disabling SQLite FK constraints.
|
|
84
|
+
*
|
|
85
|
+
* @throws {Error} When a relation targets an unknown slug not in `seeds`.
|
|
86
|
+
* @throws {Error} When a cyclic dependency is detected, listing involved slugs.
|
|
87
|
+
*/
|
|
88
|
+
export function sortSeedsByDependencies(seeds) {
|
|
89
|
+
const slugSet = new Set(seeds.map(s => s.slug));
|
|
90
|
+
// Validate all relation targets exist in the input array
|
|
91
|
+
for (const seed of seeds) {
|
|
92
|
+
for (const branch of seed.branches) {
|
|
93
|
+
if (branch.type === 'relation' && branch.targetSeed) {
|
|
94
|
+
if (!slugSet.has(branch.targetSeed)) {
|
|
95
|
+
throw new Error(`Seed '${seed.slug}' relates to unknown target '${branch.targetSeed}'`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Build adjacency: inDegree[slug] = number of dependencies
|
|
101
|
+
// dependents[slug] = slugs that depend on slug
|
|
102
|
+
const inDegree = new Map();
|
|
103
|
+
const dependents = new Map();
|
|
104
|
+
for (const seed of seeds) {
|
|
105
|
+
if (!inDegree.has(seed.slug))
|
|
106
|
+
inDegree.set(seed.slug, 0);
|
|
107
|
+
if (!dependents.has(seed.slug))
|
|
108
|
+
dependents.set(seed.slug, []);
|
|
109
|
+
}
|
|
110
|
+
for (const seed of seeds) {
|
|
111
|
+
// Deduplicate targets to avoid inflating inDegree on multi-branch refs to same seed
|
|
112
|
+
const targets = new Set(seed.branches
|
|
113
|
+
.filter(b => b.type === 'relation' && b.targetSeed)
|
|
114
|
+
.map(b => b.targetSeed));
|
|
115
|
+
for (const target of targets) {
|
|
116
|
+
if (target === seed.slug)
|
|
117
|
+
continue; // self-reference needs no ordering
|
|
118
|
+
// seed depends on target → increment seed's in-degree
|
|
119
|
+
inDegree.set(seed.slug, (inDegree.get(seed.slug) ?? 0) + 1);
|
|
120
|
+
dependents.get(target).push(seed.slug);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Kahn's BFS: start with seeds that have no dependencies
|
|
124
|
+
const seedBySlug = new Map(seeds.map(s => [s.slug, s]));
|
|
125
|
+
const queue = [];
|
|
126
|
+
for (const [slug, deg] of inDegree) {
|
|
127
|
+
if (deg === 0)
|
|
128
|
+
queue.push(slug);
|
|
129
|
+
}
|
|
130
|
+
const result = [];
|
|
131
|
+
while (queue.length > 0) {
|
|
132
|
+
const slug = queue.shift();
|
|
133
|
+
result.push(seedBySlug.get(slug));
|
|
134
|
+
for (const dependent of dependents.get(slug) ?? []) {
|
|
135
|
+
const newDeg = (inDegree.get(dependent) ?? 0) - 1;
|
|
136
|
+
inDegree.set(dependent, newDeg);
|
|
137
|
+
if (newDeg === 0)
|
|
138
|
+
queue.push(dependent);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (result.length !== seeds.length) {
|
|
142
|
+
// Isolate true cycle members
|
|
143
|
+
const stuck = new Set([...inDegree.entries()]
|
|
144
|
+
.filter(([, deg]) => deg > 0)
|
|
145
|
+
.map(([slug]) => slug));
|
|
146
|
+
const cycleNodes = [...stuck].filter(slug => {
|
|
147
|
+
const visited = new Set();
|
|
148
|
+
const queue = [...(dependents.get(slug) ?? [])].filter(s => stuck.has(s));
|
|
149
|
+
while (queue.length > 0) {
|
|
150
|
+
const curr = queue.shift();
|
|
151
|
+
if (curr === slug)
|
|
152
|
+
return true;
|
|
153
|
+
if (!visited.has(curr)) {
|
|
154
|
+
visited.add(curr);
|
|
155
|
+
for (const next of dependents.get(curr) ?? []) {
|
|
156
|
+
if (stuck.has(next))
|
|
157
|
+
queue.push(next);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
});
|
|
163
|
+
const cycle = (cycleNodes.length > 0 ? cycleNodes : [...stuck]).sort();
|
|
164
|
+
throw new Error(`Cyclic dependency detected among seeds: ${cycle.join(', ')}`);
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Seed, Branch } from './types.js';
|
|
2
|
+
/** slug/alias → PascalCase identifier. 'blog-posts' → 'BlogPosts'. */
|
|
3
|
+
export declare function pascalCase(input: string): string;
|
|
4
|
+
/** Maps a single Branch to its TypeScript type expression (no optional marker). */
|
|
5
|
+
export declare function tsTypeForBranch(branch: Branch): string;
|
|
6
|
+
/** Emits one exported interface for a single Seed. */
|
|
7
|
+
export declare function interfaceForSeed(seed: Seed): string;
|
|
8
|
+
/** Options that affect the emitted module beyond the seed interfaces themselves. */
|
|
9
|
+
export interface SeedTypesOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Schema fingerprint to embed as `SCHEMA_FINGERPRINT`, from `computeSchemaFingerprint`.
|
|
12
|
+
* Omitted ⇒ no constant is emitted and the output is byte-identical to a pre-fingerprint build,
|
|
13
|
+
* which is what keeps the legacy `beech gen-types` aliases non-breaking.
|
|
14
|
+
*/
|
|
15
|
+
fingerprint?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Pure entry point. Deterministic: sorts seeds by slug for stable diffs.
|
|
19
|
+
*
|
|
20
|
+
* The embedded fingerprint is the client's half of the drift check: `@beechcms/client` compares it
|
|
21
|
+
* against the `X-Schema-Revision` header the API returns and raises an actionable error on a
|
|
22
|
+
* mismatch, instead of trusting a stale response shape.
|
|
23
|
+
*/
|
|
24
|
+
export declare function generateSeedTypes(seeds: Seed[], options?: SeedTypesOptions): string;
|
|
25
|
+
//# sourceMappingURL=seed-types-generator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seed-types-generator.d.ts","sourceRoot":"","sources":["../../src/engine/seed-types-generator.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAe9C,sEAAsE;AACtE,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAMhD;AAMD,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAoCtD;AAWD,sDAAsD;AACtD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAYnD;AAKD,oFAAoF;AACpF,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAcvF"}
|