@objectstack/objectql 7.9.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +97 -10
- package/dist/index.d.ts +97 -10
- package/dist/index.js +315 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +315 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
package/dist/index.mjs
CHANGED
|
@@ -5039,6 +5039,7 @@ _ObjectStackProtocolImplementation.RUNTIME_CREATE_ALLOWED_TYPES = (() => {
|
|
|
5039
5039
|
var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
|
|
5040
5040
|
|
|
5041
5041
|
// src/engine.ts
|
|
5042
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
5042
5043
|
import { ExecutionContextSchema } from "@objectstack/spec/kernel";
|
|
5043
5044
|
import { createLogger } from "@objectstack/core";
|
|
5044
5045
|
import { CoreServiceName, StorageNameMapping } from "@objectstack/spec/system";
|
|
@@ -5531,7 +5532,7 @@ function optionValues(options) {
|
|
|
5531
5532
|
);
|
|
5532
5533
|
}
|
|
5533
5534
|
function validateOne(name, def, value) {
|
|
5534
|
-
if (def.required && isMissing(value)) {
|
|
5535
|
+
if (def.required && isMissing(value) && def.type !== "autonumber") {
|
|
5535
5536
|
return { field: name, code: "required", message: `${name} is required` };
|
|
5536
5537
|
}
|
|
5537
5538
|
if (isMissing(value)) return null;
|
|
@@ -5629,8 +5630,31 @@ var ajv = new Ajv({ allErrors: true, strict: false });
|
|
|
5629
5630
|
var jsonSchemaCache = /* @__PURE__ */ new WeakMap();
|
|
5630
5631
|
function needsPriorRecord(objectSchema) {
|
|
5631
5632
|
const rules = objectSchema?.validations;
|
|
5632
|
-
|
|
5633
|
-
return
|
|
5633
|
+
const ruleNeeds = Array.isArray(rules) && rules.some((r) => ruleNeedsPrior(r));
|
|
5634
|
+
return !!(ruleNeeds || fieldsNeedPrior(objectSchema?.fields));
|
|
5635
|
+
}
|
|
5636
|
+
function stripReadonlyWhenFields(objectSchema, data, previous, logger) {
|
|
5637
|
+
const fields = objectSchema?.fields;
|
|
5638
|
+
if (!fields || !data) return data;
|
|
5639
|
+
const merged = { ...previous ?? {}, ...data };
|
|
5640
|
+
let result = data;
|
|
5641
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
5642
|
+
if (!def?.readonlyWhen || !(name in data)) continue;
|
|
5643
|
+
const res = ExpressionEngine2.evaluate(toExpression(def.readonlyWhen), {
|
|
5644
|
+
record: merged,
|
|
5645
|
+
previous: previous ?? void 0
|
|
5646
|
+
});
|
|
5647
|
+
if (!res.ok) {
|
|
5648
|
+
logger?.warn?.(`readonlyWhen for '${name}' failed to evaluate \u2014 change allowed through`);
|
|
5649
|
+
continue;
|
|
5650
|
+
}
|
|
5651
|
+
if (res.value === true) {
|
|
5652
|
+
if (result === data) result = { ...data };
|
|
5653
|
+
delete result[name];
|
|
5654
|
+
logger?.warn?.(`Field '${name}' is read-only (readonlyWhen) \u2014 ignoring incoming change`);
|
|
5655
|
+
}
|
|
5656
|
+
}
|
|
5657
|
+
return result;
|
|
5634
5658
|
}
|
|
5635
5659
|
function ruleNeedsPrior(r) {
|
|
5636
5660
|
if (r == null || typeof r !== "object") return false;
|
|
@@ -5644,17 +5668,44 @@ function ruleNeedsPrior(r) {
|
|
|
5644
5668
|
}
|
|
5645
5669
|
return false;
|
|
5646
5670
|
}
|
|
5671
|
+
function isMissing2(v) {
|
|
5672
|
+
return v === void 0 || v === null || typeof v === "string" && v.trim() === "";
|
|
5673
|
+
}
|
|
5674
|
+
function fieldsNeedPrior(fields) {
|
|
5675
|
+
if (!fields) return false;
|
|
5676
|
+
return Object.values(fields).some(
|
|
5677
|
+
(f) => f && (f.requiredWhen || f.conditionalRequired || f.readonlyWhen)
|
|
5678
|
+
);
|
|
5679
|
+
}
|
|
5647
5680
|
function toExpression(cond) {
|
|
5648
5681
|
return typeof cond === "string" ? { dialect: "cel", source: cond } : cond;
|
|
5649
5682
|
}
|
|
5650
5683
|
function evaluateValidationRules(objectSchema, data, mode, opts = {}) {
|
|
5684
|
+
if (!data) return;
|
|
5651
5685
|
const rules = objectSchema?.validations;
|
|
5652
|
-
|
|
5686
|
+
const hasRules = Array.isArray(rules) && rules.length > 0;
|
|
5687
|
+
const fields = objectSchema?.fields;
|
|
5688
|
+
const hasFieldRules = fieldsNeedPrior(fields);
|
|
5689
|
+
if (!hasRules && !hasFieldRules) return;
|
|
5653
5690
|
const previous = opts.previous ?? void 0;
|
|
5654
5691
|
const merged = { ...previous ?? {}, ...data };
|
|
5655
5692
|
const ctx = { data, merged, previous, mode, logger: opts.logger };
|
|
5656
5693
|
const errors = [];
|
|
5657
|
-
|
|
5694
|
+
if (hasFieldRules && fields) {
|
|
5695
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
5696
|
+
const pred = def?.requiredWhen ?? def?.conditionalRequired;
|
|
5697
|
+
if (!pred) continue;
|
|
5698
|
+
const res = ExpressionEngine2.evaluate(toExpression(pred), { record: merged, previous });
|
|
5699
|
+
if (!res.ok) {
|
|
5700
|
+
opts.logger?.warn?.(`requiredWhen for '${name}' failed to evaluate \u2014 skipped`);
|
|
5701
|
+
continue;
|
|
5702
|
+
}
|
|
5703
|
+
if (res.value === true && isMissing2(merged[name])) {
|
|
5704
|
+
errors.push({ field: name, code: "required", message: `${name} is required` });
|
|
5705
|
+
}
|
|
5706
|
+
}
|
|
5707
|
+
}
|
|
5708
|
+
const ordered = (hasRules ? rules : []).filter((r) => r != null && typeof r === "object").filter((r) => r.active !== false).filter((r) => {
|
|
5658
5709
|
const events = r.events ?? ["insert", "update"];
|
|
5659
5710
|
return events.includes(mode);
|
|
5660
5711
|
}).sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
|
@@ -6032,6 +6083,15 @@ function resolveMetadataItemName(key, item) {
|
|
|
6032
6083
|
}
|
|
6033
6084
|
var _ObjectQL = class _ObjectQL {
|
|
6034
6085
|
constructor(hostContext = {}) {
|
|
6086
|
+
/**
|
|
6087
|
+
* Ambient transaction store (ADR-0034). While a `transaction()` callback
|
|
6088
|
+
* runs, the active transaction handle lives here so that EVERY data
|
|
6089
|
+
* operation — including internal reads done during a write (reference
|
|
6090
|
+
* checks, hooks, expand) — automatically binds to the same connection
|
|
6091
|
+
* instead of asking the pool for another one and deadlocking on the
|
|
6092
|
+
* single-connection SQLite pool.
|
|
6093
|
+
*/
|
|
6094
|
+
this.txStore = new AsyncLocalStorage();
|
|
6035
6095
|
this.drivers = /* @__PURE__ */ new Map();
|
|
6036
6096
|
this.defaultDriver = null;
|
|
6037
6097
|
// Datasource mapping rules (imported from defineStack)
|
|
@@ -6073,6 +6133,12 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6073
6133
|
// getDriver()'s owner lookup would route CRUD to the wrong database. Each
|
|
6074
6134
|
// engine now owns its registry so kernels are fully isolated.
|
|
6075
6135
|
this._registry = new SchemaRegistry();
|
|
6136
|
+
/** In-memory next-value cache per `object.field` for autonumber generation,
|
|
6137
|
+
* lazily seeded from the current max in the store. */
|
|
6138
|
+
this.autonumberCounters = /* @__PURE__ */ new Map();
|
|
6139
|
+
/** Lazily-built index: child object name → roll-up summary descriptors on
|
|
6140
|
+
* parent objects that aggregate it. Invalidated when packages register. */
|
|
6141
|
+
this.summaryIndex = null;
|
|
6076
6142
|
this.hostContext = hostContext;
|
|
6077
6143
|
this.logger = hostContext.logger || createLogger({ level: "info", format: "pretty" });
|
|
6078
6144
|
if (process?.env?.OBJECTQL_STRICT_HOOKS === "1") {
|
|
@@ -6375,13 +6441,14 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6375
6441
|
* mask the system path.
|
|
6376
6442
|
*/
|
|
6377
6443
|
buildDriverOptions(execCtx, base) {
|
|
6378
|
-
const
|
|
6444
|
+
const tx = execCtx?.transaction !== void 0 ? execCtx.transaction : this.txStore.getStore()?.transaction;
|
|
6445
|
+
const hasTx = tx !== void 0;
|
|
6379
6446
|
const hasTenant = execCtx?.tenantId !== void 0;
|
|
6380
6447
|
const isSystem = execCtx?.isSystem === true;
|
|
6381
6448
|
if (!hasTx && !hasTenant && !isSystem) return base;
|
|
6382
6449
|
const opts = base && typeof base === "object" ? { ...base } : {};
|
|
6383
6450
|
if (hasTx && opts.transaction === void 0) {
|
|
6384
|
-
opts.transaction =
|
|
6451
|
+
opts.transaction = tx;
|
|
6385
6452
|
}
|
|
6386
6453
|
if (hasTenant && opts.tenantId === void 0) {
|
|
6387
6454
|
opts.tenantId = execCtx.tenantId;
|
|
@@ -6447,6 +6514,66 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6447
6514
|
}
|
|
6448
6515
|
return out;
|
|
6449
6516
|
}
|
|
6517
|
+
/**
|
|
6518
|
+
* Generate values for empty `autonumber` fields on insert — ONLY for drivers
|
|
6519
|
+
* that do not generate them natively (memory, mongodb). For SQL-backed objects
|
|
6520
|
+
* the driver owns a persistent, atomic `_objectstack_sequences` table and
|
|
6521
|
+
* advertises `supports.autonumber === true`; the engine then defers entirely
|
|
6522
|
+
* and never pre-fills (so the persistent sequence is the single source of
|
|
6523
|
+
* truth — see #1603). Required-validation exempts `autonumber` either way, so
|
|
6524
|
+
* a `required` record number is never rejected for "missing" — the runtime
|
|
6525
|
+
* owns the value, not the client.
|
|
6526
|
+
*
|
|
6527
|
+
* In the fallback path the next value is `max(existing) + 1`, seeded once per
|
|
6528
|
+
* `object.field` from the store then incremented in memory (monotonic within
|
|
6529
|
+
* the process, resilient to deletions). `autonumberFormat` is honored, e.g.
|
|
6530
|
+
* `CASE-{0000}` → `CASE-0042`. NOTE: this in-memory seeding is single-instance.
|
|
6531
|
+
*/
|
|
6532
|
+
async applyAutonumbers(object, record, execCtx, driverOwnsAutonumber) {
|
|
6533
|
+
if (driverOwnsAutonumber) return;
|
|
6534
|
+
const fields = this.getSchema(object)?.fields;
|
|
6535
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return;
|
|
6536
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
6537
|
+
if (def?.type !== "autonumber") continue;
|
|
6538
|
+
const current = record[name];
|
|
6539
|
+
if (current != null && current !== "") continue;
|
|
6540
|
+
const key = `${object}.${name}`;
|
|
6541
|
+
let next = this.autonumberCounters.get(key);
|
|
6542
|
+
if (next == null) next = await this.seedAutonumber(object, name, execCtx);
|
|
6543
|
+
next += 1;
|
|
6544
|
+
this.autonumberCounters.set(key, next);
|
|
6545
|
+
const fmt = def.autonumberFormat ?? def.format;
|
|
6546
|
+
record[name] = this.formatAutonumber(fmt, next);
|
|
6547
|
+
}
|
|
6548
|
+
}
|
|
6549
|
+
/** Seed the autonumber counter from the current max numeric value in store. */
|
|
6550
|
+
async seedAutonumber(object, field, execCtx) {
|
|
6551
|
+
try {
|
|
6552
|
+
const rows = await this.find(object, {
|
|
6553
|
+
select: ["id", field],
|
|
6554
|
+
limit: 5e3,
|
|
6555
|
+
context: execCtx
|
|
6556
|
+
});
|
|
6557
|
+
let max = 0;
|
|
6558
|
+
for (const r of rows || []) {
|
|
6559
|
+
const v = r?.[field];
|
|
6560
|
+
if (v == null) continue;
|
|
6561
|
+
const m = String(v).match(/(\d+)(?!.*\d)/);
|
|
6562
|
+
if (m) max = Math.max(max, parseInt(m[1], 10) || 0);
|
|
6563
|
+
}
|
|
6564
|
+
return max;
|
|
6565
|
+
} catch {
|
|
6566
|
+
return 0;
|
|
6567
|
+
}
|
|
6568
|
+
}
|
|
6569
|
+
/** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */
|
|
6570
|
+
formatAutonumber(format, value) {
|
|
6571
|
+
if (!format) return String(value);
|
|
6572
|
+
const m = format.match(/\{(0+)\}/);
|
|
6573
|
+
if (!m) return format.includes("{0}") ? format.replace("{0}", String(value)) : `${format}${value}`;
|
|
6574
|
+
const padded = String(value).padStart(m[1].length, "0");
|
|
6575
|
+
return format.replace(m[0], padded);
|
|
6576
|
+
}
|
|
6450
6577
|
/**
|
|
6451
6578
|
* Register contribution (Manifest)
|
|
6452
6579
|
*
|
|
@@ -6460,6 +6587,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6460
6587
|
registerApp(manifest) {
|
|
6461
6588
|
const id = manifest.id || manifest.name;
|
|
6462
6589
|
const namespace = manifest.namespace;
|
|
6590
|
+
this.invalidateSummaryIndex();
|
|
6463
6591
|
this.logger.debug("Registering package manifest", { id, namespace });
|
|
6464
6592
|
console.warn(`[ObjectQL:registerApp] id=${id} flows=${Array.isArray(manifest.flows) ? manifest.flows.length : typeof manifest.flows} keys=${Object.keys(manifest).join(",")}`);
|
|
6465
6593
|
if (id) {
|
|
@@ -6540,6 +6668,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6540
6668
|
"pages",
|
|
6541
6669
|
"dashboards",
|
|
6542
6670
|
"reports",
|
|
6671
|
+
"datasets",
|
|
6543
6672
|
"themes",
|
|
6544
6673
|
// Automation Protocol
|
|
6545
6674
|
"flows",
|
|
@@ -6684,6 +6813,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6684
6813
|
"pages",
|
|
6685
6814
|
"dashboards",
|
|
6686
6815
|
"reports",
|
|
6816
|
+
"datasets",
|
|
6687
6817
|
"themes",
|
|
6688
6818
|
"flows",
|
|
6689
6819
|
"workflows",
|
|
@@ -7065,6 +7195,102 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7065
7195
|
}
|
|
7066
7196
|
this.logger.info("ObjectQL engine destroyed");
|
|
7067
7197
|
}
|
|
7198
|
+
/** Invalidate the cached roll-up summary index (call when metadata changes). */
|
|
7199
|
+
invalidateSummaryIndex() {
|
|
7200
|
+
this.summaryIndex = null;
|
|
7201
|
+
}
|
|
7202
|
+
/** Scan all registered objects for `summary` fields and index them by the
|
|
7203
|
+
* child object they aggregate, resolving the child→parent FK field. */
|
|
7204
|
+
buildSummaryIndex() {
|
|
7205
|
+
const index = /* @__PURE__ */ new Map();
|
|
7206
|
+
let objects = [];
|
|
7207
|
+
try {
|
|
7208
|
+
objects = this._registry.getAllObjects?.() ?? [];
|
|
7209
|
+
} catch {
|
|
7210
|
+
objects = [];
|
|
7211
|
+
}
|
|
7212
|
+
for (const parent of objects) {
|
|
7213
|
+
const fields = parent?.fields;
|
|
7214
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
7215
|
+
for (const [summaryField, def] of Object.entries(fields)) {
|
|
7216
|
+
const d = def;
|
|
7217
|
+
if (d?.type !== "summary" || !d.summaryOperations) continue;
|
|
7218
|
+
const so = d.summaryOperations;
|
|
7219
|
+
const childObject = so.object;
|
|
7220
|
+
const fn = so.function;
|
|
7221
|
+
if (!childObject || !fn) continue;
|
|
7222
|
+
let fkField = so.relationshipField;
|
|
7223
|
+
if (!fkField) {
|
|
7224
|
+
const child = this._registry.getObject(childObject);
|
|
7225
|
+
const cfields = child?.fields || {};
|
|
7226
|
+
for (const [cfName, cdef] of Object.entries(cfields)) {
|
|
7227
|
+
const cd = cdef;
|
|
7228
|
+
if ((cd?.type === "master_detail" || cd?.type === "lookup") && cd?.reference === parent.name) {
|
|
7229
|
+
fkField = cfName;
|
|
7230
|
+
break;
|
|
7231
|
+
}
|
|
7232
|
+
}
|
|
7233
|
+
}
|
|
7234
|
+
if (!fkField) continue;
|
|
7235
|
+
const list = index.get(childObject) ?? [];
|
|
7236
|
+
list.push({ parentObject: parent.name, summaryField, fkField, fn, sourceField: so.field });
|
|
7237
|
+
index.set(childObject, list);
|
|
7238
|
+
}
|
|
7239
|
+
}
|
|
7240
|
+
return index;
|
|
7241
|
+
}
|
|
7242
|
+
getSummaryDescriptors(childObject) {
|
|
7243
|
+
if (!this.summaryIndex) this.summaryIndex = this.buildSummaryIndex();
|
|
7244
|
+
return this.summaryIndex.get(childObject) ?? [];
|
|
7245
|
+
}
|
|
7246
|
+
/**
|
|
7247
|
+
* Recompute roll-up `summary` fields on parent records after a child write.
|
|
7248
|
+
* For each affected parent (the FK value on the changed/old child record), it
|
|
7249
|
+
* aggregates the child collection and writes the result onto the parent's
|
|
7250
|
+
* summary field. Runs in the caller's execution context so it joins the same
|
|
7251
|
+
* transaction (e.g. the cross-object batch) when one is open.
|
|
7252
|
+
*/
|
|
7253
|
+
async recomputeSummaries(childObject, records, previous, execCtx) {
|
|
7254
|
+
const descriptors = this.getSummaryDescriptors(childObject);
|
|
7255
|
+
if (descriptors.length === 0) return;
|
|
7256
|
+
const recs = Array.isArray(records) ? records : records ? [records] : [];
|
|
7257
|
+
const prevs = Array.isArray(previous) ? previous : previous ? [previous] : [];
|
|
7258
|
+
for (const desc of descriptors) {
|
|
7259
|
+
const ids = /* @__PURE__ */ new Set();
|
|
7260
|
+
for (const r of recs) {
|
|
7261
|
+
const v = r?.[desc.fkField];
|
|
7262
|
+
if (v != null && v !== "") ids.add(String(v));
|
|
7263
|
+
}
|
|
7264
|
+
for (const p of prevs) {
|
|
7265
|
+
const v = p?.[desc.fkField];
|
|
7266
|
+
if (v != null && v !== "") ids.add(String(v));
|
|
7267
|
+
}
|
|
7268
|
+
for (const parentId of ids) {
|
|
7269
|
+
try {
|
|
7270
|
+
const rows = await this.aggregate(childObject, {
|
|
7271
|
+
where: { [desc.fkField]: parentId },
|
|
7272
|
+
aggregations: [{
|
|
7273
|
+
function: desc.fn,
|
|
7274
|
+
...desc.fn === "count" ? {} : { field: desc.sourceField },
|
|
7275
|
+
alias: "value"
|
|
7276
|
+
}],
|
|
7277
|
+
context: execCtx
|
|
7278
|
+
});
|
|
7279
|
+
let value = rows?.[0]?.value;
|
|
7280
|
+
if (value == null) value = desc.fn === "count" || desc.fn === "sum" ? 0 : null;
|
|
7281
|
+
await this.update(desc.parentObject, { id: parentId, [desc.summaryField]: value }, { context: execCtx });
|
|
7282
|
+
} catch (err) {
|
|
7283
|
+
this.logger.warn("Roll-up summary recompute failed", {
|
|
7284
|
+
childObject,
|
|
7285
|
+
parentObject: desc.parentObject,
|
|
7286
|
+
parentId,
|
|
7287
|
+
field: desc.summaryField,
|
|
7288
|
+
error: err?.message
|
|
7289
|
+
});
|
|
7290
|
+
}
|
|
7291
|
+
}
|
|
7292
|
+
}
|
|
7293
|
+
}
|
|
7068
7294
|
/**
|
|
7069
7295
|
* Post-process expand: resolve lookup/master_detail fields by batch-loading related records.
|
|
7070
7296
|
*
|
|
@@ -7280,10 +7506,14 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7280
7506
|
let result;
|
|
7281
7507
|
const nowSnap = /* @__PURE__ */ new Date();
|
|
7282
7508
|
const schemaForValidation = this._registry.getObject(object);
|
|
7509
|
+
const driverOwnsAutonumber = driver?.supports?.autonumber === true;
|
|
7283
7510
|
if (Array.isArray(hookContext.input.data)) {
|
|
7284
7511
|
const rows = hookContext.input.data.map(
|
|
7285
7512
|
(row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
|
|
7286
7513
|
);
|
|
7514
|
+
for (const r of rows) {
|
|
7515
|
+
await this.applyAutonumbers(object, r, opCtx.context, driverOwnsAutonumber);
|
|
7516
|
+
}
|
|
7287
7517
|
for (const r of rows) {
|
|
7288
7518
|
await this.encryptSecretFields(object, r, opCtx.context, hookContext.input.options);
|
|
7289
7519
|
}
|
|
@@ -7303,6 +7533,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7303
7533
|
opCtx.context,
|
|
7304
7534
|
nowSnap
|
|
7305
7535
|
);
|
|
7536
|
+
await this.applyAutonumbers(object, row, opCtx.context, driverOwnsAutonumber);
|
|
7306
7537
|
await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options);
|
|
7307
7538
|
validateRecord(schemaForValidation, row, "insert");
|
|
7308
7539
|
evaluateValidationRules(schemaForValidation, row, "insert", { logger: this.logger });
|
|
@@ -7311,6 +7542,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7311
7542
|
hookContext.event = "afterInsert";
|
|
7312
7543
|
hookContext.result = result;
|
|
7313
7544
|
await this.triggerHooks("afterInsert", hookContext);
|
|
7545
|
+
await this.recomputeSummaries(object, result, null, opCtx.context);
|
|
7314
7546
|
if (this.realtimeService) {
|
|
7315
7547
|
try {
|
|
7316
7548
|
if (Array.isArray(result)) {
|
|
@@ -7395,6 +7627,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7395
7627
|
const priorAst = { object, where: { id: hookContext.input.id }, limit: 1 };
|
|
7396
7628
|
priorRecord = await driver.findOne(object, priorAst, hookContext.input.options);
|
|
7397
7629
|
}
|
|
7630
|
+
hookContext.input.data = stripReadonlyWhenFields(updateSchema, hookContext.input.data, priorRecord, this.logger);
|
|
7398
7631
|
evaluateValidationRules(updateSchema, hookContext.input.data, "update", { previous: priorRecord, logger: this.logger });
|
|
7399
7632
|
result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
|
|
7400
7633
|
} else if (options?.multi && driver.updateMany) {
|
|
@@ -7412,6 +7645,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7412
7645
|
hookContext.result = result;
|
|
7413
7646
|
if (priorRecord) hookContext.previous = priorRecord;
|
|
7414
7647
|
await this.triggerHooks("afterUpdate", hookContext);
|
|
7648
|
+
await this.recomputeSummaries(object, result, priorRecord, opCtx.context);
|
|
7415
7649
|
if (this.realtimeService) {
|
|
7416
7650
|
try {
|
|
7417
7651
|
const resultId = typeof result === "object" && result && "id" in result ? result.id : void 0;
|
|
@@ -7440,6 +7674,66 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7440
7674
|
});
|
|
7441
7675
|
return opCtx.result;
|
|
7442
7676
|
}
|
|
7677
|
+
/**
|
|
7678
|
+
* Apply referential delete behavior for relations pointing AT this record,
|
|
7679
|
+
* before it is removed. For every registered object with a `master_detail`
|
|
7680
|
+
* or `lookup` field referencing `object`, honor the field's `deleteBehavior`:
|
|
7681
|
+
* - `cascade` → delete the dependent rows (recursively, so grandchildren
|
|
7682
|
+
* are handled by each child's own delete),
|
|
7683
|
+
* - `set_null` → clear the foreign key,
|
|
7684
|
+
* - `restrict` → refuse the delete when dependents exist.
|
|
7685
|
+
* `master_detail` defaults to `cascade` (the parent owns the child
|
|
7686
|
+
* lifecycle); `lookup` defaults to `set_null`. Only runs for single-id
|
|
7687
|
+
* deletes — multi/predicate deletes skip cascade (logged).
|
|
7688
|
+
*/
|
|
7689
|
+
async cascadeDeleteRelations(object, id, context, depth = 0) {
|
|
7690
|
+
if (id == null || depth >= _ObjectQL.MAX_CASCADE_DEPTH) return;
|
|
7691
|
+
let objects;
|
|
7692
|
+
try {
|
|
7693
|
+
objects = this._registry.getAllObjects();
|
|
7694
|
+
} catch {
|
|
7695
|
+
return;
|
|
7696
|
+
}
|
|
7697
|
+
for (const child of objects) {
|
|
7698
|
+
const childName = child?.name;
|
|
7699
|
+
const fields = child?.fields;
|
|
7700
|
+
if (!childName || !fields) continue;
|
|
7701
|
+
for (const [fieldName, fdef] of Object.entries(fields)) {
|
|
7702
|
+
if (!fdef || fdef.type !== "master_detail" && fdef.type !== "lookup") continue;
|
|
7703
|
+
const ref = fdef.reference;
|
|
7704
|
+
if (!ref) continue;
|
|
7705
|
+
let resolvedRef;
|
|
7706
|
+
try {
|
|
7707
|
+
resolvedRef = this.resolveObjectName(ref);
|
|
7708
|
+
} catch {
|
|
7709
|
+
resolvedRef = void 0;
|
|
7710
|
+
}
|
|
7711
|
+
if (ref !== object && resolvedRef !== object) continue;
|
|
7712
|
+
const behavior = fdef.type === "master_detail" ? fdef.deleteBehavior === "restrict" ? "restrict" : "cascade" : fdef.deleteBehavior || "set_null";
|
|
7713
|
+
let dependents;
|
|
7714
|
+
try {
|
|
7715
|
+
dependents = await this.find(childName, { where: { [fieldName]: id }, context });
|
|
7716
|
+
} catch {
|
|
7717
|
+
continue;
|
|
7718
|
+
}
|
|
7719
|
+
if (!dependents || dependents.length === 0) continue;
|
|
7720
|
+
if (behavior === "restrict") {
|
|
7721
|
+
throw new Error(
|
|
7722
|
+
`Cannot delete ${object} (${id}): ${dependents.length} dependent ${childName} record(s) via ${fieldName}`
|
|
7723
|
+
);
|
|
7724
|
+
}
|
|
7725
|
+
for (const dep of dependents) {
|
|
7726
|
+
const depId = dep?.id;
|
|
7727
|
+
if (depId == null) continue;
|
|
7728
|
+
if (behavior === "cascade") {
|
|
7729
|
+
await this.delete(childName, { where: { id: depId }, context });
|
|
7730
|
+
} else {
|
|
7731
|
+
await this.update(childName, { id: depId, [fieldName]: null }, { context });
|
|
7732
|
+
}
|
|
7733
|
+
}
|
|
7734
|
+
}
|
|
7735
|
+
}
|
|
7736
|
+
}
|
|
7443
7737
|
async delete(object, options) {
|
|
7444
7738
|
object = this.resolveObjectName(object);
|
|
7445
7739
|
this.logger.debug("Delete operation starting", { object });
|
|
@@ -7469,7 +7763,15 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7469
7763
|
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
|
|
7470
7764
|
try {
|
|
7471
7765
|
let result;
|
|
7766
|
+
let summaryPrev = null;
|
|
7767
|
+
if (hookContext.input.id && this.getSummaryDescriptors(object).length > 0) {
|
|
7768
|
+
try {
|
|
7769
|
+
summaryPrev = await this.findOne(object, { where: { id: hookContext.input.id }, context: opCtx.context });
|
|
7770
|
+
} catch {
|
|
7771
|
+
}
|
|
7772
|
+
}
|
|
7472
7773
|
if (hookContext.input.id) {
|
|
7774
|
+
await this.cascadeDeleteRelations(object, hookContext.input.id, opCtx.context);
|
|
7473
7775
|
result = await driver.delete(object, hookContext.input.id, hookContext.input.options);
|
|
7474
7776
|
} else if (options?.multi && driver.deleteMany) {
|
|
7475
7777
|
const ast = { object, where: options.where };
|
|
@@ -7480,6 +7782,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7480
7782
|
hookContext.event = "afterDelete";
|
|
7481
7783
|
hookContext.result = result;
|
|
7482
7784
|
await this.triggerHooks("afterDelete", hookContext);
|
|
7785
|
+
if (summaryPrev) await this.recomputeSummaries(object, null, summaryPrev, opCtx.context);
|
|
7483
7786
|
if (this.realtimeService) {
|
|
7484
7787
|
try {
|
|
7485
7788
|
const resultId = typeof result === "object" && result && "id" in result ? result.id : void 0;
|
|
@@ -7629,7 +7932,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7629
7932
|
const trx = await drv.beginTransaction();
|
|
7630
7933
|
const trxCtx = { ...baseContext ?? {}, transaction: trx };
|
|
7631
7934
|
try {
|
|
7632
|
-
const result = await callback(trxCtx);
|
|
7935
|
+
const result = await this.txStore.run({ transaction: trx }, () => callback(trxCtx));
|
|
7633
7936
|
if (drv.commit) await drv.commit(trx);
|
|
7634
7937
|
else if (drv.commitTransaction) await drv.commitTransaction(trx);
|
|
7635
7938
|
return result;
|
|
@@ -7893,6 +8196,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7893
8196
|
// ============================================
|
|
7894
8197
|
/** Maximum depth for recursive expand to prevent infinite loops */
|
|
7895
8198
|
_ObjectQL.MAX_EXPAND_DEPTH = 3;
|
|
8199
|
+
_ObjectQL.MAX_CASCADE_DEPTH = 10;
|
|
7896
8200
|
var ObjectQL = _ObjectQL;
|
|
7897
8201
|
var ObjectRepository = class {
|
|
7898
8202
|
constructor(objectName, context, engine) {
|
|
@@ -8010,8 +8314,10 @@ var ScopedContext = class _ScopedContext {
|
|
|
8010
8314
|
{ ...this.executionContext, transaction: trx },
|
|
8011
8315
|
this.engine
|
|
8012
8316
|
);
|
|
8317
|
+
const txStore = this.engine?.txStore;
|
|
8318
|
+
const runIn = (fn) => txStore ? txStore.run({ transaction: trx }, fn) : fn();
|
|
8013
8319
|
try {
|
|
8014
|
-
const result = await callback(trxCtx);
|
|
8320
|
+
const result = await runIn(() => callback(trxCtx));
|
|
8015
8321
|
if (driver.commit) await driver.commit(trx);
|
|
8016
8322
|
else if (driver.commitTransaction) await driver.commitTransaction(trx);
|
|
8017
8323
|
return result;
|