@objectstack/objectql 7.8.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.js CHANGED
@@ -2450,6 +2450,44 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2450
2450
  }
2451
2451
  } catch {
2452
2452
  }
2453
+ if (request.previewDrafts) {
2454
+ try {
2455
+ const orgId = request.organizationId;
2456
+ const queryDrafts = async (oid) => {
2457
+ const whereClause = { type: request.type, state: "draft", organization_id: oid };
2458
+ if (packageId) whereClause.package_id = packageId;
2459
+ let rs = await this.engine.find("sys_metadata", { where: whereClause });
2460
+ if (!rs || rs.length === 0) {
2461
+ const alt = import_shared4.PLURAL_TO_SINGULAR[request.type] ?? import_shared4.SINGULAR_TO_PLURAL[request.type];
2462
+ if (alt) {
2463
+ const altWhere = { type: alt, state: "draft", organization_id: oid };
2464
+ if (packageId) altWhere.package_id = packageId;
2465
+ rs = await this.engine.find("sys_metadata", { where: altWhere });
2466
+ }
2467
+ }
2468
+ return rs ?? [];
2469
+ };
2470
+ const draftRecords = [...await queryDrafts(null), ...orgId ? await queryDrafts(orgId) : []];
2471
+ if (draftRecords.length > 0) {
2472
+ const byName = /* @__PURE__ */ new Map();
2473
+ for (const existing of items) {
2474
+ const entry = existing;
2475
+ if (entry && typeof entry === "object" && "name" in entry) byName.set(entry.name, entry);
2476
+ }
2477
+ for (const record of draftRecords) {
2478
+ const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
2479
+ if (data && typeof data === "object" && "name" in data) {
2480
+ const recPkg = record.package_id ?? void 0;
2481
+ if (recPkg && data._packageId === void 0) data._packageId = recPkg;
2482
+ data._draft = true;
2483
+ byName.set(data.name, data);
2484
+ }
2485
+ }
2486
+ items = Array.from(byName.values());
2487
+ }
2488
+ } catch {
2489
+ }
2490
+ }
2453
2491
  try {
2454
2492
  const services = this.getServicesRegistry?.();
2455
2493
  const metadataService = services?.get("metadata");
@@ -2508,6 +2546,34 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2508
2546
  let item;
2509
2547
  const orgId = request.organizationId;
2510
2548
  const readState = request.state === "draft" ? "draft" : "active";
2549
+ if (request.previewDrafts && readState !== "draft") {
2550
+ try {
2551
+ const findDraft = async (oid) => {
2552
+ const rec = await this.engine.findOne("sys_metadata", {
2553
+ where: { type: request.type, name: request.name, state: "draft", organization_id: oid }
2554
+ });
2555
+ if (rec) return rec;
2556
+ const alt = import_shared4.PLURAL_TO_SINGULAR[request.type] ?? import_shared4.SINGULAR_TO_PLURAL[request.type];
2557
+ if (alt) {
2558
+ return await this.engine.findOne("sys_metadata", {
2559
+ where: { type: alt, name: request.name, state: "draft", organization_id: oid }
2560
+ });
2561
+ }
2562
+ return void 0;
2563
+ };
2564
+ const draftRec = (orgId ? await findDraft(orgId) : void 0) ?? await findDraft(null);
2565
+ if (draftRec) {
2566
+ const draftItem = typeof draftRec.metadata === "string" ? JSON.parse(draftRec.metadata) : draftRec.metadata;
2567
+ if (draftItem && typeof draftItem === "object") {
2568
+ const recPkg = draftRec.package_id ?? void 0;
2569
+ if (recPkg && draftItem._packageId === void 0) draftItem._packageId = recPkg;
2570
+ draftItem._draft = true;
2571
+ }
2572
+ return { type: request.type, name: request.name, item: decorateMetadataItem(request.type, draftItem) };
2573
+ }
2574
+ } catch {
2575
+ }
2576
+ }
2511
2577
  try {
2512
2578
  const findOverlay = async (oid) => {
2513
2579
  const where = {
@@ -3882,6 +3948,37 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3882
3948
  console.warn(`[Protocol] table sync failed for object '${name}': ${err?.message ?? err}`);
3883
3949
  }
3884
3950
  }
3951
+ /**
3952
+ * Inverse of {@link ensureObjectStorage}: drop an object's physical table.
3953
+ * DESTRUCTIVE — deletes the table and all its rows. Only invoked when a
3954
+ * delete explicitly opts into storage teardown (see {@link deleteMetaItem}'s
3955
+ * `dropStorage`), so publishing an object solely to preview it can be undone
3956
+ * without leaving an orphan table. Best-effort: a failure is logged, not
3957
+ * thrown — the metadata delete already succeeded, and a stray table is
3958
+ * reclaimed by the next sync/drop rather than blocking the delete.
3959
+ */
3960
+ async dropObjectStorage(type, name) {
3961
+ if (type !== "object" && type !== "objects") return;
3962
+ try {
3963
+ await this.engine.dropObjectSchema(name);
3964
+ } catch (err) {
3965
+ console.warn(`[Protocol] table drop failed for object '${name}': ${err?.message ?? err}`);
3966
+ }
3967
+ }
3968
+ /**
3969
+ * Guard for storage teardown on delete. Drops a physical table only when
3970
+ * the caller opted in AND it is safe: object types only (others have no
3971
+ * table), active state only (drafts were never materialised), and never a
3972
+ * `sys_`-prefixed platform table.
3973
+ */
3974
+ shouldDropStorage(type, name, dropStorage, state) {
3975
+ if (!dropStorage) return false;
3976
+ const singular = import_shared4.PLURAL_TO_SINGULAR[type] ?? type;
3977
+ if (singular !== "object") return false;
3978
+ if (state !== "active") return false;
3979
+ if (name.startsWith("sys_")) return false;
3980
+ return true;
3981
+ }
3885
3982
  async saveMetaItem(request) {
3886
3983
  if (!request.item) {
3887
3984
  throw new Error("Item data is required");
@@ -4262,6 +4359,100 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
4262
4359
  failed
4263
4360
  };
4264
4361
  }
4362
+ /**
4363
+ * Discard every pending DRAFT bound to a package — the NON-destructive
4364
+ * inverse of {@link publishPackageDrafts}. Drops only `state='draft'` rows
4365
+ * (via the per-item delete primitive), reverting the package to its last
4366
+ * published baseline; active/published metadata and physical tables are
4367
+ * left untouched.
4368
+ *
4369
+ * Use case: "I edited this app for a while and it turned out worse than
4370
+ * before — abandon all my changes." Routes through the sys_metadata path
4371
+ * (no metadata-service dependency, unlike `POST /packages/:id/revert`).
4372
+ */
4373
+ async discardPackageDrafts(request) {
4374
+ await this.ensureOverlayIndex();
4375
+ const orgId = request.organizationId ?? null;
4376
+ const repo = this.getOverlayRepo(orgId);
4377
+ const drafts = await repo.listDrafts({ packageId: request.packageId });
4378
+ const discarded = [];
4379
+ const failed = [];
4380
+ for (const d of drafts) {
4381
+ try {
4382
+ await this.deleteMetaItem({
4383
+ type: d.type,
4384
+ name: d.name,
4385
+ state: "draft",
4386
+ ...request.organizationId ? { organizationId: request.organizationId } : {},
4387
+ ...request.actor ? { actor: request.actor } : {}
4388
+ });
4389
+ discarded.push({ type: d.type, name: d.name });
4390
+ } catch (e) {
4391
+ failed.push({
4392
+ type: d.type,
4393
+ name: d.name,
4394
+ error: e?.message ?? "discard failed",
4395
+ ...e?.code ? { code: e.code } : {}
4396
+ });
4397
+ }
4398
+ }
4399
+ return {
4400
+ success: failed.length === 0 && discarded.length > 0,
4401
+ discardedCount: discarded.length,
4402
+ failedCount: failed.length,
4403
+ discarded,
4404
+ failed
4405
+ };
4406
+ }
4407
+ /**
4408
+ * Delete an ENTIRE package: every `sys_metadata` row bound to it (active
4409
+ * AND draft) and — by default — the physical table of each object it
4410
+ * defined. DESTRUCTIVE: removes the app and its data. Use case: "I don't
4411
+ * want this package anymore."
4412
+ *
4413
+ * Set `keepData: true` to remove the metadata but preserve object tables.
4414
+ * The `sys_`-table guard in {@link deleteMetaItem} still applies, so
4415
+ * platform storage is never dropped. Drafts are removed before active rows
4416
+ * so each object's table is torn down once. Per-item failures are collected
4417
+ * without aborting the rest.
4418
+ */
4419
+ async deletePackage(request) {
4420
+ const where = { package_id: request.packageId };
4421
+ if (request.organizationId) where.organization_id = request.organizationId;
4422
+ const rows = await this.engine.find("sys_metadata", { where });
4423
+ const dropStorage = request.keepData !== true;
4424
+ const ordered = [...rows].sort((a, b) => (a.state === "draft" ? 0 : 1) - (b.state === "draft" ? 0 : 1));
4425
+ const deleted = [];
4426
+ const failed = [];
4427
+ for (const row of ordered) {
4428
+ const state = row.state === "draft" ? "draft" : "active";
4429
+ try {
4430
+ await this.deleteMetaItem({
4431
+ type: row.type,
4432
+ name: row.name,
4433
+ state,
4434
+ ...row.organization_id ? { organizationId: row.organization_id } : {},
4435
+ ...request.actor ? { actor: request.actor } : {},
4436
+ ...dropStorage ? { dropStorage: true } : {}
4437
+ });
4438
+ deleted.push({ type: row.type, name: row.name, state });
4439
+ } catch (e) {
4440
+ failed.push({
4441
+ type: row.type,
4442
+ name: row.name,
4443
+ error: e?.message ?? "delete failed",
4444
+ ...e?.code ? { code: e.code } : {}
4445
+ });
4446
+ }
4447
+ }
4448
+ return {
4449
+ success: failed.length === 0 && deleted.length > 0,
4450
+ deletedCount: deleted.length,
4451
+ failedCount: failed.length,
4452
+ deleted,
4453
+ failed
4454
+ };
4455
+ }
4265
4456
  /**
4266
4457
  * Restore the body recorded at history `toVersion` as the new
4267
4458
  * live row. Writes a history event with `op='revert'`. 404
@@ -4494,6 +4685,9 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
4494
4685
  } catch {
4495
4686
  }
4496
4687
  }
4688
+ if (this.shouldDropStorage(request.type, request.name, request.dropStorage, targetState)) {
4689
+ await this.dropObjectStorage(singularTypeForRepo, request.name);
4690
+ }
4497
4691
  await this.recordMetadataAudit({
4498
4692
  type: request.type,
4499
4693
  name: request.name,
@@ -4542,6 +4736,12 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
4542
4736
  };
4543
4737
  }
4544
4738
  await this.engine.delete("sys_metadata", { where: { id: existing.id } });
4739
+ {
4740
+ const targetState = request.state === "draft" ? "draft" : "active";
4741
+ if (this.shouldDropStorage(request.type, request.name, request.dropStorage, targetState)) {
4742
+ await this.dropObjectStorage(import_shared4.PLURAL_TO_SINGULAR[request.type] ?? request.type, request.name);
4743
+ }
4744
+ }
4545
4745
  if (this.environmentId === void 0) {
4546
4746
  try {
4547
4747
  const services = this.getServicesRegistry?.();
@@ -4903,6 +5103,7 @@ _ObjectStackProtocolImplementation.RUNTIME_CREATE_ALLOWED_TYPES = (() => {
4903
5103
  var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
4904
5104
 
4905
5105
  // src/engine.ts
5106
+ var import_node_async_hooks = require("async_hooks");
4906
5107
  var import_kernel6 = require("@objectstack/spec/kernel");
4907
5108
  var import_core = require("@objectstack/core");
4908
5109
  var import_system2 = require("@objectstack/spec/system");
@@ -5395,7 +5596,7 @@ function optionValues(options) {
5395
5596
  );
5396
5597
  }
5397
5598
  function validateOne(name, def, value) {
5398
- if (def.required && isMissing(value)) {
5599
+ if (def.required && isMissing(value) && def.type !== "autonumber") {
5399
5600
  return { field: name, code: "required", message: `${name} is required` };
5400
5601
  }
5401
5602
  if (isMissing(value)) return null;
@@ -5493,8 +5694,31 @@ var ajv = new import_ajv.default({ allErrors: true, strict: false });
5493
5694
  var jsonSchemaCache = /* @__PURE__ */ new WeakMap();
5494
5695
  function needsPriorRecord(objectSchema) {
5495
5696
  const rules = objectSchema?.validations;
5496
- if (!Array.isArray(rules)) return false;
5497
- return rules.some((r) => ruleNeedsPrior(r));
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;
5498
5722
  }
5499
5723
  function ruleNeedsPrior(r) {
5500
5724
  if (r == null || typeof r !== "object") return false;
@@ -5508,17 +5732,44 @@ function ruleNeedsPrior(r) {
5508
5732
  }
5509
5733
  return false;
5510
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
+ }
5511
5744
  function toExpression(cond) {
5512
5745
  return typeof cond === "string" ? { dialect: "cel", source: cond } : cond;
5513
5746
  }
5514
5747
  function evaluateValidationRules(objectSchema, data, mode, opts = {}) {
5748
+ if (!data) return;
5515
5749
  const rules = objectSchema?.validations;
5516
- if (!Array.isArray(rules) || rules.length === 0 || !data) return;
5750
+ const hasRules = Array.isArray(rules) && rules.length > 0;
5751
+ const fields = objectSchema?.fields;
5752
+ const hasFieldRules = fieldsNeedPrior(fields);
5753
+ if (!hasRules && !hasFieldRules) return;
5517
5754
  const previous = opts.previous ?? void 0;
5518
5755
  const merged = { ...previous ?? {}, ...data };
5519
5756
  const ctx = { data, merged, previous, mode, logger: opts.logger };
5520
5757
  const errors = [];
5521
- const ordered = rules.filter((r) => r != null && typeof r === "object").filter((r) => r.active !== false).filter((r) => {
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) => {
5522
5773
  const events = r.events ?? ["insert", "update"];
5523
5774
  return events.includes(mode);
5524
5775
  }).sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
@@ -5896,6 +6147,15 @@ function resolveMetadataItemName(key, item) {
5896
6147
  }
5897
6148
  var _ObjectQL = class _ObjectQL {
5898
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();
5899
6159
  this.drivers = /* @__PURE__ */ new Map();
5900
6160
  this.defaultDriver = null;
5901
6161
  // Datasource mapping rules (imported from defineStack)
@@ -5937,6 +6197,12 @@ var _ObjectQL = class _ObjectQL {
5937
6197
  // getDriver()'s owner lookup would route CRUD to the wrong database. Each
5938
6198
  // engine now owns its registry so kernels are fully isolated.
5939
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;
5940
6206
  this.hostContext = hostContext;
5941
6207
  this.logger = hostContext.logger || (0, import_core.createLogger)({ level: "info", format: "pretty" });
5942
6208
  if (process?.env?.OBJECTQL_STRICT_HOOKS === "1") {
@@ -6239,13 +6505,14 @@ var _ObjectQL = class _ObjectQL {
6239
6505
  * mask the system path.
6240
6506
  */
6241
6507
  buildDriverOptions(execCtx, base) {
6242
- const hasTx = execCtx?.transaction !== void 0;
6508
+ const tx = execCtx?.transaction !== void 0 ? execCtx.transaction : this.txStore.getStore()?.transaction;
6509
+ const hasTx = tx !== void 0;
6243
6510
  const hasTenant = execCtx?.tenantId !== void 0;
6244
6511
  const isSystem = execCtx?.isSystem === true;
6245
6512
  if (!hasTx && !hasTenant && !isSystem) return base;
6246
6513
  const opts = base && typeof base === "object" ? { ...base } : {};
6247
6514
  if (hasTx && opts.transaction === void 0) {
6248
- opts.transaction = execCtx.transaction;
6515
+ opts.transaction = tx;
6249
6516
  }
6250
6517
  if (hasTenant && opts.tenantId === void 0) {
6251
6518
  opts.tenantId = execCtx.tenantId;
@@ -6311,6 +6578,66 @@ var _ObjectQL = class _ObjectQL {
6311
6578
  }
6312
6579
  return out;
6313
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
+ }
6314
6641
  /**
6315
6642
  * Register contribution (Manifest)
6316
6643
  *
@@ -6324,6 +6651,7 @@ var _ObjectQL = class _ObjectQL {
6324
6651
  registerApp(manifest) {
6325
6652
  const id = manifest.id || manifest.name;
6326
6653
  const namespace = manifest.namespace;
6654
+ this.invalidateSummaryIndex();
6327
6655
  this.logger.debug("Registering package manifest", { id, namespace });
6328
6656
  console.warn(`[ObjectQL:registerApp] id=${id} flows=${Array.isArray(manifest.flows) ? manifest.flows.length : typeof manifest.flows} keys=${Object.keys(manifest).join(",")}`);
6329
6657
  if (id) {
@@ -6404,6 +6732,7 @@ var _ObjectQL = class _ObjectQL {
6404
6732
  "pages",
6405
6733
  "dashboards",
6406
6734
  "reports",
6735
+ "datasets",
6407
6736
  "themes",
6408
6737
  // Automation Protocol
6409
6738
  "flows",
@@ -6548,6 +6877,7 @@ var _ObjectQL = class _ObjectQL {
6548
6877
  "pages",
6549
6878
  "dashboards",
6550
6879
  "reports",
6880
+ "datasets",
6551
6881
  "themes",
6552
6882
  "flows",
6553
6883
  "workflows",
@@ -6929,6 +7259,102 @@ var _ObjectQL = class _ObjectQL {
6929
7259
  }
6930
7260
  this.logger.info("ObjectQL engine destroyed");
6931
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
+ }
6932
7358
  /**
6933
7359
  * Post-process expand: resolve lookup/master_detail fields by batch-loading related records.
6934
7360
  *
@@ -7144,10 +7570,14 @@ var _ObjectQL = class _ObjectQL {
7144
7570
  let result;
7145
7571
  const nowSnap = /* @__PURE__ */ new Date();
7146
7572
  const schemaForValidation = this._registry.getObject(object);
7573
+ const driverOwnsAutonumber = driver?.supports?.autonumber === true;
7147
7574
  if (Array.isArray(hookContext.input.data)) {
7148
7575
  const rows = hookContext.input.data.map(
7149
7576
  (row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
7150
7577
  );
7578
+ for (const r of rows) {
7579
+ await this.applyAutonumbers(object, r, opCtx.context, driverOwnsAutonumber);
7580
+ }
7151
7581
  for (const r of rows) {
7152
7582
  await this.encryptSecretFields(object, r, opCtx.context, hookContext.input.options);
7153
7583
  }
@@ -7167,6 +7597,7 @@ var _ObjectQL = class _ObjectQL {
7167
7597
  opCtx.context,
7168
7598
  nowSnap
7169
7599
  );
7600
+ await this.applyAutonumbers(object, row, opCtx.context, driverOwnsAutonumber);
7170
7601
  await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options);
7171
7602
  validateRecord(schemaForValidation, row, "insert");
7172
7603
  evaluateValidationRules(schemaForValidation, row, "insert", { logger: this.logger });
@@ -7175,6 +7606,7 @@ var _ObjectQL = class _ObjectQL {
7175
7606
  hookContext.event = "afterInsert";
7176
7607
  hookContext.result = result;
7177
7608
  await this.triggerHooks("afterInsert", hookContext);
7609
+ await this.recomputeSummaries(object, result, null, opCtx.context);
7178
7610
  if (this.realtimeService) {
7179
7611
  try {
7180
7612
  if (Array.isArray(result)) {
@@ -7259,6 +7691,7 @@ var _ObjectQL = class _ObjectQL {
7259
7691
  const priorAst = { object, where: { id: hookContext.input.id }, limit: 1 };
7260
7692
  priorRecord = await driver.findOne(object, priorAst, hookContext.input.options);
7261
7693
  }
7694
+ hookContext.input.data = stripReadonlyWhenFields(updateSchema, hookContext.input.data, priorRecord, this.logger);
7262
7695
  evaluateValidationRules(updateSchema, hookContext.input.data, "update", { previous: priorRecord, logger: this.logger });
7263
7696
  result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
7264
7697
  } else if (options?.multi && driver.updateMany) {
@@ -7276,6 +7709,7 @@ var _ObjectQL = class _ObjectQL {
7276
7709
  hookContext.result = result;
7277
7710
  if (priorRecord) hookContext.previous = priorRecord;
7278
7711
  await this.triggerHooks("afterUpdate", hookContext);
7712
+ await this.recomputeSummaries(object, result, priorRecord, opCtx.context);
7279
7713
  if (this.realtimeService) {
7280
7714
  try {
7281
7715
  const resultId = typeof result === "object" && result && "id" in result ? result.id : void 0;
@@ -7304,6 +7738,66 @@ var _ObjectQL = class _ObjectQL {
7304
7738
  });
7305
7739
  return opCtx.result;
7306
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
+ }
7307
7801
  async delete(object, options) {
7308
7802
  object = this.resolveObjectName(object);
7309
7803
  this.logger.debug("Delete operation starting", { object });
@@ -7333,7 +7827,15 @@ var _ObjectQL = class _ObjectQL {
7333
7827
  hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
7334
7828
  try {
7335
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
+ }
7336
7837
  if (hookContext.input.id) {
7838
+ await this.cascadeDeleteRelations(object, hookContext.input.id, opCtx.context);
7337
7839
  result = await driver.delete(object, hookContext.input.id, hookContext.input.options);
7338
7840
  } else if (options?.multi && driver.deleteMany) {
7339
7841
  const ast = { object, where: options.where };
@@ -7344,6 +7846,7 @@ var _ObjectQL = class _ObjectQL {
7344
7846
  hookContext.event = "afterDelete";
7345
7847
  hookContext.result = result;
7346
7848
  await this.triggerHooks("afterDelete", hookContext);
7849
+ if (summaryPrev) await this.recomputeSummaries(object, null, summaryPrev, opCtx.context);
7347
7850
  if (this.realtimeService) {
7348
7851
  try {
7349
7852
  const resultId = typeof result === "object" && result && "id" in result ? result.id : void 0;
@@ -7493,7 +7996,7 @@ var _ObjectQL = class _ObjectQL {
7493
7996
  const trx = await drv.beginTransaction();
7494
7997
  const trxCtx = { ...baseContext ?? {}, transaction: trx };
7495
7998
  try {
7496
- const result = await callback(trxCtx);
7999
+ const result = await this.txStore.run({ transaction: trx }, () => callback(trxCtx));
7497
8000
  if (drv.commit) await drv.commit(trx);
7498
8001
  else if (drv.commitTransaction) await drv.commitTransaction(trx);
7499
8002
  return result;
@@ -7643,6 +8146,22 @@ var _ObjectQL = class _ObjectQL {
7643
8146
  const tableName = import_system2.StorageNameMapping.resolveTableName(obj);
7644
8147
  await driver.syncSchema(tableName, obj);
7645
8148
  }
8149
+ /**
8150
+ * Drop the physical storage (table/collection) backing an object — the
8151
+ * inverse of {@link syncObjectSchema}. DESTRUCTIVE: deletes all rows in the
8152
+ * table. Used by the protocol delete path when the caller explicitly opts
8153
+ * into storage teardown (e.g. discarding an object that was published only
8154
+ * to preview it). No-op when the object's driver does not expose `dropTable`.
8155
+ * Resolves the physical table name from the registered definition, falling
8156
+ * back to the bare name if the def was already removed.
8157
+ */
8158
+ async dropObjectSchema(objectName) {
8159
+ const obj = this._registry.getObject(objectName);
8160
+ const driver = this.getDriverForObject(objectName);
8161
+ if (!driver || typeof driver.dropTable !== "function") return;
8162
+ const tableName = import_system2.StorageNameMapping.resolveTableName(obj ?? { name: objectName });
8163
+ await driver.dropTable(tableName);
8164
+ }
7646
8165
  /**
7647
8166
  * Get a registered driver by datasource name.
7648
8167
  * Alias matching @objectql/core datasource() API.
@@ -7741,6 +8260,7 @@ var _ObjectQL = class _ObjectQL {
7741
8260
  // ============================================
7742
8261
  /** Maximum depth for recursive expand to prevent infinite loops */
7743
8262
  _ObjectQL.MAX_EXPAND_DEPTH = 3;
8263
+ _ObjectQL.MAX_CASCADE_DEPTH = 10;
7744
8264
  var ObjectQL = _ObjectQL;
7745
8265
  var ObjectRepository = class {
7746
8266
  constructor(objectName, context, engine) {
@@ -7858,8 +8378,10 @@ var ScopedContext = class _ScopedContext {
7858
8378
  { ...this.executionContext, transaction: trx },
7859
8379
  this.engine
7860
8380
  );
8381
+ const txStore = this.engine?.txStore;
8382
+ const runIn = (fn) => txStore ? txStore.run({ transaction: trx }, fn) : fn();
7861
8383
  try {
7862
- const result = await callback(trxCtx);
8384
+ const result = await runIn(() => callback(trxCtx));
7863
8385
  if (driver.commit) await driver.commit(trx);
7864
8386
  else if (driver.commitTransaction) await driver.commitTransaction(trx);
7865
8387
  return result;