@beechcms/core 0.6.0-preview.3 → 0.6.0

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 (243) hide show
  1. package/dist/automations/automations-grammar-words.d.ts.map +1 -0
  2. package/dist/automations/automations.repository.interface.d.ts.map +1 -0
  3. package/dist/automations/automations.runner.interface.d.ts.map +1 -0
  4. package/dist/automations/automations.types.d.ts.map +1 -0
  5. package/dist/common/clock.d.ts.map +1 -0
  6. package/dist/common/email.provider.d.ts +32 -0
  7. package/dist/common/email.provider.d.ts.map +1 -0
  8. package/dist/common/email.provider.js +4 -0
  9. package/dist/{hooks.d.ts → common/hooks.d.ts} +2 -2
  10. package/dist/common/hooks.d.ts.map +1 -0
  11. package/dist/common/id-generator.d.ts.map +1 -0
  12. package/dist/common/storage.d.ts.map +1 -0
  13. package/dist/{content-scan.repository.d.ts → content/content-scan.repository.d.ts} +1 -1
  14. package/dist/content/content-scan.repository.d.ts.map +1 -0
  15. package/dist/{content.repository.d.ts → content/content.repository.d.ts} +13 -4
  16. package/dist/content/content.repository.d.ts.map +1 -0
  17. package/dist/content/demo-data.repository.d.ts.map +1 -0
  18. package/dist/content/idempotency.repository.d.ts.map +1 -0
  19. package/dist/content/richtext/richtext-render.d.ts +12 -0
  20. package/dist/content/richtext/richtext-render.d.ts.map +1 -0
  21. package/dist/{richtext-render.js → content/richtext/richtext-render.js} +7 -14
  22. package/dist/content/richtext/richtext.d.ts.map +1 -0
  23. package/dist/{seed.repository.d.ts → content/seed.repository.d.ts} +1 -1
  24. package/dist/content/seed.repository.d.ts.map +1 -0
  25. package/dist/content/slug-utils.d.ts.map +1 -0
  26. package/dist/{dashboard-layout.d.ts → dashboard-layout/dashboard-layout.d.ts} +1 -1
  27. package/dist/dashboard-layout/dashboard-layout.d.ts.map +1 -0
  28. package/dist/dashboard-layout/dashboard-layout.repository.d.ts.map +1 -0
  29. package/dist/dashboard-layout/dashboard-permissions.d.ts.map +1 -0
  30. package/dist/dashboard-layout/dashboard-scopes.d.ts.map +1 -0
  31. package/dist/dashboard-layout/kanban/kanban-position.repository.d.ts +19 -0
  32. package/dist/dashboard-layout/kanban/kanban-position.repository.d.ts.map +1 -0
  33. package/dist/dashboard-layout/kanban/kanban.d.ts +75 -0
  34. package/dist/dashboard-layout/kanban/kanban.d.ts.map +1 -0
  35. package/dist/dashboard-layout/kanban/kanban.js +82 -0
  36. package/dist/dashboard-layout/layout-permissions.d.ts.map +1 -0
  37. package/dist/{seed-layout.d.ts → dashboard-layout/seed-layout.d.ts} +77 -3
  38. package/dist/dashboard-layout/seed-layout.d.ts.map +1 -0
  39. package/dist/{seed-layout.js → dashboard-layout/seed-layout.js} +101 -8
  40. package/dist/{seed-layout.repository.d.ts → dashboard-layout/seed-layout.repository.d.ts} +5 -1
  41. package/dist/dashboard-layout/seed-layout.repository.d.ts.map +1 -0
  42. package/dist/dashboard-layout/view-authorization.d.ts +18 -0
  43. package/dist/dashboard-layout/view-authorization.d.ts.map +1 -0
  44. package/dist/dashboard-layout/view-authorization.js +28 -0
  45. package/dist/engine/ddl.d.ts +173 -0
  46. package/dist/engine/ddl.d.ts.map +1 -0
  47. package/dist/engine/ddl.js +454 -0
  48. package/dist/engine/define-seed.d.ts.map +1 -0
  49. package/dist/engine/engine.d.ts +4 -0
  50. package/dist/engine/engine.d.ts.map +1 -0
  51. package/dist/engine/engine.js +4 -0
  52. package/dist/engine/policies.d.ts.map +1 -0
  53. package/dist/engine/query.d.ts +23 -0
  54. package/dist/engine/query.d.ts.map +1 -0
  55. package/dist/engine/query.js +208 -0
  56. package/dist/engine/relations.d.ts.map +1 -0
  57. package/dist/{schema-mutator.d.ts → engine/schema-mutator.d.ts} +4 -0
  58. package/dist/engine/schema-mutator.d.ts.map +1 -0
  59. package/dist/engine/seed-ddl-destructive.d.ts.map +1 -0
  60. package/dist/engine/seed-ddl.d.ts.map +1 -0
  61. package/dist/engine/seed-registry.d.ts.map +1 -0
  62. package/dist/{seed-registry.js → engine/seed-registry.js} +1 -1
  63. package/dist/engine/seed-types-generator.d.ts +10 -0
  64. package/dist/engine/seed-types-generator.d.ts.map +1 -0
  65. package/dist/engine/seed-types-generator.js +86 -0
  66. package/dist/engine/seed-validation.d.ts.map +1 -0
  67. package/dist/{seed-validation.js → engine/seed-validation.js} +14 -1
  68. package/dist/engine/seeds.d.ts.map +1 -0
  69. package/dist/engine/serialize.d.ts +20 -0
  70. package/dist/engine/serialize.d.ts.map +1 -0
  71. package/dist/engine/serialize.js +160 -0
  72. package/dist/{types.d.ts → engine/types.d.ts} +102 -63
  73. package/dist/engine/types.d.ts.map +1 -0
  74. package/dist/engine/validation/cache.d.ts +12 -0
  75. package/dist/engine/validation/cache.d.ts.map +1 -0
  76. package/dist/engine/validation/cache.js +115 -0
  77. package/dist/engine/validation/file-branch.d.ts +48 -0
  78. package/dist/engine/validation/file-branch.d.ts.map +1 -0
  79. package/dist/engine/validation/file-branch.js +127 -0
  80. package/dist/engine/validation/index.d.ts +115 -0
  81. package/dist/engine/validation/index.d.ts.map +1 -0
  82. package/dist/engine/validation/index.js +333 -0
  83. package/dist/engine/validation/primitives.d.ts +33 -0
  84. package/dist/engine/validation/primitives.d.ts.map +1 -0
  85. package/dist/engine/validation/primitives.js +43 -0
  86. package/dist/engine/validation/richtext-sanitizer.d.ts +30 -0
  87. package/dist/engine/validation/richtext-sanitizer.d.ts.map +1 -0
  88. package/dist/engine/validation/richtext-sanitizer.js +193 -0
  89. package/dist/engine/validation/schema-builders.d.ts +12 -0
  90. package/dist/engine/validation/schema-builders.d.ts.map +1 -0
  91. package/dist/engine/validation/schema-builders.js +322 -0
  92. package/dist/index.d.ts +48 -40
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +48 -40
  95. package/dist/media/file-types.d.ts +13 -0
  96. package/dist/media/file-types.d.ts.map +1 -0
  97. package/dist/media/media.repository.d.ts.map +1 -0
  98. package/dist/queue/queue.interface.d.ts +39 -0
  99. package/dist/queue/queue.interface.d.ts.map +1 -0
  100. package/dist/queue/queue.interface.js +3 -0
  101. package/dist/queue/queue.stub.d.ts +6 -0
  102. package/dist/queue/queue.stub.d.ts.map +1 -0
  103. package/dist/queue/queue.stub.js +8 -0
  104. package/dist/queue/scheduler.interface.d.ts.map +1 -0
  105. package/dist/queue/scheduler.interface.js +3 -0
  106. package/dist/queue/scheduler.stub.d.ts.map +1 -0
  107. package/dist/queue.interface.d.ts +39 -0
  108. package/dist/queue.interface.d.ts.map +1 -0
  109. package/dist/queue.stub.d.ts +6 -0
  110. package/dist/queue.stub.d.ts.map +1 -0
  111. package/dist/queue.stub.js +8 -0
  112. package/dist/rate-limit/rate-limiter.d.ts +5 -0
  113. package/dist/rate-limit/rate-limiter.d.ts.map +1 -1
  114. package/dist/search/search.repository.d.ts +1 -1
  115. package/dist/search/search.repository.d.ts.map +1 -1
  116. package/dist/seed-types-generator.d.ts +10 -0
  117. package/dist/seed-types-generator.d.ts.map +1 -0
  118. package/dist/seed-types-generator.js +86 -0
  119. package/dist/settings/site-settings.repository.d.ts.map +1 -0
  120. package/dist/settings/site-settings.repository.js +3 -0
  121. package/dist/webhook-crypto.d.ts +8 -0
  122. package/dist/webhook-crypto.d.ts.map +1 -0
  123. package/dist/webhook-crypto.js +37 -0
  124. package/dist/webhooks/webhook-crypto.d.ts +8 -0
  125. package/dist/webhooks/webhook-crypto.d.ts.map +1 -0
  126. package/dist/webhooks/webhook-crypto.js +37 -0
  127. package/dist/webhooks/webhook-validation.d.ts.map +1 -0
  128. package/dist/widget/widget.repository.d.ts +1 -1
  129. package/dist/widget/widget.repository.d.ts.map +1 -1
  130. package/package.json +6 -1
  131. package/dist/automations-grammar-words.d.ts.map +0 -1
  132. package/dist/automations.repository.interface.d.ts.map +0 -1
  133. package/dist/automations.runner.interface.d.ts.map +0 -1
  134. package/dist/automations.runner.stub.d.ts +0 -5
  135. package/dist/automations.runner.stub.d.ts.map +0 -1
  136. package/dist/automations.runner.stub.js +0 -7
  137. package/dist/automations.types.d.ts.map +0 -1
  138. package/dist/clock.d.ts.map +0 -1
  139. package/dist/content-scan.repository.d.ts.map +0 -1
  140. package/dist/content.repository.d.ts.map +0 -1
  141. package/dist/dashboard-layout.d.ts.map +0 -1
  142. package/dist/dashboard-layout.repository.d.ts.map +0 -1
  143. package/dist/dashboard-permissions.d.ts.map +0 -1
  144. package/dist/dashboard-scopes.d.ts.map +0 -1
  145. package/dist/define-seed.d.ts.map +0 -1
  146. package/dist/demo-data.repository.d.ts.map +0 -1
  147. package/dist/engine.d.ts +0 -144
  148. package/dist/engine.d.ts.map +0 -1
  149. package/dist/engine.js +0 -723
  150. package/dist/file-types.d.ts +0 -13
  151. package/dist/file-types.d.ts.map +0 -1
  152. package/dist/hooks.d.ts.map +0 -1
  153. package/dist/id-generator.d.ts.map +0 -1
  154. package/dist/idempotency.repository.d.ts.map +0 -1
  155. package/dist/layout-permissions.d.ts.map +0 -1
  156. package/dist/media.repository.d.ts.map +0 -1
  157. package/dist/policies.d.ts.map +0 -1
  158. package/dist/relations.d.ts.map +0 -1
  159. package/dist/richtext-render.d.ts +0 -11
  160. package/dist/richtext-render.d.ts.map +0 -1
  161. package/dist/richtext.d.ts.map +0 -1
  162. package/dist/scheduler.interface.d.ts.map +0 -1
  163. package/dist/scheduler.stub.d.ts.map +0 -1
  164. package/dist/schema-mutator.d.ts.map +0 -1
  165. package/dist/seed-ddl-destructive.d.ts.map +0 -1
  166. package/dist/seed-ddl.d.ts.map +0 -1
  167. package/dist/seed-layout.d.ts.map +0 -1
  168. package/dist/seed-layout.repository.d.ts.map +0 -1
  169. package/dist/seed-registry.d.ts.map +0 -1
  170. package/dist/seed-validation.d.ts.map +0 -1
  171. package/dist/seed.repository.d.ts.map +0 -1
  172. package/dist/seeds.d.ts.map +0 -1
  173. package/dist/site-settings.repository.d.ts.map +0 -1
  174. package/dist/slug-utils.d.ts.map +0 -1
  175. package/dist/storage.d.ts.map +0 -1
  176. package/dist/types.d.ts.map +0 -1
  177. package/dist/validation.d.ts +0 -50
  178. package/dist/validation.d.ts.map +0 -1
  179. package/dist/validation.js +0 -659
  180. package/dist/webhook-validation.d.ts.map +0 -1
  181. /package/dist/{automations-grammar-words.d.ts → automations/automations-grammar-words.d.ts} +0 -0
  182. /package/dist/{automations-grammar-words.js → automations/automations-grammar-words.js} +0 -0
  183. /package/dist/{automations.repository.interface.d.ts → automations/automations.repository.interface.d.ts} +0 -0
  184. /package/dist/{automations.repository.interface.js → automations/automations.repository.interface.js} +0 -0
  185. /package/dist/{automations.runner.interface.d.ts → automations/automations.runner.interface.d.ts} +0 -0
  186. /package/dist/{automations.runner.interface.js → automations/automations.runner.interface.js} +0 -0
  187. /package/dist/{automations.types.d.ts → automations/automations.types.d.ts} +0 -0
  188. /package/dist/{automations.types.js → automations/automations.types.js} +0 -0
  189. /package/dist/{clock.d.ts → common/clock.d.ts} +0 -0
  190. /package/dist/{clock.js → common/clock.js} +0 -0
  191. /package/dist/{hooks.js → common/hooks.js} +0 -0
  192. /package/dist/{id-generator.d.ts → common/id-generator.d.ts} +0 -0
  193. /package/dist/{id-generator.js → common/id-generator.js} +0 -0
  194. /package/dist/{storage.d.ts → common/storage.d.ts} +0 -0
  195. /package/dist/{storage.js → common/storage.js} +0 -0
  196. /package/dist/{content-scan.repository.js → content/content-scan.repository.js} +0 -0
  197. /package/dist/{content.repository.js → content/content.repository.js} +0 -0
  198. /package/dist/{demo-data.repository.d.ts → content/demo-data.repository.d.ts} +0 -0
  199. /package/dist/{demo-data.repository.js → content/demo-data.repository.js} +0 -0
  200. /package/dist/{idempotency.repository.d.ts → content/idempotency.repository.d.ts} +0 -0
  201. /package/dist/{idempotency.repository.js → content/idempotency.repository.js} +0 -0
  202. /package/dist/{richtext.d.ts → content/richtext/richtext.d.ts} +0 -0
  203. /package/dist/{richtext.js → content/richtext/richtext.js} +0 -0
  204. /package/dist/{seed.repository.js → content/seed.repository.js} +0 -0
  205. /package/dist/{slug-utils.d.ts → content/slug-utils.d.ts} +0 -0
  206. /package/dist/{slug-utils.js → content/slug-utils.js} +0 -0
  207. /package/dist/{dashboard-layout.js → dashboard-layout/dashboard-layout.js} +0 -0
  208. /package/dist/{dashboard-layout.repository.d.ts → dashboard-layout/dashboard-layout.repository.d.ts} +0 -0
  209. /package/dist/{dashboard-layout.repository.js → dashboard-layout/dashboard-layout.repository.js} +0 -0
  210. /package/dist/{dashboard-permissions.d.ts → dashboard-layout/dashboard-permissions.d.ts} +0 -0
  211. /package/dist/{dashboard-permissions.js → dashboard-layout/dashboard-permissions.js} +0 -0
  212. /package/dist/{dashboard-scopes.d.ts → dashboard-layout/dashboard-scopes.d.ts} +0 -0
  213. /package/dist/{dashboard-scopes.js → dashboard-layout/dashboard-scopes.js} +0 -0
  214. /package/dist/{media.repository.js → dashboard-layout/kanban/kanban-position.repository.js} +0 -0
  215. /package/dist/{layout-permissions.d.ts → dashboard-layout/layout-permissions.d.ts} +0 -0
  216. /package/dist/{layout-permissions.js → dashboard-layout/layout-permissions.js} +0 -0
  217. /package/dist/{seed-layout.repository.js → dashboard-layout/seed-layout.repository.js} +0 -0
  218. /package/dist/{define-seed.d.ts → engine/define-seed.d.ts} +0 -0
  219. /package/dist/{define-seed.js → engine/define-seed.js} +0 -0
  220. /package/dist/{policies.d.ts → engine/policies.d.ts} +0 -0
  221. /package/dist/{policies.js → engine/policies.js} +0 -0
  222. /package/dist/{relations.d.ts → engine/relations.d.ts} +0 -0
  223. /package/dist/{relations.js → engine/relations.js} +0 -0
  224. /package/dist/{schema-mutator.js → engine/schema-mutator.js} +0 -0
  225. /package/dist/{seed-ddl-destructive.d.ts → engine/seed-ddl-destructive.d.ts} +0 -0
  226. /package/dist/{seed-ddl-destructive.js → engine/seed-ddl-destructive.js} +0 -0
  227. /package/dist/{seed-ddl.d.ts → engine/seed-ddl.d.ts} +0 -0
  228. /package/dist/{seed-ddl.js → engine/seed-ddl.js} +0 -0
  229. /package/dist/{seed-registry.d.ts → engine/seed-registry.d.ts} +0 -0
  230. /package/dist/{seed-validation.d.ts → engine/seed-validation.d.ts} +0 -0
  231. /package/dist/{seeds.d.ts → engine/seeds.d.ts} +0 -0
  232. /package/dist/{seeds.js → engine/seeds.js} +0 -0
  233. /package/dist/{types.js → engine/types.js} +0 -0
  234. /package/dist/{file-types.js → media/file-types.js} +0 -0
  235. /package/dist/{media.repository.d.ts → media/media.repository.d.ts} +0 -0
  236. /package/dist/{scheduler.interface.js → media/media.repository.js} +0 -0
  237. /package/dist/{scheduler.interface.d.ts → queue/scheduler.interface.d.ts} +0 -0
  238. /package/dist/{scheduler.stub.d.ts → queue/scheduler.stub.d.ts} +0 -0
  239. /package/dist/{scheduler.stub.js → queue/scheduler.stub.js} +0 -0
  240. /package/dist/{site-settings.repository.js → queue.interface.js} +0 -0
  241. /package/dist/{site-settings.repository.d.ts → settings/site-settings.repository.d.ts} +0 -0
  242. /package/dist/{webhook-validation.d.ts → webhooks/webhook-validation.d.ts} +0 -0
  243. /package/dist/{webhook-validation.js → webhooks/webhook-validation.js} +0 -0
