@objectstack/objectql 7.9.0 → 8.0.1
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.js
CHANGED
|
@@ -5103,6 +5103,7 @@ _ObjectStackProtocolImplementation.RUNTIME_CREATE_ALLOWED_TYPES = (() => {
|
|
|
5103
5103
|
var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
|
|
5104
5104
|
|
|
5105
5105
|
// src/engine.ts
|
|
5106
|
+
var import_node_async_hooks = require("async_hooks");
|
|
5106
5107
|
var import_kernel6 = require("@objectstack/spec/kernel");
|
|
5107
5108
|
var import_core = require("@objectstack/core");
|
|
5108
5109
|
var import_system2 = require("@objectstack/spec/system");
|
|
@@ -5595,7 +5596,7 @@ function optionValues(options) {
|
|
|
5595
5596
|
);
|
|
5596
5597
|
}
|
|
5597
5598
|
function validateOne(name, def, value) {
|
|
5598
|
-
if (def.required && isMissing(value)) {
|
|
5599
|
+
if (def.required && isMissing(value) && def.type !== "autonumber") {
|
|
5599
5600
|
return { field: name, code: "required", message: `${name} is required` };
|
|
5600
5601
|
}
|
|
5601
5602
|
if (isMissing(value)) return null;
|
|
@@ -5693,8 +5694,31 @@ var ajv = new import_ajv.default({ allErrors: true, strict: false });
|
|
|
5693
5694
|
var jsonSchemaCache = /* @__PURE__ */ new WeakMap();
|
|
5694
5695
|
function needsPriorRecord(objectSchema) {
|
|
5695
5696
|
const rules = objectSchema?.validations;
|
|
5696
|
-
|
|
5697
|
-
return
|
|
5697
|
+
const ruleNeeds = Array.isArray(rules) && rules.some((r) => ruleNeedsPrior(r));
|
|
5698
|
+
return !!(ruleNeeds || fieldsNeedPrior(objectSchema?.fields));
|
|
5699
|
+
}
|
|
5700
|
+
function stripReadonlyWhenFields(objectSchema, data, previous, logger) {
|
|
5701
|
+
const fields = objectSchema?.fields;
|
|
5702
|
+
if (!fields || !data) return data;
|
|
5703
|
+
const merged = { ...previous ?? {}, ...data };
|
|
5704
|
+
let result = data;
|
|
5705
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
5706
|
+
if (!def?.readonlyWhen || !(name in data)) continue;
|
|
5707
|
+
const res = import_formula2.ExpressionEngine.evaluate(toExpression(def.readonlyWhen), {
|
|
5708
|
+
record: merged,
|
|
5709
|
+
previous: previous ?? void 0
|
|
5710
|
+
});
|
|
5711
|
+
if (!res.ok) {
|
|
5712
|
+
logger?.warn?.(`readonlyWhen for '${name}' failed to evaluate \u2014 change allowed through`);
|
|
5713
|
+
continue;
|
|
5714
|
+
}
|
|
5715
|
+
if (res.value === true) {
|
|
5716
|
+
if (result === data) result = { ...data };
|
|
5717
|
+
delete result[name];
|
|
5718
|
+
logger?.warn?.(`Field '${name}' is read-only (readonlyWhen) \u2014 ignoring incoming change`);
|
|
5719
|
+
}
|
|
5720
|
+
}
|
|
5721
|
+
return result;
|
|
5698
5722
|
}
|
|
5699
5723
|
function ruleNeedsPrior(r) {
|
|
5700
5724
|
if (r == null || typeof r !== "object") return false;
|
|
@@ -5708,17 +5732,44 @@ function ruleNeedsPrior(r) {
|
|
|
5708
5732
|
}
|
|
5709
5733
|
return false;
|
|
5710
5734
|
}
|
|
5735
|
+
function isMissing2(v) {
|
|
5736
|
+
return v === void 0 || v === null || typeof v === "string" && v.trim() === "";
|
|
5737
|
+
}
|
|
5738
|
+
function fieldsNeedPrior(fields) {
|
|
5739
|
+
if (!fields) return false;
|
|
5740
|
+
return Object.values(fields).some(
|
|
5741
|
+
(f) => f && (f.requiredWhen || f.conditionalRequired || f.readonlyWhen)
|
|
5742
|
+
);
|
|
5743
|
+
}
|
|
5711
5744
|
function toExpression(cond) {
|
|
5712
5745
|
return typeof cond === "string" ? { dialect: "cel", source: cond } : cond;
|
|
5713
5746
|
}
|
|
5714
5747
|
function evaluateValidationRules(objectSchema, data, mode, opts = {}) {
|
|
5748
|
+
if (!data) return;
|
|
5715
5749
|
const rules = objectSchema?.validations;
|
|
5716
|
-
|
|
5750
|
+
const hasRules = Array.isArray(rules) && rules.length > 0;
|
|
5751
|
+
const fields = objectSchema?.fields;
|
|
5752
|
+
const hasFieldRules = fieldsNeedPrior(fields);
|
|
5753
|
+
if (!hasRules && !hasFieldRules) return;
|
|
5717
5754
|
const previous = opts.previous ?? void 0;
|
|
5718
5755
|
const merged = { ...previous ?? {}, ...data };
|
|
5719
5756
|
const ctx = { data, merged, previous, mode, logger: opts.logger };
|
|
5720
5757
|
const errors = [];
|
|
5721
|
-
|
|
5758
|
+
if (hasFieldRules && fields) {
|
|
5759
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
5760
|
+
const pred = def?.requiredWhen ?? def?.conditionalRequired;
|
|
5761
|
+
if (!pred) continue;
|
|
5762
|
+
const res = import_formula2.ExpressionEngine.evaluate(toExpression(pred), { record: merged, previous });
|
|
5763
|
+
if (!res.ok) {
|
|
5764
|
+
opts.logger?.warn?.(`requiredWhen for '${name}' failed to evaluate \u2014 skipped`);
|
|
5765
|
+
continue;
|
|
5766
|
+
}
|
|
5767
|
+
if (res.value === true && isMissing2(merged[name])) {
|
|
5768
|
+
errors.push({ field: name, code: "required", message: `${name} is required` });
|
|
5769
|
+
}
|
|
5770
|
+
}
|
|
5771
|
+
}
|
|
5772
|
+
const ordered = (hasRules ? rules : []).filter((r) => r != null && typeof r === "object").filter((r) => r.active !== false).filter((r) => {
|
|
5722
5773
|
const events = r.events ?? ["insert", "update"];
|
|
5723
5774
|
return events.includes(mode);
|
|
5724
5775
|
}).sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
|
@@ -6096,6 +6147,15 @@ function resolveMetadataItemName(key, item) {
|
|
|
6096
6147
|
}
|
|
6097
6148
|
var _ObjectQL = class _ObjectQL {
|
|
6098
6149
|
constructor(hostContext = {}) {
|
|
6150
|
+
/**
|
|
6151
|
+
* Ambient transaction store (ADR-0034). While a `transaction()` callback
|
|
6152
|
+
* runs, the active transaction handle lives here so that EVERY data
|
|
6153
|
+
* operation — including internal reads done during a write (reference
|
|
6154
|
+
* checks, hooks, expand) — automatically binds to the same connection
|
|
6155
|
+
* instead of asking the pool for another one and deadlocking on the
|
|
6156
|
+
* single-connection SQLite pool.
|
|
6157
|
+
*/
|
|
6158
|
+
this.txStore = new import_node_async_hooks.AsyncLocalStorage();
|
|
6099
6159
|
this.drivers = /* @__PURE__ */ new Map();
|
|
6100
6160
|
this.defaultDriver = null;
|
|
6101
6161
|
// Datasource mapping rules (imported from defineStack)
|
|
@@ -6137,6 +6197,12 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6137
6197
|
// getDriver()'s owner lookup would route CRUD to the wrong database. Each
|
|
6138
6198
|
// engine now owns its registry so kernels are fully isolated.
|
|
6139
6199
|
this._registry = new SchemaRegistry();
|
|
6200
|
+
/** In-memory next-value cache per `object.field` for autonumber generation,
|
|
6201
|
+
* lazily seeded from the current max in the store. */
|
|
6202
|
+
this.autonumberCounters = /* @__PURE__ */ new Map();
|
|
6203
|
+
/** Lazily-built index: child object name → roll-up summary descriptors on
|
|
6204
|
+
* parent objects that aggregate it. Invalidated when packages register. */
|
|
6205
|
+
this.summaryIndex = null;
|
|
6140
6206
|
this.hostContext = hostContext;
|
|
6141
6207
|
this.logger = hostContext.logger || (0, import_core.createLogger)({ level: "info", format: "pretty" });
|
|
6142
6208
|
if (process?.env?.OBJECTQL_STRICT_HOOKS === "1") {
|
|
@@ -6439,13 +6505,14 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6439
6505
|
* mask the system path.
|
|
6440
6506
|
*/
|
|
6441
6507
|
buildDriverOptions(execCtx, base) {
|
|
6442
|
-
const
|
|
6508
|
+
const tx = execCtx?.transaction !== void 0 ? execCtx.transaction : this.txStore.getStore()?.transaction;
|
|
6509
|
+
const hasTx = tx !== void 0;
|
|
6443
6510
|
const hasTenant = execCtx?.tenantId !== void 0;
|
|
6444
6511
|
const isSystem = execCtx?.isSystem === true;
|
|
6445
6512
|
if (!hasTx && !hasTenant && !isSystem) return base;
|
|
6446
6513
|
const opts = base && typeof base === "object" ? { ...base } : {};
|
|
6447
6514
|
if (hasTx && opts.transaction === void 0) {
|
|
6448
|
-
opts.transaction =
|
|
6515
|
+
opts.transaction = tx;
|
|
6449
6516
|
}
|
|
6450
6517
|
if (hasTenant && opts.tenantId === void 0) {
|
|
6451
6518
|
opts.tenantId = execCtx.tenantId;
|
|
@@ -6511,6 +6578,66 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6511
6578
|
}
|
|
6512
6579
|
return out;
|
|
6513
6580
|
}
|
|
6581
|
+
/**
|
|
6582
|
+
* Generate values for empty `autonumber` fields on insert — ONLY for drivers
|
|
6583
|
+
* that do not generate them natively (memory, mongodb). For SQL-backed objects
|
|
6584
|
+
* the driver owns a persistent, atomic `_objectstack_sequences` table and
|
|
6585
|
+
* advertises `supports.autonumber === true`; the engine then defers entirely
|
|
6586
|
+
* and never pre-fills (so the persistent sequence is the single source of
|
|
6587
|
+
* truth — see #1603). Required-validation exempts `autonumber` either way, so
|
|
6588
|
+
* a `required` record number is never rejected for "missing" — the runtime
|
|
6589
|
+
* owns the value, not the client.
|
|
6590
|
+
*
|
|
6591
|
+
* In the fallback path the next value is `max(existing) + 1`, seeded once per
|
|
6592
|
+
* `object.field` from the store then incremented in memory (monotonic within
|
|
6593
|
+
* the process, resilient to deletions). `autonumberFormat` is honored, e.g.
|
|
6594
|
+
* `CASE-{0000}` → `CASE-0042`. NOTE: this in-memory seeding is single-instance.
|
|
6595
|
+
*/
|
|
6596
|
+
async applyAutonumbers(object, record, execCtx, driverOwnsAutonumber) {
|
|
6597
|
+
if (driverOwnsAutonumber) return;
|
|
6598
|
+
const fields = this.getSchema(object)?.fields;
|
|
6599
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return;
|
|
6600
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
6601
|
+
if (def?.type !== "autonumber") continue;
|
|
6602
|
+
const current = record[name];
|
|
6603
|
+
if (current != null && current !== "") continue;
|
|
6604
|
+
const key = `${object}.${name}`;
|
|
6605
|
+
let next = this.autonumberCounters.get(key);
|
|
6606
|
+
if (next == null) next = await this.seedAutonumber(object, name, execCtx);
|
|
6607
|
+
next += 1;
|
|
6608
|
+
this.autonumberCounters.set(key, next);
|
|
6609
|
+
const fmt = def.autonumberFormat ?? def.format;
|
|
6610
|
+
record[name] = this.formatAutonumber(fmt, next);
|
|
6611
|
+
}
|
|
6612
|
+
}
|
|
6613
|
+
/** Seed the autonumber counter from the current max numeric value in store. */
|
|
6614
|
+
async seedAutonumber(object, field, execCtx) {
|
|
6615
|
+
try {
|
|
6616
|
+
const rows = await this.find(object, {
|
|
6617
|
+
select: ["id", field],
|
|
6618
|
+
limit: 5e3,
|
|
6619
|
+
context: execCtx
|
|
6620
|
+
});
|
|
6621
|
+
let max = 0;
|
|
6622
|
+
for (const r of rows || []) {
|
|
6623
|
+
const v = r?.[field];
|
|
6624
|
+
if (v == null) continue;
|
|
6625
|
+
const m = String(v).match(/(\d+)(?!.*\d)/);
|
|
6626
|
+
if (m) max = Math.max(max, parseInt(m[1], 10) || 0);
|
|
6627
|
+
}
|
|
6628
|
+
return max;
|
|
6629
|
+
} catch {
|
|
6630
|
+
return 0;
|
|
6631
|
+
}
|
|
6632
|
+
}
|
|
6633
|
+
/** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */
|
|
6634
|
+
formatAutonumber(format, value) {
|
|
6635
|
+
if (!format) return String(value);
|
|
6636
|
+
const m = format.match(/\{(0+)\}/);
|
|
6637
|
+
if (!m) return format.includes("{0}") ? format.replace("{0}", String(value)) : `${format}${value}`;
|
|
6638
|
+
const padded = String(value).padStart(m[1].length, "0");
|
|
6639
|
+
return format.replace(m[0], padded);
|
|
6640
|
+
}
|
|
6514
6641
|
/**
|
|
6515
6642
|
* Register contribution (Manifest)
|
|
6516
6643
|
*
|
|
@@ -6524,6 +6651,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6524
6651
|
registerApp(manifest) {
|
|
6525
6652
|
const id = manifest.id || manifest.name;
|
|
6526
6653
|
const namespace = manifest.namespace;
|
|
6654
|
+
this.invalidateSummaryIndex();
|
|
6527
6655
|
this.logger.debug("Registering package manifest", { id, namespace });
|
|
6528
6656
|
console.warn(`[ObjectQL:registerApp] id=${id} flows=${Array.isArray(manifest.flows) ? manifest.flows.length : typeof manifest.flows} keys=${Object.keys(manifest).join(",")}`);
|
|
6529
6657
|
if (id) {
|
|
@@ -6604,6 +6732,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6604
6732
|
"pages",
|
|
6605
6733
|
"dashboards",
|
|
6606
6734
|
"reports",
|
|
6735
|
+
"datasets",
|
|
6607
6736
|
"themes",
|
|
6608
6737
|
// Automation Protocol
|
|
6609
6738
|
"flows",
|
|
@@ -6748,6 +6877,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6748
6877
|
"pages",
|
|
6749
6878
|
"dashboards",
|
|
6750
6879
|
"reports",
|
|
6880
|
+
"datasets",
|
|
6751
6881
|
"themes",
|
|
6752
6882
|
"flows",
|
|
6753
6883
|
"workflows",
|
|
@@ -7129,6 +7259,102 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7129
7259
|
}
|
|
7130
7260
|
this.logger.info("ObjectQL engine destroyed");
|
|
7131
7261
|
}
|
|
7262
|
+
/** Invalidate the cached roll-up summary index (call when metadata changes). */
|
|
7263
|
+
invalidateSummaryIndex() {
|
|
7264
|
+
this.summaryIndex = null;
|
|
7265
|
+
}
|
|
7266
|
+
/** Scan all registered objects for `summary` fields and index them by the
|
|
7267
|
+
* child object they aggregate, resolving the child→parent FK field. */
|
|
7268
|
+
buildSummaryIndex() {
|
|
7269
|
+
const index = /* @__PURE__ */ new Map();
|
|
7270
|
+
let objects = [];
|
|
7271
|
+
try {
|
|
7272
|
+
objects = this._registry.getAllObjects?.() ?? [];
|
|
7273
|
+
} catch {
|
|
7274
|
+
objects = [];
|
|
7275
|
+
}
|
|
7276
|
+
for (const parent of objects) {
|
|
7277
|
+
const fields = parent?.fields;
|
|
7278
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
7279
|
+
for (const [summaryField, def] of Object.entries(fields)) {
|
|
7280
|
+
const d = def;
|
|
7281
|
+
if (d?.type !== "summary" || !d.summaryOperations) continue;
|
|
7282
|
+
const so = d.summaryOperations;
|
|
7283
|
+
const childObject = so.object;
|
|
7284
|
+
const fn = so.function;
|
|
7285
|
+
if (!childObject || !fn) continue;
|
|
7286
|
+
let fkField = so.relationshipField;
|
|
7287
|
+
if (!fkField) {
|
|
7288
|
+
const child = this._registry.getObject(childObject);
|
|
7289
|
+
const cfields = child?.fields || {};
|
|
7290
|
+
for (const [cfName, cdef] of Object.entries(cfields)) {
|
|
7291
|
+
const cd = cdef;
|
|
7292
|
+
if ((cd?.type === "master_detail" || cd?.type === "lookup") && cd?.reference === parent.name) {
|
|
7293
|
+
fkField = cfName;
|
|
7294
|
+
break;
|
|
7295
|
+
}
|
|
7296
|
+
}
|
|
7297
|
+
}
|
|
7298
|
+
if (!fkField) continue;
|
|
7299
|
+
const list = index.get(childObject) ?? [];
|
|
7300
|
+
list.push({ parentObject: parent.name, summaryField, fkField, fn, sourceField: so.field });
|
|
7301
|
+
index.set(childObject, list);
|
|
7302
|
+
}
|
|
7303
|
+
}
|
|
7304
|
+
return index;
|
|
7305
|
+
}
|
|
7306
|
+
getSummaryDescriptors(childObject) {
|
|
7307
|
+
if (!this.summaryIndex) this.summaryIndex = this.buildSummaryIndex();
|
|
7308
|
+
return this.summaryIndex.get(childObject) ?? [];
|
|
7309
|
+
}
|
|
7310
|
+
/**
|
|
7311
|
+
* Recompute roll-up `summary` fields on parent records after a child write.
|
|
7312
|
+
* For each affected parent (the FK value on the changed/old child record), it
|
|
7313
|
+
* aggregates the child collection and writes the result onto the parent's
|
|
7314
|
+
* summary field. Runs in the caller's execution context so it joins the same
|
|
7315
|
+
* transaction (e.g. the cross-object batch) when one is open.
|
|
7316
|
+
*/
|
|
7317
|
+
async recomputeSummaries(childObject, records, previous, execCtx) {
|
|
7318
|
+
const descriptors = this.getSummaryDescriptors(childObject);
|
|
7319
|
+
if (descriptors.length === 0) return;
|
|
7320
|
+
const recs = Array.isArray(records) ? records : records ? [records] : [];
|
|
7321
|
+
const prevs = Array.isArray(previous) ? previous : previous ? [previous] : [];
|
|
7322
|
+
for (const desc of descriptors) {
|
|
7323
|
+
const ids = /* @__PURE__ */ new Set();
|
|
7324
|
+
for (const r of recs) {
|
|
7325
|
+
const v = r?.[desc.fkField];
|
|
7326
|
+
if (v != null && v !== "") ids.add(String(v));
|
|
7327
|
+
}
|
|
7328
|
+
for (const p of prevs) {
|
|
7329
|
+
const v = p?.[desc.fkField];
|
|
7330
|
+
if (v != null && v !== "") ids.add(String(v));
|
|
7331
|
+
}
|
|
7332
|
+
for (const parentId of ids) {
|
|
7333
|
+
try {
|
|
7334
|
+
const rows = await this.aggregate(childObject, {
|
|
7335
|
+
where: { [desc.fkField]: parentId },
|
|
7336
|
+
aggregations: [{
|
|
7337
|
+
function: desc.fn,
|
|
7338
|
+
...desc.fn === "count" ? {} : { field: desc.sourceField },
|
|
7339
|
+
alias: "value"
|
|
7340
|
+
}],
|
|
7341
|
+
context: execCtx
|
|
7342
|
+
});
|
|
7343
|
+
let value = rows?.[0]?.value;
|
|
7344
|
+
if (value == null) value = desc.fn === "count" || desc.fn === "sum" ? 0 : null;
|
|
7345
|
+
await this.update(desc.parentObject, { id: parentId, [desc.summaryField]: value }, { context: execCtx });
|
|
7346
|
+
} catch (err) {
|
|
7347
|
+
this.logger.warn("Roll-up summary recompute failed", {
|
|
7348
|
+
childObject,
|
|
7349
|
+
parentObject: desc.parentObject,
|
|
7350
|
+
parentId,
|
|
7351
|
+
field: desc.summaryField,
|
|
7352
|
+
error: err?.message
|
|
7353
|
+
});
|
|
7354
|
+
}
|
|
7355
|
+
}
|
|
7356
|
+
}
|
|
7357
|
+
}
|
|
7132
7358
|
/**
|
|
7133
7359
|
* Post-process expand: resolve lookup/master_detail fields by batch-loading related records.
|
|
7134
7360
|
*
|
|
@@ -7344,10 +7570,14 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7344
7570
|
let result;
|
|
7345
7571
|
const nowSnap = /* @__PURE__ */ new Date();
|
|
7346
7572
|
const schemaForValidation = this._registry.getObject(object);
|
|
7573
|
+
const driverOwnsAutonumber = driver?.supports?.autonumber === true;
|
|
7347
7574
|
if (Array.isArray(hookContext.input.data)) {
|
|
7348
7575
|
const rows = hookContext.input.data.map(
|
|
7349
7576
|
(row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
|
|
7350
7577
|
);
|
|
7578
|
+
for (const r of rows) {
|
|
7579
|
+
await this.applyAutonumbers(object, r, opCtx.context, driverOwnsAutonumber);
|
|
7580
|
+
}
|
|
7351
7581
|
for (const r of rows) {
|
|
7352
7582
|
await this.encryptSecretFields(object, r, opCtx.context, hookContext.input.options);
|
|
7353
7583
|
}
|
|
@@ -7367,6 +7597,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7367
7597
|
opCtx.context,
|
|
7368
7598
|
nowSnap
|
|
7369
7599
|
);
|
|
7600
|
+
await this.applyAutonumbers(object, row, opCtx.context, driverOwnsAutonumber);
|
|
7370
7601
|
await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options);
|
|
7371
7602
|
validateRecord(schemaForValidation, row, "insert");
|
|
7372
7603
|
evaluateValidationRules(schemaForValidation, row, "insert", { logger: this.logger });
|
|
@@ -7375,6 +7606,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7375
7606
|
hookContext.event = "afterInsert";
|
|
7376
7607
|
hookContext.result = result;
|
|
7377
7608
|
await this.triggerHooks("afterInsert", hookContext);
|
|
7609
|
+
await this.recomputeSummaries(object, result, null, opCtx.context);
|
|
7378
7610
|
if (this.realtimeService) {
|
|
7379
7611
|
try {
|
|
7380
7612
|
if (Array.isArray(result)) {
|
|
@@ -7459,6 +7691,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7459
7691
|
const priorAst = { object, where: { id: hookContext.input.id }, limit: 1 };
|
|
7460
7692
|
priorRecord = await driver.findOne(object, priorAst, hookContext.input.options);
|
|
7461
7693
|
}
|
|
7694
|
+
hookContext.input.data = stripReadonlyWhenFields(updateSchema, hookContext.input.data, priorRecord, this.logger);
|
|
7462
7695
|
evaluateValidationRules(updateSchema, hookContext.input.data, "update", { previous: priorRecord, logger: this.logger });
|
|
7463
7696
|
result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
|
|
7464
7697
|
} else if (options?.multi && driver.updateMany) {
|
|
@@ -7476,6 +7709,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7476
7709
|
hookContext.result = result;
|
|
7477
7710
|
if (priorRecord) hookContext.previous = priorRecord;
|
|
7478
7711
|
await this.triggerHooks("afterUpdate", hookContext);
|
|
7712
|
+
await this.recomputeSummaries(object, result, priorRecord, opCtx.context);
|
|
7479
7713
|
if (this.realtimeService) {
|
|
7480
7714
|
try {
|
|
7481
7715
|
const resultId = typeof result === "object" && result && "id" in result ? result.id : void 0;
|
|
@@ -7504,6 +7738,66 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7504
7738
|
});
|
|
7505
7739
|
return opCtx.result;
|
|
7506
7740
|
}
|
|
7741
|
+
/**
|
|
7742
|
+
* Apply referential delete behavior for relations pointing AT this record,
|
|
7743
|
+
* before it is removed. For every registered object with a `master_detail`
|
|
7744
|
+
* or `lookup` field referencing `object`, honor the field's `deleteBehavior`:
|
|
7745
|
+
* - `cascade` → delete the dependent rows (recursively, so grandchildren
|
|
7746
|
+
* are handled by each child's own delete),
|
|
7747
|
+
* - `set_null` → clear the foreign key,
|
|
7748
|
+
* - `restrict` → refuse the delete when dependents exist.
|
|
7749
|
+
* `master_detail` defaults to `cascade` (the parent owns the child
|
|
7750
|
+
* lifecycle); `lookup` defaults to `set_null`. Only runs for single-id
|
|
7751
|
+
* deletes — multi/predicate deletes skip cascade (logged).
|
|
7752
|
+
*/
|
|
7753
|
+
async cascadeDeleteRelations(object, id, context, depth = 0) {
|
|
7754
|
+
if (id == null || depth >= _ObjectQL.MAX_CASCADE_DEPTH) return;
|
|
7755
|
+
let objects;
|
|
7756
|
+
try {
|
|
7757
|
+
objects = this._registry.getAllObjects();
|
|
7758
|
+
} catch {
|
|
7759
|
+
return;
|
|
7760
|
+
}
|
|
7761
|
+
for (const child of objects) {
|
|
7762
|
+
const childName = child?.name;
|
|
7763
|
+
const fields = child?.fields;
|
|
7764
|
+
if (!childName || !fields) continue;
|
|
7765
|
+
for (const [fieldName, fdef] of Object.entries(fields)) {
|
|
7766
|
+
if (!fdef || fdef.type !== "master_detail" && fdef.type !== "lookup") continue;
|
|
7767
|
+
const ref = fdef.reference;
|
|
7768
|
+
if (!ref) continue;
|
|
7769
|
+
let resolvedRef;
|
|
7770
|
+
try {
|
|
7771
|
+
resolvedRef = this.resolveObjectName(ref);
|
|
7772
|
+
} catch {
|
|
7773
|
+
resolvedRef = void 0;
|
|
7774
|
+
}
|
|
7775
|
+
if (ref !== object && resolvedRef !== object) continue;
|
|
7776
|
+
const behavior = fdef.type === "master_detail" ? fdef.deleteBehavior === "restrict" ? "restrict" : "cascade" : fdef.deleteBehavior || "set_null";
|
|
7777
|
+
let dependents;
|
|
7778
|
+
try {
|
|
7779
|
+
dependents = await this.find(childName, { where: { [fieldName]: id }, context });
|
|
7780
|
+
} catch {
|
|
7781
|
+
continue;
|
|
7782
|
+
}
|
|
7783
|
+
if (!dependents || dependents.length === 0) continue;
|
|
7784
|
+
if (behavior === "restrict") {
|
|
7785
|
+
throw new Error(
|
|
7786
|
+
`Cannot delete ${object} (${id}): ${dependents.length} dependent ${childName} record(s) via ${fieldName}`
|
|
7787
|
+
);
|
|
7788
|
+
}
|
|
7789
|
+
for (const dep of dependents) {
|
|
7790
|
+
const depId = dep?.id;
|
|
7791
|
+
if (depId == null) continue;
|
|
7792
|
+
if (behavior === "cascade") {
|
|
7793
|
+
await this.delete(childName, { where: { id: depId }, context });
|
|
7794
|
+
} else {
|
|
7795
|
+
await this.update(childName, { id: depId, [fieldName]: null }, { context });
|
|
7796
|
+
}
|
|
7797
|
+
}
|
|
7798
|
+
}
|
|
7799
|
+
}
|
|
7800
|
+
}
|
|
7507
7801
|
async delete(object, options) {
|
|
7508
7802
|
object = this.resolveObjectName(object);
|
|
7509
7803
|
this.logger.debug("Delete operation starting", { object });
|
|
@@ -7533,7 +7827,15 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7533
7827
|
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
|
|
7534
7828
|
try {
|
|
7535
7829
|
let result;
|
|
7830
|
+
let summaryPrev = null;
|
|
7831
|
+
if (hookContext.input.id && this.getSummaryDescriptors(object).length > 0) {
|
|
7832
|
+
try {
|
|
7833
|
+
summaryPrev = await this.findOne(object, { where: { id: hookContext.input.id }, context: opCtx.context });
|
|
7834
|
+
} catch {
|
|
7835
|
+
}
|
|
7836
|
+
}
|
|
7536
7837
|
if (hookContext.input.id) {
|
|
7838
|
+
await this.cascadeDeleteRelations(object, hookContext.input.id, opCtx.context);
|
|
7537
7839
|
result = await driver.delete(object, hookContext.input.id, hookContext.input.options);
|
|
7538
7840
|
} else if (options?.multi && driver.deleteMany) {
|
|
7539
7841
|
const ast = { object, where: options.where };
|
|
@@ -7544,6 +7846,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7544
7846
|
hookContext.event = "afterDelete";
|
|
7545
7847
|
hookContext.result = result;
|
|
7546
7848
|
await this.triggerHooks("afterDelete", hookContext);
|
|
7849
|
+
if (summaryPrev) await this.recomputeSummaries(object, null, summaryPrev, opCtx.context);
|
|
7547
7850
|
if (this.realtimeService) {
|
|
7548
7851
|
try {
|
|
7549
7852
|
const resultId = typeof result === "object" && result && "id" in result ? result.id : void 0;
|
|
@@ -7693,7 +7996,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7693
7996
|
const trx = await drv.beginTransaction();
|
|
7694
7997
|
const trxCtx = { ...baseContext ?? {}, transaction: trx };
|
|
7695
7998
|
try {
|
|
7696
|
-
const result = await callback(trxCtx);
|
|
7999
|
+
const result = await this.txStore.run({ transaction: trx }, () => callback(trxCtx));
|
|
7697
8000
|
if (drv.commit) await drv.commit(trx);
|
|
7698
8001
|
else if (drv.commitTransaction) await drv.commitTransaction(trx);
|
|
7699
8002
|
return result;
|
|
@@ -7957,6 +8260,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
7957
8260
|
// ============================================
|
|
7958
8261
|
/** Maximum depth for recursive expand to prevent infinite loops */
|
|
7959
8262
|
_ObjectQL.MAX_EXPAND_DEPTH = 3;
|
|
8263
|
+
_ObjectQL.MAX_CASCADE_DEPTH = 10;
|
|
7960
8264
|
var ObjectQL = _ObjectQL;
|
|
7961
8265
|
var ObjectRepository = class {
|
|
7962
8266
|
constructor(objectName, context, engine) {
|
|
@@ -8074,8 +8378,10 @@ var ScopedContext = class _ScopedContext {
|
|
|
8074
8378
|
{ ...this.executionContext, transaction: trx },
|
|
8075
8379
|
this.engine
|
|
8076
8380
|
);
|
|
8381
|
+
const txStore = this.engine?.txStore;
|
|
8382
|
+
const runIn = (fn) => txStore ? txStore.run({ transaction: trx }, fn) : fn();
|
|
8077
8383
|
try {
|
|
8078
|
-
const result = await callback(trxCtx);
|
|
8384
|
+
const result = await runIn(() => callback(trxCtx));
|
|
8079
8385
|
if (driver.commit) await driver.commit(trx);
|
|
8080
8386
|
else if (driver.commitTransaction) await driver.commitTransaction(trx);
|
|
8081
8387
|
return result;
|