@objectstack/formula 9.6.0 → 9.8.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/formula@9.6.0 build /home/runner/work/framework/framework/packages/formula
2
+ > @objectstack/formula@9.8.0 build /home/runner/work/framework/framework/packages/formula
3
3
  > tsup --config ../../tsup.config.ts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- ESM dist/index.mjs 23.36 KB
14
- ESM dist/index.mjs.map 61.55 KB
15
- ESM ⚡️ Build success in 78ms
16
- CJS dist/index.js 25.07 KB
17
- CJS dist/index.js.map 62.92 KB
18
- CJS ⚡️ Build success in 76ms
13
+ CJS dist/index.js 29.77 KB
14
+ CJS dist/index.js.map 78.89 KB
15
+ CJS ⚡️ Build success in 123ms
16
+ ESM dist/index.mjs 28.04 KB
17
+ ESM dist/index.mjs.map 77.53 KB
18
+ ESM ⚡️ Build success in 124ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 4187ms
21
- DTS dist/index.d.mts 14.02 KB
22
- DTS dist/index.d.ts 14.02 KB
20
+ DTS ⚡️ Build success in 4371ms
21
+ DTS dist/index.d.mts 15.71 KB
22
+ DTS dist/index.d.ts 15.71 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,88 @@
1
1
  # @objectstack/formula
2
2
 
