@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.mjs CHANGED
@@ -2386,6 +2386,44 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2386
2386
  }
2387
2387
  } catch {
2388
2388
  }
2389
+ if (request.previewDrafts) {
2390
+ try {
2391
+ const orgId = request.organizationId;
2392
+ const queryDrafts = async (oid) => {
2393
+ const whereClause = { type: request.type, state: "draft", organization_id: oid };
2394
+ if (packageId) whereClause.package_id = packageId;
2395
+ let rs = await this.engine.find("sys_metadata", { where: whereClause });
2396
+ if (!rs || rs.length === 0) {
2397
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2398
+ if (alt) {
2399
+ const altWhere = { type: alt, state: "draft", organization_id: oid };
2400
+ if (packageId) altWhere.package_id = packageId;
2401
+ rs = await this.engine.find("sys_metadata", { where: altWhere });
2402
+ }
2403
+ }
2404
+ return rs ?? [];
2405
+ };
2406
+ const draftRecords = [...await queryDrafts(null), ...orgId ? await queryDrafts(orgId) : []];
2407
+ if (draftRecords.length > 0) {
2408
+ const byName = /* @__PURE__ */ new Map();
2409
+ for (const existing of items) {
2410
+ const entry = existing;
2411
+ if (entry && typeof entry === "object" && "name" in entry) byName.set(entry.name, entry);
2412
+ }
2413
+ for (const record of draftRecords) {
2414
+ const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
2415
+ if (data && typeof data === "object" && "name" in data) {
2416
+ const recPkg = record.package_id ?? void 0;
2417
+ if (recPkg && data._packageId === void 0) data._packageId = recPkg;
2418
+ data._draft = true;
2419
+ byName.set(data.name, data);
2420
+ }
2421
+ }
2422
+ items = Array.from(byName.values());
2423
+ }
2424
+ } catch {
2425
+ }
2426
+ }
2389
2427
  try {
2390
2428
  const services = this.getServicesRegistry?.();
2391
2429
  const metadataService = services?.get("metadata");
@@ -2444,6 +2482,34 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2444
2482
  let item;
2445
2483
  const orgId = request.organizationId;
2446
2484
  const readState = request.state === "draft" ? "draft" : "active";
2485
+ if (request.previewDrafts && readState !== "draft") {
2486
+ try {
2487
+ const findDraft = async (oid) => {
2488
+ const rec = await this.engine.findOne("sys_metadata", {
2489
+ where: { type: request.type, name: request.name, state: "draft", organization_id: oid }
2490
+ });
2491
+ if (rec) return rec;
2492
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2493
+ if (alt) {
2494
+ return await this.engine.findOne("sys_metadata", {
2495
+ where: { type: alt, name: request.name, state: "draft", organization_id: oid }
2496
+ });
2497
+ }
2498
+ return void 0;
2499
+ };
2500
+ const draftRec = (orgId ? await findDraft(orgId) : void 0) ?? await findDraft(null);
2501
+ if (draftRec) {
2502
+ const draftItem = typeof draftRec.metadata === "string" ? JSON.parse(draftRec.metadata) : draftRec.metadata;
2503
+ if (draftItem && typeof draftItem === "object") {
2504
+ const recPkg = draftRec.package_id ?? void 0;
2505
+ if (recPkg && draftItem._packageId === void 0) draftItem._packageId = recPkg;
2506
+ draftItem._draft = true;
2507
+ }
2508
+ return { type: request.type, name: request.name, item: decorateMetadataItem(request.type, draftItem) };
2509
+ }
2510
+ } catch {
2511
+ }
2512
+ }
2447
2513
  try {
2448
2514
  const findOverlay = async (oid) => {
2449
2515
  const where = {
@@ -3818,6 +3884,37 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3818
3884
  console.warn(`[Protocol] table sync failed for object '${name}': ${err?.message ?? err}`);
3819
3885
  }
3820
3886
  }
3887
+ /**
3888
+ * Inverse of {@link ensureObjectStorage}: drop an object's physical table.
3889
+ * DESTRUCTIVE — deletes the table and all its rows. Only invoked when a
3890
+ * delete explicitly opts into storage teardown (see {@link deleteMetaItem}'s
3891
+ * `dropStorage`), so publishing an object solely to preview it can be undone
3892
+ * without leaving an orphan table. Best-effort: a failure is logged, not
3893
+ * thrown — the metadata delete already succeeded, and a stray table is
3894
+ * reclaimed by the next sync/drop rather than blocking the delete.
3895
+ */
3896
+ async dropObjectStorage(type, name) {
3897
+ if (type !== "object" && type !== "objects") return;
3898
+ try {
3899
+ await this.engine.dropObjectSchema(name);
3900
+ } catch (err) {
3901
+ console.warn(`[Protocol] table drop failed for object '${name}': ${err?.message ?? err}`);
3902
+ }
3903
+ }
3904
+ /**
3905
+ * Guard for storage teardown on delete. Drops a physical table only when
3906
+ * the caller opted in AND it is safe: object types only (others have no
3907
+ * table), active state only (drafts were never materialised), and never a
3908
+ * `sys_`-prefixed platform table.
3909
+ */
3910
+ shouldDropStorage(type, name, dropStorage, state) {
3911
+ if (!dropStorage) return false;
3912
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
3913
+ if (singular !== "object") return false;
3914
+ if (state !== "active") return false;
3915
+ if (name.startsWith("sys_")) return false;
3916
+ return true;
3917
+ }
3821
3918
  async saveMetaItem(request) {
3822
3919
  if (!request.item) {
3823
3920
  throw new Error("Item data is required");
@@ -4198,6 +4295,100 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
4198
4295
  failed
4199
4296
  };
4200
4297
  }
4298
+ /**
4299
+ * Discard every pending DRAFT bound to a package — the NON-destructive
4300
+ * inverse of {@link publishPackageDrafts}. Drops only `state='draft'` rows
4301
+ * (via the per-item delete primitive), reverting the package to its last
4302
+ * published baseline; active/published metadata and physical tables are
4303
+ * left untouched.
4304
+ *
4305
+ * Use case: "I edited this app for a while and it turned out worse than
4306
+ * before — abandon all my changes." Routes through the sys_metadata path
4307
+ * (no metadata-service dependency, unlike `POST /packages/:id/revert`).
4308
+ */
4309
+ async discardPackageDrafts(request) {
4310
+ await this.ensureOverlayIndex();
4311
+ const orgId = request.organizationId ?? null;
4312
+ const repo = this.getOverlayRepo(orgId);
4313
+ const drafts = await repo.listDrafts({ packageId: request.packageId });
4314
+ const discarded = [];
4315
+ const failed = [];
4316
+ for (const d of drafts) {
4317
+ try {
4318
+ await this.deleteMetaItem({
4319
+ type: d.type,
4320
+ name: d.name,
4321
+ state: "draft",
4322
+ ...request.organizationId ? { organizationId: request.organizationId } : {},
4323
+ ...request.actor ? { actor: request.actor } : {}
4324
+ });
4325
+ discarded.push({ type: d.type, name: d.name });
4326
+ } catch (e) {
4327
+ failed.push({
4328
+ type: d.type,
4329
+ name: d.name,
4330
+ error: e?.message ?? "discard failed",
4331
+ ...e?.code ? { code: e.code } : {}
4332
+ });
4333
+ }
4334
+ }
4335
+ return {
4336
+ success: failed.length === 0 && discarded.length > 0,
4337
+ discardedCount: discarded.length,
4338
+ failedCount: failed.length,
4339
+ discarded,
4340
+ failed
4341
+ };
4342
+ }
4343
+ /**
4344
+ * Delete an ENTIRE package: every `sys_metadata` row bound to it (active
4345
+ * AND draft) and — by default — the physical table of each object it
4346
+ * defined. DESTRUCTIVE: removes the app and its data. Use case: "I don't
4347
+ * want this package anymore."
4348
+ *
4349
+ * Set `keepData: true` to remove the metadata but preserve object tables.
4350
+ * The `sys_`-table guard in {@link deleteMetaItem} still applies, so
4351
+ * platform storage is never dropped. Drafts are removed before active rows
4352
+ * so each object's table is torn down once. Per-item failures are collected
4353
+ * without aborting the rest.
4354
+ */
4355
+ async deletePackage(request) {
4356
+ const where = { package_id: request.packageId };
4357
+ if (request.organizationId) where.organization_id = request.organizationId;
4358
+ const rows = await this.engine.find("sys_metadata", { where });
4359
+ const dropStorage = request.keepData !== true;
4360
+ const ordered = [...rows].sort((a, b) => (a.state === "draft" ? 0 : 1) - (b.state === "draft" ? 0 : 1));
4361
+ const deleted = [];
4362
+ const failed = [];
4363
+ for (const row of ordered) {
4364
+ const state = row.state === "draft" ? "draft" : "active";
4365
+ try {
4366
+ await this.deleteMetaItem({
4367
+ type: row.type,
4368
+ name: row.name,
4369
+ state,
4370
+ ...row.organization_id ? { organizationId: row.organization_id } : {},
4371
+ ...request.actor ? { actor: request.actor } : {},
4372
+ ...dropStorage ? { dropStorage: true } : {}
4373
+ });
4374
+ deleted.push({ type: row.type, name: row.name, state });
4375
+ } catch (e) {
4376
+ failed.push({
4377
+ type: row.type,
4378
+ name: row.name,
4379
+ error: e?.message ?? "delete failed",
4380
+ ...e?.code ? { code: e.code } : {}
4381
+ });
4382
+ }
4383
+ }
4384
+ return {
4385
+ success: failed.length === 0 && deleted.length > 0,
4386
+ deletedCount: deleted.length,
4387
+ failedCount: failed.length,
4388
+ deleted,
4389
+ failed
4390
+ };
4391
+ }
4201
4392
  /**
4202
4393
  * Restore the body recorded at history `toVersion` as the new
4203
4394
  * live row. Writes a history event with `op='revert'`. 404
@@ -4430,6 +4621,9 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
4430
4621
  } catch {
4431
4622
  }
4432
4623
  }
4624
+ if (this.shouldDropStorage(request.type, request.name, request.dropStorage, targetState)) {
4625
+ await this.dropObjectStorage(singularTypeForRepo, request.name);
4626
+ }
4433
4627
  await this.recordMetadataAudit({
4434
4628
  type: request.type,
4435
4629
  name: request.name,
@@ -4478,6 +4672,12 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
4478
4672
  };
4479
4673
  }
4480
4674
  await this.engine.delete("sys_metadata", { where: { id: existing.id } });
4675
+ {
4676
+ const targetState = request.state === "draft" ? "draft" : "active";
4677
+ if (this.shouldDropStorage(request.type, request.name, request.dropStorage, targetState)) {
4678
+ await this.dropObjectStorage(PLURAL_TO_SINGULAR3[request.type] ?? request.type, request.name);
4679
+ }
4680
+ }
4481
4681
  if (this.environmentId === void 0) {
4482
4682
  try {
4483
4683
  const services = this.getServicesRegistry?.();
@@ -4839,6 +5039,7 @@ _ObjectStackProtocolImplementation.RUNTIME_CREATE_ALLOWED_TYPES = (() => {
4839
5039
  var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
4840
5040
 
4841
5041
  // src/engine.ts
5042
+ import { AsyncLocalStorage } from "async_hooks";
4842
5043
  import { ExecutionContextSchema } from "@objectstack/spec/kernel";
4843
5044
  import { createLogger } from "@objectstack/core";
4844
5045
  import { CoreServiceName, StorageNameMapping } from "@objectstack/spec/system";
@@ -5331,7 +5532,7 @@ function optionValues(options) {
5331
5532
  );
5332
5533
  }
5333
5534
  function validateOne(name, def, value) {
5334
- if (def.required && isMissing(value)) {
5535
+ if (def.required && isMissing(value) && def.type !== "autonumber") {
5335
5536
  return { field: name, code: "required", message: `${name} is required` };
5336
5537
  }
5337
5538
  if (isMissing(value)) return null;
@@ -5429,8 +5630,31 @@ var ajv = new Ajv({ allErrors: true, strict: false });
5429
5630
  var jsonSchemaCache = /* @__PURE__ */ new WeakMap();
5430
5631
  function needsPriorRecord(objectSchema) {
5431
5632
  const rules = objectSchema?.validations;
5432
- if (!Array.isArray(rules)) return false;
5433
- return rules.some((r) => ruleNeedsPrior(r));
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;
5434
5658
  }
5435
5659
  function ruleNeedsPrior(r) {
5436
5660
  if (r == null || typeof r !== "object") return false;
@@ -5444,17 +5668,44 @@ function ruleNeedsPrior(r) {
5444
5668
  }
5445
5669
  return false;
5446
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
+ }
5447
5680
  function toExpression(cond) {
5448
5681
  return typeof cond === "string" ? { dialect: "cel", source: cond } : cond;
5449
5682
  }
5450
5683
  function evaluateValidationRules(objectSchema, data, mode, opts = {}) {
5684
+ if (!data) return;
5451
5685
  const rules = objectSchema?.validations;
5452
- if (!Array.isArray(rules) || rules.length === 0 || !data) return;
5686
+ const hasRules = Array.isArray(rules) && rules.length > 0;
5687
+ const fields = objectSchema?.fields;
5688
+ const hasFieldRules = fieldsNeedPrior(fields);
5689
+ if (!hasRules && !hasFieldRules) return;
5453
5690
  const previous = opts.previous ?? void 0;
5454
5691
  const merged = { ...previous ?? {}, ...data };
5455
5692
  const ctx = { data, merged, previous, mode, logger: opts.logger };
5456
5693
  const errors = [];
5457
- const ordered = rules.filter((r) => r != null && typeof r === "object").filter((r) => r.active !== false).filter((r) => {
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) => {
5458
5709
  const events = r.events ?? ["insert", "update"];
5459
5710
  return events.includes(mode);
5460
5711
  }).sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
@@ -5832,6 +6083,15 @@ function resolveMetadataItemName(key, item) {
5832
6083
  }
5833
6084
  var _ObjectQL = class _ObjectQL {
5834
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();
5835
6095
  this.drivers = /* @__PURE__ */ new Map();
5836
6096
  this.defaultDriver = null;
5837
6097
  // Datasource mapping rules (imported from defineStack)
@@ -5873,6 +6133,12 @@ var _ObjectQL = class _ObjectQL {
5873
6133
  // getDriver()'s owner lookup would route CRUD to the wrong database. Each
5874
6134
  // engine now owns its registry so kernels are fully isolated.
5875
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;
5876
6142
  this.hostContext = hostContext;
5877
6143
  this.logger = hostContext.logger || createLogger({ level: "info", format: "pretty" });
5878
6144
  if (process?.env?.OBJECTQL_STRICT_HOOKS === "1") {
@@ -6175,13 +6441,14 @@ var _ObjectQL = class _ObjectQL {
6175
6441
  * mask the system path.
6176
6442
  */
6177
6443
  buildDriverOptions(execCtx, base) {
6178
- const hasTx = execCtx?.transaction !== void 0;
6444
+ const tx = execCtx?.transaction !== void 0 ? execCtx.transaction : this.txStore.getStore()?.transaction;
6445
+ const hasTx = tx !== void 0;
6179
6446
  const hasTenant = execCtx?.tenantId !== void 0;
6180
6447
  const isSystem = execCtx?.isSystem === true;
6181
6448
  if (!hasTx && !hasTenant && !isSystem) return base;
6182
6449
  const opts = base && typeof base === "object" ? { ...base } : {};
6183
6450
  if (hasTx && opts.transaction === void 0) {
6184
- opts.transaction = execCtx.transaction;
6451
+ opts.transaction = tx;
6185
6452
  }
6186
6453
  if (hasTenant && opts.tenantId === void 0) {
6187
6454
  opts.tenantId = execCtx.tenantId;
@@ -6247,6 +6514,66 @@ var _ObjectQL = class _ObjectQL {
6247
6514
  }
6248
6515
  return out;
6249
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
+ }
6250
6577
  /**
6251
6578
  * Register contribution (Manifest)
6252
6579
  *
@@ -6260,6 +6587,7 @@ var _ObjectQL = class _ObjectQL {
6260
6587
  registerApp(manifest) {
6261
6588
  const id = manifest.id || manifest.name;
6262
6589
  const namespace = manifest.namespace;
6590
+ this.invalidateSummaryIndex();
6263
6591
  this.logger.debug("Registering package manifest", { id, namespace });
6264
6592
  console.warn(`[ObjectQL:registerApp] id=${id} flows=${Array.isArray(manifest.flows) ? manifest.flows.length : typeof manifest.flows} keys=${Object.keys(manifest).join(",")}`);
6265
6593
  if (id) {
@@ -6340,6 +6668,7 @@ var _ObjectQL = class _ObjectQL {
6340
6668
  "pages",
6341
6669
  "dashboards",
6342
6670
  "reports",
6671
+ "datasets",
6343
6672
  "themes",
6344
6673
  // Automation Protocol
6345
6674
  "flows",
@@ -6484,6 +6813,7 @@ var _ObjectQL = class _ObjectQL {
6484
6813
  "pages",
6485
6814
  "dashboards",
6486
6815
  "reports",
6816
+ "datasets",
6487
6817
  "themes",
6488
6818
  "flows",
6489
6819
  "workflows",
@@ -6865,6 +7195,102 @@ var _ObjectQL = class _ObjectQL {
6865
7195
  }
6866
7196
  this.logger.info("ObjectQL engine destroyed");
6867
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
+ }
6868
7294
  /**
6869
7295
  * Post-process expand: resolve lookup/master_detail fields by batch-loading related records.
6870
7296
  *
@@ -7080,10 +7506,14 @@ var _ObjectQL = class _ObjectQL {
7080
7506
  let result;
7081
7507
  const nowSnap = /* @__PURE__ */ new Date();
7082
7508
  const schemaForValidation = this._registry.getObject(object);
7509
+ const driverOwnsAutonumber = driver?.supports?.autonumber === true;
7083
7510
  if (Array.isArray(hookContext.input.data)) {
7084
7511
  const rows = hookContext.input.data.map(
7085
7512
  (row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
7086
7513
  );
7514
+ for (const r of rows) {
7515
+ await this.applyAutonumbers(object, r, opCtx.context, driverOwnsAutonumber);
7516
+ }
7087
7517
  for (const r of rows) {
7088
7518
  await this.encryptSecretFields(object, r, opCtx.context, hookContext.input.options);
7089
7519
  }
@@ -7103,6 +7533,7 @@ var _ObjectQL = class _ObjectQL {
7103
7533
  opCtx.context,
7104
7534
  nowSnap
7105
7535
  );
7536
+ await this.applyAutonumbers(object, row, opCtx.context, driverOwnsAutonumber);
7106
7537
  await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options);
7107
7538
  validateRecord(schemaForValidation, row, "insert");
7108
7539
  evaluateValidationRules(schemaForValidation, row, "insert", { logger: this.logger });
@@ -7111,6 +7542,7 @@ var _ObjectQL = class _ObjectQL {
7111
7542
  hookContext.event = "afterInsert";
7112
7543
  hookContext.result = result;
7113
7544
  await this.triggerHooks("afterInsert", hookContext);
7545
+ await this.recomputeSummaries(object, result, null, opCtx.context);
7114
7546
  if (this.realtimeService) {
7115
7547
  try {
7116
7548
  if (Array.isArray(result)) {
@@ -7195,6 +7627,7 @@ var _ObjectQL = class _ObjectQL {
7195
7627
  const priorAst = { object, where: { id: hookContext.input.id }, limit: 1 };
7196
7628
  priorRecord = await driver.findOne(object, priorAst, hookContext.input.options);
7197
7629
  }
7630
+ hookContext.input.data = stripReadonlyWhenFields(updateSchema, hookContext.input.data, priorRecord, this.logger);
7198
7631
  evaluateValidationRules(updateSchema, hookContext.input.data, "update", { previous: priorRecord, logger: this.logger });
7199
7632
  result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
7200
7633
  } else if (options?.multi && driver.updateMany) {
@@ -7212,6 +7645,7 @@ var _ObjectQL = class _ObjectQL {
7212
7645
  hookContext.result = result;
7213
7646
  if (priorRecord) hookContext.previous = priorRecord;
7214
7647
  await this.triggerHooks("afterUpdate", hookContext);
7648
+ await this.recomputeSummaries(object, result, priorRecord, opCtx.context);
7215
7649
  if (this.realtimeService) {
7216
7650
  try {
7217
7651
  const resultId = typeof result === "object" && result && "id" in result ? result.id : void 0;
@@ -7240,6 +7674,66 @@ var _ObjectQL = class _ObjectQL {
7240
7674
  });
7241
7675
  return opCtx.result;
7242
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
+ }
7243
7737
  async delete(object, options) {
7244
7738
  object = this.resolveObjectName(object);
7245
7739
  this.logger.debug("Delete operation starting", { object });
@@ -7269,7 +7763,15 @@ var _ObjectQL = class _ObjectQL {
7269
7763
  hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
7270
7764
  try {
7271
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
+ }
7272
7773
  if (hookContext.input.id) {
7774
+ await this.cascadeDeleteRelations(object, hookContext.input.id, opCtx.context);
7273
7775
  result = await driver.delete(object, hookContext.input.id, hookContext.input.options);
7274
7776
  } else if (options?.multi && driver.deleteMany) {
7275
7777
  const ast = { object, where: options.where };
@@ -7280,6 +7782,7 @@ var _ObjectQL = class _ObjectQL {
7280
7782
  hookContext.event = "afterDelete";
7281
7783
  hookContext.result = result;
7282
7784
  await this.triggerHooks("afterDelete", hookContext);
7785
+ if (summaryPrev) await this.recomputeSummaries(object, null, summaryPrev, opCtx.context);
7283
7786
  if (this.realtimeService) {
7284
7787
  try {
7285
7788
  const resultId = typeof result === "object" && result && "id" in result ? result.id : void 0;
@@ -7429,7 +7932,7 @@ var _ObjectQL = class _ObjectQL {
7429
7932
  const trx = await drv.beginTransaction();
7430
7933
  const trxCtx = { ...baseContext ?? {}, transaction: trx };
7431
7934
  try {
7432
- const result = await callback(trxCtx);
7935
+ const result = await this.txStore.run({ transaction: trx }, () => callback(trxCtx));
7433
7936
  if (drv.commit) await drv.commit(trx);
7434
7937
  else if (drv.commitTransaction) await drv.commitTransaction(trx);
7435
7938
  return result;
@@ -7579,6 +8082,22 @@ var _ObjectQL = class _ObjectQL {
7579
8082
  const tableName = StorageNameMapping.resolveTableName(obj);
7580
8083
  await driver.syncSchema(tableName, obj);
7581
8084
  }
8085
+ /**
8086
+ * Drop the physical storage (table/collection) backing an object — the
8087
+ * inverse of {@link syncObjectSchema}. DESTRUCTIVE: deletes all rows in the
8088
+ * table. Used by the protocol delete path when the caller explicitly opts
8089
+ * into storage teardown (e.g. discarding an object that was published only
8090
+ * to preview it). No-op when the object's driver does not expose `dropTable`.
8091
+ * Resolves the physical table name from the registered definition, falling
8092
+ * back to the bare name if the def was already removed.
8093
+ */
8094
+ async dropObjectSchema(objectName) {
8095
+ const obj = this._registry.getObject(objectName);
8096
+ const driver = this.getDriverForObject(objectName);
8097
+ if (!driver || typeof driver.dropTable !== "function") return;
8098
+ const tableName = StorageNameMapping.resolveTableName(obj ?? { name: objectName });
8099
+ await driver.dropTable(tableName);
8100
+ }
7582
8101
  /**
7583
8102
  * Get a registered driver by datasource name.
7584
8103
  * Alias matching @objectql/core datasource() API.
@@ -7677,6 +8196,7 @@ var _ObjectQL = class _ObjectQL {
7677
8196
  // ============================================
7678
8197
  /** Maximum depth for recursive expand to prevent infinite loops */
7679
8198
  _ObjectQL.MAX_EXPAND_DEPTH = 3;
8199
+ _ObjectQL.MAX_CASCADE_DEPTH = 10;
7680
8200
  var ObjectQL = _ObjectQL;
7681
8201
  var ObjectRepository = class {
7682
8202
  constructor(objectName, context, engine) {
@@ -7794,8 +8314,10 @@ var ScopedContext = class _ScopedContext {
7794
8314
  { ...this.executionContext, transaction: trx },
7795
8315
  this.engine
7796
8316
  );
8317
+ const txStore = this.engine?.txStore;
8318
+ const runIn = (fn) => txStore ? txStore.run({ transaction: trx }, fn) : fn();
7797
8319
  try {
7798
- const result = await callback(trxCtx);
8320
+ const result = await runIn(() => callback(trxCtx));
7799
8321
  if (driver.commit) await driver.commit(trx);
7800
8322
  else if (driver.commitTransaction) await driver.commitTransaction(trx);
7801
8323
  return result;