@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.
- package/LICENSE +21 -0
- package/README.md +24 -0
- package/dist/client-auto.d.mts +132 -0
- package/dist/client-auto.d.mts.map +1 -0
- package/dist/client-auto.mjs +277 -0
- package/dist/client-auto.mjs.map +1 -0
- package/dist/client.d.mts +2 -0
- package/dist/client.mjs +2 -0
- package/dist/idb-orm-Cupnnwum.mjs +2245 -0
- package/dist/idb-orm-Cupnnwum.mjs.map +1 -0
- package/dist/idb-orm-zCRkkD_D.d.mts +746 -0
- package/dist/idb-orm-zCRkkD_D.d.mts.map +1 -0
- package/dist/managed-client-B_8JZM3h.mjs +122 -0
- package/dist/managed-client-B_8JZM3h.mjs.map +1 -0
- package/dist/managed-client-kdyHm72t.d.mts +115 -0
- package/dist/managed-client-kdyHm72t.d.mts.map +1 -0
- package/dist/orm.d.mts +85 -0
- package/dist/orm.d.mts.map +1 -0
- package/dist/orm.mjs +28 -0
- package/dist/orm.mjs.map +1 -0
- package/package.json +60 -0
|
@@ -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
|