spark-ssr 0.1.1 → 0.3.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.
package/src/schema.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The template is the schema (§7). The framework already knows `todo.title`
3
+ * is text (it's interpolated), `todo.done` is boolean (a checkbox bind), and
4
+ * `user_id` means scoping — so nobody writes setup.js.
5
+ *
6
+ * bun spark-ssr db show inferred schema vs live DB (a diff)
7
+ * bun spark-ssr db push create/alter tables to match the templates
8
+ *
9
+ * serve() also runs the safe half automatically at startup: CREATE missing
10
+ * tables and apply idempotent seeds, so `bun spark-ssr` works on a fresh
11
+ * clone. Columns are never dropped without --force.
12
+ *
13
+ * Column sources, weakest to strongest:
14
+ * id / created_at always
15
+ * {todo.title} interpolations TEXT
16
+ * bind:checked="todo.done" INTEGER (boolean)
17
+ * <input name=… type=…> in a form type-mapped
18
+ * seed file rows keys + value types
19
+ * user_id auth configured + the page reads session
20
+ */
21
+ import { join, resolve } from 'node:path';
22
+ import { existsSync, readFileSync } from 'node:fs';
23
+
24
+ const INPUT_TYPE = {
25
+ checkbox: 'INTEGER', number: 'REAL', range: 'REAL',
26
+ date: 'TEXT', time: 'TEXT', 'datetime-local': 'TEXT',
27
+ };
28
+ const valueType = (v) =>
29
+ typeof v === 'boolean' ? 'INTEGER'
30
+ : typeof v === 'number' ? (Number.isInteger(v) ? 'INTEGER' : 'REAL')
31
+ : 'TEXT';
32
+
33
+ const RESERVED = new Set(['id', 'user_id', 'created_at']);
34
+
35
+ /**
36
+ * Infer every declared table's columns from the parsed pages.
37
+ * pagesData: [{ blocks, analysis, plan, forms }]; config: loaded spark.json.
38
+ * Returns { table: { columns: { name: TYPE }, seed: path|null } }.
39
+ */
40
+ export function inferSchema(pagesData, config, root) {
41
+ const tables = {};
42
+ const ensure = (name) => (tables[name] ||= { columns: {}, seed: null });
43
+ const setCol = (t, col, type, force = false) => {
44
+ if (RESERVED.has(col)) return;
45
+ if (!/^[a-zA-Z_]\w*$/.test(col)) return;
46
+ if (force || !t.columns[col]) t.columns[col] = type;
47
+ };
48
+
49
+ const authTable = config.auth && config.auth.table;
50
+
51
+ for (const pd of pagesData) {
52
+ // Which template vars map to which table on this page.
53
+ const varTable = {};
54
+ for (const p of pd.plan || []) {
55
+ if (p.source.kind === 'table') varTable[p.var] = p.source.table;
56
+ }
57
+ const usesSession = pd.analysis && (pd.analysis.needs.has('session')
58
+ || (pd.blocks || []).some((b) => b.guard));
59
+
60
+ for (const b of pd.blocks || []) {
61
+ if (!b.table) continue;
62
+ const t = ensure(b.table);
63
+ if (b.seed && !t.seed) t.seed = b.seed;
64
+ if (config.auth && b.table !== authTable && usesSession) t.scoped = true;
65
+
66
+ // {var.field} interpolations for vars fed by this table → TEXT.
67
+ const a = pd.analysis;
68
+ if (a) {
69
+ const roots = [
70
+ ...Object.entries(varTable).filter(([, tb]) => tb === b.table).map(([v]) => v),
71
+ ];
72
+ // Loop vars over those roots read fields too: each="todo in todos".
73
+ for (const [lv, src] of a.loopSources || []) {
74
+ if (roots.includes(src)) roots.push(lv);
75
+ }
76
+ for (const r of roots) {
77
+ for (const f of a.memberFields.get(r) || []) setCol(t, f, 'TEXT');
78
+ }
79
+ // bind kinds are stronger: a checkbox bind is a boolean column.
80
+ for (const rb of a.rowBinds || []) {
81
+ if (roots.includes(rb.loopVar)) {
82
+ setCol(t, rb.field, rb.kind === 'checked' ? 'INTEGER' : 'TEXT', rb.kind === 'checked');
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ }
89
+
90
+ // The auth table always exists once auth is configured: its identity
91
+ // column and a password.
92
+ if (authTable) {
93
+ const t = ensure(authTable);
94
+ setCol(t, config.auth.identity || 'email', 'TEXT');
95
+ setCol(t, 'password', 'TEXT');
96
+ delete t.scoped; // never scope the auth table to itself
97
+ }
98
+
99
+ // Forms posting to /api/<table>: the inputs name and type the columns —
100
+ // but only for tables a block declared. A form to /api/logout (or any
101
+ // custom endpoint) is not a table declaration. Second pass, so a form on
102
+ // one page reaches a table declared on another.
103
+ for (const pd of pagesData) {
104
+ for (const form of pd.forms || []) {
105
+ if (!form.table || !tables[form.table]) continue;
106
+ const t = tables[form.table];
107
+ for (const [name, rules] of Object.entries(form.fields)) {
108
+ setCol(t, name, INPUT_TYPE[rules.type] || 'TEXT', rules.type in INPUT_TYPE);
109
+ }
110
+ }
111
+ }
112
+
113
+ // Seed rows are the strongest signal: real keys, real value types.
114
+ for (const [name, t] of Object.entries(tables)) {
115
+ if (!t.seed) continue;
116
+ try {
117
+ const file = resolve(root, t.seed.replace(/^\.\//, ''));
118
+ if (!file.startsWith(root) || !existsSync(file)) continue;
119
+ const rows = JSON.parse(readFileSync(file, 'utf8'));
120
+ for (const row of Array.isArray(rows) ? rows.slice(0, 1) : []) {
121
+ for (const [k, v] of Object.entries(row)) setCol(t, k, valueType(v), true);
122
+ }
123
+ if (Array.isArray(rows) && rows.some((r) => 'user_id' in r)) t.scoped = true;
124
+ } catch { /* unreadable seed — diff will say so */ }
125
+ }
126
+
127
+ return tables;
128
+ }
129
+
130
+ const q = (name) => name; // identifiers come from templates the author wrote
131
+
132
+ function createSql(table, spec, kind, withScope) {
133
+ const cols = [
134
+ kind === 'postgres'
135
+ ? 'id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY'
136
+ : 'id INTEGER PRIMARY KEY AUTOINCREMENT',
137
+ ];
138
+ if (withScope) cols.push('user_id INTEGER');
139
+ for (const [name, type] of Object.entries(spec.columns)) {
140
+ cols.push(`${q(name)} ${type === 'REAL' && kind === 'postgres' ? 'DOUBLE PRECISION' : type}`);
141
+ }
142
+ cols.push('created_at TEXT DEFAULT CURRENT_TIMESTAMP');
143
+ return `CREATE TABLE ${q(table)} (\n ${cols.join(',\n ')}\n)`;
144
+ }
145
+
146
+ /**
147
+ * Diff the inferred schema against the live database.
148
+ * Returns [{ table, create?, add:[{name,type}], extra:[name] }].
149
+ */
150
+ export async function diffSchema(db, schema) {
151
+ const out = [];
152
+ for (const [table, spec] of Object.entries(schema)) {
153
+ const live = await db.columns(table);
154
+ const withScope = !!spec.scoped;
155
+ if (!live.length) {
156
+ out.push({ table, create: createSql(table, spec, db.kind, withScope), add: [], extra: [] });
157
+ continue;
158
+ }
159
+ const liveNames = new Set(live.map((c) => c.name));
160
+ const want = { ...(withScope ? { user_id: 'INTEGER' } : {}), ...spec.columns, created_at: 'TEXT' };
161
+ const add = Object.entries(want)
162
+ .filter(([n]) => !liveNames.has(n))
163
+ .map(([name, type]) => ({ name, type }));
164
+ const wantNames = new Set(['id', ...Object.keys(want)]);
165
+ const extra = [...liveNames].filter((n) => !wantNames.has(n));
166
+ if (add.length || extra.length) out.push({ table, add, extra });
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * Apply the diff: CREATE missing tables, ADD missing columns. Extra columns
173
+ * are only dropped with force (and never id/user_id).
174
+ */
175
+ export async function pushSchema(db, schema, { force = false, createOnly = false, log = () => {} } = {}) {
176
+ const diff = await diffSchema(db, schema);
177
+ for (const d of diff) {
178
+ if (d.create) {
179
+ await db.query(d.create);
180
+ log(`created table ${d.table}`);
181
+ continue;
182
+ }
183
+ if (createOnly) continue;
184
+ for (const col of d.add) {
185
+ await db.query(`ALTER TABLE ${q(d.table)} ADD COLUMN ${q(col.name)} ${col.type}`);
186
+ log(`${d.table}: added ${col.name} ${col.type}`);
187
+ }
188
+ for (const col of d.extra) {
189
+ if (!force) { log(`${d.table}: column ${col} is not in the templates (kept — use --force to drop)`); continue; }
190
+ if (col === 'id' || col === 'user_id') continue;
191
+ await db.query(`ALTER TABLE ${q(d.table)} DROP COLUMN ${q(col)}`);
192
+ log(`${d.table}: dropped ${col}`);
193
+ }
194
+ }
195
+ return diff;
196
+ }
197
+
198
+ /**
199
+ * Seed declared tables from their seed="…" files — once, idempotently: only
200
+ * when the table is empty. Auth-table passwords hash unless already hashed.
201
+ */
202
+ export async function seedTables(db, schema, config, root, log = () => {}) {
203
+ const authTable = config.auth && config.auth.table;
204
+ for (const [table, spec] of Object.entries(schema)) {
205
+ if (!spec.seed) continue;
206
+ const file = resolve(root, spec.seed.replace(/^\.\//, ''));
207
+ if (!file.startsWith(root) || !existsSync(file)) continue;
208
+ let rows;
209
+ try { rows = JSON.parse(readFileSync(file, 'utf8')); } catch { continue; }
210
+ if (!Array.isArray(rows) || !rows.length) continue;
211
+ const count = await db.query(`SELECT COUNT(*) AS n FROM ${q(table)}`);
212
+ if (Number(count[0]?.n ?? count[0]?.count ?? 0) > 0) continue;
213
+ for (const row of rows) {
214
+ const data = { ...row };
215
+ if (table === authTable && typeof data.password === 'string' && !data.password.startsWith('$')) {
216
+ data.password = await Bun.password.hash(data.password);
217
+ }
218
+ const keys = Object.keys(data);
219
+ await db.query(
220
+ `INSERT INTO ${q(table)} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
221
+ keys.map((k) => data[k]),
222
+ );
223
+ }
224
+ log(`seeded ${table} (${rows.length} rows) from ${spec.seed}`);
225
+ }
226
+ }