@beechcms/core 0.9.4 → 0.9.6

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.
Files changed (104) hide show
  1. package/dist/content/content-lock.repository.d.ts +63 -0
  2. package/dist/content/content-lock.repository.d.ts.map +1 -0
  3. package/dist/content/content-lock.repository.js +15 -0
  4. package/dist/content/content.repository.d.ts +22 -22
  5. package/dist/content/content.repository.d.ts.map +1 -1
  6. package/dist/content/retention.d.ts +13 -0
  7. package/dist/content/retention.d.ts.map +1 -0
  8. package/dist/content/retention.js +20 -0
  9. package/dist/content/slug-utils.d.ts +7 -0
  10. package/dist/content/slug-utils.d.ts.map +1 -1
  11. package/dist/content/slug-utils.js +12 -0
  12. package/dist/dashboard-layout/content-view.d.ts +4 -4
  13. package/dist/dashboard-layout/dashboard-layout.d.ts +2 -2
  14. package/dist/dashboard-layout/dashboard-layout.d.ts.map +1 -1
  15. package/dist/dashboard-layout/dashboard-layout.js +3 -3
  16. package/dist/dashboard-layout/kanban/kanban-position.repository.d.ts +19 -0
  17. package/dist/dashboard-layout/kanban/kanban-position.repository.d.ts.map +1 -0
  18. package/dist/dashboard-layout/kanban/kanban-position.repository.js +3 -0
  19. package/dist/dashboard-layout/seed-layout.d.ts.map +1 -1
  20. package/dist/dashboard-layout/seed-layout.js +17 -1
  21. package/dist/engine/ddl/ddl.d.ts +4 -6
  22. package/dist/engine/ddl/ddl.d.ts.map +1 -1
  23. package/dist/engine/ddl/ddl.js +13 -27
  24. package/dist/engine/ddl/seed-ddl-destructive.d.ts +40 -0
  25. package/dist/engine/ddl/seed-ddl-destructive.d.ts.map +1 -0
  26. package/dist/engine/ddl/seed-ddl-destructive.js +149 -0
  27. package/dist/engine/ddl/seed-ddl.d.ts.map +1 -1
  28. package/dist/engine/ddl/seed-ddl.js +10 -2
  29. package/dist/engine/ddl.d.ts +216 -0
  30. package/dist/engine/ddl.d.ts.map +1 -0
  31. package/dist/engine/ddl.js +574 -0
  32. package/dist/engine/define-seed.d.ts +3 -0
  33. package/dist/engine/define-seed.d.ts.map +1 -0
  34. package/dist/engine/define-seed.js +5 -0
  35. package/dist/engine/introspection.d.ts +101 -0
  36. package/dist/engine/introspection.d.ts.map +1 -0
  37. package/dist/engine/introspection.js +127 -0
  38. package/dist/engine/policies.d.ts +58 -0
  39. package/dist/engine/policies.d.ts.map +1 -0
  40. package/dist/engine/policies.js +168 -0
  41. package/dist/engine/privacy.service.d.ts +65 -0
  42. package/dist/engine/privacy.service.d.ts.map +1 -0
  43. package/dist/engine/privacy.service.js +114 -0
  44. package/dist/engine/query/active-clause.d.ts +2 -3
  45. package/dist/engine/query/active-clause.d.ts.map +1 -1
  46. package/dist/engine/query/active-clause.js +3 -4
  47. package/dist/engine/query/query.js +8 -8
  48. package/dist/engine/query/relations.d.ts +15 -0
  49. package/dist/engine/query/relations.d.ts.map +1 -1
  50. package/dist/engine/query.d.ts +23 -0
  51. package/dist/engine/query.d.ts.map +1 -0
  52. package/dist/engine/query.js +248 -0
  53. package/dist/engine/relations.d.ts +28 -0
  54. package/dist/engine/relations.d.ts.map +1 -0
  55. package/dist/engine/relations.js +25 -0
  56. package/dist/engine/schema-fingerprint.d.ts +71 -0
  57. package/dist/engine/schema-fingerprint.d.ts.map +1 -0
  58. package/dist/engine/schema-fingerprint.js +95 -0
  59. package/dist/engine/schema-mutator.d.ts +26 -0
  60. package/dist/engine/schema-mutator.d.ts.map +1 -0
  61. package/dist/engine/schema-mutator.js +3 -0
  62. package/dist/engine/seed-ddl-destructive.d.ts +40 -0
  63. package/dist/engine/seed-ddl-destructive.d.ts.map +1 -0
  64. package/dist/engine/seed-ddl-destructive.js +149 -0
  65. package/dist/engine/seed-ddl.d.ts +43 -0
  66. package/dist/engine/seed-ddl.d.ts.map +1 -0
  67. package/dist/engine/seed-ddl.js +115 -0
  68. package/dist/engine/seed-registry.d.ts +71 -0
  69. package/dist/engine/seed-registry.d.ts.map +1 -0
  70. package/dist/engine/seed-registry.js +167 -0
  71. package/dist/engine/seed-types-generator.d.ts +25 -0
  72. package/dist/engine/seed-types-generator.d.ts.map +1 -0
  73. package/dist/engine/seed-types-generator.js +117 -0
  74. package/dist/engine/seed-validation.d.ts +30 -0
  75. package/dist/engine/seed-validation.d.ts.map +1 -0
  76. package/dist/engine/seed-validation.js +329 -0
  77. package/dist/engine/seeds/seed-definition-diff.d.ts +1 -1
  78. package/dist/engine/seeds/seed-definition-diff.d.ts.map +1 -1
  79. package/dist/engine/seeds/seed-definition-diff.js +2 -2
  80. package/dist/engine/seeds/seed-validation.d.ts.map +1 -1
  81. package/dist/engine/seeds/seed-validation.js +3 -12
  82. package/dist/engine/seeds.d.ts +20 -0
  83. package/dist/engine/seeds.d.ts.map +1 -0
  84. package/dist/engine/seeds.js +21 -0
  85. package/dist/engine/serialize.d.ts +20 -0
  86. package/dist/engine/serialize.d.ts.map +1 -0
  87. package/dist/engine/serialize.js +160 -0
  88. package/dist/engine/types.d.ts +7 -7
  89. package/dist/engine/types.d.ts.map +1 -1
  90. package/dist/index.d.ts +1 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +1 -0
  93. package/dist/notifications/notification-service.d.ts +2 -2
  94. package/dist/notifications/notification.repository.d.ts +6 -3
  95. package/dist/notifications/notification.repository.d.ts.map +1 -1
  96. package/dist/security/time-trap.d.ts +3 -2
  97. package/dist/security/time-trap.d.ts.map +1 -1
  98. package/dist/security/time-trap.js +5 -4
  99. package/dist/seed-types-generator.d.ts +10 -0
  100. package/dist/seed-types-generator.d.ts.map +1 -0
  101. package/dist/seed-types-generator.js +86 -0
  102. package/dist/settings/site-settings.repository.d.ts +2 -0
  103. package/dist/settings/site-settings.repository.d.ts.map +1 -1
  104. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"relations.d.ts","sourceRoot":"","sources":["../../../src/engine/query/relations.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAEvC,MAAM,WAAW,aAAa;IAC5B,qDAAqD;IACrD,UAAU,EAAE,MAAM,CAAA;IAClB,qFAAqF;IACrF,WAAW,EAAE,MAAM,CAAA;IACnB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAA;IACnB,kEAAkE;IAClE,YAAY,EAAE,QAAQ,GAAG,OAAO,CAAA;IAChC,+EAA+E;IAC/E,SAAS,EAAE,OAAO,CAAA;CACnB;AAED,uDAAuD;AACvD,MAAM,MAAM,UAAU,GAAG,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,CAAA;AAErD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,UAAU,CAoBzD"}
