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
@@ -84,9 +84,21 @@ const __SCHEMA_RESERVED_STATIC = new Set([
84
84
  'parse','safe','ok','find','findMany','where','all','first','count','create','toSQL',
85
85
  ]);
86
86
  const __SCHEMA_RESERVED_INSTANCE = new Set([
87
- 'save','destroy','reload','ok','errors','toJSON',
87
+ 'save','destroy','reload','ok','errors','toJSON','savedChanges','markDirty',
88
+ '_saving',
89
+ ]);
90
+ // Implicit columns owned by directive-driven runtime behavior. Declaring
91
+ // them as user fields would either shadow the runtime API (savedChanges /
92
+ // markDirty in INSTANCE) or produce duplicate SET writes in the same
93
+ // UPDATE statement when @timestamps / @softDelete bump them.
94
+ const __SCHEMA_RESERVED_IMPLICIT = new Set([
95
+ 'createdAt','updatedAt','deletedAt',
96
+ ]);
97
+ const __SCHEMA_RESERVED = new Set([
98
+ ...__SCHEMA_RESERVED_STATIC,
99
+ ...__SCHEMA_RESERVED_INSTANCE,
100
+ ...__SCHEMA_RESERVED_IMPLICIT,
88
101
  ]);
89
- const __SCHEMA_RESERVED = new Set([...__SCHEMA_RESERVED_STATIC, ...__SCHEMA_RESERVED_INSTANCE]);
90
102
 