package/dist/engine.js DELETED
@@ -1,723 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- // Copyright (c) 2024–2026 Flavio De Musso
3
- const BRANCH_TYPE_SQL = {
4
- text: { sqlType: 'TEXT' },
5
- number: { sqlType: 'REAL' },
6
- boolean: { sqlType: 'INTEGER' },
7
- date: { sqlType: 'INTEGER' }, // Unix timestamp (seconds)
8
- json: { sqlType: 'TEXT' }, // JSON serializzato
9
- richtext: { sqlType: 'TEXT' },
10
- file: { sqlType: 'TEXT' }, // URL singolo o JSON array di URL
11
- tags: { sqlType: 'TEXT' }, // JSON array di stringhe
12
- relation: { sqlType: 'TEXT' }, // FK reference stored as TEXT (id of the target row)
13
- repeater: { sqlType: 'TEXT' }, // JSON array di record (sub-branch alias -> value)
14
- };
15
- const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at']);
16
- // ---- Private helpers ----
17
- function tableName(seed) {
18
- return `content_${seed.slug}`;
19
- }
20
- function ftsTableName(seed) {
21
- return `fts_${seed.slug}`;
22
- }
23
- function isValidColumn(seed, col) {
24
- if (SYSTEM_COLUMNS.has(col))
25
- return true;
26
- return seed.branches.some(b => b.alias === col);
27
- }
28
- export function indexableSearchBranches(seed) {
29
- return seed.branches.filter(b => (b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false);
30
- }
31
- function isAssetListBranch(branch) {
32
- return branch.type === 'file' && (branch.multiple === true || branch.format === 'asset-list');
33
- }
34
- function normalizeHttpUrl(value) {
35
- if (typeof value !== 'string')
36
- return null;
37
- const cleaned = value.trim();
38
- if (!cleaned)
39
- return null;
40
- try {
41
- const parsed = new URL(cleaned);
42
- return parsed.protocol.startsWith('http') ? cleaned : null;
43
- }
44
- catch {
45
- return null;
46
- }
47
- }
48
- function parseJsonSafe(value) {
49
- try {
50
- return JSON.parse(value);
51
- }
52
- catch {
53
- return value;
54
- }
55
- }
56
- function normalizeAssetListValue(rawValue) {
57
- const input = typeof rawValue === 'string' ? parseJsonSafe(rawValue) : rawValue;
58
- const values = Array.isArray(input) ? input : [input];
59
- const normalized = [];
60
- for (const item of values) {
61
- if (item == null)
62
- continue;
63
- const direct = normalizeHttpUrl(item);
64
- if (direct) {
65
- normalized.push(direct);
66
- continue;
67
- }
68
- if (typeof item === 'object' && !Array.isArray(item)) {
69
- const fromObj = normalizeHttpUrl(item.url);
70
- if (fromObj)
71
- normalized.push(fromObj);
72
- }
73
- }
74
- return [...new Set(normalized)];
75
- }
76
- // ---- FK helpers ----
77
- const DEFAULT_ON_DELETE_RULE = 'SET NULL';
78
- /**
79
- * Returns the trailing FK fragment for a single-value relation branch, or ''
80
- * if the branch is not a relation. Throws when a single-value relation branch
81
- * is missing `targetSeed` — that is a programmer error that must surface at
82
- * DDL time rather than producing silently invalid SQL.
83
- *
84
- * Multi-relation branches (multiple: true) produce NO column on the parent
85
- * table; they live in a dedicated junction table. Returns '' for them.
86
- */
87
- function buildForeignKeyClause(branch) {
88
- if (branch.type !== 'relation')
89
- return '';
90
- // Many-to-many branches: no column on parent table — see generateJunctionTable()
91
- if (branch.multiple === true)
92
- return '';
93
- if (!branch.targetSeed) {
94
- throw new Error(`Branch "${branch.alias}" is of type 'relation' but has no targetSeed`);
95
- }
96
- const onDeleteRule = branch.onDelete ?? DEFAULT_ON_DELETE_RULE;
97
- return ` REFERENCES content_${branch.targetSeed}(id) ON DELETE ${onDeleteRule}`;
98
- }
99
- // ---- DDL Generators ----
100
- /**
101
- * Generates `CREATE TABLE IF NOT EXISTS content_{slug}` with system columns
102
- * + one column per Branch. Pure function: same Seed → same SQL.
103
- */
104
- export function generateCreateTable(seed) {
105
- const table = tableName(seed);
106
- const lines = [
107
- `CREATE TABLE IF NOT EXISTS ${table} (`,
108
- ` id TEXT NOT NULL PRIMARY KEY,`,
109
- ` slug TEXT NOT NULL UNIQUE,`,
110
- ` status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'review', 'published', 'archived')),`,
111
- ];
112
- for (const branch of seed.branches) {
113
- // Many-to-many relation branches produce no column on the parent table;
114
- // they are represented by a dedicated junction table. See generateJunctionTable().
115
- if (branch.type === 'relation' && branch.multiple === true)
116
- continue;
117
- const { sqlType } = BRANCH_TYPE_SQL[branch.type];
118
- let col = ` ${branch.alias} ${sqlType}`;
119
- if (branch.requiredOnCreate)
120
- col += ' NOT NULL';
121
- if (branch.type === 'boolean')
122
- col += ` CHECK (${branch.alias} IN (0, 1))`;
123
- col += buildForeignKeyClause(branch);
124
- lines.push(col + ',');
125
- }
126
- lines.push(` created_at INTEGER NOT NULL DEFAULT (unixepoch()),`);
127
- lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`);
128
- lines.push(`);`);
129
- return lines.join('\n');
130
- }
131
- /**
132
- * Generates the drafts table `content_{slug}_drafts` for Seeds with `allowDrafts: true`.
133
- * All branch columns are nullable (drafts are partial).
134
- * Returns null if the Seed does not have `allowDrafts: true`.
135
- */
136
- export function generateDraftTable(seed) {
137
- if (!seed.allowDrafts)
138
- return null;
139
- const table = `content_${seed.slug}_drafts`;
140
- const mainTable = `content_${seed.slug}`;
141
- const lines = [
142
- `CREATE TABLE IF NOT EXISTS ${table} (`,
143
- ` entry_id TEXT NOT NULL PRIMARY KEY`,
144
- ` REFERENCES ${mainTable}(id) ON DELETE CASCADE,`,
145
- ];
146
- for (const branch of seed.branches) {
147
- // Many-to-many relation branches mirror in their own junction drafts table.
148
- if (branch.type === 'relation' && branch.multiple === true)
149
- continue;
150
- const { sqlType } = BRANCH_TYPE_SQL[branch.type];
151
- let col = ` ${branch.alias} ${sqlType}`;
152
- // boolean CHECK: in SQLite, NULL IN (0,1) → NULL, which passes the CHECK (only FALSE fails it)
153
- if (branch.type === 'boolean')
154
- col += ` CHECK (${branch.alias} IN (0, 1))`;
155
- lines.push(col + ',');
156
- }
157
- lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`);
158
- lines.push(`);`);
159
- return lines.join('\n');
160
- }
161
- /**
162
- * Generates `ALTER TABLE content_{slug} ADD COLUMN {alias} {type}`.
163
- * New columns are always nullable (SQLite limit on ALTER TABLE).
164
- */
165
- export function generateAddColumn(seed, branch) {
166
- const { sqlType } = BRANCH_TYPE_SQL[branch.type];
167
- return `ALTER TABLE ${tableName(seed)} ADD COLUMN ${branch.alias} ${sqlType}${buildForeignKeyClause(branch)};`;
168
- }
169
- /**
170
- * Generates B-tree indexes for status, created_at and every filterable Branch
171
- * with an indexable type (text, number, date, boolean).
172
- */
173
- export function generateIndexes(seed) {
174
- const table = tableName(seed);
175
- const slug = seed.slug;
176
- const indexes = [
177
- `CREATE INDEX IF NOT EXISTS idx_${slug}_status ON ${table}(status);`,
178
- `CREATE INDEX IF NOT EXISTS idx_${slug}_created_at ON ${table}(created_at);`,
179
- ];
180
- for (const branch of seed.branches) {
181
- // Multi-relation branches have no column on the parent table; their indexes
182
- // live on the junction table. See generateJunctionIndexes().
183
- if (branch.type === 'relation' && branch.multiple === true)
184
- continue;
185
- const isRelation = branch.type === 'relation';
186
- // Relation indexes are a system concern (JOIN performance) — bypass editorial filter policy
187
- if (!isRelation && branch.policies?.filter === false)
188
- continue;
189
- if (['text', 'number', 'date', 'boolean', 'relation'].includes(branch.type)) {
190
- indexes.push(`CREATE INDEX IF NOT EXISTS idx_${slug}_${branch.alias} ON ${table}(${branch.alias});`);
191
- }
192
- }
193
- return indexes;
194
- }
195
- /**
196
- * Generates the FTS5 virtual table for indexable text/richtext Branches.
197
- * Returns null if the Seed has no branches with search enabled.
198
- */
199
- export function generateFtsTable(seed) {
200
- const rtBranches = indexableSearchBranches(seed);
201
- if (rtBranches.length === 0)
202
- return null;
203
- const ftsTable = ftsTableName(seed);
204
- const cols = rtBranches.map(b => ` ${b.alias}`).join(',\n');
205
- return [
206
- `CREATE VIRTUAL TABLE IF NOT EXISTS ${ftsTable} USING fts5(`,
207
- ` entry_id UNINDEXED,`,
208
- `${cols},`,
209
- ` tokenize = 'unicode61'`,
210
- `);`,
211
- ].join('\n');
212
- }
213
- /**
214
- * Generates the 3 SQLite triggers (insert/update/delete) that keep the FTS
215
- * automatically synchronized — eliminates the need for manual syncFts.
216
- * Returns an empty array if the Seed has no indexable branches.
217
- */
218
- export function generateFtsTriggers(seed) {
219
- const rtBranches = indexableSearchBranches(seed);
220
- if (rtBranches.length === 0)
221
- return [];
222
- const table = tableName(seed);
223
- const ftsTable = ftsTableName(seed);
224
- const slug = seed.slug;
225
- const cols = rtBranches.map(b => b.alias);
226
- const ftsColList = ['entry_id', ...cols].join(', ');
227
- const newValList = ['new.id', ...cols.map(c => `new.${c}`)].join(', ');
228
- return [
229
- [
230
- `CREATE TRIGGER IF NOT EXISTS fts_${slug}_insert`,
231
- `AFTER INSERT ON ${table} BEGIN`,
232
- ` INSERT INTO ${ftsTable}(${ftsColList}) VALUES (${newValList});`,
233
- `END;`,
234
- ].join('\n'),
235
- [
236
- `CREATE TRIGGER IF NOT EXISTS fts_${slug}_update`,
237
- `AFTER UPDATE OF ${cols.join(', ')} ON ${table} BEGIN`,
238
- ` DELETE FROM ${ftsTable} WHERE entry_id = old.id;`,
239
- ` INSERT INTO ${ftsTable}(${ftsColList}) VALUES (${newValList});`,
240
- `END;`,
241
- ].join('\n'),
242
- [
243
- `CREATE TRIGGER IF NOT EXISTS fts_${slug}_delete`,
244
- `AFTER DELETE ON ${table} BEGIN`,
245
- ` DELETE FROM ${ftsTable} WHERE entry_id = old.id;`,
246
- `END;`,
247
- ].join('\n'),
248
- ];
249
- }
250
- // ---- Query Builder ----
251
- /**
252
- * Builds a parameterized SELECT on `content_{slug}`.
253
- * Never uses json_extract — every column is a real column.
254
- * Unknown columns in filters/orderBy are ignored (fail-closed).
255
- */
256
- //TODO: buildSelectQuery ha troppe responsabilità, va suddiviso in più funzioni
257
- export function buildSelectQuery(seed, options = {}) {
258
- const table = tableName(seed);
259
- const { filters = [], orderBy, pagination, status, search, fields } = options;
260
- const bindings = [];
261
- const whereClauses = [];
262
- let joinClause = '';
263
- // FTS JOIN — solo se il seed ha branch indicizzabili
264
- const rtBranches = indexableSearchBranches(seed);
265
- if (search && rtBranches.length > 0) {
266
- const ftsTable = ftsTableName(seed);
267
- joinClause = `INNER JOIN ${ftsTable} ON ${ftsTable}.entry_id = ${table}.id`;
268
- whereClauses.push(`${ftsTable} MATCH ?`);
269
- // FTS5: prefix match con quote per caratteri speciali
270
- bindings.push(`"${search.replace(/"/g, '""')}"*`);
271
- }
272
- // Filtro status
273
- if (status !== undefined && status !== null) {
274
- whereClauses.push(`${table}.status = ?`);
275
- bindings.push(status);
276
- }
277
- // Filtri utente
278
- for (const group of filters) {
279
- if (!isValidColumn(seed, group.column))
280
- continue;
281
- const col = SYSTEM_COLUMNS.has(group.column)
282
- ? `${table}.${group.column}`
283
- : group.column;
284
- for (const cond of group.conditions) {
285
- const clause = buildFilterCondition(col, group.type, cond, bindings);
286
- if (clause)
287
- whereClauses.push(clause);
288
- }
289
- }
290
- // Proiezione colonne
291
- let selectCols = `${table}.*`;
292
- if (fields && fields.length > 0) {
293
- const valid = fields.filter(f => isValidColumn(seed, f));
294
- if (valid.length > 0) {
295
- selectCols = valid
296
- .map(f => (SYSTEM_COLUMNS.has(f) ? `${table}.${f}` : f))
297
- .join(', ');
298
- }
299
- }
300
- let sql = `SELECT ${selectCols} FROM ${table}`;
301
- if (joinClause)
302
- sql += ` ${joinClause}`;
303
- if (whereClauses.length > 0)
304
- sql += ` WHERE ${whereClauses.join(' AND ')}`;
305
- // ORDER BY
306
- if (orderBy && isValidColumn(seed, orderBy.column)) {
307
- const dir = orderBy.dir === 'DESC' ? 'DESC' : 'ASC';
308
- const col = SYSTEM_COLUMNS.has(orderBy.column)
309
- ? `${table}.${orderBy.column}`
310
- : orderBy.column;
311
- sql += ` ORDER BY ${col} ${dir}`;
312
- }
313
- else {
314
- sql += ` ORDER BY ${table}.created_at DESC`;
315
- }
316
- // Paginazione
317
- if (pagination) {
318
- sql += ` LIMIT ? OFFSET ?`;
319
- bindings.push(pagination.limit, pagination.offset);
320
- }
321
- return { sql, bindings };
322
- }
323
- //TODO: normalizeFilterValue ha troppe responsabilità, va suddiviso in più funzioni
324
- function normalizeFilterValue(type, value) {
325
- if (value === null || value === undefined)
326
- return null;
327
- if (type === 'date') {
328
- if (typeof value === 'number')
329
- return value;
330
- if (typeof value === 'string' && value.trim() !== '') {
331
- const d = new Date(value);
332
- return Number.isNaN(d.getTime()) ? null : Math.floor(d.getTime() / 1000);
333
- }
334
- return null;
335
- }
336
- if (type === 'boolean') {
337
- return value ? 1 : 0;
338
- }
339
- if (type === 'number') {
340
- if (typeof value === 'number')
341
- return value;
342
- if (typeof value === 'string' && value.trim() !== '') {
343
- const n = Number(value);
344
- return Number.isNaN(n) ? null : n;
345
- }
346
- return null;
347
- }
348
- return value;
349
- }
350
- //TODO: buildFilterCondition ha troppe responsabilità, va suddiviso in più funzioni
351
- function buildFilterCondition(col, type, cond, bindings) {
352
- const { op, value } = cond;
353
- if (op === 'is_empty') {
354
- return type === 'text' ? `(${col} IS NULL OR ${col} = '')` : `${col} IS NULL`;
355
- }
356
- if (op === 'is_not_empty') {
357
- return type === 'text' ? `(${col} IS NOT NULL AND ${col} != '')` : `${col} IS NOT NULL`;
358
- }
359
- if (op === 'in' || op === 'not_in') {
360
- if (!Array.isArray(value) || value.length === 0)
361
- return null;
362
- const normalizedValues = value
363
- .map((v) => normalizeFilterValue(type, v))
364
- .filter((v) => v !== null);
365
- if (normalizedValues.length === 0)
366
- return null;
367
- const placeholders = normalizedValues.map(() => '?').join(', ');
368
- bindings.push(...normalizedValues);
369
- return `${col} ${op === 'in' ? 'IN' : 'NOT IN'} (${placeholders})`;
370
- }
371
- if (op === 'has_tag' || op === 'has_any_tag' || op === 'has_all_tags') {
372
- if (type !== 'tags' && type !== 'json')
373
- return null;
374
- const tags = (op === 'has_tag' ? [value] : Array.isArray(value) ? value : [value])
375
- .map(t => String(t));
376
- if (tags.length === 0)
377
- return null;
378
- if (op === 'has_tag' || op === 'has_any_tag') {
379
- const placeholders = tags.map(() => '?').join(', ');
380
- bindings.push(...tags);
381
- return `EXISTS (SELECT 1 FROM json_each(${col}) WHERE value IN (${placeholders}))`;
382
- }
383
- // has_all_tags: each tag must exist
384
- const clauses = tags.map(() => `EXISTS (SELECT 1 FROM json_each(${col}) WHERE value = ?)`);
385
- bindings.push(...tags);
386
- return `(${clauses.join(' AND ')})`;
387
- }
388
- const normalized = normalizeFilterValue(type, value);
389
- if (normalized === null)
390
- return null;
391
- if (op === 'eq' || op === 'neq') {
392
- const sqlOp = op === 'eq' ? '=' : '!=';
393
- bindings.push(normalized);
394
- return `${col} ${sqlOp} ?`;
395
- }
396
- if (op === 'contains' || op === 'not_contains' || op === 'starts_with' || op === 'ends_with') {
397
- const sqlOp = op === 'not_contains' ? 'NOT LIKE' : 'LIKE';
398
- let pattern = String(value);
399
- if (op === 'contains' || op === 'not_contains')
400
- pattern = `%${pattern}%`;
401
- else if (op === 'starts_with')
402
- pattern = `${pattern}%`;
403
- else if (op === 'ends_with')
404
- pattern = `%${pattern}`;
405
- bindings.push(pattern);
406
- return `${col} ${sqlOp} ?`;
407
- }
408
- const mathOps = { gt: '>', gte: '>=', lt: '<', lte: '<=' };
409
- if (mathOps[op]) {
410
- bindings.push(normalized);
411
- return `${col} ${mathOps[op]} ?`;
412
- }
413
- return null;
414
- }
415
- /**
416
- * Returns the list of expected columns for a Seed's table.
417
- * Used by `beech seed:load --diff` to compare current vs expected schema.
418
- */
419
- export function getExpectedColumns(seed) {
420
- return [
421
- { name: 'id', sqlType: 'TEXT', notNull: true, isPk: true },
422
- { name: 'slug', sqlType: 'TEXT', notNull: true, isPk: false },
423
- { name: 'status', sqlType: 'TEXT', notNull: true, isPk: false },
424
- // Multi-relation branches produce no column on the parent table.
425
- ...seed.branches
426
- .filter(b => !(b.type === 'relation' && b.multiple === true))
427
- .map(b => ({
428
- name: b.alias,
429
- sqlType: BRANCH_TYPE_SQL[b.type].sqlType,
430
- notNull: b.requiredOnCreate ?? false,
431
- isPk: false,
432
- })),
433
- { name: 'created_at', sqlType: 'INTEGER', notNull: true, isPk: false },
434
- { name: 'updated_at', sqlType: 'INTEGER', notNull: true, isPk: false },
435
- ];
436
- }
437
- // ---- Serialization / Deserialization ----
438
- /**
439
- * Serializes a value for writing to the DB.
440
- * boolean → 0/1 | date → Unix timestamp | json/asset-list → JSON string
441
- */
442
- //TODO: serializeForDb ha troppe responsabilità, va suddiviso in più funzioni
443
- export function serializeForDb(branch, value) {
444
- if (value === null || value === undefined)
445
- return null;
446
- switch (branch.type) {
447
- case 'boolean':
448
- return value ? 1 : 0;
449
- case 'json':
450
- case 'tags':
451
- case 'richtext':
452
- return typeof value === 'string' ? value : JSON.stringify(value);
453
- case 'date': {
454
- if (typeof value === 'number')
455
- return value;
456
- if (typeof value === 'string') {
457
- const d = new Date(value);
458
- if (Number.isNaN(d.getTime()))
459
- return null;
460
- if (branch.format === 'date') {
461
- const midnight = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
462
- return Math.floor(midnight / 1000);
463
- }
464
- return Math.floor(d.getTime() / 1000);
465
- }
466
- return null;
467
- }
468
- case 'file':
469
- if (isAssetListBranch(branch)) {
470
- return Array.isArray(value) ? JSON.stringify(value) : typeof value === 'string' ? value : null;
471
- }
472
- return typeof value === 'string' ? value : null;
473
- case 'repeater':
474
- // Non-array input is rejected by serializing as an empty list — validation.ts
475
- // is responsible for ever letting a non-array repeater value reach here.
476
- return JSON.stringify(Array.isArray(value) ? value : []);
477
- default:
478
- return typeof value === 'string' ? value : typeof value === 'number' ? value : null;
479
- }
480
- }
481
- // ---- Junction table DDL (many-to-many relations, Sprint 5) ----
482
- /**
483
- * Returns the junction table name for a many-to-many relation branch.
484
- * Format: `rel_<seedSlug>_<branchAlias>`.
485
- */
486
- export function junctionTableName(seedSlug, branchAlias) {
487
- return `rel_${seedSlug}_${branchAlias}`;
488
- }
489
- /**
490
- * Generates `CREATE TABLE IF NOT EXISTS rel_<seed>_<alias>` for a multi-relation
491
- * branch. The parent FK always cascades (parent lifecycle owns the edge);
492
- * the target FK uses `branch.onDelete` (default CASCADE).
493
- *
494
- * @throws {Error} when called on a non-multi-relation branch
495
- * @throws {Error} when `branch.targetSeed` is missing
496
- */
497
- export function generateJunctionTable(seed, branch) {
498
- if (branch.type !== 'relation' || branch.multiple !== true) {
499
- throw new Error(`Branch "${branch.alias}" is not a multi-relation`);
500
- }
501
- if (!branch.targetSeed) {
502
- throw new Error(`Branch "${branch.alias}" has no targetSeed`);
503
- }
504
- const onDeleteRule = branch.onDelete ?? 'CASCADE';
505
- const table = junctionTableName(seed.slug, branch.alias);
506
- return [
507
- `CREATE TABLE IF NOT EXISTS ${table} (`,
508
- ` parent_id TEXT NOT NULL REFERENCES content_${seed.slug}(id) ON DELETE CASCADE,`,
509
- ` target_id TEXT NOT NULL REFERENCES content_${branch.targetSeed}(id) ON DELETE ${onDeleteRule},`,
510
- ` position INTEGER NOT NULL DEFAULT 0,`,
511
- ` created_at INTEGER NOT NULL DEFAULT (unixepoch()),`,
512
- ` PRIMARY KEY (parent_id, target_id)`,
513
- `);`,
514
- ].join('\n');
515
- }
516
- /**
517
- * Generates the two B-tree indexes for a junction table:
518
- * one on `parent_id` (list entries by parent) and one on `target_id`
519
- * (cascade checks from target side).
520
- */
521
- export function generateJunctionIndexes(seed, branch) {
522
- const table = junctionTableName(seed.slug, branch.alias);
523
- return [
524
- `CREATE INDEX IF NOT EXISTS idx_${table}_parent ON ${table}(parent_id);`,
525
- `CREATE INDEX IF NOT EXISTS idx_${table}_target ON ${table}(target_id);`,
526
- ];
527
- }
528
- /**
529
- * Generates the drafts junction table `rel_<seed>_<alias>_drafts` for a
530
- * many-to-many relation branch. Note: no FK on `target_id` — targets may be
531
- * deleted between draft save and publish; the FK would be wrong here.
532
- * Returns null when `seed.allowDrafts` is falsy.
533
- */
534
- export function generateJunctionDraftTable(seed, branch) {
535
- if (!seed.allowDrafts)
536
- return null;
537
- if (branch.type !== 'relation' || branch.multiple !== true)
538
- return null;
539
- const table = `${junctionTableName(seed.slug, branch.alias)}_drafts`;
540
- const mainDraftTable = `content_${seed.slug}_drafts`;
541
- return [
542
- `CREATE TABLE IF NOT EXISTS ${table} (`,
543
- ` entry_id TEXT NOT NULL REFERENCES ${mainDraftTable}(entry_id) ON DELETE CASCADE,`,
544
- ` target_id TEXT NOT NULL,`,
545
- ` position INTEGER NOT NULL DEFAULT 0,`,
546
- ` PRIMARY KEY (entry_id, target_id)`,
547
- `);`,
548
- ].join('\n');
549
- }
550
- // ---- Destructive DDL Generators (Sprint 06 — Danger Zone) ----
551
- //
552
- // Every generator below emits IRREVERSIBLE, data-destroying SQL. They are pure
553
- // string builders (no DB access); the actual execution goes through
554
- // ISchemaMutator's dedicated destructive methods (dropTable/dropColumn/
555
- // renameColumn/execDestructive), which re-validate every identifier before
556
- // interpolation. These statements MUST NEVER be passed to execDdl (additive-only).
557
- function draftTableName(seed) {
558
- return `content_${seed.slug}_drafts`;
559
- }
560
- /**
561
- * Returns every `DROP TABLE IF EXISTS` needed to fully remove a content type:
562
- * the main `content_{slug}` table, its `fts_{slug}` virtual table, the
563
- * `content_{slug}_drafts` mirror (when drafts are enabled), and each
564
- * multi-relation junction table `rel_{slug}_{alias}` (+ its `_drafts` mirror).
565
- *
566
- * FTS triggers reference the main table; dropping the main table also drops
567
- * its triggers in SQLite, but we drop the FTS table explicitly so no orphaned
568
- * virtual table survives. Order: junction (children) → drafts → fts → main.
569
- */
570
- export function generateDropTable(seed) {
571
- const stmts = [];
572
- // Junction tables first (they FK-reference the main/draft tables).
573
- for (const branch of seed.branches) {
574
- if (branch.type !== 'relation' || branch.multiple !== true)
575
- continue;
576
- const jt = junctionTableName(seed.slug, branch.alias);
577
- stmts.push(`DROP TABLE IF EXISTS ${jt}_drafts;`);
578
- stmts.push(`DROP TABLE IF EXISTS ${jt};`);
579
- }
580
- if (seed.allowDrafts) {
581
- stmts.push(`DROP TABLE IF EXISTS ${draftTableName(seed)};`);
582
- }
583
- // FTS triggers + virtual table.
584
- if (indexableSearchBranches(seed).length > 0) {
585
- const slug = seed.slug;
586
- stmts.push(`DROP TRIGGER IF EXISTS fts_${slug}_insert;`);
587
- stmts.push(`DROP TRIGGER IF EXISTS fts_${slug}_update;`);
588
- stmts.push(`DROP TRIGGER IF EXISTS fts_${slug}_delete;`);
589
- stmts.push(`DROP TABLE IF EXISTS ${ftsTableName(seed)};`);
590
- }
591
- stmts.push(`DROP TABLE IF EXISTS ${tableName(seed)};`);
592
- return stmts;
593
- }
594
- /**
595
- * Returns the `DROP COLUMN` statements for removing a single field.
596
- *
597
- * - For a multi-relation branch (no column on the parent table) it drops the
598
- * junction table `rel_{slug}_{alias}` (+ `_drafts`) instead.
599
- * - For a normal branch it drops the column from `content_{slug}` and, when
600
- * drafts are enabled, the mirror column from `content_{slug}_drafts`.
601
- * - For an orphan column (no matching branch in the definition) it falls back
602
- * to a plain `ALTER TABLE … DROP COLUMN`, never touching drafts.
603
- *
604
- * Callers must rebuild FTS separately (planFtsRebuild) when the dropped column
605
- * was searchable, since SQLite cannot ALTER an fts5 table's columns.
606
- */
607
- export function generateDropColumn(seed, alias) {
608
- const branch = seed.branches.find(b => b.alias === alias);
609
- if (branch && branch.type === 'relation' && branch.multiple === true) {
610
- const jt = junctionTableName(seed.slug, alias);
611
- return [
612
- `DROP TABLE IF EXISTS ${jt}_drafts;`,
613
- `DROP TABLE IF EXISTS ${jt};`,
614
- ];
615
- }
616
- const stmts = [`ALTER TABLE ${tableName(seed)} DROP COLUMN ${alias};`];
617
- // Drop the mirror column from the drafts table when the branch lives there.
618
- if (branch && seed.allowDrafts) {
619
- stmts.push(`ALTER TABLE ${draftTableName(seed)} DROP COLUMN ${alias};`);
620
- }
621
- return stmts;
622
- }
623
- /**
624
- * Returns the `RENAME COLUMN` statements for renaming a field's alias while
625
- * keeping the stable `branch.id`. Renames the column on `content_{slug}` and,
626
- * when drafts are enabled, the mirror column on `content_{slug}_drafts`.
627
- *
628
- * For a multi-relation branch the alias is part of the junction table name, so
629
- * the junction table (+ `_drafts`) is renamed instead of a column.
630
- *
631
- * Callers must rebuild FTS separately (planFtsRebuild) when the renamed column
632
- * was searchable.
633
- */
634
- export function generateRenameColumn(seed, from, to) {
635
- const branch = seed.branches.find(b => b.alias === from);
636
- if (branch && branch.type === 'relation' && branch.multiple === true) {
637
- const oldJt = junctionTableName(seed.slug, from);
638
- const newJt = junctionTableName(seed.slug, to);
639
- const stmts = [`ALTER TABLE ${oldJt} RENAME TO ${newJt};`];
640
- if (seed.allowDrafts) {
641
- stmts.push(`ALTER TABLE ${oldJt}_drafts RENAME TO ${newJt}_drafts;`);
642
- }
643
- return stmts;
644
- }
645
- const stmts = [`ALTER TABLE ${tableName(seed)} RENAME COLUMN ${from} TO ${to};`];
646
- if (branch && seed.allowDrafts) {
647
- stmts.push(`ALTER TABLE ${draftTableName(seed)} RENAME COLUMN ${from} TO ${to};`);
648
- }
649
- return stmts;
650
- }
651
- /**
652
- * Returns the add/copy/drop/rename statements that change a column's SQL type
653
- * in place, preserving data via `CAST`. This is the documented simpler
654
- * alternative to SQLite's full 12-step table rebuild.
655
- *
656
- * The `branch` passed in is the *target* definition (already carrying the new
657
- * `type`); its SQL type is read from BRANCH_TYPE_SQL. Multi-relation branches
658
- * have no column to retype and are rejected. Mirrors the change onto the drafts
659
- * table when drafts are enabled. Callers rebuild FTS separately when needed.
660
- */
661
- export function generateRetypeColumn(seed, branch) {
662
- if (branch.type === 'relation' && branch.multiple === true) {
663
- throw new Error(`Branch "${branch.alias}" is a multi-relation and has no column to retype`);
664
- }
665
- const { sqlType } = BRANCH_TYPE_SQL[branch.type];
666
- const alias = branch.alias;
667
- const tmp = `__retype_${alias}`;
668
- const rebuild = (table) => [
669
- `ALTER TABLE ${table} ADD COLUMN ${tmp} ${sqlType};`,
670
- `UPDATE ${table} SET ${tmp} = CAST(${alias} AS ${sqlType});`,
671
- `ALTER TABLE ${table} DROP COLUMN ${alias};`,
672
- `ALTER TABLE ${table} RENAME COLUMN ${tmp} TO ${alias};`,
673
- ];
674
- const stmts = rebuild(tableName(seed));
675
- if (seed.allowDrafts)
676
- stmts.push(...rebuild(draftTableName(seed)));
677
- return stmts;
678
- }
679
- /**
680
- * Deserializes a value read from the DB for the API response.
681
- * 0/1 → boolean | Unix timestamp → ISO 8601 | JSON string → object
682
- */
683
- export function deserializeFromDb(branch, value) {
684
- // Repeater columns deserialize to [] (never null) — drafts and rows that
685
- // predate the column being added carry NULL, which is an empty list of items.
686
- if (branch.type === 'repeater') {
687
- if (typeof value !== 'string' || value.length === 0)
688
- return [];
689
- const parsed = parseJsonSafe(value);
690
- return Array.isArray(parsed) ? parsed : [];
691
- }
692
- if (value === null || value === undefined)
693
- return null;
694
- switch (branch.type) {
695
- case 'boolean':
696
- return value === 1 || value === true;
697
- case 'json':
698
- case 'tags':
699
- case 'richtext': {
700
- if (typeof value === 'string') {
701
- try {
702
- return JSON.parse(value);
703
- }
704
- catch {
705
- return value;
706
- }
707
- }
708
- return value;
709
- }
710
- case 'date': {
711
- if (typeof value !== 'number')
712
- return null;
713
- const d = new Date(value * 1000);
714
- return branch.format === 'date' ? d.toISOString().slice(0, 10) : d.toISOString();
715
- }
716
- case 'file':
717
- if (isAssetListBranch(branch))
718
- return normalizeAssetListValue(value);
719
- return typeof value === 'string' ? value : null;
720
- default:
721
- return value;
722
- }
723
- }