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.
Files changed (57) hide show
  1. package/README.md +3 -4
  2. package/bin/rip +201 -18
  3. package/bin/rip-schema +175 -0
  4. package/docs/AGENTS.md +1 -1
  5. package/docs/RIP-APP.md +200 -19
  6. package/docs/RIP-DUCKDB.md +64 -1
  7. package/docs/RIP-INTRO.md +4 -4
  8. package/docs/RIP-LANG.md +36 -38
  9. package/docs/RIP-SCHEMA.md +1204 -364
  10. package/docs/RIP-TYPES.md +74 -103
  11. package/docs/demo/README.md +4 -3
  12. package/docs/dist/rip.js +4195 -966
  13. package/docs/dist/rip.min.js +1161 -284
  14. package/docs/dist/rip.min.js.br +0 -0
  15. package/docs/example/index.json +7 -7
  16. package/docs/example/index.json.br +0 -0
  17. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  18. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  19. package/docs/extensions/vscode/rip/index.html +2 -1
  20. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  21. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  22. package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
  23. package/docs/index.html +1 -1
  24. package/docs/ui/bundle.json +55 -55
  25. package/docs/ui/bundle.json.br +0 -0
  26. package/docs/ui/hljs-rip.js +1 -1
  27. package/docs/ui/index.html +1 -1
  28. package/package.json +15 -7
  29. package/rip-loader.js +59 -2
  30. package/src/AGENTS.md +43 -12
  31. package/src/browser.js +52 -11
  32. package/src/compiler.js +538 -80
  33. package/src/components.js +488 -48
  34. package/src/dts.js +80 -50
  35. package/src/grammar/README.md +29 -170
  36. package/src/grammar/grammar.rip +17 -12
  37. package/src/grammar/solar.rip +4 -17
  38. package/src/lexer.js +82 -32
  39. package/src/parser.js +229 -229
  40. package/src/schema/dts.js +328 -54
  41. package/src/schema/loader-server.js +2 -1
  42. package/src/schema/runtime-browser-stubs.js +20 -9
  43. package/src/schema/runtime-ddl.js +161 -44
  44. package/src/schema/runtime-migrate.js +681 -0
  45. package/src/schema/runtime-orm.js +698 -54
  46. package/src/schema/runtime-validate.js +808 -24
  47. package/src/schema/runtime.generated.js +2395 -135
  48. package/src/schema/schema.js +1054 -94
  49. package/src/typecheck.js +1610 -127
  50. package/src/types.js +90 -6
  51. package/src/grammar/lunar.rip +0 -2412
  52. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  53. /package/docs/demo/{components → routes}/about.rip +0 -0
  54. /package/docs/demo/{components → routes}/card.rip +0 -0
  55. /package/docs/demo/{components → routes}/counter.rip +0 -0
  56. /package/docs/demo/{components → routes}/index.rip +0 -0
  57. /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
+ }