@objectstack/objectql 9.10.0 → 10.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
@@ -3870,7 +3870,9 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3870
3870
  ["$skip", "skip"],
3871
3871
  ["$orderby", "orderBy"],
3872
3872
  ["$select", "select"],
3873
- ["$count", "count"]
3873
+ ["$count", "count"],
3874
+ ["$search", "search"],
3875
+ ["$searchFields", "searchFields"]
3874
3876
  ]) {
3875
3877
  if (options[dollar] != null && options[bare] == null) {
3876
3878
  options[bare] = options[dollar];
@@ -3968,6 +3970,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3968
3970
  "aggregations",
3969
3971
  "groupBy",
3970
3972
  "search",
3973
+ "searchFields",
3971
3974
  "context",
3972
3975
  "cursor"
3973
3976
  ]);
@@ -6133,6 +6136,7 @@ var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
6133
6136
 
6134
6137
  // src/engine.ts
6135
6138
  import { AsyncLocalStorage } from "async_hooks";
6139
+ import { parseAutonumberFormat, renderAutonumber, missingFieldValues } from "@objectstack/spec/data";
6136
6140
  import { ExecutionContextSchema } from "@objectstack/spec/kernel";
6137
6141
  import { createLogger } from "@objectstack/core";
6138
6142
  import { CoreServiceName, StorageNameMapping } from "@objectstack/spec/system";
