rip-lang 3.16.0 → 3.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +3 -4
  2. package/bin/rip +201 -18
  3. package/bin/rip-schema +175 -0
  4. package/docs/AGENTS.md +1 -1
  5. package/docs/RIP-APP.md +200 -19
  6. package/docs/RIP-DUCKDB.md +64 -1
  7. package/docs/RIP-INTRO.md +4 -4
  8. package/docs/RIP-LANG.md +36 -38
  9. package/docs/RIP-SCHEMA.md +1204 -364
  10. package/docs/RIP-TYPES.md +74 -103
  11. package/docs/demo/README.md +4 -3
  12. package/docs/dist/rip.js +4195 -966
  13. package/docs/dist/rip.min.js +1161 -284
  14. package/docs/dist/rip.min.js.br +0 -0
  15. package/docs/example/index.json +7 -7
  16. package/docs/example/index.json.br +0 -0
  17. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  18. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  19. package/docs/extensions/vscode/rip/index.html +2 -1
  20. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  21. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  22. package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
  23. package/docs/index.html +1 -1
  24. package/docs/ui/bundle.json +55 -55
  25. package/docs/ui/bundle.json.br +0 -0
  26. package/docs/ui/hljs-rip.js +1 -1
  27. package/docs/ui/index.html +1 -1
  28. package/package.json +15 -7
  29. package/rip-loader.js +59 -2
  30. package/src/AGENTS.md +43 -12
  31. package/src/browser.js +52 -11
  32. package/src/compiler.js +538 -80
  33. package/src/components.js +488 -48
  34. package/src/dts.js +80 -50
  35. package/src/grammar/README.md +29 -170
  36. package/src/grammar/grammar.rip +17 -12
  37. package/src/grammar/solar.rip +4 -17
  38. package/src/lexer.js +82 -32
  39. package/src/parser.js +229 -229
  40. package/src/schema/dts.js +328 -54
  41. package/src/schema/loader-server.js +2 -1
  42. package/src/schema/runtime-browser-stubs.js +20 -9
  43. package/src/schema/runtime-ddl.js +161 -44
  44. package/src/schema/runtime-migrate.js +681 -0
  45. package/src/schema/runtime-orm.js +698 -54
  46. package/src/schema/runtime-validate.js +808 -24
  47. package/src/schema/runtime.generated.js +2395 -135
  48. package/src/schema/schema.js +1054 -94
  49. package/src/typecheck.js +1610 -127
  50. package/src/types.js +90 -6
  51. package/src/grammar/lunar.rip +0 -2412
  52. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  53. /package/docs/demo/{components → routes}/about.rip +0 -0
  54. /package/docs/demo/{components → routes}/card.rip +0 -0
  55. /package/docs/demo/{components → routes}/counter.rip +0 -0
  56. /package/docs/demo/{components → routes}/index.rip +0 -0
  57. /package/docs/demo/{components → routes}/todos.rip +0 -0
@@ -13,20 +13,91 @@
13
13
  // pinned by the test suite.
14
14
 
15
15
  /* eslint-disable no-undef, no-unused-vars */
16
- function __schemaDefaultAdapter() {
17
- const url = (typeof process !== 'undefined' && process.env?.DB_URL) || 'http://localhost:9494';
16
+ // ---- Adapter (Contract v2) -------------------------------------------------
17
+ //
18
+ // `query(sql, params) → {columns, data, rowCount}` is the only REQUIRED
19
+ // method. v2 adds optional capabilities that the runtime feature-detects:
20
+ //
21
+ // begin(options?) → TxHandle transactions (schema.transaction!)
22
+ // TxHandle: { query(sql, params), commit(), rollback() }
23
+ // capabilities: { tx: true, ... } truthful self-report (informational)
24
+ //
25
+ // Calling a feature whose method is absent throws a clear error — never
26
+ // a silent fallback.
27
+ //
28
+ // The default adapter talks to a duckdb-harbor server (or any
29
+ // /sql-compatible server). Configuration via env:
30
+ //
31
+ // RIP_DB_URL base URL (default http://127.0.0.1:9494; legacy DB_URL
32
+ // honored as a fallback)
33
+ // RIP_DB_TOKEN bearer token, required when harbor runs authenticated
34
+ //
35
+ // Transactions use harbor's session protocol: POST /sql/sessions/new
36
+ // pins a DB session (connection) so BEGIN/COMMIT survive across
37
+ // requests; statements carry the sessionId; the session is destroyed
38
+ // after COMMIT/ROLLBACK. Harbor enforces an idle TTL, so an abandoned
39
+ // transaction auto-rolls-back server-side.
40
+ function __schemaDefaultAdapter(overrides) {
41
+ const env = (typeof process !== 'undefined' && process.env) || {};
42
+ const base = () => String(
43
+ overrides?.url || env.RIP_DB_URL || env.DB_URL || 'http://127.0.0.1:9494').replace(/\/+$/, '');
44
+ const headers = () => {
45
+ const h = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
46
+ const token = overrides?.token || env.RIP_DB_TOKEN;
47
+ if (token) h['Authorization'] = 'Bearer ' + token;
48
+ return h;
49
+ };
50
+ async function post(path, body) {
51
+ const res = await fetch(base() + path, {
52
+ method: 'POST', headers: headers(), body: JSON.stringify(body),
53
+ });
54
+ let data;
55
+ try { data = await res.json(); } catch { data = {}; }
56
+ if (!res.ok || data.ok === false || data.error) {
57
+ const err = new Error(data.error || ('db request failed: ' + res.status + ' ' + (res.statusText || '')));
58
+ if (data.errorCode) err.code = data.errorCode;
59
+ if (data.errorDetails) err.details = data.errorDetails;
60
+ err.httpStatus = res.status;
61
+ throw err;
62
+ }
63
+ return data;
64
+ }
18
65
  return {
19
66
  async query(sql, params) {
20
- const body = params && params.length ? { sql, params } : { sql };
21
- const res = await fetch(url + '/sql', {
22
- method: 'POST',
23
- headers: { 'Content-Type': 'application/json' },
24
- body: JSON.stringify(body),
25
- });
26
- const data = await res.json();
27
- if (data.error) throw new Error(data.error);
28
- return data;
29
- }
67
+ return post('/sql', params && params.length ? { sql, params } : { sql });
68
+ },
69
+ async begin(options) {
70
+ let session;
71
+ try {
72
+ session = await post('/sql/sessions/new', {});
73
+ } catch (e) {
74
+ if (e && e.httpStatus === 403) {
75
+ e.message = 'transactions need a harbor DB session, and harbor denied session creation ' +
76
+ '(authz policy __HARBOR_ADMIN__:sessions:create). Allow it in your harbor_authorization_function ' +
77
+ 'or set harbor_allow_admin_without_authz = true on a trusted deployment. Original error: ' + e.message;
78
+ }
79
+ throw e;
80
+ }
81
+ const sessionId = session.sessionId;
82
+ const run = (sql, params) =>
83
+ post('/sql', params && params.length ? { sql, params, sessionId } : { sql, sessionId });
84
+ const drop = async () => {
85
+ // Best-effort: harbor's idle TTL reaps abandoned sessions, so a
86
+ // failed DELETE only delays cleanup, never leaks a transaction.
87
+ try {
88
+ await fetch(base() + '/sql/sessions/' + sessionId, { method: 'DELETE', headers: headers() });
89
+ } catch {}
90
+ };
91
+ await run('BEGIN');
92
+ return {
93
+ query: run,
94
+ async commit() { await run('COMMIT'); await drop(); },
95
+ async rollback() {
96
+ try { await run('ROLLBACK'); } finally { await drop(); }
97
+ },
98
+ };
99
+ },
100
+ capabilities: { tx: true },
30
101
  };
31
102
  }
