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.
- package/README.md +6 -4
- package/bin/rip +167 -12
- package/docs/AGENTS.md +1 -1
- package/docs/RIP-APP.md +808 -0
- package/docs/RIP-DUCKDB.md +477 -0
- package/docs/RIP-INTRO.md +396 -0
- package/docs/RIP-LANG.md +59 -5
- package/docs/RIP-SCHEMA.md +191 -8
- package/docs/RIP-TYPES.md +74 -103
- package/docs/demo/README.md +4 -3
- package/docs/dist/rip.js +3627 -1470
- package/docs/dist/rip.min.js +671 -244
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/example/index.json +7 -7
- package/docs/example/index.json.br +0 -0
- package/docs/extensions/duckdb/manifest.json +1 -1
- package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
- package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
- package/docs/extensions/vscode/print/index.html +2 -1
- package/docs/extensions/vscode/print/print-1.0.13.vsix +0 -0
- package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
- package/docs/extensions/vscode/print/print-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
- package/docs/ui/bundle.json +61 -0
- package/docs/ui/bundle.json.br +0 -0
- package/docs/ui/hljs-rip.js +0 -7
- package/docs/ui/index.css +66 -23
- package/docs/ui/index.html +6 -6
- package/package.json +9 -3
- package/rip-loader.js +64 -2
- package/src/AGENTS.md +63 -36
- package/src/browser.js +96 -14
- package/src/compiler.js +960 -143
- package/src/components.js +794 -88
- package/src/{types-emit.js → dts.js} +181 -71
- package/src/grammar/README.md +1 -1
- package/src/grammar/grammar.rip +111 -97
- package/src/lexer.js +132 -18
- package/src/parser.js +203 -205
- package/src/repl.js +74 -6
- package/src/schema/runtime-orm.js +168 -4
- package/src/schema/runtime-validate.js +146 -2
- package/src/schema/runtime.generated.js +314 -6
- package/src/schema/schema.js +5 -5
- package/src/sourcemaps.js +277 -1
- package/src/stdlib.js +253 -0
- package/src/typecheck.js +2023 -106
- package/src/types.js +127 -7
- package/docs/ui/accordion.rip +0 -103
- package/docs/ui/alert-dialog.rip +0 -53
- package/docs/ui/autocomplete.rip +0 -115
- package/docs/ui/avatar.rip +0 -37
- package/docs/ui/badge.rip +0 -15
- package/docs/ui/breadcrumb.rip +0 -47
- package/docs/ui/button-group.rip +0 -26
- package/docs/ui/button.rip +0 -23
- package/docs/ui/card.rip +0 -25
- package/docs/ui/carousel.rip +0 -110
- package/docs/ui/checkbox-group.rip +0 -61
- package/docs/ui/checkbox.rip +0 -33
- package/docs/ui/collapsible.rip +0 -50
- package/docs/ui/combobox.rip +0 -130
- package/docs/ui/context-menu.rip +0 -88
- package/docs/ui/date-picker.rip +0 -206
- package/docs/ui/dialog.rip +0 -60
- package/docs/ui/drawer.rip +0 -58
- package/docs/ui/editable-value.rip +0 -82
- package/docs/ui/field.rip +0 -53
- package/docs/ui/fieldset.rip +0 -22
- package/docs/ui/form.rip +0 -39
- package/docs/ui/grid.rip +0 -901
- package/docs/ui/input-group.rip +0 -28
- package/docs/ui/input.rip +0 -36
- package/docs/ui/label.rip +0 -16
- package/docs/ui/menu.rip +0 -134
- package/docs/ui/menubar.rip +0 -151
- package/docs/ui/meter.rip +0 -36
- package/docs/ui/multi-select.rip +0 -203
- package/docs/ui/native-select.rip +0 -33
- package/docs/ui/nav-menu.rip +0 -126
- package/docs/ui/number-field.rip +0 -162
- package/docs/ui/otp-field.rip +0 -89
- package/docs/ui/pagination.rip +0 -123
- package/docs/ui/popover.rip +0 -93
- package/docs/ui/preview-card.rip +0 -75
- package/docs/ui/progress.rip +0 -25
- package/docs/ui/radio-group.rip +0 -57
- package/docs/ui/resizable.rip +0 -123
- package/docs/ui/scroll-area.rip +0 -145
- package/docs/ui/select.rip +0 -151
- package/docs/ui/separator.rip +0 -17
- package/docs/ui/skeleton.rip +0 -22
- package/docs/ui/slider.rip +0 -165
- package/docs/ui/spinner.rip +0 -17
- package/docs/ui/table.rip +0 -27
- package/docs/ui/tabs.rip +0 -113
- package/docs/ui/textarea.rip +0 -48
- package/docs/ui/toast.rip +0 -87
- package/docs/ui/toggle-group.rip +0 -71
- package/docs/ui/toggle.rip +0 -24
- package/docs/ui/toolbar.rip +0 -38
- package/docs/ui/tooltip.rip +0 -85
- package/src/app.rip +0 -1571
- package/src/sourcemap-merge.js +0 -287
- /package/docs/demo/{components → routes}/_layout.rip +0 -0
- /package/docs/demo/{components → routes}/about.rip +0 -0
- /package/docs/demo/{components → routes}/card.rip +0 -0
- /package/docs/demo/{components → routes}/counter.rip +0 -0
- /package/docs/demo/{components → routes}/index.rip +0 -0
- /package/docs/demo/{components → routes}/todos.rip +0 -0
- /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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/schema/schema.js
CHANGED
|
@@ -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?.
|
|
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.
|
|
1471
|
+
// data.optional=true. A trailing `?` in a later token position is
|
|
1472
1472
|
// also accepted for robustness.
|
|
1473
|
-
let optional = t0.data?.
|
|
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?.
|
|
1677
|
-
if (d?.
|
|
1676
|
+
if (d?.bang === true) mods.push('!');
|
|
1677
|
+
if (d?.optional === true) mods.push('?');
|
|
1678
1678
|
return mods;
|
|
1679
1679
|
}
|
|
1680
1680
|
|