@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.mjs CHANGED
@@ -99,6 +99,28 @@ function coerce(value) {
99
99
  }
100
100
  return value;
101
101
  }
102
+ var NUMERIC_STRING_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
103
+ function isNumericOverloadError(err) {
104
+ const message = err instanceof Error ? err.message : String(err);
105
+ return /no such overload/i.test(message);
106
+ }
107
+ function hydrateNumericStrings(value) {
108
+ if (typeof value === "string") {
109
+ const trimmed = value.trim();
110
+ if (trimmed.length > 0 && NUMERIC_STRING_RE.test(trimmed)) {
111
+ const n = Number(trimmed);
112
+ if (Number.isFinite(n)) return n;
113
+ }
114
+ return value;
115
+ }
116
+ if (Array.isArray(value)) return value.map(hydrateNumericStrings);
117
+ if (value && typeof value === "object" && !(value instanceof Date)) {
118
+ const out = {};
119
+ for (const [k, v] of Object.entries(value)) out[k] = hydrateNumericStrings(v);
120
+ return out;
121
+ }
122
+ return value;
123
+ }
102
124
  function classifyError(err) {
103
125
  const message = err instanceof Error ? err.message : String(err);
104
126
  let kind = "runtime";
@@ -143,8 +165,19 @@ var celEngine = {
143
165
  try {
144
166
  const env = buildEnv(now);
145
167
  const scope = buildScope(ctx);
146
- const raw = env.evaluate(source, scope);
147
- return { ok: true, value: coerce(raw) };
168
+ try {
169
+ const raw = env.evaluate(source, scope);
170
+ return { ok: true, value: coerce(raw) };
171
+ } catch (err) {
172
+ if (!isNumericOverloadError(err)) throw err;
173
+ const hydrated = hydrateNumericStrings(scope);
174
+ try {
175
+ const raw = env.evaluate(source, hydrated);
176
+ return { ok: true, value: coerce(raw) };
177
+ } catch {
178
+ throw err;
179
+ }
180
+ }
148
181
  } catch (err) {
149
182
  return classifyError(err);
150
183
  }
@@ -221,18 +254,96 @@ var cronEngine = {
221
254
  };
222
255
 
223
256
  // src/template-engine.ts
224
- var PATH_RE = /\{\{\s*([\w.[\]]+?)\s*\}\}/g;
225
- function resolvePath(scope, path) {
226
- const normalized = path.replace(/\[(\w+)\]/g, ".$1");
227
- const segments = normalized.split(".").filter(Boolean);
228
- let cursor = scope;
229
- for (const seg of segments) {
230
- if (cursor == null || typeof cursor !== "object") return void 0;
231
- cursor = cursor[seg];
257
+ var HOLE_RE = /\{\{([^}]*)\}\}/g;
258
+ function asNumber(v) {
259
+ if (typeof v === "number") return v;
260
+ if (typeof v === "bigint") return Number(v);
261
+ if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v);
262
+ return void 0;
263
+ }
264
+ function asDate(v) {
265
+ if (v instanceof Date) return v;
266
+ if (typeof v === "number") return new Date(v);
267
+ if (typeof v === "string") {
268
+ const d = new Date(v);
269
+ if (!Number.isNaN(d.getTime())) return d;
232
270
  }
233
- return cursor;
271
+ return void 0;
234
272
  }
235
- function stringify(value) {
273
+ var FORMATTERS = {
274
+ upper: (v) => baseString(v).toUpperCase(),
275
+ lower: (v) => baseString(v).toLowerCase(),
276
+ trim: (v) => baseString(v).trim(),
277
+ // number | number:2 → grouped, optional fixed decimals
278
+ number: (v, arg, locale) => {
279
+ const n = asNumber(v);
280
+ if (n === void 0) return baseString(v);
281
+ const digits = arg !== void 0 ? Number(arg) : void 0;
282
+ return new Intl.NumberFormat(locale, digits !== void 0 && !Number.isNaN(digits) ? { minimumFractionDigits: digits, maximumFractionDigits: digits } : {}).format(n);
283
+ },
284
+ // currency | currency:EUR → defaults to USD
285
+ currency: (v, arg, locale) => {
286
+ const n = asNumber(v);
287
+ if (n === void 0) return baseString(v);
288
+ const code = arg && arg.trim() || "USD";
289
+ try {
290
+ return new Intl.NumberFormat(locale, { style: "currency", currency: code }).format(n);
291
+ } catch {
292
+ return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(n);
293
+ }
294
+ },
295
+ // percent | percent:1 → 0.42 → "42%" (value is a 0..1 ratio)
296
+ percent: (v, arg, locale) => {
297
+ const n = asNumber(v);
298
+ if (n === void 0) return baseString(v);
299
+ const digits = arg !== void 0 ? Number(arg) : 0;
300
+ return new Intl.NumberFormat(locale, {
301
+ style: "percent",
302
+ minimumFractionDigits: Number.isNaN(digits) ? 0 : digits,
303
+ maximumFractionDigits: Number.isNaN(digits) ? 0 : digits
304
+ }).format(n);
305
+ },
306
+ // date | date:long | date:iso → date-only
307
+ date: (v, arg, locale) => {
308
+ const d = asDate(v);
309
+ if (!d) return baseString(v);
310
+ if (arg === "iso") return d.toISOString().slice(0, 10);
311
+ const style = arg === "long" ? "long" : arg === "medium" ? "medium" : "short";
312
+ return new Intl.DateTimeFormat(locale, { dateStyle: style }).format(d);
313
+ },
314
+ // datetime | datetime:long | datetime:iso
315
+ datetime: (v, arg, locale) => {
316
+ const d = asDate(v);
317
+ if (!d) return baseString(v);
318
+ if (arg === "iso") return d.toISOString();
319
+ const style = arg === "long" ? "long" : arg === "medium" ? "medium" : "short";
320
+ return new Intl.DateTimeFormat(locale, {
321
+ dateStyle: style,
322
+ timeStyle: style
323
+ }).format(d);
324
+ },
325
+ // truncate:80 → cut with an ellipsis
326
+ truncate: (v, arg) => {
327
+ const s = baseString(v);
328
+ const len = arg !== void 0 ? Number(arg) : 80;
329
+ if (Number.isNaN(len) || s.length <= len) return s;
330
+ return s.slice(0, Math.max(0, len - 1)) + "\u2026";
331
+ },
332
+ // default:'N/A' → fallback when the value is null/undefined/empty
333
+ default: (v, arg) => {
334
+ const s = baseString(v);
335
+ return s === "" ? arg ?? "" : s;
336
+ },
337
+ json: (v) => {
338
+ try {
339
+ return JSON.stringify(v);
340
+ } catch {
341
+ return String(v);
342
+ }
343
+ }
344
+ };
345
+ var TEMPLATE_FORMATTERS = Object.keys(FORMATTERS);
346
+ function baseString(value) {
236
347
  if (value === null || value === void 0) return "";
237
348
  if (value instanceof Date) return value.toISOString();
238
349
  if (typeof value === "string") return value;
@@ -244,21 +355,59 @@ function stringify(value) {
244
355
  return String(value);
245
356
  }
246
357
  }
358
+ function resolvePath(scope, path) {
359
+ const normalized = path.replace(/\[(\w+)\]/g, ".$1");
360
+ const segments = normalized.split(".").filter(Boolean);
361
+ let cursor = scope;
362
+ for (const seg of segments) {
363
+ if (cursor == null || typeof cursor !== "object") return void 0;
364
+ cursor = cursor[seg];
365
+ }
366
+ return cursor;
367
+ }
368
+ var PATH_ONLY_RE = /^[\w.[\]]+$/;
369
+ function parseHole(inner) {
370
+ const pipe = inner.indexOf("|");
371
+ if (pipe === -1) {
372
+ const path2 = inner.trim();
373
+ return PATH_ONLY_RE.test(path2) ? { path: path2 } : null;
374
+ }
375
+ const path = inner.slice(0, pipe).trim();
376
+ if (!PATH_ONLY_RE.test(path)) return null;
377
+ const filterPart = inner.slice(pipe + 1).trim();
378
+ const colon = filterPart.indexOf(":");
379
+ let name = filterPart;
380
+ let arg;
381
+ if (colon !== -1) {
382
+ name = filterPart.slice(0, colon).trim();
383
+ arg = filterPart.slice(colon + 1).trim().replace(/^['"]|['"]$/g, "");
384
+ }
385
+ if (!FORMATTERS[name]) return null;
386
+ return { path, filter: { name, arg } };
387
+ }
247
388
  function compileTemplate(source) {
248
- const matches = source.match(/\{\{|\}\}/g) ?? [];
249
- if (matches.length % 2 !== 0) {
250
- return {
251
- ok: false,
252
- error: { kind: "parse", message: "template has unbalanced {{ }} delimiters" }
253
- };
389
+ const open = (source.match(/\{\{/g) ?? []).length;
390
+ const close = (source.match(/\}\}/g) ?? []).length;
391
+ if (open !== close) {
392
+ return { ok: false, error: { kind: "parse", message: "template has unbalanced {{ }} delimiters" } };
254
393
  }
255
- const refs = [];
394
+ const holes = [];
256
395
  let m;
257
- PATH_RE.lastIndex = 0;
258
- while ((m = PATH_RE.exec(source)) !== null) {
259
- refs.push(m[1]);
396
+ HOLE_RE.lastIndex = 0;
397
+ while ((m = HOLE_RE.exec(source)) !== null) {
398
+ const parsed = parseHole(m[1]);
399
+ if (!parsed) {
400
+ return {
401
+ ok: false,
402
+ error: {
403
+ kind: "parse",
404
+ 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(", ")}.`
405
+ }
406
+ };
407
+ }
408
+ holes.push(parsed);
260
409
  }
261
- return { ok: true, value: refs };
410
+ return { ok: true, value: holes };
262
411
  }
263
412
  var templateEngine = {
264
413
  dialect: "template",
@@ -273,16 +422,20 @@ var templateEngine = {
273
422
  };
274
423
  }
275
424
  if (typeof expr.source !== "string") {
276
- return {
277
- ok: false,
278
- error: { kind: "parse", message: "template Expression.source required" }
279
- };
425
+ return { ok: false, error: { kind: "parse", message: "template Expression.source required" } };
280
426
  }
281
427
  const check = compileTemplate(expr.source);
282
428
  if (!check.ok) return check;
283
429
  const scope = buildScope(ctx);
284
- const out = expr.source.replace(PATH_RE, (_match, path) => {
285
- return stringify(resolvePath(scope, path));
430
+ const locale = ctx.extra && typeof ctx.extra.locale === "string" && ctx.extra.locale || typeof ctx.locale === "string" && ctx.locale || "en-US";
431
+ const out = expr.source.replace(HOLE_RE, (_match, inner) => {
432
+ const parsed = parseHole(String(inner));
433
+ if (!parsed) return _match;
434
+ const value = resolvePath(scope, parsed.path);
435
+ if (parsed.filter) {
436
+ return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale);
437
+ }
438
+ return baseString(value);
286
439
  });
287
440
  return { ok: true, value: out };
288
441
  }
@@ -451,20 +604,162 @@ function looksLikeExpression(value) {
451
604
  if (typeof v.dialect !== "string") return false;
452
605
  return ExpressionSchema2.safeParse(v).success;
453
606
  }
607
+
608
+ // src/validate.ts
609
+ var SINGLE_BRACE_RE = /(?:^|[^{])\{\s*([A-Za-z_$][\w.$]*)\s*\}(?!\})/;
610
+ var RECORD_REF_RE = /\b(?:record|previous)\.([A-Za-z_$][\w$]*)/g;
611
+ function expectedDialect(role) {
612
+ return role === "template" ? "template" : "cel";
613
+ }
614
+ function toSource(input) {
615
+ if (input == null) return { source: "" };
616
+ if (typeof input === "string") return { source: input };
617
+ return { dialect: input.dialect, source: input.source ?? "" };
618
+ }
619
+ function bracesHint(source) {
620
+ const m = SINGLE_BRACE_RE.exec(source);
621
+ if (!m) return null;
622
+ const ref = m[1];
623
+ 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}\`.`;
624
+ }
625
+ function checkFieldExistence(source, schema, errors) {
626
+ if (!schema?.fields || schema.fields.length === 0) return;
627
+ const known = new Set(schema.fields);
628
+ const seen = /* @__PURE__ */ new Set();
629
+ let m;
630
+ RECORD_REF_RE.lastIndex = 0;
631
+ while ((m = RECORD_REF_RE.exec(source)) !== null) {
632
+ const field = m[1];
633
+ if (seen.has(field) || known.has(field)) continue;
634
+ seen.add(field);
635
+ const suggestion = nearest(field, schema.fields);
636
+ errors.push({
637
+ source,
638
+ message: `unknown field \`${field}\`${schema.objectName ? ` on \`${schema.objectName}\`` : ""}` + (suggestion ? ` \u2014 did you mean \`${suggestion}\`?` : "")
639
+ });
640
+ }
641
+ }
642
+ function nearest(name, candidates) {
643
+ let best;
644
+ let bestD = Infinity;
645
+ for (const c of candidates) {
646
+ const d = levenshtein(name, c);
647
+ if (d < bestD) {
648
+ bestD = d;
649
+ best = c;
650
+ }
651
+ }
652
+ return bestD <= Math.max(2, Math.floor(name.length / 3)) ? best : void 0;
653
+ }
654
+ function levenshtein(a, b) {
655
+ const m = a.length, n = b.length;
656
+ const dp = Array.from({ length: m + 1 }, (_, i) => i);
657
+ for (let j = 1; j <= n; j++) {
658
+ let prev = dp[0];
659
+ dp[0] = j;
660
+ for (let i = 1; i <= m; i++) {
661
+ const tmp = dp[i];
662
+ dp[i] = Math.min(dp[i] + 1, dp[i - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
663
+ prev = tmp;
664
+ }
665
+ }
666
+ return dp[m];
667
+ }
668
+ function validateExpression(role, input, schema) {
669
+ const { dialect, source } = toSource(input);
670
+ const errors = [];
671
+ if (!source.trim()) return { ok: true, errors };
672
+ if (role === "template") {
673
+ if (dialect && dialect !== "template") {
674
+ errors.push({ source, message: `expected a text template but got a \`${dialect}\` expression.` });
675
+ return { ok: false, errors };
676
+ }
677
+ const compiled2 = templateEngine.compile(source);
678
+ if (!compiled2.ok) {
679
+ errors.push({ source, message: `invalid template: ${compiled2.error.message} (holes use \`{{ path }}\`).` });
680
+ }
681
+ const hint = SINGLE_BRACE_RE.test(source) ? bracesHintForTemplate(source) : null;
682
+ if (hint) errors.push({ source, message: hint });
683
+ return { ok: errors.length === 0, errors };
684
+ }
685
+ if (dialect && dialect !== "cel") {
686
+ errors.push({ source, message: `expected a CEL expression but got a \`${dialect}\` dialect.` });
687
+ return { ok: false, errors };
688
+ }
689
+ const compiled = celEngine.compile(source);
690
+ if (!compiled.ok) {
691
+ const hint = bracesHint(source);
692
+ errors.push({
693
+ source,
694
+ message: `invalid CEL ${role}: ${compiled.error.message}` + (hint ? ` \u2014 ${hint}` : ` \u2014 ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`)
695
+ });
696
+ } else {
697
+ checkFieldExistence(source, schema, errors);
698
+ }
699
+ return { ok: errors.length === 0, errors };
700
+ }
701
+ function bracesHintForTemplate(source) {
702
+ const m = SINGLE_BRACE_RE.exec(source);
703
+ const ref = m?.[1] ?? "field";
704
+ return `single-brace \`{${ref}}\` is not a valid template hole \u2014 use double braces: \`{{ ${ref} }}\`.`;
705
+ }
706
+ function introspectScope(role, schema) {
707
+ return {
708
+ dialect: expectedDialect(role),
709
+ fields: [...schema?.fields ?? []],
710
+ roots: ["record", "previous", "input", "os", "vars"],
711
+ functions: CEL_STDLIB_FUNCTIONS
712
+ };
713
+ }
714
+ var CEL_STDLIB_FUNCTIONS = [
715
+ "now",
716
+ "today",
717
+ "daysFromNow",
718
+ "daysBetween",
719
+ "date",
720
+ "datetime",
721
+ "timestamp",
722
+ "isBlank",
723
+ "isEmpty",
724
+ "coalesce",
725
+ "len",
726
+ "size",
727
+ "int",
728
+ "float",
729
+ "string",
730
+ "bool",
731
+ "upper",
732
+ "lower",
733
+ "trim",
734
+ "contains",
735
+ "startsWith",
736
+ "endsWith",
737
+ "matches",
738
+ "has",
739
+ "min",
740
+ "max",
741
+ "abs",
742
+ "round"
743
+ ];
454
744
  export {
745
+ CEL_STDLIB_FUNCTIONS,
455
746
  DEFAULT_LIMITS,
456
747
  ExpressionEngine,
748
+ TEMPLATE_FORMATTERS,
457
749
  buildScope,
458
750
  celEngine,
459
751
  cronEngine,
752
+ expectedDialect,
460
753
  getEngine,
461
754
  hasDialect,
755
+ introspectScope,
462
756
  normalizeExpression,
463
757
  normalizeExpressionTree,
464
758
  register,
465
759
  registerStdLib,
466
760
  resolveSeed,
467
761
  resolveSeedRecord,
468
- templateEngine
762
+ templateEngine,
763
+ validateExpression
469
764
  };
470
765
  //# sourceMappingURL=index.mjs.map