32
103
 
@@ -34,6 +105,205 @@ let __schemaAdapter = __schemaDefaultAdapter();
34
105
 
35
106
  function __schemaSetAdapter(a) { __schemaAdapter = a; }
36
107
 
108
+ // Resolve the adapter for a def: its own `on:` adapter, else the
109
+ // process-global one. Migration internals pass def = null → global.
110
+ function __schemaAdapterFor(def) {
111
+ return (def && def._adapter) || __schemaAdapter;
112
+ }
113
+
114
+ // Build a NEW adapter value (without installing it globally) — the
115
+ // counterpart of `schema :model, on: analytics`:
116
+ //
117
+ // analytics = schema.connect url: env.ANALYTICS_URL, token: env.ANALYTICS_TOKEN
118
+ // Event = schema :model, on: analytics
119
+ //
120
+ // Same duckdb-harbor contract as the default adapter (query + begin +
121
+ // capabilities), pinned to an explicit URL/token instead of env vars.
122
+ function __schemaConnect(opts) {
123
+ const o = typeof opts === 'string' ? { url: opts } : (opts || {});
124
+ if (!o.url) throw new Error("schema.connect({url, token?}): a url is required");
125
+ return __schemaDefaultAdapter({ url: o.url, token: o.token });
126
+ }
127
+
128
+ // ---- Transactions ----------------------------------------------------------
129
+ //
130
+ // schema.transaction! -> propagates ambiently: every ORM
131
+ // user = User.create! ... call inside the block routes
132
+ // Order.create! userId: user.id, ... through the transaction's handle
133
+ // user via AsyncLocalStorage. Model code
134
+ // is unchanged inside the block.
135
+ //
136
+ // Block throws → ROLLBACK, exception propagates, afterRollback hooks fire.
137
+ // Block returns → COMMIT, value returned, afterCommit hooks fire.
138
+ // Nested call → joins the ambient transaction (Active Record's default;
139
+ // DuckDB has no SAVEPOINT, so nested-as-independent-unit
140
+ // cannot be honored on the primary backend).
141
+ //
142
+ // The ALS instance is created lazily on first use — `node:async_hooks`
143
+ // exists in Bun and Node, and the import never runs in browser bundles
144
+ // (this fragment is server/migration-only).
145
+ let __schemaTxALS = null;
146
+
147
+ // The ALS store is a Map<adapter, txContext> — each adapter gets its
148
+ // own slot, so a transaction pinned to one adapter never captures ORM
149
+ // calls bound to another (cross-adapter atomicity is impossible and
150
+ // the runtime never pretends otherwise), and an inner transaction on a
151
+ // second adapter doesn't shadow the outer one.
152
+ function __schemaTxStore(adapter) {
153
+ if (!__schemaTxALS) return null;
154
+ const map = __schemaTxALS.getStore();
155
+ return (map && map.get(adapter)) || null;
156
+ }
157
+
158
+ // The single SQL funnel. Every ORM-issued statement flows through here:
159
+ // it resolves the def's adapter, routes to that adapter's ambient
160
+ // transaction handle when one exists, and translates DB constraint
161
+ // violations into structured SchemaErrors.
162
+ async function __schemaRunSQL(def, sql, params) {
163
+ const adapter = __schemaAdapterFor(def);
164
+ const tx = __schemaTxStore(adapter);
165
+ try {
166
+ return await (tx ? tx.handle.query(sql, params) : adapter.query(sql, params));
167
+ } catch (e) {
168
+ throw __schemaTranslateDBError(e, def);
169
+ }
170
+ }
171
+
172
+ async function __schemaTransaction(optsOrFn, maybeFn) {
173
+ const fn = typeof optsOrFn === 'function' ? optsOrFn : maybeFn;
174
+ const opts = typeof optsOrFn === 'function' ? {} : (optsOrFn || {});
175
+ if (typeof fn !== 'function') {
176
+ throw new Error('schema.transaction(fn): expected a function (got ' + typeof fn + ')');
177
+ }
178
+ // `on:` pins the transaction to a specific adapter (per-schema
179
+ // adapters); default is the process-global one.
180
+ const adapter = opts.on || __schemaAdapter;
181
+
182
+ // Nested transaction on the SAME adapter joins the ambient one — the
183
+ // inner block's writes commit or roll back with the outer transaction.
184
+ // A nested transaction on a DIFFERENT adapter is independent.
185
+ if (__schemaTxStore(adapter)) return fn();
186
+
187
+ if (typeof adapter.begin !== 'function') {
188
+ throw new Error(
189
+ 'schema.transaction(): the configured adapter does not support transactions ' +
190
+ '(no begin() method; see Adapter Contract v2). Install an adapter with begin() ' +
191
+ 'or use the default @rip-lang/db adapter against duckdb-harbor.');
192
+ }
193
+ if (!__schemaTxALS) {
194
+ const { AsyncLocalStorage } = await import('node:async_hooks');
195
+ __schemaTxALS = new AsyncLocalStorage();
196
+ }
197
+
198
+ const handle = await adapter.begin(opts);
199
+ // `after` collects {def, inst} for every save/destroy that completed
200
+ // inside the transaction on a model declaring afterCommit/afterRollback.
201
+ const store = { adapter, handle, after: [] };
202
+ // Copy-on-run: other adapters' ambient contexts stay visible inside
203
+ // the block; only this adapter's slot is (re)bound.
204
+ const nextMap = new Map(__schemaTxALS.getStore() || []);
205
+ nextMap.set(adapter, store);
206
+ let result;
207
+ try {
208
+ result = await __schemaTxALS.run(nextMap, fn);
209
+ } catch (err) {
210
+ try { await handle.rollback(); } catch {}
211
+ await __schemaFlushTxHooks(store, 'afterRollback');
212
+ throw err;
213
+ }
214
+ await handle.commit();
215
+ // afterCommit runs OUTSIDE the transaction — this is where emails,
216
+ // webhooks, and cache invalidation belong (they must never observe
217
+ // uncommitted state). Exceptions here propagate to the caller but
218
+ // cannot roll anything back: the COMMIT already happened.
219
+ await __schemaFlushTxHooks(store, 'afterCommit');
220
+ return result;
221
+ }
222
+
223
+ async function __schemaFlushTxHooks(store, hookName) {
224
+ // Dedupe by instance: a row saved twice in one transaction gets one
225
+ // callback, matching Active Record's after_commit semantics.
226
+ const seen = new Set();
227
+ for (const entry of store.after) {
228
+ if (seen.has(entry.inst)) continue;
229
+ seen.add(entry.inst);
230
+ await __schemaRunHook(entry.def, entry.inst, hookName);
231
+ }
232
+ }
233
+
234
+ // Queue an instance's commit-time hooks on the ambient transaction for
235
+ // ITS adapter. Returns true when queued; false means "no ambient tx —
236
+ // fire now".
237
+ function __schemaEnqueueTxHook(def, inst) {
238
+ const tx = __schemaTxStore(__schemaAdapterFor(def));
239
+ if (!tx) return false;
240
+ tx.after.push({ def, inst });
241
+ return true;
242
+ }
243
+
244
+ // ---- Constraint-violation translation --------------------------------------
245
+ //
246
+ // The ORM wraps every adapter call (via __schemaRunSQL). Errors that are
247
+ // recognizably DB constraint violations are translated into SchemaError
248
+ // so a `save!` that trips a UNIQUE index fails the same way a `save!`
249
+ // that trips a validator does: structured {field, error, message} issues.
250
+ // Unrecognized errors propagate untouched. The original error is kept
251
+ // as `.cause` for debugging.
252
+ //
253
+ // Recognition is message-pattern based (DuckDB first). Deliberately NOT
254
+ // added: `validates_uniqueness_of`-style pre-checks — they race. The DB
255
+ // constraint is the check; translation makes it ergonomic.
256
+ function __schemaTranslateDBError(e, def) {
257
+ const msg = (e && e.message) || '';
258
+ const issue = __schemaConstraintIssue(msg);
259
+ if (!issue) return e;
260
+ const err = new SchemaError([issue], def ? def.name : null, def ? def.kind : null);
261
+ err.cause = e;
262
+ return err;
263
+ }
264
+
265
+ function __schemaConstraintIssue(msg) {
266
+ let m;
267
+ // DuckDB: Constraint Error: Duplicate key "email: a@b.c" violates unique constraint ...
268
+ // (also "violates primary key constraint")
269
+ m = msg.match(/[Dd]uplicate key "([A-Za-z0-9_]+):[^"]*" violates (?:unique|primary key) constraint/);
270
+ if (m || /violates unique constraint/i.test(msg)) {
271
+ const field = m ? __schemaCamel(m[1]) : '';
272
+ return { field, error: 'unique', message: (field || 'value') + ' already taken' };
273
+ }
274
+ // DuckDB: Constraint Error: NOT NULL constraint failed: users.name
275
+ m = msg.match(/NOT NULL constraint failed:\s*(?:[A-Za-z0-9_]+\.)?([A-Za-z0-9_]+)/i);
276
+ if (m) {
277
+ const field = __schemaCamel(m[1]);
278
+ return { field, error: 'required', message: field + ' is required' };
279
+ }
280
+ // DuckDB: Constraint Error: Violates foreign key constraint because ... "user_id: 99" ...
281
+ if (/[Vv]iolates foreign key constraint/.test(msg)) {
282
+ m = msg.match(/"([A-Za-z0-9_]+):[^"]*"/);
283
+ const field = m ? __schemaCamel(m[1]) : '';
284
+ return { field, error: 'reference', message: (field || 'reference') + ' refers to a missing or still-referenced record' };
285
+ }
286
+ // DuckDB: Constraint Error: CHECK constraint failed: <table>
287
+ if (/CHECK constraint failed/i.test(msg)) {
288
+ return { field: '', error: 'check', message: msg };
289
+ }
290
+ return null;
291
+ }
292
+
293
+ // ---- Query builder ----------------------------------------------------------
294
+
295
+ // Run a scope body with `this` bound to a query builder. `builder` is
296
+ // null when invoked from a model static (User.active()) — a fresh
297
+ // builder is created; chained scope calls pass the existing builder.
298
+ // Scopes conventionally return the builder (`@where` does), but a body
299
+ // that returns something else falls back to the builder so chains never
300
+ // break on a stray trailing expression.
301
+ function __schemaInvokeScope(def, builder, fn, args) {
302
+ const q = builder || new __SchemaQuery(def);
303
+ const out = fn.apply(q, args);
304
+ return out instanceof __SchemaQuery ? out : q;
305
+ }
306
+
37
307
  class __SchemaQuery {
38
308
  constructor(def, opts = {}) {
39
309
  this._def = def;
@@ -42,7 +312,26 @@ class __SchemaQuery {
42
312
  this._limit = null;
43
313
  this._offset = null;
44
314
  this._order = null;
45
- this._includeDeleted = opts.includeDeleted === true;
315
+ this._includes = [];
316
+ this._unscoped = false;
317
+ this._defaultScopeApplied = false;
318
+ // Soft-delete filter mode: 'live' (default), 'all' (.withDeleted),
319
+ // 'deleted' (.onlyDeleted). Pre-v2 `includeDeleted` option maps to 'all'.
320
+ this._deleted = opts.includeDeleted === true ? 'all' : 'live';
321
+ // Per-model scopes install as own methods so chains compose in any
322
+ // order: User.where(role: 'admin').active().since(d). Builder
323
+ // method names win on collision (normalize rejects those anyway).
324
+ const scopes = def._normalize().scopes;
325
+ if (scopes && scopes.size) {
326
+ for (const [sname, sfn] of scopes) {
327
+ if (!(sname in this)) {
328
+ Object.defineProperty(this, sname, {
329
+ enumerable: false, configurable: true,
330
+ value: (...args) => __schemaInvokeScope(def, this, sfn, args),
331
+ });
332
+ }
333
+ }
334
+ }
46
335
  }
47
336
  where(cond, ...params) {
48
337
  if (typeof cond === 'string') {
@@ -53,6 +342,9 @@ class __SchemaQuery {
53
342
  const col = __schemaSnake(k);
54
343
  if (v === null || v === undefined) {
55
344
  this._clauses.push('"' + col + '" IS NULL');
345
+ } else if (Array.isArray(v)) {
346
+ this._clauses.push('"' + col + '" IN (' + v.map(() => '?').join(', ') + ')');
347
+ this._params.push(...v);
56
348
  } else {
57
349
  this._clauses.push('"' + col + '" = ?');
58
350
  this._params.push(v);
@@ -65,12 +357,34 @@ class __SchemaQuery {
65
357
  offset(n) { this._offset = n; return this; }
66
358
  order(spec) { this._order = spec; return this; }
67
359
  orderBy(spec) { return this.order(spec); }
360
+ includes(...specs) {
361
+ this._includes.push(...__schemaNormalizeIncludes(specs));
362
+ return this;
363
+ }
364
+ withDeleted() { this._deleted = 'all'; return this; }
365
+ onlyDeleted() { this._deleted = 'deleted'; return this; }
366
+ unscoped() { this._unscoped = true; return this; }
367
+ // @defaultScope applies lazily at terminal time (all/count/updateAll/
368
+ // deleteAll) so .unscoped() works no matter where it appears in the
369
+ // chain, and so the default's clauses never double-apply.
370
+ _applyDefaultScope() {
371
+ if (this._unscoped || this._defaultScopeApplied) return;
372
+ this._defaultScopeApplied = true;
373
+ const fn = this._def._normalize().defaultScope;
374
+ if (fn) fn.call(this);
375
+ }
376
+ _whereParts(norm) {
377
+ const where = [...this._clauses];
378
+ if (norm.softDelete) {
379
+ if (this._deleted === 'live') where.push('"deleted_at" IS NULL');
380
+ else if (this._deleted === 'deleted') where.push('"deleted_at" IS NOT NULL');
381
+ }
382
+ return where;
383
+ }
68
384
  _buildSQL() {
69
385
  const n = this._def._normalize();
70
- const table = n.tableName;
71
- const parts = ['SELECT * FROM "' + table + '"'];
72
- const where = [...this._clauses];
73
- if (!this._includeDeleted && n.softDelete) where.push('"deleted_at" IS NULL');
386
+ const parts = ['SELECT * FROM "' + n.tableName + '"'];
387
+ const where = this._whereParts(n);
74
388
  if (where.length) parts.push('WHERE ' + where.join(' AND '));
75
389
  if (this._order) parts.push('ORDER BY ' + this._order);
76
390
  if (this._limit != null) parts.push('LIMIT ' + this._limit);
@@ -78,9 +392,16 @@ class __SchemaQuery {
78
392
  return parts.join(' ');
79
393
  }
80
394
  async all() {
395
+ this._applyDefaultScope();
81
396
  const sql = this._buildSQL();
82
- const res = await __schemaAdapter.query(sql, this._params);
83
- return (res.data || []).map(row => this._def._hydrate(res.columns, row));
397
+ const res = await __schemaRunSQL(this._def, sql, this._params);
398
+ const instances = (res.data || []).map(row => this._def._hydrate(res.columns, row));
399
+ // Eager loading: batched second queries (WHERE fk IN (...)) that
400
+ // fill the relation memos. Never changes the root result set.
401
+ if (this._includes.length && instances.length) {
402
+ await __schemaPreload(this._def, instances, this._includes);
403
+ }
404
+ return instances;
84
405
  }
85
406
  async first() {
86
407
  this._limit = 1;
@@ -88,14 +409,136 @@ class __SchemaQuery {
88
409
  return arr[0] || null;
89
410
  }
90
411
  async count() {
412
+ this._applyDefaultScope();
91
413
  const n = this._def._normalize();
92
414
  const parts = ['SELECT COUNT(*) FROM "' + n.tableName + '"'];
93
- const where = [...this._clauses];
94
- if (!this._includeDeleted && n.softDelete) where.push('"deleted_at" IS NULL');
415
+ const where = this._whereParts(n);
95
416
  if (where.length) parts.push('WHERE ' + where.join(' AND '));
96
- const res = await __schemaAdapter.query(parts.join(' '), this._params);
417
+ const res = await __schemaRunSQL(this._def, parts.join(' '), this._params);
97
418
  return res.data?.[0]?.[0] || 0;
98
419
  }
420
+ // One UPDATE statement for every matching row. Bypasses validation
421
+ // and per-instance hooks — the name says "all", the docs say "raw".
422
+ // Returns the adapter's reported row count when available.
423
+ async updateAll(values) {
424
+ this._applyDefaultScope();
425
+ const n = this._def._normalize();
426
+ const keys = values && typeof values === 'object' ? Object.keys(values) : [];
427
+ if (!keys.length) throw new Error('updateAll: requires at least one column to set');
428
+ const sets = [];
429
+ const params = [];
430
+ for (const k of keys) {
431
+ const name = __schemaCamel(k);
432
+ const field = n.fields.get(name);
433
+ sets.push('"' + __schemaSnake(k) + '" = ?');
434
+ params.push(__schemaSerialize(values[k], field));
435
+ }
436
+ if (n.timestamps) {
437
+ sets.push('"updated_at" = ?');
438
+ params.push(new Date().toISOString());
439
+ }
440
+ const where = this._whereParts(n);
441
+ let sql = 'UPDATE "' + n.tableName + '" SET ' + sets.join(', ');
442
+ if (where.length) sql += ' WHERE ' + where.join(' AND ');
443
+ const res = await __schemaRunSQL(this._def, sql, [...params, ...this._params]);
444
+ return res.rowCount ?? res.rows ?? null;
445
+ }
446
+ // One statement for every matching row. Soft-delete aware: on a
447
+ // @softDelete model this is an UPDATE setting deleted_at; on a hard
448
+ // model it's a real DELETE. Bypasses per-instance hooks (bulk path).
449
+ async deleteAll() {
450
+ this._applyDefaultScope();
451
+ const n = this._def._normalize();
452
+ const where = this._whereParts(n);
453
+ let sql, params;
454
+ if (n.softDelete && this._deleted === 'live') {
455
+ sql = 'UPDATE "' + n.tableName + '" SET "deleted_at" = ?';
456
+ params = [new Date().toISOString(), ...this._params];
457
+ } else {
458
+ sql = 'DELETE FROM "' + n.tableName + '"';
459
+ params = this._params;
460
+ }
461
+ if (where.length) sql += ' WHERE ' + where.join(' AND ');
462
+ const res = await __schemaRunSQL(this._def, sql, params);
463
+ return res.rowCount ?? res.rows ?? null;
464
+ }
465
+ }
466
+
467
+ // ---- Eager loading ----------------------------------------------------------
468
+
469
+ // Normalize .includes arguments into [{name, children}] trees. Accepts
470
+ // :symbols, strings, arrays, and nested maps to any depth:
471
+ // .includes(:orders)
472
+ // .includes(:author, comments: :author)
473
+ function __schemaNormalizeIncludes(specs) {
474
+ const out = [];
475
+ for (const s of specs) {
476
+ if (s == null) continue;
477
+ if (typeof s === 'symbol') out.push({ name: Symbol.keyFor(s) || s.description, children: [] });
478
+ else if (typeof s === 'string') out.push({ name: s, children: [] });
479
+ else if (Array.isArray(s)) out.push(...__schemaNormalizeIncludes(s));
480
+ else if (typeof s === 'object') {
481
+ for (const [k, v] of Object.entries(s)) {
482
+ out.push({ name: k, children: __schemaNormalizeIncludes([v]) });
483
+ }
484
+ }
485
+ }
486
+ return out;
487
+ }
488
+
489
+ // Batched preload: one query per relation per nesting level
490
+ // (`WHERE fk IN (?, …)`), never JOINs — no row duplication, uniform
491
+ // across belongs_to / has_one / has_many. Results land in the relation
492
+ // memo, so `user.orders!` resolves from cache with no query. Invisible
493
+ // to call sites: preloading is purely a performance fact.
494
+ async function __schemaPreload(def, instances, specs) {
495
+ if (!instances.length || !specs.length) return;
496
+ const norm = def._normalize();
497
+ for (const spec of specs) {
498
+ const rel = norm.relations.get(spec.name);
499
+ if (!rel) {
500
+ throw new Error(
501
+ "schema: includes('" + spec.name + "') — no such relation on " + (def.name || 'model') +
502
+ '. Declared relations: ' + ([...norm.relations.keys()].join(', ') || '(none)'));
503
+ }
504
+ const target = __SchemaRegistry.get(rel.target);
505
+ if (!target) throw new Error('schema: unknown relation target "' + rel.target + '" from ' + (def.name || 'anon'));
506
+ const children = [];
507
+ if (rel.kind === 'belongsTo') {
508
+ const fkCamel = __schemaCamel(rel.foreignKey);
509
+ const ids = [...new Set(instances.map(i => i[fkCamel]).filter(v => v != null))];
510
+ const rows = ids.length ? await target.findMany(ids) : [];
511
+ const pk = target._normalize().primaryKey;
512
+ const byId = new Map(rows.map(r => [r[pk], r]));
513
+ for (const inst of instances) {
514
+ const v = inst[fkCamel] != null ? (byId.get(inst[fkCamel]) ?? null) : null;
515
+ __schemaRelMemoSet(inst, spec.name, v);
516
+ if (v && !children.includes(v)) children.push(v);
517
+ }
518
+ } else {
519
+ const pk = norm.primaryKey;
520
+ const fkCamel = __schemaCamel(rel.foreignKey);
521
+ const ids = [...new Set(instances.map(i => i[pk]).filter(v => v != null))];
522
+ let rows = [];
523
+ if (ids.length) {
524
+ rows = await new __SchemaQuery(target)
525
+ .where('"' + rel.foreignKey + '" IN (' + ids.map(() => '?').join(', ') + ')', ...ids)
526
+ .all();
527
+ }
528
+ const groups = new Map();
529
+ for (const r of rows) {
530
+ const k = r[fkCamel];
531
+ if (!groups.has(k)) groups.set(k, []);
532
+ groups.get(k).push(r);
533
+ children.push(r);
534
+ }
535
+ for (const inst of instances) {
536
+ const g = groups.get(inst[pk]) || [];
537
+ __schemaRelMemoSet(inst, spec.name, rel.kind === 'hasOne' ? (g[0] ?? null) : g);
538
+ }
539
+ }
540
+ if (spec.children.length) await __schemaPreload(target, children, spec.children);
541
+ }
99
542
  }
100
543
 
101
544
  async function __schemaResolveRelation(def, inst, rel) {
@@ -115,11 +558,26 @@ async function __schemaResolveRelation(def, inst, rel) {
115
558
  return null;
116
559
  }
117
560
 
561
+ // ---- Save / destroy ----------------------------------------------------------
562
+
118
563
  async function __schemaRunHook(def, inst, name) {
119
564
  const fn = def._normalize().hooks.get(name);
120
565
  if (fn) await fn.call(inst);
121
566
  }
122
567
 
568
+ // After a successful save/destroy: queue afterCommit/afterRollback on
569
+ // the ambient transaction, or fire afterCommit immediately when no
570
+ // transaction is open (AR semantics: outside a tx, "commit" is the
571
+ // statement itself). Only models that declare one of the two hooks pay
572
+ // any cost here.
573
+ async function __schemaSettleTxHooks(def, inst) {
574
+ const hooks = def._normalize().hooks;
575
+ if (!hooks.has('afterCommit') && !hooks.has('afterRollback')) return;
576
+ if (!__schemaEnqueueTxHook(def, inst)) {
577
+ await __schemaRunHook(def, inst, 'afterCommit');
578
+ }
579
+ }
580
+
123
581
  async function __schemaSave(def, inst) {
124
582
  // Re-entry guard. Same-instance re-entry into save() — typically a
125
583
  // hook on this very instance calling .save() on `this` — would race
@@ -182,24 +640,9 @@ async function __schemaSave(def, inst) {
182
640
  }
183
641
  }
184
642
  const sql = 'INSERT INTO "' + norm.tableName + '" (' + cols.join(', ') + ') VALUES (' + placeholders.join(', ') + ') RETURNING *';
185
- const res = await __schemaAdapter.query(sql, values);
643
+ const res = await __schemaRunSQL(def, sql, values);
186
644
  if (res.data?.[0] && res.columns) {
187
- for (let i = 0; i < res.columns.length; i++) {
188
- const snake = res.columns[i].name;
189
- const key = __schemaCamel(snake);
190
- if (!(key in inst)) {
191
- Object.defineProperty(inst, key, { value: res.data[0][i], enumerable: true, writable: true, configurable: true });
192
- } else {
193
- inst[key] = res.data[0][i];
194
- }
195
- if (snake !== key && !(snake in inst)) {
196
- Object.defineProperty(inst, snake, {
197
- enumerable: false, configurable: true,
198
- get() { return this[key]; },
199
- set(v) { this[key] = v; },
200
- });
201
- }
202
- }
645
+ __schemaAbsorbRow(inst, res.columns, res.data[0]);
203
646
  }
204
647
  // Now that the RETURNING columns (id, @timestamps, FKs) are on the
205
648
  // instance, !> eager-derived fields can see them. Mirrors the hydrate
@@ -347,7 +790,7 @@ async function __schemaSave(def, inst) {
347
790
  }
348
791
  values.push(wherePk);
349
792
  const sql = 'UPDATE "' + norm.tableName + '" SET ' + sets.join(', ') + ' WHERE "' + pk + '" = ?';
350
- await __schemaAdapter.query(sql, values);
793
+ await __schemaRunSQL(def, sql, values);
351
794
  inst._snapshot = nextSnap;
352
795
  }
353
796
  }
@@ -356,6 +799,7 @@ async function __schemaSave(def, inst) {
356
799
  if (isNew) await __schemaRunHook(def, inst, 'afterCreate');
357
800
  else await __schemaRunHook(def, inst, 'afterUpdate');
358
801
  await __schemaRunHook(def, inst, 'afterSave');
802
+ await __schemaSettleTxHooks(def, inst);
359
803
  return inst;
360
804
 
361
805
  } finally {
@@ -363,19 +807,59 @@ async function __schemaSave(def, inst) {
363
807
  }
364
808
  }
365
809
 
366
- async function __schemaDestroy(def, inst) {
810
+ // Absorb a RETURNING row onto an instance: camelCase canonical own
811
+ // properties plus non-enumerable snake_case aliases. Shared by the
812
+ // INSERT path, upsert, and restore.
813
+ function __schemaAbsorbRow(inst, columns, row) {
814
+ for (let i = 0; i < columns.length; i++) {
815
+ const snake = columns[i].name;
816
+ const key = __schemaCamel(snake);
817
+ if (!(key in inst)) {
818
+ Object.defineProperty(inst, key, { value: row[i], enumerable: true, writable: true, configurable: true });
819
+ } else {
820
+ inst[key] = row[i];
821
+ }
822
+ if (snake !== key && !(snake in inst)) {
823
+ Object.defineProperty(inst, snake, {
824
+ enumerable: false, configurable: true,
825
+ get() { return this[key]; },
826
+ set(v) { this[key] = v; },
827
+ });
828
+ }
829
+ }
830
+ }
831
+
832
+ async function __schemaDestroy(def, inst, opts) {
367
833
  if (!inst._persisted) return inst;
368
834
  const norm = def._normalize();
835
+ const hard = opts && opts.hard === true;
369
836
  await __schemaRunHook(def, inst, 'beforeDestroy');
370
- if (norm.softDelete) {
837
+ if (norm.softDelete && !hard) {
371
838
  const now = new Date().toISOString();
372
- await __schemaAdapter.query('UPDATE "' + norm.tableName + '" SET "deleted_at" = ? WHERE "' + norm.primaryKey + '" = ?', [now, inst[norm.primaryKey]]);
839
+ await __schemaRunSQL(def, 'UPDATE "' + norm.tableName + '" SET "deleted_at" = ? WHERE "' + norm.primaryKey + '" = ?', [now, inst[norm.primaryKey]]);
373
840
  inst.deletedAt = now;
374
841
  } else {
375
- await __schemaAdapter.query('DELETE FROM "' + norm.tableName + '" WHERE "' + norm.primaryKey + '" = ?', [inst[norm.primaryKey]]);
842
+ await __schemaRunSQL(def, 'DELETE FROM "' + norm.tableName + '" WHERE "' + norm.primaryKey + '" = ?', [inst[norm.primaryKey]]);
376
843
  inst._persisted = false;
377
844
  }
378
845
  await __schemaRunHook(def, inst, 'afterDestroy');
846
+ await __schemaSettleTxHooks(def, inst);
847
+ return inst;
848
+ }
849
+
850
+ // Soft-delete recovery: UPDATE ... SET deleted_at = NULL. Fires the
851
+ // update lifecycle (beforeUpdate/afterUpdate) like Active Record's
852
+ // touch-style writes. Only meaningful on @softDelete models.
853
+ async function __schemaRestore(def, inst) {
854
+ const norm = def._normalize();
855
+ if (!norm.softDelete) {
856
+ throw new Error('schema: restore() requires @softDelete on ' + (def.name || 'model'));
857
+ }
858
+ if (!inst._persisted) return inst;
859
+ await __schemaRunHook(def, inst, 'beforeUpdate');
860
+ await __schemaRunSQL(def, 'UPDATE "' + norm.tableName + '" SET "deleted_at" = NULL WHERE "' + norm.primaryKey + '" = ?', [inst[norm.primaryKey]]);
861
+ inst.deletedAt = null;
862
+ await __schemaRunHook(def, inst, 'afterUpdate');
379
863
  return inst;
380
864
  }
381
865
 
@@ -386,20 +870,27 @@ function __schemaSerialize(v, field) {
386
870
  return v;
387
871
  }
388
872
 
389
- // ORM prototype augmentations — added to __SchemaDef
873
+ // ---- ORM prototype augmentations — added to __SchemaDef ----------------------
390
874
 
391
875
  __SchemaDef.prototype.find = async function (id) {
392
876
  this._assertModel('find');
877
+ // Routed through the builder so find honors the same filters as every
878
+ // other read: the @softDelete `deleted_at IS NULL` filter and the
879
+ // model's @defaultScope (Active Record semantics — default_scope
880
+ // applies to find). `User.unscoped().where(id: …).first!` is the
881
+ // escape hatch.
882
+ const norm = this._normalize();
883
+ return new __SchemaQuery(this).where({ [norm.primaryKey]: id }).first();
884
+ };
885
+
886
+ __SchemaDef.prototype.findMany = async function (ids) {
887
+ this._assertModel('findMany');
888
+ if (!Array.isArray(ids)) throw new Error('schema: findMany(ids) expects an array');
889
+ if (!ids.length) return [];
393
890
  const norm = this._normalize();
394
- const soft = norm.softDelete ? ' AND "deleted_at" IS NULL' : '';
395
- const sql = 'SELECT * FROM "' + norm.tableName + '" WHERE "' + norm.primaryKey + '" = ?' + soft + ' LIMIT 1';
396
- const res = await __schemaAdapter.query(sql, [id]);
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;
402
- return this._hydrate(res.columns, res.data[0]);
891
+ return new __SchemaQuery(this)
892
+ .where('"' + norm.primaryKey + '" IN (' + ids.map(() => '?').join(', ') + ')', ...ids)
893
+ .all();
403
894
  };
404
895
 
405
896
  __SchemaDef.prototype.where = function (cond, ...params) {
@@ -407,6 +898,26 @@ __SchemaDef.prototype.where = function (cond, ...params) {
407
898
  return new __SchemaQuery(this).where(cond, ...params);
408
899
  };
409
900
 
901
+ __SchemaDef.prototype.includes = function (...specs) {
902
+ this._assertModel('includes');
903
+ return new __SchemaQuery(this).includes(...specs);
904
+ };
905
+
906
+ __SchemaDef.prototype.withDeleted = function () {
907
+ this._assertModel('withDeleted');
908
+ return new __SchemaQuery(this).withDeleted();
909
+ };
910
+
911
+ __SchemaDef.prototype.onlyDeleted = function () {
912
+ this._assertModel('onlyDeleted');
913
+ return new __SchemaQuery(this).onlyDeleted();
914
+ };
915
+
916
+ __SchemaDef.prototype.unscoped = function () {
917
+ this._assertModel('unscoped');
918
+ return new __SchemaQuery(this).unscoped();
919
+ };
920
+
410
921
  __SchemaDef.prototype.all = function () {
411
922
  this._assertModel('all');
412
923
  return new __SchemaQuery(this).all();
@@ -445,6 +956,139 @@ __SchemaDef.prototype.create = async function (data) {
445
956
  return inst;
446
957
  };
447
958
 
959
+ // INSERT ... ON CONFLICT (target) DO UPDATE SET ... RETURNING *.
960
+ //
961
+ // User.upsert! {email: "a@b.c", name: "Alice"}, on: :email
962
+ //
963
+ // Validates the row and fires beforeValidation / beforeSave / afterSave.
964
+ // beforeCreate/beforeUpdate do NOT fire — the runtime cannot know which
965
+ // branch the database took. The conflict target accepts a :symbol,
966
+ // string, or array of either (composite targets).
967
+ __SchemaDef.prototype.upsert = async function (data, opts) {
968
+ this._assertModel('upsert');
969
+ const norm = this._normalize();
970
+ const on = opts && (opts.on ?? opts.conflict);
971
+ if (on == null) throw new Error("schema: upsert(data, on: :column) requires a conflict target");
972
+ const targets = (Array.isArray(on) ? on : [on]).map(t =>
973
+ __schemaSnake(typeof t === 'symbol' ? (Symbol.keyFor(t) || t.description) : String(t)));
974
+
975
+ const klass = this._getClass();
976
+ const canonical = {};
977
+ if (data && typeof data === 'object') {
978
+ for (const k of Object.keys(data)) canonical[__schemaCamel(k)] = data[k];
979
+ }
980
+ const inst = new klass(this._applyDefaults(canonical), false);
981
+ for (const [k, v] of Object.entries(canonical)) {
982
+ if (!(k in inst)) {
983
+ Object.defineProperty(inst, k, { value: v, enumerable: true, writable: true, configurable: true });
984
+ }
985
+ }
986
+
987
+ await __schemaRunHook(this, inst, 'beforeValidation');
988
+ const errs = this._validateFields(inst, true);
989
+ if (errs.length) throw new SchemaError(errs, this.name, this.kind);
990
+ await __schemaRunHook(this, inst, 'afterValidation');
991
+ await __schemaRunHook(this, inst, 'beforeSave');
992
+
993
+ const cols = [], placeholders = [], values = [];
994
+ for (const [n, f] of norm.fields) {
995
+ const v = inst[n];
996
+ if (v == null) continue;
997
+ cols.push(__schemaSnake(n));
998
+ placeholders.push('?');
999
+ values.push(__schemaSerialize(v, f));
1000
+ }
1001
+ for (const [, rel] of norm.relations) {
1002
+ if (rel.kind !== 'belongsTo') continue;
1003
+ const v = inst[__schemaCamel(rel.foreignKey)];
1004
+ if (v != null) {
1005
+ cols.push(rel.foreignKey);
1006
+ placeholders.push('?');
1007
+ values.push(v);
1008
+ }
1009
+ }
1010
+ if (!cols.length) throw new Error('schema: upsert() requires at least one column');
1011
+ const updateCols = cols.filter(c => !targets.includes(c));
1012
+ let conflict = ' ON CONFLICT (' + targets.map(t => '"' + t + '"').join(', ') + ')';
1013
+ if (updateCols.length) {
1014
+ const sets = updateCols.map(c => '"' + c + '" = EXCLUDED."' + c + '"');
1015
+ if (norm.timestamps) sets.push('"updated_at" = CURRENT_TIMESTAMP');
1016
+ conflict += ' DO UPDATE SET ' + sets.join(', ');
1017
+ } else {
1018
+ conflict += ' DO NOTHING';
1019
+ }
1020
+ const sql = 'INSERT INTO "' + norm.tableName + '" (' + cols.map(c => '"' + c + '"').join(', ') + ')' +
1021
+ ' VALUES (' + placeholders.join(', ') + ')' + conflict + ' RETURNING *';
1022
+ const res = await __schemaRunSQL(this, sql, values);
1023
+ if (res.data?.[0] && res.columns) __schemaAbsorbRow(inst, res.columns, res.data[0]);
1024
+ this._applyEagerDerived(inst);
1025
+ inst._snapshot = __schemaSnapshot(norm, inst);
1026
+ inst._persisted = true;
1027
+ await __schemaRunHook(this, inst, 'afterSave');
1028
+ await __schemaSettleTxHooks(this, inst);
1029
+ return inst;
1030
+ };
1031
+
1032
+ // Bulk insert: validates EVERY row first (collecting all failures into
1033
+ // one SchemaError, issues prefixed `[i].field`, before any SQL), then
1034
+ // issues one multi-VALUES INSERT ... RETURNING *. Per-instance hooks
1035
+ // are deliberately skipped — this is the bulk path; use create! in a
1036
+ // loop when hooks matter. Returns hydrated instances.
1037
+ __SchemaDef.prototype.insertMany = async function (rows) {
1038
+ this._assertModel('insertMany');
1039
+ if (!Array.isArray(rows)) throw new Error('schema: insertMany(rows) expects an array');
1040
+ if (!rows.length) return [];
1041
+ const norm = this._normalize();
1042
+
1043
+ const canonicalRows = [];
1044
+ const allErrs = [];
1045
+ for (let i = 0; i < rows.length; i++) {
1046
+ const canonical = {};
1047
+ const data = rows[i];
1048
+ if (data && typeof data === 'object') {
1049
+ for (const k of Object.keys(data)) canonical[__schemaCamel(k)] = data[k];
1050
+ }
1051
+ this._applyDefaults(canonical);
1052
+ for (const e of this._validateFields(canonical, true)) {
1053
+ allErrs.push({
1054
+ field: '[' + i + ']' + (e.field ? '.' + e.field : ''),
1055
+ error: e.error,
1056
+ message: '[' + i + '] ' + e.message,
1057
+ });
1058
+ }
1059
+ canonicalRows.push(canonical);
1060
+ }
1061
+ if (allErrs.length) throw new SchemaError(allErrs, this.name, this.kind);
1062
+
1063
+ // Column set = union of written columns across rows (missing values
1064
+ // insert as NULL / column default).
1065
+ const colSet = new Set();
1066
+ for (const row of canonicalRows) {
1067
+ for (const [n] of norm.fields) if (row[n] != null) colSet.add(n);
1068
+ for (const [, rel] of norm.relations) {
1069
+ if (rel.kind !== 'belongsTo') continue;
1070
+ if (row[__schemaCamel(rel.foreignKey)] != null) colSet.add(__schemaCamel(rel.foreignKey));
1071
+ }
1072
+ }
1073
+ const colNames = [...colSet];
1074
+ if (!colNames.length) throw new Error('schema: insertMany() requires at least one column');
1075
+ const values = [];
1076
+ const tuples = [];
1077
+ for (const row of canonicalRows) {
1078
+ const slots = [];
1079
+ for (const n of colNames) {
1080
+ slots.push('?');
1081
+ values.push(__schemaSerialize(row[n] ?? null, norm.fields.get(n)));
1082
+ }
1083
+ tuples.push('(' + slots.join(', ') + ')');
1084
+ }
1085
+ const sql = 'INSERT INTO "' + norm.tableName + '" (' +
1086
+ colNames.map(n => '"' + __schemaSnake(n) + '"').join(', ') + ') VALUES ' +
1087
+ tuples.join(', ') + ' RETURNING *';
1088
+ const res = await __schemaRunSQL(this, sql, values);
1089
+ return (res.data || []).map(row => this._hydrate(res.columns, row));
1090
+ };
1091
+
448
1092
  __SchemaDef.prototype._assertModel = function (api) {
449
1093
  if (this.kind !== 'model') {
450
1094
  throw new Error('schema: .' + api + '() is :model-only (got :' + this.kind + ')');