@objectstack/objectql 9.9.1 → 9.11.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
@@ -141,7 +141,10 @@ var init_seed_loader = __esm({
141
141
  const seedIdentity = config.identity;
142
142
  const baseEvalCtx = {
143
143
  now: seedNow,
144
- user: seedIdentity?.user,
144
+ // `id: null` is a legitimate seed-time state (the owning admin does not
145
+ // exist yet) that the formula EvalContext's `user.id: string` type does
146
+ // not yet model — cast the fallback so `os.user.id` evaluates to null.
147
+ user: seedIdentity?.user ?? { id: null },
145
148
  // Fall back to the per-tenant organizationId so `os.org.id` resolves
146
149
  // during per-org replay even without an explicit identity.org.
147
150
  org: seedIdentity?.org ?? (config.organizationId ? { id: config.organizationId } : void 0),
@@ -161,7 +164,7 @@ var init_seed_loader = __esm({
161
164
  targetField: "(expression)",
162
165
  attemptedValue: dataset.records[i],
163
166
  recordIndex: i,
164
- message: `Cannot resolve dynamic seed values for ${objectName} record #${i}: ${seedResult.error.message}. Records using cel\`os.user.id\` / cel\`os.org.id\` require a seed identity \u2014 ensure a system/admin user exists before seeding (see SeedLoaderConfig.identity).`
167
+ message: `Cannot resolve dynamic seed values for ${objectName} record #${i}: ${seedResult.error.message}. \`os.user.id\` resolves to null at seed time (the owning admin does not exist yet) and owner-style fields are assigned by the first-admin handoff \u2014 so a required, non-owner field must not depend on it. Provide a literal value or make the field optional.`
165
168
  };
166
169
  errors.push(error);
167
170
  allErrors.push(error);
@@ -840,6 +843,8 @@ function applySystemFields(schema, opts) {
840
843
  const tenancyDisabled = schema.tenancy?.enabled === false;
841
844
  const wantTenant = sf?.tenant !== false && !tenancyDisabled;
842
845
  const wantAudit = sf?.audit !== false;
846
+ const ownership = schema.ownership;
847
+ const wantOwner = ownership !== "org" && ownership !== "none" && !schema.managedBy && !schema.name.startsWith("sys_");
843
848
  const additions = {};
844
849
  if (wantTenant && !schema.fields?.organization_id) {
845
850
  additions.organization_id = {
@@ -898,6 +903,17 @@ function applySystemFields(schema, opts) {
898
903
  };
899
904
  }
900
905
  }
906
+ if (wantOwner && !schema.fields?.owner_id) {
907
+ additions.owner_id = {
908
+ type: "lookup",
909
+ reference: "sys_user",
910
+ label: "Owner",
911
+ required: false,
912
+ readonly: false,
913
+ system: true,
914
+ description: "Record owner (auto-stamped to the creating user on insert; reassignable). Drives owner-scoped views, reports and notifications."
915
+ };
916
+ }
901
917
  if (Object.keys(additions).length === 0) return schema;
902
918
  return {
903
919
  ...schema,
@@ -6117,6 +6133,7 @@ var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
6117
6133
 
6118
6134
  // src/engine.ts
6119
6135
  import { AsyncLocalStorage } from "async_hooks";
6136
+ import { parseAutonumberFormat, renderAutonumber, missingFieldValues } from "@objectstack/spec/data";
6120
6137
  import { ExecutionContextSchema } from "@objectstack/spec/kernel";
6121
6138
  import { createLogger } from "@objectstack/core";
6122
6139
  import { CoreServiceName, StorageNameMapping } from "@objectstack/spec/system";
@@ -7025,7 +7042,7 @@ function aggregateBucket(rows, aggregations) {
7025
7042
  const alias = agg.alias;
7026
7043
  const fn = agg.function;
7027
7044
  if (fn === "count") {
7028
- if (!agg.field) {
7045
+ if (!agg.field || agg.field === "*") {
7029
7046
  out[alias] = rows.length;
7030
7047
  } else {
7031
7048
  out[alias] = rows.reduce(
@@ -7542,8 +7559,9 @@ var _ObjectQL = class _ObjectQL {
7542
7559
  const tx = execCtx?.transaction !== void 0 ? execCtx.transaction : this.txStore.getStore()?.transaction;
7543
7560
  const hasTx = tx !== void 0;
7544
7561
  const hasTenant = execCtx?.tenantId !== void 0;
7562
+ const hasTz = execCtx?.timezone !== void 0;
7545
7563
  const isSystem = execCtx?.isSystem === true;
7546
- if (!hasTx && !hasTenant && !isSystem) return base;
7564
+ if (!hasTx && !hasTenant && !isSystem && !hasTz) return base;
7547
7565
  const opts = base && typeof base === "object" ? { ...base } : {};
7548
7566
  if (hasTx && opts.transaction === void 0) {
7549
7567
  opts.transaction = tx;
@@ -7551,6 +7569,9 @@ var _ObjectQL = class _ObjectQL {
7551
7569
  if (hasTenant && opts.tenantId === void 0) {
7552
7570
  opts.tenantId = execCtx.tenantId;
7553
7571
  }
7572
+ if (hasTz && opts.timezone === void 0) {
7573
+ opts.timezone = execCtx.timezone;
7574
+ }
7554
7575
  if (isSystem && opts.bypassTenantAudit === void 0) {
7555
7576
  opts.bypassTenantAudit = true;
7556
7577
  }
@@ -7624,29 +7645,48 @@ var _ObjectQL = class _ObjectQL {
7624
7645
  * owns the value, not the client.
7625
7646
  *
7626
7647
  * In the fallback path the next value is `max(existing) + 1`, seeded once per
7627
- * `object.field` from the store then incremented in memory (monotonic within
7628
- * the process, resilient to deletions). `autonumberFormat` is honored, e.g.
7629
- * `CASE-{0000}` `CASE-0042`. NOTE: this in-memory seeding is single-instance.
7648
+ * `object.field.<scope>` from the store then incremented in memory (monotonic
7649
+ * within the process, resilient to deletions). The shared `autonumberFormat`
7650
+ * renderer is honored end-to-end, so date tokens (`AD{YYYYMMDD}{0000}`), field
7651
+ * interpolation (`{island_zone}{000}`) and per-scope reset behave identically
7652
+ * to the SQL driver's persistent sequence (#1603). NOTE: this in-memory seeding
7653
+ * is single-instance.
7630
7654
  */
7631
7655
  async applyAutonumbers(object, record, execCtx, driverOwnsAutonumber) {
7632
7656
  if (driverOwnsAutonumber) return;
7633
7657
  const fields = this.getSchema(object)?.fields;
7634
7658
  if (!fields || typeof fields !== "object" || Array.isArray(fields)) return;
7659
+ const now = /* @__PURE__ */ new Date();
7660
+ const timezone = execCtx?.timezone;
7635
7661
  for (const [name, def] of Object.entries(fields)) {
7636
7662
  if (def?.type !== "autonumber") continue;
7637
7663
  const current = record[name];
7638
7664
  if (current != null && current !== "") continue;
7639
- const key = `${object}.${name}`;
7640
- let next = this.autonumberCounters.get(key);
7641
- if (next == null) next = await this.seedAutonumber(object, name, execCtx);
7642
- next += 1;
7643
- this.autonumberCounters.set(key, next);
7644
7665
  const fmt = def.autonumberFormat ?? def.format;
7645
- record[name] = this.formatAutonumber(fmt, next);
7666
+ const tokens = parseAutonumberFormat(typeof fmt === "string" ? fmt : "");
7667
+ const missing = missingFieldValues(tokens, record);
7668
+ if (missing.length > 0) {
7669
+ throw new Error(
7670
+ `Cannot generate autonumber "${object}.${name}" (format "${fmt}"): referenced field(s) [${missing.join(", ")}] are empty on the record. Fields interpolated into an autonumber format must be set before the record is created.`
7671
+ );
7672
+ }
7673
+ const probe = renderAutonumber({ tokens, seq: 0, record, now, timezone });
7674
+ const counterKey = `${object}.${name}.${probe.scope}`;
7675
+ let next = this.autonumberCounters.get(counterKey);
7676
+ if (next == null) next = await this.seedAutonumber(object, name, probe.prefix, execCtx);
7677
+ next += 1;
7678
+ this.autonumberCounters.set(counterKey, next);
7679
+ record[name] = renderAutonumber({ tokens, seq: next, record, now, timezone }).value;
7646
7680
  }
7647
7681
  }
7648
- /** Seed the autonumber counter from the current max numeric value in store. */
7649
- async seedAutonumber(object, field, execCtx) {
7682
+ /**
7683
+ * Seed the autonumber counter from the current max in store, scoped to
7684
+ * `prefix`. With a non-empty prefix (date/field formats) only rows in the
7685
+ * same scope count, and the counter is the digit-run immediately after the
7686
+ * prefix; with an empty prefix (legacy fixed-prefix formats) the last digit
7687
+ * run of the whole value is used, preserving the original behaviour.
7688
+ */
7689
+ async seedAutonumber(object, field, prefix, execCtx) {
7650
7690
  try {
7651
7691
  const rows = await this.find(object, {
7652
7692
  select: ["id", field],
@@ -7657,22 +7697,24 @@ var _ObjectQL = class _ObjectQL {
7657
7697
  for (const r of rows || []) {
7658
7698
  const v = r?.[field];
7659
7699
  if (v == null) continue;
7660
- const m = String(v).match(/(\d+)(?!.*\d)/);
7661
- if (m) max = Math.max(max, parseInt(m[1], 10) || 0);
7700
+ const s = String(v);
7701
+ if (prefix && !s.startsWith(prefix)) continue;
7702
+ const tail = prefix ? s.slice(prefix.length) : s;
7703
+ let digits;
7704
+ if (prefix) {
7705
+ const head = tail.match(/^\d+/);
7706
+ digits = head ? head[0] : void 0;
7707
+ } else {
7708
+ const runs = tail.match(/\d+/g);
7709
+ digits = runs ? runs[runs.length - 1] : void 0;
7710
+ }
7711
+ if (digits) max = Math.max(max, parseInt(digits, 10) || 0);
7662
7712
  }
7663
7713
  return max;
7664
7714
  } catch {
7665
7715
  return 0;
7666
7716
  }
7667
7717
  }
7668
- /** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */
7669
- formatAutonumber(format, value) {
7670
- if (!format) return String(value);
7671
- const m = format.match(/\{(0+)\}/);
7672
- if (!m) return format.includes("{0}") ? format.replace("{0}", String(value)) : `${format}${value}`;
7673
- const padded = String(value).padStart(m[1].length, "0");
7674
- return format.replace(m[0], padded);
7675
- }
7676
7718
  /**
7677
7719
  * Register contribution (Manifest)
7678
7720
  *
@@ -9448,6 +9490,58 @@ var ScopedContext = class _ScopedContext {
9448
9490
  throw error;
9449
9491
  }
9450
9492
  }
9493
+ /**
9494
+ * Resolve the default driver, if it exposes transaction primitives.
9495
+ * Shared by {@link transaction} and the discrete begin/commit/rollback trio.
9496
+ */
9497
+ txDriver() {
9498
+ const engine = this.engine;
9499
+ const driver = engine.defaultDriver ? engine.drivers?.get(engine.defaultDriver) : void 0;
9500
+ return driver?.beginTransaction ? driver : void 0;
9501
+ }
9502
+ /**
9503
+ * Discrete transaction primitives — `begin` / `commit` / `rollback` as three
9504
+ * separate calls, in contrast to {@link transaction}'s single-callback form.
9505
+ *
9506
+ * This trio exists for callers that cannot keep a JS closure on the stack for
9507
+ * the lifetime of the transaction — chiefly the sandbox runner, where the
9508
+ * hook/action body's `ctx.api.transaction(fn)` is driven across many host
9509
+ * event-loop turns via deferred promises. Across those `setImmediate`
9510
+ * boundaries the engine's ambient `txStore` (AsyncLocalStorage) does NOT
9511
+ * survive, so the transaction handle is threaded **explicitly**: `begin`
9512
+ * returns a child ScopedContext carrying `transaction: trx` in its execution
9513
+ * context, and `resolveTx` honors that explicit handle ahead of the ambient
9514
+ * store. Every `object(...)` op on the returned context therefore reuses the
9515
+ * one connection without relying on ALS.
9516
+ *
9517
+ * Returns `null` when the driver has no transaction support — the caller then
9518
+ * runs non-transactionally against `this` (same graceful degrade as
9519
+ * {@link transaction}).
9520
+ */
9521
+ async beginTransaction() {
9522
+ const driver = this.txDriver();
9523
+ if (!driver) return null;
9524
+ const trx = await driver.beginTransaction();
9525
+ const ctx = new _ScopedContext(
9526
+ { ...this.executionContext, transaction: trx },
9527
+ this.engine
9528
+ );
9529
+ return { ctx, handle: trx };
9530
+ }
9531
+ /** Commit a handle obtained from {@link beginTransaction}. */
9532
+ async commitTransaction(handle) {
9533
+ const driver = this.txDriver();
9534
+ if (!driver) return;
9535
+ if (driver.commit) await driver.commit(handle);
9536
+ else if (driver.commitTransaction) await driver.commitTransaction(handle);
9537
+ }
9538
+ /** Roll back a handle obtained from {@link beginTransaction}. */
9539
+ async rollbackTransaction(handle) {
9540
+ const driver = this.txDriver();
9541
+ if (!driver) return;
9542
+ if (driver.rollback) await driver.rollback(handle);
9543
+ else if (driver.rollbackTransaction) await driver.rollbackTransaction(handle);
9544
+ }
9451
9545
  get userId() {
9452
9546
  return this.executionContext.userId;
9453
9547
  }