@prisma-idb/client-idb 0.12.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/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-M3xMUk0n.mjs → managed-client-DMk8ERfX.mjs} +12 -5
- package/dist/managed-client-DMk8ERfX.mjs.map +1 -0
- package/dist/{managed-client-DQlsxc4Z.d.mts → managed-client-eksFK4UF.d.mts} +32 -3
- package/dist/managed-client-eksFK4UF.d.mts.map +1 -0
- package/dist/{mutation-executor-inLFb0TE.mjs → mutation-executor-IuJmyWEG.mjs} +486 -105
- package/dist/mutation-executor-IuJmyWEG.mjs.map +1 -0
- package/dist/{executor-CIEHpa1j.d.mts → mutation-scope-CE3GMR4X.d.mts} +66 -22
- package/dist/mutation-scope-CE3GMR4X.d.mts.map +1 -0
- package/dist/{idb-orm-B1Br1C0F.mjs → orm-transaction-BGx1eukw.mjs} +357 -61
- package/dist/orm-transaction-BGx1eukw.mjs.map +1 -0
- package/dist/{idb-orm-6OuwkeDM.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-CIEHpa1j.d.mts.map +0 -1
- package/dist/idb-orm-6OuwkeDM.d.mts.map +0 -1
- package/dist/idb-orm-B1Br1C0F.mjs.map +0 -1
- package/dist/managed-client-DQlsxc4Z.d.mts.map +0 -1
- package/dist/managed-client-M3xMUk0n.mjs.map +0 -1
- package/dist/mutation-executor-inLFb0TE.mjs.map +0 -1
- package/dist/mutation-scope-DrxFhek3.d.mts +0 -33
- package/dist/mutation-scope-DrxFhek3.d.mts.map +0 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncIterableResult } from "@prisma/orm-framework/components/runtime";
|
|
2
2
|
import { domainModelsAtDefaultNamespace } from "@prisma/orm-framework/contract/types";
|
|
3
|
-
import {
|
|
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";
|
|
4
6
|
import { generateId } from "@prisma/orm-framework/ids/runtime";
|
|
5
7
|
import { keyPathFields } from "@prisma-idb/target-idb/pack";
|
|
6
8
|
//#region src/core/types.ts
|
|
@@ -50,6 +52,301 @@ function getRelation(contract, modelName, relName) {
|
|
|
50
52
|
return relation;
|
|
51
53
|
}
|
|
52
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
|
|
53
350
|
//#region src/core/query-shaping.ts
|
|
54
351
|
/**
|
|
55
352
|
* Combine accumulated filter expressions with AND.
|
|
@@ -67,15 +364,19 @@ function combineFilterExprs(filters) {
|
|
|
67
364
|
* Build an in-memory comparator from an `orderBy` spec (field → direction).
|
|
68
365
|
*
|
|
69
366
|
* Returns `undefined` when there is nothing to sort by. Compares fields in
|
|
70
|
-
* declaration order
|
|
367
|
+
* declaration order. Decimal fields use exact numeric order; other fields
|
|
368
|
+
* use {@link compareFieldValues}: the same order an
|
|
71
369
|
* IndexedDB index uses, so equal `Date`s tie and fall through to the next
|
|
72
370
|
* field, and `null`s sort last (first when descending).
|
|
73
371
|
*/
|
|
74
|
-
function buildRowComparator(orderBy) {
|
|
372
|
+
function buildRowComparator(orderBy, fields = {}) {
|
|
75
373
|
if (orderBy === void 0) return void 0;
|
|
76
374
|
return (a, b) => {
|
|
77
375
|
for (const [field, dir] of Object.entries(orderBy)) {
|
|
78
|
-
const
|
|
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]);
|
|
79
380
|
if (cmp === 0) continue;
|
|
80
381
|
return dir === "desc" ? -cmp : cmp;
|
|
81
382
|
}
|
|
@@ -85,6 +386,7 @@ function buildRowComparator(orderBy) {
|
|
|
85
386
|
//#endregion
|
|
86
387
|
//#region src/core/enum-validation.ts
|
|
87
388
|
const rulesByContract = /* @__PURE__ */ new WeakMap();
|
|
389
|
+
/** Resolve an enum reference against its declared namespace before validating writes. */
|
|
88
390
|
function resolveEnumRule(contract, field) {
|
|
89
391
|
const ref = field.valueSet;
|
|
90
392
|
if (ref === void 0 || ref.plane !== "domain" || ref.entityKind !== "enum") return void 0;
|
|
@@ -94,9 +396,11 @@ function resolveEnumRule(contract, field) {
|
|
|
94
396
|
enumName: ref.entityName,
|
|
95
397
|
values: new Set(contractEnum.members.map((member) => member.value)),
|
|
96
398
|
nullable: field.nullable,
|
|
97
|
-
many: field.many
|
|
399
|
+
many: Boolean(field.many),
|
|
400
|
+
elementNullable: typeof field.many === "object" && field.many.elementNullable === true
|
|
98
401
|
};
|
|
99
402
|
}
|
|
403
|
+
/** Cache enum rules by contract identity and model to avoid rebuilding them for each write. */
|
|
100
404
|
function getModelEnumRules(contract, modelName) {
|
|
101
405
|
let byModel = rulesByContract.get(contract);
|
|
102
406
|
if (byModel === void 0) {
|
|
@@ -117,6 +421,7 @@ function getModelEnumRules(contract, modelName) {
|
|
|
117
421
|
function describe(value) {
|
|
118
422
|
return typeof value === "string" ? JSON.stringify(value) : `${String(value)} (${typeof value})`;
|
|
119
423
|
}
|
|
424
|
+
/** Describe an invalid enum value together with the declared alternatives. */
|
|
120
425
|
function invalidValueError(modelName, fieldName, rule, value) {
|
|
121
426
|
const expected = [...rule.values].map((v) => JSON.stringify(v)).join(", ");
|
|
122
427
|
return /* @__PURE__ */ new Error(`Invalid value ${describe(value)} for enum field "${modelName}.${fieldName}". Expected one of enum "${rule.enumName}": ${expected}.`);
|
|
@@ -125,7 +430,7 @@ function invalidValueError(modelName, fieldName, rule, value) {
|
|
|
125
430
|
* Throws if `data` sets an enum field to a value its enum doesn't declare.
|
|
126
431
|
* Fields left `undefined` are not checked. `null` is accepted only on an
|
|
127
432
|
* optional (`Role?`) field; a list (`Role[]`) field must be an array whose
|
|
128
|
-
* every element is a declared value.
|
|
433
|
+
* every element is a declared value, or `null` when the list allows nullable elements.
|
|
129
434
|
*/
|
|
130
435
|
function assertEnumValues(contract, modelName, data) {
|
|
131
436
|
const rules = getModelEnumRules(contract, modelName);
|
|
@@ -139,19 +444,49 @@ function assertEnumValues(contract, modelName, data) {
|
|
|
139
444
|
}
|
|
140
445
|
if (rule.many) {
|
|
141
446
|
if (!Array.isArray(value)) throw new Error(`Enum list field "${modelName}.${fieldName}" expects an array of "${rule.enumName}" values, got ${describe(value)}.`);
|
|
142
|
-
for (const element of 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
|
+
}
|
|
143
451
|
continue;
|
|
144
452
|
}
|
|
145
453
|
if (!rule.values.has(value)) throw invalidValueError(modelName, fieldName, rule, value);
|
|
146
454
|
}
|
|
147
455
|
}
|
|
148
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
|
|
149
483
|
//#region src/core/mutation-defaults.ts
|
|
150
484
|
function createMutationDefaultsCache() {
|
|
151
485
|
return /* @__PURE__ */ new Map();
|
|
152
486
|
}
|
|
153
487
|
/** Generator ids whose value is shared across an entire top-level call. Every other id always regenerates. */
|
|
154
488
|
const CACHEABLE_GENERATOR_IDS = /* @__PURE__ */ new Set(["timestampNow"]);
|
|
489
|
+
/** Resolve one declared default with the same generator IDs as the SQL execution layer. */
|
|
155
490
|
function computeGeneratedValue(spec) {
|
|
156
491
|
const id = spec.id;
|
|
157
492
|
switch (id) {
|
|
@@ -166,6 +501,7 @@ function computeGeneratedValue(spec) {
|
|
|
166
501
|
default: throw new Error(`Unknown mutation-default generator id "${String(id)}"`);
|
|
167
502
|
}
|
|
168
503
|
}
|
|
504
|
+
/** Share query-stable defaults across one mutation while generating fresh IDs for each field. */
|
|
169
505
|
function resolveGeneratedValue(spec, cache) {
|
|
170
506
|
const id = spec.id;
|
|
171
507
|
if (!CACHEABLE_GENERATOR_IDS.has(id)) return computeGeneratedValue(spec);
|
|
@@ -184,14 +520,25 @@ function applyCreateDefaults(defaults, storeName, data, cache) {
|
|
|
184
520
|
if (!defaults || defaults.length === 0) return data;
|
|
185
521
|
let result = data;
|
|
186
522
|
for (const d of defaults) {
|
|
187
|
-
if (d.ref.
|
|
188
|
-
if (result[d.ref.
|
|
523
|
+
if (d.ref.entry !== storeName || !d.onCreate) continue;
|
|
524
|
+
if (result[d.ref.field] !== void 0) continue;
|
|
189
525
|
if (result === data) result = { ...data };
|
|
190
|
-
result[d.ref.
|
|
526
|
+
result[d.ref.field] = resolveGeneratedValue(d.onCreate, cache);
|
|
191
527
|
}
|
|
192
528
|
return result;
|
|
193
529
|
}
|
|
194
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
|
+
/**
|
|
195
542
|
* Fills in every column with an `onUpdate` default that `patch` doesn't
|
|
196
543
|
* already set. A patch with no defined values (empty, or every field
|
|
197
544
|
* explicitly `undefined`) is returned unchanged — no write means no default
|
|
@@ -203,56 +550,14 @@ function applyUpdateDefaults(defaults, storeName, patch, cache) {
|
|
|
203
550
|
if (!defaults || defaults.length === 0) return patch;
|
|
204
551
|
let result = patch;
|
|
205
552
|
for (const d of defaults) {
|
|
206
|
-
if (d.ref.
|
|
207
|
-
if (result[d.ref.
|
|
553
|
+
if (d.ref.entry !== storeName || !d.onUpdate) continue;
|
|
554
|
+
if (result[d.ref.field] !== void 0) continue;
|
|
208
555
|
if (result === patch) result = { ...patch };
|
|
209
|
-
result[d.ref.
|
|
556
|
+
result[d.ref.field] = resolveGeneratedValue(d.onUpdate, cache);
|
|
210
557
|
}
|
|
211
558
|
return result;
|
|
212
559
|
}
|
|
213
560
|
//#endregion
|
|
214
|
-
//#region src/core/planner/catalog.ts
|
|
215
|
-
const catalogs = /* @__PURE__ */ new WeakMap();
|
|
216
|
-
/** Build a catalog once per contract identity and store name. Contracts are immutable. */
|
|
217
|
-
function buildCatalog(contract, storeName) {
|
|
218
|
-
const cached = catalogs.get(contract)?.get(storeName);
|
|
219
|
-
if (cached) return cached;
|
|
220
|
-
const store = contract.storage.stores[storeName];
|
|
221
|
-
if (!store) throw new Error(`Store "${storeName}" is not in the contract`);
|
|
222
|
-
const fields = {};
|
|
223
|
-
for (const model of Object.values(domainModelsAtDefaultNamespace(contract.domain))) {
|
|
224
|
-
if (model.storage.storeName !== storeName) continue;
|
|
225
|
-
for (const [name, field] of Object.entries(model.fields)) fields[name] = Object.freeze({
|
|
226
|
-
codecId: field.type.kind === "scalar" ? field.type.codecId : void 0,
|
|
227
|
-
nullable: field.nullable,
|
|
228
|
-
collection: field.many === true || field.dict === true
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
const catalog = Object.freeze({
|
|
232
|
-
storeName,
|
|
233
|
-
primaryKey: source(store.keyPath, true, false),
|
|
234
|
-
indexes: Object.freeze(Object.entries(store.indexes ?? {}).map(([name, index]) => source(index.keyPath, index.unique, index.multiEntry ?? false, name))),
|
|
235
|
-
fields: Object.freeze(fields)
|
|
236
|
-
});
|
|
237
|
-
let byStore = catalogs.get(contract);
|
|
238
|
-
if (!byStore) {
|
|
239
|
-
byStore = /* @__PURE__ */ new Map();
|
|
240
|
-
catalogs.set(contract, byStore);
|
|
241
|
-
}
|
|
242
|
-
byStore.set(storeName, catalog);
|
|
243
|
-
return catalog;
|
|
244
|
-
}
|
|
245
|
-
function source(keyPath, unique, multiEntry, indexName) {
|
|
246
|
-
const path = typeof keyPath === "string" ? keyPath : Object.freeze([...keyPath]);
|
|
247
|
-
return Object.freeze({
|
|
248
|
-
keyPath: path,
|
|
249
|
-
fields: Object.freeze(typeof path === "string" ? [path] : [...path]),
|
|
250
|
-
unique,
|
|
251
|
-
multiEntry,
|
|
252
|
-
...indexName === void 0 ? {} : { indexName }
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
//#endregion
|
|
256
561
|
//#region src/core/planner/plan.ts
|
|
257
562
|
const COST = {
|
|
258
563
|
UNIQUE_POINT: 0,
|
|
@@ -269,11 +574,11 @@ const KEY_CODECS = /* @__PURE__ */ new Set([
|
|
|
269
574
|
"idb/int32@1",
|
|
270
575
|
"idb/double@1",
|
|
271
576
|
"idb/date@1",
|
|
272
|
-
"idb/decimal@1",
|
|
273
577
|
"idb/bytes@1"
|
|
274
578
|
]);
|
|
275
579
|
/** Choose the cheapest safe access without opening IndexedDB or probing cardinality. */
|
|
276
580
|
function planQuery(catalog, request) {
|
|
581
|
+
if (request.where !== void 0) assertValidDecimalFilter(request.where, catalog.fields);
|
|
277
582
|
const normalized = normalize(request.where);
|
|
278
583
|
if (normalized.empty) return {
|
|
279
584
|
access: { kind: "empty" },
|
|
@@ -312,7 +617,7 @@ function planQuery(catalog, request) {
|
|
|
312
617
|
if (orders.length === 1 && request.take !== void 0) {
|
|
313
618
|
const [field, order] = orders[0];
|
|
314
619
|
if (selected) {
|
|
315
|
-
if (
|
|
620
|
+
if (catalog.fields[field]?.codecId !== "idb/decimal@1" && sourceOrdersField(selected.source, field, constraints)) direction = order === "asc" ? "next" : "prev";
|
|
316
621
|
} else {
|
|
317
622
|
const ordering = sources.find((source) => !source.multiEntry && singleFieldSource(source, field) && completeKeyField(catalog.fields[field]));
|
|
318
623
|
if (ordering) {
|
|
@@ -347,6 +652,7 @@ function full(exact) {
|
|
|
347
652
|
exact
|
|
348
653
|
};
|
|
349
654
|
}
|
|
655
|
+
/** Flatten supported conjunctions and same-field unions while retaining unsupported predicates. */
|
|
350
656
|
function normalize(where) {
|
|
351
657
|
const result = {
|
|
352
658
|
atoms: [],
|
|
@@ -354,6 +660,7 @@ function normalize(where) {
|
|
|
354
660
|
excluded: false,
|
|
355
661
|
empty: false
|
|
356
662
|
};
|
|
663
|
+
/** Collect usable atoms and mark filters that require the full-row path. */
|
|
357
664
|
function visit(expr) {
|
|
358
665
|
switch (expr.kind) {
|
|
359
666
|
case "field":
|
|
@@ -404,10 +711,17 @@ function keyPartial(field) {
|
|
|
404
711
|
function completeKeyField(field, constraint) {
|
|
405
712
|
return keyTyped(field) && field?.nullable === false && (!keyPartial(field) || constraint?.rejectsNonKeys === true);
|
|
406
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
|
+
}
|
|
407
719
|
function singleFieldSource(source, field) {
|
|
408
720
|
return source.fields.length === 1 && source.fields[0] === field;
|
|
409
721
|
}
|
|
722
|
+
/** Intersect one supported predicate with a field constraint without narrowing unsafe values. */
|
|
410
723
|
function addAtom(constraint, atom, field) {
|
|
724
|
+
if (field?.codecId === "idb/decimal@1") return;
|
|
411
725
|
if (atom.op === "eq" || atom.op === "in") {
|
|
412
726
|
const values = atom.op === "eq" ? [atom.value] : Array.isArray(atom.value) ? atom.value : [];
|
|
413
727
|
if (!values.every(isValidIdbKey)) return;
|
|
@@ -451,11 +765,13 @@ function uniqueKeys(values) {
|
|
|
451
765
|
const sorted = [...values].sort(compareFieldValues);
|
|
452
766
|
return sorted.filter((value, i) => i === 0 || compareFieldValues(sorted[i - 1], value) !== 0);
|
|
453
767
|
}
|
|
768
|
+
/** Keep the tighter endpoint when several comparisons constrain the same field. */
|
|
454
769
|
function intersectBound(constraint, side, bound) {
|
|
455
770
|
const previous = constraint[side];
|
|
456
771
|
const comparison = previous === void 0 ? 0 : compareFieldValues(bound.value, previous.value);
|
|
457
772
|
if (!previous || (side === "lower" ? comparison > 0 : comparison < 0) || comparison === 0 && bound.open) constraint[side] = bound;
|
|
458
773
|
}
|
|
774
|
+
/** Check a point against both endpoints, including open-bound equality. */
|
|
459
775
|
function insideBounds(value, constraint) {
|
|
460
776
|
const lower = constraint.lower;
|
|
461
777
|
const upper = constraint.upper;
|
|
@@ -463,6 +779,7 @@ function insideBounds(value, constraint) {
|
|
|
463
779
|
if (upper && (compareFieldValues(value, upper.value) > 0 || upper.open && keyEquals(value, upper.value))) return false;
|
|
464
780
|
return true;
|
|
465
781
|
}
|
|
782
|
+
/** Discard points outside the bounds and detect ranges that cannot match. */
|
|
466
783
|
function contradictory(constraint) {
|
|
467
784
|
if (constraint.points) {
|
|
468
785
|
constraint.points = constraint.points.filter((value) => insideBounds(value, constraint));
|
|
@@ -472,6 +789,7 @@ function contradictory(constraint) {
|
|
|
472
789
|
const comparison = compareFieldValues(constraint.lower.value, constraint.upper.value);
|
|
473
790
|
return comparison > 0 || comparison === 0 && (constraint.lower.open || constraint.upper.open);
|
|
474
791
|
}
|
|
792
|
+
/** Build a safe source range only when its index contains every possible match. */
|
|
475
793
|
function filteringCandidate(catalog, source, constraints, declaration) {
|
|
476
794
|
if (source.multiEntry || source.fields.length === 0) return void 0;
|
|
477
795
|
if (source.fields.some((name) => keyPartial(catalog.fields[name]) && !constraints.get(name)?.rejectsNonKeys)) return void 0;
|
|
@@ -513,6 +831,7 @@ function filteringCandidate(catalog, source, constraints, declaration) {
|
|
|
513
831
|
declaration
|
|
514
832
|
};
|
|
515
833
|
}
|
|
834
|
+
/** Convert scalar endpoints into the matching driver range descriptor. */
|
|
516
835
|
function scalarRange(constraint) {
|
|
517
836
|
if (constraint.lower && constraint.upper) return {
|
|
518
837
|
kind: "bound",
|
|
@@ -532,6 +851,7 @@ function scalarRange(constraint) {
|
|
|
532
851
|
open: constraint.upper.open
|
|
533
852
|
};
|
|
534
853
|
}
|
|
854
|
+
/** Bound a compound prefix and suffix using IndexedDB key ordering. */
|
|
535
855
|
function compoundRange(prefix, constraint, trailing) {
|
|
536
856
|
const lower = constraint?.lower;
|
|
537
857
|
const upper = constraint?.upper;
|
|
@@ -551,6 +871,7 @@ function compoundRange(prefix, constraint, trailing) {
|
|
|
551
871
|
upperOpen: upper ? upper.open || trailing > 0 : true
|
|
552
872
|
};
|
|
553
873
|
}
|
|
874
|
+
/** Find the exclusive upper bound of a UTF-16 prefix, if one exists. */
|
|
554
875
|
function stringSuccessor(prefix) {
|
|
555
876
|
for (let i = prefix.length - 1; i >= 0; i--) {
|
|
556
877
|
const code = prefix.charCodeAt(i);
|
|
@@ -560,6 +881,17 @@ function stringSuccessor(prefix) {
|
|
|
560
881
|
//#endregion
|
|
561
882
|
//#region src/core/planner/lower.ts
|
|
562
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
|
+
/**
|
|
563
895
|
* Lower row access without creating IDBKeyRange objects or awaiting work.
|
|
564
896
|
* Run `finish` after the executor has materialized all returned rows.
|
|
565
897
|
* Without `orderBy`, row order and pagination selection are unspecified.
|
|
@@ -567,8 +899,8 @@ function stringSuccessor(prefix) {
|
|
|
567
899
|
* pagination boundaries. Add a unique ordering field for deterministic pages.
|
|
568
900
|
*/
|
|
569
901
|
function lowerRows(catalog, logical, meta, request, additionalFilter) {
|
|
570
|
-
const filter =
|
|
571
|
-
const comparator = logical.direction === void 0 ? buildRowComparator(request.orderBy) : void 0;
|
|
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;
|
|
572
904
|
const base = {
|
|
573
905
|
meta,
|
|
574
906
|
storeName: catalog.storeName
|
|
@@ -592,7 +924,7 @@ function lowerRows(catalog, logical, meta, request, additionalFilter) {
|
|
|
592
924
|
idbPlan = {
|
|
593
925
|
...base,
|
|
594
926
|
kind: "cursor-scan",
|
|
595
|
-
filter,
|
|
927
|
+
...filter === void 0 ? {} : { filter },
|
|
596
928
|
...comparator === void 0 ? {} : { comparator },
|
|
597
929
|
...pagination
|
|
598
930
|
};
|
|
@@ -618,7 +950,7 @@ function lowerRows(catalog, logical, meta, request, additionalFilter) {
|
|
|
618
950
|
...access,
|
|
619
951
|
kind: "cursor-scan",
|
|
620
952
|
direction: logical.direction,
|
|
621
|
-
filter,
|
|
953
|
+
...filter === void 0 ? {} : { filter },
|
|
622
954
|
take: (request.skip ?? 0) + request.take
|
|
623
955
|
};
|
|
624
956
|
if (single && request.take !== void 0) {
|
|
@@ -626,7 +958,7 @@ function lowerRows(catalog, logical, meta, request, additionalFilter) {
|
|
|
626
958
|
return {
|
|
627
959
|
...access,
|
|
628
960
|
kind: "cursor-scan",
|
|
629
|
-
filter,
|
|
961
|
+
...filter === void 0 ? {} : { filter },
|
|
630
962
|
...logical.direction === void 0 ? {} : { direction: logical.direction },
|
|
631
963
|
...comparator === void 0 ? {} : { comparator },
|
|
632
964
|
...pagination
|
|
@@ -636,7 +968,7 @@ function lowerRows(catalog, logical, meta, request, additionalFilter) {
|
|
|
636
968
|
...access,
|
|
637
969
|
kind: "cursor-scan",
|
|
638
970
|
direction: "prev",
|
|
639
|
-
filter
|
|
971
|
+
...filter === void 0 ? {} : { filter }
|
|
640
972
|
};
|
|
641
973
|
return {
|
|
642
974
|
...access,
|
|
@@ -654,8 +986,10 @@ function lowerRows(catalog, logical, meta, request, additionalFilter) {
|
|
|
654
986
|
}
|
|
655
987
|
return {
|
|
656
988
|
idbPlan,
|
|
989
|
+
/** Normalize returned rows and apply filtering and shaping not already handled by the driver. */
|
|
657
990
|
finish(rows) {
|
|
658
|
-
let result = rows.
|
|
991
|
+
let result = rows.map(catalog.absentAsNull);
|
|
992
|
+
if (filter !== void 0) result = result.filter(filter);
|
|
659
993
|
if (!shapedByDriver) {
|
|
660
994
|
if (comparator !== void 0) result.sort(comparator);
|
|
661
995
|
const skip = request.skip ?? 0;
|
|
@@ -702,7 +1036,7 @@ function lowerExists(catalog, logical, meta, request, additionalFilter) {
|
|
|
702
1036
|
}))),
|
|
703
1037
|
finish: (rows) => rows.length > 0
|
|
704
1038
|
};
|
|
705
|
-
const filter = (
|
|
1039
|
+
const filter = rowFilter(catalog, request, additionalFilter);
|
|
706
1040
|
return {
|
|
707
1041
|
idbPlan: combine(catalog, meta, accessOps(catalog, meta, logical, (target) => ({
|
|
708
1042
|
...target,
|
|
@@ -728,7 +1062,7 @@ function lowerWrite(catalog, logical, meta, request, additionalFilter) {
|
|
|
728
1062
|
kind: "collect",
|
|
729
1063
|
rows: lowerRows(catalog, logical, meta, request, additionalFilter)
|
|
730
1064
|
};
|
|
731
|
-
const filter = (
|
|
1065
|
+
const filter = rowFilter(catalog, request, additionalFilter);
|
|
732
1066
|
return {
|
|
733
1067
|
kind: "scan",
|
|
734
1068
|
idbPlan: combine(catalog, meta, accessOps(catalog, meta, logical, (target) => ({
|
|
@@ -794,8 +1128,10 @@ async function withMutationScope(executor, storeNames, run) {
|
|
|
794
1128
|
}
|
|
795
1129
|
//#endregion
|
|
796
1130
|
//#region src/core/relation-mutator.ts
|
|
1131
|
+
/** Build the relation descriptors consumed by the nested-write executor. */
|
|
797
1132
|
function createRelationMutator() {
|
|
798
1133
|
return {
|
|
1134
|
+
/** Normalize a single nested record or a list into one create descriptor. */
|
|
799
1135
|
create(data) {
|
|
800
1136
|
return {
|
|
801
1137
|
kind: "create",
|
|
@@ -827,6 +1163,7 @@ function isRelationMutationCallback(value) {
|
|
|
827
1163
|
}
|
|
828
1164
|
//#endregion
|
|
829
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. */
|
|
830
1167
|
function makePlanMeta(contract) {
|
|
831
1168
|
return {
|
|
832
1169
|
target: "idb",
|
|
@@ -836,6 +1173,7 @@ function makePlanMeta(contract) {
|
|
|
836
1173
|
};
|
|
837
1174
|
}
|
|
838
1175
|
const relationDefsCache = /* @__PURE__ */ new WeakMap();
|
|
1176
|
+
/** Cache reference-relation metadata by contract identity and model. */
|
|
839
1177
|
function getRelationDefinitions(contract, modelName) {
|
|
840
1178
|
let perContract = relationDefsCache.get(contract);
|
|
841
1179
|
if (!perContract) {
|
|
@@ -886,6 +1224,7 @@ function requireTransactionExecutor(executor) {
|
|
|
886
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.");
|
|
887
1225
|
return executor;
|
|
888
1226
|
}
|
|
1227
|
+
/** Create a parent and its nested related rows in one transaction. */
|
|
889
1228
|
async function executeNestedCreateMutation(options) {
|
|
890
1229
|
const { executor, contract, modelName, data } = options;
|
|
891
1230
|
const record = data;
|
|
@@ -893,6 +1232,7 @@ async function executeNestedCreateMutation(options) {
|
|
|
893
1232
|
const defaultsCache = createMutationDefaultsCache();
|
|
894
1233
|
return withMutationScope(executor, storeNames, (scope) => createGraph(scope, contract, modelName, record, defaultsCache));
|
|
895
1234
|
}
|
|
1235
|
+
/** Update the first filtered parent and its nested relations in one transaction. */
|
|
896
1236
|
async function executeNestedUpdateMutation(options) {
|
|
897
1237
|
const { executor, contract, modelName, filters, data } = options;
|
|
898
1238
|
const record = data;
|
|
@@ -932,6 +1272,7 @@ function collectStoreNames(contract, modelName, data, kind) {
|
|
|
932
1272
|
function fkParentStoreNames(contract, modelName) {
|
|
933
1273
|
return getRelationDefinitions(contract, modelName).filter((def) => def.cardinality === "N:1").map((def) => def.relatedStoreName);
|
|
934
1274
|
}
|
|
1275
|
+
/** Resolve parent-owned references before inserting the parent, then write its children. */
|
|
935
1276
|
async function createGraph(scope, contract, modelName, input, defaultsCache) {
|
|
936
1277
|
const parsed = parseMutationInput(contract, modelName, input);
|
|
937
1278
|
const { parentOwned, childOwned } = partitionByOwnership(parsed.relationMutations);
|
|
@@ -947,6 +1288,7 @@ async function createGraph(scope, contract, modelName, input, defaultsCache) {
|
|
|
947
1288
|
}
|
|
948
1289
|
return parentRow;
|
|
949
1290
|
}
|
|
1291
|
+
/** Read the parent before resolving relation mutations and validating its scalar patch. */
|
|
950
1292
|
async function updateFirstGraph(scope, contract, modelName, filters, input, defaultsCache) {
|
|
951
1293
|
const existingRow = await findFirstByFilters(scope, contract, modelName, filters);
|
|
952
1294
|
if (!existingRow) return null;
|
|
@@ -961,6 +1303,7 @@ async function updateFirstGraph(scope, contract, modelName, filters, input, defa
|
|
|
961
1303
|
const meta = makePlanMeta(contract);
|
|
962
1304
|
const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, storeName, scalarData, defaultsCache);
|
|
963
1305
|
assertEnumValues(contract, modelName, patch);
|
|
1306
|
+
assertValidRecord(contract, modelName, "update", patch);
|
|
964
1307
|
await validateScalarFks(scope, contract, modelName, patch, existingRow);
|
|
965
1308
|
await applyReferentialActionsForRowOnUpdate(scope, contract, modelName, existingRow, patch);
|
|
966
1309
|
const updated = (await scope.execute({
|
|
@@ -970,11 +1313,12 @@ async function updateFirstGraph(scope, contract, modelName, filters, input, defa
|
|
|
970
1313
|
key,
|
|
971
1314
|
patch
|
|
972
1315
|
}))[0];
|
|
973
|
-
if (updated) parentRow = updated;
|
|
1316
|
+
if (updated) parentRow = absentAsNull(contract, modelName)(updated);
|
|
974
1317
|
}
|
|
975
1318
|
for (const item of childOwned) await applyChildOwnedMutation(scope, contract, modelName, parentRow, item.relation, item.mutation, defaultsCache);
|
|
976
1319
|
return parentRow;
|
|
977
1320
|
}
|
|
1321
|
+
/** Separate scalar values from validated relation callback results. */
|
|
978
1322
|
function parseMutationInput(contract, modelName, input) {
|
|
979
1323
|
const scalarData = {};
|
|
980
1324
|
const relationDefs = new Map(getRelationDefinitions(contract, modelName).map((r) => [r.relationName, r]));
|
|
@@ -998,6 +1342,7 @@ function parseMutationInput(contract, modelName, input) {
|
|
|
998
1342
|
relationMutations
|
|
999
1343
|
};
|
|
1000
1344
|
}
|
|
1345
|
+
/** Separate relations whose foreign keys belong to the parent from those owned by children. */
|
|
1001
1346
|
function partitionByOwnership(mutations) {
|
|
1002
1347
|
const parentOwned = [];
|
|
1003
1348
|
const childOwned = [];
|
|
@@ -1014,6 +1359,7 @@ function partitionByOwnership(mutations) {
|
|
|
1014
1359
|
childOwned
|
|
1015
1360
|
};
|
|
1016
1361
|
}
|
|
1362
|
+
/** Resolve a to-one mutation into scalar foreign-key values on the parent. */
|
|
1017
1363
|
async function applyParentOwnedMutation(scope, contract, scalarData, relation, mutation, defaultsCache) {
|
|
1018
1364
|
if (mutation.kind === "disconnect") {
|
|
1019
1365
|
for (const localField of relation.localFields) scalarData[localField] = null;
|
|
@@ -1031,6 +1377,7 @@ async function applyParentOwnedMutation(scope, contract, scalarData, relation, m
|
|
|
1031
1377
|
if (!relatedRow) throw new Error(`connect() nested mutation for relation "${relation.relationName}" did not find a matching row`);
|
|
1032
1378
|
copyRelatedValuesToParent(relation, scalarData, relatedRow);
|
|
1033
1379
|
}
|
|
1380
|
+
/** Copy each referenced value into the matching local foreign-key field. */
|
|
1034
1381
|
function copyRelatedValuesToParent(relation, scalarData, relatedRow) {
|
|
1035
1382
|
for (let i = 0; i < relation.localFields.length; i++) {
|
|
1036
1383
|
const localField = relation.localFields[i];
|
|
@@ -1039,6 +1386,7 @@ function copyRelatedValuesToParent(relation, scalarData, relatedRow) {
|
|
|
1039
1386
|
scalarData[localField] = relatedRow[targetField];
|
|
1040
1387
|
}
|
|
1041
1388
|
}
|
|
1389
|
+
/** Create, connect, or disconnect children using the persisted parent join values. */
|
|
1042
1390
|
async function applyChildOwnedMutation(scope, contract, parentModelName, parentRow, relation, mutation, defaultsCache) {
|
|
1043
1391
|
const parentValues = readParentColumnValues(parentModelName, relation, parentRow);
|
|
1044
1392
|
if (mutation.kind === "create") {
|
|
@@ -1087,6 +1435,7 @@ async function applyChildOwnedMutation(scope, contract, parentModelName, parentR
|
|
|
1087
1435
|
});
|
|
1088
1436
|
}
|
|
1089
1437
|
}
|
|
1438
|
+
/** Resolve parent join values before writing children; missing values cannot form a relation. */
|
|
1090
1439
|
function readParentColumnValues(parentModelName, relation, parentRow) {
|
|
1091
1440
|
const values = /* @__PURE__ */ new Map();
|
|
1092
1441
|
for (let i = 0; i < relation.localFields.length; i++) {
|
|
@@ -1099,12 +1448,14 @@ function readParentColumnValues(parentModelName, relation, parentRow) {
|
|
|
1099
1448
|
}
|
|
1100
1449
|
return values;
|
|
1101
1450
|
}
|
|
1451
|
+
/** Complete and validate a nested record, then insert it with foreign-key checks. */
|
|
1102
1452
|
async function insertSingleRow(scope, contract, modelName, data, defaultsCache) {
|
|
1103
1453
|
assertNoNestedCallbacks(modelName, data);
|
|
1104
1454
|
const storeName = getStoreName(contract, modelName);
|
|
1105
1455
|
const meta = makePlanMeta(contract);
|
|
1106
|
-
const record =
|
|
1456
|
+
const record = completeCreateRecord(contract, modelName, data, defaultsCache);
|
|
1107
1457
|
assertEnumValues(contract, modelName, record);
|
|
1458
|
+
assertValidRecord(contract, modelName, "create", record);
|
|
1108
1459
|
await validateScalarFks(scope, contract, modelName, record);
|
|
1109
1460
|
return (await scope.execute({
|
|
1110
1461
|
meta,
|
|
@@ -1115,19 +1466,21 @@ async function insertSingleRow(scope, contract, modelName, data, defaultsCache)
|
|
|
1115
1466
|
}
|
|
1116
1467
|
/**
|
|
1117
1468
|
* Recursive nesting (a relation callback inside an already-nested create) is
|
|
1118
|
-
* not supported
|
|
1469
|
+
* not supported. Without this guard the callback function would be
|
|
1119
1470
|
* handed to `store.put(...)`, where IDB's structured-clone throws an opaque
|
|
1120
1471
|
* `DataCloneError` ("could not be cloned") that gives the developer no hint
|
|
1121
|
-
* about the real cause. Surface a precise error instead.
|
|
1472
|
+
* about the real cause. Surface a precise error instead.
|
|
1122
1473
|
*/
|
|
1123
1474
|
function assertNoNestedCallbacks(modelName, data) {
|
|
1124
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.`);
|
|
1125
1476
|
}
|
|
1477
|
+
/** Require a non-empty connect criterion and return its first matching row. */
|
|
1126
1478
|
async function findRowByCriterion(scope, contract, modelName, criterion) {
|
|
1127
1479
|
const expr = shorthandToFilterExpr(criterion);
|
|
1128
1480
|
if (!expr) throw new Error(`Nested connect for model "${modelName}" requires a non-empty criterion`);
|
|
1129
1481
|
return (await readMutationRows(scope, contract, modelName, expr, 1))[0] ?? null;
|
|
1130
1482
|
}
|
|
1483
|
+
/** Find the first parent selected by the nested-update filters. */
|
|
1131
1484
|
async function findFirstByFilters(scope, contract, modelName, filters) {
|
|
1132
1485
|
if (filters.length === 0) return null;
|
|
1133
1486
|
return (await readMutationRows(scope, contract, modelName, andExpr(filters), 1))[0] ?? null;
|
|
@@ -1141,7 +1494,7 @@ async function executeMutationPlan(scope, plan) {
|
|
|
1141
1494
|
}
|
|
1142
1495
|
/** Read mutation preimages through the planner, then reapply the original filter. */
|
|
1143
1496
|
async function readMutationRows(scope, contract, modelName, where, take) {
|
|
1144
|
-
const catalog = buildCatalog(contract, getStoreName(contract, modelName));
|
|
1497
|
+
const catalog = buildCatalog(contract, getStoreName(contract, modelName), modelName);
|
|
1145
1498
|
const request = {
|
|
1146
1499
|
...where === void 0 ? {} : { where },
|
|
1147
1500
|
...take === void 0 ? {} : { take }
|
|
@@ -1149,8 +1502,10 @@ async function readMutationRows(scope, contract, modelName, where, take) {
|
|
|
1149
1502
|
const lowered = lowerRows(catalog, planQuery(catalog, request), makePlanMeta(contract), request);
|
|
1150
1503
|
return lowered.finish(await executeMutationPlan(scope, lowered.idbPlan));
|
|
1151
1504
|
}
|
|
1505
|
+
/** Write planned matches, collecting preimages first when changing the walked index key. */
|
|
1152
1506
|
async function writeMutationRows(scope, contract, modelName, request) {
|
|
1153
|
-
|
|
1507
|
+
if (request.write !== "delete") assertValidRecord(contract, modelName, "update", request.patch ?? {});
|
|
1508
|
+
const catalog = buildCatalog(contract, getStoreName(contract, modelName), modelName);
|
|
1154
1509
|
const meta = makePlanMeta(contract);
|
|
1155
1510
|
const lowered = lowerWrite(catalog, planQuery(catalog, request), meta, request);
|
|
1156
1511
|
if (lowered.kind === "scan") return executeMutationPlan(scope, lowered.idbPlan);
|
|
@@ -1177,6 +1532,7 @@ async function writeMutationRows(scope, contract, modelName, request) {
|
|
|
1177
1532
|
function buildParentJoinWhere(parentValues) {
|
|
1178
1533
|
return andExpr([...parentValues].map(([field, value]) => fieldFilter(field, "eq", value)));
|
|
1179
1534
|
}
|
|
1535
|
+
/** Read an explicitly declared referential action from model storage metadata. */
|
|
1180
1536
|
function getStoredAction(contract, modelName, relationName, kind) {
|
|
1181
1537
|
return (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.relations?.[relationName]?.[kind];
|
|
1182
1538
|
}
|
|
@@ -1203,6 +1559,7 @@ function sameFields(a, b) {
|
|
|
1203
1559
|
function getReferentialActionForRelation(contract, modelName, def, kind) {
|
|
1204
1560
|
return enforcedAction(findDeclaredAction(contract, modelName, def, kind));
|
|
1205
1561
|
}
|
|
1562
|
+
/** Find an action on the enforcement side or on its matching inverse relation. */
|
|
1206
1563
|
function findDeclaredAction(contract, modelName, def, kind) {
|
|
1207
1564
|
const direct = getStoredAction(contract, modelName, def.relationName, kind);
|
|
1208
1565
|
if (direct !== void 0) return direct;
|
|
@@ -1228,6 +1585,7 @@ function findDeclaredAction(contract, modelName, def, kind) {
|
|
|
1228
1585
|
function enforcedAction(declared) {
|
|
1229
1586
|
return declared === void 0 || declared === "noAction" ? "restrict" : declared;
|
|
1230
1587
|
}
|
|
1588
|
+
/** Identify relations that need child checks when this model changes. */
|
|
1231
1589
|
function isChildEnforcementRelation(contract, modelName, def) {
|
|
1232
1590
|
if (def.cardinality === "1:N") return true;
|
|
1233
1591
|
if (def.cardinality === "1:1") {
|
|
@@ -1243,16 +1601,16 @@ function isChildEnforcementRelation(contract, modelName, def) {
|
|
|
1243
1601
|
async function childExists(scope, contract, meta, def, parentRow) {
|
|
1244
1602
|
const values = def.localFields.map((field) => parentRow[field]);
|
|
1245
1603
|
if (values.some((value) => value === null || value === void 0)) return false;
|
|
1246
|
-
return rowExists(scope, contract, meta, def.
|
|
1604
|
+
return rowExists(scope, contract, meta, def.relatedModelName, def.targetFields, values);
|
|
1247
1605
|
}
|
|
1248
1606
|
/**
|
|
1249
|
-
* Does `
|
|
1607
|
+
* Does `modelName` have a row whose `fields` equal `values`, pairwise?
|
|
1250
1608
|
* Compares values the way IndexedDB compares keys. `additionalFilter` narrows
|
|
1251
1609
|
* the match in memory; it makes the check read rows instead of keys.
|
|
1252
1610
|
*/
|
|
1253
|
-
async function rowExists(scope, contract, meta,
|
|
1611
|
+
async function rowExists(scope, contract, meta, modelName, fields, values, additionalFilter) {
|
|
1254
1612
|
const where = andExpr(fields.map((field, i) => fieldFilter(field, "eq", values[i])));
|
|
1255
|
-
const catalog = buildCatalog(contract,
|
|
1613
|
+
const catalog = buildCatalog(contract, getStoreName(contract, modelName), modelName);
|
|
1256
1614
|
const lowered = lowerExists(catalog, planQuery(catalog, { where }), meta, { where }, additionalFilter);
|
|
1257
1615
|
return lowered.finish(await executeMutationPlan(scope, lowered.idbPlan));
|
|
1258
1616
|
}
|
|
@@ -1336,26 +1694,15 @@ async function validateSetDefaultPatch(scope, contract, modelName, def, patch, e
|
|
|
1336
1694
|
*/
|
|
1337
1695
|
function collectOnUpdateEnforcementStoreNames(contract, modelName, data) {
|
|
1338
1696
|
const stores = /* @__PURE__ */ new Set([getStoreName(contract, modelName)]);
|
|
1339
|
-
const visitedModels = /* @__PURE__ */ new Set();
|
|
1697
|
+
const visitedModels = /* @__PURE__ */ new Set([modelName]);
|
|
1340
1698
|
let enforces = false;
|
|
1341
|
-
function walkCascadeChain(mName) {
|
|
1342
|
-
if (visitedModels.has(mName)) return;
|
|
1343
|
-
visitedModels.add(mName);
|
|
1344
|
-
for (const def of getRelationDefinitions(contract, mName)) {
|
|
1345
|
-
if (!isChildEnforcementRelation(contract, mName, def)) continue;
|
|
1346
|
-
const action = getReferentialActionForRelation(contract, mName, def, "onUpdate");
|
|
1347
|
-
stores.add(def.relatedStoreName);
|
|
1348
|
-
if (action === "cascade") walkCascadeChain(def.relatedModelName);
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
visitedModels.add(modelName);
|
|
1352
1699
|
for (const def of getRelationDefinitions(contract, modelName)) {
|
|
1353
1700
|
if (!isChildEnforcementRelation(contract, modelName, def)) continue;
|
|
1354
1701
|
if (!def.localFields.some((f) => f in data)) continue;
|
|
1355
1702
|
const action = getReferentialActionForRelation(contract, modelName, def, "onUpdate");
|
|
1356
1703
|
enforces = true;
|
|
1357
1704
|
stores.add(def.relatedStoreName);
|
|
1358
|
-
if (action === "cascade")
|
|
1705
|
+
if (action === "cascade") addOnUpdateCascadeStores(contract, def.relatedModelName, stores, visitedModels);
|
|
1359
1706
|
}
|
|
1360
1707
|
return {
|
|
1361
1708
|
storeNames: [...stores],
|
|
@@ -1384,7 +1731,8 @@ function collectOnUpdateEnforcementStoreNames(contract, modelName, data) {
|
|
|
1384
1731
|
*/
|
|
1385
1732
|
async function applyReferentialActionsForRowOnUpdate(scope, contract, modelName, oldRow, patch, visited = /* @__PURE__ */ new Set()) {
|
|
1386
1733
|
const keyPath = getKeyPath(contract, modelName);
|
|
1387
|
-
|
|
1734
|
+
patch = preserveDecimalKeySpelling(contract, modelName, patch, extractKeyFromRow(oldRow, keyPath));
|
|
1735
|
+
const rowKey = `${getStoreName(contract, modelName)}::${keyToken$1(extractKeyFromRow(oldRow, keyPath))}`;
|
|
1388
1736
|
if (visited.has(rowKey)) return;
|
|
1389
1737
|
visited.add(rowKey);
|
|
1390
1738
|
const meta = makePlanMeta(contract);
|
|
@@ -1395,7 +1743,7 @@ async function applyReferentialActionsForRowOnUpdate(scope, contract, modelName,
|
|
|
1395
1743
|
const action = getReferentialActionForRelation(contract, modelName, def, "onUpdate");
|
|
1396
1744
|
const childWhere = buildChildWhereFromRow(def, oldRow);
|
|
1397
1745
|
if (action === "restrict") {
|
|
1398
|
-
if (await childExists(scope, contract, meta, def, oldRow)) throw new Error(`Cannot update ${modelName} '${keyToken(extractKeyFromRow(oldRow, keyPath))}': changing field(s) ${changedFields.join(", ")} would orphan child records on relation '${def.relationName}'. Update or remove those children first, or declare onUpdate: Cascade, SetNull or SetDefault on the relation.`);
|
|
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.`);
|
|
1399
1747
|
continue;
|
|
1400
1748
|
}
|
|
1401
1749
|
if (action === "cascade") {
|
|
@@ -1447,6 +1795,7 @@ function hasScalarFkFields(contract, modelName, data) {
|
|
|
1447
1795
|
}
|
|
1448
1796
|
return false;
|
|
1449
1797
|
}
|
|
1798
|
+
/** Include parent stores for the non-null foreign keys supplied by this write. */
|
|
1450
1799
|
function collectScalarFkStoreNames(contract, modelName, data) {
|
|
1451
1800
|
const stores = /* @__PURE__ */ new Set([getStoreName(contract, modelName)]);
|
|
1452
1801
|
for (const def of getRelationDefinitions(contract, modelName)) {
|
|
@@ -1496,18 +1845,18 @@ function fkCheckNeedsExistingRow(contract, modelName, data) {
|
|
|
1496
1845
|
* is being deleted or changed.
|
|
1497
1846
|
*/
|
|
1498
1847
|
async function parentExists(scope, contract, meta, parentModel, fields, values, excludeKey) {
|
|
1499
|
-
const storeName = getStoreName(contract, parentModel);
|
|
1500
1848
|
const keyPath = getKeyPath(contract, parentModel);
|
|
1501
|
-
return rowExists(scope, contract, meta,
|
|
1849
|
+
return rowExists(scope, contract, meta, parentModel, fields, values, excludeKey === void 0 ? void 0 : (row) => !keyEquals$1(extractKeyFromRow(row, keyPath), excludeKey));
|
|
1502
1850
|
}
|
|
1503
1851
|
/** `id='u1'`, or `orgId='a', id='u1'` for a compound key. */
|
|
1504
1852
|
function describeTuple(fields, values) {
|
|
1505
1853
|
return fields.map((f, i) => `${f}='${String(values[i])}'`).join(", ");
|
|
1506
1854
|
}
|
|
1855
|
+
/** Create one completed record and check its foreign keys in the same transaction. */
|
|
1507
1856
|
async function executeScalarCreateWithFkValidation(options) {
|
|
1508
1857
|
const { executor, contract, modelName, data } = options;
|
|
1509
1858
|
const defaultsCache = createMutationDefaultsCache();
|
|
1510
|
-
const record =
|
|
1859
|
+
const record = completeCreateRecord(contract, modelName, data, defaultsCache);
|
|
1511
1860
|
return withMutationScope(executor, collectScalarFkStoreNames(contract, modelName, record), (scope) => insertSingleRow(scope, contract, modelName, record, defaultsCache));
|
|
1512
1861
|
}
|
|
1513
1862
|
/**
|
|
@@ -1518,8 +1867,7 @@ async function executeScalarCreateWithFkValidation(options) {
|
|
|
1518
1867
|
async function executeScalarCreateAllWithFkValidation(options) {
|
|
1519
1868
|
const { executor, contract, modelName, data } = options;
|
|
1520
1869
|
const defaultsCache = createMutationDefaultsCache();
|
|
1521
|
-
const
|
|
1522
|
-
const records = data.map((row) => applyCreateDefaults(contract.execution?.mutations.defaults, storeName, row, defaultsCache));
|
|
1870
|
+
const records = data.map((row) => completeCreateRecord(contract, modelName, row, defaultsCache));
|
|
1523
1871
|
return withMutationScope(executor, [...new Set(records.flatMap((row) => collectScalarFkStoreNames(contract, modelName, row)))], async (scope) => {
|
|
1524
1872
|
const inserted = [];
|
|
1525
1873
|
for (const record of records) inserted.push(await insertSingleRow(scope, contract, modelName, record, defaultsCache));
|
|
@@ -1527,6 +1875,29 @@ async function executeScalarCreateAllWithFkValidation(options) {
|
|
|
1527
1875
|
});
|
|
1528
1876
|
}
|
|
1529
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
|
+
/**
|
|
1530
1901
|
* Combines FK-existence store scope (this model's own N:1 relations) with
|
|
1531
1902
|
* `onUpdate` enforcement store scope (this model's child relations) for a
|
|
1532
1903
|
* single write. Returns the union alongside whether `onUpdate` enforcement
|
|
@@ -1565,9 +1936,10 @@ async function executeScalarUpdates(options, take) {
|
|
|
1565
1936
|
const storeName = getStoreName(contract, modelName);
|
|
1566
1937
|
const patch = applyUpdateDefaults(contract.execution?.mutations.defaults, storeName, data, createMutationDefaultsCache());
|
|
1567
1938
|
assertEnumValues(contract, modelName, patch);
|
|
1939
|
+
assertValidRecord(contract, modelName, "update", patch);
|
|
1568
1940
|
const { storeNames, enforcesOnUpdate } = collectUpdateStoreNames(contract, modelName, patch);
|
|
1569
1941
|
const needsRowForFks = fkCheckNeedsExistingRow(contract, modelName, patch);
|
|
1570
|
-
return withMutationScope(executor, storeNames, async (scope) => {
|
|
1942
|
+
return (await withMutationScope(executor, storeNames, async (scope) => {
|
|
1571
1943
|
if (!needsRowForFks) await validateScalarFks(scope, contract, modelName, patch);
|
|
1572
1944
|
const meta = makePlanMeta(contract);
|
|
1573
1945
|
const where = filters.length === 0 ? void 0 : andExpr(filters);
|
|
@@ -1594,7 +1966,7 @@ async function executeScalarUpdates(options, take) {
|
|
|
1594
1966
|
if (updated) results.push(updated);
|
|
1595
1967
|
}
|
|
1596
1968
|
return results;
|
|
1597
|
-
});
|
|
1969
|
+
})).map(absentAsNull(contract, modelName));
|
|
1598
1970
|
}
|
|
1599
1971
|
/**
|
|
1600
1972
|
* Returns true if the model has at least one child relation (1:N or parent-side
|
|
@@ -1604,6 +1976,12 @@ async function executeScalarUpdates(options, take) {
|
|
|
1604
1976
|
function hasEnforceableChildRelations(contract, modelName) {
|
|
1605
1977
|
return getRelationDefinitions(contract, modelName).some((def) => isChildEnforcementRelation(contract, modelName, def));
|
|
1606
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
|
+
}
|
|
1607
1985
|
/**
|
|
1608
1986
|
* Transitively walks the `onDelete` cascade graph from `modelName`, collecting
|
|
1609
1987
|
* every store a recursive delete might touch. `cascade` edges are walked
|
|
@@ -1618,6 +1996,7 @@ function hasEnforceableChildRelations(contract, modelName) {
|
|
|
1618
1996
|
function collectDeleteStoreNames(contract, modelName) {
|
|
1619
1997
|
const stores = /* @__PURE__ */ new Set();
|
|
1620
1998
|
const visitedModels = /* @__PURE__ */ new Set();
|
|
1999
|
+
/** Collect cascade stores once per model so cyclic relations cannot recurse indefinitely. */
|
|
1621
2000
|
function walk(mName) {
|
|
1622
2001
|
if (visitedModels.has(mName)) return;
|
|
1623
2002
|
visitedModels.add(mName);
|
|
@@ -1652,7 +2031,7 @@ function collectDeleteStoreNames(contract, modelName) {
|
|
|
1652
2031
|
*/
|
|
1653
2032
|
async function applyReferentialActionsForRow(scope, contract, modelName, row, visited = /* @__PURE__ */ new Set()) {
|
|
1654
2033
|
const keyPath = getKeyPath(contract, modelName);
|
|
1655
|
-
const rowKey = `${getStoreName(contract, modelName)}::${keyToken(extractKeyFromRow(row, keyPath))}`;
|
|
2034
|
+
const rowKey = `${getStoreName(contract, modelName)}::${keyToken$1(extractKeyFromRow(row, keyPath))}`;
|
|
1656
2035
|
if (visited.has(rowKey)) return;
|
|
1657
2036
|
visited.add(rowKey);
|
|
1658
2037
|
const meta = makePlanMeta(contract);
|
|
@@ -1661,7 +2040,7 @@ async function applyReferentialActionsForRow(scope, contract, modelName, row, vi
|
|
|
1661
2040
|
const action = getReferentialActionForRelation(contract, modelName, def, "onDelete");
|
|
1662
2041
|
const childWhere = buildChildWhereFromRow(def, row);
|
|
1663
2042
|
if (action === "restrict") {
|
|
1664
|
-
if (await childExists(scope, contract, meta, def, row)) throw new Error(`Cannot delete ${modelName} '${keyToken(extractKeyFromRow(row, keyPath))}': child records exist on relation '${def.relationName}'. Delete those children first, or declare onDelete: Cascade, SetNull or SetDefault on the relation.`);
|
|
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.`);
|
|
1665
2044
|
continue;
|
|
1666
2045
|
}
|
|
1667
2046
|
if (action === "cascade") {
|
|
@@ -1700,6 +2079,7 @@ async function applyReferentialActionsForRow(scope, contract, modelName, row, vi
|
|
|
1700
2079
|
}
|
|
1701
2080
|
}
|
|
1702
2081
|
}
|
|
2082
|
+
/** Load a keyed row, apply its child actions, and delete it in one transaction. */
|
|
1703
2083
|
async function executeDeleteWithReferentialActions(options) {
|
|
1704
2084
|
const { executor, contract, modelName, key } = options;
|
|
1705
2085
|
await withMutationScope(executor, collectDeleteStoreNames(contract, modelName), async (scope) => {
|
|
@@ -1721,16 +2101,17 @@ async function executeDeleteWithReferentialActions(options) {
|
|
|
1721
2101
|
return [];
|
|
1722
2102
|
});
|
|
1723
2103
|
}
|
|
2104
|
+
/** Delete filtered rows with all child actions inside the same transaction. */
|
|
1724
2105
|
async function executeDeleteAllWithReferentialActions(options) {
|
|
1725
2106
|
const { executor, contract, modelName, where } = options;
|
|
1726
2107
|
return withMutationScope(executor, collectDeleteStoreNames(contract, modelName), async (scope) => {
|
|
1727
2108
|
const storeName = getStoreName(contract, modelName);
|
|
1728
2109
|
const meta = makePlanMeta(contract);
|
|
1729
2110
|
const keyPath = getKeyPath(contract, modelName);
|
|
1730
|
-
if (!hasEnforceableChildRelations(contract, modelName)) return writeMutationRows(scope, contract, modelName, {
|
|
2111
|
+
if (!hasEnforceableChildRelations(contract, modelName)) return (await writeMutationRows(scope, contract, modelName, {
|
|
1731
2112
|
write: "delete",
|
|
1732
2113
|
...where === void 0 ? {} : { where }
|
|
1733
|
-
});
|
|
2114
|
+
})).map(absentAsNull(contract, modelName));
|
|
1734
2115
|
const rows = await readMutationRows(scope, contract, modelName, where);
|
|
1735
2116
|
for (const row of rows) {
|
|
1736
2117
|
await applyReferentialActionsForRow(scope, contract, modelName, row);
|
|
@@ -1746,6 +2127,6 @@ async function executeDeleteAllWithReferentialActions(options) {
|
|
|
1746
2127
|
});
|
|
1747
2128
|
}
|
|
1748
2129
|
//#endregion
|
|
1749
|
-
export {
|
|
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 };
|
|
1750
2131
|
|
|
1751
|
-
//# sourceMappingURL=mutation-executor-
|
|
2132
|
+
//# sourceMappingURL=mutation-executor-IuJmyWEG.mjs.map
|