1
+ {"version":3,"file":"relations.d.ts","sourceRoot":"","sources":["../../../src/engine/query/relations.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAEvC,MAAM,WAAW,aAAa;IAC5B,qDAAqD;IACrD,UAAU,EAAE,MAAM,CAAA;IAClB,qFAAqF;IACrF,WAAW,EAAE,MAAM,CAAA;IACnB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAA;IACnB,kEAAkE;IAClE,YAAY,EAAE,QAAQ,GAAG,OAAO,CAAA;IAChC,+EAA+E;IAC/E,SAAS,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,WAAW,EAAE,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,6EAA6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,WAAW,CAAC,UAAU,EAAE,IAAI,GAAG,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACpE,UAAU,CACR,MAAM,EAAE,aAAa,EACrB,UAAU,EAAE,IAAI,EAChB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,gBAAgB,CAAC,CAAA;CAC7B;AAED,uDAAuD;AACvD,MAAM,MAAM,UAAU,GAAG,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,CAAA;AAErD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,UAAU,CAoBzD"}
@@ -0,0 +1,23 @@
1
+ import type { Seed, SelectOptions, ParameterizedQuery } from './types.js';
2
+ /**
3
+ * Builds a parameterized SQL SELECT query and bindings for a given Seed based on search, filtering, status, and ordering options.
4
+ *
5
+ * If `options.isCount` is set to `true`, it generates a counting query (`COUNT(*) as total`) rather than returning database rows.
6
+ * In count mode, column projections (`fields`), ordering (`orderBy` / `kanbanOrder`), and pagination (`LIMIT` / `OFFSET`) clauses and
7
+ * bindings are omitted, while join and filtering clauses are preserved.
8
+ *
9
+ * @param seed The seed definition.
10
+ * @param options Query configuration options.
11
+ * @returns The SQL query string and bindings.
12
+ * @example
13
+ * ```ts
14
+ * const { sql, bindings } = buildSelectQuery(postSeed, {
15
+ * status: 'published',
16
+ * filters: [{ column: 'title', type: 'text', conditions: [{ op: 'contains', value: 'cms' }] }],
17
+ * orderBy: { column: 'created_at', dir: 'DESC' },
18
+ * pagination: { limit: 20, offset: 0 },
19
+ * })
20
+ * ```
21
+ */
22
+ export declare function buildSelectQuery(seed: Seed, options?: SelectOptions): ParameterizedQuery;
23
+ //# sourceMappingURL=query.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../src/engine/query.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,IAAI,EAMJ,aAAa,EACb,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAKpB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,aAAkB,GAAG,kBAAkB,CAgH5F"}
@@ -0,0 +1,248 @@
1
+ import { tableName, ftsTableName, isValidColumn, indexableSearchBranches, SYSTEM_COLUMNS } from './ddl.js';
2
+ import { resolveClassification } from './policies.js';
3
+ /**
4
+ * Builds a parameterized SQL SELECT query and bindings for a given Seed based on search, filtering, status, and ordering options.
5
+ *
6
+ * If `options.isCount` is set to `true`, it generates a counting query (`COUNT(*) as total`) rather than returning database rows.
7
+ * In count mode, column projections (`fields`), ordering (`orderBy` / `kanbanOrder`), and pagination (`LIMIT` / `OFFSET`) clauses and
8
+ * bindings are omitted, while join and filtering clauses are preserved.
9
+ *
10
+ * @param seed The seed definition.
11
+ * @param options Query configuration options.
12
+ * @returns The SQL query string and bindings.
13
+ * @example
14
+ * ```ts
15
+ * const { sql, bindings } = buildSelectQuery(postSeed, {
16
+ * status: 'published',
17
+ * filters: [{ column: 'title', type: 'text', conditions: [{ op: 'contains', value: 'cms' }] }],
18
+ * orderBy: { column: 'created_at', dir: 'DESC' },
19
+ * pagination: { limit: 20, offset: 0 },
20
+ * })
21
+ * ```
22
+ */
23
+ export function buildSelectQuery(seed, options = {}) {
24
+ const table = tableName(seed);
25
+ const { filters = [], orderBy, pagination, status, search, fields } = options;
26
+ const bindings = [];
27
+ const whereClauses = [];
28
+ let joinClause = '';
29
+ let kanbanOrderClause = '';
30
+ if (options.kanbanOrder) {
31
+ joinClause = `LEFT JOIN kanban_positions kp` +
32
+ ` ON kp.seed_slug = ? AND kp.entry_id = ${table}.id AND kp.axis_branch_id = ?`;
33
+ bindings.push(options.kanbanOrder.seedSlug, options.kanbanOrder.axisBranchId);
34
+ kanbanOrderClause = ` ORDER BY (kp.position IS NULL) ASC, kp.position ASC`;
35
+ }
36
+ const rtBranches = indexableSearchBranches(seed);
37
+ if (search && rtBranches.length > 0) {
38
+ const ftsTable = ftsTableName(seed);
39
+ joinClause += (joinClause ? ' ' : '') + `INNER JOIN ${ftsTable} ON ${ftsTable}.entry_id = ${table}.id`;
40
+ whereClauses.push(`${ftsTable} MATCH ?`);
41
+ bindings.push(`"${search.replace(/"/g, '""')}"*`);
42
+ }
43
+ if (status !== undefined && status !== null) {
44
+ whereClauses.push(`${table}.status = ?`);
45
+ bindings.push(status);
46
+ }
47
+ // Soft delete: 'active' is the default, so a caller that forgets the option can never
48
+ // observe a trashed row — including the Public API, relation expansion and subqueries,
49
+ // which all reach SQL only through repository.findMany.
50
+ if (seed.softDelete) {
51
+ const trashed = options.trashed ?? 'active';
52
+ if (trashed === 'active') {
53
+ whereClauses.push(`${table}.deleted_at IS NULL`);
54
+ }
55
+ else if (trashed === 'trashed') {
56
+ whereClauses.push(`${table}.deleted_at IS NOT NULL`);
57
+ }
58
+ }
59
+ const groupClauses = [];
60
+ for (const group of filters) {
61
+ if (!isValidColumn(seed, group.column))
62
+ continue;
63
+ const branch = seed.branches.find(b => b.alias === group.column);
64
+ const isEncrypted = branch ? resolveClassification(branch).storage === 'encrypt' : false;
65
+ const col = SYSTEM_COLUMNS.has(group.column)
66
+ ? `${table}.${group.column}`
67
+ : group.column;
68
+ const condClauses = [];
69
+ for (const cond of group.conditions) {
70
+ if (isEncrypted) {
71
+ const { op } = cond;
72
+ if (op === 'eq' || op === 'neq' || op === 'in' || op === 'not_in') {
73
+ const targetCol = `${table}.${group.column}_bidx`;
74
+ const clause = buildFilterCondition(targetCol, group.type, cond, bindings);
75
+ if (clause)
76
+ condClauses.push(clause);
77
+ }
78
+ else if (op === 'is_empty' || op === 'is_not_empty') {
79
+ const clause = buildFilterCondition(col, group.type, cond, bindings);
80
+ if (clause)
81
+ condClauses.push(clause);
82
+ }
83
+ }
84
+ else {
85
+ const clause = buildFilterCondition(col, group.type, cond, bindings);
86
+ if (clause)
87
+ condClauses.push(clause);
88
+ }
89
+ }
90
+ if (condClauses.length > 0) {
91
+ groupClauses.push(condClauses.length > 1 ? `(${condClauses.join(' AND ')})` : condClauses[0]);
92
+ }
93
+ }
94
+ if (groupClauses.length > 0) {
95
+ const joiner = options.filterLogic === 'OR' ? ' OR ' : ' AND ';
96
+ whereClauses.push(groupClauses.length > 1 ? `(${groupClauses.join(joiner)})` : groupClauses[0]);
97
+ }
98
+ let selectCols = options.isCount ? 'COUNT(*) as total' : `${table}.*`;
99
+ if (!options.isCount && fields && fields.length > 0) {
100
+ const valid = fields.filter(f => isValidColumn(seed, f));
101
+ if (valid.length > 0) {
102
+ selectCols = valid
103
+ .map(f => (SYSTEM_COLUMNS.has(f) ? `${table}.${f}` : f))
104
+ .join(', ');
105
+ }
106
+ }
107
+ if (!options.isCount && options.kanbanOrder)
108
+ selectCols += ', kp.position';
109
+ let sql = `SELECT ${selectCols} FROM ${table}`;
110
+ if (joinClause)
111
+ sql += ` ${joinClause}`;
112
+ if (whereClauses.length > 0)
113
+ sql += ` WHERE ${whereClauses.join(' AND ')}`;
114
+ if (!options.isCount) {
115
+ if (kanbanOrderClause) {
116
+ sql += kanbanOrderClause;
117
+ }
118
+ else if (orderBy && isValidColumn(seed, orderBy.column)) {
119
+ const dir = orderBy.dir === 'DESC' ? 'DESC' : 'ASC';
120
+ const col = SYSTEM_COLUMNS.has(orderBy.column)
121
+ ? `${table}.${orderBy.column}`
122
+ : orderBy.column;
123
+ sql += ` ORDER BY ${col} ${dir}`;
124
+ }
125
+ else {
126
+ sql += ` ORDER BY ${table}.created_at DESC`;
127
+ }
128
+ if (pagination) {
129
+ sql += ` LIMIT ? OFFSET ?`;
130
+ bindings.push(pagination.limit, pagination.offset);
131
+ }
132
+ }
133
+ return { sql, bindings };
134
+ }
135
+ /**
136
+ * Normalizes user-provided filter values to a format appropriate for SQL execution.
137
+ *
138
+ * @param type The type of the filter.
139
+ * @param value The value to normalize.
140
+ * @returns The normalized value, or null.
141
+ */
142
+ function normalizeFilterValue(type, value) {
143
+ if (value === null || value === undefined)
144
+ return null;
145
+ if (type === 'date') {
146
+ if (typeof value === 'number')
147
+ return value;
148
+ if (typeof value === 'string' && value.trim() !== '') {
149
+ const d = new Date(value);
150
+ return Number.isNaN(d.getTime()) ? null : Math.floor(d.getTime() / 1000);
151
+ }
152
+ return null;
153
+ }
154
+ if (type === 'boolean') {
155
+ return value ? 1 : 0;
156
+ }
157
+ if (type === 'number') {
158
+ if (typeof value === 'number')
159
+ return value;
160
+ if (typeof value === 'string' && value.trim() !== '') {
161
+ const n = Number(value);
162
+ return Number.isNaN(n) ? null : n;
163
+ }
164
+ return null;
165
+ }
166
+ return value;
167
+ }
168
+ /**
169
+ * Translates a filter condition into its SQL clause and adds parameters to the bindings list.
170
+ *
171
+ * @param col The column name in the table.
172
+ * @param type The type of the filter.
173
+ * @param cond The condition containing the operator and value.
174
+ * @param bindings The array of bindings where parameterized values are appended.
175
+ * @returns The SQL condition fragment, or null if invalid.
176
+ */
177
+ function buildFilterCondition(col, type, cond, bindings) {
178
+ const { op, value } = cond;
179
+ if (op === 'is_empty' || op === 'is_not_empty') {
180
+ const isEmpty = op === 'is_empty';
181
+ if (type === 'text') {
182
+ return isEmpty ? `(${col} IS NULL OR ${col} = '')` : `(${col} IS NOT NULL AND ${col} != '')`;
183
+ }
184
+ if (type === 'tags' || type === 'json') {
185
+ return isEmpty
186
+ ? `(${col} IS NULL OR ${col} = '[]' OR ${col} = '{}')`
187
+ : `(${col} IS NOT NULL AND ${col} != '[]' AND ${col} != '{}')`;
188
+ }
189
+ return isEmpty ? `${col} IS NULL` : `${col} IS NOT NULL`;
190
+ }
191
+ if (op === 'in' || op === 'not_in') {
192
+ if (!Array.isArray(value) || value.length === 0)
193
+ return null;
194
+ const normalizedValues = value
195
+ .map((v) => normalizeFilterValue(type, v))
196
+ .filter((v) => v !== null);
197
+ if (normalizedValues.length === 0)
198
+ return null;
199
+ const placeholders = normalizedValues.map(() => '?').join(', ');
200
+ bindings.push(...normalizedValues);
201
+ return `${col} ${op === 'in' ? 'IN' : 'NOT IN'} (${placeholders})`;
202
+ }
203
+ if (op === 'has_tag' || op === 'has_any_tag' || op === 'has_all_tags') {
204
+ if (type !== 'tags' && type !== 'json')
205
+ return null;
206
+ const tags = (op === 'has_tag' ? [value] : Array.isArray(value) ? value : [value])
207
+ .map(t => String(t));
208
+ if (tags.length === 0)
209
+ return null;
210
+ // json_each on array → value is the element; on object → key is the property name.
211
+ // Tags stored as ["photo"] (array) or {"photo":"#3b82f6"} (object) must both match.
212
+ const tagRef = `CASE json_type(${col}) WHEN 'array' THEN value ELSE key END`;
213
+ if (op === 'has_tag' || op === 'has_any_tag') {
214
+ const placeholders = tags.map(() => '?').join(', ');
215
+ bindings.push(...tags);
216
+ return `EXISTS (SELECT 1 FROM json_each(${col}) WHERE ${tagRef} IN (${placeholders}))`;
217
+ }
218
+ const clauses = tags.map(() => `EXISTS (SELECT 1 FROM json_each(${col}) WHERE ${tagRef} = ?)`);
219
+ bindings.push(...tags);
220
+ return `(${clauses.join(' AND ')})`;
221
+ }
222
+ const normalized = normalizeFilterValue(type, value);
223
+ if (normalized === null)
224
+ return null;
225
+ if (op === 'eq' || op === 'neq') {
226
+ const sqlOp = op === 'eq' ? '=' : '!=';
227
+ bindings.push(normalized);
228
+ return `${col} ${sqlOp} ?`;
229
+ }
230
+ if (op === 'contains' || op === 'not_contains' || op === 'starts_with' || op === 'ends_with') {
231
+ const sqlOp = op === 'not_contains' ? 'NOT LIKE' : 'LIKE';
232
+ let pattern = String(value);
233
+ if (op === 'contains' || op === 'not_contains')
234
+ pattern = `%${pattern}%`;
235
+ else if (op === 'starts_with')
236
+ pattern = `${pattern}%`;
237
+ else if (op === 'ends_with')
238
+ pattern = `%${pattern}`;
239
+ bindings.push(pattern);
240
+ return `${col} ${sqlOp} ?`;
241
+ }
242
+ const mathOps = { gt: '>', gte: '>=', lt: '<', lte: '<=' };
243
+ if (mathOps[op]) {
244
+ bindings.push(normalized);
245
+ return `${col} ${mathOps[op]} ?`;
246
+ }
247
+ return null;
248
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Back-reference helpers for the Beech CMS relation system.
3
+ *
4
+ * `buildBackrefMap` is a pure function that iterates the seed registry once
5
+ * and builds a lookup map: targetSlug → sources that point to it.
6
+ * Build this once at app factory time and cache it for the process lifetime.
7
+ */
8
+ import type { Seed } from './types.js';
9
+ export interface BackrefSource {
10
+ /** Slug of the seed that owns the relation branch */
11
+ sourceSlug: string;
12
+ /** `branch.alias` — the FK column name (single) or join-table name suffix (multi) */
13
+ branchAlias: string;
14
+ /** `branch.label` — human label for UI grouping */
15
+ branchLabel: string;
16
+ /** 'single' for direct FK, 'multi' for many-to-many join table */
17
+ relationship: 'single' | 'multi';
18
+ /** True when branch.onDelete === 'RESTRICT' — used to disable Delete button */
19
+ restricts: boolean;
20
+ }
21
+ /** Map: targetSlug → every source that points to it */
22
+ export type BackrefMap = Map<string, BackrefSource[]>;
23
+ /**
24
+ * Builds the back-reference map from an array of seeds.
25
+ * Pure function — no side-effects, safe to call at startup and cache forever.
26
+ */
27
+ export declare function buildBackrefMap(seeds: Seed[]): BackrefMap;
28
+ //# sourceMappingURL=relations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relations.d.ts","sourceRoot":"","sources":["../../src/engine/relations.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAEtC,MAAM,WAAW,aAAa;IAC5B,qDAAqD;IACrD,UAAU,EAAE,MAAM,CAAA;IAClB,qFAAqF;IACrF,WAAW,EAAE,MAAM,CAAA;IACnB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAA;IACnB,kEAAkE;IAClE,YAAY,EAAE,QAAQ,GAAG,OAAO,CAAA;IAChC,+EAA+E;IAC/E,SAAS,EAAE,OAAO,CAAA;CACnB;AAED,uDAAuD;AACvD,MAAM,MAAM,UAAU,GAAG,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,CAAA;AAErD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,UAAU,CAoBzD"}
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+ /**
4
+ * Builds the back-reference map from an array of seeds.
5
+ * Pure function — no side-effects, safe to call at startup and cache forever.
6
+ */
7
+ export function buildBackrefMap(seeds) {
8
+ const map = new Map();
9
+ for (const seed of seeds) {
10
+ for (const branch of seed.branches) {
11
+ if (branch.type !== 'relation' || !branch.targetSeed)
12
+ continue;
13
+ const existing = map.get(branch.targetSeed) ?? [];
14
+ existing.push({
15
+ sourceSlug: seed.slug,
16
+ branchAlias: branch.alias,
17
+ branchLabel: branch.label,
18
+ relationship: branch.multiple === true ? 'multi' : 'single',
19
+ restricts: branch.onDelete === 'RESTRICT',
20
+ });
21
+ map.set(branch.targetSeed, existing);
22
+ }
23
+ }
24
+ return map;
25
+ }
@@ -0,0 +1,71 @@
1
+ import type { Branch, Seed } from './types.js';
2
+ /**
3
+ * Bumped ONLY when the projection below or the canonical byte format changes — i.e. when
4
+ * fingerprints computed by two builds are no longer comparable. It is carried in the fingerprint
5
+ * string itself so a version skew reads as a mismatch instead of as an accidental match.
6
+ */
7
+ export declare const SCHEMA_FINGERPRINT_VERSION = 1;
8
+ /** The response-shape-bearing subset of a `Branch`. */
9
+ export interface ContractBranch {
10
+ alias: string;
11
+ type: Branch['type'];
12
+ format?: Branch['format'];
13
+ multiple?: boolean;
14
+ options?: string[];
15
+ requiredOnCreate: boolean;
16
+ requiredOnUpdate: boolean;
17
+ targetSeed?: string;
18
+ /** From `resolvePolicies`, so an explicitly-written default hashes like an omitted one. */
19
+ visibility: 'full' | 'masked' | 'hidden';
20
+ publicRead: boolean;
21
+ publicEdit: boolean;
22
+ minItems?: number;
23
+ maxItems?: number;
24
+ /** Repeater sub-fields, same projection, recursively. */
25
+ fields?: ContractBranch[];
26
+ }
27
+ /** The response-shape-bearing subset of a `Seed`. */
28
+ export interface ContractSeed {
29
+ slug: string;
30
+ displayNameAlias: string;
31
+ allowDrafts: boolean;
32
+ allowPublicRead: boolean;
33
+ allowPublicPost: boolean;
34
+ allowPublicEdit: boolean;
35
+ branches: ContractBranch[];
36
+ }
37
+ /** The exact value that gets canonicalized and hashed. */
38
+ export interface SchemaContract {
39
+ fingerprintVersion: number;
40
+ seeds: ContractSeed[];
41
+ }
42
+ /**
43
+ * Reduces seeds to what a response shape depends on.
44
+ *
45
+ * Deliberately EXCLUDED, and each for a reason a future reader must not "fix":
46
+ * - `id` (`br_XX`) — a stable internal handle; it never appears in a payload, and minting order
47
+ * differs between an environment built by migration and one grown through the dashboard.
48
+ * - `label`, `labelPlural`, `hint`, `dashboard`, `layout` — presentation, never response shape.
49
+ * - `retentionDays`, `onDelete`, `policies.search|filter|sort`, `numberOptions`, `fileOptions`,
50
+ * `policies.classification|privacy` — behaviour and storage concerns that leave the response
51
+ * shape identical. (`privacy` DOES change a stored value's encoding, but the API type stays
52
+ * `string`; drift there is a data concern, not a type concern.)
53
+ * - anything physical (column order, index names) — see the VETO Audit.
54
+ *
55
+ * Seeds are sorted by slug; BRANCH ORDER IS PRESERVED, because it is the declared field order the
56
+ * generated types and the dashboard form both follow.
57
+ */
58
+ export declare function projectSchemaContract(seeds: Seed[]): SchemaContract;
59
+ /**
60
+ * `v{VERSION}:{32 lowercase hex}` — SHA-256 over the canonical JSON of the contract projection,
61
+ * truncated to 128 bits.
62
+ *
63
+ * Truncation is deliberate: this value travels on EVERY public response as `X-Schema-Revision`,
64
+ * and 128 bits of a SHA-256 digest carries no realistic collision risk for a value space of a few
65
+ * hundred schema revisions. It is a revision marker, never a security token.
66
+ *
67
+ * Web Crypto only — the same API the Worker and Node ≥ 18 both expose, as `webhook-crypto.ts`
68
+ * already relies on. `@beechcms/core` gains no dependency.
69
+ */
70
+ export declare function computeSchemaFingerprint(seeds: Seed[]): Promise<string>;
71
+ //# sourceMappingURL=schema-fingerprint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema-fingerprint.d.ts","sourceRoot":"","sources":["../../src/engine/schema-fingerprint.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAE9C;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,IAAI,CAAA;AAE3C,uDAAuD;AACvD,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAA;IACzB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,gBAAgB,EAAE,OAAO,CAAA;IACzB,gBAAgB,EAAE,OAAO,CAAA;IACzB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,2FAA2F;IAC3F,UAAU,EAAE,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAA;IACxC,UAAU,EAAE,OAAO,CAAA;IACnB,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yDAAyD;IACzD,MAAM,CAAC,EAAE,cAAc,EAAE,CAAA;CAC1B;AAED,qDAAqD;AACrD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,gBAAgB,EAAE,MAAM,CAAA;IACxB,WAAW,EAAE,OAAO,CAAA;IACpB,eAAe,EAAE,OAAO,CAAA;IACxB,eAAe,EAAE,OAAO,CAAA;IACxB,eAAe,EAAE,OAAO,CAAA;IACxB,QAAQ,EAAE,cAAc,EAAE,CAAA;CAC3B;AAED,0DAA0D;AAC1D,MAAM,WAAW,cAAc;IAC7B,kBAAkB,EAAE,MAAM,CAAA;IAC1B,KAAK,EAAE,YAAY,EAAE,CAAA;CACtB;AAuBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,cAAc,CAenE;AAMD;;;;;;;;;;GAUG;AACH,wBAAsB,wBAAwB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAI7E"}
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+ /**
4
+ * @module engine/schema-fingerprint
5
+ * A deterministic name for "the shape this API answers in".
6
+ *
7
+ * Computed from live seed definitions (`introspectSeedDefinitions`), embedded in generated client
8
+ * types at build time (sprint 3), returned on every public response as `X-Schema-Revision`
9
+ * (sprint 4), and compared by the client at request time (sprint 5). Both ends MUST compute it
10
+ * with this function — two implementations would be free to disagree, and a drift detector whose
11
+ * ends disagree is worse than none.
12
+ *
13
+ * What is hashed is a CONTRACT PROJECTION, not the whole seed definition: labels, hints, dashboard
14
+ * icons and layout cannot change a single response byte, and hashing them would fire a hard
15
+ * `BeechProblem` in someone else's production client over a typo fix.
16
+ */
17
+ import { canonicalStringify } from '../common/canonical-json.js';
18
+ import { resolvePolicies } from './policies.js';
19
+ /**
20
+ * Bumped ONLY when the projection below or the canonical byte format changes — i.e. when
21
+ * fingerprints computed by two builds are no longer comparable. It is carried in the fingerprint
22
+ * string itself so a version skew reads as a mismatch instead of as an accidental match.
23
+ */
24
+ export const SCHEMA_FINGERPRINT_VERSION = 1;
25
+ function projectBranch(branch) {
26
+ const policies = resolvePolicies(branch);
27
+ return {
28
+ alias: branch.alias,
29
+ type: branch.type,
30
+ ...(branch.format !== undefined ? { format: branch.format } : {}),
31
+ ...(branch.multiple !== undefined ? { multiple: branch.multiple } : {}),
32
+ // Option order is the contract: it is the order a select renders and a union type lists.
33
+ ...(branch.options !== undefined ? { options: [...branch.options] } : {}),
34
+ requiredOnCreate: branch.requiredOnCreate ?? false,
35
+ requiredOnUpdate: branch.requiredOnUpdate ?? false,
36
+ ...(branch.targetSeed !== undefined ? { targetSeed: branch.targetSeed } : {}),
37
+ visibility: policies.visibility,
38
+ publicRead: policies.public,
39
+ publicEdit: policies.publicEdit,
40
+ ...(branch.minItems !== undefined ? { minItems: branch.minItems } : {}),
41
+ ...(branch.maxItems !== undefined ? { maxItems: branch.maxItems } : {}),
42
+ ...(branch.fields ? { fields: branch.fields.map(projectBranch) } : {}),
43
+ };
44
+ }
45
+ /**
46
+ * Reduces seeds to what a response shape depends on.
47
+ *
48
+ * Deliberately EXCLUDED, and each for a reason a future reader must not "fix":
49
+ * - `id` (`br_XX`) — a stable internal handle; it never appears in a payload, and minting order
50
+ * differs between an environment built by migration and one grown through the dashboard.
51
+ * - `label`, `labelPlural`, `hint`, `dashboard`, `layout` — presentation, never response shape.
52
+ * - `retentionDays`, `onDelete`, `policies.search|filter|sort`, `numberOptions`, `fileOptions`,
53
+ * `policies.classification|privacy` — behaviour and storage concerns that leave the response
54
+ * shape identical. (`privacy` DOES change a stored value's encoding, but the API type stays
55
+ * `string`; drift there is a data concern, not a type concern.)
56
+ * - anything physical (column order, index names) — see the VETO Audit.
57
+ *
58
+ * Seeds are sorted by slug; BRANCH ORDER IS PRESERVED, because it is the declared field order the
59
+ * generated types and the dashboard form both follow.
60
+ */
61
+ export function projectSchemaContract(seeds) {
62
+ return {
63
+ fingerprintVersion: SCHEMA_FINGERPRINT_VERSION,
64
+ seeds: [...seeds]
65
+ .sort((a, b) => a.slug.localeCompare(b.slug))
66
+ .map(seed => ({
67
+ slug: seed.slug,
68
+ displayNameAlias: seed.displayNameAlias,
69
+ allowDrafts: seed.allowDrafts ?? false,
70
+ allowPublicRead: seed.allowPublicRead ?? false,
71
+ allowPublicPost: seed.allowPublicPost ?? false,
72
+ allowPublicEdit: seed.allowPublicEdit ?? false,
73
+ branches: seed.branches.map(projectBranch),
74
+ })),
75
+ };
76
+ }
77
+ function toHex(buffer) {
78
+ return Array.from(new Uint8Array(buffer), byte => byte.toString(16).padStart(2, '0')).join('');
79
+ }
80
+ /**
81
+ * `v{VERSION}:{32 lowercase hex}` — SHA-256 over the canonical JSON of the contract projection,
82
+ * truncated to 128 bits.
83
+ *
84
+ * Truncation is deliberate: this value travels on EVERY public response as `X-Schema-Revision`,
85
+ * and 128 bits of a SHA-256 digest carries no realistic collision risk for a value space of a few
86
+ * hundred schema revisions. It is a revision marker, never a security token.
87
+ *
88
+ * Web Crypto only — the same API the Worker and Node ≥ 18 both expose, as `webhook-crypto.ts`
89
+ * already relies on. `@beechcms/core` gains no dependency.
90
+ */
91
+ export async function computeSchemaFingerprint(seeds) {
92
+ const bytes = new TextEncoder().encode(canonicalStringify(projectSchemaContract(seeds)));
93
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
94
+ return `v${SCHEMA_FINGERPRINT_VERSION}:${toHex(digest).slice(0, 32)}`;
95
+ }
@@ -0,0 +1,26 @@
1
+ /** Executes additive schema DDL against the live database and introspects columns.
2
+ * Implemented by D1SchemaMutator in apps/api/src/shared/schema-mutator.d1.ts.
3
+ * This is the ONLY sanctioned channel for runtime DDL — handlers never touch env.DB. */
4
+ export interface ISchemaMutator {
5
+ /** Column names currently on a table, or null if the table does not exist. */
6
+ getColumns(table: string): Promise<Set<string> | null>;
7
+ /** Runs the given DDL statements in order as a single D1 batch.
8
+ * All statements must be additive (CREATE … IF NOT EXISTS / ALTER … ADD COLUMN /
9
+ * CREATE INDEX IF NOT EXISTS). Throws on the first failing statement. */
10
+ execDdl(statements: string[]): Promise<void>;
11
+ /** `DROP TABLE IF EXISTS {table}`. */
12
+ dropTable(table: string): Promise<void>;
13
+ /** `ALTER TABLE {table} DROP COLUMN {column}`. */
14
+ dropColumn(table: string, column: string): Promise<void>;
15
+ /** `ALTER TABLE {table} RENAME COLUMN {from} TO {to}`. */
16
+ renameColumn(table: string, from: string, to: string): Promise<void>;
17
+ /** Runs a multi-statement destructive batch atomically (FTS rebuild,
18
+ * type-change column rebuild, full type drop). The caller assembles the
19
+ * statements; the impl validates identifiers and runs them as one D1 batch. */
20
+ execDestructive(statements: string[]): Promise<void>;
21
+ /** Reads all rows from a table for the specified columns only.
22
+ * Used before DROP TABLE to collect R2 keys for media cleanup.
23
+ * All identifiers are validated against `^[A-Za-z0-9_]+$`. */
24
+ fetchRows(table: string, columns: string[]): Promise<Record<string, unknown>[]>;
25
+ }
26
+ //# sourceMappingURL=schema-mutator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema-mutator.d.ts","sourceRoot":"","sources":["../../src/engine/schema-mutator.ts"],"names":[],"mappings":"AAGA;;yFAEyF;AACzF,MAAM,WAAW,cAAc;IAC7B,8EAA8E;IAC9E,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC,CAAA;IACtD;;8EAE0E;IAC1E,OAAO,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAS5C,sCAAsC;IACtC,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACvC,kDAAkD;IAClD,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxD,0DAA0D;IAC1D,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACpE;;oFAEgF;IAChF,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAEpD;;mEAE+D;IAC/D,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAA;CAChF"}
@@ -0,0 +1,3 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+ export {};
@@ -0,0 +1,40 @@
1
+ import type { Seed, BranchType } from './types.js';
2
+ /**
3
+ * Ordered DROP TABLE statements for a full hard-delete of a seed's tables.
4
+ * Order: junction drafts → junctions → FTS triggers → FTS table → drafts → main table.
5
+ * All statements use IF EXISTS (idempotent).
6
+ */
7
+ export declare function generateDropTable(seed: Seed): string[];
8
+ /**
9
+ * DROP COLUMN statements for a single branch (by alias).
10
+ * For multi-relation branches: drops the junction tables (no column on parent).
11
+ * For regular branches: drops from main table + drafts table (if allowDrafts).
12
+ * Does NOT include FTS handling — callers must prepend ftsTriggerDrops() and
13
+ * append planFtsRebuild() when the branch is searchable.
14
+ */
15
+ export declare function generateDropColumn(seed: Seed, alias: string): string[];
16
+ /**
17
+ * RENAME COLUMN statements for a branch alias change.
18
+ * Updates main table + drafts (if allowDrafts).
19
+ * Does NOT handle FTS — callers must rebuild FTS separately (SQLite auto-updates
20
+ * trigger bodies on RENAME but the FTS virtual-table columns keep the old name).
21
+ */
22
+ export declare function generateRenameColumn(seed: Seed, fromAlias: string, toAlias: string): string[];
23
+ /**
24
+ * Full FTS rebuild: drop triggers + FTS table, then recreate them, then backfill.
25
+ * Returns an empty array if the seed has no searchable branches.
26
+ * The drop steps use IF EXISTS so this is safe to call even if FTS was already dropped.
27
+ */
28
+ export declare function planFtsRebuild(seed: Seed): string[];
29
+ export interface RetypeColumnPlan {
30
+ statements: string[];
31
+ ftsRebuildNeeded: boolean;
32
+ }
33
+ /**
34
+ * Generates statements to change a branch's SQL storage type using the
35
+ * add-new-column / copy / drop-old / rename approach.
36
+ * Includes FTS rebuild statements when the branch is (or becomes) searchable.
37
+ * `updatedSeed` must already reflect the new type on the renamed branch.
38
+ */
39
+ export declare function planRetypeColumn(seed: Seed, alias: string, newType: BranchType, updatedSeed: Seed): RetypeColumnPlan;
40
+ //# sourceMappingURL=seed-ddl-destructive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed-ddl-destructive.d.ts","sourceRoot":"","sources":["../../src/engine/seed-ddl-destructive.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAU,UAAU,EAAE,MAAM,YAAY,CAAA;AAkC1D;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,CAgBtD;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAgBtE;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAQ7F;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,CAiBnD;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,gBAAgB,EAAE,OAAO,CAAA;CAC1B;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,UAAU,EACnB,WAAW,EAAE,IAAI,GAChB,gBAAgB,CAyClB"}