@prisma-idb/client-idb 0.0.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.
@@ -0,0 +1,2245 @@
1
+ import { AsyncIterableResult } from "@prisma/orm-framework/components/runtime";
2
+ import { andExpr, evaluateFilter, fieldFilter, nullCheckExpr, shorthandToFilterExpr } from "@prisma-idb/adapter-idb/runtime";
3
+ import { domainModelsAtDefaultNamespace } from "@prisma/orm-framework/contract/types";
4
+ import { generateId } from "@prisma/orm-framework/ids/runtime";
5
+ //#region src/core/types.ts
6
+ /**
7
+ * Extract the `storeName` from a model's storage metadata at runtime.
8
+ * Falls back to the model name if `storeName` is absent.
9
+ */
10
+ function getStoreName(contract, modelName) {
11
+ return (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.storeName ?? modelName;
12
+ }
13
+ /**
14
+ * 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).
16
+ */
17
+ function getKeyPath$1(contract, modelName) {
18
+ return (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.keyPath ?? "id";
19
+ }
20
+ /**
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.
28
+ */
29
+ function getIndexForField(contract, storeName, fieldName) {
30
+ return buildFieldToIndexMap(contract, storeName)[fieldName];
31
+ }
32
+ /**
33
+ * Build a field → indexName lookup for every single-field, non-multi-entry
34
+ * index on `storeName`. Shared by {@link getIndexForField} (relation loader,
35
+ * single-field lookups) and {@link IdbStoreAccessorImpl} (top-level scans,
36
+ * which need the whole map to probe combined filter expressions).
37
+ */
38
+ function buildFieldToIndexMap(contract, storeName) {
39
+ const storeDef = contract.storage.stores[storeName];
40
+ const result = {};
41
+ if (storeDef?.indexes === void 0) return result;
42
+ for (const [indexName, indexDef] of Object.entries(storeDef.indexes)) if (typeof indexDef.keyPath === "string" && indexDef.multiEntry !== true) result[indexDef.keyPath] = indexName;
43
+ return result;
44
+ }
45
+ /**
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
+ * Resolve a model's named relation to a {@link ContractReferenceRelation} at
68
+ * runtime, or `undefined` when the relation is absent or an embedded relation
69
+ * (no `on` join block). Used by `include()` to find the related model name and
70
+ * cardinality before building the child-accessor refinement.
71
+ */
72
+ function getRelation(contract, modelName, relName) {
73
+ const relation = domainModelsAtDefaultNamespace(contract.domain)[modelName]?.relations?.[relName];
74
+ if (relation === void 0 || !("on" in relation)) return void 0;
75
+ return relation;
76
+ }
77
+ //#endregion
78
+ //#region src/core/model-accessor.ts
79
+ /**
80
+ * Proxy-based typed accessor handed to `where(fn)` callbacks.
81
+ *
82
+ * Mirrors `createModelAccessor()` from
83
+ * Prisma ORM's SQL client, but without
84
+ * the codec-trait gating — IDB stores native JS values, so every operator
85
+ * is available on every field. The runtime accessor object is a Proxy
86
+ * that materialises an `IdbFieldAccessor` on demand for whichever field
87
+ * name is read; the type-level surface narrows that to the model's
88
+ * actual field set so callbacks get autocomplete.
89
+ */
90
+ /**
91
+ * Build an {@link IdbFieldAccessor} for a single field. The accessor is
92
+ * shared via the proxy below, but each accessor instance is bound to its
93
+ * own field name so the produced AST nodes carry the right field.
94
+ */
95
+ function createFieldAccessor(field) {
96
+ return {
97
+ eq: (value) => fieldFilter(field, "eq", value),
98
+ neq: (value) => fieldFilter(field, "neq", value),
99
+ gt: (value) => fieldFilter(field, "gt", value),
100
+ lt: (value) => fieldFilter(field, "lt", value),
101
+ gte: (value) => fieldFilter(field, "gte", value),
102
+ lte: (value) => fieldFilter(field, "lte", value),
103
+ in: (values) => fieldFilter(field, "in", values),
104
+ notIn: (values) => fieldFilter(field, "notIn", values),
105
+ contains: (sub) => fieldFilter(field, "contains", sub),
106
+ startsWith: (sub) => fieldFilter(field, "startsWith", sub),
107
+ endsWith: (sub) => fieldFilter(field, "endsWith", sub),
108
+ isNull: () => nullCheckExpr(field, true),
109
+ isNotNull: () => nullCheckExpr(field, false)
110
+ };
111
+ }
112
+ /**
113
+ * Create the typed model accessor used by `where(fn)` callbacks.
114
+ *
115
+ * Implemented as a Proxy keyed on field names — every read returns an
116
+ * {@link IdbFieldAccessor} bound to that field. The accessor cache means
117
+ * `m.name.eq(...)` and `m.name.startsWith(...)` reuse the same per-field
118
+ * accessor object across the same `where()` invocation.
119
+ *
120
+ * The type parameter is structural — no contract is consulted at
121
+ * runtime, because TS already gates the visible field set at compile
122
+ * time. (Misspelled fields surface as accessor calls on names that
123
+ * don't exist in stored rows, which is consistent with how the
124
+ * shorthand form behaves today.)
125
+ */
126
+ function createModelAccessor() {
127
+ const cache = /* @__PURE__ */ new Map();
128
+ return new Proxy({}, { get(_target, prop) {
129
+ if (typeof prop !== "string") return void 0;
130
+ let acc = cache.get(prop);
131
+ if (acc === void 0) {
132
+ acc = createFieldAccessor(prop);
133
+ cache.set(prop, acc);
134
+ }
135
+ return acc;
136
+ } });
137
+ }
138
+ //#endregion
139
+ //#region src/core/store-state.ts
140
+ /** Build an {@link IdbIncludeScalar} marker for a `count()` reducer. */
141
+ function createIncludeScalar(state) {
142
+ return {
143
+ kind: "includeScalar",
144
+ fn: "count",
145
+ state
146
+ };
147
+ }
148
+ /** Type guard: is `value` an {@link IdbIncludeScalar} marker? */
149
+ function isIncludeScalar(value) {
150
+ if (typeof value !== "object" || value === null) return false;
151
+ const candidate = value;
152
+ return candidate.kind === "includeScalar" && candidate.fn === "count";
153
+ }
154
+ /** Create a fresh, empty accessor state (no filters, no ordering, no includes). */
155
+ function emptyAccessorState() {
156
+ return {
157
+ filters: [],
158
+ includes: {}
159
+ };
160
+ }
161
+ /** Return a shallow-merged copy of `state` with the given overrides applied. */
162
+ function mergeAccessorState(state, overrides) {
163
+ return {
164
+ ...state,
165
+ ...overrides
166
+ };
167
+ }
168
+ //#endregion
169
+ //#region src/core/query-shaping.ts
170
+ /**
171
+ * Combine accumulated filter expressions with AND.
172
+ *
173
+ * Returns `undefined` when no filter is present so callers can skip building a
174
+ * row-filter closure. Shared by {@link IdbStoreAccessorImpl} (top-level scans)
175
+ * and the relation loader (refined `include()` child scans).
176
+ */
177
+ function combineFilterExprs(filters) {
178
+ if (filters.length === 0) return void 0;
179
+ if (filters.length === 1) return filters[0];
180
+ return andExpr(filters);
181
+ }
182
+ /** Recursively flatten nested AND nodes into a single array of children. */
183
+ function flattenAnd(filter) {
184
+ if (filter.kind !== "and") return [filter];
185
+ return filter.exprs.flatMap(flattenAnd);
186
+ }
187
+ /** Fold the leftover AND children (after peeling an indexed condition) back into a single filter, or `undefined` if none remain. */
188
+ function foldRemainder(rest) {
189
+ if (rest.length === 0) return void 0;
190
+ if (rest.length === 1) return rest[0];
191
+ return andExpr(rest);
192
+ }
193
+ /**
194
+ * `IDBKeyRange.only()` throws `DataError` for values that aren't valid
195
+ * IndexedDB keys (`null`/`undefined`, booleans, `NaN`, `BigInt`, plain
196
+ * objects, or arrays containing any of those) — an `eq` condition on such a
197
+ * value can't be served by an index point-range scan and must fall back to
198
+ * a full scan. Delegates to the shared {@link isValidIdbKey} check.
199
+ */
200
+ function isIndexableEqValue(value) {
201
+ return isValidIdbKey(value);
202
+ }
203
+ /**
204
+ * Scan the combined filter expression for the first `eq` field condition whose
205
+ * field either has a matching IDB index (`fieldToIndexName[field]` is set) or
206
+ * *is* the store's own primary key (`keyPath`) — a store's keyPath is always
207
+ * point-range-queryable directly, no named index required.
208
+ *
209
+ * When found, the indexed condition is peeled off and returned as an
210
+ * {@link IndexEqualityHint}; any remaining conditions are preserved in
211
+ * `remainingFilter`. Returns `null` when no usable index can be found.
212
+ *
213
+ * Nested AND nodes are flattened before scanning, so deeply-composed filters
214
+ * are handled correctly. Only `eq` conditions are considered — range operators
215
+ * (`gt`, `lt`, etc.) could also use an index range but that optimization is
216
+ * not yet implemented.
217
+ */
218
+ function extractIndexEqualityHint(filter, fieldToIndexName, keyPath) {
219
+ if (filter === void 0) return null;
220
+ if (filter.kind === "field" && filter.op === "eq") {
221
+ const indexName = fieldToIndexName[filter.field];
222
+ if ((indexName !== void 0 || filter.field === keyPath) && isIndexableEqValue(filter.value)) return {
223
+ indexName,
224
+ value: filter.value,
225
+ remainingFilter: void 0
226
+ };
227
+ return null;
228
+ }
229
+ if (filter.kind === "and") {
230
+ const flat = flattenAnd(filter);
231
+ for (let i = 0; i < flat.length; i++) {
232
+ const expr = flat[i];
233
+ if (expr.kind === "field" && expr.op === "eq") {
234
+ const indexName = fieldToIndexName[expr.field];
235
+ if ((indexName !== void 0 || expr.field === keyPath) && isIndexableEqValue(expr.value)) {
236
+ const rest = flat.filter((_, j) => j !== i);
237
+ return {
238
+ indexName,
239
+ value: expr.value,
240
+ remainingFilter: foldRemainder(rest)
241
+ };
242
+ }
243
+ }
244
+ }
245
+ }
246
+ return null;
247
+ }
248
+ /**
249
+ * Check whether every branch of an OR node is a single `field eq value`
250
+ * condition whose field has a matching index. Returns an {@link IndexOrHint}
251
+ * when the whole OR can be satisfied by N point-range cursor scans, `null`
252
+ * otherwise.
253
+ */
254
+ function tryExtractOrBranches(orNode, fieldToIndexName, keyPath, remainingFilter) {
255
+ if (orNode.exprs.length === 0) return null;
256
+ const branches = [];
257
+ for (const expr of orNode.exprs) {
258
+ if (expr.kind !== "field" || expr.op !== "eq") return null;
259
+ const indexName = fieldToIndexName[expr.field];
260
+ if (indexName === void 0 && expr.field !== keyPath || !isIndexableEqValue(expr.value)) return null;
261
+ branches.push({
262
+ indexName,
263
+ value: expr.value
264
+ });
265
+ }
266
+ return {
267
+ branches,
268
+ remainingFilter
269
+ };
270
+ }
271
+ /**
272
+ * Attempt to extract an {@link IndexOrHint} from `filter`.
273
+ *
274
+ * Handles two shapes:
275
+ * - Top-level `or` where every branch is `field eq value` on an indexed field.
276
+ * - Top-level `and` (with nested ANDs flattened) that contains exactly one `or`
277
+ * child fitting the above pattern; the remaining AND children become
278
+ * `remainingFilter` applied in-memory after the union.
279
+ *
280
+ * Returns `null` when the filter cannot be accelerated via OR index scans.
281
+ */
282
+ function extractIndexOrHint(filter, fieldToIndexName, keyPath) {
283
+ if (filter === void 0) return null;
284
+ if (filter.kind === "or") return tryExtractOrBranches(filter, fieldToIndexName, keyPath, void 0);
285
+ if (filter.kind === "and") {
286
+ const flat = flattenAnd(filter);
287
+ const orIndices = [];
288
+ for (let i = 0; i < flat.length; i++) if (flat[i].kind === "or") orIndices.push(i);
289
+ if (orIndices.length !== 1) return null;
290
+ const orIdx = orIndices[0];
291
+ const orNode = flat[orIdx];
292
+ return tryExtractOrBranches(orNode, fieldToIndexName, keyPath, foldRemainder(flat.filter((_, j) => j !== orIdx)));
293
+ }
294
+ return null;
295
+ }
296
+ /**
297
+ * Build an in-memory comparator from an `orderBy` spec (field → direction).
298
+ *
299
+ * 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.
302
+ */
303
+ function buildRowComparator(orderBy) {
304
+ if (orderBy === void 0) return void 0;
305
+ return (a, b) => {
306
+ 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;
311
+ return dir === "desc" ? -cmp : cmp;
312
+ }
313
+ return 0;
314
+ };
315
+ }
316
+ //#endregion
317
+ //#region src/core/aggregate-builder.ts
318
+ /**
319
+ * Build the aggregate selector factory handed to `.aggregate(agg => …)` and
320
+ * `groupBy(...).aggregate(agg => …)`.
321
+ *
322
+ * Each method returns a frozen {@link IdbAggregateSelector} marker; the actual
323
+ * reduction happens in {@link reduceAggregate} once the matching rows are
324
+ * materialised in memory. Mirrors `createAggregateBuilder` from the vendor
325
+ * `sql-orm-client/aggregate-builder.ts`, minus the field→column mapping (IDB
326
+ * stores native field names).
327
+ */
328
+ function createAggregateBuilder() {
329
+ return {
330
+ count: () => ({
331
+ kind: "aggregate",
332
+ fn: "count"
333
+ }),
334
+ sum: (field) => ({
335
+ kind: "aggregate",
336
+ fn: "sum",
337
+ field
338
+ }),
339
+ avg: (field) => ({
340
+ kind: "aggregate",
341
+ fn: "avg",
342
+ field
343
+ }),
344
+ min: (field) => ({
345
+ kind: "aggregate",
346
+ fn: "min",
347
+ field
348
+ }),
349
+ max: (field) => ({
350
+ kind: "aggregate",
351
+ fn: "max",
352
+ field
353
+ })
354
+ };
355
+ }
356
+ /** Type guard: is `value` an {@link IdbAggregateSelector}? */
357
+ function isAggregateSelector(value) {
358
+ if (typeof value !== "object" || value === null) return false;
359
+ const candidate = value;
360
+ return candidate.kind === "aggregate" && (candidate.fn === "count" || candidate.fn === "sum" || candidate.fn === "avg" || candidate.fn === "min" || candidate.fn === "max");
361
+ }
362
+ /**
363
+ * Reduce a set of materialised rows to a single aggregate value.
364
+ *
365
+ * - `count` → the number of rows (always a number, never null).
366
+ * - `sum`/`avg`/`min`/`max` → computed over the non-null numeric values of
367
+ * `field`; `null` when no row has a non-null value (matching Prisma's
368
+ * "aggregate of an empty set is null" and the vendor `coerceAggregateValue`
369
+ * null handling). String-encoded numbers are coerced via `Number()`.
370
+ */
371
+ function reduceAggregate(fn, field, rows) {
372
+ if (fn === "count") return rows.length;
373
+ if (field === void 0) return null;
374
+ const values = [];
375
+ for (const row of rows) {
376
+ const raw = row[field];
377
+ if (raw === null || raw === void 0) continue;
378
+ const n = typeof raw === "bigint" ? Number(raw) : Number(raw);
379
+ if (!Number.isNaN(n)) values.push(n);
380
+ }
381
+ if (values.length === 0) return null;
382
+ switch (fn) {
383
+ case "sum": return values.reduce((acc, n) => acc + n, 0);
384
+ case "avg": return values.reduce((acc, n) => acc + n, 0) / values.length;
385
+ case "min": return Math.min(...values);
386
+ case "max": return Math.max(...values);
387
+ }
388
+ }
389
+ /**
390
+ * Run every selector in an aggregate spec against `rows`, producing the
391
+ * result object keyed by the spec's aliases.
392
+ */
393
+ function computeAggregateSpec(spec, rows) {
394
+ const result = {};
395
+ for (const [alias, selector] of Object.entries(spec)) result[alias] = reduceAggregate(selector.fn, selector.field, rows);
396
+ return result;
397
+ }
398
+ /**
399
+ * Project an aggregate spec down to the plain `{ fn, field? }` shape carried by
400
+ * the query AST (`IdbAggregateAst.aggregates` / `IdbGroupByAst.aggregates`), so
401
+ * middleware can observe the requested aggregations.
402
+ */
403
+ function toAggregateRequests(spec) {
404
+ const out = {};
405
+ for (const [alias, selector] of Object.entries(spec)) out[alias] = selector.field !== void 0 ? {
406
+ fn: selector.fn,
407
+ field: selector.field
408
+ } : { fn: selector.fn };
409
+ return out;
410
+ }
411
+ /**
412
+ * Validate a user-supplied aggregate spec: it must be non-empty and every
413
+ * value must be a real selector. Throws a descriptive error otherwise, so a
414
+ * typo (e.g. returning a plain object) fails loudly rather than silently
415
+ * producing `NaN`/`undefined` results.
416
+ */
417
+ function assertValidAggregateSpec(spec, context) {
418
+ const entries = Object.entries(spec);
419
+ if (entries.length === 0) throw new Error(`${context} requires at least one aggregation selector`);
420
+ for (const [alias, selector] of entries) if (!isAggregateSelector(selector)) throw new Error(`${context} selector "${alias}" is invalid`);
421
+ }
422
+ //#endregion
423
+ //#region src/core/grouped-accessor.ts
424
+ /**
425
+ * Build the composite group key for a row. Uses a JSON encoding of the ordered
426
+ * key-field values so multi-field groups and primitive value types (string,
427
+ * number, boolean, null) partition correctly.
428
+ */
429
+ function groupKeyOf(by, row) {
430
+ return JSON.stringify(by.map((field) => row[field] ?? null));
431
+ }
432
+ function createGroupedAccessor(init) {
433
+ return { async aggregate(fn) {
434
+ const spec = fn(createAggregateBuilder());
435
+ assertValidAggregateSpec(spec, "groupBy().aggregate()");
436
+ const ast = {
437
+ kind: "groupBy",
438
+ modelName: init.modelName,
439
+ by: init.by,
440
+ aggregates: toAggregateRequests(spec),
441
+ ...init.where !== void 0 ? { where: init.where } : {}
442
+ };
443
+ const rows = await init.materialize(ast);
444
+ const groups = /* @__PURE__ */ new Map();
445
+ for (const row of rows) {
446
+ const gk = groupKeyOf(init.by, row);
447
+ let group = groups.get(gk);
448
+ if (group === void 0) {
449
+ const key = {};
450
+ for (const field of init.by) key[field] = row[field];
451
+ group = {
452
+ key,
453
+ rows: []
454
+ };
455
+ groups.set(gk, group);
456
+ }
457
+ group.rows.push(row);
458
+ }
459
+ return Array.from(groups.values()).map((group) => ({
460
+ ...group.key,
461
+ ...computeAggregateSpec(spec, group.rows)
462
+ }));
463
+ } };
464
+ }
465
+ //#endregion
466
+ //#region src/core/mutation-defaults.ts
467
+ function createMutationDefaultsCache() {
468
+ return /* @__PURE__ */ new Map();
469
+ }
470
+ /** Generator ids whose value is shared across an entire top-level call. Every other id always regenerates. */
471
+ const CACHEABLE_GENERATOR_IDS = /* @__PURE__ */ new Set(["timestampNow"]);
472
+ function computeGeneratedValue(spec) {
473
+ const id = spec.id;
474
+ switch (id) {
475
+ case "timestampNow": return /* @__PURE__ */ new Date();
476
+ case "literal": return spec.params?.["value"];
477
+ case "uuidv4":
478
+ case "uuidv7":
479
+ case "cuid2": return generateId(spec.params !== void 0 ? {
480
+ id,
481
+ params: spec.params
482
+ } : { id });
483
+ default: throw new Error(`Unknown mutation-default generator id "${String(id)}"`);
484
+ }
485
+ }
486
+ function resolveGeneratedValue(spec, cache) {
487
+ const id = spec.id;
488
+ if (!CACHEABLE_GENERATOR_IDS.has(id)) return computeGeneratedValue(spec);
489
+ const cached = cache.get(id);
490
+ if (cached !== void 0) return cached;
491
+ const value = computeGeneratedValue(spec);
492
+ cache.set(id, value);
493
+ return value;
494
+ }
495
+ /**
496
+ * Fills in every column with an `onCreate` default that `data` doesn't
497
+ * already set. Returns `data` unchanged (same reference) if there's nothing
498
+ * to apply.
499
+ */
500
+ function applyCreateDefaults(defaults, storeName, data, cache) {
501
+ if (!defaults || defaults.length === 0) return data;
502
+ let result = data;
503
+ for (const d of defaults) {
504
+ if (d.ref.table !== storeName || !d.onCreate) continue;
505
+ if (result[d.ref.column] !== void 0) continue;
506
+ if (result === data) result = { ...data };
507
+ result[d.ref.column] = resolveGeneratedValue(d.onCreate, cache);
508
+ }
509
+ return result;
510
+ }
511
+ /**
512
+ * Fills in every column with an `onUpdate` default that `patch` doesn't
513
+ * already set. A patch with no defined values (empty, or every field
514
+ * explicitly `undefined`) is returned unchanged — no write means no default
515
+ * should fire either (matches SQL's "empty update payloads skip onUpdate
516
+ * defaults" rule).
517
+ */
518
+ function applyUpdateDefaults(defaults, storeName, patch, cache) {
519
+ if (Object.values(patch).every((v) => v === void 0)) return patch;
520
+ if (!defaults || defaults.length === 0) return patch;
521
+ let result = patch;
522
+ for (const d of defaults) {
523
+ if (d.ref.table !== storeName || !d.onUpdate) continue;
524
+ if (result[d.ref.column] !== void 0) continue;
525
+ if (result === patch) result = { ...patch };
526
+ result[d.ref.column] = resolveGeneratedValue(d.onUpdate, cache);
527
+ }
528
+ return result;
529
+ }
530
+ //#endregion
531
+ //#region src/core/relation-loader.ts
532
+ /**
533
+ * Batch-load a single named relation for all rows in `rows` and attach the
534
+ * result to each row under the `relName` key.
535
+ *
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.
538
+ *
539
+ * The `entry` carries any `include()` refinement:
540
+ *
541
+ * - `collection` — the refined `where` further filters the child scan;
542
+ * `orderBy` / `skip` / `take` are applied **per parent group** for `1:N`
543
+ * relations (each parent's children are independently sorted and paginated).
544
+ * - `scalar` — the relation field becomes the `count` of matching children
545
+ * (to-many only; `include()` rejects scalar refinements on to-one relations).
546
+ *
547
+ * @param relName - The relation key to load (e.g. `"posts"`, `"author"`).
548
+ * @param entry - How to materialise the relation (collection vs scalar + refinement state).
549
+ * @param rows - The parent rows to attach related data to.
550
+ * @param contract - The resolved IDB contract.
551
+ * @param modelName - The source model name (owner of the relation).
552
+ * @param executor - The query executor used to run the related-store scan.
553
+ */
554
+ async function loadRelation(relName, entry, rows, contract, modelName, executor, groupingKey) {
555
+ if (rows.length === 0) return rows;
556
+ const models = domainModelsAtDefaultNamespace(contract.domain);
557
+ const model = models[modelName];
558
+ if (model === void 0) return rows;
559
+ const rawRelation = model.relations[relName];
560
+ if (rawRelation === void 0) return rows;
561
+ if (!("on" in rawRelation)) return rows;
562
+ const relation = rawRelation;
563
+ const { cardinality, on } = relation;
564
+ 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;
568
+ const relatedModel = models[relatedModelName];
569
+ if (relatedModel === void 0) return rows;
570
+ const relatedStoreName = typeof relatedModel.storage === "object" && relatedModel.storage !== null && "storeName" in relatedModel.storage ? String(relatedModel.storage["storeName"]) : relatedModelName;
571
+ const isScalar = entry.kind === "scalar";
572
+ const localValues = /* @__PURE__ */ new Set();
573
+ for (const row of rows) {
574
+ const v = row[localField];
575
+ if (v !== void 0 && v !== null) localValues.add(v);
576
+ }
577
+ if (localValues.size === 0) return rows.map((row) => ({
578
+ ...row,
579
+ [relName]: isScalar ? 0 : cardinality === "1:N" ? [] : null
580
+ }));
581
+ const capturedForeignField = foreignField;
582
+ const refinedWhere = combineFilterExprs(entry.state.filters);
583
+ const planMeta = {
584
+ target: "idb",
585
+ storageHash: contract.storage.storageHash,
586
+ lane: "idb-orm",
587
+ annotations: { groupingKey }
588
+ };
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);
591
+ let relatedRows;
592
+ if (fkIndexName !== void 0 || targetsRelatedPk) {
593
+ 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) => {
596
+ const plan = {
597
+ meta: planMeta,
598
+ idbPlan: {
599
+ meta: planMeta,
600
+ kind: "cursor-scan",
601
+ storeName: relatedStoreName,
602
+ ...fkIndexName !== void 0 ? { indexName: fkIndexName } : {},
603
+ range: IDBKeyRange.only(value),
604
+ ...refinedFilter !== void 0 ? { filter: refinedFilter } : {}
605
+ }
606
+ };
607
+ const rows = [];
608
+ for await (const row of executor.query(plan)) rows.push(row);
609
+ return rows;
610
+ }))).flat();
611
+ } else {
612
+ relatedRows = [];
613
+ const filter = (row) => localValues.has(row[capturedForeignField]) && (refinedWhere === void 0 || evaluateFilter(refinedWhere, row));
614
+ const plan = {
615
+ meta: planMeta,
616
+ idbPlan: {
617
+ meta: planMeta,
618
+ kind: "cursor-scan",
619
+ storeName: relatedStoreName,
620
+ filter
621
+ }
622
+ };
623
+ for await (const row of executor.query(plan)) relatedRows.push(row);
624
+ }
625
+ if (cardinality === "1:N") {
626
+ const grouped = /* @__PURE__ */ new Map();
627
+ for (const rrow of relatedRows) {
628
+ const gk = rrow[capturedForeignField];
629
+ const group = grouped.get(gk) ?? [];
630
+ group.push(rrow);
631
+ grouped.set(gk, group);
632
+ }
633
+ if (isScalar) return rows.map((row) => ({
634
+ ...row,
635
+ [relName]: (grouped.get(row[localField]) ?? []).length
636
+ }));
637
+ const comparator = buildRowComparator(entry.state.orderBy);
638
+ const skip = entry.state.skip ?? 0;
639
+ const take = entry.state.take;
640
+ return rows.map((row) => {
641
+ let group = grouped.get(row[localField]) ?? [];
642
+ if (comparator !== void 0) group = [...group].sort(comparator);
643
+ if (skip > 0 || take !== void 0) group = group.slice(skip, take !== void 0 ? skip + take : void 0);
644
+ return {
645
+ ...row,
646
+ [relName]: group
647
+ };
648
+ });
649
+ }
650
+ 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
+ }));
656
+ }
657
+ //#endregion
658
+ //#region src/core/mutation-scope.ts
659
+ /**
660
+ * Run a callback inside a single multi-store IDB readwrite transaction.
661
+ *
662
+ * Opens the transaction, passes the `IdbTransactionScope` to `run`, then:
663
+ * - On success: awaits `scope.commit()` (waits for `tx.oncomplete`).
664
+ * - On error: calls `scope.rollback()` and rethrows.
665
+ *
666
+ * The callback receives the low-level scope, not the ORM accessor.
667
+ * Use this for multi-store atomic writes that span several object stores.
668
+ */
669
+ async function withMutationScope(executor, storeNames, run) {
670
+ const tx = await executor.transaction(storeNames, "readwrite");
671
+ try {
672
+ const result = await run(tx);
673
+ await tx.commit();
674
+ return result;
675
+ } catch (err) {
676
+ tx.rollback();
677
+ throw err;
678
+ }
679
+ }
680
+ //#endregion
681
+ //#region src/core/relation-mutator.ts
682
+ function createRelationMutator() {
683
+ return {
684
+ create(data) {
685
+ return {
686
+ kind: "create",
687
+ data: Array.isArray(data) ? [...data] : [data]
688
+ };
689
+ },
690
+ connect(criteria) {
691
+ return {
692
+ kind: "connect",
693
+ criteria: Array.isArray(criteria) ? [...criteria] : [criteria]
694
+ };
695
+ },
696
+ disconnect(criteria) {
697
+ if (!criteria) return { kind: "disconnect" };
698
+ return {
699
+ kind: "disconnect",
700
+ criteria: [...criteria]
701
+ };
702
+ }
703
+ };
704
+ }
705
+ function isRelationMutationDescriptor(value) {
706
+ if (!value || typeof value !== "object") return false;
707
+ const candidate = value;
708
+ return candidate.kind === "create" || candidate.kind === "connect" || candidate.kind === "disconnect";
709
+ }
710
+ function isRelationMutationCallback(value) {
711
+ return typeof value === "function";
712
+ }
713
+ //#endregion
714
+ //#region src/core/mutation-executor.ts
715
+ function makePlanMeta(contract) {
716
+ return {
717
+ target: "idb",
718
+ storageHash: contract.storage.storageHash,
719
+ lane: "idb-mutation-executor",
720
+ annotations: { groupingKey: "nested" }
721
+ };
722
+ }
723
+ const relationDefsCache = /* @__PURE__ */ new WeakMap();
724
+ function getRelationDefinitions(contract, modelName) {
725
+ let perContract = relationDefsCache.get(contract);
726
+ if (!perContract) {
727
+ perContract = /* @__PURE__ */ new Map();
728
+ relationDefsCache.set(contract, perContract);
729
+ }
730
+ const cached = perContract.get(modelName);
731
+ if (cached) return cached;
732
+ const model = domainModelsAtDefaultNamespace(contract.domain)[modelName];
733
+ if (!model) {
734
+ perContract.set(modelName, []);
735
+ return [];
736
+ }
737
+ const defs = [];
738
+ for (const [relationName, rawRelation] of Object.entries(model.relations)) {
739
+ if (!rawRelation || typeof rawRelation !== "object" || !("on" in rawRelation)) continue;
740
+ const relation = rawRelation;
741
+ const relatedModelName = relation.to.model;
742
+ const relatedStoreName = getStoreName(contract, relatedModelName);
743
+ defs.push({
744
+ relationName,
745
+ relatedModelName,
746
+ relatedStoreName,
747
+ cardinality: relation.cardinality,
748
+ localFields: relation.on.localFields,
749
+ targetFields: relation.on.targetFields
750
+ });
751
+ }
752
+ perContract.set(modelName, defs);
753
+ return defs;
754
+ }
755
+ /**
756
+ * Returns true if `data` contains at least one field that is both a known
757
+ * relation name for `modelName` and a function (a mutation callback).
758
+ */
759
+ function hasNestedMutationCallbacks(contract, modelName, data) {
760
+ const relationNames = new Set(getRelationDefinitions(contract, modelName).map((r) => r.relationName));
761
+ for (const [fieldName, value] of Object.entries(data)) if (relationNames.has(fieldName) && isRelationMutationCallback(value)) return true;
762
+ return false;
763
+ }
764
+ /**
765
+ * Guards that the executor supports multi-store transactions.
766
+ * Throws a clear error if `transaction()` is not available — the user must
767
+ * use IdbRuntime (createIdbRuntime / createAutoMigratingIdbClient) rather than
768
+ * a plain IdbQueryExecutor stub.
769
+ */
770
+ function requireTransactionExecutor(executor) {
771
+ if (typeof executor.transaction !== "function") throw new Error("This operation requires an executor with transaction support (nested relation writes, FK-validated create/update, and referential-action delete all need it). Use IdbRuntime (createIdbRuntime or createAutoMigratingIdbClient) instead of a plain IdbQueryExecutor.");
772
+ return executor;
773
+ }
774
+ async function executeNestedCreateMutation(options) {
775
+ const { executor, contract, modelName, data } = options;
776
+ const record = data;
777
+ const storeNames = collectStoreNames(contract, modelName, record);
778
+ const defaultsCache = createMutationDefaultsCache();
779
+ return withMutationScope(executor, storeNames, (scope) => createGraph(scope, contract, modelName, record, defaultsCache));
780
+ }
781
+ async function executeNestedUpdateMutation(options) {
782
+ const { executor, contract, modelName, filters, data } = options;
783
+ const record = data;
784
+ const storeNames = collectStoreNames(contract, modelName, record);
785
+ const defaultsCache = createMutationDefaultsCache();
786
+ return withMutationScope(executor, storeNames, (scope) => updateFirstGraph(scope, contract, modelName, filters, record, defaultsCache));
787
+ }
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);
791
+ return [...stores];
792
+ }
793
+ async function createGraph(scope, contract, modelName, input, defaultsCache) {
794
+ const parsed = parseMutationInput(contract, modelName, input);
795
+ const { parentOwned, childOwned } = partitionByOwnership(parsed.relationMutations);
796
+ const scalarData = { ...parsed.scalarData };
797
+ for (const item of parentOwned) {
798
+ if (item.mutation.kind === "disconnect") throw new Error("disconnect() is only supported in update() nested mutations");
799
+ await applyParentOwnedMutation(scope, contract, modelName, scalarData, item.relation, item.mutation, defaultsCache);
800
+ }
801
+ const parentRow = await insertSingleRow(scope, contract, modelName, scalarData, defaultsCache);
802
+ for (const item of childOwned) {
803
+ if (item.mutation.kind === "disconnect") throw new Error("disconnect() is only supported in update() nested mutations");
804
+ await applyChildOwnedMutation(scope, contract, modelName, parentRow, item.relation, item.mutation, defaultsCache);
805
+ }
806
+ return parentRow;
807
+ }
808
+ async function updateFirstGraph(scope, contract, modelName, filters, input, defaultsCache) {
809
+ const existingRow = await findFirstByFilters(scope, contract, modelName, filters);
810
+ if (!existingRow) return null;
811
+ const parsed = parseMutationInput(contract, modelName, input);
812
+ const { parentOwned, childOwned } = partitionByOwnership(parsed.relationMutations);
813
+ const scalarData = { ...parsed.scalarData };
814
+ for (const item of parentOwned) await applyParentOwnedMutation(scope, contract, modelName, scalarData, item.relation, item.mutation, defaultsCache);
815
+ let parentRow = existingRow;
816
+ if (Object.keys(scalarData).length > 0) {
817
+ const storeName = getStoreName(contract, modelName);
818
+ const key = existingRow[getKeyPath(contract, modelName)];
819
+ const meta = makePlanMeta(contract);
820
+ const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, storeName, scalarData, defaultsCache);
821
+ const updated = (await scope.execute({
822
+ meta,
823
+ kind: "update",
824
+ storeName,
825
+ key,
826
+ patch
827
+ }))[0];
828
+ if (updated) parentRow = updated;
829
+ }
830
+ for (const item of childOwned) await applyChildOwnedMutation(scope, contract, modelName, parentRow, item.relation, item.mutation, defaultsCache);
831
+ return parentRow;
832
+ }
833
+ function parseMutationInput(contract, modelName, input) {
834
+ const scalarData = {};
835
+ const relationDefs = new Map(getRelationDefinitions(contract, modelName).map((r) => [r.relationName, r]));
836
+ const relationMutations = [];
837
+ for (const [fieldName, value] of Object.entries(input)) {
838
+ const relation = relationDefs.get(fieldName);
839
+ if (!relation) {
840
+ scalarData[fieldName] = value;
841
+ continue;
842
+ }
843
+ if (!isRelationMutationCallback(value)) throw new Error(`Relation field "${fieldName}" on model "${modelName}" expects a mutator callback`);
844
+ const mutation = value(createRelationMutator());
845
+ if (!isRelationMutationDescriptor(mutation)) throw new Error(`Relation field "${fieldName}" on model "${modelName}" returned an invalid mutation descriptor`);
846
+ relationMutations.push({
847
+ relation,
848
+ mutation
849
+ });
850
+ }
851
+ return {
852
+ scalarData,
853
+ relationMutations
854
+ };
855
+ }
856
+ function partitionByOwnership(mutations) {
857
+ const parentOwned = [];
858
+ const childOwned = [];
859
+ for (const item of mutations) {
860
+ if (item.relation.cardinality === "N:1") {
861
+ parentOwned.push(item);
862
+ continue;
863
+ }
864
+ if (item.relation.cardinality === "M:N") throw new Error("M:N nested mutations are not supported");
865
+ childOwned.push(item);
866
+ }
867
+ return {
868
+ parentOwned,
869
+ childOwned
870
+ };
871
+ }
872
+ async function applyParentOwnedMutation(scope, contract, parentModelName, scalarData, relation, mutation, defaultsCache) {
873
+ if (mutation.kind === "disconnect") {
874
+ for (const localField of relation.localFields) scalarData[localField] = null;
875
+ return;
876
+ }
877
+ if (mutation.kind === "create") {
878
+ const row = mutation.data[0];
879
+ if (!row) throw new Error(`create() nested mutation for relation "${relation.relationName}" requires data`);
880
+ copyRelatedValuesToParent(relation, scalarData, await insertSingleRow(scope, contract, relation.relatedModelName, row, defaultsCache), parentModelName, contract);
881
+ return;
882
+ }
883
+ const criterion = mutation.criteria[0];
884
+ if (!criterion) throw new Error(`connect() nested mutation for relation "${relation.relationName}" requires a criterion`);
885
+ const relatedRow = await findRowByCriterion(scope, contract, relation.relatedModelName, criterion);
886
+ if (!relatedRow) throw new Error(`connect() nested mutation for relation "${relation.relationName}" did not find a matching row`);
887
+ copyRelatedValuesToParent(relation, scalarData, relatedRow, parentModelName, contract);
888
+ }
889
+ function copyRelatedValuesToParent(relation, scalarData, relatedRow, _parentModelName, _contract) {
890
+ for (let i = 0; i < relation.localFields.length; i++) {
891
+ const localField = relation.localFields[i];
892
+ const targetField = relation.targetFields[i];
893
+ if (!localField || !targetField) continue;
894
+ scalarData[localField] = relatedRow[targetField];
895
+ }
896
+ }
897
+ async function applyChildOwnedMutation(scope, contract, parentModelName, parentRow, relation, mutation, defaultsCache) {
898
+ const parentValues = readParentColumnValues(parentModelName, relation, parentRow);
899
+ if (mutation.kind === "create") {
900
+ for (const childInput of mutation.data) {
901
+ const payload = { ...childInput };
902
+ for (const [childField, parentValue] of parentValues.entries()) payload[childField] = parentValue;
903
+ await insertSingleRow(scope, contract, relation.relatedModelName, payload, defaultsCache);
904
+ }
905
+ return;
906
+ }
907
+ if (mutation.kind === "connect") {
908
+ for (const criterion of mutation.criteria) {
909
+ const setValues = {};
910
+ for (const [childField, parentValue] of parentValues.entries()) setValues[childField] = parentValue;
911
+ const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, relation.relatedStoreName, setValues, defaultsCache);
912
+ const filter = buildCriterionFilter(criterion);
913
+ const meta = makePlanMeta(contract);
914
+ await scope.execute({
915
+ meta,
916
+ kind: "scan-write",
917
+ storeName: relation.relatedStoreName,
918
+ write: "put-merged",
919
+ patch,
920
+ filter
921
+ });
922
+ }
923
+ return;
924
+ }
925
+ const setValues = {};
926
+ for (const childField of parentValues.keys()) setValues[childField] = null;
927
+ const meta = makePlanMeta(contract);
928
+ if (!mutation.criteria || mutation.criteria.length === 0) {
929
+ const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, relation.relatedStoreName, setValues, defaultsCache);
930
+ const parentJoinFilter = buildParentJoinFilter(parentValues);
931
+ await scope.execute({
932
+ meta,
933
+ kind: "scan-write",
934
+ storeName: relation.relatedStoreName,
935
+ write: "put-merged",
936
+ patch,
937
+ filter: parentJoinFilter
938
+ });
939
+ return;
940
+ }
941
+ for (const criterion of mutation.criteria) {
942
+ const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, relation.relatedStoreName, setValues, defaultsCache);
943
+ const criterionFilter = buildCriterionFilter(criterion);
944
+ const parentJoinFilter = buildParentJoinFilter(parentValues);
945
+ const combinedFilter = (row) => parentJoinFilter(row) && criterionFilter(row);
946
+ await scope.execute({
947
+ meta,
948
+ kind: "scan-write",
949
+ storeName: relation.relatedStoreName,
950
+ write: "put-merged",
951
+ patch,
952
+ filter: combinedFilter
953
+ });
954
+ }
955
+ }
956
+ function readParentColumnValues(parentModelName, relation, parentRow) {
957
+ const values = /* @__PURE__ */ new Map();
958
+ for (let i = 0; i < relation.localFields.length; i++) {
959
+ const localField = relation.localFields[i];
960
+ const targetField = relation.targetFields[i];
961
+ if (!localField || !targetField) continue;
962
+ const parentValue = parentRow[localField];
963
+ if (parentValue === void 0) throw new Error(`Nested mutation requires parent field "${localField}" to be present in "${parentModelName}" row`);
964
+ values.set(targetField, parentValue);
965
+ }
966
+ return values;
967
+ }
968
+ async function insertSingleRow(scope, contract, modelName, data, defaultsCache) {
969
+ assertNoNestedCallbacks(modelName, data);
970
+ const storeName = getStoreName(contract, modelName);
971
+ const meta = makePlanMeta(contract);
972
+ const record = applyCreateDefaults(contract.execution?.mutations.defaults, storeName, data, defaultsCache);
973
+ return (await scope.execute({
974
+ meta,
975
+ kind: "add",
976
+ storeName,
977
+ record
978
+ }))[0] ?? record;
979
+ }
980
+ /**
981
+ * Recursive nesting (a relation callback inside an already-nested create) is
982
+ * not supported in Phase 6.4. Without this guard the callback function would be
983
+ * handed to `store.put(...)`, where IDB's structured-clone throws an opaque
984
+ * `DataCloneError` ("could not be cloned") that gives the developer no hint
985
+ * about the real cause. Surface a precise error instead. (PLAN Issue #22.)
986
+ */
987
+ function assertNoNestedCallbacks(modelName, data) {
988
+ for (const [field, value] of Object.entries(data)) if (typeof value === "function") throw new Error(`Recursive nested writes are not supported: field "${field}" on a nested "${modelName}" record is a relation callback. Only one level of relation nesting is supported — flatten the inner relation into a separate create/connect call.`);
989
+ }
990
+ async function findRowByCriterion(scope, contract, modelName, criterion) {
991
+ const expr = shorthandToFilterExpr(criterion);
992
+ if (!expr) throw new Error(`Nested connect for model "${modelName}" requires a non-empty criterion`);
993
+ const filter = (row) => evaluateFilter(expr, row);
994
+ return scanOneRow(scope, contract, modelName, filter);
995
+ }
996
+ async function findFirstByFilters(scope, contract, modelName, filters) {
997
+ if (filters.length === 0) return null;
998
+ const combined = filters.length === 1 ? filters[0] : {
999
+ kind: "and",
1000
+ exprs: filters
1001
+ };
1002
+ const filter = (row) => evaluateFilter(combined, row);
1003
+ return scanOneRow(scope, contract, modelName, filter);
1004
+ }
1005
+ async function scanOneRow(scope, contract, modelName, filter) {
1006
+ const storeName = getStoreName(contract, modelName);
1007
+ const plan = {
1008
+ meta: makePlanMeta(contract),
1009
+ kind: "cursor-scan",
1010
+ storeName,
1011
+ filter,
1012
+ take: 1
1013
+ };
1014
+ return (await scope.execute(plan))[0] ?? null;
1015
+ }
1016
+ function buildCriterionFilter(criterion) {
1017
+ const expr = shorthandToFilterExpr(criterion);
1018
+ if (!expr) return () => true;
1019
+ return (row) => evaluateFilter(expr, row);
1020
+ }
1021
+ function buildParentJoinFilter(parentValues) {
1022
+ 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";
1027
+ }
1028
+ function getStoredAction(contract, modelName, relationName, kind) {
1029
+ return (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.relations?.[relationName]?.[kind];
1030
+ }
1031
+ function sameFields(a, b) {
1032
+ return a.length === b.length && a.every((field, index) => field === b[index]);
1033
+ }
1034
+ /**
1035
+ * Resolves the referential action for a child-enforcement relation: prefers
1036
+ * the action stored directly on this side, falls back to the same action
1037
+ * stored on the inverse (FK-owning) side of the relation, and finally to
1038
+ * `defaultAction` when neither side declares one.
1039
+ *
1040
+ * Both branches are live, for different authoring paths. PSL only ever
1041
+ * stores the action on the `N:1` (FK-owning) side — enforcement always walks
1042
+ * from the parent (`1:N`/non-owning-`1:1`) side, which PSL leaves bare, so
1043
+ * the fallback is what actually finds it. TS-DSL relation entries are
1044
+ * self-contained (no PSL-style pairing requirement) and typically declare
1045
+ * the action directly on the side this function is called for, so the direct
1046
+ * check is what fires there — see `contract-builder.ts`'s
1047
+ * `validateNoConflictingRelationActions`, which rejects declaring the same
1048
+ * action kind on both sides so this fallback never has to arbitrate a real
1049
+ * conflict.
1050
+ */
1051
+ function getReferentialActionForRelation(contract, modelName, def, kind, defaultAction) {
1052
+ const direct = getStoredAction(contract, modelName, def.relationName, kind);
1053
+ if (direct !== void 0) return direct;
1054
+ for (const inverse of getRelationDefinitions(contract, def.relatedModelName)) {
1055
+ if (inverse.relatedModelName !== modelName) continue;
1056
+ if (!sameFields(inverse.localFields, def.targetFields)) continue;
1057
+ if (!sameFields(inverse.targetFields, def.localFields)) continue;
1058
+ const inverseAction = getStoredAction(contract, def.relatedModelName, inverse.relationName, kind);
1059
+ if (inverseAction !== void 0) return inverseAction;
1060
+ }
1061
+ return defaultAction;
1062
+ }
1063
+ function getOnDeleteForDeleteRelation(contract, modelName, def) {
1064
+ return getReferentialActionForRelation(contract, modelName, def, "onDelete", "restrict");
1065
+ }
1066
+ /**
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.
1071
+ */
1072
+ function getOnUpdateForRelation(contract, modelName, def) {
1073
+ return getReferentialActionForRelation(contract, modelName, def, "onUpdate", "cascade");
1074
+ }
1075
+ function isDeleteEnforcementRelation(contract, modelName, def) {
1076
+ if (def.cardinality === "1:N") return true;
1077
+ if (def.cardinality === "1:1") {
1078
+ const keyPath = getKeyPath(contract, modelName);
1079
+ return def.localFields.length > 0 && def.localFields[0] === keyPath;
1080
+ }
1081
+ return false;
1082
+ }
1083
+ /**
1084
+ * Builds a filter matching a relation's children against one specific parent
1085
+ * row's values. Shared by `onDelete` cascade (`applyReferentialActionsForRow`)
1086
+ * and `onUpdate` cascade (`applyReferentialActionsForRowOnUpdate`).
1087
+ */
1088
+ function buildChildFilterFromRow(def, row) {
1089
+ const pairs = def.localFields.map((lf, i) => ({
1090
+ childField: def.targetFields[i],
1091
+ parentValue: row[lf]
1092
+ }));
1093
+ return (child) => pairs.every(({ childField, parentValue }) => child[childField] === parentValue);
1094
+ }
1095
+ /** Reads a field's literal `@default(...)` value from `IdbModelStorage.fieldDefaults`, if declared. */
1096
+ function getFieldDefault(contract, modelName, fieldName) {
1097
+ return (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.fieldDefaults?.[fieldName];
1098
+ }
1099
+ /**
1100
+ * Builds the patch for a `setDefault` referential action: each of the
1101
+ * relation's `targetFields` (the child's FK fields) reset to its own
1102
+ * declared literal default — not the parent's. Throws when any target field
1103
+ * has no declared default; `setDefault` is only meaningful when every FK
1104
+ * field it resets has somewhere to reset to.
1105
+ */
1106
+ function buildSetDefaultPatch(contract, def) {
1107
+ const patch = {};
1108
+ for (const targetField of def.targetFields) {
1109
+ const value = getFieldDefault(contract, def.relatedModelName, targetField);
1110
+ if (value === void 0) throw new Error(`setDefault referential action on relation '${def.relationName}' requires field '${def.relatedModelName}.${targetField}' to declare a literal @default(...) value — no default is registered for that field.`);
1111
+ patch[targetField] = value;
1112
+ }
1113
+ return patch;
1114
+ }
1115
+ /**
1116
+ * Validates that a `setDefault` patch actually references a real row before
1117
+ * it's written — without this, `setDefault` would silently reintroduce the
1118
+ * exact dangling-FK problem the rest of this file exists to prevent. A real
1119
+ * SQL database only makes `SET DEFAULT` safe because its FK constraint
1120
+ * re-checks the new value transactionally at write time; IDB has no such
1121
+ * engine to delegate to, so this check is IDB's equivalent.
1122
+ *
1123
+ * Checks against `modelName`'s own store (the parent being deleted/updated)
1124
+ * using `def.localFields` — the relation's own semantics ("child.targetField
1125
+ * references parent.localField") mean that's exactly what a default value
1126
+ * written to the child's FK field must match. That store is always already
1127
+ * part of the transaction (it's the model literally being written), so no
1128
+ * store-list changes are needed to call this.
1129
+ *
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.
1134
+ *
1135
+ * `excludeKey`, when given, excludes the parent row currently being
1136
+ * deleted/updated from the existence scan. That row is still physically
1137
+ * present in the store at this point (its own write/delete hasn't happened
1138
+ * yet — this check runs first, before the enforcement loop's caller applies
1139
+ * it), so without the exclusion, a default value equal to that row's *old*
1140
+ * `localField` value would false-positive: the scan would find the row about
1141
+ * to disappear (or change away from that value) and wrongly conclude the
1142
+ * default references a real row, when after the transaction nothing will.
1143
+ */
1144
+ 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.`);
1160
+ }
1161
+ /**
1162
+ * Collects every store an `onUpdate`-enforced write to `modelName` might
1163
+ * touch: the model's own store, plus the related store of any 1:N/1:1-
1164
+ * parent-side relation whose `localFields` appear in the raw patch —
1165
+ * pessimistic (checks field *presence*, not whether the resolved value will
1166
+ * actually change, since the current row isn't read yet when this runs).
1167
+ * Once a `cascade` edge is entered, the walk continues transitively (mirrors
1168
+ * `collectDeleteStoreNames`'s cascade-only recursion), since a propagated
1169
+ * value change may itself need to cascade further.
1170
+ *
1171
+ * Returns `enforces: false` when no `onUpdate` enforcement can apply to this
1172
+ * write — callers use that to keep the existing fast blind-write path
1173
+ * instead of paying for a read-before-write. This is tracked as an explicit
1174
+ * flag rather than inferred from `storeNames.length > 1`: for a
1175
+ * self-referential relation, `def.relatedStoreName` equals `modelName`'s own
1176
+ * store (already the seed of the `Set`), so the store count alone can't tell
1177
+ * "no enforcement" apart from "enforces, but only against this same store".
1178
+ */
1179
+ function collectOnUpdateEnforcementStoreNames(contract, modelName, data) {
1180
+ const stores = /* @__PURE__ */ new Set([getStoreName(contract, modelName)]);
1181
+ const visitedModels = /* @__PURE__ */ new Set();
1182
+ let enforces = false;
1183
+ function walkCascadeChain(mName) {
1184
+ if (visitedModels.has(mName)) return;
1185
+ visitedModels.add(mName);
1186
+ for (const def of getRelationDefinitions(contract, mName)) {
1187
+ if (!isDeleteEnforcementRelation(contract, mName, def)) continue;
1188
+ const action = getOnUpdateForRelation(contract, mName, def);
1189
+ if (action === "noAction") continue;
1190
+ stores.add(def.relatedStoreName);
1191
+ if (action === "cascade") walkCascadeChain(def.relatedModelName);
1192
+ }
1193
+ }
1194
+ visitedModels.add(modelName);
1195
+ for (const def of getRelationDefinitions(contract, modelName)) {
1196
+ if (!isDeleteEnforcementRelation(contract, modelName, def)) continue;
1197
+ if (!def.localFields.some((f) => f in data)) continue;
1198
+ const action = getOnUpdateForRelation(contract, modelName, def);
1199
+ if (action === "noAction") continue;
1200
+ enforces = true;
1201
+ stores.add(def.relatedStoreName);
1202
+ if (action === "cascade") walkCascadeChain(def.relatedModelName);
1203
+ }
1204
+ return {
1205
+ storeNames: [...stores],
1206
+ enforces
1207
+ };
1208
+ }
1209
+ /**
1210
+ * Enforces every `onUpdate` action declared on `modelName`'s child relations
1211
+ * against one specific `oldRow` about to be patched with `patch`. Only
1212
+ * relations whose `localFields` are both present in `patch` and differ from
1213
+ * `oldRow`'s current value are enforced — a patch that sets a field to its
1214
+ * existing value, or never touches a locally-referenced field, is a no-op
1215
+ * here. Children are matched against `oldRow`'s pre-change values.
1216
+ *
1217
+ * `cascade` propagates the new value(s) onto the matched children's FK
1218
+ * fields and recurses into each child's own `onUpdate` relations before
1219
+ * writing (the propagated change may itself need to cascade further) —
1220
+ * mirrors `applyReferentialActionsForRow`'s recursive shape, including the
1221
+ * `visited` row-level cycle guard (see that function's doc comment for the
1222
+ * fresh-per-top-level-row / shared-across-one-row's-descent contract).
1223
+ * `setNull`/`setDefault` are leaf actions: the child's FK field changes but
1224
+ * its own key doesn't, so nothing below it needs re-enforcement.
1225
+ *
1226
+ * Unlike `validateScalarFks`'s compound-FK restriction, a compound (multi-
1227
+ * field) relation is safe to cascade here: both `localFields` values come
1228
+ * from the same `oldRow`/`patch`, so there's no risk of assembling a value
1229
+ * from two unrelated rows the way independently-validated FK-existence
1230
+ * checks could.
1231
+ */
1232
+ async function applyReferentialActionsForRowOnUpdate(scope, contract, modelName, oldRow, patch, visited = /* @__PURE__ */ new Set()) {
1233
+ const keyPath = getKeyPath(contract, modelName);
1234
+ const rowKey = `${getStoreName(contract, modelName)}::${String(oldRow[keyPath])}`;
1235
+ if (visited.has(rowKey)) return;
1236
+ visited.add(rowKey);
1237
+ const meta = makePlanMeta(contract);
1238
+ for (const def of getRelationDefinitions(contract, modelName)) {
1239
+ if (!isDeleteEnforcementRelation(contract, modelName, def)) continue;
1240
+ const changedFields = def.localFields.filter((f) => f in patch && patch[f] !== oldRow[f]);
1241
+ if (changedFields.length === 0) continue;
1242
+ const action = getOnUpdateForRelation(contract, modelName, def);
1243
+ if (action === "noAction") continue;
1244
+ const childFilter = buildChildFilterFromRow(def, oldRow);
1245
+ 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'.`);
1253
+ continue;
1254
+ }
1255
+ if (action === "cascade") {
1256
+ const childPatch = {};
1257
+ for (let i = 0; i < def.localFields.length; i++) {
1258
+ const lf = def.localFields[i];
1259
+ const tf = def.targetFields[i];
1260
+ if (changedFields.includes(lf)) childPatch[tf] = patch[lf];
1261
+ }
1262
+ const children = await scope.execute({
1263
+ meta,
1264
+ kind: "cursor-scan",
1265
+ storeName: def.relatedStoreName,
1266
+ filter: childFilter
1267
+ });
1268
+ for (const child of children) await applyReferentialActionsForRowOnUpdate(scope, contract, def.relatedModelName, child, childPatch, visited);
1269
+ await scope.execute({
1270
+ meta,
1271
+ kind: "scan-write",
1272
+ storeName: def.relatedStoreName,
1273
+ write: "put-merged",
1274
+ patch: childPatch,
1275
+ filter: childFilter
1276
+ });
1277
+ continue;
1278
+ }
1279
+ if (action === "setNull") {
1280
+ const childPatch = {};
1281
+ for (const targetField of def.targetFields) childPatch[targetField] = null;
1282
+ await scope.execute({
1283
+ meta,
1284
+ kind: "scan-write",
1285
+ storeName: def.relatedStoreName,
1286
+ write: "put-merged",
1287
+ patch: childPatch,
1288
+ filter: childFilter
1289
+ });
1290
+ continue;
1291
+ }
1292
+ if (action === "setDefault") {
1293
+ const childPatch = buildSetDefaultPatch(contract, def);
1294
+ await validateSetDefaultPatch(scope, contract, modelName, def, childPatch, oldRow[keyPath]);
1295
+ await scope.execute({
1296
+ meta,
1297
+ kind: "scan-write",
1298
+ storeName: def.relatedStoreName,
1299
+ write: "put-merged",
1300
+ patch: childPatch,
1301
+ filter: childFilter
1302
+ });
1303
+ continue;
1304
+ }
1305
+ }
1306
+ }
1307
+ /**
1308
+ * Returns true if `data` contains at least one non-null value for a localField
1309
+ * of a N:1 relation — indicating scalar FK fields that need existence validation.
1310
+ */
1311
+ function hasScalarFkFields(contract, modelName, data) {
1312
+ for (const def of getRelationDefinitions(contract, modelName)) {
1313
+ if (def.cardinality !== "N:1") continue;
1314
+ for (const localField of def.localFields) if (localField in data && data[localField] !== null && data[localField] !== void 0) return true;
1315
+ }
1316
+ return false;
1317
+ }
1318
+ function collectScalarFkStoreNames(contract, modelName, data) {
1319
+ const stores = /* @__PURE__ */ new Set([getStoreName(contract, modelName)]);
1320
+ for (const def of getRelationDefinitions(contract, modelName)) {
1321
+ if (def.cardinality !== "N:1") continue;
1322
+ if (def.localFields.some((f) => f in data && data[f] !== null && data[f] !== void 0)) stores.add(def.relatedStoreName);
1323
+ }
1324
+ return [...stores];
1325
+ }
1326
+ async function validateScalarFks(scope, contract, modelName, data) {
1327
+ const meta = makePlanMeta(contract);
1328
+ for (const def of getRelationDefinitions(contract, modelName)) {
1329
+ 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)}'`);
1344
+ }
1345
+ }
1346
+ async function executeScalarCreateWithFkValidation(options) {
1347
+ 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());
1351
+ });
1352
+ }
1353
+ /**
1354
+ * Combines FK-existence store scope (this model's own N:1 relations) with
1355
+ * `onUpdate` enforcement store scope (this model's child relations) for a
1356
+ * single write. Returns the union alongside whether `onUpdate` enforcement
1357
+ * actually applies (i.e. the `onUpdate` collector found more than just the
1358
+ * model's own store) — callers use that flag to choose between the existing
1359
+ * fast blind-write path and the read-before-write enforcement path.
1360
+ */
1361
+ function collectUpdateStoreNames(contract, modelName, data) {
1362
+ const fkStoreNames = collectScalarFkStoreNames(contract, modelName, data);
1363
+ const { storeNames: onUpdateStoreNames, enforces } = collectOnUpdateEnforcementStoreNames(contract, modelName, data);
1364
+ return {
1365
+ storeNames: [.../* @__PURE__ */ new Set([...fkStoreNames, ...onUpdateStoreNames])],
1366
+ enforcesOnUpdate: enforces
1367
+ };
1368
+ }
1369
+ async function executeScalarUpdateWithFkValidation(options) {
1370
+ const { executor, contract, modelName, filters, data } = options;
1371
+ const { storeNames, enforcesOnUpdate } = collectUpdateStoreNames(contract, modelName, data);
1372
+ return withMutationScope(executor, storeNames, async (scope) => {
1373
+ await validateScalarFks(scope, contract, modelName, data);
1374
+ const storeName = getStoreName(contract, modelName);
1375
+ const meta = makePlanMeta(contract);
1376
+ const combined = filters.length === 0 ? void 0 : filters.length === 1 ? filters[0] : {
1377
+ kind: "and",
1378
+ exprs: filters
1379
+ };
1380
+ 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({
1383
+ meta,
1384
+ kind: "scan-write",
1385
+ storeName,
1386
+ write: "put-merged",
1387
+ patch,
1388
+ take: 1,
1389
+ ...filter !== void 0 ? { filter } : {}
1390
+ }))[0] ?? null;
1391
+ const oldRow = (await scope.execute({
1392
+ meta,
1393
+ kind: "cursor-scan",
1394
+ storeName,
1395
+ take: 1,
1396
+ ...filter !== void 0 ? { filter } : {}
1397
+ }))[0];
1398
+ if (!oldRow) return null;
1399
+ await applyReferentialActionsForRowOnUpdate(scope, contract, modelName, oldRow, patch);
1400
+ const key = oldRow[getKeyPath(contract, modelName)];
1401
+ return (await scope.execute({
1402
+ meta,
1403
+ kind: "update",
1404
+ storeName,
1405
+ key,
1406
+ patch
1407
+ }))[0] ?? null;
1408
+ });
1409
+ }
1410
+ /**
1411
+ * Bulk counterpart to {@link executeScalarUpdateWithFkValidation}: same FK
1412
+ * validation and `onUpdate` enforcement, but applies the patch to every row
1413
+ * the filter matches (no `take: 1`) and returns all of them.
1414
+ *
1415
+ * Always goes through the transaction scope — unlike single-row `update()`,
1416
+ * which only needs one when there's a scalar FK field or `onUpdate`
1417
+ * enforcement to apply. A bulk scan-write's affected row SET isn't knowable
1418
+ * until it actually runs, and observing that result INSIDE the same
1419
+ * transaction is exactly what the sync interceptor needs to track the write
1420
+ * correctly: its transaction-scope hook (`SyncInterceptingTransactionScope#maybeTrack`'s
1421
+ * `scan-write`/`update` cases) writes one outbox event per row it's handed,
1422
+ * atomically with the write itself. The plan-level path `update()` falls
1423
+ * back to for a non-enforced single row has no equivalent hook — a plan is
1424
+ * extended with outbox ops BEFORE it runs, which only works when the
1425
+ * affected key is knowable up front.
1426
+ */
1427
+ async function executeBulkUpdateWithFkValidation(options) {
1428
+ const { executor, contract, modelName, filters, data } = options;
1429
+ const { storeNames, enforcesOnUpdate } = collectUpdateStoreNames(contract, modelName, data);
1430
+ return withMutationScope(executor, storeNames, async (scope) => {
1431
+ await validateScalarFks(scope, contract, modelName, data);
1432
+ const storeName = getStoreName(contract, modelName);
1433
+ const meta = makePlanMeta(contract);
1434
+ const combined = filters.length === 0 ? void 0 : filters.length === 1 ? filters[0] : {
1435
+ kind: "and",
1436
+ exprs: filters
1437
+ };
1438
+ 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({
1441
+ meta,
1442
+ kind: "scan-write",
1443
+ storeName,
1444
+ write: "put-merged",
1445
+ patch,
1446
+ ...filter !== void 0 ? { filter } : {}
1447
+ });
1448
+ const oldRows = await scope.execute({
1449
+ meta,
1450
+ kind: "cursor-scan",
1451
+ storeName,
1452
+ ...filter !== void 0 ? { filter } : {}
1453
+ });
1454
+ const keyPath = getKeyPath(contract, modelName);
1455
+ const results = [];
1456
+ for (const oldRow of oldRows) {
1457
+ await applyReferentialActionsForRowOnUpdate(scope, contract, modelName, oldRow, patch);
1458
+ const key = oldRow[keyPath];
1459
+ const updated = (await scope.execute({
1460
+ meta,
1461
+ kind: "update",
1462
+ storeName,
1463
+ key,
1464
+ patch
1465
+ }))[0];
1466
+ if (updated) results.push(updated);
1467
+ }
1468
+ return results;
1469
+ });
1470
+ }
1471
+ /**
1472
+ * 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.
1476
+ */
1477
+ 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;
1483
+ }
1484
+ /**
1485
+ * Transitively walks the `onDelete` cascade graph from `modelName`, collecting
1486
+ * every store a recursive delete might touch. `cascade` edges are walked
1487
+ * further (a cascaded child's own children may themselves cascade);
1488
+ * `restrict`/`setNull`/`setDefault` edges still need their store added (each
1489
+ * is read or written once) but don't recurse further, since none of them
1490
+ * delete the child row. Guarded by a model-level visited set — required
1491
+ * because IDB must declare every store a transaction might touch before it
1492
+ * opens, so this static walk must terminate even on a self-referential model
1493
+ * or a cycle of mutually-cascading models.
1494
+ */
1495
+ function collectDeleteStoreNames(contract, modelName) {
1496
+ const stores = /* @__PURE__ */ new Set();
1497
+ const visitedModels = /* @__PURE__ */ new Set();
1498
+ function walk(mName) {
1499
+ if (visitedModels.has(mName)) return;
1500
+ visitedModels.add(mName);
1501
+ stores.add(getStoreName(contract, mName));
1502
+ for (const def of getRelationDefinitions(contract, mName)) {
1503
+ if (!isDeleteEnforcementRelation(contract, mName, def)) continue;
1504
+ const action = getOnDeleteForDeleteRelation(contract, mName, def);
1505
+ if (action === "noAction") continue;
1506
+ stores.add(def.relatedStoreName);
1507
+ if (action === "cascade") walk(def.relatedModelName);
1508
+ }
1509
+ }
1510
+ walk(modelName);
1511
+ return [...stores];
1512
+ }
1513
+ /**
1514
+ * Enforces every `onDelete` action declared on `modelName`'s child relations
1515
+ * against one specific `row` about to be deleted. `cascade` recurses into
1516
+ * each matched child's own `onDelete` relations *before* deleting it — so a
1517
+ * multi-hop chain (`User --cascade--> Post --cascade--> Comment`) is fully
1518
+ * torn down, and a `restrict` several hops deep still aborts the whole
1519
+ * transaction (recursing before deleting means the delete never happens if a
1520
+ * deeper hop throws). `setNull`/`setDefault` are leaf actions: the child row
1521
+ * survives, so recursion never continues past them.
1522
+ *
1523
+ * `visited` (keyed by `storeName::key`) guards against row-level cycles — two
1524
+ * specific rows whose FKs point at each other through a self-referential or
1525
+ * mutually-cascading relation graph. Callers should leave it at its default
1526
+ * (a fresh `Set` per top-level call) so independent rows deleted in the same
1527
+ * `deleteAll()` batch don't cross-suppress each other's cascades; it's only
1528
+ * ever passed explicitly by this function's own recursive calls, to keep one
1529
+ * shared guard across a single row's full recursive descent.
1530
+ */
1531
+ async function applyReferentialActionsForRow(scope, contract, modelName, row, visited = /* @__PURE__ */ new Set()) {
1532
+ const keyPath = getKeyPath(contract, modelName);
1533
+ const rowKey = `${getStoreName(contract, modelName)}::${String(row[keyPath])}`;
1534
+ if (visited.has(rowKey)) return;
1535
+ visited.add(rowKey);
1536
+ const meta = makePlanMeta(contract);
1537
+ for (const def of getRelationDefinitions(contract, modelName)) {
1538
+ if (!isDeleteEnforcementRelation(contract, modelName, def)) continue;
1539
+ const action = getOnDeleteForDeleteRelation(contract, modelName, def);
1540
+ if (action === "noAction") continue;
1541
+ const childFilter = buildChildFilterFromRow(def, row);
1542
+ 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'.`);
1550
+ continue;
1551
+ }
1552
+ if (action === "cascade") {
1553
+ const childKeyPath = getKeyPath(contract, def.relatedModelName);
1554
+ const children = await scope.execute({
1555
+ meta,
1556
+ kind: "cursor-scan",
1557
+ storeName: def.relatedStoreName,
1558
+ filter: childFilter
1559
+ });
1560
+ for (const child of children) {
1561
+ await applyReferentialActionsForRow(scope, contract, def.relatedModelName, child, visited);
1562
+ await scope.execute({
1563
+ meta,
1564
+ kind: "delete",
1565
+ storeName: def.relatedStoreName,
1566
+ key: child[childKeyPath]
1567
+ });
1568
+ }
1569
+ continue;
1570
+ }
1571
+ if (action === "setNull") {
1572
+ const patch = {};
1573
+ for (const targetField of def.targetFields) patch[targetField] = null;
1574
+ await scope.execute({
1575
+ meta,
1576
+ kind: "scan-write",
1577
+ storeName: def.relatedStoreName,
1578
+ write: "put-merged",
1579
+ patch,
1580
+ filter: childFilter
1581
+ });
1582
+ continue;
1583
+ }
1584
+ if (action === "setDefault") {
1585
+ const patch = buildSetDefaultPatch(contract, def);
1586
+ await validateSetDefaultPatch(scope, contract, modelName, def, patch, row[keyPath]);
1587
+ await scope.execute({
1588
+ meta,
1589
+ kind: "scan-write",
1590
+ storeName: def.relatedStoreName,
1591
+ write: "put-merged",
1592
+ patch,
1593
+ filter: childFilter
1594
+ });
1595
+ continue;
1596
+ }
1597
+ }
1598
+ }
1599
+ async function executeDeleteWithReferentialActions(options) {
1600
+ const { executor, contract, modelName, key } = options;
1601
+ await withMutationScope(executor, collectDeleteStoreNames(contract, modelName), async (scope) => {
1602
+ const storeName = getStoreName(contract, modelName);
1603
+ const meta = makePlanMeta(contract);
1604
+ const row = (await scope.execute({
1605
+ meta,
1606
+ kind: "key-get",
1607
+ storeName,
1608
+ key
1609
+ }))[0];
1610
+ if (!row) return [];
1611
+ await applyReferentialActionsForRow(scope, contract, modelName, row);
1612
+ await scope.execute({
1613
+ meta,
1614
+ kind: "delete",
1615
+ storeName,
1616
+ key
1617
+ });
1618
+ return [];
1619
+ });
1620
+ }
1621
+ async function executeDeleteAllWithReferentialActions(options) {
1622
+ const { executor, contract, modelName, filter } = options;
1623
+ return withMutationScope(executor, collectDeleteStoreNames(contract, modelName), async (scope) => {
1624
+ const storeName = getStoreName(contract, modelName);
1625
+ const meta = makePlanMeta(contract);
1626
+ const keyPath = getKeyPath(contract, modelName);
1627
+ const rows = await scope.execute({
1628
+ meta,
1629
+ kind: "cursor-scan",
1630
+ storeName,
1631
+ ...filter !== void 0 ? { filter } : {}
1632
+ });
1633
+ for (const row of rows) {
1634
+ await applyReferentialActionsForRow(scope, contract, modelName, row);
1635
+ const key = row[keyPath];
1636
+ await scope.execute({
1637
+ meta,
1638
+ kind: "delete",
1639
+ storeName,
1640
+ key
1641
+ });
1642
+ }
1643
+ return rows;
1644
+ });
1645
+ }
1646
+ //#endregion
1647
+ //#region src/core/store-accessor.ts
1648
+ /**
1649
+ * Concrete immutable query builder.
1650
+ *
1651
+ * Internal details:
1652
+ * - All state is in `#state` (filters, orderBy, skip, take, includes, selectedFields).
1653
+ * - Builder methods clone via `#clone()` — O(1) copies since state is
1654
+ * structurally shared.
1655
+ * - `all()` materialises the main rows first, then batch-loads each included
1656
+ * relation, then applies any `.select()` projection before yielding.
1657
+ * - `#includeRefinementMode` flips `count()` from an async terminal to an
1658
+ * {@link IdbIncludeScalar} marker so it can be used inside `include()`.
1659
+ */
1660
+ var IdbStoreAccessorImpl = class IdbStoreAccessorImpl {
1661
+ #contract;
1662
+ #modelName;
1663
+ #executor;
1664
+ #storeName;
1665
+ #state;
1666
+ #newGroupingKey;
1667
+ #includeRefinementMode;
1668
+ constructor(contract, modelName, executor, state, newGroupingKey, includeRefinementMode = false) {
1669
+ this.#contract = contract;
1670
+ this.#modelName = modelName;
1671
+ this.#executor = executor;
1672
+ this.#storeName = getStoreName(contract, modelName);
1673
+ this.#state = state ?? emptyAccessorState();
1674
+ let _key = 0;
1675
+ this.#newGroupingKey = newGroupingKey ?? (() => `idb-op-${++_key}`);
1676
+ this.#includeRefinementMode = includeRefinementMode;
1677
+ }
1678
+ where(filter) {
1679
+ const expr = typeof filter === "function" ? filter(createModelAccessor()) : shorthandToFilterExpr(filter);
1680
+ if (expr === void 0) return this.#clone({});
1681
+ return this.#clone({ filters: [...this.#state.filters, expr] });
1682
+ }
1683
+ orderBy(spec) {
1684
+ return this.#clone({ orderBy: spec });
1685
+ }
1686
+ take(n) {
1687
+ return this.#clone({ take: n });
1688
+ }
1689
+ skip(n) {
1690
+ return this.#clone({ skip: n });
1691
+ }
1692
+ include(relation, refineFn) {
1693
+ const entry = this.#resolveIncludeEntry(relation, refineFn);
1694
+ const newState = mergeAccessorState(this.#state, { includes: {
1695
+ ...this.#state.includes,
1696
+ [relation]: entry
1697
+ } });
1698
+ return new IdbStoreAccessorImpl(this.#contract, this.#modelName, this.#executor, newState, this.#newGroupingKey, this.#includeRefinementMode);
1699
+ }
1700
+ select(...fields) {
1701
+ return this.#clone({ selectedFields: fields });
1702
+ }
1703
+ all() {
1704
+ const groupingKey = this.#newGroupingKey();
1705
+ const buildScanPlan = this.#buildScanPlan.bind(this);
1706
+ const executeOrRows = this.#executeOrRows.bind(this);
1707
+ const executorQuery = this.#executor.query.bind(this.#executor);
1708
+ const applyIncludes = this.#applyIncludes.bind(this);
1709
+ const projectRows = this.#projectRows.bind(this);
1710
+ const combined = this.#combinedFilterExpr();
1711
+ const fieldToIndexMap = typeof IDBKeyRange !== "undefined" ? buildFieldToIndexMap(this.#contract, this.#storeName) : void 0;
1712
+ const keyPath = getKeyPath$1(this.#contract, this.#modelName);
1713
+ const comparator = buildRowComparator(this.#state.orderBy);
1714
+ const skip = this.#state.skip;
1715
+ const take = this.#state.take;
1716
+ return new AsyncIterableResult((async function* () {
1717
+ let rows;
1718
+ const orHint = fieldToIndexMap !== void 0 ? extractIndexOrHint(combined, fieldToIndexMap, keyPath) : null;
1719
+ if (orHint !== null) {
1720
+ rows = await executeOrRows(orHint, groupingKey, combined);
1721
+ if (comparator !== void 0) rows.sort(comparator);
1722
+ if (skip !== void 0) rows = rows.slice(skip);
1723
+ if (take !== void 0) rows = rows.slice(0, take);
1724
+ } else {
1725
+ const scanPlan = buildScanPlan(groupingKey, fieldToIndexMap);
1726
+ rows = [];
1727
+ for await (const row of executorQuery(scanPlan)) rows.push(row);
1728
+ }
1729
+ const withIncludes = await applyIncludes(rows, groupingKey);
1730
+ for (const row of projectRows(withIncludes)) yield row;
1731
+ })());
1732
+ }
1733
+ async first() {
1734
+ return this.take(1).all().first();
1735
+ }
1736
+ async aggregate(fn) {
1737
+ const spec = fn(createAggregateBuilder());
1738
+ assertValidAggregateSpec(spec, "aggregate()");
1739
+ const combined = this.#combinedFilterExpr();
1740
+ const ast = {
1741
+ kind: "aggregate",
1742
+ modelName: this.#modelName,
1743
+ aggregates: toAggregateRequests(spec),
1744
+ ...combined !== void 0 ? { where: combined } : {}
1745
+ };
1746
+ return computeAggregateSpec(spec, await this.#materialize(this.#newGroupingKey(), ast));
1747
+ }
1748
+ groupBy(...fields) {
1749
+ const combined = this.#combinedFilterExpr();
1750
+ const materialize = (ast) => this.#materialize(this.#newGroupingKey(), ast);
1751
+ return createGroupedAccessor({
1752
+ modelName: this.#modelName,
1753
+ by: fields,
1754
+ where: combined,
1755
+ materialize
1756
+ });
1757
+ }
1758
+ async create(data) {
1759
+ const record = data;
1760
+ if (hasNestedMutationCallbacks(this.#contract, this.#modelName, record)) return await executeNestedCreateMutation({
1761
+ executor: requireTransactionExecutor(this.#executor),
1762
+ contract: this.#contract,
1763
+ modelName: this.#modelName,
1764
+ data: record
1765
+ });
1766
+ if (hasScalarFkFields(this.#contract, this.#modelName, record)) return await executeScalarCreateWithFkValidation({
1767
+ executor: requireTransactionExecutor(this.#executor),
1768
+ contract: this.#contract,
1769
+ modelName: this.#modelName,
1770
+ data: record
1771
+ });
1772
+ const groupingKey = this.#newGroupingKey();
1773
+ const meta = this.#planMeta(groupingKey);
1774
+ const withDefaults = applyCreateDefaults(this.#contract.execution?.mutations.defaults, this.#storeName, record, createMutationDefaultsCache());
1775
+ const plan = {
1776
+ meta,
1777
+ ast: {
1778
+ kind: "create",
1779
+ modelName: this.#modelName,
1780
+ data: withDefaults
1781
+ },
1782
+ idbPlan: {
1783
+ meta,
1784
+ kind: "add",
1785
+ storeName: this.#storeName,
1786
+ record: withDefaults
1787
+ }
1788
+ };
1789
+ for await (const row of this.#executor.query(plan)) return row;
1790
+ return withDefaults;
1791
+ }
1792
+ async findUnique(key) {
1793
+ const groupingKey = this.#newGroupingKey();
1794
+ const meta = this.#planMeta(groupingKey);
1795
+ const plan = {
1796
+ meta,
1797
+ ast: {
1798
+ kind: "findUnique",
1799
+ modelName: this.#modelName,
1800
+ key
1801
+ },
1802
+ idbPlan: {
1803
+ meta,
1804
+ kind: "key-get",
1805
+ storeName: this.#storeName,
1806
+ key
1807
+ }
1808
+ };
1809
+ for await (const row of this.#executor.query(plan)) return row;
1810
+ return null;
1811
+ }
1812
+ async delete(key) {
1813
+ if (hasEnforceableChildRelations(this.#contract, this.#modelName)) {
1814
+ await executeDeleteWithReferentialActions({
1815
+ executor: requireTransactionExecutor(this.#executor),
1816
+ contract: this.#contract,
1817
+ modelName: this.#modelName,
1818
+ key
1819
+ });
1820
+ return;
1821
+ }
1822
+ const groupingKey = this.#newGroupingKey();
1823
+ const meta = this.#planMeta(groupingKey);
1824
+ const plan = {
1825
+ meta,
1826
+ ast: {
1827
+ kind: "delete",
1828
+ modelName: this.#modelName,
1829
+ key
1830
+ },
1831
+ idbPlan: {
1832
+ meta,
1833
+ kind: "delete",
1834
+ storeName: this.#storeName,
1835
+ key
1836
+ }
1837
+ };
1838
+ await this.#executor.query(plan).toArray();
1839
+ }
1840
+ async update(patch) {
1841
+ const patchRecord = patch;
1842
+ if (hasNestedMutationCallbacks(this.#contract, this.#modelName, patchRecord)) return await executeNestedUpdateMutation({
1843
+ executor: requireTransactionExecutor(this.#executor),
1844
+ contract: this.#contract,
1845
+ modelName: this.#modelName,
1846
+ filters: this.#state.filters,
1847
+ data: patchRecord
1848
+ });
1849
+ return await executeScalarUpdateWithFkValidation({
1850
+ executor: requireTransactionExecutor(this.#executor),
1851
+ contract: this.#contract,
1852
+ modelName: this.#modelName,
1853
+ filters: this.#state.filters,
1854
+ data: patchRecord
1855
+ });
1856
+ }
1857
+ /**
1858
+ * Always routes through the transaction scope (`executeBulkUpdateWithFkValidation`),
1859
+ * unlike single-row `update()`'s plan-level fallback for the no-FK-field
1860
+ * case. A bulk scan-write's affected row set isn't knowable until it
1861
+ * actually runs — going through the transaction scope is what lets the
1862
+ * sync interceptor observe the real affected rows and write one outbox
1863
+ * event per row (see `SyncInterceptingTransactionScope#maybeTrack`'s
1864
+ * `scan-write` case), atomically with the write itself, instead of one
1865
+ * event for the whole batch with no way to recover which rows it covered.
1866
+ */
1867
+ updateAll(patch) {
1868
+ const contract = this.#contract;
1869
+ const modelName = this.#modelName;
1870
+ const filters = this.#state.filters;
1871
+ const patchRecord = patch;
1872
+ const executor = requireTransactionExecutor(this.#executor);
1873
+ return new AsyncIterableResult((async function* () {
1874
+ const rows = await executeBulkUpdateWithFkValidation({
1875
+ executor,
1876
+ contract,
1877
+ modelName,
1878
+ filters,
1879
+ data: patchRecord
1880
+ });
1881
+ for (const row of rows) yield row;
1882
+ })());
1883
+ }
1884
+ async updateCount(patch) {
1885
+ return (await this.updateAll(patch).toArray()).length;
1886
+ }
1887
+ async upsert(args) {
1888
+ const keyPath = getKeyPath$1(this.#contract, this.#modelName);
1889
+ const whereExpr = shorthandToFilterExpr(args.where);
1890
+ const matches = (row) => whereExpr === void 0 || evaluateFilter(whereExpr, row);
1891
+ const meta = this.#planMeta(this.#newGroupingKey());
1892
+ const createRecord = args.create;
1893
+ const patchRecord = args.update;
1894
+ const storeName = this.#storeName;
1895
+ const executionDefaults = this.#contract.execution?.mutations.defaults;
1896
+ const exec = requireTransactionExecutor(this.#executor);
1897
+ const effectivePatch = applyUpdateDefaults(executionDefaults, storeName, patchRecord, createMutationDefaultsCache());
1898
+ const { storeNames: onUpdateStoreNames } = collectOnUpdateEnforcementStoreNames(this.#contract, this.#modelName, effectivePatch);
1899
+ return withMutationScope(exec, [.../* @__PURE__ */ new Set([storeName, ...onUpdateStoreNames])], async (scope) => {
1900
+ const existing = (await scope.execute({
1901
+ meta,
1902
+ kind: "cursor-scan",
1903
+ storeName,
1904
+ filter: matches,
1905
+ take: 1
1906
+ }))[0];
1907
+ if (existing === void 0) {
1908
+ const record = applyCreateDefaults(executionDefaults, storeName, createRecord, createMutationDefaultsCache());
1909
+ return (await scope.execute({
1910
+ meta,
1911
+ kind: "add",
1912
+ storeName,
1913
+ record
1914
+ }))[0] ?? record;
1915
+ }
1916
+ const key = existing[keyPath];
1917
+ await applyReferentialActionsForRowOnUpdate(scope, this.#contract, this.#modelName, existing, effectivePatch);
1918
+ return (await scope.execute({
1919
+ meta,
1920
+ kind: "update",
1921
+ storeName,
1922
+ key,
1923
+ patch: effectivePatch
1924
+ }))[0] ?? existing;
1925
+ });
1926
+ }
1927
+ createAll(data) {
1928
+ const groupingKey = this.#newGroupingKey();
1929
+ const meta = this.#planMeta(groupingKey);
1930
+ const defaultsCache = createMutationDefaultsCache();
1931
+ const executionDefaults = this.#contract.execution?.mutations.defaults;
1932
+ const records = data.map((d) => applyCreateDefaults(executionDefaults, this.#storeName, d, defaultsCache));
1933
+ const plan = {
1934
+ meta,
1935
+ ast: {
1936
+ kind: "createAll",
1937
+ modelName: this.#modelName,
1938
+ data: records
1939
+ },
1940
+ idbPlan: {
1941
+ meta,
1942
+ kind: "batch",
1943
+ storeNames: [this.#storeName],
1944
+ ops: records.map((record) => ({
1945
+ meta,
1946
+ kind: "add",
1947
+ storeName: this.#storeName,
1948
+ record
1949
+ }))
1950
+ }
1951
+ };
1952
+ const executorQuery = this.#executor.query.bind(this.#executor);
1953
+ return new AsyncIterableResult((async function* () {
1954
+ for await (const row of executorQuery(plan)) yield row;
1955
+ })());
1956
+ }
1957
+ async createCount(data) {
1958
+ return (await this.createAll(data).toArray()).length;
1959
+ }
1960
+ /**
1961
+ * Always routes through the transaction scope (`executeDeleteAllWithReferentialActions`),
1962
+ * not just when there are enforceable child relations to cascade — that
1963
+ * function already handles the no-relations case correctly (an empty
1964
+ * relation list is a no-op loop), and going through it unconditionally is
1965
+ * what lets the sync interceptor's transaction-scope tracking write one
1966
+ * outbox event per actually-deleted row instead of one lump event for
1967
+ * the whole batch with no way to know which rows it covered. See
1968
+ * `updateAll()`'s doc comment for the same reasoning.
1969
+ */
1970
+ deleteAll() {
1971
+ const combined = this.#combinedFilterExpr();
1972
+ const filter = combined !== void 0 ? (row) => evaluateFilter(combined, row) : void 0;
1973
+ const contract = this.#contract;
1974
+ const modelName = this.#modelName;
1975
+ const executor = requireTransactionExecutor(this.#executor);
1976
+ return new AsyncIterableResult((async function* () {
1977
+ const rows = await executeDeleteAllWithReferentialActions({
1978
+ executor,
1979
+ contract,
1980
+ modelName,
1981
+ ...filter !== void 0 ? { filter } : {}
1982
+ });
1983
+ for (const row of rows) yield row;
1984
+ })());
1985
+ }
1986
+ async deleteCount() {
1987
+ return (await this.deleteAll().toArray()).length;
1988
+ }
1989
+ count() {
1990
+ if (this.#includeRefinementMode) return createIncludeScalar(this.#state);
1991
+ return this.#countTerminal();
1992
+ }
1993
+ async #countTerminal() {
1994
+ const groupingKey = this.#newGroupingKey();
1995
+ const combined = this.#combinedFilterExpr();
1996
+ const fieldToIndexMap = typeof IDBKeyRange !== "undefined" ? buildFieldToIndexMap(this.#contract, this.#storeName) : void 0;
1997
+ 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
+ }
2005
+ }
2006
+ const scanPlan = this.#buildScanPlan(groupingKey, fieldToIndexMap);
2007
+ const scanAst = scanPlan.ast;
2008
+ const ast = {
2009
+ kind: "count",
2010
+ modelName: this.#modelName,
2011
+ ...scanAst?.kind === "findMany" && scanAst.where !== void 0 ? { where: scanAst.where } : {}
2012
+ };
2013
+ const plan = {
2014
+ ...scanPlan,
2015
+ ast
2016
+ };
2017
+ let n = 0;
2018
+ for await (const _ of this.#executor.query(plan)) n++;
2019
+ return n;
2020
+ }
2021
+ /**
2022
+ * Resolve an `include()` argument pair into an {@link IncludeEntry}: run the
2023
+ * optional refinement against a fresh refinement-mode child accessor, then
2024
+ * classify the result as a scalar count or a refined collection.
2025
+ */
2026
+ #resolveIncludeEntry(relation, refineFn) {
2027
+ if (refineFn === void 0) return {
2028
+ kind: "collection",
2029
+ state: emptyAccessorState()
2030
+ };
2031
+ const rel = getRelation(this.#contract, this.#modelName, relation);
2032
+ const relatedModelName = rel?.to.model ?? relation;
2033
+ const refined = refineFn(new IdbStoreAccessorImpl(this.#contract, relatedModelName, this.#executor, emptyAccessorState(), this.#newGroupingKey, true));
2034
+ if (isIncludeScalar(refined)) {
2035
+ if (rel !== void 0 && rel.cardinality !== "1:N") throw new Error(`include('${relation}'): count() is only supported for to-many (1:N) relations`);
2036
+ return {
2037
+ kind: "scalar",
2038
+ fn: refined.fn,
2039
+ state: refined.state
2040
+ };
2041
+ }
2042
+ if (refined instanceof IdbStoreAccessorImpl) return {
2043
+ kind: "collection",
2044
+ state: refined.#state
2045
+ };
2046
+ throw new Error(`include('${relation}') refinement must return the collection (for where/orderBy/take/skip) or a count() selector`);
2047
+ }
2048
+ /**
2049
+ * Materialise all rows matching the accumulated filters with no pagination —
2050
+ * used by `aggregate()` / `groupBy()`. The supplied `ast` is attached to the
2051
+ * scan plan so middleware can observe the aggregate intent.
2052
+ */
2053
+ async #materialize(groupingKey, ast) {
2054
+ const combined = this.#combinedFilterExpr();
2055
+ const filter = combined !== void 0 ? (row) => evaluateFilter(combined, row) : void 0;
2056
+ const meta = this.#planMeta(groupingKey);
2057
+ const plan = {
2058
+ meta,
2059
+ ast,
2060
+ idbPlan: {
2061
+ meta,
2062
+ kind: "cursor-scan",
2063
+ storeName: this.#storeName,
2064
+ ...filter !== void 0 ? { filter } : {}
2065
+ }
2066
+ };
2067
+ const rows = [];
2068
+ for await (const row of this.#executor.query(plan)) rows.push(row);
2069
+ return rows;
2070
+ }
2071
+ #buildScanPlan(groupingKey, fieldToIndexMap) {
2072
+ const combined = this.#combinedFilterExpr();
2073
+ const comparator = buildRowComparator(this.#state.orderBy);
2074
+ const meta = this.#planMeta(groupingKey);
2075
+ const ast = {
2076
+ kind: "findMany",
2077
+ modelName: this.#modelName,
2078
+ ...combined !== void 0 ? { where: combined } : {},
2079
+ ...this.#state.orderBy !== void 0 ? { orderBy: this.#state.orderBy } : {},
2080
+ ...this.#state.skip !== void 0 ? { skip: this.#state.skip } : {},
2081
+ ...this.#state.take !== void 0 ? { take: this.#state.take } : {}
2082
+ };
2083
+ if (typeof IDBKeyRange !== "undefined") {
2084
+ const hint = extractIndexEqualityHint(combined, fieldToIndexMap ?? buildFieldToIndexMap(this.#contract, this.#storeName), getKeyPath$1(this.#contract, this.#modelName));
2085
+ if (hint !== null) {
2086
+ const { indexName, value, remainingFilter } = hint;
2087
+ const filter = remainingFilter !== void 0 ? (row) => evaluateFilter(remainingFilter, row) : void 0;
2088
+ return {
2089
+ meta,
2090
+ ast,
2091
+ idbPlan: {
2092
+ meta,
2093
+ kind: "cursor-scan",
2094
+ storeName: this.#storeName,
2095
+ ...indexName !== void 0 ? { indexName } : {},
2096
+ range: IDBKeyRange.only(value),
2097
+ ...filter !== void 0 ? { filter } : {},
2098
+ ...comparator !== void 0 ? { comparator } : {},
2099
+ ...this.#state.skip !== void 0 ? { skip: this.#state.skip } : {},
2100
+ ...this.#state.take !== void 0 ? { take: this.#state.take } : {}
2101
+ }
2102
+ };
2103
+ }
2104
+ }
2105
+ const filter = combined !== void 0 ? (row) => evaluateFilter(combined, row) : void 0;
2106
+ return {
2107
+ meta,
2108
+ ast,
2109
+ idbPlan: {
2110
+ meta,
2111
+ kind: "cursor-scan",
2112
+ storeName: this.#storeName,
2113
+ ...filter !== void 0 ? { filter } : {},
2114
+ ...comparator !== void 0 ? { comparator } : {},
2115
+ ...this.#state.skip !== void 0 ? { skip: this.#state.skip } : {},
2116
+ ...this.#state.take !== void 0 ? { take: this.#state.take } : {}
2117
+ }
2118
+ };
2119
+ }
2120
+ /**
2121
+ * Execute one cursor-scan per OR branch, union the results, deduplicate by
2122
+ * primary key, and apply `hint.remainingFilter` in-memory. Skip/take are
2123
+ * NOT applied here — the caller slices the array after union so pagination
2124
+ * is correct across branches.
2125
+ */
2126
+ async #executeOrRows(hint, groupingKey, combined) {
2127
+ const meta = this.#planMeta(groupingKey);
2128
+ const storeName = this.#storeName;
2129
+ const keyPath = getKeyPath$1(this.#contract, this.#modelName);
2130
+ const seen = /* @__PURE__ */ new Set();
2131
+ const rows = [];
2132
+ const ast = {
2133
+ kind: "findMany",
2134
+ modelName: this.#modelName,
2135
+ ...combined !== void 0 ? { where: combined } : {}
2136
+ };
2137
+ const branchResults = await Promise.all(hint.branches.map(async (branch) => {
2138
+ const plan = {
2139
+ meta,
2140
+ ast,
2141
+ idbPlan: {
2142
+ meta,
2143
+ kind: "cursor-scan",
2144
+ storeName,
2145
+ ...branch.indexName !== void 0 ? { indexName: branch.indexName } : {},
2146
+ range: IDBKeyRange.only(branch.value)
2147
+ }
2148
+ };
2149
+ const branchRows = [];
2150
+ for await (const row of this.#executor.query(plan)) branchRows.push(row);
2151
+ return branchRows;
2152
+ }));
2153
+ for (const branchRows of branchResults) for (const row of branchRows) {
2154
+ const pk = row[keyPath];
2155
+ if (!seen.has(pk)) {
2156
+ seen.add(pk);
2157
+ rows.push(row);
2158
+ }
2159
+ }
2160
+ if (hint.remainingFilter === void 0) return rows;
2161
+ const { remainingFilter } = hint;
2162
+ return rows.filter((row) => evaluateFilter(remainingFilter, row));
2163
+ }
2164
+ /**
2165
+ * Combine all accumulated filter expressions with AND.
2166
+ *
2167
+ * Returns `undefined` when no filter has been installed so the driver can
2168
+ * skip building a row filter closure (a small perf and readability win on
2169
+ * `.all()` paths). Delegates to the shared {@link combineFilterExprs}.
2170
+ */
2171
+ #combinedFilterExpr() {
2172
+ return combineFilterExprs(this.#state.filters);
2173
+ }
2174
+ async #applyIncludes(rows, groupingKey) {
2175
+ const relNames = Object.keys(this.#state.includes);
2176
+ if (relNames.length === 0) return rows;
2177
+ let result = rows;
2178
+ for (const relName of relNames) {
2179
+ const entry = this.#state.includes[relName];
2180
+ result = await loadRelation(relName, entry, result, this.#contract, this.#modelName, this.#executor, groupingKey);
2181
+ }
2182
+ return result;
2183
+ }
2184
+ /**
2185
+ * Apply a `.select()` projection (if any) to materialised rows. Keeps the
2186
+ * selected scalar fields plus every included relation key (which `include()`
2187
+ * attached during {@link #applyIncludes}); a no-op when nothing is selected.
2188
+ */
2189
+ #projectRows(rows) {
2190
+ const selected = this.#state.selectedFields;
2191
+ if (selected === void 0) return rows;
2192
+ const keep = [...selected, ...Object.keys(this.#state.includes)];
2193
+ return rows.map((row) => {
2194
+ const out = {};
2195
+ for (const field of keep) if (field in row) out[field] = row[field];
2196
+ return out;
2197
+ });
2198
+ }
2199
+ #planMeta(groupingKey) {
2200
+ return {
2201
+ target: "idb",
2202
+ storageHash: this.#contract.storage.storageHash,
2203
+ lane: "idb-orm",
2204
+ annotations: { groupingKey }
2205
+ };
2206
+ }
2207
+ #clone(overrides) {
2208
+ return new IdbStoreAccessorImpl(this.#contract, this.#modelName, this.#executor, mergeAccessorState(this.#state, overrides), this.#newGroupingKey, this.#includeRefinementMode);
2209
+ }
2210
+ };
2211
+ //#endregion
2212
+ //#region src/core/idb-orm.ts
2213
+ /**
2214
+ * Create a typed IDB ORM client from a contract and executor.
2215
+ *
2216
+ * The client exposes one `IdbStoreAccessor` per entry in `contract.roots`.
2217
+ * Only roots-declared stores are accessible at the top level — other stores
2218
+ * can be reached via `.include()` on any accessor.
2219
+ *
2220
+ * @example
2221
+ * ```ts
2222
+ * import { idbOrm } from "@prisma-idb/client-idb/orm";
2223
+ * import { createIdbRuntime } from "@prisma-idb/runtime-idb/runtime";
2224
+ * import contract from "./prisma/idb-contract";
2225
+ *
2226
+ * const runtime = createIdbRuntime({ adapter, driver });
2227
+ * const db = idbOrm({ contract, executor: runtime });
2228
+ *
2229
+ * // Typed query builder:
2230
+ * const alice = await db.users.where({ email: "alice@example.com" }).first();
2231
+ * const postsWithAuthor = await db.posts.include("author").all();
2232
+ * ```
2233
+ */
2234
+ function idbOrm(options) {
2235
+ const { contract, executor } = options;
2236
+ let _key = 0;
2237
+ const newGroupingKey = () => `idb-op-${++_key}`;
2238
+ const client = {};
2239
+ for (const [rootKey, ref] of Object.entries(contract.roots)) client[rootKey] = new IdbStoreAccessorImpl(contract, ref.model, executor, void 0, newGroupingKey);
2240
+ return client;
2241
+ }
2242
+ //#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 };
2244
+
2245
+ //# sourceMappingURL=idb-orm-Cupnnwum.mjs.map