@@ -6161,6 +6165,118 @@ function collectSecretFields(schema) {
6161
6165
 
6162
6166
  // src/engine.ts
6163
6167
  import { pluralToSingular, ExternalWriteForbiddenError } from "@objectstack/spec/shared";
6168
+
6169
+ // src/search-filter.ts
6170
+ var TEXTUAL_TYPES = /* @__PURE__ */ new Set(["text", "email", "phone", "url", "autonumber", "textarea", "markdown"]);
6171
+ var ENUM_TYPES = /* @__PURE__ */ new Set(["select", "status"]);
6172
+ var EXCLUDED_FIELDS = /* @__PURE__ */ new Set([
6173
+ "id",
6174
+ "_id",
6175
+ "created",
6176
+ "modified",
6177
+ "created_at",
6178
+ "updated_at",
6179
+ "created_by",
6180
+ "updated_by",
6181
+ "owner_id",
6182
+ "organization_id",
6183
+ "space",
6184
+ "company_id"
6185
+ ]);
6186
+ var EXCLUDED_TYPES = /* @__PURE__ */ new Set([
6187
+ "json",
6188
+ "object",
6189
+ "grid",
6190
+ "image",
6191
+ "file",
6192
+ "avatar",
6193
+ "vector",
6194
+ "location",
6195
+ "geometry",
6196
+ "secret",
6197
+ "password",
6198
+ "encrypted",
6199
+ "boolean",
6200
+ "lookup",
6201
+ "master_detail"
6202
+ ]);
6203
+ function normalizeSearch(raw) {
6204
+ if (raw == null) return { query: "" };
6205
+ if (typeof raw === "string") return { query: raw };
6206
+ if (typeof raw === "object") {
6207
+ const o = raw;
6208
+ const q = typeof o.query === "string" ? o.query : typeof o.q === "string" ? o.q : "";
6209
+ const fields = Array.isArray(o.fields) ? o.fields : void 0;
6210
+ return { query: q, fields };
6211
+ }
6212
+ return { query: "" };
6213
+ }
6214
+ function autoDefaultFields(fields, displayField) {
6215
+ const names = Object.keys(fields).filter((f) => {
6216
+ if (EXCLUDED_FIELDS.has(f)) return false;
6217
+ const meta = fields[f];
6218
+ if (!meta || meta.hidden) return false;
6219
+ const t = meta.type;
6220
+ if (!t) return false;
6221
+ if (EXCLUDED_TYPES.has(t)) return false;
6222
+ return TEXTUAL_TYPES.has(t) || ENUM_TYPES.has(t);
6223
+ });
6224
+ const lead = displayField && fields[displayField] ? displayField : fields.name ? "name" : fields.title ? "title" : void 0;
6225
+ if (!lead) return names;
6226
+ return [lead, ...names.filter((f) => f !== lead)];
6227
+ }
6228
+ function resolveSearchFields(opts) {
6229
+ const all = opts.fields || {};
6230
+ const declared = opts.searchableFields?.filter((f) => all[f]);
6231
+ const allowed = declared && declared.length > 0 ? declared : autoDefaultFields(all, opts.displayField);
6232
+ const requested = typeof opts.requestedFields === "string" ? opts.requestedFields.split(",").map((f) => f.trim()).filter(Boolean) : opts.requestedFields;
6233
+ if (requested && requested.length > 0) {
6234
+ const allowSet = new Set(allowed);
6235
+ const validated = requested.filter((f) => allowSet.has(f));
6236
+ if (validated.length > 0) return validated;
6237
+ }
6238
+ return allowed;
6239
+ }
6240
+ function optionValuesMatching(meta, term) {
6241
+ if (!Array.isArray(meta.options)) return [];
6242
+ const lc = term.toLowerCase();
6243
+ const out = [];
6244
+ for (const opt of meta.options) {
6245
+ if (opt == null) continue;
6246
+ if (typeof opt === "string") {
6247
+ if (opt.toLowerCase().includes(lc)) out.push(opt);
6248
+ continue;
6249
+ }
6250
+ const label = String(opt.label ?? opt.value ?? "");
6251
+ if (label.toLowerCase().includes(lc)) out.push(opt.value);
6252
+ }
6253
+ return out;
6254
+ }
6255
+ function fieldClausesForTerm(field, term, meta) {
6256
+ if (ENUM_TYPES.has(meta?.type ?? "")) {
6257
+ const values = optionValuesMatching(meta, term);
6258
+ if (values.length > 0) return [{ [field]: { $in: values } }];
6259
+ return [{ [field]: { $contains: term } }];
6260
+ }
6261
+ return [{ [field]: { $contains: term } }];
6262
+ }
6263
+ function expandSearchToFilter(raw, opts) {
6264
+ const { query, fields: requested } = normalizeSearch(raw);
6265
+ if (!query || !query.trim()) return null;
6266
+ const searchFields = resolveSearchFields({
6267
+ ...opts,
6268
+ requestedFields: requested ?? opts.requestedFields
6269
+ });
6270
+ if (searchFields.length === 0) return null;
6271
+ const terms = query.trim().split(/\s+/).filter(Boolean);
6272
+ const andClauses = terms.map((term) => ({
6273
+ $or: searchFields.flatMap((f) => fieldClausesForTerm(f, term, opts.fields[f] || {}))
6274
+ }));
6275
+ if (andClauses.length === 0) return null;
6276
+ return andClauses.length === 1 ? andClauses[0] : { $and: andClauses };
6277
+ }
6278
+
6279
+ // src/engine.ts
6164
6280
  import { ExpressionEngine as ExpressionEngine3 } from "@objectstack/formula";
6165
6281
  import { isAggregatedViewContainer as isAggregatedViewContainer2, expandViewContainer } from "@objectstack/spec";
6166
6282
 
@@ -7558,8 +7674,9 @@ var _ObjectQL = class _ObjectQL {
7558
7674
  const tx = execCtx?.transaction !== void 0 ? execCtx.transaction : this.txStore.getStore()?.transaction;
7559
7675
  const hasTx = tx !== void 0;
7560
7676
  const hasTenant = execCtx?.tenantId !== void 0;
7677
+ const hasTz = execCtx?.timezone !== void 0;
7561
7678
  const isSystem = execCtx?.isSystem === true;
7562
- if (!hasTx && !hasTenant && !isSystem) return base;
7679
+ if (!hasTx && !hasTenant && !isSystem && !hasTz) return base;
7563
7680
  const opts = base && typeof base === "object" ? { ...base } : {};
7564
7681
  if (hasTx && opts.transaction === void 0) {
7565
7682
  opts.transaction = tx;
@@ -7567,6 +7684,9 @@ var _ObjectQL = class _ObjectQL {
7567
7684
  if (hasTenant && opts.tenantId === void 0) {
7568
7685
  opts.tenantId = execCtx.tenantId;
7569
7686
  }
7687
+ if (hasTz && opts.timezone === void 0) {
7688
+ opts.timezone = execCtx.timezone;
7689
+ }
7570
7690
  if (isSystem && opts.bypassTenantAudit === void 0) {
7571
7691
  opts.bypassTenantAudit = true;
7572
7692
  }
@@ -7640,29 +7760,48 @@ var _ObjectQL = class _ObjectQL {
7640
7760
  * owns the value, not the client.
7641
7761
  *
7642
7762
  * In the fallback path the next value is `max(existing) + 1`, seeded once per
7643
- * `object.field` from the store then incremented in memory (monotonic within
7644
- * the process, resilient to deletions). `autonumberFormat` is honored, e.g.
7645
- * `CASE-{0000}` `CASE-0042`. NOTE: this in-memory seeding is single-instance.
7763
+ * `object.field.<scope>` from the store then incremented in memory (monotonic
7764
+ * within the process, resilient to deletions). The shared `autonumberFormat`
7765
+ * renderer is honored end-to-end, so date tokens (`AD{YYYYMMDD}{0000}`), field
7766
+ * interpolation (`{island_zone}{000}`) and per-scope reset behave identically
7767
+ * to the SQL driver's persistent sequence (#1603). NOTE: this in-memory seeding
7768
+ * is single-instance.
7646
7769
  */
7647
7770
  async applyAutonumbers(object, record, execCtx, driverOwnsAutonumber) {
7648
7771
  if (driverOwnsAutonumber) return;
7649
7772
  const fields = this.getSchema(object)?.fields;
7650
7773
  if (!fields || typeof fields !== "object" || Array.isArray(fields)) return;
7774
+ const now = /* @__PURE__ */ new Date();
7775
+ const timezone = execCtx?.timezone;
7651
7776
  for (const [name, def] of Object.entries(fields)) {
7652
7777
  if (def?.type !== "autonumber") continue;
7653
7778
  const current = record[name];
7654
7779
  if (current != null && current !== "") continue;
7655
- const key = `${object}.${name}`;
7656
- let next = this.autonumberCounters.get(key);
7657
- if (next == null) next = await this.seedAutonumber(object, name, execCtx);
7658
- next += 1;
7659
- this.autonumberCounters.set(key, next);
7660
7780
  const fmt = def.autonumberFormat ?? def.format;
7661
- record[name] = this.formatAutonumber(fmt, next);
7781
+ const tokens = parseAutonumberFormat(typeof fmt === "string" ? fmt : "");
7782
+ const missing = missingFieldValues(tokens, record);
7783
+ if (missing.length > 0) {
7784
+ throw new Error(
7785
+ `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.`
7786
+ );
7787
+ }
7788
+ const probe = renderAutonumber({ tokens, seq: 0, record, now, timezone });
7789
+ const counterKey = `${object}.${name}.${probe.scope}`;
7790
+ let next = this.autonumberCounters.get(counterKey);
7791
+ if (next == null) next = await this.seedAutonumber(object, name, probe.prefix, execCtx);
7792
+ next += 1;
7793
+ this.autonumberCounters.set(counterKey, next);
7794
+ record[name] = renderAutonumber({ tokens, seq: next, record, now, timezone }).value;
7662
7795
  }
7663
7796
  }
7664
- /** Seed the autonumber counter from the current max numeric value in store. */
7665
- async seedAutonumber(object, field, execCtx) {
7797
+ /**
7798
+ * Seed the autonumber counter from the current max in store, scoped to
7799
+ * `prefix`. With a non-empty prefix (date/field formats) only rows in the
7800
+ * same scope count, and the counter is the digit-run immediately after the
7801
+ * prefix; with an empty prefix (legacy fixed-prefix formats) the last digit
7802
+ * run of the whole value is used, preserving the original behaviour.
7803
+ */
7804
+ async seedAutonumber(object, field, prefix, execCtx) {
7666
7805
  try {
7667
7806
  const rows = await this.find(object, {
7668
7807
  select: ["id", field],
@@ -7673,22 +7812,24 @@ var _ObjectQL = class _ObjectQL {
7673
7812
  for (const r of rows || []) {
7674
7813
  const v = r?.[field];
7675
7814
  if (v == null) continue;
7676
- const m = String(v).match(/(\d+)(?!.*\d)/);
7677
- if (m) max = Math.max(max, parseInt(m[1], 10) || 0);
7815
+ const s = String(v);
7816
+ if (prefix && !s.startsWith(prefix)) continue;
7817
+ const tail = prefix ? s.slice(prefix.length) : s;
7818
+ let digits;
7819
+ if (prefix) {
7820
+ const head = tail.match(/^\d+/);
7821
+ digits = head ? head[0] : void 0;
7822
+ } else {
7823
+ const runs = tail.match(/\d+/g);
7824
+ digits = runs ? runs[runs.length - 1] : void 0;
7825
+ }
7826
+ if (digits) max = Math.max(max, parseInt(digits, 10) || 0);
7678
7827
  }
7679
7828
  return max;
7680
7829
  } catch {
7681
7830
  return 0;
7682
7831
  }
7683
7832
  }
7684
- /** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */
7685
- formatAutonumber(format, value) {
7686
- if (!format) return String(value);
7687
- const m = format.match(/\{(0+)\}/);
7688
- if (!m) return format.includes("{0}") ? format.replace("{0}", String(value)) : `${format}${value}`;
7689
- const padded = String(value).padStart(m[1].length, "0");
7690
- return format.replace(m[0], padded);
7691
- }
7692
7833
  /**
7693
7834
  * Register contribution (Manifest)
7694
7835
  *
@@ -8449,11 +8590,18 @@ var _ObjectQL = class _ObjectQL {
8449
8590
  const uniqueIds = [...new Set(allIds)];
8450
8591
  if (uniqueIds.length === 0) continue;
8451
8592
  try {
8593
+ const idFilter = { id: { $in: uniqueIds } };
8594
+ const where = nestedAST.where ? { $and: [idFilter, nestedAST.where] } : idFilter;
8452
8595
  const relatedQuery = {
8453
8596
  object: referenceObject,
8454
- where: { id: { $in: uniqueIds } },
8597
+ where,
8455
8598
  ...nestedAST.fields ? { fields: nestedAST.fields } : {},
8456
8599
  ...nestedAST.orderBy ? { orderBy: nestedAST.orderBy } : {}
8600
+ // NOTE: nestedAST.limit/offset are intentionally NOT forwarded here.
8601
+ // This path batch-loads every parent's related records in a single
8602
+ // $in query, so a *per-parent* limit/offset can't be expressed — a
8603
+ // global cap on the batch would silently drop records other parents
8604
+ // need. Paginate by querying the related object directly instead.
8457
8605
  };
8458
8606
  const driver = this.getDriver(referenceObject);
8459
8607
  const expandOpts = this.buildDriverOptions(execCtx);
@@ -8506,11 +8654,34 @@ var _ObjectQL = class _ObjectQL {
8506
8654
  const driver = this.getDriver(object);
8507
8655
  const ast = { object, ...query };
8508
8656
  delete ast.context;
8657
+ if (ast.filter != null && ast.where == null) {
8658
+ ast.where = ast.filter;
8659
+ }
8660
+ delete ast.filter;
8509
8661
  if (ast.top != null && ast.limit == null) {
8510
8662
  ast.limit = ast.top;
8511
8663
  }
8512
8664
  delete ast.top;
8513
8665
  const _findSchema = this._registry.getObject(object);
8666
+ {
8667
+ const _searchRaw = ast.search ?? ast.$search;
8668
+ if (_searchRaw != null && _findSchema?.fields) {
8669
+ const _reqFields = ast.searchFields ?? ast.$searchFields ?? (typeof ast.search === "object" ? ast.search?.fields : void 0);
8670
+ const _searchFilter = expandSearchToFilter(_searchRaw, {
8671
+ fields: _findSchema.fields,
8672
+ searchableFields: _findSchema.searchableFields,
8673
+ requestedFields: _reqFields,
8674
+ displayField: _findSchema.displayNameField
8675
+ });
8676
+ if (_searchFilter) {
8677
+ ast.where = ast.where ? { $and: [ast.where, _searchFilter] } : _searchFilter;
8678
+ }
8679
+ }
8680
+ delete ast.search;
8681
+ delete ast.$search;
8682
+ delete ast.searchFields;
8683
+ delete ast.$searchFields;
8684
+ }
8514
8685
  const _findFormula = planFormulaProjection(_findSchema, ast.fields);
8515
8686
  if (_findFormula.projected) ast.fields = _findFormula.projected;
8516
8687
  if (_findSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) {
@@ -9215,7 +9386,14 @@ var _ObjectQL = class _ObjectQL {
9215
9386
  const obj = this._registry.getObject(objectName);
9216
9387
  if (!obj) return;
9217
9388
  const driver = this.getDriverForObject(objectName);
9218
- if (!driver || typeof driver.syncSchema !== "function") return;
9389
+ if (!driver) return;
9390
+ if (obj.external != null) {
9391
+ if (typeof driver.registerExternalObject === "function") {
9392
+ await driver.registerExternalObject(obj);
9393
+ }
9394
+ return;
9395
+ }
9396
+ if (typeof driver.syncSchema !== "function") return;
9219
9397
  const tableName = StorageNameMapping.resolveTableName(obj);
9220
9398
  await driver.syncSchema(tableName, obj);
9221
9399
  }
@@ -10049,6 +10227,27 @@ var ObjectQLPlugin = class {
10049
10227
  skipped++;
10050
10228
  continue;
10051
10229
  }
10230
+ if (obj.external != null) {
10231
+ if (typeof driver.registerExternalObject === "function") {
10232
+ try {
10233
+ await driver.registerExternalObject(obj);
10234
+ synced++;
10235
+ } catch (e) {
10236
+ ctx.logger.warn("Failed to register external object metadata", {
10237
+ object: obj.name,
10238
+ driver: driver.name,
10239
+ error: e instanceof Error ? e.message : String(e)
10240
+ });
10241
+ }
10242
+ } else {
10243
+ ctx.logger.debug("Driver does not support registerExternalObject, skipping external object", {
10244
+ object: obj.name,
10245
+ driver: driver.name
10246
+ });
10247
+ skipped++;
10248
+ }
10249
+ continue;
10250
+ }
10052
10251
  if (typeof driver.syncSchema !== "function") {
10053
10252
  ctx.logger.debug("Driver does not support syncSchema, skipping", {
10054
10253
  object: obj.name,