@prisma-idb/client-idb 0.0.0 → 0.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.
@@ -1,7 +1,9 @@
1
1
  import { AsyncIterableResult } from "@prisma/orm-framework/components/runtime";
2
2
  import { andExpr, evaluateFilter, fieldFilter, nullCheckExpr, shorthandToFilterExpr } from "@prisma-idb/adapter-idb/runtime";
3
3
  import { domainModelsAtDefaultNamespace } from "@prisma/orm-framework/contract/types";
4
+ import { compareFieldValues, fieldValueToken, fieldValuesEqual, isValidIdbKey, keyEquals, keyToken } from "@prisma-idb/target-idb/runtime";
4
5
  import { generateId } from "@prisma/orm-framework/ids/runtime";
6
+ import { keyPathFields } from "@prisma-idb/target-idb/pack";
5
7
  //#region src/core/types.ts
6
8
  /**
7
9
  * Extract the `storeName` from a model's storage metadata at runtime.
@@ -12,22 +14,30 @@ function getStoreName(contract, modelName) {
12
14
  }
13
15
  /**
14
16
  * Extract the `keyPath` from a model's storage metadata at runtime.
15
- * Falls back to `"id"` (the invariant key name for all syncable IDB models).
17
+ * A single field name for the common case; an ordered array of field names
18
+ * for a compound primary key.
19
+ *
20
+ * @throws if the model is unknown or its storage has no `keyPath` — a
21
+ * validated contract always has one, so this only fires on a malformed or
22
+ * hand-built contract, where guessing a key name would silently read/write
23
+ * the wrong key.
16
24
  */