91
103
  const __schemaTypes = {
92
104
  string: v => typeof v === 'string',
@@ -148,6 +160,63 @@ function __schemaSnake(s) { return s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLo
148
160
 
149
161
  function __schemaCamel(col) { return String(col).replace(/_([a-z])/g, (_, c) => c.toUpperCase()); }
150
162
 
163
+ // Reject acronym-style camelCase like \`mdmID\`, \`userOrgID\`, or
164
+ // \`XMLHttpRequest\`. Two consecutive uppercase letters break the
165
+ // snake_case <-> camelCase bijection: \`mdmID\` would round-trip via
166
+ // __schemaSnake to \`mdm_i_d\` and back via __schemaCamel to \`mdmID\`,
167
+ // while a more natural snake_case spelling \`mdm_id\` round-trips to
168
+ // \`mdmId\` (different identifier). Forcing canonical camelCase at
169
+ // schema-definition time eliminates the entire class of edge case
170
+ // in field-name resolution (markDirty, savedChanges keys, snake
171
+ // aliases on hydrate). Same convention as Active Record / Java
172
+ // Beans / Swift's "Acronyms in API names" guidance.
173
+ //
174
+ // Accepts: lowercase-first, alphanumeric body, no two consecutive
175
+ // uppercase letters anywhere.
176
+ // ok: name, mrn, firstName, mdmId, userOrgId, line2, a1b2
177
+ // bad: ID, mdmID, userID, XMLHttpRequest, _foo, 1foo, foo_bar
178
+ function __schemaValidateCanonicalName(name) {
179
+ if (typeof name !== 'string' || !/^[a-z][a-zA-Z0-9]*$/.test(name)) return false;
180
+ if (/[A-Z]{2,}/.test(name)) return false;
181
+ return true;
182
+ }
183
+
184
+ // Snapshot the current values of every persisted column on an instance:
185
+ // the primary key, declared fields (from \`norm.fields\`), and \`belongsTo\`
186
+ // FK columns (from \`norm.relations\`). Used by \`_hydrate\` and the INSERT
187
+ // / UPDATE branches of \`__schemaSave\` (defined in the orm fragment,
188
+ // which loads after this one) so that a later .save() can compare and
189
+ // emit a SET only for columns the caller actually mutated. Lives in the
190
+ // validate fragment because \`_hydrate\` owns it; the orm fragment is
191
+ // the consumer.
192
+ //
193
+ // FK columns are keyed by their camelCase property name on the instance
194
+ // (e.g. \`userId\`) — same convention the dirty Set, savedChanges Map,
195
+ // and markDirty() resolver use.
196
+ //
197
+ // The primary key is captured so __schemaSave's UPDATE WHERE clause can
198
+ // target the originally-loaded row even if \`inst[pk]\` is reassigned in
199
+ // memory. PK never appears in the UPDATE SET; it's identity, not data.
200
+ function __schemaSnapshot(norm, inst) {
201
+ const snap = Object.create(null);
202
+ snap[norm.primaryKey] = inst[norm.primaryKey];
203
+ for (const [n] of norm.fields) snap[n] = inst[n];
204
+ for (const [, rel] of norm.relations) {
205
+ if (rel.kind !== 'belongsTo') continue;
206
+ const fkCamel = __schemaCamel(rel.foreignKey);
207
+ snap[fkCamel] = inst[fkCamel];
208
+ }
209
+ return snap;
210
+ }
211
+
212
+ // SameValue-Zero: like ===, except NaN equals NaN. Used by the dirty
213
+ // check so a persisted NaN doesn't trigger a wasted UPDATE on every
214
+ // save. Distinguishes from Object.is by treating +0/-0 as equal, which
215
+ // is the right semantics for SQL: the DB doesn't distinguish them.
216
+ function __schemaSameValue(a, b) {
217
+ return a === b || (a !== a && b !== b);
218
+ }
219
+
151
220
  const __SchemaRegistry = {
152
221
  _entries: new Map(),
153
222
  register(def) {
@@ -212,9 +281,24 @@ class __SchemaDef {
212
281
  if (this.kind === 'model' && __SCHEMA_RESERVED.has(n)) collision(n, 'reserved ORM name');
213
282
  };
214
283
 
284
+ const requireCanonicalName = (n, kindLabel) => {
285
+ if (!__schemaValidateCanonicalName(n)) {
286
+ throw new SchemaError(
287
+ [{
288
+ field: n,
289
+ error: 'invalid-name',
290
+ message: kindLabel + " name '" + n + "' is not canonical camelCase. " +
291
+ "Use a lowercase-first, alphanumeric identifier with no consecutive uppercase letters " +
292
+ "(e.g. 'mdmId' not 'mdmID'). This keeps snake_case <-> camelCase mapping unambiguous.",
293
+ }],
294
+ this.name, this.kind);
295
+ }
296
+ };
297
+
215
298
  for (const e of this._desc.entries) {
216
299
  switch (e.tag) {
217
300
  case 'field':
301
+ requireCanonicalName(e.name, 'field');
218
302
  noteCollision(e.name);
219
303
  fields.set(e.name, {
220
304
  name: e.name,
@@ -381,6 +465,20 @@ class __SchemaDef {
381
465
  Object.defineProperty(this, '_dirty', { value: new Set(), enumerable: false, writable: false, configurable: true });
382
466
  Object.defineProperty(this, '_persisted', { value: persisted === true, enumerable: false, writable: true, configurable: true });
383
467
  Object.defineProperty(this, '_snapshot', { value: null, enumerable: false, writable: true, configurable: true });
468
+ // Re-entry guard for save(): set true while a save is in flight,
469
+ // cleared in __schemaSave's finally. Throws on same-instance
470
+ // re-entry (typically from a hook accidentally calling save()
471
+ // on its own instance) instead of looping forever or racing the
472
+ // snapshot / savedChanges machinery.
473
+ Object.defineProperty(this, '_saving', { value: false, enumerable: false, writable: true, configurable: true });
474
+ // Mirrors Active Record's \`saved_changes\`: populated by save()
475
+ // with the field-level diff of the just-completed write. INSERT
476
+ // produces \`[null, newValue]\` per written field; UPDATE produces
477
+ // \`[oldValue, newValue]\` per changed field. An empty Map after a
478
+ // save() call means nothing was actually written. Reset to a
479
+ // fresh Map at the start of every save() so it always reflects
480
+ // the most recent save, never accumulates across calls.
481
+ Object.defineProperty(this, 'savedChanges', { value: new Map(), enumerable: false, writable: true, configurable: true });
384
482
  if (data && typeof data === 'object') {
385
483
  for (const k of fieldNames) {
386
484
  if (k in data && data[k] !== undefined) this[k] = data[k];
@@ -427,6 +525,43 @@ class __SchemaDef {
427
525
  enumerable: false, configurable: true, writable: true,
428
526
  value: function() { return def._validateFields(this, true); },
429
527
  });
528
+ // Public API for forcing a column into the next UPDATE when value
529
+ // identity can't detect the change — typically after an in-place
530
+ // mutation of an object-valued field (json, Date) where the JS
531
+ // reference is unchanged. Validates the field name against the
532
+ // schema so typos throw instead of silently no-op'ing, and is
533
+ // restricted to persisted instances since INSERT writes every
534
+ // non-null field anyway (silently doing nothing is a footgun).
535
+ Object.defineProperty(klass.prototype, 'markDirty', {
536
+ enumerable: false, configurable: true, writable: true,
537
+ value: function(name) {
538
+ if (!this._persisted) {
539
+ throw new Error(
540
+ "schema: markDirty('" + name + "') is only valid on persisted instances; INSERT writes every set field"
541
+ );
542
+ }
543
+ const n = __schemaCamel(name);
544
+ const norm = def._normalize();
545
+ // Accept declared fields and \`belongsTo\` FK column names
546
+ // (camelCase or snake_case input both resolve via __schemaCamel).
547
+ let valid = norm.fields.has(n);
548
+ if (!valid) {
549
+ for (const [, rel] of norm.relations) {
550
+ if (rel.kind === 'belongsTo' && __schemaCamel(rel.foreignKey) === n) {
551
+ valid = true;
552
+ break;
553
+ }
554
+ }
555
+ }
556
+ if (!valid) {
557
+ throw new Error(
558
+ "schema: markDirty('" + name + "') — '" + n + "' is not a declared field or belongs_to FK on " + (def.name || 'anon')
559
+ );
560
+ }
561
+ this._dirty.add(n);
562
+ return this;
563
+ },
564
+ });
430
565
  // toJSON mirrors the instance's own enumerable properties, which by
431
566
  // construction are: the primary key, declared fields, @timestamps
432
567
  // columns, @softDelete timestamp, @belongs_to FK columns, and any
@@ -486,6 +621,15 @@ class __SchemaDef {
486
621
  // Eager-derived fields re-run on hydrate — they're not persisted
487
622
  // and must be re-computed from the declared fields now present.
488
623
  this._applyEagerDerived(inst);
624
+ // Capture the as-loaded values so \`save()\` can emit a column-targeted
625
+ // UPDATE that only touches fields the caller actually mutated. Two
626
+ // reasons this matters: (a) avoids a pointless DB round-trip when the
627
+ // caller didn't change anything, and (b) sidesteps a hard DuckDB FK
628
+ // limitation — UPDATEs that touch indexed columns (PK / UNIQUE) on a
629
+ // row referenced by another table's FK are rejected even when the
630
+ // value isn't really changing. Writing only dirty columns keeps no-op
631
+ // saves out of the index path entirely.
632
+ inst._snapshot = __schemaSnapshot(this._normalize(), inst);
489
633
  return inst;
490
634
  }
491
635
 
@@ -887,7 +1031,7 @@ function __schemaTableName(model) { return __schemaPluralize(__schemaSnake(model
887
1031
  function __schemaFkName(model) { return __schemaSnake(model) + '_id'; }
888
1032
  `;
889
1033
  export const SCHEMA_ORM_RUNTIME = `function __schemaDefaultAdapter() {
890
- const url = (typeof process !== 'undefined' && process.env?.DB_URL) || 'http://localhost:4213';
1034
+ const url = (typeof process !== 'undefined' && process.env?.DB_URL) || 'http://localhost:9494';
891
1035
  return {
892
1036
  async query(sql, params) {
893
1037
  const body = params && params.length ? { sql, params } : { sql };
@@ -994,6 +1138,21 @@ async function __schemaRunHook(def, inst, name) {
994
1138
  }
995
1139
 
996
1140
  async function __schemaSave(def, inst) {
1141
+ // Re-entry guard. Same-instance re-entry into save() — typically a
1142
+ // hook on this very instance calling .save() on \`this\` — would race
1143
+ // the snapshot / savedChanges machinery and almost certainly loop
1144
+ // forever. Throw a clear error instead. The flag is per-instance, so
1145
+ // independent instances saving in parallel are unaffected; sequential
1146
+ // saves on the same instance work fine because \`finally\` clears it.
1147
+ if (inst._saving) {
1148
+ throw new Error(
1149
+ "schema: save() re-entered on the same " + (def.name || 'instance') +
1150
+ "; a hook on this instance called save() while a save was already in flight."
1151
+ );
1152
+ }
1153
+ inst._saving = true;
1154
+ try {
1155
+
997
1156
  const norm = def._normalize();
998
1157
  const isNew = !inst._persisted;
999
1158
 
@@ -1006,14 +1165,25 @@ async function __schemaSave(def, inst) {
1006
1165
  if (isNew) await __schemaRunHook(def, inst, 'beforeCreate');
1007
1166
  else await __schemaRunHook(def, inst, 'beforeUpdate');
1008
1167
 
1168
+ // Reset \`savedChanges\` at the start of every save so it always
1169
+ // reflects the most recent write, never accumulates. Hooks running
1170
+ // from this point until end-of-save read this Map; afterCreate /
1171
+ // afterUpdate / afterSave see the just-completed write's diff.
1172
+ inst.savedChanges = new Map();
1173
+
1009
1174
  if (isNew) {
1010
1175
  const cols = [], placeholders = [], values = [];
1176
+ // Track which persisted columns actually got written so savedChanges
1177
+ // can record [null, newValue] entries below. Both declared fields
1178
+ // and belongsTo FK columns count.
1179
+ const writtenColumns = [];
1011
1180
  for (const [n, f] of norm.fields) {
1012
1181
  const v = inst[n];
1013
1182
  if (v == null) continue;
1014
1183
  cols.push('"' + __schemaSnake(n) + '"');
1015
1184
  placeholders.push('?');
1016
1185
  values.push(__schemaSerialize(v, f));
1186
+ writtenColumns.push([n, v]);
1017
1187
  }
1018
1188
  // Include relation FKs. belongsTo FKs are camelCase properties on
1019
1189
  // the instance (e.g. organizationId for organization_id).
@@ -1025,6 +1195,7 @@ async function __schemaSave(def, inst) {
1025
1195
  cols.push('"' + rel.foreignKey + '"');
1026
1196
  placeholders.push('?');
1027
1197
  values.push(v);
1198
+ writtenColumns.push([fkCamel, v]);
1028
1199
  }
1029
1200
  }
1030
1201
  const sql = 'INSERT INTO "' + norm.tableName + '" (' + cols.join(', ') + ') VALUES (' + placeholders.join(', ') + ') RETURNING *';
@@ -1053,19 +1224,148 @@ async function __schemaSave(def, inst) {
1053
1224
  // populated. Per-docs semantics ("materialize once, not reactive")
1054
1225
  // still hold — we're firing once, at end of construction, not on
1055
1226
  // subsequent mutations.
1227
+ //
1228
+ // Order matters here: snapshot the declared-field state BEFORE
1229
+ // flipping \`_persisted\`, so a later save() can never see the
1230
+ // combination "_persisted = true, _snapshot = null" (which would
1231
+ // fall through to a full-row UPDATE and reintroduce the FK bug).
1056
1232
  def._applyEagerDerived(inst);
1233
+ inst._snapshot = __schemaSnapshot(norm, inst);
1057
1234
  inst._persisted = true;
1235
+ // Populate savedChanges with [null, newValue] per persisted column
1236
+ // that was written (declared fields + belongsTo FKs). Mirrors
1237
+ // Active Record: on a fresh INSERT every attribute "changed from
1238
+ // nil to its new value". @timestamps columns get the same
1239
+ // [null, newValue] treatment using the values RETURNING gave us
1240
+ // — they were assigned on this INSERT, so they belong in the diff.
1241
+ for (const [n, v] of writtenColumns) inst.savedChanges.set(n, [null, v]);
1242
+ if (norm.timestamps) {
1243
+ if (inst.createdAt != null) inst.savedChanges.set('createdAt', [null, inst.createdAt]);
1244
+ if (inst.updatedAt != null) inst.savedChanges.set('updatedAt', [null, inst.updatedAt]);
1245
+ }
1058
1246
  } else {
1247
+ // Column-targeted UPDATE: only write fields that actually changed
1248
+ // since hydrate / last save (snapshot comparison) or that the caller
1249
+ // explicitly marked dirty via .markDirty(name) — escape hatch for
1250
+ // in-place mutations of object-valued fields where === can't detect
1251
+ // change. Two reasons this matters:
1252
+ // 1. Skip a wasted DB round-trip when nothing changed.
1253
+ // 2. DuckDB's foreign-key implementation rejects UPDATE statements
1254
+ // that touch indexed columns (PK / UNIQUE) on a row that is
1255
+ // referenced by another table's FK — even when the SET is a
1256
+ // no-op like "mrn = mrn". A full-row UPDATE on a parent table
1257
+ // with any child rows is therefore a hard error in DuckDB.
1258
+ // Writing only changed columns keeps no-op saves entirely off
1259
+ // the index path.
1260
+ //
1261
+ // We build \`nextSnap\` from the values we are about to write — BEFORE
1262
+ // the await — and only install it on success. Doing this after the
1263
+ // await would be unsafe under concurrent mutation: a write to the
1264
+ // instance during the in-flight query would be captured into the
1265
+ // post-await snapshot, mark itself "clean", and never be persisted.
1266
+ //
1267
+ // \`nextSnap\` is allocated lazily on the first changed field; the
1268
+ // common no-op-save path keeps zero allocations.
1059
1269
  const sets = [], values = [];
1270
+ const snap = inst._snapshot;
1271
+ const dirty = inst._dirty;
1272
+ const changes = inst.savedChanges;
1273
+ let nextSnap = null;
1274
+ // Declared fields.
1060
1275
  for (const [n, f] of norm.fields) {
1276
+ const cur = inst[n];
1277
+ const isDirty = dirty && dirty.has(n);
1278
+ const changed = !snap || !Object.prototype.hasOwnProperty.call(snap, n) || !__schemaSameValue(snap[n], cur);
1279
+ if (!isDirty && !changed) continue;
1280
+ if (!nextSnap) nextSnap = Object.assign(Object.create(null), snap || {});
1061
1281
  sets.push('"' + __schemaSnake(n) + '" = ?');
1062
- values.push(__schemaSerialize(inst[n], f));
1282
+ values.push(__schemaSerialize(cur, f));
1283
+ nextSnap[n] = cur;
1284
+ // Record [oldValue, newValue] for hook consumers / audit. Old
1285
+ // value comes from the snapshot; if no snapshot existed (first
1286
+ // save after a manually-constructed persisted instance) we
1287
+ // record null as the old value, which is the best information
1288
+ // we have.
1289
+ const old = snap && Object.prototype.hasOwnProperty.call(snap, n) ? snap[n] : null;
1290
+ changes.set(n, [old, cur]);
1291
+ }
1292
+ // belongsTo FK columns. Same dirty / snapshot / savedChanges
1293
+ // machinery as declared fields, but the SQL column name is
1294
+ // already snake_case (rel.foreignKey) and the value isn't passed
1295
+ // through __schemaSerialize since FKs are scalar IDs.
1296
+ for (const [, rel] of norm.relations) {
1297
+ if (rel.kind !== 'belongsTo') continue;
1298
+ const fkCamel = __schemaCamel(rel.foreignKey);
1299
+ const cur = inst[fkCamel];
1300
+ const isDirty = dirty && dirty.has(fkCamel);
1301
+ const changed = !snap || !Object.prototype.hasOwnProperty.call(snap, fkCamel) || !__schemaSameValue(snap[fkCamel], cur);
1302
+ if (!isDirty && !changed) continue;
1303
+ if (!nextSnap) nextSnap = Object.assign(Object.create(null), snap || {});
1304
+ sets.push('"' + rel.foreignKey + '" = ?');
1305
+ values.push(cur);
1306
+ nextSnap[fkCamel] = cur;
1307
+ const old = snap && Object.prototype.hasOwnProperty.call(snap, fkCamel) ? snap[fkCamel] : null;
1308
+ changes.set(fkCamel, [old, cur]);
1309
+ }
1310
+ // @timestamps: bump updated_at iff this UPDATE will actually emit
1311
+ // SQL. The check sits between the diff loops and the write, so we
1312
+ // only touch the column when sets has something else in it —
1313
+ // never on a no-op save (which would defeat the column-targeted
1314
+ // UPDATE optimization and reintroduce a wasted DB round-trip).
1315
+ // The column itself isn't in \`_snapshot\` (we always overwrite it
1316
+ // explicitly on every real write, never compare it for diffs),
1317
+ // so we mirror the new value onto the instance and record it in
1318
+ // savedChanges to mirror Active Record's saved_changes shape.
1319
+ //
1320
+ // \`oldTs\` is the in-memory value at this moment, which after
1321
+ // hydrate is the DB-loaded timestamp and after a prior save in
1322
+ // this session is the value we set then. If user code reassigns
1323
+ // \`inst.updatedAt\` between saves, the recorded "old" reflects
1324
+ // that reassignment, not what's actually in the DB. The implicit
1325
+ // column isn't in the snapshot for the same reason it isn't in
1326
+ // the diff loop: we always overwrite it on real writes.
1327
+ //
1328
+ // Declaring \`updatedAt\` as a regular field is rejected at schema
1329
+ // definition (__SCHEMA_RESERVED_IMPLICIT) so we can't end up with
1330
+ // duplicate "updated_at = ?" entries in \`sets\`.
1331
+ if (norm.timestamps && sets.length > 0) {
1332
+ const newTs = new Date().toISOString();
1333
+ const oldTs = inst.updatedAt != null ? inst.updatedAt : null;
1334
+ sets.push('"updated_at" = ?');
1335
+ values.push(newTs);
1336
+ inst.updatedAt = newTs;
1337
+ changes.set('updatedAt', [oldTs, newTs]);
1063
1338
  }
1064
1339
  if (sets.length) {
1340
+ // WHERE uses the *original* PK from the snapshot, not the live
1341
+ // \`inst[pk]\` value. If user code reassigns the in-memory PK
1342
+ // between hydrate and save, the UPDATE still targets the row
1343
+ // that was actually loaded — mirrors Active Record, which
1344
+ // ignores in-memory PK mutation when building the UPDATE.
1345
+ // Falls back to \`inst[pk]\` only when no snapshot exists (e.g.
1346
+ // a manually-constructed persisted instance), where there's
1347
+ // no better information available. We log a warning in non-prod
1348
+ // when the fallback fires, since "persisted instance with no
1349
+ // snapshot" is almost always an accidental misuse pattern.
1065
1350
  const pk = norm.primaryKey;
1066
- values.push(inst[pk]);
1351
+ let wherePk;
1352
+ if (snap && snap[pk] != null) {
1353
+ wherePk = snap[pk];
1354
+ } else {
1355
+ wherePk = inst[pk];
1356
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
1357
+ console.warn(
1358
+ "[schema] " + (def.name || 'save()') + ": no _snapshot, falling back to inst." + pk +
1359
+ " for the UPDATE WHERE clause. This usually means the instance was constructed " +
1360
+ "with _persisted = true without going through hydrate(); the WHERE will target " +
1361
+ "whatever inst." + pk + " happens to be at save time."
1362
+ );
1363
+ }
1364
+ }
1365
+ values.push(wherePk);
1067
1366
  const sql = 'UPDATE "' + norm.tableName + '" SET ' + sets.join(', ') + ' WHERE "' + pk + '" = ?';
1068
1367
  await __schemaAdapter.query(sql, values);
1368
+ inst._snapshot = nextSnap;
1069
1369
  }
1070
1370
  }
1071
1371
  inst._dirty.clear();
@@ -1074,6 +1374,10 @@ async function __schemaSave(def, inst) {
1074
1374
  else await __schemaRunHook(def, inst, 'afterUpdate');
1075
1375
  await __schemaRunHook(def, inst, 'afterSave');
1076
1376
  return inst;
1377
+
1378
+ } finally {
1379
+ inst._saving = false;
1380
+ }
1077
1381
  }
1078
1382
 
1079
1383
  async function __schemaDestroy(def, inst) {
@@ -1107,7 +1411,11 @@ __SchemaDef.prototype.find = async function (id) {
1107
1411
  const soft = norm.softDelete ? ' AND "deleted_at" IS NULL' : '';
1108
1412
  const sql = 'SELECT * FROM "' + norm.tableName + '" WHERE "' + norm.primaryKey + '" = ?' + soft + ' LIMIT 1';
1109
1413
  const res = await __schemaAdapter.query(sql, [id]);
1110
- if (!res.rows) return null;
1414
+ // Harbor returns rowCount (not the legacy \`rows\` alias). Treat both
1415
+ // as authoritative so the runtime works against any /sql adapter
1416
+ // that has a row-count field, regardless of which name it uses.
1417
+ const n = res.rowCount ?? res.rows;
1418
+ if (!n || !res.data?.[0]) return null;
1111
1419
  return this._hydrate(res.columns, res.data[0]);
1112
1420
  };
1113
1421
 
@@ -541,7 +541,7 @@ function parseFieldedLine(kind, line, entries, ctx) {
541
541
  dname === 'one' || dname === 'many' || dname === 'mixin') {
542
542
  let t0 = argTokens[0];
543
543
  if (t0 && (t0[0] === 'IDENTIFIER' || t0[0] === 'PROPERTY')) {
544
- let optional = t0.data?.predicate === true;
544
+ let optional = t0.data?.optional === true;
545
545
  if (!optional && argTokens[1]?.[0] === '?') optional = true;
546
546
  args = [{ target: t0[1], optional }];
547
547
  }
@@ -1468,9 +1468,9 @@ function compileDirectiveArgsLiteral(name, tokens) {
1468
1468
  }
1469
1469
  let target = t0[1];
1470
1470
  // `@belongs_to User?` tokenizes as IDENTIFIER "User" with
1471
- // data.predicate=true. A trailing `?` in a later token position is
1471
+ // data.optional=true. A trailing `?` in a later token position is
1472
1472
  // also accepted for robustness.
1473
- let optional = t0.data?.predicate === true;
1473
+ let optional = t0.data?.optional === true;
1474
1474
  let pos = 1;
1475
1475
  if (!optional && tokens[pos]?.[0] === '?') { optional = true; pos++; }
1476
1476
  let parts = [`target: ${JSON.stringify(target)}`];
@@ -1673,8 +1673,8 @@ function parseBodyTokens(bodyTokens) {
1673
1673
  function collectModifiers(identToken) {
1674
1674
  let mods = [];
1675
1675
  let d = identToken.data;
1676
- if (d?.await === true) mods.push('!');
1677
- if (d?.predicate === true) mods.push('?');
1676
+ if (d?.bang === true) mods.push('!');
1677
+ if (d?.optional === true) mods.push('?');
1678
1678
  return mods;
1679
1679
  }
1680
1680