rip-lang 3.15.4 → 3.16.1

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 (112) hide show
  1. package/README.md +6 -4
  2. package/bin/rip +167 -12
  3. package/docs/AGENTS.md +1 -1
  4. package/docs/RIP-APP.md +808 -0
  5. package/docs/RIP-DUCKDB.md +477 -0
  6. package/docs/RIP-INTRO.md +396 -0
  7. package/docs/RIP-LANG.md +59 -5
  8. package/docs/RIP-SCHEMA.md +191 -8
  9. package/docs/RIP-TYPES.md +74 -103
  10. package/docs/demo/README.md +4 -3
  11. package/docs/dist/rip.js +3627 -1470
  12. package/docs/dist/rip.min.js +671 -244
  13. package/docs/dist/rip.min.js.br +0 -0
  14. package/docs/example/index.json +7 -7
  15. package/docs/example/index.json.br +0 -0
  16. package/docs/extensions/duckdb/manifest.json +1 -1
  17. package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
  18. package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
  19. package/docs/extensions/vscode/print/index.html +2 -1
  20. package/docs/extensions/vscode/print/print-1.0.13.vsix +0 -0
  21. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  22. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  23. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  24. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  25. package/docs/ui/bundle.json +61 -0
  26. package/docs/ui/bundle.json.br +0 -0
  27. package/docs/ui/hljs-rip.js +0 -7
  28. package/docs/ui/index.css +66 -23
  29. package/docs/ui/index.html +6 -6
  30. package/package.json +9 -3
  31. package/rip-loader.js +64 -2
  32. package/src/AGENTS.md +63 -36
  33. package/src/browser.js +96 -14
  34. package/src/compiler.js +960 -143
  35. package/src/components.js +794 -88
  36. package/src/{types-emit.js → dts.js} +181 -71
  37. package/src/grammar/README.md +1 -1
  38. package/src/grammar/grammar.rip +111 -97
  39. package/src/lexer.js +132 -18
  40. package/src/parser.js +203 -205
  41. package/src/repl.js +74 -6
  42. package/src/schema/runtime-orm.js +168 -4
  43. package/src/schema/runtime-validate.js +146 -2
  44. package/src/schema/runtime.generated.js +314 -6
  45. package/src/schema/schema.js +5 -5
  46. package/src/sourcemaps.js +277 -1
  47. package/src/stdlib.js +253 -0
  48. package/src/typecheck.js +2023 -106
  49. package/src/types.js +127 -7
  50. package/docs/ui/accordion.rip +0 -103
  51. package/docs/ui/alert-dialog.rip +0 -53
  52. package/docs/ui/autocomplete.rip +0 -115
  53. package/docs/ui/avatar.rip +0 -37
  54. package/docs/ui/badge.rip +0 -15
  55. package/docs/ui/breadcrumb.rip +0 -47
  56. package/docs/ui/button-group.rip +0 -26
  57. package/docs/ui/button.rip +0 -23
  58. package/docs/ui/card.rip +0 -25
  59. package/docs/ui/carousel.rip +0 -110
  60. package/docs/ui/checkbox-group.rip +0 -61
  61. package/docs/ui/checkbox.rip +0 -33
  62. package/docs/ui/collapsible.rip +0 -50
  63. package/docs/ui/combobox.rip +0 -130
  64. package/docs/ui/context-menu.rip +0 -88
  65. package/docs/ui/date-picker.rip +0 -206
  66. package/docs/ui/dialog.rip +0 -60
  67. package/docs/ui/drawer.rip +0 -58
  68. package/docs/ui/editable-value.rip +0 -82
  69. package/docs/ui/field.rip +0 -53
  70. package/docs/ui/fieldset.rip +0 -22
  71. package/docs/ui/form.rip +0 -39
  72. package/docs/ui/grid.rip +0 -901
  73. package/docs/ui/input-group.rip +0 -28
  74. package/docs/ui/input.rip +0 -36
  75. package/docs/ui/label.rip +0 -16
  76. package/docs/ui/menu.rip +0 -134
  77. package/docs/ui/menubar.rip +0 -151
  78. package/docs/ui/meter.rip +0 -36
  79. package/docs/ui/multi-select.rip +0 -203
  80. package/docs/ui/native-select.rip +0 -33
  81. package/docs/ui/nav-menu.rip +0 -126
  82. package/docs/ui/number-field.rip +0 -162
  83. package/docs/ui/otp-field.rip +0 -89
  84. package/docs/ui/pagination.rip +0 -123
  85. package/docs/ui/popover.rip +0 -93
  86. package/docs/ui/preview-card.rip +0 -75
  87. package/docs/ui/progress.rip +0 -25
  88. package/docs/ui/radio-group.rip +0 -57
  89. package/docs/ui/resizable.rip +0 -123
  90. package/docs/ui/scroll-area.rip +0 -145
  91. package/docs/ui/select.rip +0 -151
  92. package/docs/ui/separator.rip +0 -17
  93. package/docs/ui/skeleton.rip +0 -22
  94. package/docs/ui/slider.rip +0 -165
  95. package/docs/ui/spinner.rip +0 -17
  96. package/docs/ui/table.rip +0 -27
  97. package/docs/ui/tabs.rip +0 -113
  98. package/docs/ui/textarea.rip +0 -48
  99. package/docs/ui/toast.rip +0 -87
  100. package/docs/ui/toggle-group.rip +0 -71
  101. package/docs/ui/toggle.rip +0 -24
  102. package/docs/ui/toolbar.rip +0 -38
  103. package/docs/ui/tooltip.rip +0 -85
  104. package/src/app.rip +0 -1571
  105. package/src/sourcemap-merge.js +0 -287
  106. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  107. /package/docs/demo/{components → routes}/about.rip +0 -0
  108. /package/docs/demo/{components → routes}/card.rip +0 -0
  109. /package/docs/demo/{components → routes}/counter.rip +0 -0
  110. /package/docs/demo/{components → routes}/index.rip +0 -0
  111. /package/docs/demo/{components → routes}/todos.rip +0 -0
  112. /package/src/schema/{dts-emit.js → dts.js} +0 -0
