@rudderjs/orm 1.5.0 → 1.7.0

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/dist/index.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { castGet, castSet } from './cast.js';
2
+ import { AGGREGATES_SYMBOL, aggregateKeysOf, loadCountOrExists, loadMissingRelations, loadNumericAggregate, normalizeWithCount, normalizeWithExists, normalizeWithNumericAggregate, } from './aggregate.js';
2
3
  export { Attribute } from './attribute.js';
3
4
  export { JsonResource, ResourceCollection } from './resource.js';
4
5
  export { ModelCollection } from './collection.js';
5
6
  export { ModelFactory, sequence } from './factory.js';
6
7
  export { Seeder } from './seeder.js';
8
+ export { AggregateConstraintBuilder, AGGREGATES_SYMBOL } from './aggregate.js';
9
+ export { pruneModels } from './prune.js';
7
10
  // ─── Global ORM Registry ───────────────────────────────────
8
11
  export class ModelRegistry {
9
12
  static adapter = null;
@@ -38,6 +41,7 @@ export class ModelRegistry {
38
41
  return;
39
42
  this.models.set(name, ModelClass);
40
43
  _installBelongsToManyMethods(ModelClass);
44
+ _installMorphPivotMethods(ModelClass);
41
45
  for (const listener of this.listeners)
42
46
  listener(name, ModelClass);
43
47
  }
@@ -179,6 +183,13 @@ export class Model {
179
183
  * }
180
184
  */
181
185
  static morphAlias;
186
+ /**
187
+ * Pruning mode for `pnpm rudder model:prune`. Override to `'mass'` for
188
+ * {@link MassPrunable}. The runner only considers models that also define
189
+ * `static prunable()`; this static just disambiguates instance- vs
190
+ * bulk-mode for those that do.
191
+ */
192
+ static pruneMode = 'instance';
182
193
  /**
183
194
  * Column used to resolve a route parameter into a Model instance via
184
195
  * {@link Model.findForRoute}. Defaults to the primary key. Override to
@@ -234,10 +245,9 @@ export class Model {
234
245
  * want a chainable QueryBuilder scoped to the parent record.
235
246
  *
236
247
  * Supported types: `hasMany`, `hasOne`, `belongsTo`, `belongsToMany`,
237
- * `morphMany`, `morphOne`, `morphTo`. Polymorphic columns use camelCase
238
- * (`commentableId` / `commentableType`) for ORM consistency — a deliberate
239
- * divergence from Laravel's snake_case. `morphToMany` / `morphedByMany` are
240
- * deferred (drop to the adapter for now).
248
+ * `morphMany`, `morphOne`, `morphTo`, `morphToMany`, `morphedByMany`.
249
+ * Polymorphic columns use camelCase (`commentableId` / `commentableType`)
250
+ * for ORM consistency — a deliberate divergence from Laravel's snake_case.
241
251
  *
242
252
  * For `belongsToMany`, pivot mutations (`attach` / `detach` / `sync`) live
243
253
  * on a separate accessor — see {@link Model.belongsToMany}. `related()`
@@ -338,6 +348,16 @@ export class Model {
338
348
  #instanceHidden;
339
349
  /** @internal */
340
350
  #instanceVisible;
351
+ // ── Dirty Tracking ─────────────────────────────────────
352
+ //
353
+ // Snapshot of attribute values as of the last load / save / refresh.
354
+ // Used by isDirty / isClean / wasChanged / getOriginal / getChanges /
355
+ // getDirty. Captured by hydrate(), save(), refresh(), and increment/
356
+ // decrement so the baseline always matches the persisted state.
357
+ /** @internal — own enumerable column values as of last load/save/refresh. */
358
+ #original = {};
359
+ /** @internal — diff of attributes that changed during the most recent save. */
360
+ #changes = {};
341
361
  // ── Scopes ─────────────────────────────────────────────
342
362
  static globalScopes = {};
343
363
  static scopes = {};
@@ -428,16 +448,80 @@ export class Model {
428
448
  const Ctor = this;
429
449
  const instance = new Ctor();
430
450
  Object.assign(instance, record);
451
+ instance._syncOriginal();
431
452
  return instance;
432
453
  }
433
454
  /** @internal — wrap a QueryBuilder so its read methods return Model instances. */
434
455
  static _hydratingQb(self, qb) {
435
456
  const ModelClass = self;
436
- const wrap = (r) => ModelClass.hydrate.call(self, r);
457
+ /** Aliases stamped onto rows by the adapter for any aggregates registered
458
+ * on this QB. Tagged on each hydrated instance via `aggregateKeysOf` so
459
+ * `_toData()` excludes them on writes. */
460
+ const aggregateAliases = new Set();
461
+ const wrap = (r) => {
462
+ const inst = ModelClass.hydrate.call(self, r);
463
+ if (inst && aggregateAliases.size > 0) {
464
+ const set = aggregateKeysOf(inst);
465
+ for (const a of aggregateAliases)
466
+ set.add(a);
467
+ }
468
+ return inst;
469
+ };
437
470
  const wrapMaybe = (r) => r == null ? null : wrap(r);
438
471
  const wrapMany = (rs) => rs.map(wrap);
472
+ const dispatchAggregates = (reqs) => {
473
+ for (const r of reqs)
474
+ aggregateAliases.add(r.alias);
475
+ qb.withAggregate(reqs);
476
+ };
439
477
  const proxy = new Proxy(qb, {
440
478
  get(target, prop, receiver) {
479
+ // ORM-side chainables that don't exist on the adapter QB itself —
480
+ // intercept before the existence check below, since `whereHas` etc.
481
+ // are added by this proxy, not by the adapter.
482
+ if (prop === 'whereHas') {
483
+ return (relation, constrain) => {
484
+ _attachWhereHas(ModelClass, target, relation, true, constrain);
485
+ return proxy;
486
+ };
487
+ }
488
+ if (prop === 'whereDoesntHave') {
489
+ return (relation, constrain) => {
490
+ _attachWhereHas(ModelClass, target, relation, false, constrain);
491
+ return proxy;
492
+ };
493
+ }
494
+ if (prop === 'withWhereHas') {
495
+ return (relation, constrain) => {
496
+ _attachWithWhereHas(ModelClass, target, relation, constrain);
497
+ return proxy;
498
+ };
499
+ }
500
+ if (prop === 'whereBelongsTo') {
501
+ return (parent, relation) => {
502
+ _attachWhereBelongsTo(ModelClass, target, parent, relation);
503
+ return proxy;
504
+ };
505
+ }
506
+ if (prop === 'withCount') {
507
+ return (arg) => {
508
+ dispatchAggregates(normalizeWithCount(ModelClass, arg));
509
+ return proxy;
510
+ };
511
+ }
512
+ if (prop === 'withExists') {
513
+ return (arg) => {
514
+ dispatchAggregates(normalizeWithExists(ModelClass, arg));
515
+ return proxy;
516
+ };
517
+ }
518
+ if (prop === 'withSum' || prop === 'withMin' || prop === 'withMax' || prop === 'withAvg') {
519
+ const fn = prop.slice(4).toLowerCase();
520
+ return (arg1, arg2) => {
521
+ dispatchAggregates(normalizeWithNumericAggregate(ModelClass, fn, arg1, arg2));
522
+ return proxy;
523
+ };
524
+ }
441
525
  const value = Reflect.get(target, prop, receiver);
442
526
  if (typeof value !== 'function')
443
527
  return value;
@@ -576,6 +660,120 @@ export class Model {
576
660
  static where(column, value) {
577
661
  return Model._q(this).where(column, value);
578
662
  }
663
+ /**
664
+ * Filter rows where the named relation has at least one matching child.
665
+ * The optional callback receives a sub-QueryBuilder scoped to the related
666
+ * model — chain `.where()` etc. on it to narrow the relation predicate
667
+ * further.
668
+ *
669
+ * Resolves the relation declaration on `static relations`, builds a
670
+ * {@link RelationExistencePredicate}, and dispatches it to the adapter via
671
+ * `whereRelationExists`. `morphTo` relations are not supported (the related
672
+ * table is dynamic) — call sites should filter on the discriminator
673
+ * columns directly.
674
+ *
675
+ * @example
676
+ * await User.whereHas('posts').get() // users with at least one post
677
+ * await User.whereHas('posts', q => q.where('published', true)).get() // users with at least one published post
678
+ * await Post.whereHas('tags', q => q.where('name', 'featured')).get() // belongsToMany pivot path
679
+ * await Post.whereHas('comments').get() // morphMany — adds the {morph}Type filter automatically
680
+ */
681
+ static whereHas(relation, constrain) {
682
+ return _attachWhereHas(this, Model._q(this), relation, true, constrain);
683
+ }
684
+ /**
685
+ * Inverse of {@link Model.whereHas} — rows whose named relation has zero
686
+ * matching children. Same constrain-callback semantics: when present,
687
+ * narrows the "matching" set so `whereDoesntHave` matches "no children
688
+ * matching the constraint" rather than "no children at all".
689
+ */
690
+ static whereDoesntHave(relation, constrain) {
691
+ return _attachWhereHas(this, Model._q(this), relation, false, constrain);
692
+ }
693
+ /**
694
+ * `whereHas` + `with` — filter by the relation predicate AND eager-load the
695
+ * matching rows under the same constraint when the adapter supports it
696
+ * (`withConstrained`). Adapters without constrained eager-load fall back to
697
+ * unconstrained `with(relation)` — every related row is returned even if
698
+ * the parent was matched on a narrower predicate.
699
+ */
700
+ static withWhereHas(relation, constrain) {
701
+ return _attachWithWhereHas(this, Model._q(this), relation, constrain);
702
+ }
703
+ /**
704
+ * Filter rows whose `belongsTo` relation points at `parent`. Sugar for
705
+ * `where(fk, parent.primaryKeyValue)` with the FK column resolved from the
706
+ * relation declaration. When `relation` is omitted, looks up the single
707
+ * `belongsTo` relation pointing at `parent.constructor` and throws if zero
708
+ * or more-than-one match.
709
+ *
710
+ * @example
711
+ * await Post.whereBelongsTo(user).get() // posts whose author belongsTo this user (single FK)
712
+ * await Comment.whereBelongsTo(post, 'post').get() // explicit relation name when ambiguous
713
+ */
714
+ static whereBelongsTo(parent, relation) {
715
+ return _attachWhereBelongsTo(this, Model._q(this), parent, relation);
716
+ }
717
+ /**
718
+ * Aggregate eager-loading — count related rows alongside the parent in a
719
+ * single query. The result is stamped onto each parent under
720
+ * `<relation>Count` (`postsCount` for `withCount('posts')`) without dropping
721
+ * into the adapter.
722
+ *
723
+ * Three call shapes:
724
+ * - `withCount('posts')` — single relation, no constraint.
725
+ * - `withCount(['posts', 'comments'])` — multiple, no constraints.
726
+ * - `withCount({ posts: q => q.where('published', true).as('publishedPosts') })`
727
+ * — map form with `where`/`orWhere` constraints + optional alias override.
728
+ *
729
+ * Closes the N+1 footgun for hot list pages. For a single instance use
730
+ * `instance.loadCount('posts')` instead.
731
+ *
732
+ * @example
733
+ * await User.query().withCount('posts').get() // user.postsCount
734
+ * await User.query().withCount({ posts: q => q.where('published', true) }).get()
735
+ * await Post.query().withCount(['comments', 'tags']).paginate(1)
736
+ */
737
+ static withCount(arg) {
738
+ return Model._q(this).withCount(arg);
739
+ }
740
+ /**
741
+ * Boolean aggregate — stamps `<relation>Exists` (true/false) onto each
742
+ * parent. Cheap on Prisma (translates to `_count > 0`) and Drizzle
743
+ * (`EXISTS (...)` correlated subquery). Use this instead of `withCount`
744
+ * when you only need presence, not the count.
745
+ */
746
+ static withExists(arg) {
747
+ return Model._q(this).withExists(arg);
748
+ }
749
+ /**
750
+ * Aggregate eager-loading — sum a column across the related rows.
751
+ * Stamps `<relation>Sum<Column>` onto each parent (e.g.
752
+ * `withSum('orders', 'total')` → `ordersSumTotal`).
753
+ *
754
+ * Map form supports per-relation constraints + alias overrides:
755
+ *
756
+ * ```ts
757
+ * await User.query().withSum({
758
+ * orders: { column: 'total', constraint: q => q.where('status', 'paid') },
759
+ * }).get()
760
+ * ```
761
+ */
762
+ static withSum(arg1, column) {
763
+ return Model._q(this).withSum(arg1, column);
764
+ }
765
+ /** Min of a column across the related rows. Stamps `<relation>Min<Column>`. */
766
+ static withMin(arg1, column) {
767
+ return Model._q(this).withMin(arg1, column);
768
+ }
769
+ /** Max of a column across the related rows. Stamps `<relation>Max<Column>`. */
770
+ static withMax(arg1, column) {
771
+ return Model._q(this).withMax(arg1, column);
772
+ }
773
+ /** Average of a column across the related rows. Stamps `<relation>Avg<Column>`. */
774
+ static withAvg(arg1, column) {
775
+ return Model._q(this).withAvg(arg1, column);
776
+ }
579
777
  /**
580
778
  * Find a record by attributes; if none exists, create one with `attrs` merged with `values`.
581
779
  * Returns the existing or newly-created record.
@@ -765,21 +963,39 @@ export class Model {
765
963
  return value;
766
964
  }
767
965
  /**
768
- * @internal — own enumerable data fields, with framework-internal `_` keys
769
- * stripped and `undefined` values dropped so a class-declared but never-set
770
- * field (`id!: number`) doesn't leak into a create/update payload.
966
+ * @internal — current own-property column attributes, with framework `_`
967
+ * keys + `undefined` placeholders dropped. Aggregate-injected keys (stamped
968
+ * by `withCount` / `loadCount` etc.) are excluded they're not real schema
969
+ * columns and would be rejected by Prisma writes / Drizzle inserts.
970
+ *
971
+ * Single source of truth shared by `_toData()` and dirty-tracking baselines.
771
972
  */
772
- _toData() {
973
+ _currentAttrs() {
773
974
  const out = {};
975
+ const aggregates = this[AGGREGATES_SYMBOL];
774
976
  for (const [k, v] of Object.entries(this)) {
775
977
  if (k.startsWith('_'))
776
978
  continue;
777
979
  if (v === undefined)
778
980
  continue;
981
+ if (aggregates && aggregates.has(k))
982
+ continue;
779
983
  out[k] = v;
780
984
  }
781
985
  return out;
782
986
  }
987
+ /**
988
+ * @internal — own enumerable data fields, with framework-internal `_` keys
989
+ * stripped and `undefined` values dropped so a class-declared but never-set
990
+ * field (`id!: number`) doesn't leak into a create/update payload.
991
+ */
992
+ _toData() {
993
+ return this._currentAttrs();
994
+ }
995
+ /** @internal — capture current attrs as the new dirty-tracking baseline. */
996
+ _syncOriginal() {
997
+ this.#original = this._currentAttrs();
998
+ }
783
999
  /**
784
1000
  * Persist this instance. Inserts when the primary key is unset; otherwise updates.
785
1001
  *
@@ -797,6 +1013,14 @@ export class Model {
797
1013
  ? await Model._doCreate.call(ctor, data)
798
1014
  : await Model._doUpdate.call(ctor, id, data);
799
1015
  Object.assign(this, persisted);
1016
+ const next = this._currentAttrs();
1017
+ const diff = {};
1018
+ for (const k of new Set([...Object.keys(next), ...Object.keys(this.#original)])) {
1019
+ if (!_attrEqual(next[k], this.#original[k]))
1020
+ diff[k] = next[k];
1021
+ }
1022
+ this.#changes = diff;
1023
+ this.#original = next;
800
1024
  return this;
801
1025
  }
802
1026
  /**
@@ -840,6 +1064,8 @@ export class Model {
840
1064
  delete this[k];
841
1065
  }
842
1066
  Object.assign(this, fresh);
1067
+ this.#changes = {};
1068
+ this._syncOriginal();
843
1069
  return this;
844
1070
  }
845
1071
  /**
@@ -854,6 +1080,42 @@ export class Model {
854
1080
  }
855
1081
  await ctor.delete(id);
856
1082
  }
1083
+ /**
1084
+ * Restore this soft-deleted instance — clears `deletedAt` and routes through
1085
+ * the static `restore()` so observers fire. Refreshes in-place with the
1086
+ * canonical row returned from the database.
1087
+ */
1088
+ async restore() {
1089
+ const ctor = this.constructor;
1090
+ const id = this._getKey();
1091
+ if (id === undefined) {
1092
+ throw new Error(`[RudderJS ORM] Cannot restore a ${ctor.name} without a primary key.`);
1093
+ }
1094
+ const restored = await ctor.restore(id);
1095
+ Object.assign(this, restored);
1096
+ this._syncOriginal();
1097
+ return this;
1098
+ }
1099
+ /**
1100
+ * Persist this instance without firing observer / listener events.
1101
+ * Equivalent to `await Model.withoutEvents(() => instance.save())`.
1102
+ *
1103
+ * Per-class — observers cascading into child classes still fire normally.
1104
+ */
1105
+ async saveQuietly() {
1106
+ const ctor = this.constructor;
1107
+ return ctor.withoutEvents(() => this.save());
1108
+ }
1109
+ /** Delete this instance without firing observer / listener events. */
1110
+ async deleteQuietly() {
1111
+ const ctor = this.constructor;
1112
+ await ctor.withoutEvents(() => this.delete());
1113
+ }
1114
+ /** Restore this soft-deleted instance without firing observer / listener events. */
1115
+ async restoreQuietly() {
1116
+ const ctor = this.constructor;
1117
+ return ctor.withoutEvents(() => this.restore());
1118
+ }
857
1119
  /**
858
1120
  * Atomically add `amount` to `column` on this instance. The row is updated
859
1121
  * via SQL `UPDATE col = col + amount` and the new value is merged back into
@@ -870,6 +1132,67 @@ export class Model {
870
1132
  }
871
1133
  const updated = await ctor.increment(id, column, amount, extra);
872
1134
  Object.assign(this, updated);
1135
+ this._syncOriginal();
1136
+ return this;
1137
+ }
1138
+ /**
1139
+ * Aggregate-load related rows for this single instance. Mutates in place
1140
+ * by setting `this[<relation>Count]` (or the `.as(name)` override) and
1141
+ * returns `this` for chaining.
1142
+ *
1143
+ * One round-trip per call. For batched loads on a list, prefer
1144
+ * `Model.query().withCount(...)` on the parent query.
1145
+ *
1146
+ * @example
1147
+ * const user = await User.find(1)
1148
+ * await user!.loadCount('posts')
1149
+ * console.log(user!.postsCount)
1150
+ *
1151
+ * await user!.loadCount({ posts: q => q.where('published', true).as('publishedPosts') })
1152
+ * console.log(user!.publishedPostsCount)
1153
+ */
1154
+ async loadCount(arg) {
1155
+ await loadCountOrExists(this, 'count', arg);
1156
+ return this;
1157
+ }
1158
+ /** Boolean aggregate — stamps `<relation>Exists` on the instance. */
1159
+ async loadExists(arg) {
1160
+ await loadCountOrExists(this, 'exists', arg);
1161
+ return this;
1162
+ }
1163
+ /** Sum of `column` across the related rows. Stamps `<relation>Sum<Column>`. */
1164
+ async loadSum(arg1, column) {
1165
+ await loadNumericAggregate(this, 'sum', arg1, column);
1166
+ return this;
1167
+ }
1168
+ /** Min of `column` across the related rows. Stamps `<relation>Min<Column>`. */
1169
+ async loadMin(arg1, column) {
1170
+ await loadNumericAggregate(this, 'min', arg1, column);
1171
+ return this;
1172
+ }
1173
+ /** Max of `column` across the related rows. Stamps `<relation>Max<Column>`. */
1174
+ async loadMax(arg1, column) {
1175
+ await loadNumericAggregate(this, 'max', arg1, column);
1176
+ return this;
1177
+ }
1178
+ /** Average of `column` across the related rows. Stamps `<relation>Avg<Column>`. */
1179
+ async loadAvg(arg1, column) {
1180
+ await loadNumericAggregate(this, 'avg', arg1, column);
1181
+ return this;
1182
+ }
1183
+ /**
1184
+ * Eager-load each named relation onto the instance only when the property
1185
+ * is currently `null` / `undefined`. Truthy properties are skipped — useful
1186
+ * after partial hydration to backfill the relations a downstream consumer
1187
+ * needs without refetching what's already there.
1188
+ *
1189
+ * @example
1190
+ * const user = await User.query().with('profile').first()
1191
+ * // profile already populated; only `posts` issues a query.
1192
+ * await user!.loadMissing('profile', 'posts')
1193
+ */
1194
+ async loadMissing(...relations) {
1195
+ await loadMissingRelations(this, relations);
873
1196
  return this;
874
1197
  }
875
1198
  /**
@@ -884,6 +1207,7 @@ export class Model {
884
1207
  }
885
1208
  const updated = await ctor.decrement(id, column, amount, extra);
886
1209
  Object.assign(this, updated);
1210
+ this._syncOriginal();
887
1211
  return this;
888
1212
  }
889
1213
  /**
@@ -917,6 +1241,47 @@ export class Model {
917
1241
  }
918
1242
  return clone;
919
1243
  }
1244
+ // ── Dirty Tracking ─────────────────────────────────────
1245
+ /**
1246
+ * Whether any attribute (or the named attribute) has been changed since
1247
+ * the last save / load / refresh.
1248
+ */
1249
+ isDirty(key) {
1250
+ const dirty = this.getDirty();
1251
+ return key === undefined ? Object.keys(dirty).length > 0 : key in dirty;
1252
+ }
1253
+ /** Inverse of {@link isDirty}. */
1254
+ isClean(key) {
1255
+ return !this.isDirty(key);
1256
+ }
1257
+ /**
1258
+ * Whether the named attribute (or any attribute) was actually changed on
1259
+ * the most recent {@link save}. Stays true until the next save or refresh.
1260
+ */
1261
+ wasChanged(key) {
1262
+ return key === undefined
1263
+ ? Object.keys(this.#changes).length > 0
1264
+ : key in this.#changes;
1265
+ }
1266
+ getOriginal(key) {
1267
+ if (key === undefined)
1268
+ return { ...this.#original };
1269
+ return this.#original[key];
1270
+ }
1271
+ /** Diff map of attributes that changed during the most recent {@link save}. */
1272
+ getChanges() {
1273
+ return { ...this.#changes };
1274
+ }
1275
+ /** Diff map of attributes currently dirty (unsaved). */
1276
+ getDirty() {
1277
+ const out = {};
1278
+ const current = this._currentAttrs();
1279
+ for (const k of new Set([...Object.keys(current), ...Object.keys(this.#original)])) {
1280
+ if (!_attrEqual(current[k], this.#original[k]))
1281
+ out[k] = current[k];
1282
+ }
1283
+ return out;
1284
+ }
920
1285
  /**
921
1286
  * True when `other` represents the same record — same model class (by table)
922
1287
  * and same primary key.
@@ -1015,6 +1380,22 @@ export class Model {
1015
1380
  }
1016
1381
  return _belongsToManyDeferredQb(Related, def, meta, parentVal);
1017
1382
  }
1383
+ if (def.type === 'morphToMany') {
1384
+ const meta = _resolveMorphToManyMeta(ctor, Related, def);
1385
+ const parentVal = this[meta.parentKey];
1386
+ if (parentVal === undefined || parentVal === null) {
1387
+ throw new Error(`[RudderJS ORM] Cannot resolve "${name}" on ${ctor.name} — ${meta.parentKey} is unset.`);
1388
+ }
1389
+ return _morphToManyDeferredQb(Related, def, meta, parentVal);
1390
+ }
1391
+ if (def.type === 'morphedByMany') {
1392
+ const meta = _resolveMorphedByManyMeta(ctor, Related, def);
1393
+ const parentVal = this[meta.parentKey];
1394
+ if (parentVal === undefined || parentVal === null) {
1395
+ throw new Error(`[RudderJS ORM] Cannot resolve "${name}" on ${ctor.name} — ${meta.parentKey} is unset.`);
1396
+ }
1397
+ return _morphedByManyDeferredQb(Related, def, meta, parentVal);
1398
+ }
1018
1399
  if (def.type === 'belongsTo') {
1019
1400
  // This model holds the FK; query the related model's PK.
1020
1401
  const fk = def.foreignKey ?? `${fkCamel(Related.name)}Id`;
@@ -1073,6 +1454,71 @@ export class Model {
1073
1454
  _installBelongsToManyMethods(ctor);
1074
1455
  return _makeBelongsToManyAccessor(ctor, Related, def, parentVal);
1075
1456
  }
1457
+ /**
1458
+ * Pivot-mutation accessor for a `morphToMany` relation (the polymorphic
1459
+ * owning side, e.g. `Post.tags()`). Same surface as `belongsToMany` —
1460
+ * `attach`/`detach`/`sync` — but every pivot row carries the parent's
1461
+ * discriminator (`{morphName}Type`) and every pivot query filters by it.
1462
+ *
1463
+ * @example
1464
+ * class Post extends Model {
1465
+ * static override relations = {
1466
+ * tags: { type: 'morphToMany' as const, model: () => Tag, pivotTable: 'taggable', morphName: 'taggable' },
1467
+ * }
1468
+ * tags() { return Model.morphToMany(this, 'tags') }
1469
+ * }
1470
+ */
1471
+ static morphToMany(parent, name) {
1472
+ const ctor = parent.constructor;
1473
+ const def = ctor.relations[name];
1474
+ if (!def) {
1475
+ throw new Error(`[RudderJS ORM] Relation "${name}" is not defined on ${ctor.name}.`);
1476
+ }
1477
+ if (def.type !== 'morphToMany') {
1478
+ throw new Error(`[RudderJS ORM] Relation "${name}" on ${ctor.name} is "${def.type}", not "morphToMany".`);
1479
+ }
1480
+ const Related = def.model();
1481
+ const meta = _resolveMorphToManyMeta(ctor, Related, def);
1482
+ const parentVal = parent[meta.parentKey];
1483
+ if (parentVal === undefined || parentVal === null) {
1484
+ throw new Error(`[RudderJS ORM] Cannot use morphToMany "${name}" on ${ctor.name} — ${meta.parentKey} is unset.`);
1485
+ }
1486
+ _installMorphPivotMethods(ctor);
1487
+ return _makeMorphToManyAccessor(ctor, Related, def, parentVal);
1488
+ }
1489
+ /**
1490
+ * Pivot-mutation accessor for a `morphedByMany` relation (the inverse
1491
+ * polymorphic side, e.g. `Tag.posts()`). Each `morphedByMany` declaration
1492
+ * targets one concrete inverse class; declare one relation per target.
1493
+ *
1494
+ * @example
1495
+ * class Tag extends Model {
1496
+ * static override relations = {
1497
+ * posts: { type: 'morphedByMany' as const, model: () => Post, pivotTable: 'taggable', morphName: 'taggable' },
1498
+ * videos: { type: 'morphedByMany' as const, model: () => Video, pivotTable: 'taggable', morphName: 'taggable' },
1499
+ * }
1500
+ * posts() { return Model.morphedByMany(this, 'posts') }
1501
+ * videos() { return Model.morphedByMany(this, 'videos') }
1502
+ * }
1503
+ */
1504
+ static morphedByMany(parent, name) {
1505
+ const ctor = parent.constructor;
1506
+ const def = ctor.relations[name];
1507
+ if (!def) {
1508
+ throw new Error(`[RudderJS ORM] Relation "${name}" is not defined on ${ctor.name}.`);
1509
+ }
1510
+ if (def.type !== 'morphedByMany') {
1511
+ throw new Error(`[RudderJS ORM] Relation "${name}" on ${ctor.name} is "${def.type}", not "morphedByMany".`);
1512
+ }
1513
+ const Related = def.model();
1514
+ const meta = _resolveMorphedByManyMeta(ctor, Related, def);
1515
+ const parentVal = parent[meta.parentKey];
1516
+ if (parentVal === undefined || parentVal === null) {
1517
+ throw new Error(`[RudderJS ORM] Cannot use morphedByMany "${name}" on ${ctor.name} — ${meta.parentKey} is unset.`);
1518
+ }
1519
+ _installMorphPivotMethods(ctor);
1520
+ return _makeMorphedByManyAccessor(ctor, Related, def, parentVal);
1521
+ }
1076
1522
  /**
1077
1523
  * Build the `{name}Id + {name}Type` payload for a polymorphic write.
1078
1524
  *
@@ -1212,6 +1658,34 @@ export class Model {
1212
1658
  return Object.fromEntries(Object.entries(result).filter(([k]) => !effectiveHidden.includes(k)));
1213
1659
  }
1214
1660
  }
1661
+ // ─── Dirty tracking equality ───────────────────────────────
1662
+ /**
1663
+ * @internal — value equality used by dirty tracking. Mirrors Eloquent's
1664
+ * `originalIsEquivalent`: strict for primitives, `getTime()` for Date,
1665
+ * structural-by-JSON for arrays / plain objects (covers `json` / `array`
1666
+ * casts), null/undefined collapsed.
1667
+ *
1668
+ * Caveat: JSON.stringify is key-order sensitive, so `{a:1,b:2}` vs
1669
+ * `{b:2,a:1}` compares unequal. Same posture Laravel takes — documented
1670
+ * in the Dirty Tracking section of the orm README.
1671
+ */
1672
+ function _attrEqual(a, b) {
1673
+ if (a === b)
1674
+ return true;
1675
+ if (a == null && b == null)
1676
+ return true;
1677
+ if (a instanceof Date && b instanceof Date)
1678
+ return a.getTime() === b.getTime();
1679
+ if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
1680
+ try {
1681
+ return JSON.stringify(a) === JSON.stringify(b);
1682
+ }
1683
+ catch {
1684
+ return false;
1685
+ }
1686
+ }
1687
+ return false;
1688
+ }
1215
1689
  function _camelHead(s) {
1216
1690
  return s.charAt(0).toLowerCase() + s.slice(1);
1217
1691
  }
@@ -1236,6 +1710,250 @@ function _resolveBelongsToManyMeta(Parent, Related, def) {
1236
1710
  relatedKey: def.relatedKey ?? Related.primaryKey,
1237
1711
  };
1238
1712
  }
1713
+ function _resolveMorphToManyMeta(Parent, Related, def) {
1714
+ return {
1715
+ pivotTable: def.pivotTable,
1716
+ foreignPivotKey: `${def.morphName}Id`,
1717
+ morphTypeKey: `${def.morphName}Type`,
1718
+ morphTypeValue: def.morphType ?? Parent.morphAlias ?? Parent.name,
1719
+ relatedPivotKey: def.relatedPivotKey ?? `${_camelHead(Related.name)}Id`,
1720
+ parentKey: def.parentKey ?? Parent.primaryKey,
1721
+ relatedKey: def.relatedKey ?? Related.primaryKey,
1722
+ };
1723
+ }
1724
+ function _resolveMorphedByManyMeta(Parent, Related, def) {
1725
+ return {
1726
+ pivotTable: def.pivotTable,
1727
+ relatedPivotKey: `${def.morphName}Id`,
1728
+ morphTypeKey: `${def.morphName}Type`,
1729
+ morphTypeValue: def.morphType ?? Related.morphAlias ?? Related.name,
1730
+ foreignPivotKey: def.foreignPivotKey ?? `${_camelHead(Parent.name)}Id`,
1731
+ parentKey: def.parentKey ?? Parent.primaryKey,
1732
+ relatedKey: def.relatedKey ?? Related.primaryKey,
1733
+ };
1734
+ }
1735
+ // ─── whereHas internals ────────────────────────────────────
1736
+ /**
1737
+ * Run the constrain callback against a recording-only QueryBuilder that
1738
+ * captures `.where()` calls into a flat `WhereClause[]` and treats every
1739
+ * other chainable method as a no-op. Nested `whereHas` inside the callback
1740
+ * throws — recursive predicates are deferred to v2.
1741
+ */
1742
+ function _captureConstraintWheres(constrain) {
1743
+ const wheres = [];
1744
+ const recorder = new Proxy({}, {
1745
+ get(_t, prop) {
1746
+ const name = String(prop);
1747
+ if (name === 'where') {
1748
+ return (col, opOrVal, maybeVal) => {
1749
+ if (maybeVal === undefined) {
1750
+ wheres.push({ column: col, operator: '=', value: opOrVal });
1751
+ }
1752
+ else {
1753
+ wheres.push({ column: col, operator: opOrVal, value: maybeVal });
1754
+ }
1755
+ return recorder;
1756
+ };
1757
+ }
1758
+ if (name === 'whereHas' || name === 'whereDoesntHave' || name === 'withWhereHas') {
1759
+ return () => {
1760
+ throw new Error(`[RudderJS ORM] Nested ${name} inside a whereHas constrain callback is deferred to v2. ` +
1761
+ `Filter on flat columns inside the callback for now.`);
1762
+ };
1763
+ }
1764
+ // All other chainable methods record nothing and return the recorder so
1765
+ // `q.orderBy('x').limit(1)` chains through silently. Terminal methods
1766
+ // (find/get/etc.) don't make sense in a constrain callback — they'd
1767
+ // execute mid-build — but we don't intercept them here; they'd just
1768
+ // return the recorder which then fails downstream. Keep the contract
1769
+ // simple.
1770
+ return () => recorder;
1771
+ },
1772
+ });
1773
+ constrain(recorder);
1774
+ return wheres;
1775
+ }
1776
+ /**
1777
+ * Resolve the `belongsTo` relation declaration on `Self` that points at
1778
+ * `ParentCtor`. When `relation` is given, looks it up directly. Otherwise
1779
+ * scans `Self.relations` for a single `belongsTo` whose `model()` resolves
1780
+ * to `ParentCtor` — throws on zero or multiple candidates.
1781
+ */
1782
+ function _resolveBelongsToFor(Self, ParentCtor, relation) {
1783
+ if (relation !== undefined) {
1784
+ const def = Self.relations[relation];
1785
+ if (!def)
1786
+ throw new Error(`[RudderJS ORM] Relation "${relation}" is not defined on ${Self.name}.`);
1787
+ if (def.type !== 'belongsTo') {
1788
+ throw new Error(`[RudderJS ORM] Relation "${relation}" on ${Self.name} is "${def.type}", not "belongsTo".`);
1789
+ }
1790
+ return def;
1791
+ }
1792
+ const candidates = [];
1793
+ for (const [name, def] of Object.entries(Self.relations)) {
1794
+ if (def.type !== 'belongsTo')
1795
+ continue;
1796
+ const btDef = def;
1797
+ if (btDef.model() === ParentCtor)
1798
+ candidates.push([name, btDef]);
1799
+ }
1800
+ if (candidates.length === 0) {
1801
+ throw new Error(`[RudderJS ORM] whereBelongsTo: ${Self.name} has no belongsTo relation pointing at ${ParentCtor.name}. ` +
1802
+ `Pass a relation name explicitly.`);
1803
+ }
1804
+ if (candidates.length > 1) {
1805
+ const names = candidates.map(([n]) => n).join(', ');
1806
+ throw new Error(`[RudderJS ORM] whereBelongsTo: ${Self.name} has multiple belongsTo relations pointing at ${ParentCtor.name} (${names}). ` +
1807
+ `Pass the relation name explicitly.`);
1808
+ }
1809
+ return candidates[0][1];
1810
+ }
1811
+ /**
1812
+ * Build the {@link RelationExistencePredicate} for a relation declared on
1813
+ * `Parent` by `relation` and dispatch it to the adapter via
1814
+ * `q.whereRelationExists(predicate)`. Returns the same QueryBuilder for
1815
+ * chaining (the adapter mutates in place).
1816
+ *
1817
+ * `morphTo` throws — the related table isn't statically known so a single
1818
+ * EXISTS subquery can't represent it. Filter on the discriminator + id
1819
+ * columns directly when you need that semantic.
1820
+ */
1821
+ function _attachWhereHas(Parent, q, relation, exists, constrain) {
1822
+ const def = Parent.relations[relation];
1823
+ if (!def) {
1824
+ throw new Error(`[RudderJS ORM] Relation "${relation}" is not defined on ${Parent.name}.`);
1825
+ }
1826
+ if (def.type === 'morphTo') {
1827
+ throw new Error(`[RudderJS ORM] morphTo "${relation}" cannot be used with whereHas — the related table is dynamic. ` +
1828
+ `Filter on ${def.morphName}Id / ${def.morphName}Type directly instead.`);
1829
+ }
1830
+ const constraintWheres = constrain ? _captureConstraintWheres(constrain) : [];
1831
+ const predicate = _buildRelationPredicate(Parent, relation, def, exists, constraintWheres);
1832
+ return q.whereRelationExists(predicate);
1833
+ }
1834
+ function _buildRelationPredicate(Parent, relation, def, exists, constraintWheres) {
1835
+ const Related = def.model();
1836
+ if (def.type === 'belongsToMany') {
1837
+ const meta = _resolveBelongsToManyMeta(Parent, Related, def);
1838
+ return {
1839
+ relation,
1840
+ exists,
1841
+ relatedTable: Related.getTable(),
1842
+ parentColumn: meta.parentKey,
1843
+ relatedColumn: meta.relatedKey,
1844
+ constraintWheres,
1845
+ through: {
1846
+ pivotTable: meta.pivotTable,
1847
+ foreignPivotKey: meta.foreignPivotKey,
1848
+ relatedPivotKey: meta.relatedPivotKey,
1849
+ },
1850
+ };
1851
+ }
1852
+ if (def.type === 'morphToMany') {
1853
+ const meta = _resolveMorphToManyMeta(Parent, Related, def);
1854
+ return {
1855
+ relation,
1856
+ exists,
1857
+ relatedTable: Related.getTable(),
1858
+ parentColumn: meta.parentKey,
1859
+ relatedColumn: meta.relatedKey,
1860
+ constraintWheres,
1861
+ extraEquals: { [meta.morphTypeKey]: meta.morphTypeValue },
1862
+ through: {
1863
+ pivotTable: meta.pivotTable,
1864
+ foreignPivotKey: meta.foreignPivotKey,
1865
+ relatedPivotKey: meta.relatedPivotKey,
1866
+ },
1867
+ };
1868
+ }
1869
+ if (def.type === 'morphedByMany') {
1870
+ const meta = _resolveMorphedByManyMeta(Parent, Related, def);
1871
+ return {
1872
+ relation,
1873
+ exists,
1874
+ relatedTable: Related.getTable(),
1875
+ parentColumn: meta.parentKey,
1876
+ relatedColumn: meta.relatedKey,
1877
+ constraintWheres,
1878
+ extraEquals: { [meta.morphTypeKey]: meta.morphTypeValue },
1879
+ through: {
1880
+ pivotTable: meta.pivotTable,
1881
+ foreignPivotKey: meta.foreignPivotKey,
1882
+ relatedPivotKey: meta.relatedPivotKey,
1883
+ },
1884
+ };
1885
+ }
1886
+ if (def.type === 'morphMany' || def.type === 'morphOne') {
1887
+ const idCol = `${def.morphName}Id`;
1888
+ const typeCol = `${def.morphName}Type`;
1889
+ const localCol = def.localKey ?? Parent.primaryKey;
1890
+ const typeVal = def.morphType ?? Parent.morphAlias ?? Parent.name;
1891
+ return {
1892
+ relation,
1893
+ exists,
1894
+ relatedTable: Related.getTable(),
1895
+ parentColumn: localCol,
1896
+ relatedColumn: idCol,
1897
+ constraintWheres,
1898
+ extraEquals: { [typeCol]: typeVal },
1899
+ };
1900
+ }
1901
+ if (def.type === 'belongsTo') {
1902
+ const fk = def.foreignKey ?? `${_camelHead(Related.name)}Id`;
1903
+ const localCol = def.localKey ?? fk;
1904
+ return {
1905
+ relation,
1906
+ exists,
1907
+ relatedTable: Related.getTable(),
1908
+ parentColumn: localCol,
1909
+ relatedColumn: Related.primaryKey,
1910
+ constraintWheres,
1911
+ };
1912
+ }
1913
+ // hasOne / hasMany — related table holds the FK pointing back to Parent.
1914
+ const fk = def.foreignKey ?? `${_camelHead(Parent.name)}Id`;
1915
+ const localCol = def.localKey ?? Parent.primaryKey;
1916
+ return {
1917
+ relation,
1918
+ exists,
1919
+ relatedTable: Related.getTable(),
1920
+ parentColumn: localCol,
1921
+ relatedColumn: fk,
1922
+ constraintWheres,
1923
+ };
1924
+ }
1925
+ /**
1926
+ * `withWhereHas` — run `whereHas` AND eager-load the relation under the
1927
+ * same constraint when the adapter implements `withConstrained`. Adapters
1928
+ * without it fall back to plain `with(relation)` (constraint applies only
1929
+ * to the parent filter, not the eagerly loaded children).
1930
+ */
1931
+ function _attachWithWhereHas(Parent, q, relation, constrain) {
1932
+ const constraintWheres = constrain ? _captureConstraintWheres(constrain) : [];
1933
+ // Reuse _attachWhereHas for the parent-side filter — it re-runs the
1934
+ // constrain callback against a fresh recorder, so the WhereClause[] we
1935
+ // capture above and the one captured inside _attachWhereHas come from
1936
+ // distinct recorder instances. That's intentional: each captures the
1937
+ // same constraint independently and neither is mutated by the adapter.
1938
+ _attachWhereHas(Parent, q, relation, true, constrain);
1939
+ const withConstrained = q.withConstrained;
1940
+ if (constraintWheres.length > 0 && typeof withConstrained === 'function') {
1941
+ return withConstrained.call(q, relation, constraintWheres);
1942
+ }
1943
+ return q.with(relation);
1944
+ }
1945
+ function _attachWhereBelongsTo(Self, q, parent, relation) {
1946
+ const ParentCtor = parent.constructor;
1947
+ const def = _resolveBelongsToFor(Self, ParentCtor, relation);
1948
+ const Related = def.model();
1949
+ const fk = def.foreignKey ?? `${_camelHead(Related.name)}Id`;
1950
+ const localCol = def.localKey ?? fk;
1951
+ const parentVal = parent[ParentCtor.primaryKey];
1952
+ if (parentVal === undefined || parentVal === null) {
1953
+ throw new Error(`[RudderJS ORM] whereBelongsTo: parent.${ParentCtor.primaryKey} is unset on ${ParentCtor.name}.`);
1954
+ }
1955
+ return q.where(localCol, parentVal);
1956
+ }
1239
1957
  const _CHAIN_METHODS = new Set([
1240
1958
  'where', 'orWhere', 'orderBy', 'limit', 'offset', 'with', 'withTrashed', 'onlyTrashed',
1241
1959
  ]);
@@ -1254,21 +1972,7 @@ function _replayChain(q, recorded) {
1254
1972
  }
1255
1973
  return cur;
1256
1974
  }
1257
- function _belongsToManyDeferredQb(Related, _def, meta, parentVal) {
1258
- const recorded = [];
1259
- const buildResolved = async () => {
1260
- const adapter = ModelRegistry.getAdapter();
1261
- const pivotRows = await adapter
1262
- .query(meta.pivotTable)
1263
- .where(meta.foreignPivotKey, parentVal)
1264
- .get();
1265
- const ids = pivotRows.map(r => r[meta.relatedPivotKey]);
1266
- // Empty IN list — short-circuit with a guaranteed-empty query so
1267
- // adapters don't have to handle the edge case.
1268
- const q = Related.query()
1269
- .where(meta.relatedKey, 'IN', ids.length === 0 ? [] : ids);
1270
- return _replayChain(q, recorded);
1271
- };
1975
+ function _makeDeferredProxy(buildResolved, recorded, relationKind) {
1272
1976
  const proxy = new Proxy({}, {
1273
1977
  get(_t, prop) {
1274
1978
  const name = String(prop);
@@ -1287,8 +1991,8 @@ function _belongsToManyDeferredQb(Related, _def, meta, parentVal) {
1287
1991
  }
1288
1992
  if (_UNSUPPORTED_TERMINALS.has(name)) {
1289
1993
  return () => {
1290
- throw new Error(`[RudderJS ORM] "${name}" is not supported on a belongsToMany lazy-fetch query. ` +
1291
- `Use Model.belongsToMany(parent, name) for pivot mutations or call methods on the related Model directly.`);
1994
+ throw new Error(`[RudderJS ORM] "${name}" is not supported on a ${relationKind} lazy-fetch query. ` +
1995
+ `Use Model.${relationKind}(parent, name) for pivot mutations or call methods on the related Model directly.`);
1292
1996
  };
1293
1997
  }
1294
1998
  return undefined;
@@ -1296,6 +2000,55 @@ function _belongsToManyDeferredQb(Related, _def, meta, parentVal) {
1296
2000
  });
1297
2001
  return proxy;
1298
2002
  }
2003
+ function _belongsToManyDeferredQb(Related, _def, meta, parentVal) {
2004
+ const recorded = [];
2005
+ const buildResolved = async () => {
2006
+ const adapter = ModelRegistry.getAdapter();
2007
+ const pivotRows = await adapter
2008
+ .query(meta.pivotTable)
2009
+ .where(meta.foreignPivotKey, parentVal)
2010
+ .get();
2011
+ const ids = pivotRows.map(r => r[meta.relatedPivotKey]);
2012
+ // Empty IN list — short-circuit with a guaranteed-empty query so
2013
+ // adapters don't have to handle the edge case.
2014
+ const q = Related.query()
2015
+ .where(meta.relatedKey, 'IN', ids.length === 0 ? [] : ids);
2016
+ return _replayChain(q, recorded);
2017
+ };
2018
+ return _makeDeferredProxy(buildResolved, recorded, 'belongsToMany');
2019
+ }
2020
+ function _morphToManyDeferredQb(Related, _def, meta, parentVal) {
2021
+ const recorded = [];
2022
+ const buildResolved = async () => {
2023
+ const adapter = ModelRegistry.getAdapter();
2024
+ const pivotRows = await adapter
2025
+ .query(meta.pivotTable)
2026
+ .where(meta.foreignPivotKey, parentVal)
2027
+ .where(meta.morphTypeKey, meta.morphTypeValue)
2028
+ .get();
2029
+ const ids = pivotRows.map(r => r[meta.relatedPivotKey]);
2030
+ const q = Related.query()
2031
+ .where(meta.relatedKey, 'IN', ids.length === 0 ? [] : ids);
2032
+ return _replayChain(q, recorded);
2033
+ };
2034
+ return _makeDeferredProxy(buildResolved, recorded, 'morphToMany');
2035
+ }
2036
+ function _morphedByManyDeferredQb(Related, _def, meta, parentVal) {
2037
+ const recorded = [];
2038
+ const buildResolved = async () => {
2039
+ const adapter = ModelRegistry.getAdapter();
2040
+ const pivotRows = await adapter
2041
+ .query(meta.pivotTable)
2042
+ .where(meta.foreignPivotKey, parentVal)
2043
+ .where(meta.morphTypeKey, meta.morphTypeValue)
2044
+ .get();
2045
+ const ids = pivotRows.map(r => r[meta.relatedPivotKey]);
2046
+ const q = Related.query()
2047
+ .where(meta.relatedKey, 'IN', ids.length === 0 ? [] : ids);
2048
+ return _replayChain(q, recorded);
2049
+ };
2050
+ return _makeDeferredProxy(buildResolved, recorded, 'morphedByMany');
2051
+ }
1299
2052
  function _normalizeAttachInput(input, foreignPivotKey, parentVal, relatedPivotKey, flatPivot) {
1300
2053
  const rows = [];
1301
2054
  if (Array.isArray(input)) {
@@ -1409,6 +2162,159 @@ function _installBelongsToManyMethods(ModelClass) {
1409
2162
  });
1410
2163
  }
1411
2164
  }
2165
+ function _makeMorphToManyAccessor(Parent, Related, def, parentVal) {
2166
+ const meta = _resolveMorphToManyMeta(Parent, Related, def);
2167
+ return {
2168
+ async attach(input, flatPivot) {
2169
+ const ids = _idsFromAttachInput(input);
2170
+ if (ids.length === 0)
2171
+ return;
2172
+ const rows = _normalizeAttachInput(input, meta.foreignPivotKey, parentVal, meta.relatedPivotKey, flatPivot)
2173
+ .map(r => ({ ...r, [meta.morphTypeKey]: meta.morphTypeValue }));
2174
+ await ModelRegistry.getAdapter()
2175
+ .query(meta.pivotTable)
2176
+ .insertMany(rows);
2177
+ },
2178
+ async detach(ids) {
2179
+ const adapter = ModelRegistry.getAdapter();
2180
+ let q = adapter
2181
+ .query(meta.pivotTable)
2182
+ .where(meta.foreignPivotKey, parentVal)
2183
+ .where(meta.morphTypeKey, meta.morphTypeValue);
2184
+ if (ids !== undefined) {
2185
+ if (ids.length === 0)
2186
+ return 0;
2187
+ q = q.where(meta.relatedPivotKey, 'IN', [...ids]);
2188
+ }
2189
+ return q.deleteAll();
2190
+ },
2191
+ async sync(desiredIds, flatPivot) {
2192
+ const adapter = ModelRegistry.getAdapter();
2193
+ const currentRows = await adapter
2194
+ .query(meta.pivotTable)
2195
+ .where(meta.foreignPivotKey, parentVal)
2196
+ .where(meta.morphTypeKey, meta.morphTypeValue)
2197
+ .get();
2198
+ const current = new Set(currentRows.map(r => r[meta.relatedPivotKey]));
2199
+ const desired = new Set(desiredIds);
2200
+ const attached = [];
2201
+ const detached = [];
2202
+ for (const id of desired)
2203
+ if (!current.has(id))
2204
+ attached.push(id);
2205
+ for (const id of current)
2206
+ if (!desired.has(id))
2207
+ detached.push(id);
2208
+ if (attached.length > 0) {
2209
+ const rows = attached.map(id => ({
2210
+ ...(flatPivot ?? {}),
2211
+ [meta.foreignPivotKey]: parentVal,
2212
+ [meta.relatedPivotKey]: id,
2213
+ [meta.morphTypeKey]: meta.morphTypeValue,
2214
+ }));
2215
+ await adapter.query(meta.pivotTable).insertMany(rows);
2216
+ }
2217
+ if (detached.length > 0) {
2218
+ await adapter
2219
+ .query(meta.pivotTable)
2220
+ .where(meta.foreignPivotKey, parentVal)
2221
+ .where(meta.morphTypeKey, meta.morphTypeValue)
2222
+ .where(meta.relatedPivotKey, 'IN', detached)
2223
+ .deleteAll();
2224
+ }
2225
+ return { attached, detached };
2226
+ },
2227
+ };
2228
+ }
2229
+ function _makeMorphedByManyAccessor(Parent, Related, def, parentVal) {
2230
+ const meta = _resolveMorphedByManyMeta(Parent, Related, def);
2231
+ return {
2232
+ async attach(input, flatPivot) {
2233
+ const ids = _idsFromAttachInput(input);
2234
+ if (ids.length === 0)
2235
+ return;
2236
+ // Parent is the strong side, related is the polymorphic side. Each row
2237
+ // carries: parent FK, related FK, related-class discriminator.
2238
+ const rows = _normalizeAttachInput(input, meta.foreignPivotKey, parentVal, meta.relatedPivotKey, flatPivot)
2239
+ .map(r => ({ ...r, [meta.morphTypeKey]: meta.morphTypeValue }));
2240
+ await ModelRegistry.getAdapter()
2241
+ .query(meta.pivotTable)
2242
+ .insertMany(rows);
2243
+ },
2244
+ async detach(ids) {
2245
+ const adapter = ModelRegistry.getAdapter();
2246
+ let q = adapter
2247
+ .query(meta.pivotTable)
2248
+ .where(meta.foreignPivotKey, parentVal)
2249
+ .where(meta.morphTypeKey, meta.morphTypeValue);
2250
+ if (ids !== undefined) {
2251
+ if (ids.length === 0)
2252
+ return 0;
2253
+ q = q.where(meta.relatedPivotKey, 'IN', [...ids]);
2254
+ }
2255
+ return q.deleteAll();
2256
+ },
2257
+ async sync(desiredIds, flatPivot) {
2258
+ const adapter = ModelRegistry.getAdapter();
2259
+ const currentRows = await adapter
2260
+ .query(meta.pivotTable)
2261
+ .where(meta.foreignPivotKey, parentVal)
2262
+ .where(meta.morphTypeKey, meta.morphTypeValue)
2263
+ .get();
2264
+ const current = new Set(currentRows.map(r => r[meta.relatedPivotKey]));
2265
+ const desired = new Set(desiredIds);
2266
+ const attached = [];
2267
+ const detached = [];
2268
+ for (const id of desired)
2269
+ if (!current.has(id))
2270
+ attached.push(id);
2271
+ for (const id of current)
2272
+ if (!desired.has(id))
2273
+ detached.push(id);
2274
+ if (attached.length > 0) {
2275
+ const rows = attached.map(id => ({
2276
+ ...(flatPivot ?? {}),
2277
+ [meta.foreignPivotKey]: parentVal,
2278
+ [meta.relatedPivotKey]: id,
2279
+ [meta.morphTypeKey]: meta.morphTypeValue,
2280
+ }));
2281
+ await adapter.query(meta.pivotTable).insertMany(rows);
2282
+ }
2283
+ if (detached.length > 0) {
2284
+ await adapter
2285
+ .query(meta.pivotTable)
2286
+ .where(meta.foreignPivotKey, parentVal)
2287
+ .where(meta.morphTypeKey, meta.morphTypeValue)
2288
+ .where(meta.relatedPivotKey, 'IN', detached)
2289
+ .deleteAll();
2290
+ }
2291
+ return { attached, detached };
2292
+ },
2293
+ };
2294
+ }
2295
+ /**
2296
+ * Install per-relation prototype methods for every `morphToMany` /
2297
+ * `morphedByMany` entry. Same idempotent shape as
2298
+ * {@link _installBelongsToManyMethods}.
2299
+ */
2300
+ function _installMorphPivotMethods(ModelClass) {
2301
+ for (const [name, def] of Object.entries(ModelClass.relations)) {
2302
+ if (def.type !== 'morphToMany' && def.type !== 'morphedByMany')
2303
+ continue;
2304
+ if (Object.prototype.hasOwnProperty.call(ModelClass.prototype, name))
2305
+ continue;
2306
+ const isOwning = def.type === 'morphToMany';
2307
+ Object.defineProperty(ModelClass.prototype, name, {
2308
+ configurable: true,
2309
+ writable: true,
2310
+ value() {
2311
+ return isOwning
2312
+ ? Model.morphToMany(this, name)
2313
+ : Model.morphedByMany(this, name);
2314
+ },
2315
+ });
2316
+ }
2317
+ }
1412
2318
  // ─── Compile-time contract check ───────────────────────────
1413
2319
  // Asserts that `Model`'s static surface conforms to the `ModelLike`
1414
2320
  // contract from `@rudderjs/contracts`. Downstream tools (admin panels