rip-lang 3.16.0 → 3.16.2
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/README.md +3 -4
- package/bin/rip +201 -18
- package/bin/rip-schema +175 -0
- package/docs/AGENTS.md +1 -1
- package/docs/RIP-APP.md +200 -19
- package/docs/RIP-DUCKDB.md +64 -1
- package/docs/RIP-INTRO.md +4 -4
- package/docs/RIP-LANG.md +36 -38
- package/docs/RIP-SCHEMA.md +1204 -364
- package/docs/RIP-TYPES.md +74 -103
- package/docs/demo/README.md +4 -3
- package/docs/dist/rip.js +4195 -966
- package/docs/dist/rip.min.js +1161 -284
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/example/index.json +7 -7
- package/docs/example/index.json.br +0 -0
- package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
- package/docs/extensions/vscode/print/print-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/index.html +2 -1
- package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
- package/docs/index.html +1 -1
- package/docs/ui/bundle.json +55 -55
- package/docs/ui/bundle.json.br +0 -0
- package/docs/ui/hljs-rip.js +1 -1
- package/docs/ui/index.html +1 -1
- package/package.json +15 -7
- package/rip-loader.js +59 -2
- package/src/AGENTS.md +43 -12
- package/src/browser.js +52 -11
- package/src/compiler.js +538 -80
- package/src/components.js +488 -48
- package/src/dts.js +80 -50
- package/src/grammar/README.md +29 -170
- package/src/grammar/grammar.rip +17 -12
- package/src/grammar/solar.rip +4 -17
- package/src/lexer.js +82 -32
- package/src/parser.js +229 -229
- package/src/schema/dts.js +328 -54
- package/src/schema/loader-server.js +2 -1
- package/src/schema/runtime-browser-stubs.js +20 -9
- package/src/schema/runtime-ddl.js +161 -44
- package/src/schema/runtime-migrate.js +681 -0
- package/src/schema/runtime-orm.js +698 -54
- package/src/schema/runtime-validate.js +808 -24
- package/src/schema/runtime.generated.js +2395 -135
- package/src/schema/schema.js +1054 -94
- package/src/typecheck.js +1610 -127
- package/src/types.js +90 -6
- package/src/grammar/lunar.rip +0 -2412
- /package/docs/demo/{components → routes}/_layout.rip +0 -0
- /package/docs/demo/{components → routes}/about.rip +0 -0
- /package/docs/demo/{components → routes}/card.rip +0 -0
- /package/docs/demo/{components → routes}/counter.rip +0 -0
- /package/docs/demo/{components → routes}/index.rip +0 -0
- /package/docs/demo/{components → routes}/todos.rip +0 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// Schema runtime fragment: migrate (migration only)
|
|
2
|
+
//
|
|
3
|
+
// This file is the source of truth for one slice of the schema runtime.
|
|
4
|
+
// Edit here, then run `bun run build:schema-runtime` to regenerate
|
|
5
|
+
// `src/schema/runtime.generated.js`. Tests pin the public surface via
|
|
6
|
+
// test/schema/errors.test.js, test/schema/modes.test.js, and the source
|
|
7
|
+
// schema test suite.
|
|
8
|
+
//
|
|
9
|
+
// Fragments are concatenated INSIDE one shared IIFE wrapper at build time.
|
|
10
|
+
// They share scope; references like `__SchemaRegistry` resolve to bindings
|
|
11
|
+
// defined in earlier-included fragments. Editor tooling (LSP / lint) may
|
|
12
|
+
// not recognize cross-fragment references — that is expected; behavior is
|
|
13
|
+
// pinned by the test suite.
|
|
14
|
+
|
|
15
|
+
/* eslint-disable no-undef, no-unused-vars */
|
|
16
|
+
// ---- Schema evolution: introspect → diff → status / make / migrate ----------
|
|
17
|
+
//
|
|
18
|
+
// `toSQL()` solves greenfield CREATE; this fragment solves evolution:
|
|
19
|
+
// diff the declared models against the deployed database and emit ALTER
|
|
20
|
+
// migrations, with history, checksums, and destructive-change gates.
|
|
21
|
+
//
|
|
22
|
+
// schema.plan() → classified diff steps (pure, no files)
|
|
23
|
+
// schema.status(opts) → { steps, applied, pending, mismatched }
|
|
24
|
+
// schema.make(name, opts) → write migrations/NNNN_name.sql from the diff
|
|
25
|
+
// schema.migrate(opts) → apply pending migration files in order
|
|
26
|
+
// schema.introspect() → DeployedSchema (canonical table specs)
|
|
27
|
+
//
|
|
28
|
+
// Migration FILES are plain SQL — numbered, hand-editable, checked into
|
|
29
|
+
// git. The generator writes them; humans may amend them; migrate()
|
|
30
|
+
// applies them and records (version, name, checksum, applied_at) in the
|
|
31
|
+
// `_rip_migrations` table. A checksum mismatch on an applied file aborts
|
|
32
|
+
// (someone edited history) unless {repair: true} re-records checksums.
|
|
33
|
+
|
|
34
|
+
const __SCHEMA_MIGRATIONS_TABLE = '_rip_migrations';
|
|
35
|
+
|
|
36
|
+
// ---- Row materializer ---------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function __schemaMigrateRows(res) {
|
|
39
|
+
const cols = (res.columns || []).map(c => c.name);
|
|
40
|
+
return (res.data || []).map(row => {
|
|
41
|
+
const obj = {};
|
|
42
|
+
for (let i = 0; i < cols.length; i++) obj[cols[i]] = row[i];
|
|
43
|
+
return obj;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- Introspection ------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
// Build the DeployedSchema — an array of canonical table specs in the
|
|
50
|
+
// same shape `_tableSpec()` produces — from the live database. Uses the
|
|
51
|
+
// adapter's `introspect()` capability when present (Contract v2);
|
|
52
|
+
// otherwise falls back to DuckDB catalog queries through `query()`.
|
|
53
|
+
async function __schemaIntrospect() {
|
|
54
|
+
if (typeof __schemaAdapter.introspect === 'function') {
|
|
55
|
+
return await __schemaAdapter.introspect();
|
|
56
|
+
}
|
|
57
|
+
const q = (sql) => __schemaRunSQL(null, sql, []);
|
|
58
|
+
const [tablesRes, columnsRes, constraintsRes, indexesRes, sequencesRes] = [
|
|
59
|
+
await q("SELECT table_name FROM information_schema.tables WHERE table_schema = 'main' AND table_type = 'BASE TABLE'"),
|
|
60
|
+
await q("SELECT table_name, column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'main' ORDER BY table_name, ordinal_position"),
|
|
61
|
+
await q("SELECT table_name, constraint_type, constraint_column_names, constraint_text FROM duckdb_constraints() WHERE schema_name = 'main'"),
|
|
62
|
+
await q("SELECT table_name, index_name, is_unique, expressions FROM duckdb_indexes() WHERE schema_name = 'main'"),
|
|
63
|
+
await q("SELECT sequence_name, start_value FROM duckdb_sequences() WHERE schema_name = 'main'"),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const tables = new Map();
|
|
67
|
+
for (const r of __schemaMigrateRows(tablesRes)) {
|
|
68
|
+
if (r.table_name === __SCHEMA_MIGRATIONS_TABLE) continue;
|
|
69
|
+
tables.set(r.table_name, {
|
|
70
|
+
name: r.table_name,
|
|
71
|
+
sequence: null,
|
|
72
|
+
primaryKey: null,
|
|
73
|
+
columns: [],
|
|
74
|
+
indexes: [],
|
|
75
|
+
foreignKeys: [],
|
|
76
|
+
tableWas: null,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const r of __schemaMigrateRows(columnsRes)) {
|
|
81
|
+
const t = tables.get(r.table_name);
|
|
82
|
+
if (!t) continue;
|
|
83
|
+
t.columns.push({
|
|
84
|
+
name: r.column_name,
|
|
85
|
+
type: r.data_type,
|
|
86
|
+
notNull: r.is_nullable === 'NO',
|
|
87
|
+
unique: false,
|
|
88
|
+
default: r.column_default != null && r.column_default !== '' ? r.column_default : null,
|
|
89
|
+
was: null,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// constraint_column_names arrives as a JSON array over harbor, or as a
|
|
94
|
+
// "[a, b]" string from other transports. Normalize to string[].
|
|
95
|
+
const listOf = (v) => {
|
|
96
|
+
if (Array.isArray(v)) return v.map(String);
|
|
97
|
+
if (typeof v === 'string') {
|
|
98
|
+
const inner = v.replace(/^\[/, '').replace(/\]$/, '').trim();
|
|
99
|
+
return inner ? inner.split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')) : [];
|
|
100
|
+
}
|
|
101
|
+
return [];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
for (const r of __schemaMigrateRows(constraintsRes)) {
|
|
105
|
+
const t = tables.get(r.table_name);
|
|
106
|
+
if (!t) continue;
|
|
107
|
+
const cols = listOf(r.constraint_column_names);
|
|
108
|
+
if (r.constraint_type === 'PRIMARY KEY' && cols.length === 1) {
|
|
109
|
+
t.primaryKey = cols[0];
|
|
110
|
+
const col = t.columns.find(c => c.name === cols[0]);
|
|
111
|
+
if (col) col.primary = true;
|
|
112
|
+
} else if (r.constraint_type === 'UNIQUE' && cols.length === 1) {
|
|
113
|
+
const col = t.columns.find(c => c.name === cols[0]);
|
|
114
|
+
if (col) col.unique = true;
|
|
115
|
+
} else if (r.constraint_type === 'FOREIGN KEY') {
|
|
116
|
+
const m = String(r.constraint_text || '').match(/FOREIGN KEY\s*\(([^)]+)\)\s*REFERENCES\s+(\S+?)\s*\(([^)]+)\)/i);
|
|
117
|
+
if (m) {
|
|
118
|
+
t.foreignKeys.push({ column: m[1].trim(), refTable: m[2].trim(), refColumn: m[3].trim() });
|
|
119
|
+
} else if (cols.length === 1) {
|
|
120
|
+
t.foreignKeys.push({ column: cols[0], refTable: null, refColumn: null });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const r of __schemaMigrateRows(indexesRes)) {
|
|
126
|
+
const t = tables.get(r.table_name);
|
|
127
|
+
if (!t) continue;
|
|
128
|
+
t.indexes.push({
|
|
129
|
+
name: r.index_name,
|
|
130
|
+
columns: listOf(r.expressions),
|
|
131
|
+
unique: r.is_unique === true || r.is_unique === 'true',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const r of __schemaMigrateRows(sequencesRes)) {
|
|
136
|
+
// Attach by the `<table>_seq` naming convention the DDL emitter uses.
|
|
137
|
+
const tableName = String(r.sequence_name).replace(/_seq$/, '');
|
|
138
|
+
const t = tables.get(tableName);
|
|
139
|
+
if (t && String(r.sequence_name).endsWith('_seq')) {
|
|
140
|
+
t.sequence = { name: r.sequence_name, start: Number(r.start_value) };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { tables: [...tables.values()] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Canonical declared schema: one table spec per registered :model.
|
|
148
|
+
function __schemaCanonicalDeclared() {
|
|
149
|
+
const tables = [];
|
|
150
|
+
for (const [, entry] of __SchemaRegistry._entries) {
|
|
151
|
+
if (entry.kind !== 'model') continue;
|
|
152
|
+
tables.push(entry.def._tableSpec());
|
|
153
|
+
}
|
|
154
|
+
return { tables };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- Comparison normalizers ----------------------------------------------------
|
|
158
|
+
|
|
159
|
+
// DuckDB does not persist VARCHAR length hints, and reports several type
|
|
160
|
+
// aliases under canonical names. Compare under those equivalences.
|
|
161
|
+
const __SCHEMA_TYPE_ALIASES = {
|
|
162
|
+
'TEXT': 'VARCHAR', 'CHARACTER VARYING': 'VARCHAR', 'CHAR': 'VARCHAR', 'BPCHAR': 'VARCHAR', 'STRING': 'VARCHAR',
|
|
163
|
+
'INT': 'INTEGER', 'INT4': 'INTEGER', 'SIGNED': 'INTEGER',
|
|
164
|
+
'INT8': 'BIGINT', 'LONG': 'BIGINT',
|
|
165
|
+
'FLOAT8': 'DOUBLE', 'DOUBLE PRECISION': 'DOUBLE',
|
|
166
|
+
'BOOL': 'BOOLEAN', 'LOGICAL': 'BOOLEAN',
|
|
167
|
+
'DATETIME': 'TIMESTAMP', 'TIMESTAMP WITHOUT TIME ZONE': 'TIMESTAMP',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
function __schemaTypeKey(t) {
|
|
171
|
+
let k = String(t || '').toUpperCase().replace(/\(.*\)\s*$/, '').trim();
|
|
172
|
+
return __SCHEMA_TYPE_ALIASES[k] || k;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Tolerant default comparison: deployed defaults round-trip through the
|
|
176
|
+
// catalog with cosmetic differences (CAST wrappers, now() for
|
|
177
|
+
// CURRENT_TIMESTAMP, case). Don't emit ALTERs for representation noise.
|
|
178
|
+
function __schemaDefaultKey(d) {
|
|
179
|
+
if (d == null) return '';
|
|
180
|
+
let s = String(d).trim();
|
|
181
|
+
const cast = s.match(/^CAST\s*\(\s*(.*?)\s+AS\s+[A-Za-z0-9_ ()]+\)$/i);
|
|
182
|
+
if (cast) s = cast[1].trim();
|
|
183
|
+
s = s.toLowerCase();
|
|
184
|
+
if (s === 'now()' || s === 'current_timestamp()' || s === 'get_current_timestamp()') s = 'current_timestamp';
|
|
185
|
+
return s;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Fold the `#`-modifier pattern: a UNIQUE column plus its auto-named
|
|
189
|
+
// single-column unique index (`idx_<table>_<col>`) count as ONE fact —
|
|
190
|
+
// the column's unique flag. Applies to both sides so the differ never
|
|
191
|
+
// sees the pair as two separate diffs.
|
|
192
|
+
function __schemaFoldSpec(spec) {
|
|
193
|
+
const columnsByName = new Map(spec.columns.map(c => [c.name, c]));
|
|
194
|
+
const indexes = [];
|
|
195
|
+
for (const ix of spec.indexes) {
|
|
196
|
+
const autoName = ix.columns.length === 1 && ix.name === 'idx_' + spec.name + '_' + ix.columns[0];
|
|
197
|
+
if (ix.unique && autoName) {
|
|
198
|
+
const col = columnsByName.get(ix.columns[0]);
|
|
199
|
+
if (col) { col.unique = true; continue; }
|
|
200
|
+
}
|
|
201
|
+
indexes.push(ix);
|
|
202
|
+
}
|
|
203
|
+
return { ...spec, indexes };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- The differ -----------------------------------------------------------------
|
|
207
|
+
//
|
|
208
|
+
// Returns classified steps:
|
|
209
|
+
//
|
|
210
|
+
// { table, kind, class: 'safe' | 'lossy' | 'destructive' | 'blocked',
|
|
211
|
+
// sql: [statements/comments], notes: [strings] }
|
|
212
|
+
//
|
|
213
|
+
// Classes gate generation (`make` refuses lossy/destructive without the
|
|
214
|
+
// matching allow flag, and refuses `blocked` outright); the printed plan
|
|
215
|
+
// always shows everything.
|
|
216
|
+
//
|
|
217
|
+
// DuckDB ALTER constraints shape several decisions:
|
|
218
|
+
// - ADD COLUMN cannot carry NOT NULL / UNIQUE / REFERENCES → required
|
|
219
|
+
// adds become add + (backfill) + SET NOT NULL; unique adds get a
|
|
220
|
+
// separate CREATE UNIQUE INDEX; FK constraints cannot be added to an
|
|
221
|
+
// existing table at all (note emitted).
|
|
222
|
+
// - A table referenced by another table's FOREIGN KEY is frozen for
|
|
223
|
+
// everything except ADD COLUMN and index DDL ("Dependency Error:
|
|
224
|
+
// cannot alter entry") — even DROP TABLE … CASCADE is refused.
|
|
225
|
+
// Steps that hit this wall classify as `blocked`: the change
|
|
226
|
+
// requires dropping/rebuilding the referencing tables around it.
|
|
227
|
+
// - No SAVEPOINT / ALTER SEQUENCE RESTART → sequence-start drift is a
|
|
228
|
+
// note, not a step.
|
|
229
|
+
|
|
230
|
+
// Step kinds DuckDB executes even when the table is FK-referenced.
|
|
231
|
+
const __SCHEMA_UNBLOCKED_KINDS = new Set([
|
|
232
|
+
'create-table', 'add-column', 'create-index', 'drop-index', 'note-fk',
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
// Mark steps that DuckDB will refuse because the target table is
|
|
236
|
+
// referenced by other tables' FOREIGN KEYs.
|
|
237
|
+
function __schemaApplyFkBlocks(steps, deployed) {
|
|
238
|
+
const referencedBy = new Map(); // table → [child.fkColumn, ...]
|
|
239
|
+
for (const t of deployed.tables) {
|
|
240
|
+
for (const fk of t.foreignKeys) {
|
|
241
|
+
if (!fk.refTable) continue;
|
|
242
|
+
if (!referencedBy.has(fk.refTable)) referencedBy.set(fk.refTable, []);
|
|
243
|
+
referencedBy.get(fk.refTable).push(t.name + '.' + fk.column);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const s of steps) {
|
|
247
|
+
if (__SCHEMA_UNBLOCKED_KINDS.has(s.kind)) continue;
|
|
248
|
+
const refs = referencedBy.get(s.table) ||
|
|
249
|
+
(s.kind === 'rename-table' && s.sql[0] ? referencedBy.get((s.sql[0].match(/^ALTER TABLE (\S+) RENAME TO/) || [])[1]) : null);
|
|
250
|
+
if (!refs || !refs.length) continue;
|
|
251
|
+
s.class = 'blocked';
|
|
252
|
+
s.notes.push(
|
|
253
|
+
'DuckDB refuses this ALTER while ' + refs.join(', ') + ' reference(s) this table ' +
|
|
254
|
+
'("Dependency Error"). Rebuild the referencing table(s) around this change, or ' +
|
|
255
|
+
'apply it manually with the referencing tables dropped and recreated.');
|
|
256
|
+
}
|
|
257
|
+
return steps;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function __schemaDiff(declared, deployed) {
|
|
261
|
+
const steps = [];
|
|
262
|
+
const dTables = new Map(declared.tables.map(t => [t.name, __schemaFoldSpec(t)]));
|
|
263
|
+
const pTables = new Map(deployed.tables.map(t => [t.name, __schemaFoldSpec(t)]));
|
|
264
|
+
|
|
265
|
+
// Table renames first: declared table missing from deployed, with a
|
|
266
|
+
// @tableWas pointing at a deployed table that no declared model claims.
|
|
267
|
+
for (const [name, d] of dTables) {
|
|
268
|
+
if (pTables.has(name) || !d.tableWas) continue;
|
|
269
|
+
const old = pTables.get(d.tableWas);
|
|
270
|
+
if (old && !dTables.has(d.tableWas)) {
|
|
271
|
+
steps.push({
|
|
272
|
+
table: name, kind: 'rename-table', class: 'safe',
|
|
273
|
+
sql: ['ALTER TABLE ' + d.tableWas + ' RENAME TO ' + name + ';'],
|
|
274
|
+
notes: ["@tableWas " + d.tableWas + " can be removed once this migration lands"],
|
|
275
|
+
});
|
|
276
|
+
pTables.delete(d.tableWas);
|
|
277
|
+
pTables.set(name, { ...old, name });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Matched tables next: column / index / FK diffs. Alters run BEFORE
|
|
282
|
+
// create-table steps on purpose — a new child table's FOREIGN KEY
|
|
283
|
+
// freezes its parent the moment it exists, so a migration that both
|
|
284
|
+
// alters `orders` and creates `invoices REFERENCES orders` must alter
|
|
285
|
+
// first.
|
|
286
|
+
for (const [name, d] of dTables) {
|
|
287
|
+
const p = pTables.get(name);
|
|
288
|
+
if (!p) continue;
|
|
289
|
+
__schemaDiffTable(d, p, steps);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// New tables.
|
|
293
|
+
for (const [name, d] of dTables) {
|
|
294
|
+
if (pTables.has(name)) continue;
|
|
295
|
+
steps.push({
|
|
296
|
+
table: name, kind: 'create-table', class: 'safe',
|
|
297
|
+
sql: __schemaRenderCreate(d),
|
|
298
|
+
notes: [],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Dropped tables (deployed but not declared) — the "someone ran manual
|
|
303
|
+
// SQL" detector doubles as the model-deletion path. Destructive.
|
|
304
|
+
for (const [name, p] of pTables) {
|
|
305
|
+
if (dTables.has(name)) continue;
|
|
306
|
+
const sql = ['DROP TABLE ' + name + ';'];
|
|
307
|
+
if (p.sequence) sql.push('DROP SEQUENCE ' + p.sequence.name + ';');
|
|
308
|
+
steps.push({ table: name, kind: 'drop-table', class: 'destructive', sql, notes: [] });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return __schemaApplyFkBlocks(steps, deployed);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function __schemaDiffTable(d, p, steps) {
|
|
315
|
+
const t = d.name;
|
|
316
|
+
const dCols = new Map(d.columns.map(c => [c.name, c]));
|
|
317
|
+
const pCols = new Map(p.columns.map(c => [c.name, c]));
|
|
318
|
+
|
|
319
|
+
// Column renames: declared column missing from deployed whose `was`
|
|
320
|
+
// names a deployed column that no declared column claims.
|
|
321
|
+
for (const [name, col] of dCols) {
|
|
322
|
+
if (pCols.has(name) || !col.was) continue;
|
|
323
|
+
const old = pCols.get(col.was);
|
|
324
|
+
if (old && !dCols.has(col.was)) {
|
|
325
|
+
steps.push({
|
|
326
|
+
table: t, kind: 'rename-column', class: 'safe',
|
|
327
|
+
sql: ['ALTER TABLE ' + t + ' RENAME COLUMN ' + col.was + ' TO ' + name + ';'],
|
|
328
|
+
notes: ['{was: "' + col.was + '"} on ' + name + ' can be removed once this migration lands'],
|
|
329
|
+
});
|
|
330
|
+
pCols.delete(col.was);
|
|
331
|
+
pCols.set(name, { ...old, name });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Added columns.
|
|
336
|
+
for (const [name, col] of dCols) {
|
|
337
|
+
if (pCols.has(name)) continue;
|
|
338
|
+
const sql = [];
|
|
339
|
+
const notes = [];
|
|
340
|
+
let cls = 'safe';
|
|
341
|
+
// DuckDB: ADD COLUMN cannot carry constraints. DEFAULT is allowed
|
|
342
|
+
// (and backfills existing rows), so add with the default when one
|
|
343
|
+
// is declared, then tighten with SET NOT NULL.
|
|
344
|
+
let add = 'ALTER TABLE ' + t + ' ADD COLUMN ' + name + ' ' + col.type;
|
|
345
|
+
if (col.default != null) add += ' DEFAULT ' + col.default;
|
|
346
|
+
sql.push(add + ';');
|
|
347
|
+
if (col.notNull) {
|
|
348
|
+
if (col.default == null) {
|
|
349
|
+
sql.push("-- TODO: backfill " + t + "." + name + " before SET NOT NULL (required column, no default)");
|
|
350
|
+
}
|
|
351
|
+
sql.push('ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' SET NOT NULL;');
|
|
352
|
+
}
|
|
353
|
+
if (col.unique) {
|
|
354
|
+
sql.push('CREATE UNIQUE INDEX idx_' + t + '_' + name + ' ON ' + t + ' ("' + name + '");');
|
|
355
|
+
}
|
|
356
|
+
const fk = d.foreignKeys.find(f => f.column === name);
|
|
357
|
+
if (fk) {
|
|
358
|
+
notes.push('DuckDB cannot add FOREIGN KEY constraints to an existing table; ' +
|
|
359
|
+
name + ' -> ' + fk.refTable + '(' + fk.refColumn + ') is unenforced until the table is recreated');
|
|
360
|
+
}
|
|
361
|
+
steps.push({ table: t, kind: 'add-column', class: cls, sql, notes });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Dropped columns.
|
|
365
|
+
for (const [name] of pCols) {
|
|
366
|
+
if (dCols.has(name)) continue;
|
|
367
|
+
steps.push({
|
|
368
|
+
table: t, kind: 'drop-column', class: 'destructive',
|
|
369
|
+
sql: ['ALTER TABLE ' + t + ' DROP COLUMN ' + name + ';'],
|
|
370
|
+
notes: [],
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Altered columns.
|
|
375
|
+
for (const [name, dc] of dCols) {
|
|
376
|
+
const pc = pCols.get(name);
|
|
377
|
+
if (!pc) continue;
|
|
378
|
+
if (dc.primary || pc.primary) continue; // pk shape is fixed (INTEGER + nextval)
|
|
379
|
+
if (__schemaTypeKey(dc.type) !== __schemaTypeKey(pc.type)) {
|
|
380
|
+
steps.push({
|
|
381
|
+
table: t, kind: 'alter-type', class: 'lossy',
|
|
382
|
+
sql: ['ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' TYPE ' + dc.type + ';'],
|
|
383
|
+
notes: [pc.type + ' -> ' + dc.type + ' casts existing values; rows that cannot cast will fail the migration'],
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
if (dc.notNull !== pc.notNull) {
|
|
387
|
+
if (dc.notNull) {
|
|
388
|
+
steps.push({
|
|
389
|
+
table: t, kind: 'set-not-null', class: 'lossy',
|
|
390
|
+
sql: ['ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' SET NOT NULL;'],
|
|
391
|
+
notes: ['fails if existing rows hold NULLs — backfill first'],
|
|
392
|
+
});
|
|
393
|
+
} else {
|
|
394
|
+
steps.push({
|
|
395
|
+
table: t, kind: 'drop-not-null', class: 'safe',
|
|
396
|
+
sql: ['ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' DROP NOT NULL;'],
|
|
397
|
+
notes: [],
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (__schemaDefaultKey(dc.default) !== __schemaDefaultKey(pc.default)) {
|
|
402
|
+
steps.push({
|
|
403
|
+
table: t, kind: 'alter-default', class: 'safe',
|
|
404
|
+
sql: [dc.default != null
|
|
405
|
+
? 'ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' SET DEFAULT ' + dc.default + ';'
|
|
406
|
+
: 'ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' DROP DEFAULT;'],
|
|
407
|
+
notes: [],
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (dc.unique !== pc.unique) {
|
|
411
|
+
if (dc.unique) {
|
|
412
|
+
steps.push({
|
|
413
|
+
table: t, kind: 'add-unique', class: 'lossy',
|
|
414
|
+
sql: ['CREATE UNIQUE INDEX idx_' + t + '_' + name + ' ON ' + t + ' ("' + name + '");'],
|
|
415
|
+
notes: ['fails if existing rows hold duplicates'],
|
|
416
|
+
});
|
|
417
|
+
} else {
|
|
418
|
+
steps.push({
|
|
419
|
+
table: t, kind: 'drop-unique', class: 'safe',
|
|
420
|
+
sql: ['DROP INDEX IF EXISTS idx_' + t + '_' + name + ';'],
|
|
421
|
+
notes: ['a UNIQUE declared inline in CREATE TABLE cannot be dropped by index name; recreate the table if this fails'],
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Index diffs (auto-unique indexes already folded into column flags).
|
|
428
|
+
const dIdx = new Map(d.indexes.map(i => [i.name, i]));
|
|
429
|
+
const pIdx = new Map(p.indexes.map(i => [i.name, i]));
|
|
430
|
+
for (const [name, ix] of dIdx) {
|
|
431
|
+
const ex = pIdx.get(name);
|
|
432
|
+
if (ex && ex.unique === ix.unique &&
|
|
433
|
+
ex.columns.join(',') === ix.columns.join(',')) continue;
|
|
434
|
+
const sql = [];
|
|
435
|
+
if (ex) sql.push('DROP INDEX ' + name + ';');
|
|
436
|
+
sql.push(__schemaRenderIndex(d, ix));
|
|
437
|
+
steps.push({
|
|
438
|
+
table: t, kind: 'create-index', class: ix.unique ? 'lossy' : 'safe',
|
|
439
|
+
sql,
|
|
440
|
+
notes: ix.unique ? ['unique index creation fails if existing rows hold duplicates'] : [],
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
for (const [name] of pIdx) {
|
|
444
|
+
if (dIdx.has(name)) continue;
|
|
445
|
+
steps.push({
|
|
446
|
+
table: t, kind: 'drop-index', class: 'safe',
|
|
447
|
+
sql: ['DROP INDEX ' + name + ';'],
|
|
448
|
+
notes: [],
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// FK diffs are notes only — DuckDB has no ALTER TABLE ADD/DROP CONSTRAINT.
|
|
453
|
+
const pFks = new Set(p.foreignKeys.map(f => f.column));
|
|
454
|
+
for (const fk of d.foreignKeys) {
|
|
455
|
+
if (pFks.has(fk.column) || !pCols.has(fk.column)) continue;
|
|
456
|
+
steps.push({
|
|
457
|
+
table: t, kind: 'note-fk', class: 'safe',
|
|
458
|
+
sql: ['-- NOTE: ' + t + '.' + fk.column + ' should reference ' + fk.refTable + '(' + fk.refColumn + ') ' +
|
|
459
|
+
'but DuckDB cannot add FK constraints to an existing table'],
|
|
460
|
+
notes: [],
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---- Plan rendering --------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
function __schemaRenderPlan(steps) {
|
|
468
|
+
const lines = [];
|
|
469
|
+
for (const s of steps) {
|
|
470
|
+
lines.push('-- [' + s.class + '] ' + s.kind + ' ' + s.table);
|
|
471
|
+
for (const n of s.notes) lines.push('-- ' + n);
|
|
472
|
+
lines.push(...s.sql);
|
|
473
|
+
lines.push('');
|
|
474
|
+
}
|
|
475
|
+
return lines.join('\n');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---- Migration files & history ------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
const __SCHEMA_MIGRATION_FILE_RE = /^(\d{4,})_(.+)\.sql$/;
|
|
481
|
+
|
|
482
|
+
async function __schemaMigrationFiles(dir) {
|
|
483
|
+
const fs = await import('node:fs');
|
|
484
|
+
const path = await import('node:path');
|
|
485
|
+
const crypto = await import('node:crypto');
|
|
486
|
+
if (!fs.existsSync(dir)) return [];
|
|
487
|
+
const out = [];
|
|
488
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
489
|
+
const m = f.match(__SCHEMA_MIGRATION_FILE_RE);
|
|
490
|
+
if (!m) continue;
|
|
491
|
+
const content = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
492
|
+
out.push({
|
|
493
|
+
version: m[1],
|
|
494
|
+
name: m[2],
|
|
495
|
+
file: path.join(dir, f),
|
|
496
|
+
checksum: crypto.createHash('sha256').update(content).digest('hex'),
|
|
497
|
+
content,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function __schemaAppliedMigrations() {
|
|
504
|
+
try {
|
|
505
|
+
const res = await __schemaRunSQL(null,
|
|
506
|
+
'SELECT version, name, checksum, applied_at FROM ' + __SCHEMA_MIGRATIONS_TABLE + ' ORDER BY version', []);
|
|
507
|
+
return __schemaMigrateRows(res);
|
|
508
|
+
} catch (e) {
|
|
509
|
+
// History table doesn't exist yet — nothing applied. Anything else
|
|
510
|
+
// (connection refused, auth) should propagate.
|
|
511
|
+
if (/does not exist|Catalog Error/i.test(e?.message || '')) return [];
|
|
512
|
+
throw e;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function __schemaEnsureMigrationsTable() {
|
|
517
|
+
await __schemaRunSQL(null,
|
|
518
|
+
'CREATE TABLE IF NOT EXISTS ' + __SCHEMA_MIGRATIONS_TABLE +
|
|
519
|
+
' (version VARCHAR PRIMARY KEY, name VARCHAR, checksum VARCHAR, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)', []);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Split a migration file into statements: ';' terminates, except inside
|
|
523
|
+
// single-quoted strings; `--` line comments pass through attached to the
|
|
524
|
+
// following statement (so a leading TODO comment is visible in errors but
|
|
525
|
+
// never executed alone). Pure-comment / empty fragments are dropped.
|
|
526
|
+
function __schemaSplitStatements(sql) {
|
|
527
|
+
const out = [];
|
|
528
|
+
let cur = '';
|
|
529
|
+
let inString = false;
|
|
530
|
+
let inComment = false;
|
|
531
|
+
for (let i = 0; i < sql.length; i++) {
|
|
532
|
+
const ch = sql[i];
|
|
533
|
+
if (inComment) {
|
|
534
|
+
cur += ch;
|
|
535
|
+
if (ch === '\n') inComment = false;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (inString) {
|
|
539
|
+
cur += ch;
|
|
540
|
+
if (ch === "'") {
|
|
541
|
+
if (sql[i + 1] === "'") { cur += "'"; i++; }
|
|
542
|
+
else inString = false;
|
|
543
|
+
}
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (ch === "'") { inString = true; cur += ch; continue; }
|
|
547
|
+
if (ch === '-' && sql[i + 1] === '-') { inComment = true; cur += ch; continue; }
|
|
548
|
+
if (ch === ';') {
|
|
549
|
+
out.push(cur);
|
|
550
|
+
cur = '';
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
cur += ch;
|
|
554
|
+
}
|
|
555
|
+
if (cur.trim()) out.push(cur);
|
|
556
|
+
// Strip comment-only / empty fragments; keep executable text intact.
|
|
557
|
+
return out
|
|
558
|
+
.map(s => s.trim())
|
|
559
|
+
.filter(s => s && s.split('\n').some(line => {
|
|
560
|
+
const l = line.trim();
|
|
561
|
+
return l && !l.startsWith('--');
|
|
562
|
+
}));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---- Public functions ----------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
async function __schemaPlan() {
|
|
568
|
+
const declared = __schemaCanonicalDeclared();
|
|
569
|
+
if (!declared.tables.length) {
|
|
570
|
+
throw new Error('schema.plan(): no :model schemas are registered — import your model files first');
|
|
571
|
+
}
|
|
572
|
+
const deployed = await __schemaIntrospect();
|
|
573
|
+
return __schemaDiff(declared, deployed);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function __schemaStatus(opts = {}) {
|
|
577
|
+
const dir = opts.dir || 'migrations';
|
|
578
|
+
const steps = await __schemaPlan();
|
|
579
|
+
const files = await __schemaMigrationFiles(dir);
|
|
580
|
+
const applied = await __schemaAppliedMigrations();
|
|
581
|
+
const appliedByVersion = new Map(applied.map(a => [a.version, a]));
|
|
582
|
+
const pending = files.filter(f => !appliedByVersion.has(f.version));
|
|
583
|
+
const mismatched = files.filter(f => {
|
|
584
|
+
const a = appliedByVersion.get(f.version);
|
|
585
|
+
return a && a.checksum !== f.checksum;
|
|
586
|
+
}).map(f => f.version + '_' + f.name);
|
|
587
|
+
return { steps, files, applied, pending, mismatched };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function __schemaMake(name, opts = {}) {
|
|
591
|
+
if (!name || typeof name !== 'string') {
|
|
592
|
+
throw new Error("schema.make(name): a migration name is required, e.g. schema.make('add_orders')");
|
|
593
|
+
}
|
|
594
|
+
const dir = opts.dir || 'migrations';
|
|
595
|
+
const steps = await __schemaPlan();
|
|
596
|
+
if (!steps.length) return null;
|
|
597
|
+
|
|
598
|
+
const blocked = steps.filter(s => s.class === 'blocked');
|
|
599
|
+
if (blocked.length) {
|
|
600
|
+
const list = blocked.map(s => ' [blocked] ' + s.kind + ' ' + s.table + '\n ' + s.notes.join('\n ')).join('\n');
|
|
601
|
+
throw new Error(
|
|
602
|
+
'schema.make: the plan contains steps DuckDB cannot execute while foreign keys reference the table:\n' +
|
|
603
|
+
list + '\nThese need a manual rebuild of the referencing tables; no flag overrides this.');
|
|
604
|
+
}
|
|
605
|
+
const gated = [];
|
|
606
|
+
for (const s of steps) {
|
|
607
|
+
if (s.class === 'lossy' && !opts.allowLossy) gated.push(s);
|
|
608
|
+
if (s.class === 'destructive' && !opts.allowDestructive) gated.push(s);
|
|
609
|
+
}
|
|
610
|
+
if (gated.length) {
|
|
611
|
+
const list = gated.map(s => ' [' + s.class + '] ' + s.kind + ' ' + s.table).join('\n');
|
|
612
|
+
throw new Error(
|
|
613
|
+
'schema.make: the plan contains gated steps:\n' + list +
|
|
614
|
+
'\nPass {allowLossy: true} / {allowDestructive: true} (CLI: --allow-lossy / --allow-destructive) to include them.');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const fs = await import('node:fs');
|
|
618
|
+
const path = await import('node:path');
|
|
619
|
+
const files = await __schemaMigrationFiles(dir);
|
|
620
|
+
const next = files.length ? Math.max(...files.map(f => parseInt(f.version, 10))) + 1 : 1;
|
|
621
|
+
const version = String(next).padStart(4, '0');
|
|
622
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'migration';
|
|
623
|
+
const file = path.join(dir, version + '_' + slug + '.sql');
|
|
624
|
+
|
|
625
|
+
const body =
|
|
626
|
+
'-- ' + version + '_' + slug + '.sql\n' +
|
|
627
|
+
'-- Generated by `rip schema make` — review (and edit) before applying.\n' +
|
|
628
|
+
'-- Apply with `rip schema migrate`.\n\n' +
|
|
629
|
+
__schemaRenderPlan(steps);
|
|
630
|
+
|
|
631
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
632
|
+
fs.writeFileSync(file, body);
|
|
633
|
+
return { file, version, steps };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function __schemaMigrate(opts = {}) {
|
|
637
|
+
const dir = opts.dir || 'migrations';
|
|
638
|
+
const files = await __schemaMigrationFiles(dir);
|
|
639
|
+
await __schemaEnsureMigrationsTable();
|
|
640
|
+
const applied = await __schemaAppliedMigrations();
|
|
641
|
+
const appliedByVersion = new Map(applied.map(a => [a.version, a]));
|
|
642
|
+
|
|
643
|
+
// History integrity: an applied file whose content changed is an
|
|
644
|
+
// edited-history error — abort unless {repair: true} re-records.
|
|
645
|
+
for (const f of files) {
|
|
646
|
+
const a = appliedByVersion.get(f.version);
|
|
647
|
+
if (!a || a.checksum === f.checksum) continue;
|
|
648
|
+
if (opts.repair) {
|
|
649
|
+
await __schemaRunSQL(null,
|
|
650
|
+
'UPDATE ' + __SCHEMA_MIGRATIONS_TABLE + ' SET checksum = ? WHERE version = ?',
|
|
651
|
+
[f.checksum, f.version]);
|
|
652
|
+
} else {
|
|
653
|
+
throw new Error(
|
|
654
|
+
'schema.migrate: checksum mismatch on applied migration ' + f.version + '_' + f.name +
|
|
655
|
+
' — the file changed after it was applied. Restore the original file, or re-record with {repair: true} (CLI: --repair).');
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const pending = files.filter(f => !appliedByVersion.has(f.version));
|
|
660
|
+
const ran = [];
|
|
661
|
+
for (const f of pending) {
|
|
662
|
+
const statements = __schemaSplitStatements(f.content);
|
|
663
|
+
const apply = async () => {
|
|
664
|
+
for (const stmt of statements) {
|
|
665
|
+
await __schemaRunSQL(null, stmt, []);
|
|
666
|
+
}
|
|
667
|
+
await __schemaRunSQL(null,
|
|
668
|
+
'INSERT INTO ' + __SCHEMA_MIGRATIONS_TABLE + ' (version, name, checksum) VALUES (?, ?, ?)',
|
|
669
|
+
[f.version, f.name, f.checksum]);
|
|
670
|
+
};
|
|
671
|
+
// Transactional apply when the adapter supports it — a failed
|
|
672
|
+
// statement leaves neither earlier statements nor the history row.
|
|
673
|
+
if (typeof __schemaAdapter.begin === 'function') {
|
|
674
|
+
await __schemaTransaction(apply);
|
|
675
|
+
} else {
|
|
676
|
+
await apply();
|
|
677
|
+
}
|
|
678
|
+
ran.push(f.version + '_' + f.name);
|
|
679
|
+
}
|
|
680
|
+
return { ran, pending: [] };
|
|
681
|
+
}
|