3
+ ## 9.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c17d2c8: feat(formula): register the CEL functions the authoring catalog advertises (daysBetween, abs, round, min, max, upper, lower, contains, startsWith, endsWith, matches, len, isEmpty, date, datetime)
8
+
9
+ `introspectScope` / `CEL_STDLIB_FUNCTIONS` advertised 25 functions to authors
10
+ (incl. AI), but only 8 were registered — 14 faulted at runtime (`daysBetween`,
11
+ `abs`, `round`, `min`, `max`, `upper`, `lower`, `len`, `isEmpty`, `contains`,
12
+ `startsWith`, `endsWith`, `matches`, plus `date`/`datetime`). Authors were told
13
+ to call functions that don't exist (e.g. `daysBetween` for "days remaining").
14
+
15
+ Register the genuinely-useful set in `registerStdLib` with dyn-lenient signatures
16
+ (so a `Field.date` arriving as a string still works) and internal coercion, and
17
+ reconcile the catalog so every advertised entry resolves — guarded by a test that
18
+ evaluates every `CEL_STDLIB_FUNCTIONS` entry. Pure additions; no behavior change
19
+ to existing expressions.
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [97c55b3]
24
+ - Updated dependencies [1b1f490]
25
+ - @objectstack/spec@9.8.0
26
+
27
+ ## 9.7.0
28
+
29
+ ### Minor Changes
30
+
31
+ - ff0a87a: feat(validate): flag bare field references in record-scoped CEL sites at build time
32
+
33
+ > **Heads-up for downstream:** this adds a NEW build-time error. A `Field.formula`
34
+ > or validation predicate that references a field bare (`amount` instead of
35
+ > `record.amount`) now fails `objectstack compile`. These expressions were already
36
+ > silently broken at runtime (they evaluated to `null` / never fired), so this is a
37
+ > fix that surfaces a latent bug — but a stack carrying one will go from
38
+ > "builds, silently wrong" to "fails the build" on upgrade. The error message
39
+ > states the exact correction (`write record.<field>`).
40
+
41
+ A `Field.formula` and an object validation predicate evaluate against the
42
+ `record` namespace only — there is no field flattening — so a bare top-level
43
+ identifier (`amount`, `status`) resolves to nothing and the expression silently
44
+ evaluates to `null` / never fires. This is the silent-at-runtime class behind
45
+ the broken example-crm formulas (#1927) and is exactly what AI authors get wrong.
46
+
47
+ `validateExpression` now takes an evaluation `scope` and, for `scope: 'record'`,
48
+ reports a bare reference with the corrective form (`write record.<field>`). The
49
+ check is schema-free and acts only on cel-js's `Unknown variable` fault, so it
50
+ cannot false-positive on arithmetic/comparison/null-guard type overloads. Flow
51
+ and automation conditions keep the default `scope: 'flattened'` — the record's
52
+ fields ARE spread to top-level there (alongside flow variables), so bare refs
53
+ are correct and are NOT flagged. `objectstack compile` wires `record` scope for
54
+ field formulas and validation predicates; flow conditions stay flattened.
55
+
56
+ ### Patch Changes
57
+
58
+ - 82c7438: fix(formula): register mixed `double <op> int` arithmetic overloads so number-field formulas compute
59
+
60
+ cel-js types a record field number as `double` and a bare integer literal as
61
+ `int`, and ships overloads only for matching numeric pairs. So an everyday
62
+ formula like `record.amount / 100` or `record.price * 2` faulted at runtime
63
+ (`no such overload: dyn<double> / int`); the engine caught the fault and the
64
+ formula silently evaluated to `null` — passing build, empty at runtime (#1928).
65
+
66
+ The CEL engine now registers the missing `double <op> int` / `int <op> double`
67
+ overloads for `+ - * / %`, computing the result as a `double` (CEL's mixed-numeric
68
+ promotion). Pure `int op int` is untouched, so integer division (`7 / 2 == 3`)
69
+ keeps its semantics — the overloads fire only when the operands are genuinely a
70
+ `double` and an `int`. Authors no longer need the `/ 100.0` float-literal workaround.
71
+
72
+ - 417b6ac: feat(validate): advisory did-you-mean warnings for likely field typos in flow conditions
73
+
74
+ Adds a non-blocking warning channel to build-time expression validation (#1928
75
+ tier 3). Flow / automation conditions flatten the record's fields to top-level,
76
+ so a bare `status` is correct — but a bare NON-field identifier is either a flow
77
+ variable or a typo. When it is a near-miss of a known field (edit distance), the
78
+ build now emits a `did you mean \`status\`?`warning instead of staying silent,
79
+ WITHOUT failing the build (a genuine flow variable won't be close to a field
80
+ name, so it stays quiet).`ExprValidationResult`gains a`warnings`array and`ExprIssue`a`severity`; `objectstack compile` prints warnings and only fails on
81
+ errors. This closes the silent-skip gap for misspelled trigger-condition fields
82
+ (the #1877 family) without the false-positive risk of a hard gate.
83
+
84
+ - @objectstack/spec@9.7.0
85
+
3
86
  ## 9.6.0
4
87
 
5
88
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -310,6 +310,23 @@ interface ExprSchemaHint {
310
310
  objectName?: string;
311
311
  /** Known top-level field names, so `record.<field>` can be checked. */
312
312
  fields?: readonly string[];
313
+ /**
314
+ * Evaluation scope of the authoring site — determines whether a bare top-level
315
+ * identifier is legal (#1928):
316
+ * - `'record'` → the record is bound only as the `record` namespace, with
317
+ * no field flattening (`Field.formula`, object validation
318
+ * predicates). A bare `amount` resolves to nothing and the
319
+ * expression silently evaluates to `null` / never fires, so
320
+ * it MUST be written `record.amount`. We flag bare refs.
321
+ * - `'flattened'` → the record's own fields are spread to top-level alongside
322
+ * flow variables (flow / automation conditions), so bare
323
+ * `status` is correct and is NOT an error. Flow variables
324
+ * are not schema-knowable, so a non-field bare identifier
325
+ * can't be soundly told apart from a typo — but when one is
326
+ * a near-miss of a known field we emit a non-blocking
327
+ * did-you-mean *warning*. (Default.)
328
+ */
329
+ scope?: 'record' | 'flattened';
313
330
  }
314
331
  interface ExprValidationError {
315
332
  /** Self-correcting message: what is wrong + the correct form. */
@@ -320,6 +337,13 @@ interface ExprValidationError {
320
337
  interface ExprValidationResult {
321
338
  ok: boolean;
322
339
  errors: ExprValidationError[];
340
+ /**
341
+ * Non-blocking advisories (#1928 tier 3): a likely-typo'd field reference in a
342
+ * flattened flow condition. Never affects `ok` — callers surface these without
343
+ * failing the build, since a bare identifier there may legitimately be a flow
344
+ * variable.
345
+ */
346
+ warnings: ExprValidationError[];
323
347
  }
324
348
  /** The dialect a field role expects (Decision 2). */
325
349
  declare function expectedDialect(role: FieldRole): 'cel' | 'template';
@@ -340,7 +364,12 @@ declare function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
340
364
  roots: string[];
341
365
  functions: string[];
342
366
  };
343
- /** Public catalog of CEL stdlib functions available in expressions. */
367
+ /**
368
+ * Public catalog of CEL functions available in expressions — what `introspectScope`
369
+ * advertises to authors (incl. AI). Every entry MUST actually resolve at runtime:
370
+ * either registered in `registerStdLib` or a verified cel-js built-in. Drifting this
371
+ * list ahead of the runtime tells the author to call functions that fault (#1928).
372
+ */
344
373
  declare const CEL_STDLIB_FUNCTIONS: string[];
345
374
 
346
375
  export { CEL_STDLIB_FUNCTIONS, DEFAULT_LIMITS, type DialectEngine, type EvalContext, type EvalError, type EvalResult, type ExprSchemaHint, type ExprValidationError, type ExprValidationResult, ExpressionEngine, type FieldRole, type SeedPrimitive, type SeedValue, TEMPLATE_FORMATTERS, buildScope, celEngine, cronEngine, expectedDialect, getEngine, hasDialect, introspectScope, normalizeExpression, normalizeExpressionTree, register, registerStdLib, resolveSeed, resolveSeedRecord, templateEngine, validateExpression };
package/dist/index.d.ts CHANGED
@@ -310,6 +310,23 @@ interface ExprSchemaHint {
310
310
  objectName?: string;
311
311
  /** Known top-level field names, so `record.<field>` can be checked. */
312
312
  fields?: readonly string[];
313
+ /**
314
+ * Evaluation scope of the authoring site — determines whether a bare top-level
315
+ * identifier is legal (#1928):
316
+ * - `'record'` → the record is bound only as the `record` namespace, with
317
+ * no field flattening (`Field.formula`, object validation
318
+ * predicates). A bare `amount` resolves to nothing and the
319
+ * expression silently evaluates to `null` / never fires, so
320
+ * it MUST be written `record.amount`. We flag bare refs.
321
+ * - `'flattened'` → the record's own fields are spread to top-level alongside
322
+ * flow variables (flow / automation conditions), so bare
323
+ * `status` is correct and is NOT an error. Flow variables
324
+ * are not schema-knowable, so a non-field bare identifier
325
+ * can't be soundly told apart from a typo — but when one is
326
+ * a near-miss of a known field we emit a non-blocking
327
+ * did-you-mean *warning*. (Default.)
328
+ */
329
+ scope?: 'record' | 'flattened';
313
330
  }
314
331
  interface ExprValidationError {
315
332
  /** Self-correcting message: what is wrong + the correct form. */
@@ -320,6 +337,13 @@ interface ExprValidationError {
320
337
  interface ExprValidationResult {
321
338
  ok: boolean;
322
339
  errors: ExprValidationError[];
340
+ /**
341
+ * Non-blocking advisories (#1928 tier 3): a likely-typo'd field reference in a
342
+ * flattened flow condition. Never affects `ok` — callers surface these without
343
+ * failing the build, since a bare identifier there may legitimately be a flow
344
+ * variable.
345
+ */
346
+ warnings: ExprValidationError[];
323
347
  }
324
348
  /** The dialect a field role expects (Decision 2). */
325
349
  declare function expectedDialect(role: FieldRole): 'cel' | 'template';
@@ -340,7 +364,12 @@ declare function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
340
364
  roots: string[];
341
365
  functions: string[];
342
366
  };
343
- /** Public catalog of CEL stdlib functions available in expressions. */
367
+ /**
368
+ * Public catalog of CEL functions available in expressions — what `introspectScope`
369
+ * advertises to authors (incl. AI). Every entry MUST actually resolve at runtime:
370
+ * either registered in `registerStdLib` or a verified cel-js built-in. Drifting this
371
+ * list ahead of the runtime tells the author to call functions that fault (#1928).
372
+ */
344
373
  declare const CEL_STDLIB_FUNCTIONS: string[];
345
374
 
346
375
  export { CEL_STDLIB_FUNCTIONS, DEFAULT_LIMITS, type DialectEngine, type EvalContext, type EvalError, type EvalResult, type ExprSchemaHint, type ExprValidationError, type ExprValidationResult, ExpressionEngine, type FieldRole, type SeedPrimitive, type SeedValue, TEMPLATE_FORMATTERS, buildScope, celEngine, cronEngine, expectedDialect, getEngine, hasDialect, introspectScope, normalizeExpression, normalizeExpressionTree, register, registerStdLib, resolveSeed, resolveSeedRecord, templateEngine, validateExpression };
package/dist/index.js CHANGED
@@ -51,6 +51,12 @@ function startOfDayUtc(d) {
51
51
  out.setUTCHours(0, 0, 0, 0);
52
52
  return out;
53
53
  }
54
+ function toDate(v) {
55
+ if (v instanceof Date) return v;
56
+ if (typeof v === "number" || typeof v === "bigint") return new Date(Number(v));
57
+ return new Date(String(v));
58
+ }
59
+ var MS_PER_DAY = 864e5;
54
60
  function addDaysUtc(d, n) {
55
61
  const out = new Date(d.getTime());
56
62
  out.setUTCDate(out.getUTCDate() + n);
@@ -96,8 +102,35 @@ function registerStdLib(env, now) {
96
102
  }
97
103
  return parts.join(separator);
98
104
  }
105
+ ).registerFunction(
106
+ "daysBetween(dyn, dyn): int",
107
+ (a, b) => BigInt(Math.round((toDate(b).getTime() - toDate(a).getTime()) / MS_PER_DAY))
108
+ ).registerFunction("date(dyn): google.protobuf.Timestamp", (s) => toDate(s)).registerFunction("datetime(dyn): google.protobuf.Timestamp", (s) => toDate(s)).registerFunction("abs(dyn): double", (x) => Math.abs(Number(x))).registerFunction("round(dyn): int", (x) => BigInt(Math.round(Number(x)))).registerFunction("min(dyn, dyn): dyn", (a, b) => Number(a) <= Number(b) ? a : b).registerFunction("max(dyn, dyn): dyn", (a, b) => Number(a) >= Number(b) ? a : b).registerFunction("upper(dyn): string", (s) => String(s ?? "").toUpperCase()).registerFunction("lower(dyn): string", (s) => String(s ?? "").toLowerCase()).registerFunction("contains(dyn, dyn): bool", (s, sub) => String(s ?? "").includes(String(sub ?? ""))).registerFunction("startsWith(dyn, dyn): bool", (s, p) => String(s ?? "").startsWith(String(p ?? ""))).registerFunction("endsWith(dyn, dyn): bool", (s, p) => String(s ?? "").endsWith(String(p ?? ""))).registerFunction("matches(dyn, dyn): bool", (s, re) => new RegExp(String(re ?? "")).test(String(s ?? ""))).registerFunction("len(dyn): int", (v) => BigInt(lengthOf(v))).registerFunction(
109
+ "isEmpty(dyn): bool",
110
+ (v) => v === null || v === void 0 || lengthOf(v) === 0
99
111
  );
100
112
  }
113
+ function lengthOf(v) {
114
+ if (v === null || v === void 0) return 0;
115
+ if (typeof v === "string" || Array.isArray(v)) return v.length;
116
+ if (typeof v === "object") return Object.keys(v).length;
117
+ return 0;
118
+ }
119
+ function registerNumericCoercions(env) {
120
+ const ops = {
121
+ "+": (a, b) => a + b,
122
+ "-": (a, b) => a - b,
123
+ "*": (a, b) => a * b,
124
+ "/": (a, b) => a / b,
125
+ "%": (a, b) => a % b
126
+ };
127
+ for (const [op, fn] of Object.entries(ops)) {
128
+ const impl = (a, b) => fn(Number(a), Number(b));
129
+ env.registerOperator(`double ${op} int`, impl);
130
+ env.registerOperator(`int ${op} double`, impl);
131
+ }
132
+ return env;
133
+ }
101
134
  function buildScope(ctx) {
102
135
  const scope = {};
103
136
  if (ctx.record !== void 0) scope.record = ctx.record;
@@ -126,7 +159,66 @@ function buildEnv(now) {
126
159
  enableOptionalTypes: true,
127
160
  limits: DEFAULT_LIMITS
128
161
  });
129
- return registerStdLib(env, now);
162
+ return registerNumericCoercions(registerStdLib(env, now));
163
+ }
164
+ var SCOPE_ROOTS = [
165
+ "record",
166
+ "previous",
167
+ "input",
168
+ "output",
169
+ "os",
170
+ "vars",
171
+ "variables",
172
+ "automation",
173
+ "context",
174
+ "args",
175
+ "item",
176
+ "env",
177
+ "user",
178
+ "step",
179
+ "result",
180
+ "trigger",
181
+ "event",
182
+ "payload",
183
+ "data",
184
+ "params",
185
+ "config",
186
+ "settings"
187
+ ];
188
+ function buildScopedEnv(knownFields) {
189
+ const env = new import_cel_js.Environment({
190
+ unlistedVariablesAreDyn: false,
191
+ enableOptionalTypes: true,
192
+ limits: DEFAULT_LIMITS
193
+ });
194
+ registerStdLib(env, () => /* @__PURE__ */ new Date(0));
195
+ for (const root of SCOPE_ROOTS) {
196
+ try {
197
+ env.registerVariable(root, "map");
198
+ } catch {
199
+ }
200
+ }
201
+ for (const field of knownFields) {
202
+ try {
203
+ env.registerVariable(field, "dyn");
204
+ } catch {
205
+ }
206
+ }
207
+ return env;
208
+ }
209
+ var recordScopeEnv;
210
+ function firstUndeclaredReference(source, knownFields = []) {
211
+ if (typeof source !== "string" || !source.trim()) return null;
212
+ try {
213
+ const env = knownFields.length === 0 ? recordScopeEnv ?? (recordScopeEnv = buildScopedEnv([])) : buildScopedEnv(knownFields);
214
+ const result = env.parse(source).check?.();
215
+ if (result && result.valid === false) {
216
+ const m = /Unknown variable:\s*([A-Za-z_$][\w$]*)/.exec(result.error?.message ?? "");
217
+ if (m) return m[1];
218
+ }
219
+ } catch {
220
+ }
221
+ return null;
130
222
  }
131
223
  function coerce(value) {
132
224
  if (typeof value === "bigint") {
@@ -715,11 +807,12 @@ function levenshtein(a, b) {
715
807
  function validateExpression(role, input, schema) {
716
808
  const { dialect, source } = toSource(input);
717
809
  const errors = [];
718
- if (!source.trim()) return { ok: true, errors };
810
+ const warnings = [];
811
+ if (!source.trim()) return { ok: true, errors, warnings };
719
812
  if (role === "template") {
720
813
  if (dialect && dialect !== "template") {
721
814
  errors.push({ source, message: `expected a text template but got a \`${dialect}\` expression.` });
722
- return { ok: false, errors };
815
+ return { ok: false, errors, warnings };
723
816
  }
724
817
  const compiled2 = templateEngine.compile(source);
725
818
  if (!compiled2.ok) {
@@ -727,11 +820,11 @@ function validateExpression(role, input, schema) {
727
820
  }
728
821
  const hint = SINGLE_BRACE_RE.test(source) ? bracesHintForTemplate(source) : null;
729
822
  if (hint) errors.push({ source, message: hint });
730
- return { ok: errors.length === 0, errors };
823
+ return { ok: errors.length === 0, errors, warnings };
731
824
  }
732
825
  if (dialect && dialect !== "cel") {
733
826
  errors.push({ source, message: `expected a CEL expression but got a \`${dialect}\` dialect.` });
734
- return { ok: false, errors };
827
+ return { ok: false, errors, warnings };
735
828
  }
736
829
  const compiled = celEngine.compile(source);
737
830
  if (!compiled.ok) {
@@ -742,8 +835,28 @@ function validateExpression(role, input, schema) {
742
835
  });
743
836
  } else {
744
837
  checkFieldExistence(source, schema, errors);
838
+ if (schema?.scope === "record") {
839
+ const bare = firstUndeclaredReference(source);
840
+ if (bare) {
841
+ errors.push({
842
+ source,
843
+ message: `bare reference \`${bare}\` \u2014 a formula/validation expression binds the record as the \`record\` namespace, not at top level, so \`${bare}\` resolves to nothing and the expression silently evaluates to null. Write \`record.${bare}\`.`
844
+ });
845
+ }
846
+ } else if (schema?.fields && schema.fields.length > 0) {
847
+ const unknown = firstUndeclaredReference(source, schema.fields);
848
+ if (unknown) {
849
+ const suggestion = nearest(unknown, schema.fields);
850
+ if (suggestion) {
851
+ warnings.push({
852
+ source,
853
+ message: `\`${unknown}\` is not a field of \`${schema.objectName ?? "the trigger object"}\` \u2014 did you mean \`${suggestion}\`? (flow conditions reference fields bare, e.g. \`${suggestion} == \u2026\`). If \`${unknown}\` is a flow variable this is safe to ignore.`
854
+ });
855
+ }
856
+ }
857
+ }
745
858
  }
746
- return { ok: errors.length === 0, errors };
859
+ return { ok: errors.length === 0, errors, warnings };
747
860
  }
748
861
  function bracesHintForTemplate(source) {
749
862
  const m = SINGLE_BRACE_RE.exec(source);
@@ -759,22 +872,20 @@ function introspectScope(role, schema) {
759
872
  };
760
873
  }
761
874
  var CEL_STDLIB_FUNCTIONS = [
875
+ // Dates (registered stdlib)
762
876
  "now",
763
877
  "today",
764
878
  "daysFromNow",
879
+ "daysAgo",
765
880
  "daysBetween",
766
881
  "date",
767
882
  "datetime",
768
- "timestamp",
769
- "isBlank",
770
- "isEmpty",
771
- "coalesce",
772
- "len",
773
- "size",
774
- "int",
775
- "float",
776
- "string",
777
- "bool",
883
+ // Numbers (registered stdlib)
884
+ "abs",
885
+ "round",
886
+ "min",
887
+ "max",
888
+ // Strings (registered stdlib)
778
889
  "upper",
779
890
  "lower",
780
891
  "trim",
@@ -782,11 +893,21 @@ var CEL_STDLIB_FUNCTIONS = [
782
893
  "startsWith",
783
894
  "endsWith",
784
895
  "matches",
896
+ "joinNonEmpty",
897
+ // Collections / null-ish (registered stdlib)
898
+ "isBlank",
899
+ "isEmpty",
900
+ "coalesce",
901
+ "len",
902
+ // cel-js built-ins (verified to resolve)
903
+ "size",
785
904
  "has",
786
- "min",
787
- "max",
788
- "abs",
789
- "round"
905
+ "int",
906
+ "string",
907
+ "bool",
908
+ "double",
909
+ "timestamp",
910
+ "duration"
790
911
  ];
791
912
  // Annotate the CommonJS export names for ESM import in node:
792
913
  0 && (module.exports = {