@objectstack/formula 7.5.0 → 7.7.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
@@ -20,20 +20,25 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ CEL_STDLIB_FUNCTIONS: () => CEL_STDLIB_FUNCTIONS,
23
24
  DEFAULT_LIMITS: () => DEFAULT_LIMITS,
24
25
  ExpressionEngine: () => ExpressionEngine,
26
+ TEMPLATE_FORMATTERS: () => TEMPLATE_FORMATTERS,
25
27
  buildScope: () => buildScope,
26
28
  celEngine: () => celEngine,
27
29
  cronEngine: () => cronEngine,
30
+ expectedDialect: () => expectedDialect,
28
31
  getEngine: () => getEngine,
29
32
  hasDialect: () => hasDialect,
33
+ introspectScope: () => introspectScope,
30
34
  normalizeExpression: () => normalizeExpression,
31
35
  normalizeExpressionTree: () => normalizeExpressionTree,
32
36
  register: () => register,
33
37
  registerStdLib: () => registerStdLib,
34
38
  resolveSeed: () => resolveSeed,
35
39
  resolveSeedRecord: () => resolveSeedRecord,
36
- templateEngine: () => templateEngine
40
+ templateEngine: () => templateEngine,
41
+ validateExpression: () => validateExpression
37
42
  });
38
43
  module.exports = __toCommonJS(index_exports);
39
44
 
@@ -138,6 +143,28 @@ function coerce(value) {
138
143
  }
139
144
  return value;
140
145
  }