17
- function getKeyPath$1(contract, modelName) {
18
- return (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.keyPath ?? "id";
25
+ function getKeyPath(contract, modelName) {
26
+ const keyPath = (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.keyPath;
27
+ if (keyPath === void 0) throw new Error(`Model "${modelName}" has no storage.keyPath in the contract.`);
28
+ return keyPath;
19
29
  }
20
30
  /**
21
- * Find the IDB index name for `fieldName` on the given object store, searching
22
- * `contract.storage.stores[storeName].indexes` by `keyPath` equality.
23
- *
24
- * Returns the index name (e.g. `"byEmail"`) when a single-field index whose
25
- * `keyPath` equals `fieldName` exists, or `undefined` otherwise.
26
- * Multi-entry and composite indexes (`keyPath` is an array) are skipped —
27
- * equality semantics on those are unsupported.
31
+ * Extracts a row's primary key as an {@link IDBValidKey} — the scalar value
32
+ * at `row[keyPath]` for a single-field key, or an ordered array of the
33
+ * compound key's member field values (matching the array form IDB itself
34
+ * expects for a compound `keyPath`). The one construction site every
35
+ * key-get/put/delete/update call should route through, so compound-key field
36
+ * order stays consistent everywhere.
28
37
  */
29
- function getIndexForField(contract, storeName, fieldName) {
30
- return buildFieldToIndexMap(contract, storeName)[fieldName];
38
+ function extractKeyFromRow(row, keyPath) {
39
+ if (typeof keyPath === "string") return row[keyPath];
40
+ return keyPath.map((field) => row[field]);
31
41
  }
32
42
  /**
33
43
  * Build a field → indexName lookup for every single-field, non-multi-entry
@@ -43,27 +53,6 @@ function buildFieldToIndexMap(contract, storeName) {
43
53
  return result;
44
54
  }
45
55
  /**
46
- * Returns `true` when `value` is a valid {@link IDBValidKey} — i.e. a value
47
- * that can be passed to `IDBKeyRange.only()` without throwing a DataError.
48
- * Booleans, `NaN`, `BigInt`, plain objects, and `null`/`undefined` are not
49
- * valid IDB keys (only number/string/Date/binary/Array are, per the
50
- * IndexedDB spec). Arrays are validated recursively — an array containing
51
- * any invalid element (e.g. a nested boolean) is itself not a valid key.
52
- *
53
- * Shared by the relation loader (filtering FK values before building
54
- * `IDBKeyRange.only()` plans) and query-shaping (gating `eq` conditions for
55
- * index/PK point-range acceleration).
56
- */
57
- function isValidIdbKey(value) {
58
- if (typeof value === "number") return !Number.isNaN(value);
59
- if (typeof value === "string") return true;
60
- if (value instanceof Date) return true;
61
- if (value instanceof ArrayBuffer) return true;
62
- if (ArrayBuffer.isView(value)) return true;
63
- if (Array.isArray(value)) return value.every((v) => isValidIdbKey(v));
64
- return false;
65
- }
66
- /**
67
56
  * Resolve a model's named relation to a {@link ContractReferenceRelation} at
68
57
  * runtime, or `undefined` when the relation is absent or an embedded relation
69
58
  * (no `on` join block). Used by `include()` to find the related model name and
@@ -201,6 +190,21 @@ function isIndexableEqValue(value) {
201
190
  return isValidIdbKey(value);
202
191
  }
203
192
  /**
193
+ * `true` when `field` is a *single-field* primary key that a lone `eq`
194
+ * condition can directly point-range-query (`store.get`/`openCursor(range)`
195
+ * against the store's own keyPath, no named index required).
196
+ *
197
+ * Deliberately `false` for a compound (array) `keyPath`, even if `field`
198
+ * happens to be one of its member fields — a single `eq` condition can only
199
+ * pin one member, not the whole compound key, so it can't drive a PK
200
+ * point-range scan on its own. Accelerating that case needs *all* member
201
+ * fields ANDed together, which is a cost-based multi-field planner decision
202
+ * (Phase 10 §3.2), not this peephole optimizer's job.
203
+ */
204
+ function isPrimaryKeyField(field, keyPath) {
205
+ return typeof keyPath === "string" && field === keyPath;
206
+ }
207
+ /**
204
208
  * Scan the combined filter expression for the first `eq` field condition whose
205
209
  * field either has a matching IDB index (`fieldToIndexName[field]` is set) or
206
210
  * *is* the store's own primary key (`keyPath`) — a store's keyPath is always
@@ -219,7 +223,7 @@ function extractIndexEqualityHint(filter, fieldToIndexName, keyPath) {
219
223
  if (filter === void 0) return null;
220
224
  if (filter.kind === "field" && filter.op === "eq") {
221
225
  const indexName = fieldToIndexName[filter.field];
222
- if ((indexName !== void 0 || filter.field === keyPath) && isIndexableEqValue(filter.value)) return {
226
+ if ((indexName !== void 0 || isPrimaryKeyField(filter.field, keyPath)) && isIndexableEqValue(filter.value)) return {
223
227
  indexName,
224
228
  value: filter.value,
225
229
  remainingFilter: void 0
@@ -232,7 +236,7 @@ function extractIndexEqualityHint(filter, fieldToIndexName, keyPath) {
232
236
  const expr = flat[i];
233
237
  if (expr.kind === "field" && expr.op === "eq") {
234
238
  const indexName = fieldToIndexName[expr.field];
235
- if ((indexName !== void 0 || expr.field === keyPath) && isIndexableEqValue(expr.value)) {
239
+ if ((indexName !== void 0 || isPrimaryKeyField(expr.field, keyPath)) && isIndexableEqValue(expr.value)) {
236
240
  const rest = flat.filter((_, j) => j !== i);
237
241
  return {
238
242
  indexName,
@@ -257,7 +261,7 @@ function tryExtractOrBranches(orNode, fieldToIndexName, keyPath, remainingFilter
257
261
  for (const expr of orNode.exprs) {
258
262
  if (expr.kind !== "field" || expr.op !== "eq") return null;
259
263
  const indexName = fieldToIndexName[expr.field];
260
- if (indexName === void 0 && expr.field !== keyPath || !isIndexableEqValue(expr.value)) return null;
264
+ if (indexName === void 0 && !isPrimaryKeyField(expr.field, keyPath) || !isIndexableEqValue(expr.value)) return null;
261
265
  branches.push({
262
266
  indexName,
263
267
  value: expr.value
@@ -297,22 +301,59 @@ function extractIndexOrHint(filter, fieldToIndexName, keyPath) {
297
301
  * Build an in-memory comparator from an `orderBy` spec (field → direction).
298
302
  *
299
303
  * Returns `undefined` when there is nothing to sort by. Compares fields in
300
- * declaration order; values are primitives (strings, numbers, dates) in
301
- * practice, so JS relational comparison is sufficient.
304
+ * declaration order, with {@link compareFieldValues}: the same order an
305
+ * IndexedDB index uses, so equal `Date`s tie and fall through to the next
306
+ * field, and `null`s sort last (first when descending).
302
307
  */
303
308
  function buildRowComparator(orderBy) {
304
309
  if (orderBy === void 0) return void 0;
305
310
  return (a, b) => {
306
311
  for (const [field, dir] of Object.entries(orderBy)) {
307
- const av = a[field];
308
- const bv = b[field];
309
- if (av === bv) continue;
310
- const cmp = av < bv ? -1 : 1;
312
+ const cmp = compareFieldValues(a[field], b[field]);
313
+ if (cmp === 0) continue;
311
314
  return dir === "desc" ? -cmp : cmp;
312
315
  }
313
316
  return 0;
314
317
  };
315
318
  }
319
+ /**
320
+ * `true` when a `cursor-scan` plan's result *cardinality* is fully determined
321
+ * by its store/index + key range — i.e. there is no in-memory `filter` that
322
+ * would need each row's value. Only then can `count()` be answered natively
323
+ * (`store.count(range)` / `index.count(range)`) without deserializing rows.
324
+ *
325
+ * `comparator` (ORDER BY), `direction`, `skip` and `take` don't change *which*
326
+ * rows match, so they don't disqualify a plan — `skip`/`take` are applied to
327
+ * the native total afterwards via {@link clampCount}.
328
+ *
329
+ * Safe for index scans because the equality hint that builds `range` never
330
+ * selects a `multiEntry` (or compound) index — `buildFieldToIndexMap` excludes
331
+ * them — so one record maps to at most one index entry and entries == rows.
332
+ */
333
+ function isNativelyCountable(plan) {
334
+ return plan.filter === void 0;
335
+ }
336
+ /** Rewrites a natively-countable `cursor-scan` as the equivalent native `count` plan. */
337
+ function toCountPlan(plan) {
338
+ return {
339
+ meta: plan.meta,
340
+ kind: "count",
341
+ storeName: plan.storeName,
342
+ ...plan.indexName !== void 0 ? { indexName: plan.indexName } : {},
343
+ ...plan.range !== void 0 ? { range: plan.range } : {}
344
+ };
345
+ }
346
+ /**
347
+ * Applies `skip`/`take` to an *unpaginated* total. A paginated count is pure
348
+ * arithmetic on the unpaginated count — `max(0, total - skip)`, capped at
349
+ * `take` — so this is exact, not an approximation.
350
+ */
351
+ function clampCount(total, skip, take) {
352
+ let n = total;
353
+ if (skip !== void 0) n = Math.max(0, n - skip);
354
+ if (take !== void 0) n = Math.min(take, n);
355
+ return n;
356
+ }
316
357
  //#endregion
317
358
  //#region src/core/aggregate-builder.ts
318
359
  /**
@@ -533,8 +574,11 @@ function applyUpdateDefaults(defaults, storeName, patch, cache) {
533
574
  * Batch-load a single named relation for all rows in `rows` and attach the
534
575
  * result to each row under the `relName` key.
535
576
  *
536
- * The join is done with one cursor scan over the related store (with an
537
- * in-memory filter), then grouped/indexed in memory — avoiding N+1 queries.
577
+ * The join matches every field of the relation, so compound foreign keys join
578
+ * on the whole tuple. It uses one key-range scan per distinct tuple when an
579
+ * index or the related primary key covers the target fields, and otherwise
580
+ * one full scan with an in-memory filter. Rows are then grouped in memory,
581
+ * which avoids N+1 queries.
538
582
  *
539
583
  * The `entry` carries any `include()` refinement:
540
584
  *
@@ -562,23 +606,22 @@ async function loadRelation(relName, entry, rows, contract, modelName, executor,
562
606
  const relation = rawRelation;
563
607
  const { cardinality, on } = relation;
564
608
  const relatedModelName = relation.to.model;
565
- const localField = on.localFields[0];
566
- const foreignField = on.targetFields[0];
567
- if (localField === void 0 || foreignField === void 0) return rows;
609
+ const localFields = on.localFields;
610
+ const targetFields = on.targetFields;
611
+ if (localFields.length === 0 || localFields.length !== targetFields.length) return rows;
568
612
  const relatedModel = models[relatedModelName];
569
613
  if (relatedModel === void 0) return rows;
570
614
  const relatedStoreName = typeof relatedModel.storage === "object" && relatedModel.storage !== null && "storeName" in relatedModel.storage ? String(relatedModel.storage["storeName"]) : relatedModelName;
571
615
  const isScalar = entry.kind === "scalar";
572
- const localValues = /* @__PURE__ */ new Set();
616
+ const localTuples = /* @__PURE__ */ new Map();
573
617
  for (const row of rows) {
574
- const v = row[localField];
575
- if (v !== void 0 && v !== null) localValues.add(v);
618
+ const token = tupleToken(row, localFields);
619
+ if (token !== null) localTuples.set(token, localFields.map((f) => row[f]));
576
620
  }
577
- if (localValues.size === 0) return rows.map((row) => ({
621
+ if (localTuples.size === 0) return rows.map((row) => ({
578
622
  ...row,
579
623
  [relName]: isScalar ? 0 : cardinality === "1:N" ? [] : null
580
624
  }));
581
- const capturedForeignField = foreignField;
582
625
  const refinedWhere = combineFilterExprs(entry.state.filters);
583
626
  const planMeta = {
584
627
  target: "idb",
@@ -586,21 +629,25 @@ async function loadRelation(relName, entry, rows, contract, modelName, executor,
586
629
  lane: "idb-orm",
587
630
  annotations: { groupingKey }
588
631
  };
589
- const fkIndexName = typeof IDBKeyRange !== "undefined" ? getIndexForField(contract, relatedStoreName, capturedForeignField) : void 0;
590
- const targetsRelatedPk = fkIndexName === void 0 && typeof IDBKeyRange !== "undefined" && capturedForeignField === getKeyPath$1(contract, relatedModelName);
632
+ const rangeSource = findRangeSource(contract, relatedStoreName, relatedModelName, targetFields);
591
633
  let relatedRows;
592
- if (fkIndexName !== void 0 || targetsRelatedPk) {
634
+ if (rangeSource !== void 0) {
593
635
  const refinedFilter = refinedWhere !== void 0 ? (row) => evaluateFilter(refinedWhere, row) : void 0;
594
- const validValues = Array.from(localValues).filter(isValidIdbKey);
595
- relatedRows = (await Promise.all(validValues.map(async (value) => {
636
+ const ranges = [];
637
+ for (const values of localTuples.values()) {
638
+ const ordered = keyPathFields(rangeSource.keyPath).map((f) => values[targetFields.indexOf(f)]);
639
+ if (!ordered.every((v) => isValidIdbKey(v))) continue;
640
+ ranges.push(IDBKeyRange.only(typeof rangeSource.keyPath === "string" ? ordered[0] : ordered));
641
+ }
642
+ relatedRows = (await Promise.all(ranges.map(async (range) => {
596
643
  const plan = {
597
644
  meta: planMeta,
598
645
  idbPlan: {
599
646
  meta: planMeta,
600
647
  kind: "cursor-scan",
601
648
  storeName: relatedStoreName,
602
- ...fkIndexName !== void 0 ? { indexName: fkIndexName } : {},
603
- range: IDBKeyRange.only(value),
649
+ ...rangeSource.indexName !== void 0 ? { indexName: rangeSource.indexName } : {},
650
+ range,
604
651
  ...refinedFilter !== void 0 ? { filter: refinedFilter } : {}
605
652
  }
606
653
  };
@@ -610,7 +657,10 @@ async function loadRelation(relName, entry, rows, contract, modelName, executor,
610
657
  }))).flat();
611
658
  } else {
612
659
  relatedRows = [];
613
- const filter = (row) => localValues.has(row[capturedForeignField]) && (refinedWhere === void 0 || evaluateFilter(refinedWhere, row));
660
+ const filter = (row) => {
661
+ const token = tupleToken(row, targetFields);
662
+ return token !== null && localTuples.has(token) && (refinedWhere === void 0 || evaluateFilter(refinedWhere, row));
663
+ };
614
664
  const plan = {
615
665
  meta: planMeta,
616
666
  idbPlan: {
@@ -625,20 +675,25 @@ async function loadRelation(relName, entry, rows, contract, modelName, executor,
625
675
  if (cardinality === "1:N") {
626
676
  const grouped = /* @__PURE__ */ new Map();
627
677
  for (const rrow of relatedRows) {
628
- const gk = rrow[capturedForeignField];
678
+ const gk = tupleToken(rrow, targetFields);
679
+ if (gk === null) continue;
629
680
  const group = grouped.get(gk) ?? [];
630
681
  group.push(rrow);
631
682
  grouped.set(gk, group);
632
683
  }
684
+ const groupFor = (row) => {
685
+ const token = tupleToken(row, localFields);
686
+ return token === null ? [] : grouped.get(token) ?? [];
687
+ };
633
688
  if (isScalar) return rows.map((row) => ({
634
689
  ...row,
635
- [relName]: (grouped.get(row[localField]) ?? []).length
690
+ [relName]: groupFor(row).length
636
691
  }));
637
692
  const comparator = buildRowComparator(entry.state.orderBy);
638
693
  const skip = entry.state.skip ?? 0;
639
694
  const take = entry.state.take;
640
695
  return rows.map((row) => {
641
- let group = grouped.get(row[localField]) ?? [];
696
+ let group = groupFor(row);
642
697
  if (comparator !== void 0) group = [...group].sort(comparator);
643
698
  if (skip > 0 || take !== void 0) group = group.slice(skip, take !== void 0 ? skip + take : void 0);
644
699
  return {
@@ -648,11 +703,50 @@ async function loadRelation(relName, entry, rows, contract, modelName, executor,
648
703
  });
649
704
  }
650
705
  const indexed = /* @__PURE__ */ new Map();
651
- for (const rrow of relatedRows) indexed.set(rrow[capturedForeignField], rrow);
652
- return rows.map((row) => ({
653
- ...row,
654
- [relName]: indexed.get(row[localField]) ?? null
655
- }));
706
+ for (const rrow of relatedRows) {
707
+ const token = tupleToken(rrow, targetFields);
708
+ if (token !== null) indexed.set(token, rrow);
709
+ }
710
+ return rows.map((row) => {
711
+ const token = tupleToken(row, localFields);
712
+ return {
713
+ ...row,
714
+ [relName]: (token === null ? void 0 : indexed.get(token)) ?? null
715
+ };
716
+ });
717
+ }
718
+ /**
719
+ * A `Map` key for the values of `fields` in `row`, or `null` when any of them
720
+ * is null or undefined. Equal `Date`s and binary values give equal tokens.
721
+ */
722
+ function tupleToken(row, fields) {
723
+ const parts = [];
724
+ for (const field of fields) {
725
+ const value = row[field];
726
+ if (value === null || value === void 0) return null;
727
+ const token = fieldValueToken(value);
728
+ parts.push([typeof token, String(token)]);
729
+ }
730
+ return JSON.stringify(parts);
731
+ }
732
+ /**
733
+ * Finds an index, or else the store's primary key, whose key path has exactly
734
+ * `fields` (in any order), so a lookup on those fields can use a key range.
735
+ * Returns `undefined` when there is none, or when `IDBKeyRange` isn't available.
736
+ */
737
+ function findRangeSource(contract, storeName, modelName, fields) {
738
+ if (typeof IDBKeyRange === "undefined") return void 0;
739
+ const coversFields = (keyPath) => {
740
+ const keyFields = keyPathFields(keyPath);
741
+ return keyFields.length === fields.length && keyFields.every((f) => fields.includes(f));
742
+ };
743
+ const indexes = contract.storage.stores[storeName]?.indexes ?? {};
744
+ for (const [indexName, indexDef] of Object.entries(indexes)) if (indexDef.multiEntry !== true && coversFields(indexDef.keyPath)) return {
745
+ indexName,
746
+ keyPath: indexDef.keyPath
747
+ };
748
+ const primaryKey = getKeyPath(contract, modelName);
749
+ return coversFields(primaryKey) ? { keyPath: primaryKey } : void 0;
656
750
  }
657
751
  //#endregion
658
752
  //#region src/core/mutation-scope.ts
@@ -774,22 +868,49 @@ function requireTransactionExecutor(executor) {
774
868
  async function executeNestedCreateMutation(options) {
775
869
  const { executor, contract, modelName, data } = options;
776
870
  const record = data;
777
- const storeNames = collectStoreNames(contract, modelName, record);
871
+ const storeNames = collectStoreNames(contract, modelName, record, "create");
778
872
  const defaultsCache = createMutationDefaultsCache();
779
873
  return withMutationScope(executor, storeNames, (scope) => createGraph(scope, contract, modelName, record, defaultsCache));
780
874
  }
781
875
  async function executeNestedUpdateMutation(options) {
782
876
  const { executor, contract, modelName, filters, data } = options;
783
877
  const record = data;
784
- const storeNames = collectStoreNames(contract, modelName, record);
878
+ const storeNames = collectStoreNames(contract, modelName, record, "update");
785
879
  const defaultsCache = createMutationDefaultsCache();
786
880
  return withMutationScope(executor, storeNames, (scope) => updateFirstGraph(scope, contract, modelName, filters, record, defaultsCache));
787
881
  }
788
- function collectStoreNames(contract, modelName, data) {
789
- const stores = /* @__PURE__ */ new Set([getStoreName(contract, modelName)]);
790
- for (const def of getRelationDefinitions(contract, modelName)) if (def.relationName in data && isRelationMutationCallback(data[def.relationName])) stores.add(def.relatedStoreName);
882
+ /**
883
+ * Every store a nested write may touch: the model's own store, each related
884
+ * store a relation callback writes to, and the parent stores that foreign-key
885
+ * checks read, for the model and for each related model. An update also
886
+ * declares the stores its `onUpdate` referential actions may touch.
887
+ *
888
+ * Parent stores are declared for every foreign key, not only the ones `data`
889
+ * sets, because a nested write can set foreign keys through `connect()` or
890
+ * through a default.
891
+ */
892
+ function collectStoreNames(contract, modelName, data, kind) {
893
+ const stores = /* @__PURE__ */ new Set([getStoreName(contract, modelName), ...fkParentStoreNames(contract, modelName)]);
894
+ const scalarData = {};
895
+ const relationNames = /* @__PURE__ */ new Set();
896
+ for (const def of getRelationDefinitions(contract, modelName)) {
897
+ relationNames.add(def.relationName);
898
+ if (def.relationName in data && isRelationMutationCallback(data[def.relationName])) {
899
+ stores.add(def.relatedStoreName);
900
+ for (const store of fkParentStoreNames(contract, def.relatedModelName)) stores.add(store);
901
+ }
902
+ }
903
+ if (kind === "update") {
904
+ for (const [field, value] of Object.entries(data)) if (!relationNames.has(field)) scalarData[field] = value;
905
+ const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, getStoreName(contract, modelName), scalarData, createMutationDefaultsCache());
906
+ for (const store of collectOnUpdateEnforcementStoreNames(contract, modelName, patch).storeNames) stores.add(store);
907
+ }
791
908
  return [...stores];
792
909
  }
910
+ /** The stores of every parent `modelName` has a foreign key to. */
911
+ function fkParentStoreNames(contract, modelName) {
912
+ return getRelationDefinitions(contract, modelName).filter((def) => def.cardinality === "N:1").map((def) => def.relatedStoreName);
913
+ }
793
914
  async function createGraph(scope, contract, modelName, input, defaultsCache) {
794
915
  const parsed = parseMutationInput(contract, modelName, input);
795
916
  const { parentOwned, childOwned } = partitionByOwnership(parsed.relationMutations);
@@ -815,9 +936,11 @@ async function updateFirstGraph(scope, contract, modelName, filters, input, defa
815
936
  let parentRow = existingRow;
816
937
  if (Object.keys(scalarData).length > 0) {
817
938
  const storeName = getStoreName(contract, modelName);
818
- const key = existingRow[getKeyPath(contract, modelName)];
939
+ const key = extractKeyFromRow(existingRow, getKeyPath(contract, modelName));
819
940
  const meta = makePlanMeta(contract);
820
941
  const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, storeName, scalarData, defaultsCache);
942
+ await validateScalarFks(scope, contract, modelName, patch, existingRow);
943
+ await applyReferentialActionsForRowOnUpdate(scope, contract, modelName, existingRow, patch);
821
944
  const updated = (await scope.execute({
822
945
  meta,
823
946
  kind: "update",
@@ -970,6 +1093,7 @@ async function insertSingleRow(scope, contract, modelName, data, defaultsCache)
970
1093
  const storeName = getStoreName(contract, modelName);
971
1094
  const meta = makePlanMeta(contract);
972
1095
  const record = applyCreateDefaults(contract.execution?.mutations.defaults, storeName, data, defaultsCache);
1096
+ await validateScalarFks(scope, contract, modelName, record);
973
1097
  return (await scope.execute({
974
1098
  meta,
975
1099
  kind: "add",
@@ -1020,10 +1144,7 @@ function buildCriterionFilter(criterion) {
1020
1144
  }
1021
1145
  function buildParentJoinFilter(parentValues) {
1022
1146
  const pairs = [...parentValues.entries()];
1023
- return (row) => pairs.every(([childField, parentValue]) => row[childField] === parentValue);
1024
- }
1025
- function getKeyPath(contract, modelName) {
1026
- return (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.keyPath ?? "id";
1147
+ return (row) => pairs.every(([childField, parentValue]) => fieldValuesEqual(row[childField], parentValue));
1027
1148
  }
1028
1149
  function getStoredAction(contract, modelName, relationName, kind) {
1029
1150
  return (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.relations?.[relationName]?.[kind];
@@ -1048,7 +1169,10 @@ function sameFields(a, b) {
1048
1169
  * action kind on both sides so this fallback never has to arbitrate a real
1049
1170
  * conflict.
1050
1171
  */
1051
- function getReferentialActionForRelation(contract, modelName, def, kind, defaultAction) {
1172
+ function getReferentialActionForRelation(contract, modelName, def, kind) {
1173
+ return enforcedAction(findDeclaredAction(contract, modelName, def, kind));
1174
+ }
1175
+ function findDeclaredAction(contract, modelName, def, kind) {
1052
1176
  const direct = getStoredAction(contract, modelName, def.relationName, kind);
1053
1177
  if (direct !== void 0) return direct;
1054
1178
  for (const inverse of getRelationDefinitions(contract, def.relatedModelName)) {
@@ -1058,29 +1182,88 @@ function getReferentialActionForRelation(contract, modelName, def, kind, default
1058
1182
  const inverseAction = getStoredAction(contract, def.relatedModelName, inverse.relationName, kind);
1059
1183
  if (inverseAction !== void 0) return inverseAction;
1060
1184
  }
1061
- return defaultAction;
1062
- }
1063
- function getOnDeleteForDeleteRelation(contract, modelName, def) {
1064
- return getReferentialActionForRelation(contract, modelName, def, "onDelete", "restrict");
1065
1185
  }
1066
1186
  /**
1067
- * `onUpdate`'s default (when neither side declares one) is `cascade`, unlike
1068
- * `onDelete`'s `restrict` default — this matches Prisma's own documented
1069
- * behavior: propagating a changed referenced value to children is normally
1070
- * safe, unlike deleting the row those children point to.
1187
+ * Applies the default and resolves `noAction`, matching what Postgres does
1188
+ * with the same schema, so a synced app behaves the same on both sides:
1189
+ *
1190
+ * - An undeclared action defaults to `restrict`, for `onDelete` and
1191
+ * `onUpdate` alike. Prisma 8 emits no `ON DELETE`/`ON UPDATE` clause for an
1192
+ * undeclared action, and the database default is `NO ACTION`.
1193
+ * - `noAction` behaves like `restrict`. In SQL, `NO ACTION` also rejects the
1194
+ * change; it only defers the check to the end of the statement, which makes
1195
+ * no difference here.
1071
1196
  */
1197
+ function enforcedAction(declared) {
1198
+ return declared === void 0 || declared === "noAction" ? "restrict" : declared;
1199
+ }
1200
+ function getOnDeleteForDeleteRelation(contract, modelName, def) {
1201
+ return getReferentialActionForRelation(contract, modelName, def, "onDelete");
1202
+ }
1072
1203
  function getOnUpdateForRelation(contract, modelName, def) {
1073
- return getReferentialActionForRelation(contract, modelName, def, "onUpdate", "cascade");
1204
+ return getReferentialActionForRelation(contract, modelName, def, "onUpdate");
1074
1205
  }
1075
1206
  function isDeleteEnforcementRelation(contract, modelName, def) {
1076
1207
  if (def.cardinality === "1:N") return true;
1077
1208
  if (def.cardinality === "1:1") {
1078
1209
  const keyPath = getKeyPath(contract, modelName);
1079
- return def.localFields.length > 0 && def.localFields[0] === keyPath;
1210
+ return sameFields(def.localFields, keyPathFields(keyPath));
1080
1211
  }
1081
1212
  return false;
1082
1213
  }
1083
1214
  /**
1215
+ * An `IDBKeyRange` for the lookup "does `modelName` have a row whose `field`
1216
+ * equals `value`?" — but ONLY when `field` is that model's own single-field
1217
+ * primary key (so the answer is a pure key lookup that needs no row value)
1218
+ * and `value` is a legal IDB key. `null` otherwise, and the caller keeps its
1219
+ * value-materializing `cursor-scan`.
1220
+ *
1221
+ * Deliberately `null` for a compound primary key even if `field` is one of its
1222
+ * members (one member can't pin the whole key), and for any non-key field
1223
+ * (that needs an index-resolution step — Phase 10.5's shared primitive, not
1224
+ * this helper's job). `IDBKeyRange.only` throws `DataError` on invalid keys
1225
+ * (`null`, `NaN`, booleans, …), hence the guard.
1226
+ */
1227
+ function pkEqualityRange(contract, modelName, field, value) {
1228
+ if (typeof IDBKeyRange === "undefined") return null;
1229
+ const keyPath = getKeyPath(contract, modelName);
1230
+ if (typeof keyPath !== "string" || keyPath !== field) return null;
1231
+ if (!isValidIdbKey(value)) return null;
1232
+ return IDBKeyRange.only(value);
1233
+ }
1234
+ /**
1235
+ * Resolves to the first primary key in `storeName` within `range`, or
1236
+ * `undefined` — one `getKey` request, no row deserialized.
1237
+ */
1238
+ async function firstKeyInRange(scope, meta, storeName, range) {
1239
+ return (await scope.execute({
1240
+ meta,
1241
+ kind: "keys",
1242
+ storeName,
1243
+ range,
1244
+ take: 1
1245
+ }))[0]?.["key"];
1246
+ }
1247
+ /**
1248
+ * `true` if any child row matches the relation against `parentRow`'s values —
1249
+ * the existence check behind `restrict`. Key-only when the relation's single
1250
+ * child field is the child model's own primary key (a shared-PK 1:1); a
1251
+ * value-materializing `cursor-scan` (`take: 1`) otherwise.
1252
+ */
1253
+ async function childExists(scope, contract, meta, def, parentRow) {
1254
+ if (def.targetFields.length === 1) {
1255
+ const range = pkEqualityRange(contract, def.relatedModelName, def.targetFields[0], parentRow[def.localFields[0]]);
1256
+ if (range !== null) return await firstKeyInRange(scope, meta, def.relatedStoreName, range) !== void 0;
1257
+ }
1258
+ return (await scope.execute({
1259
+ meta,
1260
+ kind: "cursor-scan",
1261
+ storeName: def.relatedStoreName,
1262
+ filter: buildChildFilterFromRow(def, parentRow),
1263
+ take: 1
1264
+ })).length > 0;
1265
+ }
1266
+ /**
1084
1267
  * Builds a filter matching a relation's children against one specific parent
1085
1268
  * row's values. Shared by `onDelete` cascade (`applyReferentialActionsForRow`)
1086
1269
  * and `onUpdate` cascade (`applyReferentialActionsForRowOnUpdate`).
@@ -1090,7 +1273,7 @@ function buildChildFilterFromRow(def, row) {
1090
1273
  childField: def.targetFields[i],
1091
1274
  parentValue: row[lf]
1092
1275
  }));
1093
- return (child) => pairs.every(({ childField, parentValue }) => child[childField] === parentValue);
1276
+ return (child) => pairs.every(({ childField, parentValue }) => fieldValuesEqual(child[childField], parentValue));
1094
1277
  }
1095
1278
  /** Reads a field's literal `@default(...)` value from `IdbModelStorage.fieldDefaults`, if declared. */
1096
1279
  function getFieldDefault(contract, modelName, fieldName) {
@@ -1127,10 +1310,8 @@ function buildSetDefaultPatch(contract, def) {
1127
1310
  * part of the transaction (it's the model literally being written), so no
1128
1311
  * store-list changes are needed to call this.
1129
1312
  *
1130
- * Refuses (throws) rather than validates incorrectly for compound (multi-
1131
- * field) relations, for the same reason `validateScalarFks` does: each
1132
- * field's default is resolved independently, so there's no guarantee the
1133
- * combination corresponds to a single real row.
1313
+ * A compound relation is checked as one tuple: a single parent row must
1314
+ * match every field's default.
1134
1315
  *
1135
1316
  * `excludeKey`, when given, excludes the parent row currently being
1136
1317
  * deleted/updated from the existence scan. That row is still physically
@@ -1142,21 +1323,9 @@ function buildSetDefaultPatch(contract, def) {
1142
1323
  * default references a real row, when after the transaction nothing will.
1143
1324
  */
1144
1325
  async function validateSetDefaultPatch(scope, contract, modelName, def, patch, excludeKey) {
1145
- if (def.localFields.length > 1) throw new Error(`setDefault referential action on relation '${def.relationName}' cannot validate a compound (multi-field) default: each field's default is resolved independently, so there is no guarantee the combination corresponds to a single real row.`);
1146
- const meta = makePlanMeta(contract);
1147
- const parentStoreName = getStoreName(contract, modelName);
1148
- const parentKeyPath = getKeyPath(contract, modelName);
1149
- const localField = def.localFields[0];
1150
- const targetField = def.targetFields[0];
1151
- const value = patch[targetField];
1152
- const filter = (row) => row[localField] === value && (excludeKey === void 0 || row[parentKeyPath] !== excludeKey);
1153
- if ((await scope.execute({
1154
- meta,
1155
- kind: "cursor-scan",
1156
- storeName: parentStoreName,
1157
- filter,
1158
- take: 1
1159
- })).length === 0) throw new Error(`setDefault referential action on relation '${def.relationName}' would set '${def.relatedModelName}.${targetField}' to '${String(value)}', but no ${modelName} with ${localField}='${String(value)}' exists — the declared default does not reference a real row.`);
1326
+ const values = def.targetFields.map((f) => patch[f]);
1327
+ if (values.some((v) => v === null || v === void 0)) return;
1328
+ if (!await parentExists(scope, contract, makePlanMeta(contract), modelName, def.localFields, values, excludeKey)) throw new Error(`setDefault referential action on relation '${def.relationName}' would set ${def.relatedModelName} to ${describeTuple(def.targetFields, values)}, but no ${modelName} with ${describeTuple(def.localFields, values)} exists — the declared default does not reference a real row.`);
1160
1329
  }
1161
1330
  /**
1162
1331
  * Collects every store an `onUpdate`-enforced write to `modelName` might
@@ -1186,7 +1355,6 @@ function collectOnUpdateEnforcementStoreNames(contract, modelName, data) {
1186
1355
  for (const def of getRelationDefinitions(contract, mName)) {
1187
1356
  if (!isDeleteEnforcementRelation(contract, mName, def)) continue;
1188
1357
  const action = getOnUpdateForRelation(contract, mName, def);
1189
- if (action === "noAction") continue;
1190
1358
  stores.add(def.relatedStoreName);
1191
1359
  if (action === "cascade") walkCascadeChain(def.relatedModelName);
1192
1360
  }
@@ -1196,7 +1364,6 @@ function collectOnUpdateEnforcementStoreNames(contract, modelName, data) {
1196
1364
  if (!isDeleteEnforcementRelation(contract, modelName, def)) continue;
1197
1365
  if (!def.localFields.some((f) => f in data)) continue;
1198
1366
  const action = getOnUpdateForRelation(contract, modelName, def);
1199
- if (action === "noAction") continue;
1200
1367
  enforces = true;
1201
1368
  stores.add(def.relatedStoreName);
1202
1369
  if (action === "cascade") walkCascadeChain(def.relatedModelName);
@@ -1231,25 +1398,18 @@ function collectOnUpdateEnforcementStoreNames(contract, modelName, data) {
1231
1398
  */
1232
1399
  async function applyReferentialActionsForRowOnUpdate(scope, contract, modelName, oldRow, patch, visited = /* @__PURE__ */ new Set()) {
1233
1400
  const keyPath = getKeyPath(contract, modelName);
1234
- const rowKey = `${getStoreName(contract, modelName)}::${String(oldRow[keyPath])}`;
1401
+ const rowKey = `${getStoreName(contract, modelName)}::${keyToken(extractKeyFromRow(oldRow, keyPath))}`;
1235
1402
  if (visited.has(rowKey)) return;
1236
1403
  visited.add(rowKey);
1237
1404
  const meta = makePlanMeta(contract);
1238
1405
  for (const def of getRelationDefinitions(contract, modelName)) {
1239
1406
  if (!isDeleteEnforcementRelation(contract, modelName, def)) continue;
1240
- const changedFields = def.localFields.filter((f) => f in patch && patch[f] !== oldRow[f]);
1407
+ const changedFields = def.localFields.filter((f) => f in patch && !fieldValuesEqual(patch[f], oldRow[f]));
1241
1408
  if (changedFields.length === 0) continue;
1242
1409
  const action = getOnUpdateForRelation(contract, modelName, def);
1243
- if (action === "noAction") continue;
1244
1410
  const childFilter = buildChildFilterFromRow(def, oldRow);
1245
1411
  if (action === "restrict") {
1246
- if ((await scope.execute({
1247
- meta,
1248
- kind: "cursor-scan",
1249
- storeName: def.relatedStoreName,
1250
- filter: childFilter,
1251
- take: 1
1252
- })).length > 0) throw new Error(`Cannot update ${modelName} '${String(oldRow[keyPath])}': changing field(s) ${changedFields.join(", ")} would orphan child records on relation '${def.relationName}'. Use onUpdate: 'cascade', 'setNull', 'setDefault', or 'noAction'.`);
1412
+ if (await childExists(scope, contract, meta, def, oldRow)) throw new Error(`Cannot update ${modelName} '${keyToken(extractKeyFromRow(oldRow, keyPath))}': changing field(s) ${changedFields.join(", ")} would orphan child records on relation '${def.relationName}'. Update or remove those children first, or declare onUpdate: Cascade, SetNull or SetDefault on the relation.`);
1253
1413
  continue;
1254
1414
  }
1255
1415
  if (action === "cascade") {
@@ -1291,7 +1451,7 @@ async function applyReferentialActionsForRowOnUpdate(scope, contract, modelName,
1291
1451
  }
1292
1452
  if (action === "setDefault") {
1293
1453
  const childPatch = buildSetDefaultPatch(contract, def);
1294
- await validateSetDefaultPatch(scope, contract, modelName, def, childPatch, oldRow[keyPath]);
1454
+ await validateSetDefaultPatch(scope, contract, modelName, def, childPatch, extractKeyFromRow(oldRow, keyPath));
1295
1455
  await scope.execute({
1296
1456
  meta,
1297
1457
  kind: "scan-write",
@@ -1323,31 +1483,102 @@ function collectScalarFkStoreNames(contract, modelName, data) {
1323
1483
  }
1324
1484
  return [...stores];
1325
1485
  }
1326
- async function validateScalarFks(scope, contract, modelName, data) {
1486
+ /**
1487
+ * Checks that every foreign key `data` sets points at an existing parent row,
1488
+ * inside the write's transaction.
1489
+ *
1490
+ * A compound foreign key is checked as one tuple: a single parent row must
1491
+ * match every field. Checking the fields one at a time could pass with each
1492
+ * value taken from a different parent. When `data` sets only some fields of a
1493
+ * compound key, the rest come from `existingRow`, the row being updated.
1494
+ * Callers must pass it in that case; see {@link fkCheckNeedsExistingRow}.
1495
+ *
1496
+ * A key with any `null` field isn't checked, like SQL's default `MATCH SIMPLE`.
1497
+ */
1498
+ async function validateScalarFks(scope, contract, modelName, data, existingRow) {
1327
1499
  const meta = makePlanMeta(contract);
1328
1500
  for (const def of getRelationDefinitions(contract, modelName)) {
1329
1501
  if (def.cardinality !== "N:1") continue;
1330
- if (!def.localFields.some((f) => f in data && data[f] !== null && data[f] !== void 0)) continue;
1331
- if (def.localFields.length > 1) throw new Error(`FK violation on relation '${def.relationName}': compound (multi-field) scalar FK validation is not supported`);
1332
- const localField = def.localFields[0];
1333
- const targetField = def.targetFields[0];
1334
- const value = data[localField];
1335
- const filter = (row) => row[targetField] === value;
1336
- const plan = {
1337
- meta,
1338
- kind: "cursor-scan",
1339
- storeName: def.relatedStoreName,
1340
- filter,
1341
- take: 1
1342
- };
1343
- if ((await scope.execute(plan)).length === 0) throw new Error(`FK violation on relation '${def.relationName}': no ${def.relatedModelName} with ${targetField}='${String(value)}'`);
1502
+ if (!def.localFields.some((f) => f in data)) continue;
1503
+ const values = def.localFields.map((f) => f in data ? data[f] : existingRow?.[f]);
1504
+ if (values.some((v) => v === null || v === void 0)) continue;
1505
+ if (!await parentExists(scope, contract, meta, def.relatedModelName, def.targetFields, values)) throw new Error(`FK violation on relation '${def.relationName}': no ${def.relatedModelName} with ${describeTuple(def.targetFields, values)}`);
1344
1506
  }
1345
1507
  }
1508
+ /**
1509
+ * `true` when `data` sets some, but not all, fields of a compound foreign
1510
+ * key. Checking it then needs the rest of the key from the row being
1511
+ * updated, so the update has to read each row before writing it.
1512
+ */
1513
+ function fkCheckNeedsExistingRow(contract, modelName, data) {
1514
+ return getRelationDefinitions(contract, modelName).some((def) => {
1515
+ if (def.cardinality !== "N:1") return false;
1516
+ const set = def.localFields.filter((f) => f in data).length;
1517
+ return set > 0 && set < def.localFields.length;
1518
+ });
1519
+ }
1520
+ /**
1521
+ * Does `parentModel` have a row whose `fields` equal `values`, pairwise?
1522
+ *
1523
+ * When `fields` are exactly the parent's primary key (in any order), this is
1524
+ * one key-only lookup. Otherwise it scans the parent store, comparing values
1525
+ * the way IndexedDB compares keys. `excludeKey` leaves out one parent row,
1526
+ * for checks that run while that row is being deleted or changed.
1527
+ */
1528
+ async function parentExists(scope, contract, meta, parentModel, fields, values, excludeKey) {
1529
+ const storeName = getStoreName(contract, parentModel);
1530
+ const keyPath = getKeyPath(contract, parentModel);
1531
+ const range = primaryKeyRange(keyPath, fields, values);
1532
+ if (range !== null) {
1533
+ const foundKey = await firstKeyInRange(scope, meta, storeName, range);
1534
+ return foundKey !== void 0 && (excludeKey === void 0 || !keyEquals(foundKey, excludeKey));
1535
+ }
1536
+ const filter = (row) => fields.every((f, i) => fieldValuesEqual(row[f], values[i])) && (excludeKey === void 0 || !keyEquals(extractKeyFromRow(row, keyPath), excludeKey));
1537
+ return (await scope.execute({
1538
+ meta,
1539
+ kind: "cursor-scan",
1540
+ storeName,
1541
+ filter,
1542
+ take: 1
1543
+ })).length > 0;
1544
+ }
1545
+ /**
1546
+ * An `IDBKeyRange` for "the row whose primary key is `values`", when `fields`
1547
+ * are exactly the key's fields (in any order) and every value is a valid
1548
+ * IndexedDB key. `null` otherwise, and the caller scans instead.
1549
+ */
1550
+ function primaryKeyRange(keyPath, fields, values) {
1551
+ if (typeof IDBKeyRange === "undefined") return null;
1552
+ const keyFields = keyPathFields(keyPath);
1553
+ if (keyFields.length !== fields.length || !keyFields.every((f) => fields.includes(f))) return null;
1554
+ const ordered = keyFields.map((f) => values[fields.indexOf(f)]);
1555
+ if (!ordered.every((v) => isValidIdbKey(v))) return null;
1556
+ return IDBKeyRange.only(typeof keyPath === "string" ? ordered[0] : ordered);
1557
+ }
1558
+ /** `id='u1'`, or `orgId='a', id='u1'` for a compound key. */
1559
+ function describeTuple(fields, values) {
1560
+ return fields.map((f, i) => `${f}='${String(values[i])}'`).join(", ");
1561
+ }
1346
1562
  async function executeScalarCreateWithFkValidation(options) {
1347
1563
  const { executor, contract, modelName, data } = options;
1348
- return withMutationScope(executor, collectScalarFkStoreNames(contract, modelName, data), async (scope) => {
1349
- await validateScalarFks(scope, contract, modelName, data);
1350
- return insertSingleRow(scope, contract, modelName, data, createMutationDefaultsCache());
1564
+ const defaultsCache = createMutationDefaultsCache();
1565
+ const record = applyCreateDefaults(contract.execution?.mutations.defaults, getStoreName(contract, modelName), data, defaultsCache);
1566
+ return withMutationScope(executor, collectScalarFkStoreNames(contract, modelName, record), (scope) => insertSingleRow(scope, contract, modelName, record, defaultsCache));
1567
+ }
1568
+ /**
1569
+ * `createAll()` for rows that set foreign keys: checks every row's foreign
1570
+ * keys and inserts it, all in one transaction, so one bad row writes nothing.
1571
+ * The batch shares one defaults cache, like the plain `createAll()` path.
1572
+ */
1573
+ async function executeScalarCreateAllWithFkValidation(options) {
1574
+ const { executor, contract, modelName, data } = options;
1575
+ const defaultsCache = createMutationDefaultsCache();
1576
+ const storeName = getStoreName(contract, modelName);
1577
+ const records = data.map((row) => applyCreateDefaults(contract.execution?.mutations.defaults, storeName, row, defaultsCache));
1578
+ return withMutationScope(executor, [...new Set(records.flatMap((row) => collectScalarFkStoreNames(contract, modelName, row)))], async (scope) => {
1579
+ const inserted = [];
1580
+ for (const record of records) inserted.push(await insertSingleRow(scope, contract, modelName, record, defaultsCache));
1581
+ return inserted;
1351
1582
  });
1352
1583
  }
1353
1584
  /**
@@ -1368,18 +1599,19 @@ function collectUpdateStoreNames(contract, modelName, data) {
1368
1599
  }
1369
1600
  async function executeScalarUpdateWithFkValidation(options) {
1370
1601
  const { executor, contract, modelName, filters, data } = options;
1371
- const { storeNames, enforcesOnUpdate } = collectUpdateStoreNames(contract, modelName, data);
1602
+ const storeName = getStoreName(contract, modelName);
1603
+ const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, storeName, data, createMutationDefaultsCache());
1604
+ const { storeNames, enforcesOnUpdate } = collectUpdateStoreNames(contract, modelName, patch);
1605
+ const needsRowForFks = fkCheckNeedsExistingRow(contract, modelName, patch);
1372
1606
  return withMutationScope(executor, storeNames, async (scope) => {
1373
- await validateScalarFks(scope, contract, modelName, data);
1374
- const storeName = getStoreName(contract, modelName);
1607
+ if (!needsRowForFks) await validateScalarFks(scope, contract, modelName, patch);
1375
1608
  const meta = makePlanMeta(contract);
1376
1609
  const combined = filters.length === 0 ? void 0 : filters.length === 1 ? filters[0] : {
1377
1610
  kind: "and",
1378
1611
  exprs: filters
1379
1612
  };
1380
1613
  const filter = combined !== void 0 ? (row) => evaluateFilter(combined, row) : void 0;
1381
- const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, storeName, data, createMutationDefaultsCache());
1382
- if (!enforcesOnUpdate) return (await scope.execute({
1614
+ if (!enforcesOnUpdate && !needsRowForFks) return (await scope.execute({
1383
1615
  meta,
1384
1616
  kind: "scan-write",
1385
1617
  storeName,
@@ -1396,8 +1628,9 @@ async function executeScalarUpdateWithFkValidation(options) {
1396
1628
  ...filter !== void 0 ? { filter } : {}
1397
1629
  }))[0];
1398
1630
  if (!oldRow) return null;
1399
- await applyReferentialActionsForRowOnUpdate(scope, contract, modelName, oldRow, patch);
1400
- const key = oldRow[getKeyPath(contract, modelName)];
1631
+ if (needsRowForFks) await validateScalarFks(scope, contract, modelName, patch, oldRow);
1632
+ if (enforcesOnUpdate) await applyReferentialActionsForRowOnUpdate(scope, contract, modelName, oldRow, patch);
1633
+ const key = extractKeyFromRow(oldRow, getKeyPath(contract, modelName));
1401
1634
  return (await scope.execute({
1402
1635
  meta,
1403
1636
  kind: "update",
@@ -1426,18 +1659,19 @@ async function executeScalarUpdateWithFkValidation(options) {
1426
1659
  */
1427
1660
  async function executeBulkUpdateWithFkValidation(options) {
1428
1661
  const { executor, contract, modelName, filters, data } = options;
1429
- const { storeNames, enforcesOnUpdate } = collectUpdateStoreNames(contract, modelName, data);
1662
+ const storeName = getStoreName(contract, modelName);
1663
+ const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, storeName, data, createMutationDefaultsCache());
1664
+ const { storeNames, enforcesOnUpdate } = collectUpdateStoreNames(contract, modelName, patch);
1665
+ const needsRowForFks = fkCheckNeedsExistingRow(contract, modelName, patch);
1430
1666
  return withMutationScope(executor, storeNames, async (scope) => {
1431
- await validateScalarFks(scope, contract, modelName, data);
1432
- const storeName = getStoreName(contract, modelName);
1667
+ if (!needsRowForFks) await validateScalarFks(scope, contract, modelName, patch);
1433
1668
  const meta = makePlanMeta(contract);
1434
1669
  const combined = filters.length === 0 ? void 0 : filters.length === 1 ? filters[0] : {
1435
1670
  kind: "and",
1436
1671
  exprs: filters
1437
1672
  };
1438
1673
  const filter = combined !== void 0 ? (row) => evaluateFilter(combined, row) : void 0;
1439
- const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, storeName, data, createMutationDefaultsCache());
1440
- if (!enforcesOnUpdate) return scope.execute({
1674
+ if (!enforcesOnUpdate && !needsRowForFks) return scope.execute({
1441
1675
  meta,
1442
1676
  kind: "scan-write",
1443
1677
  storeName,
@@ -1454,8 +1688,9 @@ async function executeBulkUpdateWithFkValidation(options) {
1454
1688
  const keyPath = getKeyPath(contract, modelName);
1455
1689
  const results = [];
1456
1690
  for (const oldRow of oldRows) {
1457
- await applyReferentialActionsForRowOnUpdate(scope, contract, modelName, oldRow, patch);
1458
- const key = oldRow[keyPath];
1691
+ if (needsRowForFks) await validateScalarFks(scope, contract, modelName, patch, oldRow);
1692
+ if (enforcesOnUpdate) await applyReferentialActionsForRowOnUpdate(scope, contract, modelName, oldRow, patch);
1693
+ const key = extractKeyFromRow(oldRow, keyPath);
1459
1694
  const updated = (await scope.execute({
1460
1695
  meta,
1461
1696
  kind: "update",
@@ -1470,16 +1705,11 @@ async function executeBulkUpdateWithFkValidation(options) {
1470
1705
  }
1471
1706
  /**
1472
1707
  * Returns true if the model has at least one child relation (1:N or parent-side
1473
- * 1:1) whose `onDelete` action requires enforcement (anything except `noAction`).
1474
- * Since the default is `restrict`, any model with 1:N/1:1 relations that do not
1475
- * explicitly set `noAction` returns true.
1708
+ * 1:1). Every such relation has an `onDelete` action to enforce: `noAction`
1709
+ * behaves like `restrict`, which is also the default.
1476
1710
  */
1477
1711
  function hasEnforceableChildRelations(contract, modelName) {
1478
- for (const def of getRelationDefinitions(contract, modelName)) {
1479
- if (!isDeleteEnforcementRelation(contract, modelName, def)) continue;
1480
- if (getOnDeleteForDeleteRelation(contract, modelName, def) !== "noAction") return true;
1481
- }
1482
- return false;
1712
+ return getRelationDefinitions(contract, modelName).some((def) => isDeleteEnforcementRelation(contract, modelName, def));
1483
1713
  }
1484
1714
  /**
1485
1715
  * Transitively walks the `onDelete` cascade graph from `modelName`, collecting
@@ -1502,7 +1732,6 @@ function collectDeleteStoreNames(contract, modelName) {
1502
1732
  for (const def of getRelationDefinitions(contract, mName)) {
1503
1733
  if (!isDeleteEnforcementRelation(contract, mName, def)) continue;
1504
1734
  const action = getOnDeleteForDeleteRelation(contract, mName, def);
1505
- if (action === "noAction") continue;
1506
1735
  stores.add(def.relatedStoreName);
1507
1736
  if (action === "cascade") walk(def.relatedModelName);
1508
1737
  }
@@ -1530,23 +1759,16 @@ function collectDeleteStoreNames(contract, modelName) {
1530
1759
  */
1531
1760
  async function applyReferentialActionsForRow(scope, contract, modelName, row, visited = /* @__PURE__ */ new Set()) {
1532
1761
  const keyPath = getKeyPath(contract, modelName);
1533
- const rowKey = `${getStoreName(contract, modelName)}::${String(row[keyPath])}`;
1762
+ const rowKey = `${getStoreName(contract, modelName)}::${keyToken(extractKeyFromRow(row, keyPath))}`;
1534
1763
  if (visited.has(rowKey)) return;
1535
1764
  visited.add(rowKey);
1536
1765
  const meta = makePlanMeta(contract);
1537
1766
  for (const def of getRelationDefinitions(contract, modelName)) {
1538
1767
  if (!isDeleteEnforcementRelation(contract, modelName, def)) continue;
1539
1768
  const action = getOnDeleteForDeleteRelation(contract, modelName, def);
1540
- if (action === "noAction") continue;
1541
1769
  const childFilter = buildChildFilterFromRow(def, row);
1542
1770
  if (action === "restrict") {
1543
- if ((await scope.execute({
1544
- meta,
1545
- kind: "cursor-scan",
1546
- storeName: def.relatedStoreName,
1547
- filter: childFilter,
1548
- take: 1
1549
- })).length > 0) throw new Error(`Cannot delete ${modelName} '${String(row[keyPath])}': child records exist on relation '${def.relationName}'. Use onDelete: 'cascade', 'setNull', or 'noAction'.`);
1771
+ if (await childExists(scope, contract, meta, def, row)) throw new Error(`Cannot delete ${modelName} '${keyToken(extractKeyFromRow(row, keyPath))}': child records exist on relation '${def.relationName}'. Delete those children first, or declare onDelete: Cascade, SetNull or SetDefault on the relation.`);
1550
1772
  continue;
1551
1773
  }
1552
1774
  if (action === "cascade") {
@@ -1563,7 +1785,7 @@ async function applyReferentialActionsForRow(scope, contract, modelName, row, vi
1563
1785
  meta,
1564
1786
  kind: "delete",
1565
1787
  storeName: def.relatedStoreName,
1566
- key: child[childKeyPath]
1788
+ key: extractKeyFromRow(child, childKeyPath)
1567
1789
  });
1568
1790
  }
1569
1791
  continue;
@@ -1583,7 +1805,7 @@ async function applyReferentialActionsForRow(scope, contract, modelName, row, vi
1583
1805
  }
1584
1806
  if (action === "setDefault") {
1585
1807
  const patch = buildSetDefaultPatch(contract, def);
1586
- await validateSetDefaultPatch(scope, contract, modelName, def, patch, row[keyPath]);
1808
+ await validateSetDefaultPatch(scope, contract, modelName, def, patch, extractKeyFromRow(row, keyPath));
1587
1809
  await scope.execute({
1588
1810
  meta,
1589
1811
  kind: "scan-write",
@@ -1632,7 +1854,7 @@ async function executeDeleteAllWithReferentialActions(options) {
1632
1854
  });
1633
1855
  for (const row of rows) {
1634
1856
  await applyReferentialActionsForRow(scope, contract, modelName, row);
1635
- const key = row[keyPath];
1857
+ const key = extractKeyFromRow(row, keyPath);
1636
1858
  await scope.execute({
1637
1859
  meta,
1638
1860
  kind: "delete",
@@ -1709,7 +1931,7 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
1709
1931
  const projectRows = this.#projectRows.bind(this);
1710
1932
  const combined = this.#combinedFilterExpr();
1711
1933
  const fieldToIndexMap = typeof IDBKeyRange !== "undefined" ? buildFieldToIndexMap(this.#contract, this.#storeName) : void 0;
1712
- const keyPath = getKeyPath$1(this.#contract, this.#modelName);
1934
+ const keyPath = getKeyPath(this.#contract, this.#modelName);
1713
1935
  const comparator = buildRowComparator(this.#state.orderBy);
1714
1936
  const skip = this.#state.skip;
1715
1937
  const take = this.#state.take;
@@ -1743,7 +1965,17 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
1743
1965
  aggregates: toAggregateRequests(spec),
1744
1966
  ...combined !== void 0 ? { where: combined } : {}
1745
1967
  };
1746
- return computeAggregateSpec(spec, await this.#materialize(this.#newGroupingKey(), ast));
1968
+ const groupingKey = this.#newGroupingKey();
1969
+ if (Object.values(spec).every((selector) => selector.fn === "count")) {
1970
+ const scanPlan = this.#buildScanPlan(groupingKey);
1971
+ const total = await this.#executeNativeCount(scanPlan, ast);
1972
+ if (total !== null) {
1973
+ const result = {};
1974
+ for (const alias of Object.keys(spec)) result[alias] = total;
1975
+ return result;
1976
+ }
1977
+ }
1978
+ return computeAggregateSpec(spec, await this.#materialize(groupingKey, ast));
1747
1979
  }
1748
1980
  groupBy(...fields) {
1749
1981
  const combined = this.#combinedFilterExpr();
@@ -1763,15 +1995,15 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
1763
1995
  modelName: this.#modelName,
1764
1996
  data: record
1765
1997
  });
1766
- if (hasScalarFkFields(this.#contract, this.#modelName, record)) return await executeScalarCreateWithFkValidation({
1998
+ const withDefaults = applyCreateDefaults(this.#contract.execution?.mutations.defaults, this.#storeName, record, createMutationDefaultsCache());
1999
+ if (hasScalarFkFields(this.#contract, this.#modelName, withDefaults)) return await executeScalarCreateWithFkValidation({
1767
2000
  executor: requireTransactionExecutor(this.#executor),
1768
2001
  contract: this.#contract,
1769
2002
  modelName: this.#modelName,
1770
- data: record
2003
+ data: withDefaults
1771
2004
  });
1772
2005
  const groupingKey = this.#newGroupingKey();
1773
2006
  const meta = this.#planMeta(groupingKey);
1774
- const withDefaults = applyCreateDefaults(this.#contract.execution?.mutations.defaults, this.#storeName, record, createMutationDefaultsCache());
1775
2007
  const plan = {
1776
2008
  meta,
1777
2009
  ast: {
@@ -1885,7 +2117,7 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
1885
2117
  return (await this.updateAll(patch).toArray()).length;
1886
2118
  }
1887
2119
  async upsert(args) {
1888
- const keyPath = getKeyPath$1(this.#contract, this.#modelName);
2120
+ const keyPath = getKeyPath(this.#contract, this.#modelName);
1889
2121
  const whereExpr = shorthandToFilterExpr(args.where);
1890
2122
  const matches = (row) => whereExpr === void 0 || evaluateFilter(whereExpr, row);
1891
2123
  const meta = this.#planMeta(this.#newGroupingKey());
@@ -1895,8 +2127,14 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
1895
2127
  const executionDefaults = this.#contract.execution?.mutations.defaults;
1896
2128
  const exec = requireTransactionExecutor(this.#executor);
1897
2129
  const effectivePatch = applyUpdateDefaults(executionDefaults, storeName, patchRecord, createMutationDefaultsCache());
2130
+ const createWithDefaults = applyCreateDefaults(executionDefaults, storeName, createRecord, createMutationDefaultsCache());
1898
2131
  const { storeNames: onUpdateStoreNames } = collectOnUpdateEnforcementStoreNames(this.#contract, this.#modelName, effectivePatch);
1899
- return withMutationScope(exec, [.../* @__PURE__ */ new Set([storeName, ...onUpdateStoreNames])], async (scope) => {
2132
+ return withMutationScope(exec, [.../* @__PURE__ */ new Set([
2133
+ storeName,
2134
+ ...onUpdateStoreNames,
2135
+ ...collectScalarFkStoreNames(this.#contract, this.#modelName, createWithDefaults),
2136
+ ...collectScalarFkStoreNames(this.#contract, this.#modelName, effectivePatch)
2137
+ ])], async (scope) => {
1900
2138
  const existing = (await scope.execute({
1901
2139
  meta,
1902
2140
  kind: "cursor-scan",
@@ -1905,15 +2143,16 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
1905
2143
  take: 1
1906
2144
  }))[0];
1907
2145
  if (existing === void 0) {
1908
- const record = applyCreateDefaults(executionDefaults, storeName, createRecord, createMutationDefaultsCache());
2146
+ await validateScalarFks(scope, this.#contract, this.#modelName, createWithDefaults);
1909
2147
  return (await scope.execute({
1910
2148
  meta,
1911
2149
  kind: "add",
1912
2150
  storeName,
1913
- record
1914
- }))[0] ?? record;
2151
+ record: createWithDefaults
2152
+ }))[0] ?? createWithDefaults;
1915
2153
  }
1916
- const key = existing[keyPath];
2154
+ await validateScalarFks(scope, this.#contract, this.#modelName, effectivePatch, existing);
2155
+ const key = extractKeyFromRow(existing, keyPath);
1917
2156
  await applyReferentialActionsForRowOnUpdate(scope, this.#contract, this.#modelName, existing, effectivePatch);
1918
2157
  return (await scope.execute({
1919
2158
  meta,
@@ -1925,11 +2164,25 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
1925
2164
  });
1926
2165
  }
1927
2166
  createAll(data) {
1928
- const groupingKey = this.#newGroupingKey();
1929
- const meta = this.#planMeta(groupingKey);
1930
2167
  const defaultsCache = createMutationDefaultsCache();
1931
2168
  const executionDefaults = this.#contract.execution?.mutations.defaults;
1932
2169
  const records = data.map((d) => applyCreateDefaults(executionDefaults, this.#storeName, d, defaultsCache));
2170
+ if (records.some((row) => hasScalarFkFields(this.#contract, this.#modelName, row))) {
2171
+ const executor = requireTransactionExecutor(this.#executor);
2172
+ const contract = this.#contract;
2173
+ const modelName = this.#modelName;
2174
+ return new AsyncIterableResult((async function* () {
2175
+ const rows = await executeScalarCreateAllWithFkValidation({
2176
+ executor,
2177
+ contract,
2178
+ modelName,
2179
+ data: records
2180
+ });
2181
+ for (const row of rows) yield row;
2182
+ })());
2183
+ }
2184
+ const groupingKey = this.#newGroupingKey();
2185
+ const meta = this.#planMeta(groupingKey);
1933
2186
  const plan = {
1934
2187
  meta,
1935
2188
  ast: {
@@ -1995,13 +2248,8 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
1995
2248
  const combined = this.#combinedFilterExpr();
1996
2249
  const fieldToIndexMap = typeof IDBKeyRange !== "undefined" ? buildFieldToIndexMap(this.#contract, this.#storeName) : void 0;
1997
2250
  if (fieldToIndexMap !== void 0) {
1998
- const orHint = extractIndexOrHint(combined, fieldToIndexMap, getKeyPath$1(this.#contract, this.#modelName));
1999
- if (orHint !== null) {
2000
- let n = (await this.#executeOrRows(orHint, groupingKey, combined)).length;
2001
- if (this.#state.skip !== void 0) n = Math.max(0, n - this.#state.skip);
2002
- if (this.#state.take !== void 0) n = Math.min(this.#state.take, n);
2003
- return n;
2004
- }
2251
+ const orHint = extractIndexOrHint(combined, fieldToIndexMap, getKeyPath(this.#contract, this.#modelName));
2252
+ if (orHint !== null) return clampCount((await this.#executeOrRows(orHint, groupingKey, combined)).length, this.#state.skip, this.#state.take);
2005
2253
  }
2006
2254
  const scanPlan = this.#buildScanPlan(groupingKey, fieldToIndexMap);
2007
2255
  const scanAst = scanPlan.ast;
@@ -2010,6 +2258,8 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
2010
2258
  modelName: this.#modelName,
2011
2259
  ...scanAst?.kind === "findMany" && scanAst.where !== void 0 ? { where: scanAst.where } : {}
2012
2260
  };
2261
+ const nativeTotal = await this.#executeNativeCount(scanPlan, ast);
2262
+ if (nativeTotal !== null) return clampCount(nativeTotal, this.#state.skip, this.#state.take);
2013
2263
  const plan = {
2014
2264
  ...scanPlan,
2015
2265
  ast
@@ -2046,6 +2296,25 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
2046
2296
  throw new Error(`include('${relation}') refinement must return the collection (for where/orderBy/take/skip) or a count() selector`);
2047
2297
  }
2048
2298
  /**
2299
+ * Runs `scanPlan` as a native `count` plan when its cardinality is fully
2300
+ * determined by a key range (no in-memory filter) — returning the
2301
+ * *unpaginated* total — or `null` when it can't (the caller then falls back
2302
+ * to materializing). `ast` is attached so middleware sees the caller's
2303
+ * intent (`count` / `aggregate`) regardless of the physical plan.
2304
+ */
2305
+ async #executeNativeCount(scanPlan, ast) {
2306
+ const body = scanPlan.idbPlan;
2307
+ if (body.kind !== "cursor-scan" || !isNativelyCountable(body)) return null;
2308
+ const nativePlan = {
2309
+ ...scanPlan,
2310
+ ast,
2311
+ idbPlan: toCountPlan(body)
2312
+ };
2313
+ let total = 0;
2314
+ for await (const row of this.#executor.query(nativePlan)) total = row.count;
2315
+ return total;
2316
+ }
2317
+ /**
2049
2318
  * Materialise all rows matching the accumulated filters with no pagination —
2050
2319
  * used by `aggregate()` / `groupBy()`. The supplied `ast` is attached to the
2051
2320
  * scan plan so middleware can observe the aggregate intent.
@@ -2081,7 +2350,7 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
2081
2350
  ...this.#state.take !== void 0 ? { take: this.#state.take } : {}
2082
2351
  };
2083
2352
  if (typeof IDBKeyRange !== "undefined") {
2084
- const hint = extractIndexEqualityHint(combined, fieldToIndexMap ?? buildFieldToIndexMap(this.#contract, this.#storeName), getKeyPath$1(this.#contract, this.#modelName));
2353
+ const hint = extractIndexEqualityHint(combined, fieldToIndexMap ?? buildFieldToIndexMap(this.#contract, this.#storeName), getKeyPath(this.#contract, this.#modelName));
2085
2354
  if (hint !== null) {
2086
2355
  const { indexName, value, remainingFilter } = hint;
2087
2356
  const filter = remainingFilter !== void 0 ? (row) => evaluateFilter(remainingFilter, row) : void 0;
@@ -2126,7 +2395,7 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
2126
2395
  async #executeOrRows(hint, groupingKey, combined) {
2127
2396
  const meta = this.#planMeta(groupingKey);
2128
2397
  const storeName = this.#storeName;
2129
- const keyPath = getKeyPath$1(this.#contract, this.#modelName);
2398
+ const keyPath = getKeyPath(this.#contract, this.#modelName);
2130
2399
  const seen = /* @__PURE__ */ new Set();
2131
2400
  const rows = [];
2132
2401
  const ast = {
@@ -2151,7 +2420,7 @@ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
2151
2420
  return branchRows;
2152
2421
  }));
2153
2422
  for (const branchRows of branchResults) for (const row of branchRows) {
2154
- const pk = row[keyPath];
2423
+ const pk = keyToken(extractKeyFromRow(row, keyPath));
2155
2424
  if (!seen.has(pk)) {
2156
2425
  seen.add(pk);
2157
2426
  rows.push(row);
@@ -2240,6 +2509,6 @@ function idbOrm(options) {
2240
2509
  return client;
2241
2510
  }
2242
2511
  //#endregion
2243
- export { createRelationMutator as a, withMutationScope as c, hasNestedMutationCallbacks as i, getKeyPath$1 as l, applyReferentialActionsForRow as n, isRelationMutationCallback as o, collectDeleteStoreNames as r, isRelationMutationDescriptor as s, idbOrm as t, getStoreName as u };
2512
+ export { createRelationMutator as a, withMutationScope as c, getStoreName as d, keyEquals as f, hasNestedMutationCallbacks as i, extractKeyFromRow as l, applyReferentialActionsForRow as n, isRelationMutationCallback as o, keyToken as p, collectDeleteStoreNames as r, isRelationMutationDescriptor as s, idbOrm as t, getKeyPath as u };
2244
2513
 
2245
- //# sourceMappingURL=idb-orm-Cupnnwum.mjs.map
2514
+ //# sourceMappingURL=idb-orm-JBg6si7u.mjs.map