@prisma-idb/client-idb 0.11.0 → 0.13.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/README.md +6 -0
- package/dist/client-auto.d.mts +2 -2
- package/dist/client-auto.d.mts.map +1 -1
- package/dist/client-auto.mjs +44 -3
- package/dist/client-auto.mjs.map +1 -1
- package/dist/client.d.mts +3 -2
- package/dist/client.mjs +3 -2
- package/dist/decimal-identity-D_1J9lPN.d.mts +21 -0
- package/dist/decimal-identity-D_1J9lPN.d.mts.map +1 -0
- package/dist/internal.d.mts +5 -4
- package/dist/internal.d.mts.map +1 -1
- package/dist/internal.mjs +2 -2
- package/dist/{managed-client-CxlNmERd.mjs → managed-client-DMk8ERfX.mjs} +12 -5
- package/dist/managed-client-DMk8ERfX.mjs.map +1 -0
- package/dist/{managed-client-CS78EjPu.d.mts → managed-client-eksFK4UF.d.mts} +32 -3
- package/dist/managed-client-eksFK4UF.d.mts.map +1 -0
- package/dist/mutation-executor-IuJmyWEG.mjs +2132 -0
- package/dist/mutation-executor-IuJmyWEG.mjs.map +1 -0
- package/dist/{executor-Bp-hdRg2.d.mts → mutation-scope-CE3GMR4X.d.mts} +79 -28
- package/dist/mutation-scope-CE3GMR4X.d.mts.map +1 -0
- package/dist/{idb-orm-BitJ-EMq.mjs → orm-transaction-BGx1eukw.mjs} +366 -558
- package/dist/orm-transaction-BGx1eukw.mjs.map +1 -0
- package/dist/{idb-orm-9XN3c9ly.d.mts → orm-transaction-B_ZJ92uY.d.mts} +40 -7
- package/dist/orm-transaction-B_ZJ92uY.d.mts.map +1 -0
- package/dist/orm.d.mts +13 -4
- package/dist/orm.d.mts.map +1 -1
- package/dist/orm.mjs +3 -3
- package/package.json +9 -9
- package/dist/executor-Bp-hdRg2.d.mts.map +0 -1
- package/dist/idb-orm-9XN3c9ly.d.mts.map +0 -1
- package/dist/idb-orm-BitJ-EMq.mjs.map +0 -1
- package/dist/managed-client-CS78EjPu.d.mts.map +0 -1
- package/dist/managed-client-CxlNmERd.mjs.map +0 -1
- package/dist/mutation-executor-SJqTuSbb.mjs +0 -1196
- package/dist/mutation-executor-SJqTuSbb.mjs.map +0 -1
- package/dist/mutation-scope-1hS0ogGa.d.mts +0 -33
- package/dist/mutation-scope-1hS0ogGa.d.mts.map +0 -1
|
@@ -0,0 +1,2132 @@
|
|
|
1
|
+
import { AsyncIterableResult } from "@prisma/orm-framework/components/runtime";
|
|
2
|
+
import { domainModelsAtDefaultNamespace } from "@prisma/orm-framework/contract/types";
|
|
3
|
+
import { IdbExecuteError } from "@prisma-idb/driver-idb/runtime";
|
|
4
|
+
import { absentAsNull, compareDecimalValues, compareFieldValues, fieldValueToken, fieldValuesEqual, isValidIdbKey, keyEquals, keyEquals as keyEquals$1, keyToken, keyToken as keyToken$1, validateRecord } from "@prisma-idb/target-idb/runtime";
|
|
5
|
+
import { andExpr, assertValidDecimalFilter, evaluateFilter, fieldFilter, shorthandToFilterExpr } from "@prisma-idb/adapter-idb/runtime";
|
|
6
|
+
import { generateId } from "@prisma/orm-framework/ids/runtime";
|
|
7
|
+
import { keyPathFields } from "@prisma-idb/target-idb/pack";
|
|
8
|
+
//#region src/core/types.ts
|
|
9
|
+
/**
|
|
10
|
+
* Extract the `storeName` from a model's storage metadata at runtime.
|
|
11
|
+
* Falls back to the model name if `storeName` is absent.
|
|
12
|
+
*/
|
|
13
|
+
function getStoreName(contract, modelName) {
|
|
14
|
+
return (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.storeName ?? modelName;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Extract the `keyPath` from a model's storage metadata at runtime.
|
|
18
|
+
* A single field name for the common case; an ordered array of field names
|
|
19
|
+
* for a compound primary key.
|
|
20
|
+
*
|
|
21
|
+
* @throws if the model is unknown or its storage has no `keyPath` — a
|
|
22
|
+
* validated contract always has one, so this only fires on a malformed or
|
|
23
|
+
* hand-built contract, where guessing a key name would silently read/write
|
|
24
|
+
* the wrong key.
|
|
25
|
+
*/
|
|
26
|
+
function getKeyPath(contract, modelName) {
|
|
27
|
+
const keyPath = (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.keyPath;
|
|
28
|
+
if (keyPath === void 0) throw new Error(`Model "${modelName}" has no storage.keyPath in the contract.`);
|
|
29
|
+
return keyPath;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Extracts a row's primary key as an {@link IDBValidKey} — the scalar value
|
|
33
|
+
* at `row[keyPath]` for a single-field key, or an ordered array of the
|
|
34
|
+
* compound key's member field values (matching the array form IDB itself
|
|
35
|
+
* expects for a compound `keyPath`). The one construction site every
|
|
36
|
+
* key-get/put/delete/update call should route through, so compound-key field
|
|
37
|
+
* order stays consistent everywhere.
|
|
38
|
+
*/
|
|
39
|
+
function extractKeyFromRow(row, keyPath) {
|
|
40
|
+
if (typeof keyPath === "string") return row[keyPath];
|
|
41
|
+
return keyPath.map((field) => row[field]);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a model's named relation to a {@link ContractReferenceRelation} at
|
|
45
|
+
* runtime, or `undefined` when the relation is absent or an embedded relation
|
|
46
|
+
* (no `on` join block). Used by `include()` to find the related model name and
|
|
47
|
+
* cardinality before building the child-accessor refinement.
|
|
48
|
+
*/
|
|
49
|
+
function getRelation(contract, modelName, relName) {
|
|
50
|
+
const relation = domainModelsAtDefaultNamespace(contract.domain)[modelName]?.relations?.[relName];
|
|
51
|
+
if (relation === void 0 || !("on" in relation)) return void 0;
|
|
52
|
+
return relation;
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/core/planner/catalog.ts
|
|
56
|
+
const catalogs = /* @__PURE__ */ new WeakMap();
|
|
57
|
+
/**
|
|
58
|
+
* Build a catalog once per contract identity, store name and model name.
|
|
59
|
+
* Contracts are immutable.
|
|
60
|
+
*
|
|
61
|
+
* Several models can share one store, and each reads the rows differently.
|
|
62
|
+
* Pass the model that owns the request. Omit `modelName` for a store that no
|
|
63
|
+
* app model owns, such as an extension's own store: it has no fields to fill.
|
|
64
|
+
*/
|
|
65
|
+
function buildCatalog(contract, storeName, modelName) {
|
|
66
|
+
const cacheKey = `${storeName}\0${modelName ?? ""}`;
|
|
67
|
+
const cached = catalogs.get(contract)?.get(cacheKey);
|
|
68
|
+
if (cached) return cached;
|
|
69
|
+
const store = contract.storage.stores[storeName];
|
|
70
|
+
if (!store) throw new Error(`Store "${storeName}" is not in the contract`);
|
|
71
|
+
const fields = {};
|
|
72
|
+
let normalize = (row) => row;
|
|
73
|
+
if (modelName !== void 0) {
|
|
74
|
+
const model = domainModelsAtDefaultNamespace(contract.domain)[modelName];
|
|
75
|
+
if (model === void 0) throw new Error(`Model "${modelName}" is not in the contract`);
|
|
76
|
+
if (getStoreName(contract, modelName) !== storeName) throw new Error(`Model "${modelName}" is not stored in "${storeName}"`);
|
|
77
|
+
normalize = absentAsNull(contract, modelName);
|
|
78
|
+
for (const [name, field] of Object.entries(model.fields)) fields[name] = Object.freeze({
|
|
79
|
+
codecId: field.type.kind === "scalar" ? field.type.codecId : void 0,
|
|
80
|
+
nullable: field.nullable,
|
|
81
|
+
collection: Boolean(field.many) || field.dict === true
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const catalog = Object.freeze({
|
|
85
|
+
storeName,
|
|
86
|
+
primaryKey: source(store.keyPath, true, false),
|
|
87
|
+
indexes: Object.freeze(Object.entries(store.indexes ?? {}).map(([name, index]) => source(index.keyPath, index.unique, index.multiEntry ?? false, name))),
|
|
88
|
+
fields: Object.freeze(fields),
|
|
89
|
+
absentAsNull: normalize
|
|
90
|
+
});
|
|
91
|
+
let byStore = catalogs.get(contract);
|
|
92
|
+
if (!byStore) {
|
|
93
|
+
byStore = /* @__PURE__ */ new Map();
|
|
94
|
+
catalogs.set(contract, byStore);
|
|
95
|
+
}
|
|
96
|
+
byStore.set(cacheKey, catalog);
|
|
97
|
+
return catalog;
|
|
98
|
+
}
|
|
99
|
+
/** Freeze a key source and its field order for deterministic access selection. */
|
|
100
|
+
function source(keyPath, unique, multiEntry, indexName) {
|
|
101
|
+
const path = typeof keyPath === "string" ? keyPath : Object.freeze([...keyPath]);
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
keyPath: path,
|
|
104
|
+
fields: Object.freeze(typeof path === "string" ? [path] : [...path]),
|
|
105
|
+
unique,
|
|
106
|
+
multiEntry,
|
|
107
|
+
...indexName === void 0 ? {} : { indexName }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/core/decimal-identity.ts
|
|
112
|
+
/** A legacy store or sync metadata set contains several spellings of one Decimal key. */
|
|
113
|
+
var IdbDecimalIdentityConflictError = class extends Error {
|
|
114
|
+
code = "DECIMAL_IDENTITY_CONFLICT";
|
|
115
|
+
constructor(modelName) {
|
|
116
|
+
super(`Several stored keys identify the same Decimal value in model "${modelName}". Resolve the conflicting records before retrying.`);
|
|
117
|
+
this.name = "IdbDecimalIdentityConflictError";
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/** Whether a primary key contains a contract-declared Decimal field. */
|
|
121
|
+
function hasDecimalPrimaryKey(contract, modelName) {
|
|
122
|
+
const catalog = buildCatalog(contract, getStoreName(contract, modelName), modelName);
|
|
123
|
+
return hasDecimalFields(catalog, catalog.primaryKey);
|
|
124
|
+
}
|
|
125
|
+
/** Compare scalar or compound primary keys using Decimal codecs and native IDB equality elsewhere. */
|
|
126
|
+
function decimalKeysEqual(contract, modelName, left, right) {
|
|
127
|
+
const catalog = buildCatalog(contract, getStoreName(contract, modelName), modelName);
|
|
128
|
+
const leftParts = typeof catalog.primaryKey.keyPath === "string" ? [left] : left;
|
|
129
|
+
const rightParts = typeof catalog.primaryKey.keyPath === "string" ? [right] : right;
|
|
130
|
+
if (!Array.isArray(leftParts) || !Array.isArray(rightParts) || leftParts.length !== catalog.primaryKey.fields.length || rightParts.length !== leftParts.length) return false;
|
|
131
|
+
return catalog.primaryKey.fields.every((field, i) => valuesEqual(catalog, field, leftParts[i], rightParts[i]));
|
|
132
|
+
}
|
|
133
|
+
/** Keep an existing Decimal primary-key spelling when the supplied value identifies the same row. */
|
|
134
|
+
function preserveDecimalKeySpelling(contract, modelName, record, storedKey) {
|
|
135
|
+
const catalog = buildCatalog(contract, getStoreName(contract, modelName), modelName);
|
|
136
|
+
const parts = typeof catalog.primaryKey.keyPath === "string" ? [storedKey] : storedKey;
|
|
137
|
+
if (!Array.isArray(parts) || parts.length !== catalog.primaryKey.fields.length) return record;
|
|
138
|
+
const result = { ...record };
|
|
139
|
+
catalog.primaryKey.fields.forEach((field, i) => {
|
|
140
|
+
if (catalog.fields[field]?.codecId === "idb/decimal@1" && result[field] !== void 0 && valuesEqual(catalog, field, result[field], parts[i])) result[field] = parts[i];
|
|
141
|
+
});
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
/** Read the physical key first, then check Decimal aliases without choosing a legacy duplicate. */
|
|
145
|
+
async function resolveDecimalKey(scope, contract, modelName, key, snapshot) {
|
|
146
|
+
if (!isValidIdbKey(key)) return void 0;
|
|
147
|
+
const storeName = getStoreName(contract, modelName);
|
|
148
|
+
const decimal = hasDecimalPrimaryKey(contract, modelName);
|
|
149
|
+
if (decimal && !decimalKeysEqual(contract, modelName, key, key)) return void 0;
|
|
150
|
+
if (!snapshot) {
|
|
151
|
+
const [exact] = await scope.execute({
|
|
152
|
+
kind: "key-get",
|
|
153
|
+
storeName,
|
|
154
|
+
key
|
|
155
|
+
});
|
|
156
|
+
if (!decimal) return exact;
|
|
157
|
+
}
|
|
158
|
+
const matches = (snapshot ?? await scope.execute({
|
|
159
|
+
kind: "cursor-scan",
|
|
160
|
+
storeName
|
|
161
|
+
})).filter((row) => decimalKeysEqual(contract, modelName, extractKeyFromRow(row, buildCatalog(contract, storeName, modelName).primaryKey.keyPath), key));
|
|
162
|
+
if (matches.length > 1) throw new IdbDecimalIdentityConflictError(modelName);
|
|
163
|
+
return matches[0];
|
|
164
|
+
}
|
|
165
|
+
/** Whether this key source needs numeric equality instead of the native string index. */
|
|
166
|
+
function hasDecimalFields(catalog, source) {
|
|
167
|
+
return source.fields.some((field) => catalog.fields[field]?.codecId === "idb/decimal@1");
|
|
168
|
+
}
|
|
169
|
+
/** Compare one declared scalar without converting Decimal text through Number. */
|
|
170
|
+
function valuesEqual(catalog, field, left, right) {
|
|
171
|
+
return catalog.fields[field]?.codecId === "idb/decimal@1" ? typeof left === "string" && typeof right === "string" && compareDecimalValues(left, right) === 0 : isValidIdbKey(left) && isValidIdbKey(right) && keyEquals(left, right);
|
|
172
|
+
}
|
|
173
|
+
/** Numeric unique sources only; ordinary native indexes continue to enforce their own constraints. */
|
|
174
|
+
function decimalUniqueSources(catalog) {
|
|
175
|
+
return [catalog.primaryKey, ...catalog.indexes].filter((source) => source.unique && hasDecimalFields(catalog, source));
|
|
176
|
+
}
|
|
177
|
+
/** Match one index entry, excluding absent/null keys just as IndexedDB does. */
|
|
178
|
+
function sourceCollides(catalog, source, left, right) {
|
|
179
|
+
if (source.multiEntry && source.fields.length === 1) {
|
|
180
|
+
const field = source.fields[0];
|
|
181
|
+
const a = Array.isArray(left[field]) ? left[field] : [left[field]];
|
|
182
|
+
const b = Array.isArray(right[field]) ? right[field] : [right[field]];
|
|
183
|
+
return a.some((x) => b.some((y) => valuesEqual(catalog, field, x, y)));
|
|
184
|
+
}
|
|
185
|
+
return source.fields.every((field) => valuesEqual(catalog, field, left[field], right[field]));
|
|
186
|
+
}
|
|
187
|
+
/** Check new values against every other physical row inside the write transaction. */
|
|
188
|
+
async function assertUnique(scope, catalog, record, previousKey, kind, modelName, snapshot) {
|
|
189
|
+
const rows = snapshot ?? await scope.execute({
|
|
190
|
+
kind: "cursor-scan",
|
|
191
|
+
storeName: catalog.storeName
|
|
192
|
+
});
|
|
193
|
+
const previous = rows.find((row) => previousKey !== void 0 && keyEquals(extractKeyFromRow(row, catalog.primaryKey.keyPath), previousKey));
|
|
194
|
+
for (const source of decimalUniqueSources(catalog)) {
|
|
195
|
+
const matches = rows.filter((row) => sourceCollides(catalog, source, row, record));
|
|
196
|
+
if (matches.length > 1 || previous && rows.filter((row) => sourceCollides(catalog, source, row, previous)).length > 1) throw new IdbDecimalIdentityConflictError(modelName);
|
|
197
|
+
if (matches.some((row) => previousKey === void 0 || !keyEquals(extractKeyFromRow(row, catalog.primaryKey.keyPath), previousKey))) throw new IdbExecuteError({
|
|
198
|
+
code: kind === "add" ? "ADD_FAILED" : "PUT_FAILED",
|
|
199
|
+
planKind: kind,
|
|
200
|
+
storeName: catalog.storeName,
|
|
201
|
+
cause: new DOMException("Numerically equal Decimal key already exists", "ConstraintError")
|
|
202
|
+
}, "Unique constraint failed for a Decimal key");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const decimalModelCache = /* @__PURE__ */ new WeakMap();
|
|
206
|
+
/** Cache the contract models whose uniqueness needs more than native string indexes. */
|
|
207
|
+
function decimalModels(contract) {
|
|
208
|
+
const cached = decimalModelCache.get(contract);
|
|
209
|
+
if (cached) return cached;
|
|
210
|
+
const models = /* @__PURE__ */ new Map();
|
|
211
|
+
for (const modelName of Object.keys(domainModelsAtDefaultNamespace(contract.domain))) {
|
|
212
|
+
const store = getStoreName(contract, modelName);
|
|
213
|
+
if (decimalUniqueSources(buildCatalog(contract, store, modelName)).length > 0) models.set(store, modelName);
|
|
214
|
+
}
|
|
215
|
+
decimalModelCache.set(contract, models);
|
|
216
|
+
return models;
|
|
217
|
+
}
|
|
218
|
+
/** Wrap the transaction seam so nested, bulk and raw sync writes share the same identity rules. */
|
|
219
|
+
function withDecimalIdentityScope(scope, contract) {
|
|
220
|
+
const models = decimalModels(contract);
|
|
221
|
+
if (models.size === 0) return scope;
|
|
222
|
+
return {
|
|
223
|
+
commit: () => scope.commit(),
|
|
224
|
+
rollback: () => scope.rollback(),
|
|
225
|
+
async execute(plan) {
|
|
226
|
+
const modelName = models.get(plan.storeName);
|
|
227
|
+
if (modelName === void 0) return scope.execute(plan);
|
|
228
|
+
try {
|
|
229
|
+
return await executeDecimalWrite(scope, contract, modelName, plan);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
scope.rollback();
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** Resolve a keyed write or materialize a scan before enforcing its numeric constraints. */
|
|
238
|
+
async function executeDecimalWrite(scope, contract, modelName, plan, snapshot) {
|
|
239
|
+
const catalog = buildCatalog(contract, plan.storeName, modelName);
|
|
240
|
+
if (plan.kind === "scan-write") return executeDecimalScanWrite(scope, contract, modelName, plan);
|
|
241
|
+
if (plan.kind === "delete") {
|
|
242
|
+
if (!isValidIdbKey(plan.key)) return scope.execute(plan);
|
|
243
|
+
const row = await resolveDecimalKey(scope, contract, modelName, plan.key, snapshot);
|
|
244
|
+
return scope.execute({
|
|
245
|
+
...plan,
|
|
246
|
+
key: row ? extractKeyFromRow(row, catalog.primaryKey.keyPath) : plan.key
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (plan.kind !== "add" && plan.kind !== "put" && plan.kind !== "update") return scope.execute(plan);
|
|
250
|
+
const key = plan.kind === "update" ? plan.key : extractKeyFromRow(plan.record, catalog.primaryKey.keyPath);
|
|
251
|
+
const previous = plan.kind === "add" ? void 0 : await resolveDecimalKey(scope, contract, modelName, key, snapshot);
|
|
252
|
+
const previousKey = previous && extractKeyFromRow(previous, catalog.primaryKey.keyPath);
|
|
253
|
+
const record = preserveDecimalKeySpelling(contract, modelName, plan.kind === "update" ? {
|
|
254
|
+
...previous,
|
|
255
|
+
...plan.patch
|
|
256
|
+
} : plan.record, previousKey);
|
|
257
|
+
await assertUnique(scope, catalog, record, previousKey, plan.kind, modelName, snapshot);
|
|
258
|
+
return scope.execute(plan.kind === "update" ? {
|
|
259
|
+
...plan,
|
|
260
|
+
key: previousKey ?? plan.key,
|
|
261
|
+
patch: preserveDecimalKeySpelling(contract, modelName, plan.patch, previousKey)
|
|
262
|
+
} : {
|
|
263
|
+
...plan,
|
|
264
|
+
record
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/** Materialize preimages once so moving indexed fields cannot revisit a row. */
|
|
268
|
+
async function executeDecimalScanWrite(scope, contract, modelName, plan) {
|
|
269
|
+
const catalog = buildCatalog(contract, plan.storeName, modelName);
|
|
270
|
+
const { take, ...scan } = plan;
|
|
271
|
+
const rows = await scope.execute({
|
|
272
|
+
...scan,
|
|
273
|
+
kind: "cursor-scan"
|
|
274
|
+
});
|
|
275
|
+
const seen = /* @__PURE__ */ new Set();
|
|
276
|
+
const matches = rows.filter((row) => {
|
|
277
|
+
const token = keyToken(extractKeyFromRow(row, catalog.primaryKey.keyPath));
|
|
278
|
+
if (seen.has(token)) return false;
|
|
279
|
+
seen.add(token);
|
|
280
|
+
return true;
|
|
281
|
+
}).slice(0, take);
|
|
282
|
+
if (matches.length === 0) return [];
|
|
283
|
+
const snapshot = !plan.indexName && !plan.range && !plan.filter ? [...rows] : await scope.execute({
|
|
284
|
+
kind: "cursor-scan",
|
|
285
|
+
storeName: plan.storeName
|
|
286
|
+
});
|
|
287
|
+
const result = [];
|
|
288
|
+
for (const row of matches) {
|
|
289
|
+
const key = extractKeyFromRow(row, catalog.primaryKey.keyPath);
|
|
290
|
+
const written = await executeDecimalWrite(scope, contract, modelName, plan.write === "delete" ? {
|
|
291
|
+
...plan,
|
|
292
|
+
kind: "delete",
|
|
293
|
+
key
|
|
294
|
+
} : {
|
|
295
|
+
...plan,
|
|
296
|
+
kind: "update",
|
|
297
|
+
key,
|
|
298
|
+
patch: plan.patch ?? {}
|
|
299
|
+
}, snapshot);
|
|
300
|
+
result.push(...written);
|
|
301
|
+
const index = snapshot.findIndex((stored) => keyEquals(extractKeyFromRow(stored, catalog.primaryKey.keyPath), key));
|
|
302
|
+
if (index >= 0) snapshot.splice(index, 1, ...plan.write === "delete" ? [] : written);
|
|
303
|
+
else if (plan.write !== "delete") snapshot.push(...written);
|
|
304
|
+
}
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
/** Decorate only Decimal-key mutations; all other queries retain their existing execution path. */
|
|
308
|
+
function withDecimalIdentityExecutor(contract, executor) {
|
|
309
|
+
const models = decimalModels(contract);
|
|
310
|
+
if (models.size === 0) return executor;
|
|
311
|
+
const inner = executor;
|
|
312
|
+
/** Only mutations of declared Decimal key sources need a transaction wrapper. */
|
|
313
|
+
const needsCheck = (plan) => {
|
|
314
|
+
if (![
|
|
315
|
+
"add",
|
|
316
|
+
"put",
|
|
317
|
+
"update",
|
|
318
|
+
"delete",
|
|
319
|
+
"scan-write"
|
|
320
|
+
].includes(plan.kind)) return false;
|
|
321
|
+
return models.has(plan.storeName);
|
|
322
|
+
};
|
|
323
|
+
/** Keep validation and the write under the caller's transaction and sync interceptor. */
|
|
324
|
+
const transaction = async (stores, mode) => {
|
|
325
|
+
if (typeof inner.transaction !== "function") throw new Error("Decimal key mutations require a transaction-capable executor");
|
|
326
|
+
return withDecimalIdentityScope(await inner.transaction(stores, mode), contract);
|
|
327
|
+
};
|
|
328
|
+
return {
|
|
329
|
+
...typeof inner.transaction === "function" ? { transaction } : {},
|
|
330
|
+
query(plan) {
|
|
331
|
+
const body = plan.idbPlan;
|
|
332
|
+
const ops = body.kind === "batch" ? body.ops : [body];
|
|
333
|
+
if (!ops.some(needsCheck)) return executor.query(plan);
|
|
334
|
+
return new AsyncIterableResult((async function* () {
|
|
335
|
+
const scope = await transaction(body.kind === "batch" ? [...body.storeNames] : [body.storeName], "readwrite");
|
|
336
|
+
try {
|
|
337
|
+
const rows = [];
|
|
338
|
+
for (const op of ops) rows.push(...await scope.execute(op));
|
|
339
|
+
await scope.commit();
|
|
340
|
+
for (const row of rows) yield row;
|
|
341
|
+
} catch (error) {
|
|
342
|
+
scope.rollback();
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
})());
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/core/query-shaping.ts
|
|
351
|
+
/**
|
|
352
|
+
* Combine accumulated filter expressions with AND.
|
|
353
|
+
*
|
|
354
|
+
* Returns `undefined` when no filter is present so callers can skip building a
|
|
355
|
+
* row-filter closure. Shared by {@link IdbStoreAccessorImpl} (top-level scans)
|
|
356
|
+
* and the relation loader (refined `include()` child scans).
|
|
357
|
+
*/
|
|
358
|
+
function combineFilterExprs(filters) {
|
|
359
|
+
if (filters.length === 0) return void 0;
|
|
360
|
+
if (filters.length === 1) return filters[0];
|
|
361
|
+
return andExpr(filters);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Build an in-memory comparator from an `orderBy` spec (field → direction).
|
|
365
|
+
*
|
|
366
|
+
* Returns `undefined` when there is nothing to sort by. Compares fields in
|
|
367
|
+
* declaration order. Decimal fields use exact numeric order; other fields
|
|
368
|
+
* use {@link compareFieldValues}: the same order an
|
|
369
|
+
* IndexedDB index uses, so equal `Date`s tie and fall through to the next
|
|
370
|
+
* field, and `null`s sort last (first when descending).
|
|
371
|
+
*/
|
|
372
|
+
function buildRowComparator(orderBy, fields = {}) {
|
|
373
|
+
if (orderBy === void 0) return void 0;
|
|
374
|
+
return (a, b) => {
|
|
375
|
+
for (const [field, dir] of Object.entries(orderBy)) {
|
|
376
|
+
const metadata = fields[field];
|
|
377
|
+
const decimal = metadata?.codecId === "idb/decimal@1" && !metadata.collection;
|
|
378
|
+
const present = a[field] != null && b[field] != null;
|
|
379
|
+
const cmp = decimal && present ? compareDecimalValues(a[field], b[field]) : compareFieldValues(a[field], b[field]);
|
|
380
|
+
if (cmp === 0) continue;
|
|
381
|
+
return dir === "desc" ? -cmp : cmp;
|
|
382
|
+
}
|
|
383
|
+
return 0;
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/core/enum-validation.ts
|
|
388
|
+
const rulesByContract = /* @__PURE__ */ new WeakMap();
|
|
389
|
+
/** Resolve an enum reference against its declared namespace before validating writes. */
|
|
390
|
+
function resolveEnumRule(contract, field) {
|
|
391
|
+
const ref = field.valueSet;
|
|
392
|
+
if (ref === void 0 || ref.plane !== "domain" || ref.entityKind !== "enum") return void 0;
|
|
393
|
+
const contractEnum = contract.domain.namespaces[ref.namespaceId]?.enum?.[ref.entityName];
|
|
394
|
+
if (contractEnum === void 0) throw new Error(`Enum "${ref.entityName}" referenced by the contract is not declared in its domain.`);
|
|
395
|
+
return {
|
|
396
|
+
enumName: ref.entityName,
|
|
397
|
+
values: new Set(contractEnum.members.map((member) => member.value)),
|
|
398
|
+
nullable: field.nullable,
|
|
399
|
+
many: Boolean(field.many),
|
|
400
|
+
elementNullable: typeof field.many === "object" && field.many.elementNullable === true
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/** Cache enum rules by contract identity and model to avoid rebuilding them for each write. */
|
|
404
|
+
function getModelEnumRules(contract, modelName) {
|
|
405
|
+
let byModel = rulesByContract.get(contract);
|
|
406
|
+
if (byModel === void 0) {
|
|
407
|
+
byModel = /* @__PURE__ */ new Map();
|
|
408
|
+
rulesByContract.set(contract, byModel);
|
|
409
|
+
}
|
|
410
|
+
const cached = byModel.get(modelName);
|
|
411
|
+
if (cached !== void 0) return cached;
|
|
412
|
+
const rules = /* @__PURE__ */ new Map();
|
|
413
|
+
const fields = domainModelsAtDefaultNamespace(contract.domain)[modelName]?.fields ?? {};
|
|
414
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
415
|
+
const rule = resolveEnumRule(contract, field);
|
|
416
|
+
if (rule !== void 0) rules.set(fieldName, rule);
|
|
417
|
+
}
|
|
418
|
+
byModel.set(modelName, rules);
|
|
419
|
+
return rules;
|
|
420
|
+
}
|
|
421
|
+
function describe(value) {
|
|
422
|
+
return typeof value === "string" ? JSON.stringify(value) : `${String(value)} (${typeof value})`;
|
|
423
|
+
}
|
|
424
|
+
/** Describe an invalid enum value together with the declared alternatives. */
|
|
425
|
+
function invalidValueError(modelName, fieldName, rule, value) {
|
|
426
|
+
const expected = [...rule.values].map((v) => JSON.stringify(v)).join(", ");
|
|
427
|
+
return /* @__PURE__ */ new Error(`Invalid value ${describe(value)} for enum field "${modelName}.${fieldName}". Expected one of enum "${rule.enumName}": ${expected}.`);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Throws if `data` sets an enum field to a value its enum doesn't declare.
|
|
431
|
+
* Fields left `undefined` are not checked. `null` is accepted only on an
|
|
432
|
+
* optional (`Role?`) field; a list (`Role[]`) field must be an array whose
|
|
433
|
+
* every element is a declared value, or `null` when the list allows nullable elements.
|
|
434
|
+
*/
|
|
435
|
+
function assertEnumValues(contract, modelName, data) {
|
|
436
|
+
const rules = getModelEnumRules(contract, modelName);
|
|
437
|
+
if (rules.size === 0) return;
|
|
438
|
+
for (const [fieldName, rule] of rules) {
|
|
439
|
+
const value = data[fieldName];
|
|
440
|
+
if (value === void 0) continue;
|
|
441
|
+
if (value === null) {
|
|
442
|
+
if (rule.nullable) continue;
|
|
443
|
+
throw new Error(`Enum field "${modelName}.${fieldName}" is required and cannot be null.`);
|
|
444
|
+
}
|
|
445
|
+
if (rule.many) {
|
|
446
|
+
if (!Array.isArray(value)) throw new Error(`Enum list field "${modelName}.${fieldName}" expects an array of "${rule.enumName}" values, got ${describe(value)}.`);
|
|
447
|
+
for (const element of value) {
|
|
448
|
+
if (element === null && rule.elementNullable) continue;
|
|
449
|
+
if (!rule.values.has(element)) throw invalidValueError(modelName, fieldName, rule, element);
|
|
450
|
+
}
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (!rule.values.has(value)) throw invalidValueError(modelName, fieldName, rule, value);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/core/record-validation.ts
|
|
458
|
+
/** A local ORM create or update failed the contract's native record validation. */
|
|
459
|
+
var IdbRecordValidationError = class extends Error {
|
|
460
|
+
modelName;
|
|
461
|
+
operation;
|
|
462
|
+
issues;
|
|
463
|
+
constructor(modelName, operation, issues) {
|
|
464
|
+
super(`Invalid ${operation} record for "${modelName}": ${issues.join("; ")}`);
|
|
465
|
+
this.modelName = modelName;
|
|
466
|
+
this.operation = operation;
|
|
467
|
+
this.issues = issues;
|
|
468
|
+
this.name = "IdbRecordValidationError";
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
/** Validate final write data, after defaults and relation scalar values are resolved. */
|
|
472
|
+
function assertValidRecord(contract, modelName, operation, data) {
|
|
473
|
+
const keyPath = getKeyPath(contract, modelName);
|
|
474
|
+
const store = contract.storage.stores[getStoreName(contract, modelName)];
|
|
475
|
+
const optionalFields = operation === "create" && store?.autoIncrement && typeof keyPath === "string" && !Object.hasOwn(data, keyPath) ? [keyPath] : [];
|
|
476
|
+
const result = validateRecord(contract, modelName, data, {
|
|
477
|
+
partial: operation === "update",
|
|
478
|
+
optionalFields
|
|
479
|
+
});
|
|
480
|
+
if (!result.ok) throw new IdbRecordValidationError(modelName, operation, result.issues);
|
|
481
|
+
}
|
|
482
|
+
//#endregion
|
|
483
|
+
//#region src/core/mutation-defaults.ts
|
|
484
|
+
function createMutationDefaultsCache() {
|
|
485
|
+
return /* @__PURE__ */ new Map();
|
|
486
|
+
}
|
|
487
|
+
/** Generator ids whose value is shared across an entire top-level call. Every other id always regenerates. */
|
|
488
|
+
const CACHEABLE_GENERATOR_IDS = /* @__PURE__ */ new Set(["timestampNow"]);
|
|
489
|
+
/** Resolve one declared default with the same generator IDs as the SQL execution layer. */
|
|
490
|
+
function computeGeneratedValue(spec) {
|
|
491
|
+
const id = spec.id;
|
|
492
|
+
switch (id) {
|
|
493
|
+
case "timestampNow": return /* @__PURE__ */ new Date();
|
|
494
|
+
case "literal": return spec.params?.["value"];
|
|
495
|
+
case "uuidv4":
|
|
496
|
+
case "uuidv7":
|
|
497
|
+
case "cuid2": return generateId(spec.params !== void 0 ? {
|
|
498
|
+
id,
|
|
499
|
+
params: spec.params
|
|
500
|
+
} : { id });
|
|
501
|
+
default: throw new Error(`Unknown mutation-default generator id "${String(id)}"`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Share query-stable defaults across one mutation while generating fresh IDs for each field. */
|
|
505
|
+
function resolveGeneratedValue(spec, cache) {
|
|
506
|
+
const id = spec.id;
|
|
507
|
+
if (!CACHEABLE_GENERATOR_IDS.has(id)) return computeGeneratedValue(spec);
|
|
508
|
+
const cached = cache.get(id);
|
|
509
|
+
if (cached !== void 0) return cached;
|
|
510
|
+
const value = computeGeneratedValue(spec);
|
|
511
|
+
cache.set(id, value);
|
|
512
|
+
return value;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Fills in every column with an `onCreate` default that `data` doesn't
|
|
516
|
+
* already set. Returns `data` unchanged (same reference) if there's nothing
|
|
517
|
+
* to apply.
|
|
518
|
+
*/
|
|
519
|
+
function applyCreateDefaults(defaults, storeName, data, cache) {
|
|
520
|
+
if (!defaults || defaults.length === 0) return data;
|
|
521
|
+
let result = data;
|
|
522
|
+
for (const d of defaults) {
|
|
523
|
+
if (d.ref.entry !== storeName || !d.onCreate) continue;
|
|
524
|
+
if (result[d.ref.field] !== void 0) continue;
|
|
525
|
+
if (result === data) result = { ...data };
|
|
526
|
+
result[d.ref.field] = resolveGeneratedValue(d.onCreate, cache);
|
|
527
|
+
}
|
|
528
|
+
return result;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Completes a create record: applies the `onCreate` defaults, then stores
|
|
532
|
+
* `null` for every nullable field the record still lacks. IndexedDB has no
|
|
533
|
+
* column defaults, so this is what keeps stored rows complete while the create
|
|
534
|
+
* input leaves nullable fields optional, as Prisma's does. Call it after any
|
|
535
|
+
* relation values are resolved into `data`, and validate the result.
|
|
536
|
+
*/
|
|
537
|
+
function completeCreateRecord(contract, modelName, data, cache) {
|
|
538
|
+
const withDefaults = applyCreateDefaults(contract.execution?.mutations.defaults, getStoreName(contract, modelName), data, cache);
|
|
539
|
+
return absentAsNull(contract, modelName)(withDefaults);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Fills in every column with an `onUpdate` default that `patch` doesn't
|
|
543
|
+
* already set. A patch with no defined values (empty, or every field
|
|
544
|
+
* explicitly `undefined`) is returned unchanged — no write means no default
|
|
545
|
+
* should fire either (matches SQL's "empty update payloads skip onUpdate
|
|
546
|
+
* defaults" rule).
|
|
547
|
+
*/
|
|
548
|
+
function applyUpdateDefaults(defaults, storeName, patch, cache) {
|
|
549
|
+
if (Object.values(patch).every((v) => v === void 0)) return patch;
|
|
550
|
+
if (!defaults || defaults.length === 0) return patch;
|
|
551
|
+
let result = patch;
|
|
552
|
+
for (const d of defaults) {
|
|
553
|
+
if (d.ref.entry !== storeName || !d.onUpdate) continue;
|
|
554
|
+
if (result[d.ref.field] !== void 0) continue;
|
|
555
|
+
if (result === patch) result = { ...patch };
|
|
556
|
+
result[d.ref.field] = resolveGeneratedValue(d.onUpdate, cache);
|
|
557
|
+
}
|
|
558
|
+
return result;
|
|
559
|
+
}
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/core/planner/plan.ts
|
|
562
|
+
const COST = {
|
|
563
|
+
UNIQUE_POINT: 0,
|
|
564
|
+
PRIMARY_POINT: 1,
|
|
565
|
+
INDEX_POINT: 2,
|
|
566
|
+
PREFIX_POINT: 3,
|
|
567
|
+
TWO_SIDED_RANGE: 4,
|
|
568
|
+
ONE_SIDED_RANGE: 5,
|
|
569
|
+
ORDER_ONLY_SCAN: 6
|
|
570
|
+
};
|
|
571
|
+
const KEY_PARTIAL_CODECS = /* @__PURE__ */ new Set(["idb/double@1", "idb/date@1"]);
|
|
572
|
+
const KEY_CODECS = /* @__PURE__ */ new Set([
|
|
573
|
+
"idb/string@1",
|
|
574
|
+
"idb/int32@1",
|
|
575
|
+
"idb/double@1",
|
|
576
|
+
"idb/date@1",
|
|
577
|
+
"idb/bytes@1"
|
|
578
|
+
]);
|
|
579
|
+
/** Choose the cheapest safe access without opening IndexedDB or probing cardinality. */
|
|
580
|
+
function planQuery(catalog, request) {
|
|
581
|
+
if (request.where !== void 0) assertValidDecimalFilter(request.where, catalog.fields);
|
|
582
|
+
const normalized = normalize(request.where);
|
|
583
|
+
if (normalized.empty) return {
|
|
584
|
+
access: { kind: "empty" },
|
|
585
|
+
exact: true
|
|
586
|
+
};
|
|
587
|
+
const orders = Object.entries(request.orderBy ?? {});
|
|
588
|
+
if (normalized.excluded || request.hasRelationFilter || orders.length > 1) return full(false);
|
|
589
|
+
const constraints = /* @__PURE__ */ new Map();
|
|
590
|
+
for (const atom of normalized.atoms) {
|
|
591
|
+
let constraint = constraints.get(atom.field);
|
|
592
|
+
if (!constraint) {
|
|
593
|
+
constraint = { consumed: 0 };
|
|
594
|
+
constraints.set(atom.field, constraint);
|
|
595
|
+
}
|
|
596
|
+
addAtom(constraint, atom, catalog.fields[atom.field]);
|
|
597
|
+
}
|
|
598
|
+
for (const [name, constraint] of constraints) {
|
|
599
|
+
if (keyPartial(catalog.fields[name]) && !constraint.rejectsNonKeys) {
|
|
600
|
+
delete constraint.lower;
|
|
601
|
+
delete constraint.upper;
|
|
602
|
+
constraint.consumed = 0;
|
|
603
|
+
}
|
|
604
|
+
if (contradictory(constraint)) return {
|
|
605
|
+
access: { kind: "empty" },
|
|
606
|
+
exact: true
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
const sources = [catalog.primaryKey, ...catalog.indexes];
|
|
610
|
+
const candidates = sources.flatMap((source, declaration) => {
|
|
611
|
+
const candidate = filteringCandidate(catalog, source, constraints, declaration);
|
|
612
|
+
return candidate ? [candidate] : [];
|
|
613
|
+
});
|
|
614
|
+
candidates.sort((a, b) => a.cost - b.cost || a.ranges.length - b.ranges.length || Number(a.source.indexName !== void 0) - Number(b.source.indexName !== void 0) || a.declaration - b.declaration);
|
|
615
|
+
let selected = candidates[0];
|
|
616
|
+
let direction;
|
|
617
|
+
if (orders.length === 1 && request.take !== void 0) {
|
|
618
|
+
const [field, order] = orders[0];
|
|
619
|
+
if (selected) {
|
|
620
|
+
if (catalog.fields[field]?.codecId !== "idb/decimal@1" && sourceOrdersField(selected.source, field, constraints)) direction = order === "asc" ? "next" : "prev";
|
|
621
|
+
} else {
|
|
622
|
+
const ordering = sources.find((source) => !source.multiEntry && singleFieldSource(source, field) && completeKeyField(catalog.fields[field]));
|
|
623
|
+
if (ordering) {
|
|
624
|
+
selected = {
|
|
625
|
+
source: ordering,
|
|
626
|
+
ranges: [void 0],
|
|
627
|
+
cost: COST.ORDER_ONLY_SCAN,
|
|
628
|
+
consumed: 0,
|
|
629
|
+
declaration: sources.indexOf(ordering)
|
|
630
|
+
};
|
|
631
|
+
direction = order === "asc" ? "next" : "prev";
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (!selected) {
|
|
636
|
+
const partialOrder = orders.length === 1 && request.take !== void 0 && keyPartial(catalog.fields[orders[0][0]]);
|
|
637
|
+
return full(normalized.atoms.length + normalized.residual === 0 && !partialOrder);
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
access: {
|
|
641
|
+
kind: "ranges",
|
|
642
|
+
source: selected.source,
|
|
643
|
+
ranges: selected.ranges
|
|
644
|
+
},
|
|
645
|
+
exact: selected.consumed === normalized.atoms.length && normalized.residual === 0,
|
|
646
|
+
...direction === void 0 ? {} : { direction }
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function full(exact) {
|
|
650
|
+
return {
|
|
651
|
+
access: { kind: "full" },
|
|
652
|
+
exact
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/** Flatten supported conjunctions and same-field unions while retaining unsupported predicates. */
|
|
656
|
+
function normalize(where) {
|
|
657
|
+
const result = {
|
|
658
|
+
atoms: [],
|
|
659
|
+
residual: 0,
|
|
660
|
+
excluded: false,
|
|
661
|
+
empty: false
|
|
662
|
+
};
|
|
663
|
+
/** Collect usable atoms and mark filters that require the full-row path. */
|
|
664
|
+
function visit(expr) {
|
|
665
|
+
switch (expr.kind) {
|
|
666
|
+
case "field":
|
|
667
|
+
if (expr.op === "in" && (!Array.isArray(expr.value) || expr.value.length === 0)) result.empty = true;
|
|
668
|
+
result.atoms.push(expr);
|
|
669
|
+
break;
|
|
670
|
+
case "and":
|
|
671
|
+
expr.exprs.forEach(visit);
|
|
672
|
+
break;
|
|
673
|
+
case "or": {
|
|
674
|
+
if (expr.exprs.length === 0) {
|
|
675
|
+
result.empty = true;
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
const branches = expr.exprs;
|
|
679
|
+
const first = branches[0];
|
|
680
|
+
if (first.kind !== "field" || !branches.every((branch) => branch.kind === "field" && branch.field === first.field && (branch.op === "eq" || branch.op === "in"))) {
|
|
681
|
+
result.excluded = true;
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
const values = branches.flatMap((branch) => {
|
|
685
|
+
const atom = branch;
|
|
686
|
+
return atom.op === "eq" ? [atom.value] : Array.isArray(atom.value) ? atom.value : [];
|
|
687
|
+
});
|
|
688
|
+
visit({
|
|
689
|
+
kind: "field",
|
|
690
|
+
field: first.field,
|
|
691
|
+
op: "in",
|
|
692
|
+
value: values
|
|
693
|
+
});
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
case "not":
|
|
697
|
+
result.excluded = true;
|
|
698
|
+
break;
|
|
699
|
+
case "null-check": result.residual++;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (where) visit(where);
|
|
703
|
+
return result;
|
|
704
|
+
}
|
|
705
|
+
function keyTyped(field) {
|
|
706
|
+
return field !== void 0 && !field.collection && KEY_CODECS.has(field.codecId ?? "");
|
|
707
|
+
}
|
|
708
|
+
function keyPartial(field) {
|
|
709
|
+
return KEY_PARTIAL_CODECS.has(field?.codecId ?? "");
|
|
710
|
+
}
|
|
711
|
+
function completeKeyField(field, constraint) {
|
|
712
|
+
return keyTyped(field) && field?.nullable === false && (!keyPartial(field) || constraint?.rejectsNonKeys === true);
|
|
713
|
+
}
|
|
714
|
+
/** A compound cursor orders a suffix only within one fixed preceding key. */
|
|
715
|
+
function sourceOrdersField(source, field, constraints) {
|
|
716
|
+
const position = source.fields.indexOf(field);
|
|
717
|
+
return position >= 0 && source.fields.slice(0, position).every((name) => constraints.get(name)?.points?.length === 1);
|
|
718
|
+
}
|
|
719
|
+
function singleFieldSource(source, field) {
|
|
720
|
+
return source.fields.length === 1 && source.fields[0] === field;
|
|
721
|
+
}
|
|
722
|
+
/** Intersect one supported predicate with a field constraint without narrowing unsafe values. */
|
|
723
|
+
function addAtom(constraint, atom, field) {
|
|
724
|
+
if (field?.codecId === "idb/decimal@1") return;
|
|
725
|
+
if (atom.op === "eq" || atom.op === "in") {
|
|
726
|
+
const values = atom.op === "eq" ? [atom.value] : Array.isArray(atom.value) ? atom.value : [];
|
|
727
|
+
if (!values.every(isValidIdbKey)) return;
|
|
728
|
+
const points = uniqueKeys(values);
|
|
729
|
+
constraint.points = constraint.points === void 0 ? points : constraint.points.filter((point) => points.some((value) => keyEquals(point, value)));
|
|
730
|
+
constraint.rejectsNonKeys = true;
|
|
731
|
+
constraint.consumed++;
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (!keyTyped(field)) return;
|
|
735
|
+
if (atom.op === "startsWith") {
|
|
736
|
+
if (field?.codecId !== "idb/string@1" || typeof atom.value !== "string") return;
|
|
737
|
+
intersectBound(constraint, "lower", {
|
|
738
|
+
value: atom.value,
|
|
739
|
+
open: false
|
|
740
|
+
});
|
|
741
|
+
const upper = stringSuccessor(atom.value);
|
|
742
|
+
if (upper !== void 0) intersectBound(constraint, "upper", {
|
|
743
|
+
value: upper,
|
|
744
|
+
open: true
|
|
745
|
+
});
|
|
746
|
+
constraint.startsWith = true;
|
|
747
|
+
constraint.consumed++;
|
|
748
|
+
} else if ([
|
|
749
|
+
"gt",
|
|
750
|
+
"gte",
|
|
751
|
+
"lt",
|
|
752
|
+
"lte"
|
|
753
|
+
].includes(atom.op) && isValidIdbKey(atom.value)) {
|
|
754
|
+
const side = atom.op === "gt" || atom.op === "gte" ? "lower" : "upper";
|
|
755
|
+
const strict = atom.op === "gt" || atom.op === "lt";
|
|
756
|
+
intersectBound(constraint, side, {
|
|
757
|
+
value: atom.value,
|
|
758
|
+
open: strict
|
|
759
|
+
});
|
|
760
|
+
if (strict) constraint.rejectsNonKeys = true;
|
|
761
|
+
constraint.consumed++;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function uniqueKeys(values) {
|
|
765
|
+
const sorted = [...values].sort(compareFieldValues);
|
|
766
|
+
return sorted.filter((value, i) => i === 0 || compareFieldValues(sorted[i - 1], value) !== 0);
|
|
767
|
+
}
|
|
768
|
+
/** Keep the tighter endpoint when several comparisons constrain the same field. */
|
|
769
|
+
function intersectBound(constraint, side, bound) {
|
|
770
|
+
const previous = constraint[side];
|
|
771
|
+
const comparison = previous === void 0 ? 0 : compareFieldValues(bound.value, previous.value);
|
|
772
|
+
if (!previous || (side === "lower" ? comparison > 0 : comparison < 0) || comparison === 0 && bound.open) constraint[side] = bound;
|
|
773
|
+
}
|
|
774
|
+
/** Check a point against both endpoints, including open-bound equality. */
|
|
775
|
+
function insideBounds(value, constraint) {
|
|
776
|
+
const lower = constraint.lower;
|
|
777
|
+
const upper = constraint.upper;
|
|
778
|
+
if (lower && (compareFieldValues(value, lower.value) < 0 || lower.open && keyEquals(value, lower.value))) return false;
|
|
779
|
+
if (upper && (compareFieldValues(value, upper.value) > 0 || upper.open && keyEquals(value, upper.value))) return false;
|
|
780
|
+
return true;
|
|
781
|
+
}
|
|
782
|
+
/** Discard points outside the bounds and detect ranges that cannot match. */
|
|
783
|
+
function contradictory(constraint) {
|
|
784
|
+
if (constraint.points) {
|
|
785
|
+
constraint.points = constraint.points.filter((value) => insideBounds(value, constraint));
|
|
786
|
+
return constraint.points.length === 0;
|
|
787
|
+
}
|
|
788
|
+
if (!constraint.lower || !constraint.upper) return false;
|
|
789
|
+
const comparison = compareFieldValues(constraint.lower.value, constraint.upper.value);
|
|
790
|
+
return comparison > 0 || comparison === 0 && (constraint.lower.open || constraint.upper.open);
|
|
791
|
+
}
|
|
792
|
+
/** Build a safe source range only when its index contains every possible match. */
|
|
793
|
+
function filteringCandidate(catalog, source, constraints, declaration) {
|
|
794
|
+
if (source.multiEntry || source.fields.length === 0) return void 0;
|
|
795
|
+
if (source.fields.some((name) => keyPartial(catalog.fields[name]) && !constraints.get(name)?.rejectsNonKeys)) return void 0;
|
|
796
|
+
let prefixes = [[]];
|
|
797
|
+
let consumed = 0;
|
|
798
|
+
let position = 0;
|
|
799
|
+
while (position < source.fields.length) {
|
|
800
|
+
const constraint = constraints.get(source.fields[position]);
|
|
801
|
+
if (constraint?.points === void 0) break;
|
|
802
|
+
if (prefixes.length * constraint.points.length > 1024) return void 0;
|
|
803
|
+
prefixes = prefixes.flatMap((prefix) => constraint.points.map((point) => [...prefix, point]));
|
|
804
|
+
consumed += constraint.consumed;
|
|
805
|
+
position++;
|
|
806
|
+
}
|
|
807
|
+
const constraint = constraints.get(source.fields[position] ?? "");
|
|
808
|
+
const hasRange = constraint !== void 0 && (constraint.lower !== void 0 || constraint.upper !== void 0);
|
|
809
|
+
if (position === 0 && !hasRange) return void 0;
|
|
810
|
+
const used = position + Number(hasRange);
|
|
811
|
+
if (!source.fields.slice(used).every((field) => completeKeyField(catalog.fields[field], constraints.get(field)))) return void 0;
|
|
812
|
+
const compound = typeof source.keyPath !== "string";
|
|
813
|
+
if (position === source.fields.length) return {
|
|
814
|
+
source,
|
|
815
|
+
ranges: prefixes.map((prefix) => ({
|
|
816
|
+
kind: "only",
|
|
817
|
+
key: compound ? prefix : prefix[0]
|
|
818
|
+
})),
|
|
819
|
+
cost: source.indexName !== void 0 && source.unique ? COST.UNIQUE_POINT : source.indexName === void 0 ? COST.PRIMARY_POINT : COST.INDEX_POINT,
|
|
820
|
+
consumed,
|
|
821
|
+
declaration
|
|
822
|
+
};
|
|
823
|
+
if (hasRange) consumed += constraint.consumed;
|
|
824
|
+
const ranges = prefixes.map((prefix) => compound ? compoundRange(prefix, constraint, source.fields.length - used) : scalarRange(constraint));
|
|
825
|
+
if (ranges.some((range) => range.kind === "bound" && (compareFieldValues(range.lower, range.upper) > 0 || keyEquals(range.lower, range.upper) && (range.lowerOpen || range.upperOpen)))) return void 0;
|
|
826
|
+
return {
|
|
827
|
+
source,
|
|
828
|
+
ranges,
|
|
829
|
+
cost: hasRange ? constraint.lower && constraint.upper && !constraint.startsWith ? COST.TWO_SIDED_RANGE : COST.ONE_SIDED_RANGE : COST.PREFIX_POINT,
|
|
830
|
+
consumed,
|
|
831
|
+
declaration
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
/** Convert scalar endpoints into the matching driver range descriptor. */
|
|
835
|
+
function scalarRange(constraint) {
|
|
836
|
+
if (constraint.lower && constraint.upper) return {
|
|
837
|
+
kind: "bound",
|
|
838
|
+
lower: constraint.lower.value,
|
|
839
|
+
upper: constraint.upper.value,
|
|
840
|
+
lowerOpen: constraint.lower.open,
|
|
841
|
+
upperOpen: constraint.upper.open
|
|
842
|
+
};
|
|
843
|
+
if (constraint.lower) return {
|
|
844
|
+
kind: "lower",
|
|
845
|
+
key: constraint.lower.value,
|
|
846
|
+
open: constraint.lower.open
|
|
847
|
+
};
|
|
848
|
+
return {
|
|
849
|
+
kind: "upper",
|
|
850
|
+
key: constraint.upper.value,
|
|
851
|
+
open: constraint.upper.open
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
/** Bound a compound prefix and suffix using IndexedDB key ordering. */
|
|
855
|
+
function compoundRange(prefix, constraint, trailing) {
|
|
856
|
+
const lower = constraint?.lower;
|
|
857
|
+
const upper = constraint?.upper;
|
|
858
|
+
return {
|
|
859
|
+
kind: "bound",
|
|
860
|
+
lower: lower ? [
|
|
861
|
+
...prefix,
|
|
862
|
+
lower.value,
|
|
863
|
+
...lower.open && trailing > 0 ? [[]] : []
|
|
864
|
+
] : prefix.length ? prefix : [-Infinity],
|
|
865
|
+
upper: upper ? [
|
|
866
|
+
...prefix,
|
|
867
|
+
upper.value,
|
|
868
|
+
...!upper.open && trailing > 0 ? [[]] : []
|
|
869
|
+
] : [...prefix, []],
|
|
870
|
+
lowerOpen: lower?.open ?? false,
|
|
871
|
+
upperOpen: upper ? upper.open || trailing > 0 : true
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
/** Find the exclusive upper bound of a UTF-16 prefix, if one exists. */
|
|
875
|
+
function stringSuccessor(prefix) {
|
|
876
|
+
for (let i = prefix.length - 1; i >= 0; i--) {
|
|
877
|
+
const code = prefix.charCodeAt(i);
|
|
878
|
+
if (code < 65535) return prefix.slice(0, i) + String.fromCharCode(code + 1);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
//#endregion
|
|
882
|
+
//#region src/core/planner/lower.ts
|
|
883
|
+
/**
|
|
884
|
+
* The row filter shared by every lowered read and write. The driver runs it on stored rows, so it
|
|
885
|
+
* reads each row as the contract describes it first: a filter on `null` matches a row that
|
|
886
|
+
* lacks the field.
|
|
887
|
+
*/
|
|
888
|
+
function rowFilter(catalog, request, additionalFilter) {
|
|
889
|
+
return (stored) => {
|
|
890
|
+
const row = catalog.absentAsNull(stored);
|
|
891
|
+
return (request.where === void 0 || evaluateFilter(request.where, row, catalog.fields)) && (additionalFilter === void 0 || additionalFilter(row));
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Lower row access without creating IDBKeyRange objects or awaiting work.
|
|
896
|
+
* Run `finish` after the executor has materialized all returned rows.
|
|
897
|
+
* Without `orderBy`, row order and pagination selection are unspecified.
|
|
898
|
+
* Rows tied on every ordering field may appear in any order, including at
|
|
899
|
+
* pagination boundaries. Add a unique ordering field for deterministic pages.
|
|
900
|
+
*/
|
|
901
|
+
function lowerRows(catalog, logical, meta, request, additionalFilter) {
|
|
902
|
+
const filter = request.where === void 0 && additionalFilter === void 0 ? void 0 : rowFilter(catalog, request, additionalFilter);
|
|
903
|
+
const comparator = logical.direction === void 0 ? buildRowComparator(request.orderBy, catalog.fields) : void 0;
|
|
904
|
+
const base = {
|
|
905
|
+
meta,
|
|
906
|
+
storeName: catalog.storeName
|
|
907
|
+
};
|
|
908
|
+
const pagination = {
|
|
909
|
+
...request.skip === void 0 ? {} : { skip: request.skip },
|
|
910
|
+
...request.take === void 0 ? {} : { take: request.take }
|
|
911
|
+
};
|
|
912
|
+
let shapedByDriver = false;
|
|
913
|
+
let idbPlan;
|
|
914
|
+
switch (logical.access.kind) {
|
|
915
|
+
case "empty":
|
|
916
|
+
idbPlan = {
|
|
917
|
+
meta,
|
|
918
|
+
kind: "batch",
|
|
919
|
+
storeNames: [catalog.storeName],
|
|
920
|
+
ops: []
|
|
921
|
+
};
|
|
922
|
+
break;
|
|
923
|
+
case "full":
|
|
924
|
+
idbPlan = {
|
|
925
|
+
...base,
|
|
926
|
+
kind: "cursor-scan",
|
|
927
|
+
...filter === void 0 ? {} : { filter },
|
|
928
|
+
...comparator === void 0 ? {} : { comparator },
|
|
929
|
+
...pagination
|
|
930
|
+
};
|
|
931
|
+
shapedByDriver = true;
|
|
932
|
+
break;
|
|
933
|
+
case "ranges": {
|
|
934
|
+
const { source } = logical.access;
|
|
935
|
+
const ranges = [...logical.access.ranges];
|
|
936
|
+
if (logical.direction === "prev") ranges.reverse();
|
|
937
|
+
const single = ranges.length === 1;
|
|
938
|
+
const ops = ranges.map((range) => {
|
|
939
|
+
if (range?.kind === "only" && source.indexName === void 0) return {
|
|
940
|
+
...base,
|
|
941
|
+
kind: "key-get",
|
|
942
|
+
key: range.key
|
|
943
|
+
};
|
|
944
|
+
const access = {
|
|
945
|
+
...base,
|
|
946
|
+
...source.indexName === void 0 ? {} : { indexName: source.indexName },
|
|
947
|
+
...range === void 0 ? {} : { range }
|
|
948
|
+
};
|
|
949
|
+
if (!single && logical.direction !== void 0 && request.take !== void 0) return {
|
|
950
|
+
...access,
|
|
951
|
+
kind: "cursor-scan",
|
|
952
|
+
direction: logical.direction,
|
|
953
|
+
...filter === void 0 ? {} : { filter },
|
|
954
|
+
take: (request.skip ?? 0) + request.take
|
|
955
|
+
};
|
|
956
|
+
if (single && request.take !== void 0) {
|
|
957
|
+
shapedByDriver = true;
|
|
958
|
+
return {
|
|
959
|
+
...access,
|
|
960
|
+
kind: "cursor-scan",
|
|
961
|
+
...filter === void 0 ? {} : { filter },
|
|
962
|
+
...logical.direction === void 0 ? {} : { direction: logical.direction },
|
|
963
|
+
...comparator === void 0 ? {} : { comparator },
|
|
964
|
+
...pagination
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
if (logical.direction === "prev") return {
|
|
968
|
+
...access,
|
|
969
|
+
kind: "cursor-scan",
|
|
970
|
+
direction: "prev",
|
|
971
|
+
...filter === void 0 ? {} : { filter }
|
|
972
|
+
};
|
|
973
|
+
return {
|
|
974
|
+
...access,
|
|
975
|
+
kind: "get-all"
|
|
976
|
+
};
|
|
977
|
+
});
|
|
978
|
+
idbPlan = single ? ops[0] : {
|
|
979
|
+
meta,
|
|
980
|
+
kind: "batch",
|
|
981
|
+
storeNames: [catalog.storeName],
|
|
982
|
+
ops
|
|
983
|
+
};
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return {
|
|
988
|
+
idbPlan,
|
|
989
|
+
/** Normalize returned rows and apply filtering and shaping not already handled by the driver. */
|
|
990
|
+
finish(rows) {
|
|
991
|
+
let result = rows.map(catalog.absentAsNull);
|
|
992
|
+
if (filter !== void 0) result = result.filter(filter);
|
|
993
|
+
if (!shapedByDriver) {
|
|
994
|
+
if (comparator !== void 0) result.sort(comparator);
|
|
995
|
+
const skip = request.skip ?? 0;
|
|
996
|
+
result = result.slice(skip, request.take === void 0 ? void 0 : skip + request.take);
|
|
997
|
+
}
|
|
998
|
+
return result;
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Lower a row count. An `exact` plan counts index entries and loads no rows.
|
|
1004
|
+
* The planner emits disjoint ranges on a non-multi-entry source, so each row
|
|
1005
|
+
* is counted once. Anything else counts the rows that pass the filter.
|
|
1006
|
+
* Pagination and `additionalFilter` always take the row path.
|
|
1007
|
+
*/
|
|
1008
|
+
function lowerCount(catalog, logical, meta, request, additionalFilter) {
|
|
1009
|
+
const paginated = request.skip !== void 0 || request.take !== void 0;
|
|
1010
|
+
if (!logical.exact || paginated || additionalFilter !== void 0) {
|
|
1011
|
+
const lowered = lowerRows(catalog, logical, meta, request, additionalFilter);
|
|
1012
|
+
return {
|
|
1013
|
+
idbPlan: lowered.idbPlan,
|
|
1014
|
+
finish: (rows) => lowered.finish(rows).length
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
return {
|
|
1018
|
+
idbPlan: combine(catalog, meta, accessOps(catalog, meta, logical, (target) => ({
|
|
1019
|
+
...target,
|
|
1020
|
+
kind: "count"
|
|
1021
|
+
}))),
|
|
1022
|
+
finish: (rows) => rows.reduce((total, row) => total + row["count"], 0)
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Lower an existence check. An `exact` plan reads one primary key per range
|
|
1027
|
+
* and loads no rows. Anything else stops each range at its first row that
|
|
1028
|
+
* passes the filter.
|
|
1029
|
+
*/
|
|
1030
|
+
function lowerExists(catalog, logical, meta, request, additionalFilter) {
|
|
1031
|
+
if (logical.exact && additionalFilter === void 0) return {
|
|
1032
|
+
idbPlan: combine(catalog, meta, accessOps(catalog, meta, logical, (target) => ({
|
|
1033
|
+
...target,
|
|
1034
|
+
kind: "keys",
|
|
1035
|
+
take: 1
|
|
1036
|
+
}))),
|
|
1037
|
+
finish: (rows) => rows.length > 0
|
|
1038
|
+
};
|
|
1039
|
+
const filter = rowFilter(catalog, request, additionalFilter);
|
|
1040
|
+
return {
|
|
1041
|
+
idbPlan: combine(catalog, meta, accessOps(catalog, meta, logical, (target) => ({
|
|
1042
|
+
...target,
|
|
1043
|
+
kind: "cursor-scan",
|
|
1044
|
+
filter,
|
|
1045
|
+
take: 1
|
|
1046
|
+
}))),
|
|
1047
|
+
finish: (rows) => rows.some(filter)
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Lower writes through the same access paths as reads. If the patch touches
|
|
1052
|
+
* the walked key, collect every range first so moved rows cannot enter a
|
|
1053
|
+
* later range or change the cursor's position. Multiple ranges with a limit
|
|
1054
|
+
* also collect first, because the limit applies to their combined matches.
|
|
1055
|
+
*/
|
|
1056
|
+
function lowerWrite(catalog, logical, meta, request, additionalFilter) {
|
|
1057
|
+
const { access } = logical;
|
|
1058
|
+
const walkedFields = access.kind === "ranges" ? access.source.fields : catalog.primaryKey.fields;
|
|
1059
|
+
const movesKey = request.write === "put-merged" && walkedFields.some((field) => field in (request.patch ?? {}));
|
|
1060
|
+
const limitedRanges = access.kind === "ranges" && access.ranges.length > 1 && request.take !== void 0;
|
|
1061
|
+
if (movesKey || limitedRanges) return {
|
|
1062
|
+
kind: "collect",
|
|
1063
|
+
rows: lowerRows(catalog, logical, meta, request, additionalFilter)
|
|
1064
|
+
};
|
|
1065
|
+
const filter = rowFilter(catalog, request, additionalFilter);
|
|
1066
|
+
return {
|
|
1067
|
+
kind: "scan",
|
|
1068
|
+
idbPlan: combine(catalog, meta, accessOps(catalog, meta, logical, (target) => ({
|
|
1069
|
+
...target,
|
|
1070
|
+
kind: "scan-write",
|
|
1071
|
+
write: request.write,
|
|
1072
|
+
filter,
|
|
1073
|
+
...request.patch === void 0 ? {} : { patch: request.patch },
|
|
1074
|
+
...request.take === void 0 ? {} : { take: request.take }
|
|
1075
|
+
})))
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
/** One operation per planned range, in ascending key order. */
|
|
1079
|
+
function accessOps(catalog, meta, logical, build) {
|
|
1080
|
+
const { access } = logical;
|
|
1081
|
+
const base = {
|
|
1082
|
+
meta,
|
|
1083
|
+
storeName: catalog.storeName
|
|
1084
|
+
};
|
|
1085
|
+
switch (access.kind) {
|
|
1086
|
+
case "empty": return [];
|
|
1087
|
+
case "full": return [build(base)];
|
|
1088
|
+
case "ranges": {
|
|
1089
|
+
const { indexName } = access.source;
|
|
1090
|
+
return access.ranges.map((range) => build({
|
|
1091
|
+
...base,
|
|
1092
|
+
...indexName === void 0 ? {} : { indexName },
|
|
1093
|
+
...range === void 0 ? {} : { range }
|
|
1094
|
+
}));
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
function combine(catalog, meta, ops) {
|
|
1099
|
+
return ops.length === 1 ? ops[0] : {
|
|
1100
|
+
meta,
|
|
1101
|
+
kind: "batch",
|
|
1102
|
+
storeNames: [catalog.storeName],
|
|
1103
|
+
ops
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/core/mutation-scope.ts
|
|
1108
|
+
/**
|
|
1109
|
+
* Run a callback inside a single multi-store IDB readwrite transaction.
|
|
1110
|
+
*
|
|
1111
|
+
* Opens the transaction, passes the `IdbTransactionScope` to `run`, then:
|
|
1112
|
+
* - On success: awaits `scope.commit()` (waits for `tx.oncomplete`).
|
|
1113
|
+
* - On error: calls `scope.rollback()` and rethrows.
|
|
1114
|
+
*
|
|
1115
|
+
* The callback receives the low-level scope, not the ORM accessor.
|
|
1116
|
+
* Use this for multi-store atomic writes that span several object stores.
|
|
1117
|
+
*/
|
|
1118
|
+
async function withMutationScope(executor, storeNames, run) {
|
|
1119
|
+
const tx = await executor.transaction(storeNames, "readwrite");
|
|
1120
|
+
try {
|
|
1121
|
+
const result = await run(tx);
|
|
1122
|
+
await tx.commit();
|
|
1123
|
+
return result;
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
tx.rollback();
|
|
1126
|
+
throw err;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
//#endregion
|
|
1130
|
+
//#region src/core/relation-mutator.ts
|
|
1131
|
+
/** Build the relation descriptors consumed by the nested-write executor. */
|
|
1132
|
+
function createRelationMutator() {
|
|
1133
|
+
return {
|
|
1134
|
+
/** Normalize a single nested record or a list into one create descriptor. */
|
|
1135
|
+
create(data) {
|
|
1136
|
+
return {
|
|
1137
|
+
kind: "create",
|
|
1138
|
+
data: Array.isArray(data) ? [...data] : [data]
|
|
1139
|
+
};
|
|
1140
|
+
},
|
|
1141
|
+
connect(criteria) {
|
|
1142
|
+
return {
|
|
1143
|
+
kind: "connect",
|
|
1144
|
+
criteria: Array.isArray(criteria) ? [...criteria] : [criteria]
|
|
1145
|
+
};
|
|
1146
|
+
},
|
|
1147
|
+
disconnect(criteria) {
|
|
1148
|
+
if (!criteria) return { kind: "disconnect" };
|
|
1149
|
+
return {
|
|
1150
|
+
kind: "disconnect",
|
|
1151
|
+
criteria: [...criteria]
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
function isRelationMutationDescriptor(value) {
|
|
1157
|
+
if (!value || typeof value !== "object") return false;
|
|
1158
|
+
const candidate = value;
|
|
1159
|
+
return candidate.kind === "create" || candidate.kind === "connect" || candidate.kind === "disconnect";
|
|
1160
|
+
}
|
|
1161
|
+
function isRelationMutationCallback(value) {
|
|
1162
|
+
return typeof value === "function";
|
|
1163
|
+
}
|
|
1164
|
+
//#endregion
|
|
1165
|
+
//#region src/core/mutation-executor.ts
|
|
1166
|
+
/** Plan metadata for the raw plans the mutation executor issues itself, outside any accessor's grouping key. */
|
|
1167
|
+
function makePlanMeta(contract) {
|
|
1168
|
+
return {
|
|
1169
|
+
target: "idb",
|
|
1170
|
+
storageHash: contract.storage.storageHash,
|
|
1171
|
+
lane: "idb-mutation-executor",
|
|
1172
|
+
annotations: { groupingKey: "nested" }
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
const relationDefsCache = /* @__PURE__ */ new WeakMap();
|
|
1176
|
+
/** Cache reference-relation metadata by contract identity and model. */
|
|
1177
|
+
function getRelationDefinitions(contract, modelName) {
|
|
1178
|
+
let perContract = relationDefsCache.get(contract);
|
|
1179
|
+
if (!perContract) {
|
|
1180
|
+
perContract = /* @__PURE__ */ new Map();
|
|
1181
|
+
relationDefsCache.set(contract, perContract);
|
|
1182
|
+
}
|
|
1183
|
+
const cached = perContract.get(modelName);
|
|
1184
|
+
if (cached) return cached;
|
|
1185
|
+
const model = domainModelsAtDefaultNamespace(contract.domain)[modelName];
|
|
1186
|
+
if (!model) {
|
|
1187
|
+
perContract.set(modelName, []);
|
|
1188
|
+
return [];
|
|
1189
|
+
}
|
|
1190
|
+
const defs = [];
|
|
1191
|
+
for (const [relationName, rawRelation] of Object.entries(model.relations)) {
|
|
1192
|
+
if (!rawRelation || typeof rawRelation !== "object" || !("on" in rawRelation)) continue;
|
|
1193
|
+
const relation = rawRelation;
|
|
1194
|
+
const relatedModelName = relation.to.model;
|
|
1195
|
+
const relatedStoreName = getStoreName(contract, relatedModelName);
|
|
1196
|
+
defs.push({
|
|
1197
|
+
relationName,
|
|
1198
|
+
relatedModelName,
|
|
1199
|
+
relatedStoreName,
|
|
1200
|
+
cardinality: relation.cardinality,
|
|
1201
|
+
localFields: relation.on.localFields,
|
|
1202
|
+
targetFields: relation.on.targetFields
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
perContract.set(modelName, defs);
|
|
1206
|
+
return defs;
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Returns true if `data` contains at least one field that is both a known
|
|
1210
|
+
* relation name for `modelName` and a function (a mutation callback).
|
|
1211
|
+
*/
|
|
1212
|
+
function hasNestedMutationCallbacks(contract, modelName, data) {
|
|
1213
|
+
const relationNames = new Set(getRelationDefinitions(contract, modelName).map((r) => r.relationName));
|
|
1214
|
+
for (const [fieldName, value] of Object.entries(data)) if (relationNames.has(fieldName) && isRelationMutationCallback(value)) return true;
|
|
1215
|
+
return false;
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Guards that the executor supports multi-store transactions.
|
|
1219
|
+
* Throws a clear error if `transaction()` is not available — the user must
|
|
1220
|
+
* use IdbRuntime (createIdbRuntime / createAutoMigratingIdbClient) rather than
|
|
1221
|
+
* a plain IdbQueryExecutor stub.
|
|
1222
|
+
*/
|
|
1223
|
+
function requireTransactionExecutor(executor) {
|
|
1224
|
+
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.");
|
|
1225
|
+
return executor;
|
|
1226
|
+
}
|
|
1227
|
+
/** Create a parent and its nested related rows in one transaction. */
|
|
1228
|
+
async function executeNestedCreateMutation(options) {
|
|
1229
|
+
const { executor, contract, modelName, data } = options;
|
|
1230
|
+
const record = data;
|
|
1231
|
+
const storeNames = collectStoreNames(contract, modelName, record, "create");
|
|
1232
|
+
const defaultsCache = createMutationDefaultsCache();
|
|
1233
|
+
return withMutationScope(executor, storeNames, (scope) => createGraph(scope, contract, modelName, record, defaultsCache));
|
|
1234
|
+
}
|
|
1235
|
+
/** Update the first filtered parent and its nested relations in one transaction. */
|
|
1236
|
+
async function executeNestedUpdateMutation(options) {
|
|
1237
|
+
const { executor, contract, modelName, filters, data } = options;
|
|
1238
|
+
const record = data;
|
|
1239
|
+
const storeNames = collectStoreNames(contract, modelName, record, "update");
|
|
1240
|
+
const defaultsCache = createMutationDefaultsCache();
|
|
1241
|
+
return withMutationScope(executor, storeNames, (scope) => updateFirstGraph(scope, contract, modelName, filters, record, defaultsCache));
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Every store a nested write may touch: the model's own store, each related
|
|
1245
|
+
* store a relation callback writes to, and the parent stores that foreign-key
|
|
1246
|
+
* checks read, for the model and for each related model. An update also
|
|
1247
|
+
* declares the stores its `onUpdate` referential actions may touch.
|
|
1248
|
+
*
|
|
1249
|
+
* Parent stores are declared for every foreign key, not only the ones `data`
|
|
1250
|
+
* sets, because a nested write can set foreign keys through `connect()` or
|
|
1251
|
+
* through a default.
|
|
1252
|
+
*/
|
|
1253
|
+
function collectStoreNames(contract, modelName, data, kind) {
|
|
1254
|
+
const stores = /* @__PURE__ */ new Set([getStoreName(contract, modelName), ...fkParentStoreNames(contract, modelName)]);
|
|
1255
|
+
const scalarData = {};
|
|
1256
|
+
const relationNames = /* @__PURE__ */ new Set();
|
|
1257
|
+
for (const def of getRelationDefinitions(contract, modelName)) {
|
|
1258
|
+
relationNames.add(def.relationName);
|
|
1259
|
+
if (def.relationName in data && isRelationMutationCallback(data[def.relationName])) {
|
|
1260
|
+
stores.add(def.relatedStoreName);
|
|
1261
|
+
for (const store of fkParentStoreNames(contract, def.relatedModelName)) stores.add(store);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (kind === "update") {
|
|
1265
|
+
for (const [field, value] of Object.entries(data)) if (!relationNames.has(field)) scalarData[field] = value;
|
|
1266
|
+
const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, getStoreName(contract, modelName), scalarData, createMutationDefaultsCache());
|
|
1267
|
+
for (const store of collectOnUpdateEnforcementStoreNames(contract, modelName, patch).storeNames) stores.add(store);
|
|
1268
|
+
}
|
|
1269
|
+
return [...stores];
|
|
1270
|
+
}
|
|
1271
|
+
/** The stores of every parent `modelName` has a foreign key to. */
|
|
1272
|
+
function fkParentStoreNames(contract, modelName) {
|
|
1273
|
+
return getRelationDefinitions(contract, modelName).filter((def) => def.cardinality === "N:1").map((def) => def.relatedStoreName);
|
|
1274
|
+
}
|
|
1275
|
+
/** Resolve parent-owned references before inserting the parent, then write its children. */
|
|
1276
|
+
async function createGraph(scope, contract, modelName, input, defaultsCache) {
|
|
1277
|
+
const parsed = parseMutationInput(contract, modelName, input);
|
|
1278
|
+
const { parentOwned, childOwned } = partitionByOwnership(parsed.relationMutations);
|
|
1279
|
+
const scalarData = { ...parsed.scalarData };
|
|
1280
|
+
for (const item of parentOwned) {
|
|
1281
|
+
if (item.mutation.kind === "disconnect") throw new Error("disconnect() is only supported in update() nested mutations");
|
|
1282
|
+
await applyParentOwnedMutation(scope, contract, scalarData, item.relation, item.mutation, defaultsCache);
|
|
1283
|
+
}
|
|
1284
|
+
const parentRow = await insertSingleRow(scope, contract, modelName, scalarData, defaultsCache);
|
|
1285
|
+
for (const item of childOwned) {
|
|
1286
|
+
if (item.mutation.kind === "disconnect") throw new Error("disconnect() is only supported in update() nested mutations");
|
|
1287
|
+
await applyChildOwnedMutation(scope, contract, modelName, parentRow, item.relation, item.mutation, defaultsCache);
|
|
1288
|
+
}
|
|
1289
|
+
return parentRow;
|
|
1290
|
+
}
|
|
1291
|
+
/** Read the parent before resolving relation mutations and validating its scalar patch. */
|
|
1292
|
+
async function updateFirstGraph(scope, contract, modelName, filters, input, defaultsCache) {
|
|
1293
|
+
const existingRow = await findFirstByFilters(scope, contract, modelName, filters);
|
|
1294
|
+
if (!existingRow) return null;
|
|
1295
|
+
const parsed = parseMutationInput(contract, modelName, input);
|
|
1296
|
+
const { parentOwned, childOwned } = partitionByOwnership(parsed.relationMutations);
|
|
1297
|
+
const scalarData = { ...parsed.scalarData };
|
|
1298
|
+
for (const item of parentOwned) await applyParentOwnedMutation(scope, contract, scalarData, item.relation, item.mutation, defaultsCache);
|
|
1299
|
+
let parentRow = existingRow;
|
|
1300
|
+
if (Object.keys(scalarData).length > 0) {
|
|
1301
|
+
const storeName = getStoreName(contract, modelName);
|
|
1302
|
+
const key = extractKeyFromRow(existingRow, getKeyPath(contract, modelName));
|
|
1303
|
+
const meta = makePlanMeta(contract);
|
|
1304
|
+
const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, storeName, scalarData, defaultsCache);
|
|
1305
|
+
assertEnumValues(contract, modelName, patch);
|
|
1306
|
+
assertValidRecord(contract, modelName, "update", patch);
|
|
1307
|
+
await validateScalarFks(scope, contract, modelName, patch, existingRow);
|
|
1308
|
+
await applyReferentialActionsForRowOnUpdate(scope, contract, modelName, existingRow, patch);
|
|
1309
|
+
const updated = (await scope.execute({
|
|
1310
|
+
meta,
|
|
1311
|
+
kind: "update",
|
|
1312
|
+
storeName,
|
|
1313
|
+
key,
|
|
1314
|
+
patch
|
|
1315
|
+
}))[0];
|
|
1316
|
+
if (updated) parentRow = absentAsNull(contract, modelName)(updated);
|
|
1317
|
+
}
|
|
1318
|
+
for (const item of childOwned) await applyChildOwnedMutation(scope, contract, modelName, parentRow, item.relation, item.mutation, defaultsCache);
|
|
1319
|
+
return parentRow;
|
|
1320
|
+
}
|
|
1321
|
+
/** Separate scalar values from validated relation callback results. */
|
|
1322
|
+
function parseMutationInput(contract, modelName, input) {
|
|
1323
|
+
const scalarData = {};
|
|
1324
|
+
const relationDefs = new Map(getRelationDefinitions(contract, modelName).map((r) => [r.relationName, r]));
|
|
1325
|
+
const relationMutations = [];
|
|
1326
|
+
for (const [fieldName, value] of Object.entries(input)) {
|
|
1327
|
+
const relation = relationDefs.get(fieldName);
|
|
1328
|
+
if (!relation) {
|
|
1329
|
+
scalarData[fieldName] = value;
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
if (!isRelationMutationCallback(value)) throw new Error(`Relation field "${fieldName}" on model "${modelName}" expects a mutator callback`);
|
|
1333
|
+
const mutation = value(createRelationMutator());
|
|
1334
|
+
if (!isRelationMutationDescriptor(mutation)) throw new Error(`Relation field "${fieldName}" on model "${modelName}" returned an invalid mutation descriptor`);
|
|
1335
|
+
relationMutations.push({
|
|
1336
|
+
relation,
|
|
1337
|
+
mutation
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
return {
|
|
1341
|
+
scalarData,
|
|
1342
|
+
relationMutations
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
/** Separate relations whose foreign keys belong to the parent from those owned by children. */
|
|
1346
|
+
function partitionByOwnership(mutations) {
|
|
1347
|
+
const parentOwned = [];
|
|
1348
|
+
const childOwned = [];
|
|
1349
|
+
for (const item of mutations) {
|
|
1350
|
+
if (item.relation.cardinality === "N:1") {
|
|
1351
|
+
parentOwned.push(item);
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
if (item.relation.cardinality === "M:N") throw new Error("M:N nested mutations are not supported");
|
|
1355
|
+
childOwned.push(item);
|
|
1356
|
+
}
|
|
1357
|
+
return {
|
|
1358
|
+
parentOwned,
|
|
1359
|
+
childOwned
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
/** Resolve a to-one mutation into scalar foreign-key values on the parent. */
|
|
1363
|
+
async function applyParentOwnedMutation(scope, contract, scalarData, relation, mutation, defaultsCache) {
|
|
1364
|
+
if (mutation.kind === "disconnect") {
|
|
1365
|
+
for (const localField of relation.localFields) scalarData[localField] = null;
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
if (mutation.kind === "create") {
|
|
1369
|
+
const row = mutation.data[0];
|
|
1370
|
+
if (!row) throw new Error(`create() nested mutation for relation "${relation.relationName}" requires data`);
|
|
1371
|
+
copyRelatedValuesToParent(relation, scalarData, await insertSingleRow(scope, contract, relation.relatedModelName, row, defaultsCache));
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
const criterion = mutation.criteria[0];
|
|
1375
|
+
if (!criterion) throw new Error(`connect() nested mutation for relation "${relation.relationName}" requires a criterion`);
|
|
1376
|
+
const relatedRow = await findRowByCriterion(scope, contract, relation.relatedModelName, criterion);
|
|
1377
|
+
if (!relatedRow) throw new Error(`connect() nested mutation for relation "${relation.relationName}" did not find a matching row`);
|
|
1378
|
+
copyRelatedValuesToParent(relation, scalarData, relatedRow);
|
|
1379
|
+
}
|
|
1380
|
+
/** Copy each referenced value into the matching local foreign-key field. */
|
|
1381
|
+
function copyRelatedValuesToParent(relation, scalarData, relatedRow) {
|
|
1382
|
+
for (let i = 0; i < relation.localFields.length; i++) {
|
|
1383
|
+
const localField = relation.localFields[i];
|
|
1384
|
+
const targetField = relation.targetFields[i];
|
|
1385
|
+
if (!localField || !targetField) continue;
|
|
1386
|
+
scalarData[localField] = relatedRow[targetField];
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
/** Create, connect, or disconnect children using the persisted parent join values. */
|
|
1390
|
+
async function applyChildOwnedMutation(scope, contract, parentModelName, parentRow, relation, mutation, defaultsCache) {
|
|
1391
|
+
const parentValues = readParentColumnValues(parentModelName, relation, parentRow);
|
|
1392
|
+
if (mutation.kind === "create") {
|
|
1393
|
+
for (const childInput of mutation.data) {
|
|
1394
|
+
const payload = { ...childInput };
|
|
1395
|
+
for (const [childField, parentValue] of parentValues.entries()) payload[childField] = parentValue;
|
|
1396
|
+
await insertSingleRow(scope, contract, relation.relatedModelName, payload, defaultsCache);
|
|
1397
|
+
}
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
if (mutation.kind === "connect") {
|
|
1401
|
+
for (const criterion of mutation.criteria) {
|
|
1402
|
+
const setValues = {};
|
|
1403
|
+
for (const [childField, parentValue] of parentValues.entries()) setValues[childField] = parentValue;
|
|
1404
|
+
const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, relation.relatedStoreName, setValues, defaultsCache);
|
|
1405
|
+
const where = shorthandToFilterExpr(criterion);
|
|
1406
|
+
await writeMutationRows(scope, contract, relation.relatedModelName, {
|
|
1407
|
+
write: "put-merged",
|
|
1408
|
+
patch,
|
|
1409
|
+
...where === void 0 ? {} : { where }
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
const setValues = {};
|
|
1415
|
+
for (const childField of parentValues.keys()) setValues[childField] = null;
|
|
1416
|
+
if (!mutation.criteria || mutation.criteria.length === 0) {
|
|
1417
|
+
const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, relation.relatedStoreName, setValues, defaultsCache);
|
|
1418
|
+
const parentJoinWhere = buildParentJoinWhere(parentValues);
|
|
1419
|
+
await writeMutationRows(scope, contract, relation.relatedModelName, {
|
|
1420
|
+
write: "put-merged",
|
|
1421
|
+
patch,
|
|
1422
|
+
where: parentJoinWhere
|
|
1423
|
+
});
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
for (const criterion of mutation.criteria) {
|
|
1427
|
+
const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, relation.relatedStoreName, setValues, defaultsCache);
|
|
1428
|
+
const criterionWhere = shorthandToFilterExpr(criterion);
|
|
1429
|
+
const parentJoinWhere = buildParentJoinWhere(parentValues);
|
|
1430
|
+
const where = criterionWhere === void 0 ? parentJoinWhere : andExpr([parentJoinWhere, criterionWhere]);
|
|
1431
|
+
await writeMutationRows(scope, contract, relation.relatedModelName, {
|
|
1432
|
+
write: "put-merged",
|
|
1433
|
+
patch,
|
|
1434
|
+
where
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
/** Resolve parent join values before writing children; missing values cannot form a relation. */
|
|
1439
|
+
function readParentColumnValues(parentModelName, relation, parentRow) {
|
|
1440
|
+
const values = /* @__PURE__ */ new Map();
|
|
1441
|
+
for (let i = 0; i < relation.localFields.length; i++) {
|
|
1442
|
+
const localField = relation.localFields[i];
|
|
1443
|
+
const targetField = relation.targetFields[i];
|
|
1444
|
+
if (!localField || !targetField) continue;
|
|
1445
|
+
const parentValue = parentRow[localField];
|
|
1446
|
+
if (parentValue === void 0) throw new Error(`Nested mutation requires parent field "${localField}" to be present in "${parentModelName}" row`);
|
|
1447
|
+
values.set(targetField, parentValue);
|
|
1448
|
+
}
|
|
1449
|
+
return values;
|
|
1450
|
+
}
|
|
1451
|
+
/** Complete and validate a nested record, then insert it with foreign-key checks. */
|
|
1452
|
+
async function insertSingleRow(scope, contract, modelName, data, defaultsCache) {
|
|
1453
|
+
assertNoNestedCallbacks(modelName, data);
|
|
1454
|
+
const storeName = getStoreName(contract, modelName);
|
|
1455
|
+
const meta = makePlanMeta(contract);
|
|
1456
|
+
const record = completeCreateRecord(contract, modelName, data, defaultsCache);
|
|
1457
|
+
assertEnumValues(contract, modelName, record);
|
|
1458
|
+
assertValidRecord(contract, modelName, "create", record);
|
|
1459
|
+
await validateScalarFks(scope, contract, modelName, record);
|
|
1460
|
+
return (await scope.execute({
|
|
1461
|
+
meta,
|
|
1462
|
+
kind: "add",
|
|
1463
|
+
storeName,
|
|
1464
|
+
record
|
|
1465
|
+
}))[0] ?? record;
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Recursive nesting (a relation callback inside an already-nested create) is
|
|
1469
|
+
* not supported. Without this guard the callback function would be
|
|
1470
|
+
* handed to `store.put(...)`, where IDB's structured-clone throws an opaque
|
|
1471
|
+
* `DataCloneError` ("could not be cloned") that gives the developer no hint
|
|
1472
|
+
* about the real cause. Surface a precise error instead.
|
|
1473
|
+
*/
|
|
1474
|
+
function assertNoNestedCallbacks(modelName, data) {
|
|
1475
|
+
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.`);
|
|
1476
|
+
}
|
|
1477
|
+
/** Require a non-empty connect criterion and return its first matching row. */
|
|
1478
|
+
async function findRowByCriterion(scope, contract, modelName, criterion) {
|
|
1479
|
+
const expr = shorthandToFilterExpr(criterion);
|
|
1480
|
+
if (!expr) throw new Error(`Nested connect for model "${modelName}" requires a non-empty criterion`);
|
|
1481
|
+
return (await readMutationRows(scope, contract, modelName, expr, 1))[0] ?? null;
|
|
1482
|
+
}
|
|
1483
|
+
/** Find the first parent selected by the nested-update filters. */
|
|
1484
|
+
async function findFirstByFilters(scope, contract, modelName, filters) {
|
|
1485
|
+
if (filters.length === 0) return null;
|
|
1486
|
+
return (await readMutationRows(scope, contract, modelName, andExpr(filters), 1))[0] ?? null;
|
|
1487
|
+
}
|
|
1488
|
+
/** Execute lowered atomic operations without leaving the transaction's microtask chain. */
|
|
1489
|
+
async function executeMutationPlan(scope, plan) {
|
|
1490
|
+
const plans = plan.kind === "batch" ? plan.ops : [plan];
|
|
1491
|
+
const rows = [];
|
|
1492
|
+
for (const op of plans) rows.push(...await scope.execute(op));
|
|
1493
|
+
return rows;
|
|
1494
|
+
}
|
|
1495
|
+
/** Read mutation preimages through the planner, then reapply the original filter. */
|
|
1496
|
+
async function readMutationRows(scope, contract, modelName, where, take) {
|
|
1497
|
+
const catalog = buildCatalog(contract, getStoreName(contract, modelName), modelName);
|
|
1498
|
+
const request = {
|
|
1499
|
+
...where === void 0 ? {} : { where },
|
|
1500
|
+
...take === void 0 ? {} : { take }
|
|
1501
|
+
};
|
|
1502
|
+
const lowered = lowerRows(catalog, planQuery(catalog, request), makePlanMeta(contract), request);
|
|
1503
|
+
return lowered.finish(await executeMutationPlan(scope, lowered.idbPlan));
|
|
1504
|
+
}
|
|
1505
|
+
/** Write planned matches, collecting preimages first when changing the walked index key. */
|
|
1506
|
+
async function writeMutationRows(scope, contract, modelName, request) {
|
|
1507
|
+
if (request.write !== "delete") assertValidRecord(contract, modelName, "update", request.patch ?? {});
|
|
1508
|
+
const catalog = buildCatalog(contract, getStoreName(contract, modelName), modelName);
|
|
1509
|
+
const meta = makePlanMeta(contract);
|
|
1510
|
+
const lowered = lowerWrite(catalog, planQuery(catalog, request), meta, request);
|
|
1511
|
+
if (lowered.kind === "scan") return executeMutationPlan(scope, lowered.idbPlan);
|
|
1512
|
+
const oldRows = lowered.rows.finish(await executeMutationPlan(scope, lowered.rows.idbPlan));
|
|
1513
|
+
const results = [];
|
|
1514
|
+
for (const row of oldRows) {
|
|
1515
|
+
const key = extractKeyFromRow(row, catalog.primaryKey.keyPath);
|
|
1516
|
+
const rows = await scope.execute({
|
|
1517
|
+
meta,
|
|
1518
|
+
storeName: catalog.storeName,
|
|
1519
|
+
...request.write === "delete" ? {
|
|
1520
|
+
kind: "delete",
|
|
1521
|
+
key
|
|
1522
|
+
} : {
|
|
1523
|
+
kind: "update",
|
|
1524
|
+
key,
|
|
1525
|
+
patch: request.patch ?? {}
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
results.push(...rows);
|
|
1529
|
+
}
|
|
1530
|
+
return results;
|
|
1531
|
+
}
|
|
1532
|
+
function buildParentJoinWhere(parentValues) {
|
|
1533
|
+
return andExpr([...parentValues].map(([field, value]) => fieldFilter(field, "eq", value)));
|
|
1534
|
+
}
|
|
1535
|
+
/** Read an explicitly declared referential action from model storage metadata. */
|
|
1536
|
+
function getStoredAction(contract, modelName, relationName, kind) {
|
|
1537
|
+
return (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.relations?.[relationName]?.[kind];
|
|
1538
|
+
}
|
|
1539
|
+
function sameFields(a, b) {
|
|
1540
|
+
return a.length === b.length && a.every((field, index) => field === b[index]);
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Resolves the referential action for a child-enforcement relation: prefers
|
|
1544
|
+
* the action stored directly on this side, falls back to the same action
|
|
1545
|
+
* stored on the inverse (FK-owning) side of the relation, and finally to
|
|
1546
|
+
* `defaultAction` when neither side declares one.
|
|
1547
|
+
*
|
|
1548
|
+
* Both branches are live, for different authoring paths. PSL only ever
|
|
1549
|
+
* stores the action on the `N:1` (FK-owning) side — enforcement always walks
|
|
1550
|
+
* from the parent (`1:N`/non-owning-`1:1`) side, which PSL leaves bare, so
|
|
1551
|
+
* the fallback is what actually finds it. TS-DSL relation entries are
|
|
1552
|
+
* self-contained (no PSL-style pairing requirement) and typically declare
|
|
1553
|
+
* the action directly on the side this function is called for, so the direct
|
|
1554
|
+
* check is what fires there — see `contract-builder.ts`'s
|
|
1555
|
+
* `validateNoConflictingRelationActions`, which rejects declaring the same
|
|
1556
|
+
* action kind on both sides so this fallback never has to arbitrate a real
|
|
1557
|
+
* conflict.
|
|
1558
|
+
*/
|
|
1559
|
+
function getReferentialActionForRelation(contract, modelName, def, kind) {
|
|
1560
|
+
return enforcedAction(findDeclaredAction(contract, modelName, def, kind));
|
|
1561
|
+
}
|
|
1562
|
+
/** Find an action on the enforcement side or on its matching inverse relation. */
|
|
1563
|
+
function findDeclaredAction(contract, modelName, def, kind) {
|
|
1564
|
+
const direct = getStoredAction(contract, modelName, def.relationName, kind);
|
|
1565
|
+
if (direct !== void 0) return direct;
|
|
1566
|
+
for (const inverse of getRelationDefinitions(contract, def.relatedModelName)) {
|
|
1567
|
+
if (inverse.relatedModelName !== modelName) continue;
|
|
1568
|
+
if (!sameFields(inverse.localFields, def.targetFields)) continue;
|
|
1569
|
+
if (!sameFields(inverse.targetFields, def.localFields)) continue;
|
|
1570
|
+
const inverseAction = getStoredAction(contract, def.relatedModelName, inverse.relationName, kind);
|
|
1571
|
+
if (inverseAction !== void 0) return inverseAction;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Applies the default and resolves `noAction`, matching what Postgres does
|
|
1576
|
+
* with the same schema, so a synced app behaves the same on both sides:
|
|
1577
|
+
*
|
|
1578
|
+
* - An undeclared action defaults to `restrict`, for `onDelete` and
|
|
1579
|
+
* `onUpdate` alike. Prisma 8 emits no `ON DELETE`/`ON UPDATE` clause for an
|
|
1580
|
+
* undeclared action, and the database default is `NO ACTION`.
|
|
1581
|
+
* - `noAction` behaves like `restrict`. In SQL, `NO ACTION` also rejects the
|
|
1582
|
+
* change; it only defers the check to the end of the statement, which makes
|
|
1583
|
+
* no difference here.
|
|
1584
|
+
*/
|
|
1585
|
+
function enforcedAction(declared) {
|
|
1586
|
+
return declared === void 0 || declared === "noAction" ? "restrict" : declared;
|
|
1587
|
+
}
|
|
1588
|
+
/** Identify relations that need child checks when this model changes. */
|
|
1589
|
+
function isChildEnforcementRelation(contract, modelName, def) {
|
|
1590
|
+
if (def.cardinality === "1:N") return true;
|
|
1591
|
+
if (def.cardinality === "1:1") {
|
|
1592
|
+
const keyPath = getKeyPath(contract, modelName);
|
|
1593
|
+
return sameFields(def.localFields, keyPathFields(keyPath));
|
|
1594
|
+
}
|
|
1595
|
+
return false;
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* `true` if any child row matches the relation against `parentRow`'s values —
|
|
1599
|
+
* the existence check behind `restrict`. A null value references no child.
|
|
1600
|
+
*/
|
|
1601
|
+
async function childExists(scope, contract, meta, def, parentRow) {
|
|
1602
|
+
const values = def.localFields.map((field) => parentRow[field]);
|
|
1603
|
+
if (values.some((value) => value === null || value === void 0)) return false;
|
|
1604
|
+
return rowExists(scope, contract, meta, def.relatedModelName, def.targetFields, values);
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Does `modelName` have a row whose `fields` equal `values`, pairwise?
|
|
1608
|
+
* Compares values the way IndexedDB compares keys. `additionalFilter` narrows
|
|
1609
|
+
* the match in memory; it makes the check read rows instead of keys.
|
|
1610
|
+
*/
|
|
1611
|
+
async function rowExists(scope, contract, meta, modelName, fields, values, additionalFilter) {
|
|
1612
|
+
const where = andExpr(fields.map((field, i) => fieldFilter(field, "eq", values[i])));
|
|
1613
|
+
const catalog = buildCatalog(contract, getStoreName(contract, modelName), modelName);
|
|
1614
|
+
const lowered = lowerExists(catalog, planQuery(catalog, { where }), meta, { where }, additionalFilter);
|
|
1615
|
+
return lowered.finish(await executeMutationPlan(scope, lowered.idbPlan));
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Builds a filter matching a relation's children against one specific parent
|
|
1619
|
+
* row's values. Shared by `onDelete` cascade (`applyReferentialActionsForRow`)
|
|
1620
|
+
* and `onUpdate` cascade (`applyReferentialActionsForRowOnUpdate`).
|
|
1621
|
+
*/
|
|
1622
|
+
function buildChildWhereFromRow(def, row) {
|
|
1623
|
+
return andExpr(def.targetFields.map((field, i) => fieldFilter(field, "eq", row[def.localFields[i]])));
|
|
1624
|
+
}
|
|
1625
|
+
/** Reads a field's literal `@default(...)` value from `IdbModelStorage.fieldDefaults`, if declared. */
|
|
1626
|
+
function getFieldDefault(contract, modelName, fieldName) {
|
|
1627
|
+
return (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.fieldDefaults?.[fieldName];
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Builds the patch for a `setDefault` referential action: each of the
|
|
1631
|
+
* relation's `targetFields` (the child's FK fields) reset to its own
|
|
1632
|
+
* declared literal default — not the parent's. Throws when any target field
|
|
1633
|
+
* has no declared default; `setDefault` is only meaningful when every FK
|
|
1634
|
+
* field it resets has somewhere to reset to.
|
|
1635
|
+
*/
|
|
1636
|
+
function buildSetDefaultPatch(contract, def) {
|
|
1637
|
+
const patch = {};
|
|
1638
|
+
for (const targetField of def.targetFields) {
|
|
1639
|
+
const value = getFieldDefault(contract, def.relatedModelName, targetField);
|
|
1640
|
+
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.`);
|
|
1641
|
+
patch[targetField] = value;
|
|
1642
|
+
}
|
|
1643
|
+
return patch;
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Validates that a `setDefault` patch actually references a real row before
|
|
1647
|
+
* it's written — without this, `setDefault` would silently reintroduce the
|
|
1648
|
+
* exact dangling-FK problem the rest of this file exists to prevent. A real
|
|
1649
|
+
* SQL database only makes `SET DEFAULT` safe because its FK constraint
|
|
1650
|
+
* re-checks the new value transactionally at write time; IDB has no such
|
|
1651
|
+
* engine to delegate to, so this check is IDB's equivalent.
|
|
1652
|
+
*
|
|
1653
|
+
* Checks against `modelName`'s own store (the parent being deleted/updated)
|
|
1654
|
+
* using `def.localFields` — the relation's own semantics ("child.targetField
|
|
1655
|
+
* references parent.localField") mean that's exactly what a default value
|
|
1656
|
+
* written to the child's FK field must match. That store is always already
|
|
1657
|
+
* part of the transaction (it's the model literally being written), so no
|
|
1658
|
+
* store-list changes are needed to call this.
|
|
1659
|
+
*
|
|
1660
|
+
* A compound relation is checked as one tuple: a single parent row must
|
|
1661
|
+
* match every field's default.
|
|
1662
|
+
*
|
|
1663
|
+
* `excludeKey`, when given, excludes the parent row currently being
|
|
1664
|
+
* deleted/updated from the existence scan. That row is still physically
|
|
1665
|
+
* present in the store at this point (its own write/delete hasn't happened
|
|
1666
|
+
* yet — this check runs first, before the enforcement loop's caller applies
|
|
1667
|
+
* it), so without the exclusion, a default value equal to that row's *old*
|
|
1668
|
+
* `localField` value would false-positive: the scan would find the row about
|
|
1669
|
+
* to disappear (or change away from that value) and wrongly conclude the
|
|
1670
|
+
* default references a real row, when after the transaction nothing will.
|
|
1671
|
+
*/
|
|
1672
|
+
async function validateSetDefaultPatch(scope, contract, modelName, def, patch, excludeKey) {
|
|
1673
|
+
const values = def.targetFields.map((f) => patch[f]);
|
|
1674
|
+
if (values.some((v) => v === null || v === void 0)) return;
|
|
1675
|
+
if (!await parentExists(scope, contract, makePlanMeta(contract), modelName, def.localFields, values, excludeKey)) throw new Error(`setDefault referential action on relation '${def.relationName}' would set ${def.relatedModelName} to ${describeTuple(def.targetFields, values)}, but no ${modelName} with ${describeTuple(def.localFields, values)} exists — the declared default does not reference a real row.`);
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Collects every store an `onUpdate`-enforced write to `modelName` might
|
|
1679
|
+
* touch: the model's own store, plus the related store of any 1:N/1:1-
|
|
1680
|
+
* parent-side relation whose `localFields` appear in the raw patch —
|
|
1681
|
+
* pessimistic (checks field *presence*, not whether the resolved value will
|
|
1682
|
+
* actually change, since the current row isn't read yet when this runs).
|
|
1683
|
+
* Once a `cascade` edge is entered, the walk continues transitively (mirrors
|
|
1684
|
+
* `collectDeleteStoreNames`'s cascade-only recursion), since a propagated
|
|
1685
|
+
* value change may itself need to cascade further.
|
|
1686
|
+
*
|
|
1687
|
+
* Returns `enforces: false` when no `onUpdate` enforcement can apply to this
|
|
1688
|
+
* write — callers use that to keep the existing fast blind-write path
|
|
1689
|
+
* instead of paying for a read-before-write. This is tracked as an explicit
|
|
1690
|
+
* flag rather than inferred from `storeNames.length > 1`: for a
|
|
1691
|
+
* self-referential relation, `def.relatedStoreName` equals `modelName`'s own
|
|
1692
|
+
* store (already the seed of the `Set`), so the store count alone can't tell
|
|
1693
|
+
* "no enforcement" apart from "enforces, but only against this same store".
|
|
1694
|
+
*/
|
|
1695
|
+
function collectOnUpdateEnforcementStoreNames(contract, modelName, data) {
|
|
1696
|
+
const stores = /* @__PURE__ */ new Set([getStoreName(contract, modelName)]);
|
|
1697
|
+
const visitedModels = /* @__PURE__ */ new Set([modelName]);
|
|
1698
|
+
let enforces = false;
|
|
1699
|
+
for (const def of getRelationDefinitions(contract, modelName)) {
|
|
1700
|
+
if (!isChildEnforcementRelation(contract, modelName, def)) continue;
|
|
1701
|
+
if (!def.localFields.some((f) => f in data)) continue;
|
|
1702
|
+
const action = getReferentialActionForRelation(contract, modelName, def, "onUpdate");
|
|
1703
|
+
enforces = true;
|
|
1704
|
+
stores.add(def.relatedStoreName);
|
|
1705
|
+
if (action === "cascade") addOnUpdateCascadeStores(contract, def.relatedModelName, stores, visitedModels);
|
|
1706
|
+
}
|
|
1707
|
+
return {
|
|
1708
|
+
storeNames: [...stores],
|
|
1709
|
+
enforces
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Enforces every `onUpdate` action declared on `modelName`'s child relations
|
|
1714
|
+
* against one specific `oldRow` about to be patched with `patch`. Only
|
|
1715
|
+
* relations whose `localFields` are both present in `patch` and differ from
|
|
1716
|
+
* `oldRow`'s current value are enforced — a patch that sets a field to its
|
|
1717
|
+
* existing value, or never touches a locally-referenced field, is a no-op
|
|
1718
|
+
* here. Children are matched against `oldRow`'s pre-change values.
|
|
1719
|
+
*
|
|
1720
|
+
* `cascade` propagates the new value(s) onto the matched children's FK
|
|
1721
|
+
* fields and recurses into each child's own `onUpdate` relations before
|
|
1722
|
+
* writing (the propagated change may itself need to cascade further) —
|
|
1723
|
+
* mirrors `applyReferentialActionsForRow`'s recursive shape, including the
|
|
1724
|
+
* `visited` row-level cycle guard (see that function's doc comment for the
|
|
1725
|
+
* fresh-per-top-level-row / shared-across-one-row's-descent contract).
|
|
1726
|
+
* `setNull`/`setDefault` are leaf actions: the child's FK field changes but
|
|
1727
|
+
* its own key doesn't, so nothing below it needs re-enforcement.
|
|
1728
|
+
*
|
|
1729
|
+
* Compound relations use values from the same `oldRow`/`patch`, so every
|
|
1730
|
+
* child is matched against the full pre-change tuple.
|
|
1731
|
+
*/
|
|
1732
|
+
async function applyReferentialActionsForRowOnUpdate(scope, contract, modelName, oldRow, patch, visited = /* @__PURE__ */ new Set()) {
|
|
1733
|
+
const keyPath = getKeyPath(contract, modelName);
|
|
1734
|
+
patch = preserveDecimalKeySpelling(contract, modelName, patch, extractKeyFromRow(oldRow, keyPath));
|
|
1735
|
+
const rowKey = `${getStoreName(contract, modelName)}::${keyToken$1(extractKeyFromRow(oldRow, keyPath))}`;
|
|
1736
|
+
if (visited.has(rowKey)) return;
|
|
1737
|
+
visited.add(rowKey);
|
|
1738
|
+
const meta = makePlanMeta(contract);
|
|
1739
|
+
for (const def of getRelationDefinitions(contract, modelName)) {
|
|
1740
|
+
if (!isChildEnforcementRelation(contract, modelName, def)) continue;
|
|
1741
|
+
const changedFields = def.localFields.filter((f) => f in patch && !fieldValuesEqual(patch[f], oldRow[f]));
|
|
1742
|
+
if (changedFields.length === 0) continue;
|
|
1743
|
+
const action = getReferentialActionForRelation(contract, modelName, def, "onUpdate");
|
|
1744
|
+
const childWhere = buildChildWhereFromRow(def, oldRow);
|
|
1745
|
+
if (action === "restrict") {
|
|
1746
|
+
if (await childExists(scope, contract, meta, def, oldRow)) throw new Error(`Cannot update ${modelName} '${keyToken$1(extractKeyFromRow(oldRow, keyPath))}': changing field(s) ${changedFields.join(", ")} would orphan child records on relation '${def.relationName}'. Update or remove those children first, or declare onUpdate: Cascade, SetNull or SetDefault on the relation.`);
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
1749
|
+
if (action === "cascade") {
|
|
1750
|
+
const childPatch = {};
|
|
1751
|
+
for (let i = 0; i < def.localFields.length; i++) {
|
|
1752
|
+
const lf = def.localFields[i];
|
|
1753
|
+
const tf = def.targetFields[i];
|
|
1754
|
+
if (changedFields.includes(lf)) childPatch[tf] = patch[lf];
|
|
1755
|
+
}
|
|
1756
|
+
const children = await readMutationRows(scope, contract, def.relatedModelName, childWhere);
|
|
1757
|
+
for (const child of children) await applyReferentialActionsForRowOnUpdate(scope, contract, def.relatedModelName, child, childPatch, visited);
|
|
1758
|
+
await writeMutationRows(scope, contract, def.relatedModelName, {
|
|
1759
|
+
write: "put-merged",
|
|
1760
|
+
patch: childPatch,
|
|
1761
|
+
where: childWhere
|
|
1762
|
+
});
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
if (action === "setNull") {
|
|
1766
|
+
const childPatch = {};
|
|
1767
|
+
for (const targetField of def.targetFields) childPatch[targetField] = null;
|
|
1768
|
+
await writeMutationRows(scope, contract, def.relatedModelName, {
|
|
1769
|
+
write: "put-merged",
|
|
1770
|
+
patch: childPatch,
|
|
1771
|
+
where: childWhere
|
|
1772
|
+
});
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
if (action === "setDefault") {
|
|
1776
|
+
const childPatch = buildSetDefaultPatch(contract, def);
|
|
1777
|
+
await validateSetDefaultPatch(scope, contract, modelName, def, childPatch, extractKeyFromRow(oldRow, keyPath));
|
|
1778
|
+
await writeMutationRows(scope, contract, def.relatedModelName, {
|
|
1779
|
+
write: "put-merged",
|
|
1780
|
+
patch: childPatch,
|
|
1781
|
+
where: childWhere
|
|
1782
|
+
});
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Returns true if `data` contains at least one non-null value for a localField
|
|
1789
|
+
* of a N:1 relation — indicating scalar FK fields that need existence validation.
|
|
1790
|
+
*/
|
|
1791
|
+
function hasScalarFkFields(contract, modelName, data) {
|
|
1792
|
+
for (const def of getRelationDefinitions(contract, modelName)) {
|
|
1793
|
+
if (def.cardinality !== "N:1") continue;
|
|
1794
|
+
for (const localField of def.localFields) if (localField in data && data[localField] !== null && data[localField] !== void 0) return true;
|
|
1795
|
+
}
|
|
1796
|
+
return false;
|
|
1797
|
+
}
|
|
1798
|
+
/** Include parent stores for the non-null foreign keys supplied by this write. */
|
|
1799
|
+
function collectScalarFkStoreNames(contract, modelName, data) {
|
|
1800
|
+
const stores = /* @__PURE__ */ new Set([getStoreName(contract, modelName)]);
|
|
1801
|
+
for (const def of getRelationDefinitions(contract, modelName)) {
|
|
1802
|
+
if (def.cardinality !== "N:1") continue;
|
|
1803
|
+
if (def.localFields.some((f) => f in data && data[f] !== null && data[f] !== void 0)) stores.add(def.relatedStoreName);
|
|
1804
|
+
}
|
|
1805
|
+
return [...stores];
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Checks that every foreign key `data` sets points at an existing parent row,
|
|
1809
|
+
* inside the write's transaction.
|
|
1810
|
+
*
|
|
1811
|
+
* A compound foreign key is checked as one tuple: a single parent row must
|
|
1812
|
+
* match every field. Checking the fields one at a time could pass with each
|
|
1813
|
+
* value taken from a different parent. When `data` sets only some fields of a
|
|
1814
|
+
* compound key, the rest come from `existingRow`, the row being updated.
|
|
1815
|
+
* Callers must pass it in that case; see {@link fkCheckNeedsExistingRow}.
|
|
1816
|
+
*
|
|
1817
|
+
* A key with any `null` field isn't checked, like SQL's default `MATCH SIMPLE`.
|
|
1818
|
+
*/
|
|
1819
|
+
async function validateScalarFks(scope, contract, modelName, data, existingRow) {
|
|
1820
|
+
const meta = makePlanMeta(contract);
|
|
1821
|
+
for (const def of getRelationDefinitions(contract, modelName)) {
|
|
1822
|
+
if (def.cardinality !== "N:1") continue;
|
|
1823
|
+
if (!def.localFields.some((f) => f in data)) continue;
|
|
1824
|
+
const values = def.localFields.map((f) => f in data ? data[f] : existingRow?.[f]);
|
|
1825
|
+
if (values.some((v) => v === null || v === void 0)) continue;
|
|
1826
|
+
if (!await parentExists(scope, contract, meta, def.relatedModelName, def.targetFields, values)) throw new Error(`FK violation on relation '${def.relationName}': no ${def.relatedModelName} with ${describeTuple(def.targetFields, values)}`);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* `true` when `data` sets some, but not all, fields of a compound foreign
|
|
1831
|
+
* key. Checking it then needs the rest of the key from the row being
|
|
1832
|
+
* updated, so the update has to read each row before writing it.
|
|
1833
|
+
*/
|
|
1834
|
+
function fkCheckNeedsExistingRow(contract, modelName, data) {
|
|
1835
|
+
return getRelationDefinitions(contract, modelName).some((def) => {
|
|
1836
|
+
if (def.cardinality !== "N:1") return false;
|
|
1837
|
+
const set = def.localFields.filter((f) => f in data).length;
|
|
1838
|
+
return set > 0 && set < def.localFields.length;
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Does `parentModel` have a row whose `fields` equal `values`, pairwise?
|
|
1843
|
+
*
|
|
1844
|
+
* `excludeKey` leaves out one parent row, for checks that run while that row
|
|
1845
|
+
* is being deleted or changed.
|
|
1846
|
+
*/
|
|
1847
|
+
async function parentExists(scope, contract, meta, parentModel, fields, values, excludeKey) {
|
|
1848
|
+
const keyPath = getKeyPath(contract, parentModel);
|
|
1849
|
+
return rowExists(scope, contract, meta, parentModel, fields, values, excludeKey === void 0 ? void 0 : (row) => !keyEquals$1(extractKeyFromRow(row, keyPath), excludeKey));
|
|
1850
|
+
}
|
|
1851
|
+
/** `id='u1'`, or `orgId='a', id='u1'` for a compound key. */
|
|
1852
|
+
function describeTuple(fields, values) {
|
|
1853
|
+
return fields.map((f, i) => `${f}='${String(values[i])}'`).join(", ");
|
|
1854
|
+
}
|
|
1855
|
+
/** Create one completed record and check its foreign keys in the same transaction. */
|
|
1856
|
+
async function executeScalarCreateWithFkValidation(options) {
|
|
1857
|
+
const { executor, contract, modelName, data } = options;
|
|
1858
|
+
const defaultsCache = createMutationDefaultsCache();
|
|
1859
|
+
const record = completeCreateRecord(contract, modelName, data, defaultsCache);
|
|
1860
|
+
return withMutationScope(executor, collectScalarFkStoreNames(contract, modelName, record), (scope) => insertSingleRow(scope, contract, modelName, record, defaultsCache));
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1863
|
+
* `createAll()` for rows that set foreign keys: checks every row's foreign
|
|
1864
|
+
* keys and inserts it, all in one transaction, so one bad row writes nothing.
|
|
1865
|
+
* The batch shares one defaults cache, like the plain `createAll()` path.
|
|
1866
|
+
*/
|
|
1867
|
+
async function executeScalarCreateAllWithFkValidation(options) {
|
|
1868
|
+
const { executor, contract, modelName, data } = options;
|
|
1869
|
+
const defaultsCache = createMutationDefaultsCache();
|
|
1870
|
+
const records = data.map((row) => completeCreateRecord(contract, modelName, row, defaultsCache));
|
|
1871
|
+
return withMutationScope(executor, [...new Set(records.flatMap((row) => collectScalarFkStoreNames(contract, modelName, row)))], async (scope) => {
|
|
1872
|
+
const inserted = [];
|
|
1873
|
+
for (const record of records) inserted.push(await insertSingleRow(scope, contract, modelName, record, defaultsCache));
|
|
1874
|
+
return inserted;
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Adds the stores an `onUpdate` cascade reaches from `modelName`: every child
|
|
1879
|
+
* relation's store, walking further through `cascade` edges only.
|
|
1880
|
+
*/
|
|
1881
|
+
function addOnUpdateCascadeStores(contract, modelName, stores, visitedModels) {
|
|
1882
|
+
if (visitedModels.has(modelName)) return;
|
|
1883
|
+
visitedModels.add(modelName);
|
|
1884
|
+
for (const def of getRelationDefinitions(contract, modelName)) {
|
|
1885
|
+
if (!isChildEnforcementRelation(contract, modelName, def)) continue;
|
|
1886
|
+
stores.add(def.relatedStoreName);
|
|
1887
|
+
if (getReferentialActionForRelation(contract, modelName, def, "onUpdate") === "cascade") addOnUpdateCascadeStores(contract, def.relatedModelName, stores, visitedModels);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* Every store an `onUpdate` enforcement on `modelName` could touch, whichever
|
|
1892
|
+
* fields the write sets. Use it to open a transaction before the write's
|
|
1893
|
+
* fields are known.
|
|
1894
|
+
*/
|
|
1895
|
+
function collectOnUpdateClosureStoreNames(contract, modelName) {
|
|
1896
|
+
const stores = /* @__PURE__ */ new Set([getStoreName(contract, modelName)]);
|
|
1897
|
+
addOnUpdateCascadeStores(contract, modelName, stores, /* @__PURE__ */ new Set());
|
|
1898
|
+
return [...stores];
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Combines FK-existence store scope (this model's own N:1 relations) with
|
|
1902
|
+
* `onUpdate` enforcement store scope (this model's child relations) for a
|
|
1903
|
+
* single write. Returns the union alongside whether `onUpdate` enforcement
|
|
1904
|
+
* actually applies, including self-relations that touch only this model's
|
|
1905
|
+
* own store. Callers use that flag to choose between the existing
|
|
1906
|
+
* fast blind-write path and the read-before-write enforcement path.
|
|
1907
|
+
*/
|
|
1908
|
+
function collectUpdateStoreNames(contract, modelName, data) {
|
|
1909
|
+
const fkStoreNames = collectScalarFkStoreNames(contract, modelName, data);
|
|
1910
|
+
const { storeNames: onUpdateStoreNames, enforces } = collectOnUpdateEnforcementStoreNames(contract, modelName, data);
|
|
1911
|
+
return {
|
|
1912
|
+
storeNames: [.../* @__PURE__ */ new Set([...fkStoreNames, ...onUpdateStoreNames])],
|
|
1913
|
+
enforcesOnUpdate: enforces
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
/** Updates the first matching row with FK validation and referential actions. */
|
|
1917
|
+
async function executeScalarUpdateWithFkValidation(options) {
|
|
1918
|
+
return (await executeScalarUpdates(options, 1))[0] ?? null;
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Bulk counterpart to {@link executeScalarUpdateWithFkValidation}: same FK
|
|
1922
|
+
* validation and `onUpdate` enforcement, but applies the patch to every row
|
|
1923
|
+
* the filter matches (no `take: 1`) and returns all of them.
|
|
1924
|
+
*
|
|
1925
|
+
* The transaction scope lets the sync interceptor observe the affected rows
|
|
1926
|
+
* and write one outbox event per row in the same transaction. A bulk write's
|
|
1927
|
+
* affected keys are only known after it runs, so tracking cannot happen by
|
|
1928
|
+
* extending the plan before execution.
|
|
1929
|
+
*/
|
|
1930
|
+
async function executeBulkUpdateWithFkValidation(options) {
|
|
1931
|
+
return executeScalarUpdates(options);
|
|
1932
|
+
}
|
|
1933
|
+
/** Shares validation and atomic writes; only single-row updates limit the scan. */
|
|
1934
|
+
async function executeScalarUpdates(options, take) {
|
|
1935
|
+
const { executor, contract, modelName, filters, data } = options;
|
|
1936
|
+
const storeName = getStoreName(contract, modelName);
|
|
1937
|
+
const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, storeName, data, createMutationDefaultsCache());
|
|
1938
|
+
assertEnumValues(contract, modelName, patch);
|
|
1939
|
+
assertValidRecord(contract, modelName, "update", patch);
|
|
1940
|
+
const { storeNames, enforcesOnUpdate } = collectUpdateStoreNames(contract, modelName, patch);
|
|
1941
|
+
const needsRowForFks = fkCheckNeedsExistingRow(contract, modelName, patch);
|
|
1942
|
+
return (await withMutationScope(executor, storeNames, async (scope) => {
|
|
1943
|
+
if (!needsRowForFks) await validateScalarFks(scope, contract, modelName, patch);
|
|
1944
|
+
const meta = makePlanMeta(contract);
|
|
1945
|
+
const where = filters.length === 0 ? void 0 : andExpr(filters);
|
|
1946
|
+
if (!enforcesOnUpdate && !needsRowForFks) return writeMutationRows(scope, contract, modelName, {
|
|
1947
|
+
write: "put-merged",
|
|
1948
|
+
patch,
|
|
1949
|
+
...take === void 0 ? {} : { take },
|
|
1950
|
+
...where === void 0 ? {} : { where }
|
|
1951
|
+
});
|
|
1952
|
+
const oldRows = await readMutationRows(scope, contract, modelName, where, take);
|
|
1953
|
+
const keyPath = getKeyPath(contract, modelName);
|
|
1954
|
+
const results = [];
|
|
1955
|
+
for (const oldRow of oldRows) {
|
|
1956
|
+
if (needsRowForFks) await validateScalarFks(scope, contract, modelName, patch, oldRow);
|
|
1957
|
+
if (enforcesOnUpdate) await applyReferentialActionsForRowOnUpdate(scope, contract, modelName, oldRow, patch);
|
|
1958
|
+
const key = extractKeyFromRow(oldRow, keyPath);
|
|
1959
|
+
const updated = (await scope.execute({
|
|
1960
|
+
meta,
|
|
1961
|
+
kind: "update",
|
|
1962
|
+
storeName,
|
|
1963
|
+
key,
|
|
1964
|
+
patch
|
|
1965
|
+
}))[0];
|
|
1966
|
+
if (updated) results.push(updated);
|
|
1967
|
+
}
|
|
1968
|
+
return results;
|
|
1969
|
+
})).map(absentAsNull(contract, modelName));
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Returns true if the model has at least one child relation (1:N or parent-side
|
|
1973
|
+
* 1:1). Every such relation has an `onDelete` action to enforce: `noAction`
|
|
1974
|
+
* behaves like `restrict`, which is also the default.
|
|
1975
|
+
*/
|
|
1976
|
+
function hasEnforceableChildRelations(contract, modelName) {
|
|
1977
|
+
return getRelationDefinitions(contract, modelName).some((def) => isChildEnforcementRelation(contract, modelName, def));
|
|
1978
|
+
}
|
|
1979
|
+
/** The store of `modelName` plus the store of every model it has a relation to, in either direction. */
|
|
1980
|
+
function collectRelatedStoreNames(contract, modelName) {
|
|
1981
|
+
const stores = /* @__PURE__ */ new Set([getStoreName(contract, modelName)]);
|
|
1982
|
+
for (const def of getRelationDefinitions(contract, modelName)) stores.add(def.relatedStoreName);
|
|
1983
|
+
return [...stores];
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Transitively walks the `onDelete` cascade graph from `modelName`, collecting
|
|
1987
|
+
* every store a recursive delete might touch. `cascade` edges are walked
|
|
1988
|
+
* further (a cascaded child's own children may themselves cascade);
|
|
1989
|
+
* `restrict`/`setNull`/`setDefault` edges still need their store added (each
|
|
1990
|
+
* is read or written once) but don't recurse further, since none of them
|
|
1991
|
+
* delete the child row. Guarded by a model-level visited set — required
|
|
1992
|
+
* because IDB must declare every store a transaction might touch before it
|
|
1993
|
+
* opens, so this static walk must terminate even on a self-referential model
|
|
1994
|
+
* or a cycle of mutually-cascading models.
|
|
1995
|
+
*/
|
|
1996
|
+
function collectDeleteStoreNames(contract, modelName) {
|
|
1997
|
+
const stores = /* @__PURE__ */ new Set();
|
|
1998
|
+
const visitedModels = /* @__PURE__ */ new Set();
|
|
1999
|
+
/** Collect cascade stores once per model so cyclic relations cannot recurse indefinitely. */
|
|
2000
|
+
function walk(mName) {
|
|
2001
|
+
if (visitedModels.has(mName)) return;
|
|
2002
|
+
visitedModels.add(mName);
|
|
2003
|
+
stores.add(getStoreName(contract, mName));
|
|
2004
|
+
for (const def of getRelationDefinitions(contract, mName)) {
|
|
2005
|
+
if (!isChildEnforcementRelation(contract, mName, def)) continue;
|
|
2006
|
+
const action = getReferentialActionForRelation(contract, mName, def, "onDelete");
|
|
2007
|
+
stores.add(def.relatedStoreName);
|
|
2008
|
+
if (action === "cascade") walk(def.relatedModelName);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
walk(modelName);
|
|
2012
|
+
return [...stores];
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* Enforces every `onDelete` action declared on `modelName`'s child relations
|
|
2016
|
+
* against one specific `row` about to be deleted. `cascade` recurses into
|
|
2017
|
+
* each matched child's own `onDelete` relations *before* deleting it — so a
|
|
2018
|
+
* multi-hop chain (`User --cascade--> Post --cascade--> Comment`) is fully
|
|
2019
|
+
* torn down, and a `restrict` several hops deep still aborts the whole
|
|
2020
|
+
* transaction (recursing before deleting means the delete never happens if a
|
|
2021
|
+
* deeper hop throws). `setNull`/`setDefault` are leaf actions: the child row
|
|
2022
|
+
* survives, so recursion never continues past them.
|
|
2023
|
+
*
|
|
2024
|
+
* `visited` (keyed by `storeName::key`) guards against row-level cycles — two
|
|
2025
|
+
* specific rows whose FKs point at each other through a self-referential or
|
|
2026
|
+
* mutually-cascading relation graph. Callers should leave it at its default
|
|
2027
|
+
* (a fresh `Set` per top-level call) so independent rows deleted in the same
|
|
2028
|
+
* `deleteAll()` batch don't cross-suppress each other's cascades; it's only
|
|
2029
|
+
* ever passed explicitly by this function's own recursive calls, to keep one
|
|
2030
|
+
* shared guard across a single row's full recursive descent.
|
|
2031
|
+
*/
|
|
2032
|
+
async function applyReferentialActionsForRow(scope, contract, modelName, row, visited = /* @__PURE__ */ new Set()) {
|
|
2033
|
+
const keyPath = getKeyPath(contract, modelName);
|
|
2034
|
+
const rowKey = `${getStoreName(contract, modelName)}::${keyToken$1(extractKeyFromRow(row, keyPath))}`;
|
|
2035
|
+
if (visited.has(rowKey)) return;
|
|
2036
|
+
visited.add(rowKey);
|
|
2037
|
+
const meta = makePlanMeta(contract);
|
|
2038
|
+
for (const def of getRelationDefinitions(contract, modelName)) {
|
|
2039
|
+
if (!isChildEnforcementRelation(contract, modelName, def)) continue;
|
|
2040
|
+
const action = getReferentialActionForRelation(contract, modelName, def, "onDelete");
|
|
2041
|
+
const childWhere = buildChildWhereFromRow(def, row);
|
|
2042
|
+
if (action === "restrict") {
|
|
2043
|
+
if (await childExists(scope, contract, meta, def, row)) throw new Error(`Cannot delete ${modelName} '${keyToken$1(extractKeyFromRow(row, keyPath))}': child records exist on relation '${def.relationName}'. Delete those children first, or declare onDelete: Cascade, SetNull or SetDefault on the relation.`);
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
2046
|
+
if (action === "cascade") {
|
|
2047
|
+
const childKeyPath = getKeyPath(contract, def.relatedModelName);
|
|
2048
|
+
const children = await readMutationRows(scope, contract, def.relatedModelName, childWhere);
|
|
2049
|
+
for (const child of children) {
|
|
2050
|
+
await applyReferentialActionsForRow(scope, contract, def.relatedModelName, child, visited);
|
|
2051
|
+
await scope.execute({
|
|
2052
|
+
meta,
|
|
2053
|
+
kind: "delete",
|
|
2054
|
+
storeName: def.relatedStoreName,
|
|
2055
|
+
key: extractKeyFromRow(child, childKeyPath)
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
continue;
|
|
2059
|
+
}
|
|
2060
|
+
if (action === "setNull") {
|
|
2061
|
+
const patch = {};
|
|
2062
|
+
for (const targetField of def.targetFields) patch[targetField] = null;
|
|
2063
|
+
await writeMutationRows(scope, contract, def.relatedModelName, {
|
|
2064
|
+
write: "put-merged",
|
|
2065
|
+
patch,
|
|
2066
|
+
where: childWhere
|
|
2067
|
+
});
|
|
2068
|
+
continue;
|
|
2069
|
+
}
|
|
2070
|
+
if (action === "setDefault") {
|
|
2071
|
+
const patch = buildSetDefaultPatch(contract, def);
|
|
2072
|
+
await validateSetDefaultPatch(scope, contract, modelName, def, patch, extractKeyFromRow(row, keyPath));
|
|
2073
|
+
await writeMutationRows(scope, contract, def.relatedModelName, {
|
|
2074
|
+
write: "put-merged",
|
|
2075
|
+
patch,
|
|
2076
|
+
where: childWhere
|
|
2077
|
+
});
|
|
2078
|
+
continue;
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
/** Load a keyed row, apply its child actions, and delete it in one transaction. */
|
|
2083
|
+
async function executeDeleteWithReferentialActions(options) {
|
|
2084
|
+
const { executor, contract, modelName, key } = options;
|
|
2085
|
+
await withMutationScope(executor, collectDeleteStoreNames(contract, modelName), async (scope) => {
|
|
2086
|
+
const storeName = getStoreName(contract, modelName);
|
|
2087
|
+
const meta = makePlanMeta(contract);
|
|
2088
|
+
const keyPath = getKeyPath(contract, modelName);
|
|
2089
|
+
const fields = keyPathFields(keyPath);
|
|
2090
|
+
const values = typeof keyPath === "string" ? [key] : key;
|
|
2091
|
+
const where = andExpr(fields.map((field, i) => fieldFilter(field, "eq", values[i])));
|
|
2092
|
+
const row = (await readMutationRows(scope, contract, modelName, where, 1))[0];
|
|
2093
|
+
if (!row) return [];
|
|
2094
|
+
await applyReferentialActionsForRow(scope, contract, modelName, row);
|
|
2095
|
+
await scope.execute({
|
|
2096
|
+
meta,
|
|
2097
|
+
kind: "delete",
|
|
2098
|
+
storeName,
|
|
2099
|
+
key
|
|
2100
|
+
});
|
|
2101
|
+
return [];
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
/** Delete filtered rows with all child actions inside the same transaction. */
|
|
2105
|
+
async function executeDeleteAllWithReferentialActions(options) {
|
|
2106
|
+
const { executor, contract, modelName, where } = options;
|
|
2107
|
+
return withMutationScope(executor, collectDeleteStoreNames(contract, modelName), async (scope) => {
|
|
2108
|
+
const storeName = getStoreName(contract, modelName);
|
|
2109
|
+
const meta = makePlanMeta(contract);
|
|
2110
|
+
const keyPath = getKeyPath(contract, modelName);
|
|
2111
|
+
if (!hasEnforceableChildRelations(contract, modelName)) return (await writeMutationRows(scope, contract, modelName, {
|
|
2112
|
+
write: "delete",
|
|
2113
|
+
...where === void 0 ? {} : { where }
|
|
2114
|
+
})).map(absentAsNull(contract, modelName));
|
|
2115
|
+
const rows = await readMutationRows(scope, contract, modelName, where);
|
|
2116
|
+
for (const row of rows) {
|
|
2117
|
+
await applyReferentialActionsForRow(scope, contract, modelName, row);
|
|
2118
|
+
const key = extractKeyFromRow(row, keyPath);
|
|
2119
|
+
await scope.execute({
|
|
2120
|
+
meta,
|
|
2121
|
+
kind: "delete",
|
|
2122
|
+
storeName,
|
|
2123
|
+
key
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
return rows;
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
//#endregion
|
|
2130
|
+
export { createMutationDefaultsCache as A, resolveDecimalKey as B, validateScalarFks as C, planQuery as D, lowerRows as E, combineFilterExprs as F, fieldValueToken as G, withDecimalIdentityScope as H, IdbDecimalIdentityConflictError as I, getStoreName as J, getKeyPath as K, decimalKeysEqual as L, assertValidRecord as M, assertEnumValues as N, applyUpdateDefaults as O, buildRowComparator as P, hasDecimalPrimaryKey as R, requireTransactionExecutor as S, lowerCount as T, buildCatalog as U, withDecimalIdentityExecutor as V, extractKeyFromRow as W, keyToken$1 as X, keyEquals$1 as Y, hasEnforceableChildRelations as _, collectOnUpdateEnforcementStoreNames as a, makePlanMeta as b, executeBulkUpdateWithFkValidation as c, executeMutationPlan as d, executeNestedCreateMutation as f, executeScalarUpdateWithFkValidation as g, executeScalarCreateWithFkValidation as h, collectOnUpdateClosureStoreNames as i, IdbRecordValidationError as j, completeCreateRecord as k, executeDeleteAllWithReferentialActions as l, executeScalarCreateAllWithFkValidation as m, applyReferentialActionsForRowOnUpdate as n, collectRelatedStoreNames as o, executeNestedUpdateMutation as p, getRelation as q, collectDeleteStoreNames as r, collectScalarFkStoreNames as s, applyReferentialActionsForRow as t, executeDeleteWithReferentialActions as u, hasNestedMutationCallbacks as v, withMutationScope as w, readMutationRows as x, hasScalarFkFields as y, preserveDecimalKeySpelling as z };
|
|
2131
|
+
|
|
2132
|
+
//# sourceMappingURL=mutation-executor-IuJmyWEG.mjs.map
|