146
+ var NUMERIC_STRING_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
147
+ function isNumericOverloadError(err) {
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ return /no such overload/i.test(message);
150
+ }
151
+ function hydrateNumericStrings(value) {
152
+ if (typeof value === "string") {
153
+ const trimmed = value.trim();
154
+ if (trimmed.length > 0 && NUMERIC_STRING_RE.test(trimmed)) {
155
+ const n = Number(trimmed);
156
+ if (Number.isFinite(n)) return n;
157
+ }
158
+ return value;
159
+ }
160
+ if (Array.isArray(value)) return value.map(hydrateNumericStrings);
161
+ if (value && typeof value === "object" && !(value instanceof Date)) {
162
+ const out = {};
163
+ for (const [k, v] of Object.entries(value)) out[k] = hydrateNumericStrings(v);
164
+ return out;
165
+ }
166
+ return value;
167
+ }
141
168
  function classifyError(err) {
142
169
  const message = err instanceof Error ? err.message : String(err);
143
170
  let kind = "runtime";
@@ -182,8 +209,19 @@ var celEngine = {
182
209
  try {
183
210
  const env = buildEnv(now);
184
211
  const scope = buildScope(ctx);
185
- const raw = env.evaluate(source, scope);
186
- return { ok: true, value: coerce(raw) };
212
+ try {
213
+ const raw = env.evaluate(source, scope);
214
+ return { ok: true, value: coerce(raw) };
215
+ } catch (err) {
216
+ if (!isNumericOverloadError(err)) throw err;
217
+ const hydrated = hydrateNumericStrings(scope);
218
+ try {
219
+ const raw = env.evaluate(source, hydrated);
220
+ return { ok: true, value: coerce(raw) };
221
+ } catch {
222
+ throw err;
223
+ }
224
+ }
187
225
  } catch (err) {
188
226
  return classifyError(err);
189
227
  }
@@ -260,18 +298,96 @@ var cronEngine = {
260
298
  };
261
299
 
262
300
  // src/template-engine.ts
263
- var PATH_RE = /\{\{\s*([\w.[\]]+?)\s*\}\}/g;
264
- function resolvePath(scope, path) {
265
- const normalized = path.replace(/\[(\w+)\]/g, ".$1");
266
- const segments = normalized.split(".").filter(Boolean);
267
- let cursor = scope;
268
- for (const seg of segments) {
269
- if (cursor == null || typeof cursor !== "object") return void 0;
270
- cursor = cursor[seg];
301
+ var HOLE_RE = /\{\{([^}]*)\}\}/g;
302
+ function asNumber(v) {
303
+ if (typeof v === "number") return v;
304
+ if (typeof v === "bigint") return Number(v);
305
+ if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v);
306
+ return void 0;
307
+ }
308
+ function asDate(v) {
309
+ if (v instanceof Date) return v;
310
+ if (typeof v === "number") return new Date(v);
311
+ if (typeof v === "string") {
312
+ const d = new Date(v);
313
+ if (!Number.isNaN(d.getTime())) return d;
271
314
  }
272
- return cursor;
315
+ return void 0;
273
316
  }
274
- function stringify(value) {
317
+ var FORMATTERS = {
318
+ upper: (v) => baseString(v).toUpperCase(),
319
+ lower: (v) => baseString(v).toLowerCase(),
320
+ trim: (v) => baseString(v).trim(),
321
+ // number | number:2 → grouped, optional fixed decimals
322
+ number: (v, arg, locale) => {
323
+ const n = asNumber(v);
324
+ if (n === void 0) return baseString(v);
325
+ const digits = arg !== void 0 ? Number(arg) : void 0;
326
+ return new Intl.NumberFormat(locale, digits !== void 0 && !Number.isNaN(digits) ? { minimumFractionDigits: digits, maximumFractionDigits: digits } : {}).format(n);
327
+ },
328
+ // currency | currency:EUR → defaults to USD
329
+ currency: (v, arg, locale) => {
330
+ const n = asNumber(v);
331
+ if (n === void 0) return baseString(v);
332
+ const code = arg && arg.trim() || "USD";
333
+ try {
334
+ return new Intl.NumberFormat(locale, { style: "currency", currency: code }).format(n);
335
+ } catch {
336
+ return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(n);
337
+ }
338
+ },
339
+ // percent | percent:1 → 0.42 → "42%" (value is a 0..1 ratio)
340
+ percent: (v, arg, locale) => {
341
+ const n = asNumber(v);
342
+ if (n === void 0) return baseString(v);
343
+ const digits = arg !== void 0 ? Number(arg) : 0;
344
+ return new Intl.NumberFormat(locale, {
345
+ style: "percent",
346
+ minimumFractionDigits: Number.isNaN(digits) ? 0 : digits,
347
+ maximumFractionDigits: Number.isNaN(digits) ? 0 : digits
348
+ }).format(n);
349
+ },
350
+ // date | date:long | date:iso → date-only
351
+ date: (v, arg, locale) => {
352
+ const d = asDate(v);
353
+ if (!d) return baseString(v);
354
+ if (arg === "iso") return d.toISOString().slice(0, 10);
355
+ const style = arg === "long" ? "long" : arg === "medium" ? "medium" : "short";
356
+ return new Intl.DateTimeFormat(locale, { dateStyle: style }).format(d);
357
+ },
358
+ // datetime | datetime:long | datetime:iso
359
+ datetime: (v, arg, locale) => {
360
+ const d = asDate(v);
361
+ if (!d) return baseString(v);
362
+ if (arg === "iso") return d.toISOString();
363
+ const style = arg === "long" ? "long" : arg === "medium" ? "medium" : "short";
364
+ return new Intl.DateTimeFormat(locale, {
365
+ dateStyle: style,
366
+ timeStyle: style
367
+ }).format(d);
368
+ },
369
+ // truncate:80 → cut with an ellipsis
370
+ truncate: (v, arg) => {
371
+ const s = baseString(v);
372
+ const len = arg !== void 0 ? Number(arg) : 80;
373
+ if (Number.isNaN(len) || s.length <= len) return s;
374
+ return s.slice(0, Math.max(0, len - 1)) + "\u2026";
375
+ },
376
+ // default:'N/A' → fallback when the value is null/undefined/empty
377
+ default: (v, arg) => {
378
+ const s = baseString(v);
379
+ return s === "" ? arg ?? "" : s;
380
+ },
381
+ json: (v) => {
382
+ try {
383
+ return JSON.stringify(v);
384
+ } catch {
385
+ return String(v);
386
+ }
387
+ }
388
+ };
389
+ var TEMPLATE_FORMATTERS = Object.keys(FORMATTERS);
390
+ function baseString(value) {
275
391
  if (value === null || value === void 0) return "";
276
392
  if (value instanceof Date) return value.toISOString();
277
393
  if (typeof value === "string") return value;
@@ -283,21 +399,59 @@ function stringify(value) {
283
399
  return String(value);
284
400
  }
285
401
  }
402
+ function resolvePath(scope, path) {
403
+ const normalized = path.replace(/\[(\w+)\]/g, ".$1");
404
+ const segments = normalized.split(".").filter(Boolean);
405
+ let cursor = scope;
406
+ for (const seg of segments) {
407
+ if (cursor == null || typeof cursor !== "object") return void 0;
408
+ cursor = cursor[seg];
409
+ }
410
+ return cursor;
411
+ }
412
+ var PATH_ONLY_RE = /^[\w.[\]]+$/;
413
+ function parseHole(inner) {
414
+ const pipe = inner.indexOf("|");
415
+ if (pipe === -1) {
416
+ const path2 = inner.trim();
417
+ return PATH_ONLY_RE.test(path2) ? { path: path2 } : null;
418
+ }
419
+ const path = inner.slice(0, pipe).trim();
420
+ if (!PATH_ONLY_RE.test(path)) return null;
421
+ const filterPart = inner.slice(pipe + 1).trim();
422
+ const colon = filterPart.indexOf(":");
423
+ let name = filterPart;
424
+ let arg;
425
+ if (colon !== -1) {
426
+ name = filterPart.slice(0, colon).trim();
427
+ arg = filterPart.slice(colon + 1).trim().replace(/^['"]|['"]$/g, "");
428
+ }
429
+ if (!FORMATTERS[name]) return null;
430
+ return { path, filter: { name, arg } };
431
+ }
286
432
  function compileTemplate(source) {
287
- const matches = source.match(/\{\{|\}\}/g) ?? [];
288
- if (matches.length % 2 !== 0) {
289
- return {
290
- ok: false,
291
- error: { kind: "parse", message: "template has unbalanced {{ }} delimiters" }
292
- };
433
+ const open = (source.match(/\{\{/g) ?? []).length;
434
+ const close = (source.match(/\}\}/g) ?? []).length;
435
+ if (open !== close) {
436
+ return { ok: false, error: { kind: "parse", message: "template has unbalanced {{ }} delimiters" } };
293
437
  }
294
- const refs = [];
438
+ const holes = [];
295
439
  let m;
296
- PATH_RE.lastIndex = 0;
297
- while ((m = PATH_RE.exec(source)) !== null) {
298
- refs.push(m[1]);
440
+ HOLE_RE.lastIndex = 0;
441
+ while ((m = HOLE_RE.exec(source)) !== null) {
442
+ const parsed = parseHole(m[1]);
443
+ if (!parsed) {
444
+ return {
445
+ ok: false,
446
+ error: {
447
+ kind: "parse",
448
+ message: `invalid template hole \`{{ ${m[1]} }}\` \u2014 holes are a field path with an optional formatter (\`{{ record.amount | currency }}\`), not arbitrary logic. Move logic into a CEL field. Known formatters: ${TEMPLATE_FORMATTERS.join(", ")}.`
449
+ }
450
+ };
451
+ }
452
+ holes.push(parsed);
299
453
  }
300
- return { ok: true, value: refs };
454
+ return { ok: true, value: holes };
301
455
  }
302
456
  var templateEngine = {
303
457
  dialect: "template",
@@ -312,16 +466,20 @@ var templateEngine = {
312
466
  };
313
467
  }
314
468
  if (typeof expr.source !== "string") {
315
- return {
316
- ok: false,
317
- error: { kind: "parse", message: "template Expression.source required" }
318
- };
469
+ return { ok: false, error: { kind: "parse", message: "template Expression.source required" } };
319
470
  }
320
471
  const check = compileTemplate(expr.source);
321
472
  if (!check.ok) return check;
322
473
  const scope = buildScope(ctx);
323
- const out = expr.source.replace(PATH_RE, (_match, path) => {
324
- return stringify(resolvePath(scope, path));
474
+ const locale = ctx.extra && typeof ctx.extra.locale === "string" && ctx.extra.locale || typeof ctx.locale === "string" && ctx.locale || "en-US";
475
+ const out = expr.source.replace(HOLE_RE, (_match, inner) => {
476
+ const parsed = parseHole(String(inner));
477
+ if (!parsed) return _match;
478
+ const value = resolvePath(scope, parsed.path);
479
+ if (parsed.filter) {
480
+ return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale);
481
+ }
482
+ return baseString(value);
325
483
  });
326
484
  return { ok: true, value: out };
327
485
  }
@@ -487,21 +645,163 @@ function looksLikeExpression(value) {
487
645
  if (typeof v.dialect !== "string") return false;
488
646
  return import_spec2.ExpressionSchema.safeParse(v).success;
489
647
  }
648
+
649
+ // src/validate.ts
650
+ var SINGLE_BRACE_RE = /(?:^|[^{])\{\s*([A-Za-z_$][\w.$]*)\s*\}(?!\})/;
651
+ var RECORD_REF_RE = /\b(?:record|previous)\.([A-Za-z_$][\w$]*)/g;
652
+ function expectedDialect(role) {
653
+ return role === "template" ? "template" : "cel";
654
+ }
655
+ function toSource(input) {
656
+ if (input == null) return { source: "" };
657
+ if (typeof input === "string") return { source: input };
658
+ return { dialect: input.dialect, source: input.source ?? "" };
659
+ }
660
+ function bracesHint(source) {
661
+ const m = SINGLE_BRACE_RE.exec(source);
662
+ if (!m) return null;
663
+ const ref = m[1];
664
+ return `it looks like a \`{${ref}}\` template brace was used inside a CEL expression \u2014 \`{\u2026}\` parses as a CEL map literal and fails. Write the bare reference instead, e.g. \`${ref}\`.`;
665
+ }
666
+ function checkFieldExistence(source, schema, errors) {
667
+ if (!schema?.fields || schema.fields.length === 0) return;
668
+ const known = new Set(schema.fields);
669
+ const seen = /* @__PURE__ */ new Set();
670
+ let m;
671
+ RECORD_REF_RE.lastIndex = 0;
672
+ while ((m = RECORD_REF_RE.exec(source)) !== null) {
673
+ const field = m[1];
674
+ if (seen.has(field) || known.has(field)) continue;
675
+ seen.add(field);
676
+ const suggestion = nearest(field, schema.fields);
677
+ errors.push({
678
+ source,
679
+ message: `unknown field \`${field}\`${schema.objectName ? ` on \`${schema.objectName}\`` : ""}` + (suggestion ? ` \u2014 did you mean \`${suggestion}\`?` : "")
680
+ });
681
+ }
682
+ }
683
+ function nearest(name, candidates) {
684
+ let best;
685
+ let bestD = Infinity;
686
+ for (const c of candidates) {
687
+ const d = levenshtein(name, c);
688
+ if (d < bestD) {
689
+ bestD = d;
690
+ best = c;
691
+ }
692
+ }
693
+ return bestD <= Math.max(2, Math.floor(name.length / 3)) ? best : void 0;
694
+ }
695
+ function levenshtein(a, b) {
696
+ const m = a.length, n = b.length;
697
+ const dp = Array.from({ length: m + 1 }, (_, i) => i);
698
+ for (let j = 1; j <= n; j++) {
699
+ let prev = dp[0];
700
+ dp[0] = j;
701
+ for (let i = 1; i <= m; i++) {
702
+ const tmp = dp[i];
703
+ dp[i] = Math.min(dp[i] + 1, dp[i - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
704
+ prev = tmp;
705
+ }
706
+ }
707
+ return dp[m];
708
+ }
709
+ function validateExpression(role, input, schema) {
710
+ const { dialect, source } = toSource(input);
711
+ const errors = [];
712
+ if (!source.trim()) return { ok: true, errors };
713
+ if (role === "template") {
714
+ if (dialect && dialect !== "template") {
715
+ errors.push({ source, message: `expected a text template but got a \`${dialect}\` expression.` });
716
+ return { ok: false, errors };
717
+ }
718
+ const compiled2 = templateEngine.compile(source);
719
+ if (!compiled2.ok) {
720
+ errors.push({ source, message: `invalid template: ${compiled2.error.message} (holes use \`{{ path }}\`).` });
721
+ }
722
+ const hint = SINGLE_BRACE_RE.test(source) ? bracesHintForTemplate(source) : null;
723
+ if (hint) errors.push({ source, message: hint });
724
+ return { ok: errors.length === 0, errors };
725
+ }
726
+ if (dialect && dialect !== "cel") {
727
+ errors.push({ source, message: `expected a CEL expression but got a \`${dialect}\` dialect.` });
728
+ return { ok: false, errors };
729
+ }
730
+ const compiled = celEngine.compile(source);
731
+ if (!compiled.ok) {
732
+ const hint = bracesHint(source);
733
+ errors.push({
734
+ source,
735
+ message: `invalid CEL ${role}: ${compiled.error.message}` + (hint ? ` \u2014 ${hint}` : ` \u2014 ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`)
736
+ });
737
+ } else {
738
+ checkFieldExistence(source, schema, errors);
739
+ }
740
+ return { ok: errors.length === 0, errors };
741
+ }
742
+ function bracesHintForTemplate(source) {
743
+ const m = SINGLE_BRACE_RE.exec(source);
744
+ const ref = m?.[1] ?? "field";
745
+ return `single-brace \`{${ref}}\` is not a valid template hole \u2014 use double braces: \`{{ ${ref} }}\`.`;
746
+ }
747
+ function introspectScope(role, schema) {
748
+ return {
749
+ dialect: expectedDialect(role),
750
+ fields: [...schema?.fields ?? []],
751
+ roots: ["record", "previous", "input", "os", "vars"],
752
+ functions: CEL_STDLIB_FUNCTIONS
753
+ };
754
+ }
755
+ var CEL_STDLIB_FUNCTIONS = [
756
+ "now",
757
+ "today",
758
+ "daysFromNow",
759
+ "daysBetween",
760
+ "date",
761
+ "datetime",
762
+ "timestamp",
763
+ "isBlank",
764
+ "isEmpty",
765
+ "coalesce",
766
+ "len",
767
+ "size",
768
+ "int",
769
+ "float",
770
+ "string",
771
+ "bool",
772
+ "upper",
773
+ "lower",
774
+ "trim",
775
+ "contains",
776
+ "startsWith",
777
+ "endsWith",
778
+ "matches",
779
+ "has",
780
+ "min",
781
+ "max",
782
+ "abs",
783
+ "round"
784
+ ];
490
785
  // Annotate the CommonJS export names for ESM import in node:
491
786
  0 && (module.exports = {
787
+ CEL_STDLIB_FUNCTIONS,
492
788
  DEFAULT_LIMITS,
493
789
  ExpressionEngine,
790
+ TEMPLATE_FORMATTERS,
494
791
  buildScope,
495
792
  celEngine,
496
793
  cronEngine,
794
+ expectedDialect,
497
795
  getEngine,
498
796
  hasDialect,
797
+ introspectScope,
499
798
  normalizeExpression,
500
799
  normalizeExpressionTree,
501
800
  register,
502
801
  registerStdLib,
503
802
  resolveSeed,
504
803
  resolveSeedRecord,
505
- templateEngine
804
+ templateEngine,
805
+ validateExpression
506
806
  });
507
807
  //# sourceMappingURL=index.js.map