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
package/src/repl.js
CHANGED
|
@@ -327,9 +327,23 @@ export class RipREPL {
|
|
|
327
327
|
return varName;
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
-
const
|
|
331
|
-
.map(({ varName, specifier }) => `import * as ${varName} from '${specifier}';`)
|
|
332
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|