package/src/repl.js CHANGED
@@ -327,9 +327,23 @@ export class RipREPL {
327
327
  return varName;
328
328
  });
329
329
 
330
- const staticImports = dynamicImports
331
- .map(({ varName, specifier }) => `import * as ${varName} from '${specifier}';`)
332
- .join('\n');
330
+ const staticImportLines = dynamicImports
331
+ .map(({ varName, specifier }) => `import * as ${varName} from '${specifier}';`);
332
+
333
+ // Extract user-written `import X from "…"` statements out of the body so they
334
+ // live in the SourceTextModule prelude instead of failing inside the async IIFE.
335
+ // Captures default, named, namespace, mixed, renamed, and side-effect imports.
336
+ const importedBindings = new Set();
337
+ js = js.replace(
338
+ /^import\s+(?:([^'"]+?)\s+from\s+)?(['"])([^'"]+)\2\s*;?\s*$/gm,
339
+ (match, spec) => {
340
+ staticImportLines.push(match);
341
+ for (const name of parseImportBindings(spec)) importedBindings.add(name);
342
+ return '';
343
+ }
344
+ );
345
+
346
+ const staticImports = staticImportLines.join('\n');
333
347
 
334
348
  // Restore existing variables and remove duplicate declarations
335
349
  const existingVars = Object.keys(this.vars);
@@ -351,13 +365,16 @@ export class RipREPL {
351
365
  // Build restore code
352
366
  // Non-reactive vars: let x = __vars['x'];
353
367
  // Reactive vars: const x = __vars['x']; (they're already stored)
368
+ // Skip names being declared by an `import` on this line — the import itself
369
+ // is the binding, and `let x = …` (or `const x = …`) on top would be a
370
+ // redeclaration. Apply the skip uniformly to both restore paths.
354
371
  const nonReactiveRestore = existingNonReactive
355
- .filter(k => k !== '_')
372
+ .filter(k => k !== '_' && !importedBindings.has(k))
356
373
  .map(v => `let ${v} = __vars['${v}'];`)
357
374
  .join('\n');
358
375
 
359
376
  const reactiveRestore = [...this.reactiveVars]
360
- .filter(v => existingVars.includes(v))
377
+ .filter(v => existingVars.includes(v) && !importedBindings.has(v))
361
378
  .map(v => `const ${v} = __vars['${v}'];`)
362
379
  .join('\n');
363
380
 
@@ -365,7 +382,7 @@ export class RipREPL {
365
382
 
366
383
  // Build save code (save non-reactive vars back to __vars)
367
384
  // Reactive vars are already saved via the const transformation
368
- const nonReactiveVars = [...new Set([...existingNonReactive, ...declaredVars])]
385
+ const nonReactiveVars = [...new Set([...existingNonReactive, ...declaredVars, ...importedBindings])]
369
386
  .filter(k => k !== '_' && !this.reactiveVars.has(k));
370
387
  const saveCode = nonReactiveVars
371
388
  .map(v => `if (typeof ${v} !== 'undefined') __vars['${v}'] = ${v};`)
@@ -592,6 +609,57 @@ ${colors.cyan}Tips:${colors.reset}
592
609
  }
593
610
  }
594
611
 
612
+ /**
613
+ * Extract local-binding names from the bindings clause of a static import.
614
+ *
615
+ * Handles:
616
+ * default → `Time` → ['Time']
617
+ * namespace → `* as time` → ['time']
618
+ * default + named → `Time, { Duration }` → ['Time', 'Duration']
619
+ * default + ns → `Time, * as ns` → ['Time', 'ns']
620
+ * named + rename → `{ A, B as C }` → ['A', 'C']
621
+ * side-effect → undefined / `{}` → []
622
+ */
623
+ function parseImportBindings(spec) {
624
+ if (!spec) return [];
625
+ const names = [];
626
+ let rest = spec.trim();
627
+
628
+ const ID = /^([A-Za-z_$][\w$]*)/;
629
+ const NS = /^\*\s+as\s+([A-Za-z_$][\w$]*)/;
630
+ const RENAME = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/;
631
+
632
+ // Default import (must come first if present)
633
+ const def = rest.match(ID);
634
+ if (def && rest.slice(def[0].length).trimStart().match(/^[,{]|$/)) {
635
+ names.push(def[1]);
636
+ rest = rest.slice(def[0].length).trimStart();
637
+ if (rest.startsWith(',')) rest = rest.slice(1).trimStart();
638
+ }
639
+
640
+ // Namespace
641
+ const ns = rest.match(NS);
642
+ if (ns) {
643
+ names.push(ns[1]);
644
+ rest = rest.slice(ns[0].length).trimStart();
645
+ }
646
+
647
+ // Named bindings { A, B as C }
648
+ const named = rest.match(/^\{([^}]*)\}/);
649
+ if (named) {
650
+ for (const part of named[1].split(',')) {
651
+ const t = part.trim();
652
+ if (!t) continue;
653
+ const ren = t.match(RENAME);
654
+ if (ren) { names.push(ren[2]); continue; }
655
+ const id = t.match(/^([A-Za-z_$][\w$]*)$/);
656
+ if (id) names.push(id[1]);
657
+ }
658
+ }
659
+
660
+ return names;
661
+ }
662
+
595
663
  /**
596
664
  * Start the REPL
597
665
  */
@@ -14,7 +14,7 @@
14
14
 
15
15
  /* eslint-disable no-undef, no-unused-vars */
16
16
  function __schemaDefaultAdapter() {
17
- const url = (typeof process !== 'undefined' && process.env?.DB_URL) || 'http://localhost:4213';
17
+ const url = (typeof process !== 'undefined' && process.env?.DB_URL) || 'http://localhost:9494';
18
18
  return {
19
19
  async query(sql, params) {
20
20
  const body = params && params.length ? { sql, params } : { sql };
@@ -121,6 +121,21 @@ async function __schemaRunHook(def, inst, name) {
121
121
  }
122
122
 
123
123
  async function __schemaSave(def, inst) {
124
+ // Re-entry guard. Same-instance re-entry into save() — typically a
125
+ // hook on this very instance calling .save() on `this` — would race
126
+ // the snapshot / savedChanges machinery and almost certainly loop
127
+ // forever. Throw a clear error instead. The flag is per-instance, so
128
+ // independent instances saving in parallel are unaffected; sequential
129
+ // saves on the same instance work fine because `finally` clears it.
130
+ if (inst._saving) {
131
+ throw new Error(
132
+ "schema: save() re-entered on the same " + (def.name || 'instance') +
133
+ "; a hook on this instance called save() while a save was already in flight."
134
+ );
135
+ }
136
+ inst._saving = true;
137
+ try {
138
+
124
139
  const norm = def._normalize();
125
140
  const isNew = !inst._persisted;
126
141
 
@@ -133,14 +148,25 @@ async function __schemaSave(def, inst) {
133
148
  if (isNew) await __schemaRunHook(def, inst, 'beforeCreate');
134
149
  else await __schemaRunHook(def, inst, 'beforeUpdate');
135
150
 
151
+ // Reset `savedChanges` at the start of every save so it always
152
+ // reflects the most recent write, never accumulates. Hooks running
153
+ // from this point until end-of-save read this Map; afterCreate /
154
+ // afterUpdate / afterSave see the just-completed write's diff.
155
+ inst.savedChanges = new Map();
156
+
136
157
  if (isNew) {
137
158
  const cols = [], placeholders = [], values = [];
159
+ // Track which persisted columns actually got written so savedChanges
160
+ // can record [null, newValue] entries below. Both declared fields
161
+ // and belongsTo FK columns count.
162
+ const writtenColumns = [];
138
163
  for (const [n, f] of norm.fields) {
139
164
  const v = inst[n];
140
165
  if (v == null) continue;
141
166
  cols.push('"' + __schemaSnake(n) + '"');
142
167
  placeholders.push('?');
143
168
  values.push(__schemaSerialize(v, f));
169
+ writtenColumns.push([n, v]);
144
170
  }
145
171
  // Include relation FKs. belongsTo FKs are camelCase properties on
146
172
  // the instance (e.g. organizationId for organization_id).
@@ -152,6 +178,7 @@ async function __schemaSave(def, inst) {
152
178
  cols.push('"' + rel.foreignKey + '"');
153
179
  placeholders.push('?');
154
180
  values.push(v);
181
+ writtenColumns.push([fkCamel, v]);
155
182
  }
156
183
  }
157
184
  const sql = 'INSERT INTO "' + norm.tableName + '" (' + cols.join(', ') + ') VALUES (' + placeholders.join(', ') + ') RETURNING *';
@@ -180,19 +207,148 @@ async function __schemaSave(def, inst) {
180
207
  // populated. Per-docs semantics ("materialize once, not reactive")
181
208
  // still hold — we're firing once, at end of construction, not on
182
209
  // subsequent mutations.
210
+ //
211
+ // Order matters here: snapshot the declared-field state BEFORE
212
+ // flipping `_persisted`, so a later save() can never see the
213
+ // combination "_persisted = true, _snapshot = null" (which would
214
+ // fall through to a full-row UPDATE and reintroduce the FK bug).
183
215
  def._applyEagerDerived(inst);
216
+ inst._snapshot = __schemaSnapshot(norm, inst);
184
217
  inst._persisted = true;
218
+ // Populate savedChanges with [null, newValue] per persisted column
219
+ // that was written (declared fields + belongsTo FKs). Mirrors
220
+ // Active Record: on a fresh INSERT every attribute "changed from
221
+ // nil to its new value". @timestamps columns get the same
222
+ // [null, newValue] treatment using the values RETURNING gave us
223
+ // — they were assigned on this INSERT, so they belong in the diff.
224
+ for (const [n, v] of writtenColumns) inst.savedChanges.set(n, [null, v]);
225
+ if (norm.timestamps) {
226
+ if (inst.createdAt != null) inst.savedChanges.set('createdAt', [null, inst.createdAt]);
227
+ if (inst.updatedAt != null) inst.savedChanges.set('updatedAt', [null, inst.updatedAt]);
228
+ }
185
229
  } else {
230
+ // Column-targeted UPDATE: only write fields that actually changed
231
+ // since hydrate / last save (snapshot comparison) or that the caller
232
+ // explicitly marked dirty via .markDirty(name) — escape hatch for
233
+ // in-place mutations of object-valued fields where === can't detect
234
+ // change. Two reasons this matters:
235
+ // 1. Skip a wasted DB round-trip when nothing changed.
236
+ // 2. DuckDB's foreign-key implementation rejects UPDATE statements
237
+ // that touch indexed columns (PK / UNIQUE) on a row that is
238
+ // referenced by another table's FK — even when the SET is a
239
+ // no-op like "mrn = mrn". A full-row UPDATE on a parent table
240
+ // with any child rows is therefore a hard error in DuckDB.
241
+ // Writing only changed columns keeps no-op saves entirely off
242
+ // the index path.
243
+ //
244
+ // We build `nextSnap` from the values we are about to write — BEFORE
245
+ // the await — and only install it on success. Doing this after the
246
+ // await would be unsafe under concurrent mutation: a write to the
247
+ // instance during the in-flight query would be captured into the
248
+ // post-await snapshot, mark itself "clean", and never be persisted.
249
+ //
250
+ // `nextSnap` is allocated lazily on the first changed field; the
251
+ // common no-op-save path keeps zero allocations.
186
252
  const sets = [], values = [];
253
+ const snap = inst._snapshot;
254
+ const dirty = inst._dirty;
255
+ const changes = inst.savedChanges;
256
+ let nextSnap = null;
257
+ // Declared fields.
187
258
  for (const [n, f] of norm.fields) {
259
+ const cur = inst[n];
260
+ const isDirty = dirty && dirty.has(n);
261
+ const changed = !snap || !Object.prototype.hasOwnProperty.call(snap, n) || !__schemaSameValue(snap[n], cur);
262
+ if (!isDirty && !changed) continue;
263
+ if (!nextSnap) nextSnap = Object.assign(Object.create(null), snap || {});
188
264
  sets.push('"' + __schemaSnake(n) + '" = ?');
189
- values.push(__schemaSerialize(inst[n], f));
265
+ values.push(__schemaSerialize(cur, f));
266
+ nextSnap[n] = cur;
267
+ // Record [oldValue, newValue] for hook consumers / audit. Old
268
+ // value comes from the snapshot; if no snapshot existed (first
269
+ // save after a manually-constructed persisted instance) we
270
+ // record null as the old value, which is the best information
271
+ // we have.
272
+ const old = snap && Object.prototype.hasOwnProperty.call(snap, n) ? snap[n] : null;
273
+ changes.set(n, [old, cur]);
274
+ }
275
+ // belongsTo FK columns. Same dirty / snapshot / savedChanges
276
+ // machinery as declared fields, but the SQL column name is
277
+ // already snake_case (rel.foreignKey) and the value isn't passed
278
+ // through __schemaSerialize since FKs are scalar IDs.
279
+ for (const [, rel] of norm.relations) {
280
+ if (rel.kind !== 'belongsTo') continue;
281
+ const fkCamel = __schemaCamel(rel.foreignKey);
282
+ const cur = inst[fkCamel];
283
+ const isDirty = dirty && dirty.has(fkCamel);
284
+ const changed = !snap || !Object.prototype.hasOwnProperty.call(snap, fkCamel) || !__schemaSameValue(snap[fkCamel], cur);
285
+ if (!isDirty && !changed) continue;
286
+ if (!nextSnap) nextSnap = Object.assign(Object.create(null), snap || {});
287
+ sets.push('"' + rel.foreignKey + '" = ?');
288
+ values.push(cur);
289
+ nextSnap[fkCamel] = cur;
290
+ const old = snap && Object.prototype.hasOwnProperty.call(snap, fkCamel) ? snap[fkCamel] : null;
291
+ changes.set(fkCamel, [old, cur]);
292
+ }
293
+ // @timestamps: bump updated_at iff this UPDATE will actually emit
294
+ // SQL. The check sits between the diff loops and the write, so we
295
+ // only touch the column when sets has something else in it —
296
+ // never on a no-op save (which would defeat the column-targeted
297
+ // UPDATE optimization and reintroduce a wasted DB round-trip).
298
+ // The column itself isn't in `_snapshot` (we always overwrite it
299
+ // explicitly on every real write, never compare it for diffs),
300
+ // so we mirror the new value onto the instance and record it in
301
+ // savedChanges to mirror Active Record's saved_changes shape.
302
+ //
303
+ // `oldTs` is the in-memory value at this moment, which after
304
+ // hydrate is the DB-loaded timestamp and after a prior save in
305
+ // this session is the value we set then. If user code reassigns
306
+ // `inst.updatedAt` between saves, the recorded "old" reflects
307
+ // that reassignment, not what's actually in the DB. The implicit
308
+ // column isn't in the snapshot for the same reason it isn't in
309
+ // the diff loop: we always overwrite it on real writes.
310
+ //
311
+ // Declaring `updatedAt` as a regular field is rejected at schema
312
+ // definition (__SCHEMA_RESERVED_IMPLICIT) so we can't end up with
313
+ // duplicate "updated_at = ?" entries in `sets`.
314
+ if (norm.timestamps && sets.length > 0) {
315
+ const newTs = new Date().toISOString();
316
+ const oldTs = inst.updatedAt != null ? inst.updatedAt : null;
317
+ sets.push('"updated_at" = ?');
318
+ values.push(newTs);
319
+ inst.updatedAt = newTs;
320
+ changes.set('updatedAt', [oldTs, newTs]);
190
321
  }
191
322
  if (sets.length) {
323
+ // WHERE uses the *original* PK from the snapshot, not the live
324
+ // `inst[pk]` value. If user code reassigns the in-memory PK
325
+ // between hydrate and save, the UPDATE still targets the row
326
+ // that was actually loaded — mirrors Active Record, which
327
+ // ignores in-memory PK mutation when building the UPDATE.
328
+ // Falls back to `inst[pk]` only when no snapshot exists (e.g.
329
+ // a manually-constructed persisted instance), where there's
330
+ // no better information available. We log a warning in non-prod
331
+ // when the fallback fires, since "persisted instance with no
332
+ // snapshot" is almost always an accidental misuse pattern.
192
333
  const pk = norm.primaryKey;
193
- values.push(inst[pk]);
334
+ let wherePk;
335
+ if (snap && snap[pk] != null) {
336
+ wherePk = snap[pk];
337
+ } else {
338
+ wherePk = inst[pk];
339
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
340
+ console.warn(
341
+ "[schema] " + (def.name || 'save()') + ": no _snapshot, falling back to inst." + pk +
342
+ " for the UPDATE WHERE clause. This usually means the instance was constructed " +
343
+ "with _persisted = true without going through hydrate(); the WHERE will target " +
344
+ "whatever inst." + pk + " happens to be at save time."
345
+ );
346
+ }
347
+ }
348
+ values.push(wherePk);
194
349
  const sql = 'UPDATE "' + norm.tableName + '" SET ' + sets.join(', ') + ' WHERE "' + pk + '" = ?';
195
350
  await __schemaAdapter.query(sql, values);
351
+ inst._snapshot = nextSnap;
196
352
  }
197
353
  }
198
354
  inst._dirty.clear();
@@ -201,6 +357,10 @@ async function __schemaSave(def, inst) {
201
357
  else await __schemaRunHook(def, inst, 'afterUpdate');
202
358
  await __schemaRunHook(def, inst, 'afterSave');
203
359
  return inst;
360
+
361
+ } finally {
362
+ inst._saving = false;
363
+ }
204
364
  }
205
365
 
206
366
  async function __schemaDestroy(def, inst) {
@@ -234,7 +394,11 @@ __SchemaDef.prototype.find = async function (id) {
234
394
  const soft = norm.softDelete ? ' AND "deleted_at" IS NULL' : '';
235
395
  const sql = 'SELECT * FROM "' + norm.tableName + '" WHERE "' + norm.primaryKey + '" = ?' + soft + ' LIMIT 1';
236
396
  const res = await __schemaAdapter.query(sql, [id]);
237
- if (!res.rows) return null;
397
+ // Harbor returns rowCount (not the legacy `rows` alias). Treat both
398
+ // as authoritative so the runtime works against any /sql adapter
399
+ // that has a row-count field, regardless of which name it uses.
400
+ const n = res.rowCount ?? res.rows;
401
+ if (!n || !res.data?.[0]) return null;
238
402
  return this._hydrate(res.columns, res.data[0]);
239
403
  };
240
404
 
@@ -33,9 +33,21 @@ const __SCHEMA_RESERVED_STATIC = new Set([
33
33
  'parse','safe','ok','find','findMany','where','all','first','count','create','toSQL',
34
34
  ]);
35
35
  const __SCHEMA_RESERVED_INSTANCE = new Set([
36
- 'save','destroy','reload','ok','errors','toJSON',
36
+ 'save','destroy','reload','ok','errors','toJSON','savedChanges','markDirty',
37
+ '_saving',
38
+ ]);
39
+ // Implicit columns owned by directive-driven runtime behavior. Declaring
40
+ // them as user fields would either shadow the runtime API (savedChanges /
41
+ // markDirty in INSTANCE) or produce duplicate SET writes in the same
42
+ // UPDATE statement when @timestamps / @softDelete bump them.
43
+ const __SCHEMA_RESERVED_IMPLICIT = new Set([
44
+ 'createdAt','updatedAt','deletedAt',
45
+ ]);
46
+ const __SCHEMA_RESERVED = new Set([
47
+ ...__SCHEMA_RESERVED_STATIC,
48
+ ...__SCHEMA_RESERVED_INSTANCE,
49
+ ...__SCHEMA_RESERVED_IMPLICIT,
37
50
  ]);
38
- const __SCHEMA_RESERVED = new Set([...__SCHEMA_RESERVED_STATIC, ...__SCHEMA_RESERVED_INSTANCE]);
39
51
 
40
52
  const __schemaTypes = {
41
53
  string: v => typeof v === 'string',
@@ -97,6 +109,63 @@ function __schemaSnake(s) { return s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLo
97
109
 
98
110
  function __schemaCamel(col) { return String(col).replace(/_([a-z])/g, (_, c) => c.toUpperCase()); }
99
111
 
112
+ // Reject acronym-style camelCase like `mdmID`, `userOrgID`, or
113
+ // `XMLHttpRequest`. Two consecutive uppercase letters break the
114
+ // snake_case <-> camelCase bijection: `mdmID` would round-trip via
115
+ // __schemaSnake to `mdm_i_d` and back via __schemaCamel to `mdmID`,
116
+ // while a more natural snake_case spelling `mdm_id` round-trips to
117
+ // `mdmId` (different identifier). Forcing canonical camelCase at
118
+ // schema-definition time eliminates the entire class of edge case
119
+ // in field-name resolution (markDirty, savedChanges keys, snake
120
+ // aliases on hydrate). Same convention as Active Record / Java
121
+ // Beans / Swift's "Acronyms in API names" guidance.
122
+ //
123
+ // Accepts: lowercase-first, alphanumeric body, no two consecutive
124
+ // uppercase letters anywhere.
125
+ // ok: name, mrn, firstName, mdmId, userOrgId, line2, a1b2
126
+ // bad: ID, mdmID, userID, XMLHttpRequest, _foo, 1foo, foo_bar
127
+ function __schemaValidateCanonicalName(name) {
128
+ if (typeof name !== 'string' || !/^[a-z][a-zA-Z0-9]*$/.test(name)) return false;
129
+ if (/[A-Z]{2,}/.test(name)) return false;
130
+ return true;
131
+ }
132
+
133
+ // Snapshot the current values of every persisted column on an instance:
134
+ // the primary key, declared fields (from `norm.fields`), and `belongsTo`
135
+ // FK columns (from `norm.relations`). Used by `_hydrate` and the INSERT
136
+ // / UPDATE branches of `__schemaSave` (defined in the orm fragment,
137
+ // which loads after this one) so that a later .save() can compare and
138
+ // emit a SET only for columns the caller actually mutated. Lives in the
139
+ // validate fragment because `_hydrate` owns it; the orm fragment is
140
+ // the consumer.
141
+ //
142
+ // FK columns are keyed by their camelCase property name on the instance
143
+ // (e.g. `userId`) — same convention the dirty Set, savedChanges Map,
144
+ // and markDirty() resolver use.
145
+ //
146
+ // The primary key is captured so __schemaSave's UPDATE WHERE clause can
147
+ // target the originally-loaded row even if `inst[pk]` is reassigned in
148
+ // memory. PK never appears in the UPDATE SET; it's identity, not data.
149
+ function __schemaSnapshot(norm, inst) {
150
+ const snap = Object.create(null);
151
+ snap[norm.primaryKey] = inst[norm.primaryKey];
152
+ for (const [n] of norm.fields) snap[n] = inst[n];
153
+ for (const [, rel] of norm.relations) {
154
+ if (rel.kind !== 'belongsTo') continue;
155
+ const fkCamel = __schemaCamel(rel.foreignKey);
156
+ snap[fkCamel] = inst[fkCamel];
157
+ }
158
+ return snap;
159
+ }
160
+
161
+ // SameValue-Zero: like ===, except NaN equals NaN. Used by the dirty
162
+ // check so a persisted NaN doesn't trigger a wasted UPDATE on every
163
+ // save. Distinguishes from Object.is by treating +0/-0 as equal, which
164
+ // is the right semantics for SQL: the DB doesn't distinguish them.
165
+ function __schemaSameValue(a, b) {
166
+ return a === b || (a !== a && b !== b);
167
+ }
168
+
100
169
  const __SchemaRegistry = {
101
170
  _entries: new Map(),
102
171
  register(def) {
@@ -161,9 +230,24 @@ class __SchemaDef {
161
230
  if (this.kind === 'model' && __SCHEMA_RESERVED.has(n)) collision(n, 'reserved ORM name');
162
231
  };
163
232
 
233
+ const requireCanonicalName = (n, kindLabel) => {
234
+ if (!__schemaValidateCanonicalName(n)) {
235
+ throw new SchemaError(
236
+ [{
237
+ field: n,
238
+ error: 'invalid-name',
239
+ message: kindLabel + " name '" + n + "' is not canonical camelCase. " +
240
+ "Use a lowercase-first, alphanumeric identifier with no consecutive uppercase letters " +
241
+ "(e.g. 'mdmId' not 'mdmID'). This keeps snake_case <-> camelCase mapping unambiguous.",
242
+ }],
243
+ this.name, this.kind);
244
+ }
245
+ };
246
+
164
247
  for (const e of this._desc.entries) {
165
248
  switch (e.tag) {
166
249
  case 'field':
250
+ requireCanonicalName(e.name, 'field');
167
251
  noteCollision(e.name);
168
252
  fields.set(e.name, {
169
253
  name: e.name,
@@ -330,6 +414,20 @@ class __SchemaDef {
330
414
  Object.defineProperty(this, '_dirty', { value: new Set(), enumerable: false, writable: false, configurable: true });
331
415
  Object.defineProperty(this, '_persisted', { value: persisted === true, enumerable: false, writable: true, configurable: true });
332
416
  Object.defineProperty(this, '_snapshot', { value: null, enumerable: false, writable: true, configurable: true });
417
+ // Re-entry guard for save(): set true while a save is in flight,
418
+ // cleared in __schemaSave's finally. Throws on same-instance
419
+ // re-entry (typically from a hook accidentally calling save()
420
+ // on its own instance) instead of looping forever or racing the
421
+ // snapshot / savedChanges machinery.
422
+ Object.defineProperty(this, '_saving', { value: false, enumerable: false, writable: true, configurable: true });
423
+ // Mirrors Active Record's `saved_changes`: populated by save()
424
+ // with the field-level diff of the just-completed write. INSERT
425
+ // produces `[null, newValue]` per written field; UPDATE produces
426
+ // `[oldValue, newValue]` per changed field. An empty Map after a
427
+ // save() call means nothing was actually written. Reset to a
428
+ // fresh Map at the start of every save() so it always reflects
429
+ // the most recent save, never accumulates across calls.
430
+ Object.defineProperty(this, 'savedChanges', { value: new Map(), enumerable: false, writable: true, configurable: true });
333
431
  if (data && typeof data === 'object') {
334
432
  for (const k of fieldNames) {
335
433
  if (k in data && data[k] !== undefined) this[k] = data[k];
@@ -376,6 +474,43 @@ class __SchemaDef {
376
474
  enumerable: false, configurable: true, writable: true,
377
475
  value: function() { return def._validateFields(this, true); },
378
476
  });
477
+ // Public API for forcing a column into the next UPDATE when value
478
+ // identity can't detect the change — typically after an in-place
479
+ // mutation of an object-valued field (json, Date) where the JS
480
+ // reference is unchanged. Validates the field name against the
481
+ // schema so typos throw instead of silently no-op'ing, and is
482
+ // restricted to persisted instances since INSERT writes every
483
+ // non-null field anyway (silently doing nothing is a footgun).
484
+ Object.defineProperty(klass.prototype, 'markDirty', {
485
+ enumerable: false, configurable: true, writable: true,
486
+ value: function(name) {
487
+ if (!this._persisted) {
488
+ throw new Error(
489
+ "schema: markDirty('" + name + "') is only valid on persisted instances; INSERT writes every set field"
490
+ );
491
+ }
492
+ const n = __schemaCamel(name);
493
+ const norm = def._normalize();
494
+ // Accept declared fields and `belongsTo` FK column names
495
+ // (camelCase or snake_case input both resolve via __schemaCamel).
496
+ let valid = norm.fields.has(n);
497
+ if (!valid) {
498
+ for (const [, rel] of norm.relations) {
499
+ if (rel.kind === 'belongsTo' && __schemaCamel(rel.foreignKey) === n) {
500
+ valid = true;
501
+ break;
502
+ }
503
+ }
504
+ }
505
+ if (!valid) {
506
+ throw new Error(
507
+ "schema: markDirty('" + name + "') — '" + n + "' is not a declared field or belongs_to FK on " + (def.name || 'anon')
508
+ );
509
+ }
510
+ this._dirty.add(n);
511
+ return this;
512
+ },
513
+ });
379
514
  // toJSON mirrors the instance's own enumerable properties, which by
380
515
  // construction are: the primary key, declared fields, @timestamps
381
516
  // columns, @softDelete timestamp, @belongs_to FK columns, and any
@@ -435,6 +570,15 @@ class __SchemaDef {
435
570
  // Eager-derived fields re-run on hydrate — they're not persisted
436
571
  // and must be re-computed from the declared fields now present.
437
572
  this._applyEagerDerived(inst);
573
+ // Capture the as-loaded values so `save()` can emit a column-targeted
574
+ // UPDATE that only touches fields the caller actually mutated. Two
575
+ // reasons this matters: (a) avoids a pointless DB round-trip when the
576
+ // caller didn't change anything, and (b) sidesteps a hard DuckDB FK
577
+ // limitation — UPDATEs that touch indexed columns (PK / UNIQUE) on a
578
+ // row referenced by another table's FK are rejected even when the
579
+ // value isn't really changing. Writing only dirty columns keeps no-op
580
+ // saves out of the index path entirely.
581
+ inst._snapshot = __schemaSnapshot(this._normalize(), inst);
438
582
  return inst;
439
583
  }
440
584