@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.d.mts CHANGED
@@ -2225,15 +2225,22 @@ declare class ObjectQL implements IDataEngine {
2225
2225
  * owns the value, not the client.
2226
2226
  *
2227
2227
  * In the fallback path the next value is `max(existing) + 1`, seeded once per
2228
- * `object.field` from the store then incremented in memory (monotonic within
2229
- * the process, resilient to deletions). `autonumberFormat` is honored, e.g.
2230
- * `CASE-{0000}` `CASE-0042`. NOTE: this in-memory seeding is single-instance.
2228
+ * `object.field.<scope>` from the store then incremented in memory (monotonic
2229
+ * within the process, resilient to deletions). The shared `autonumberFormat`
2230
+ * renderer is honored end-to-end, so date tokens (`AD{YYYYMMDD}{0000}`), field
2231
+ * interpolation (`{island_zone}{000}`) and per-scope reset behave identically
2232
+ * to the SQL driver's persistent sequence (#1603). NOTE: this in-memory seeding
2233
+ * is single-instance.
2231
2234
  */
2232
2235
  private applyAutonumbers;
2233
- /** Seed the autonumber counter from the current max numeric value in store. */
2236
+ /**
2237
+ * Seed the autonumber counter from the current max in store, scoped to
2238
+ * `prefix`. With a non-empty prefix (date/field formats) only rows in the
2239
+ * same scope count, and the counter is the digit-run immediately after the
2240
+ * prefix; with an empty prefix (legacy fixed-prefix formats) the last digit
2241
+ * run of the whole value is used, preserving the original behaviour.
2242
+ */
2234
2243
  private seedAutonumber;
2235
- /** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */
2236
- private formatAutonumber;
2237
2244
  /**
2238
2245
  * Register contribution (Manifest)
2239
2246
  *
package/dist/index.d.ts CHANGED
@@ -2225,15 +2225,22 @@ declare class ObjectQL implements IDataEngine {
2225
2225
  * owns the value, not the client.
2226
2226
  *
2227
2227
  * In the fallback path the next value is `max(existing) + 1`, seeded once per
2228
- * `object.field` from the store then incremented in memory (monotonic within
2229
- * the process, resilient to deletions). `autonumberFormat` is honored, e.g.
2230
- * `CASE-{0000}` `CASE-0042`. NOTE: this in-memory seeding is single-instance.
2228
+ * `object.field.<scope>` from the store then incremented in memory (monotonic
2229
+ * within the process, resilient to deletions). The shared `autonumberFormat`
2230
+ * renderer is honored end-to-end, so date tokens (`AD{YYYYMMDD}{0000}`), field
2231
+ * interpolation (`{island_zone}{000}`) and per-scope reset behave identically
2232
+ * to the SQL driver's persistent sequence (#1603). NOTE: this in-memory seeding
2233
+ * is single-instance.
2231
2234
  */
2232
2235
  private applyAutonumbers;
2233
- /** Seed the autonumber counter from the current max numeric value in store. */
2236
+ /**
2237
+ * Seed the autonumber counter from the current max in store, scoped to
2238
+ * `prefix`. With a non-empty prefix (date/field formats) only rows in the
2239
+ * same scope count, and the counter is the digit-run immediately after the
2240
+ * prefix; with an empty prefix (legacy fixed-prefix formats) the last digit
2241
+ * run of the whole value is used, preserving the original behaviour.
2242
+ */
2234
2243
  private seedAutonumber;
2235
- /** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */
2236
- private formatAutonumber;
2237
2244
  /**
2238
2245
  * Register contribution (Manifest)
2239
2246
  *
package/dist/index.js CHANGED
@@ -3929,7 +3929,9 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3929
3929
  ["$skip", "skip"],
3930
3930
  ["$orderby", "orderBy"],
3931
3931
  ["$select", "select"],
3932
- ["$count", "count"]
3932
+ ["$count", "count"],
3933
+ ["$search", "search"],
3934
+ ["$searchFields", "searchFields"]
3933
3935
  ]) {
3934
3936
  if (options[dollar] != null && options[bare] == null) {
3935
3937
  options[bare] = options[dollar];
@@ -4027,6 +4029,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
4027
4029
  "aggregations",
4028
4030
  "groupBy",
4029
4031
  "search",
4032
+ "searchFields",
4030
4033
  "context",
4031
4034
  "cursor"
4032
4035
  ]);
@@ -6192,6 +6195,7 @@ var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
6192
6195
 
6193
6196
  // src/engine.ts
6194
6197
  var import_node_async_hooks = require("async_hooks");
6198
+ var import_data4 = require("@objectstack/spec/data");
6195
6199
  var import_kernel6 = require("@objectstack/spec/kernel");
6196
6200
  var import_core2 = require("@objectstack/core");
6197
6201
  var import_system2 = require("@objectstack/spec/system");
@@ -6220,6 +6224,118 @@ function collectSecretFields(schema) {
6220
6224
 
6221
6225
  // src/engine.ts
6222
6226
  var import_shared5 = require("@objectstack/spec/shared");
6227
+
6228
+ // src/search-filter.ts
6229
+ var TEXTUAL_TYPES = /* @__PURE__ */ new Set(["text", "email", "phone", "url", "autonumber", "textarea", "markdown"]);
6230
+ var ENUM_TYPES = /* @__PURE__ */ new Set(["select", "status"]);
6231
+ var EXCLUDED_FIELDS = /* @__PURE__ */ new Set([
6232
+ "id",
6233
+ "_id",
6234
+ "created",
6235
+ "modified",
6236
+ "created_at",
6237
+ "updated_at",
6238
+ "created_by",
6239
+ "updated_by",
6240
+ "owner_id",
6241
+ "organization_id",
6242
+ "space",
6243
+ "company_id"
6244
+ ]);
6245
+ var EXCLUDED_TYPES = /* @__PURE__ */ new Set([
6246
+ "json",
6247
+ "object",
6248
+ "grid",
6249
+ "image",
6250
+ "file",
6251
+ "avatar",
6252
+ "vector",
6253
+ "location",
6254
+ "geometry",
6255
+ "secret",
6256
+ "password",
6257
+ "encrypted",
6258
+ "boolean",
6259
+ "lookup",
6260
+ "master_detail"
6261
+ ]);
6262
+ function normalizeSearch(raw) {
6263
+ if (raw == null) return { query: "" };
6264
+ if (typeof raw === "string") return { query: raw };
6265
+ if (typeof raw === "object") {
6266
+ const o = raw;
6267
+ const q = typeof o.query === "string" ? o.query : typeof o.q === "string" ? o.q : "";
6268
+ const fields = Array.isArray(o.fields) ? o.fields : void 0;
6269
+ return { query: q, fields };
6270
+ }
6271
+ return { query: "" };
6272
+ }
6273
+ function autoDefaultFields(fields, displayField) {
6274
+ const names = Object.keys(fields).filter((f) => {
6275
+ if (EXCLUDED_FIELDS.has(f)) return false;
6276
+ const meta = fields[f];
6277
+ if (!meta || meta.hidden) return false;
6278
+ const t = meta.type;
6279
+ if (!t) return false;
6280
+ if (EXCLUDED_TYPES.has(t)) return false;
6281
+ return TEXTUAL_TYPES.has(t) || ENUM_TYPES.has(t);
6282
+ });
6283
+ const lead = displayField && fields[displayField] ? displayField : fields.name ? "name" : fields.title ? "title" : void 0;
6284
+ if (!lead) return names;
6285
+ return [lead, ...names.filter((f) => f !== lead)];
6286
+ }
6287
+ function resolveSearchFields(opts) {
6288
+ const all = opts.fields || {};
6289
+ const declared = opts.searchableFields?.filter((f) => all[f]);
6290
+ const allowed = declared && declared.length > 0 ? declared : autoDefaultFields(all, opts.displayField);
6291
+ const requested = typeof opts.requestedFields === "string" ? opts.requestedFields.split(",").map((f) => f.trim()).filter(Boolean) : opts.requestedFields;
6292
+ if (requested && requested.length > 0) {
6293
+ const allowSet = new Set(allowed);
6294
+ const validated = requested.filter((f) => allowSet.has(f));
6295
+ if (validated.length > 0) return validated;
6296
+ }
6297
+ return allowed;
6298
+ }
6299
+ function optionValuesMatching(meta, term) {
6300
+ if (!Array.isArray(meta.options)) return [];
6301
+ const lc = term.toLowerCase();
6302
+ const out = [];
6303
+ for (const opt of meta.options) {
6304
+ if (opt == null) continue;
6305
+ if (typeof opt === "string") {
6306
+ if (opt.toLowerCase().includes(lc)) out.push(opt);
6307
+ continue;
6308
+ }
6309
+ const label = String(opt.label ?? opt.value ?? "");
6310
+ if (label.toLowerCase().includes(lc)) out.push(opt.value);
6311
+ }
6312
+ return out;
6313
+ }
6314
+ function fieldClausesForTerm(field, term, meta) {
6315
+ if (ENUM_TYPES.has(meta?.type ?? "")) {
6316
+ const values = optionValuesMatching(meta, term);
6317
+ if (values.length > 0) return [{ [field]: { $in: values } }];
6318
+ return [{ [field]: { $contains: term } }];
6319
+ }
6320
+ return [{ [field]: { $contains: term } }];
6321
+ }
6322
+ function expandSearchToFilter(raw, opts) {
6323
+ const { query, fields: requested } = normalizeSearch(raw);
6324
+ if (!query || !query.trim()) return null;
6325
+ const searchFields = resolveSearchFields({
6326
+ ...opts,
6327
+ requestedFields: requested ?? opts.requestedFields
6328
+ });
6329
+ if (searchFields.length === 0) return null;
6330
+ const terms = query.trim().split(/\s+/).filter(Boolean);
6331
+ const andClauses = terms.map((term) => ({
6332
+ $or: searchFields.flatMap((f) => fieldClausesForTerm(f, term, opts.fields[f] || {}))
6333
+ }));
6334
+ if (andClauses.length === 0) return null;
6335
+ return andClauses.length === 1 ? andClauses[0] : { $and: andClauses };
6336
+ }
6337
+
6338
+ // src/engine.ts
6223
6339
  var import_formula4 = require("@objectstack/formula");
6224
6340
  var import_spec = require("@objectstack/spec");
6225
6341
 
@@ -7617,8 +7733,9 @@ var _ObjectQL = class _ObjectQL {
7617
7733
  const tx = execCtx?.transaction !== void 0 ? execCtx.transaction : this.txStore.getStore()?.transaction;
7618
7734
  const hasTx = tx !== void 0;
7619
7735
  const hasTenant = execCtx?.tenantId !== void 0;
7736
+ const hasTz = execCtx?.timezone !== void 0;
7620
7737
  const isSystem = execCtx?.isSystem === true;
7621
- if (!hasTx && !hasTenant && !isSystem) return base;
7738
+ if (!hasTx && !hasTenant && !isSystem && !hasTz) return base;
7622
7739
  const opts = base && typeof base === "object" ? { ...base } : {};
7623
7740
  if (hasTx && opts.transaction === void 0) {
7624
7741
  opts.transaction = tx;
@@ -7626,6 +7743,9 @@ var _ObjectQL = class _ObjectQL {
7626
7743
  if (hasTenant && opts.tenantId === void 0) {
7627
7744
  opts.tenantId = execCtx.tenantId;
7628
7745
  }
7746
+ if (hasTz && opts.timezone === void 0) {
7747
+ opts.timezone = execCtx.timezone;
7748
+ }
7629
7749
  if (isSystem && opts.bypassTenantAudit === void 0) {
7630
7750
  opts.bypassTenantAudit = true;
7631
7751
  }
@@ -7699,29 +7819,48 @@ var _ObjectQL = class _ObjectQL {
7699
7819
  * owns the value, not the client.
7700
7820
  *
7701
7821
  * In the fallback path the next value is `max(existing) + 1`, seeded once per
7702
- * `object.field` from the store then incremented in memory (monotonic within
7703
- * the process, resilient to deletions). `autonumberFormat` is honored, e.g.
7704
- * `CASE-{0000}` `CASE-0042`. NOTE: this in-memory seeding is single-instance.
7822
+ * `object.field.<scope>` from the store then incremented in memory (monotonic
7823
+ * within the process, resilient to deletions). The shared `autonumberFormat`
7824
+ * renderer is honored end-to-end, so date tokens (`AD{YYYYMMDD}{0000}`), field
7825
+ * interpolation (`{island_zone}{000}`) and per-scope reset behave identically
7826
+ * to the SQL driver's persistent sequence (#1603). NOTE: this in-memory seeding
7827
+ * is single-instance.
7705
7828
  */
7706
7829
  async applyAutonumbers(object, record, execCtx, driverOwnsAutonumber) {
7707
7830
  if (driverOwnsAutonumber) return;
7708
7831
  const fields = this.getSchema(object)?.fields;
7709
7832
  if (!fields || typeof fields !== "object" || Array.isArray(fields)) return;
7833
+ const now = /* @__PURE__ */ new Date();
7834
+ const timezone = execCtx?.timezone;
7710
7835
  for (const [name, def] of Object.entries(fields)) {
7711
7836
  if (def?.type !== "autonumber") continue;
7712
7837
  const current = record[name];
7713
7838
  if (current != null && current !== "") continue;
7714
- const key = `${object}.${name}`;
7715
- let next = this.autonumberCounters.get(key);
7716
- if (next == null) next = await this.seedAutonumber(object, name, execCtx);
7717
- next += 1;
7718
- this.autonumberCounters.set(key, next);
7719
7839
  const fmt = def.autonumberFormat ?? def.format;
7720
- record[name] = this.formatAutonumber(fmt, next);
7840
+ const tokens = (0, import_data4.parseAutonumberFormat)(typeof fmt === "string" ? fmt : "");
7841
+ const missing = (0, import_data4.missingFieldValues)(tokens, record);
7842
+ if (missing.length > 0) {
7843
+ throw new Error(
7844
+ `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.`
7845
+ );
7846
+ }
7847
+ const probe = (0, import_data4.renderAutonumber)({ tokens, seq: 0, record, now, timezone });
7848
+ const counterKey = `${object}.${name}.${probe.scope}`;
7849
+ let next = this.autonumberCounters.get(counterKey);
7850
+ if (next == null) next = await this.seedAutonumber(object, name, probe.prefix, execCtx);
7851
+ next += 1;
7852
+ this.autonumberCounters.set(counterKey, next);
7853
+ record[name] = (0, import_data4.renderAutonumber)({ tokens, seq: next, record, now, timezone }).value;
7721
7854
  }
7722
7855
  }
7723
- /** Seed the autonumber counter from the current max numeric value in store. */
7724
- async seedAutonumber(object, field, execCtx) {
7856
+ /**
7857
+ * Seed the autonumber counter from the current max in store, scoped to
7858
+ * `prefix`. With a non-empty prefix (date/field formats) only rows in the
7859
+ * same scope count, and the counter is the digit-run immediately after the
7860
+ * prefix; with an empty prefix (legacy fixed-prefix formats) the last digit
7861
+ * run of the whole value is used, preserving the original behaviour.
7862
+ */
7863
+ async seedAutonumber(object, field, prefix, execCtx) {
7725
7864
  try {
7726
7865
  const rows = await this.find(object, {
7727
7866
  select: ["id", field],
@@ -7732,22 +7871,24 @@ var _ObjectQL = class _ObjectQL {
7732
7871
  for (const r of rows || []) {
7733
7872
  const v = r?.[field];
7734
7873
  if (v == null) continue;
7735
- const m = String(v).match(/(\d+)(?!.*\d)/);
7736
- if (m) max = Math.max(max, parseInt(m[1], 10) || 0);
7874
+ const s = String(v);
7875
+ if (prefix && !s.startsWith(prefix)) continue;
7876
+ const tail = prefix ? s.slice(prefix.length) : s;
7877
+ let digits;
7878
+ if (prefix) {
7879
+ const head = tail.match(/^\d+/);
7880
+ digits = head ? head[0] : void 0;
7881
+ } else {
7882
+ const runs = tail.match(/\d+/g);
7883
+ digits = runs ? runs[runs.length - 1] : void 0;
7884
+ }
7885
+ if (digits) max = Math.max(max, parseInt(digits, 10) || 0);
7737
7886
  }
7738
7887
  return max;
7739
7888
  } catch {
7740
7889
  return 0;
7741
7890
  }
7742
7891
  }
7743
- /** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */
7744
- formatAutonumber(format, value) {
7745
- if (!format) return String(value);
7746
- const m = format.match(/\{(0+)\}/);
7747
- if (!m) return format.includes("{0}") ? format.replace("{0}", String(value)) : `${format}${value}`;
7748
- const padded = String(value).padStart(m[1].length, "0");
7749
- return format.replace(m[0], padded);
7750
- }
7751
7892
  /**
7752
7893
  * Register contribution (Manifest)
7753
7894
  *
@@ -8508,11 +8649,18 @@ var _ObjectQL = class _ObjectQL {
8508
8649
  const uniqueIds = [...new Set(allIds)];
8509
8650
  if (uniqueIds.length === 0) continue;
8510
8651
  try {
8652
+ const idFilter = { id: { $in: uniqueIds } };
8653
+ const where = nestedAST.where ? { $and: [idFilter, nestedAST.where] } : idFilter;
8511
8654
  const relatedQuery = {
8512
8655
  object: referenceObject,
8513
- where: { id: { $in: uniqueIds } },
8656
+ where,
8514
8657
  ...nestedAST.fields ? { fields: nestedAST.fields } : {},
8515
8658
  ...nestedAST.orderBy ? { orderBy: nestedAST.orderBy } : {}
8659
+ // NOTE: nestedAST.limit/offset are intentionally NOT forwarded here.
8660
+ // This path batch-loads every parent's related records in a single
8661
+ // $in query, so a *per-parent* limit/offset can't be expressed — a
8662
+ // global cap on the batch would silently drop records other parents
8663
+ // need. Paginate by querying the related object directly instead.
8516
8664
  };
8517
8665
  const driver = this.getDriver(referenceObject);
8518
8666
  const expandOpts = this.buildDriverOptions(execCtx);
@@ -8565,11 +8713,34 @@ var _ObjectQL = class _ObjectQL {
8565
8713
  const driver = this.getDriver(object);
8566
8714
  const ast = { object, ...query };
8567
8715
  delete ast.context;
8716
+ if (ast.filter != null && ast.where == null) {
8717
+ ast.where = ast.filter;
8718
+ }
8719
+ delete ast.filter;
8568
8720
  if (ast.top != null && ast.limit == null) {
8569
8721
  ast.limit = ast.top;
8570
8722
  }
8571
8723
  delete ast.top;
8572
8724
  const _findSchema = this._registry.getObject(object);
8725
+ {
8726
+ const _searchRaw = ast.search ?? ast.$search;
8727
+ if (_searchRaw != null && _findSchema?.fields) {
8728
+ const _reqFields = ast.searchFields ?? ast.$searchFields ?? (typeof ast.search === "object" ? ast.search?.fields : void 0);
8729
+ const _searchFilter = expandSearchToFilter(_searchRaw, {
8730
+ fields: _findSchema.fields,
8731
+ searchableFields: _findSchema.searchableFields,
8732
+ requestedFields: _reqFields,
8733
+ displayField: _findSchema.displayNameField
8734
+ });
8735
+ if (_searchFilter) {
8736
+ ast.where = ast.where ? { $and: [ast.where, _searchFilter] } : _searchFilter;
8737
+ }
8738
+ }
8739
+ delete ast.search;
8740
+ delete ast.$search;
8741
+ delete ast.searchFields;
8742
+ delete ast.$searchFields;
8743
+ }
8573
8744
  const _findFormula = planFormulaProjection(_findSchema, ast.fields);
8574
8745
  if (_findFormula.projected) ast.fields = _findFormula.projected;
8575
8746
  if (_findSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) {
@@ -9274,7 +9445,14 @@ var _ObjectQL = class _ObjectQL {
9274
9445
  const obj = this._registry.getObject(objectName);
9275
9446
  if (!obj) return;
9276
9447
  const driver = this.getDriverForObject(objectName);
9277
- if (!driver || typeof driver.syncSchema !== "function") return;
9448
+ if (!driver) return;
9449
+ if (obj.external != null) {
9450
+ if (typeof driver.registerExternalObject === "function") {
9451
+ await driver.registerExternalObject(obj);
9452
+ }
9453
+ return;
9454
+ }
9455
+ if (typeof driver.syncSchema !== "function") return;
9278
9456
  const tableName = import_system2.StorageNameMapping.resolveTableName(obj);
9279
9457
  await driver.syncSchema(tableName, obj);
9280
9458
  }
@@ -10103,6 +10281,27 @@ var ObjectQLPlugin = class {
10103
10281
  skipped++;
10104
10282
  continue;
10105
10283
  }
10284
+ if (obj.external != null) {
10285
+ if (typeof driver.registerExternalObject === "function") {
10286
+ try {
10287
+ await driver.registerExternalObject(obj);
10288
+ synced++;
10289
+ } catch (e) {
10290
+ ctx.logger.warn("Failed to register external object metadata", {
10291
+ object: obj.name,
10292
+ driver: driver.name,
10293
+ error: e instanceof Error ? e.message : String(e)
10294
+ });
10295
+ }
10296
+ } else {
10297
+ ctx.logger.debug("Driver does not support registerExternalObject, skipping external object", {
10298
+ object: obj.name,
10299
+ driver: driver.name
10300
+ });
10301
+ skipped++;
10302
+ }
10303
+ continue;
10304
+ }
10106
10305
  if (typeof driver.syncSchema !== "function") {
10107
10306
  ctx.logger.debug("Driver does not support syncSchema, skipping", {
10108
10307
  object: obj.name,