@objectstack/formula 10.3.0 → 11.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/formula@10.3.0 build /home/runner/work/framework/framework/packages/formula
2
+ > @objectstack/formula@11.0.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 43.08 KB
14
- ESM dist/index.mjs.map 118.46 KB
15
- ESM ⚡️ Build success in 124ms
16
- CJS dist/index.js 45.02 KB
17
- CJS dist/index.js.map 120.34 KB
18
- CJS ⚡️ Build success in 130ms
13
+ CJS dist/index.js 48.18 KB
14
+ CJS dist/index.js.map 130.43 KB
15
+ CJS ⚡️ Build success in 114ms
16
+ ESM dist/index.mjs 46.17 KB
17
+ ESM dist/index.mjs.map 128.47 KB
18
+ ESM ⚡️ Build success in 115ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 4555ms
21
- DTS dist/index.d.mts 20.19 KB
22
- DTS dist/index.d.ts 20.19 KB
20
+ DTS ⚡️ Build success in 4697ms
21
+ DTS dist/index.d.mts 22.39 KB
22
+ DTS dist/index.d.ts 22.39 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @objectstack/formula
2
2
 
3
+ ## 11.0.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ef3ed67: Formula field typing: `inferExpressionType()` + a declared `returnType`.
8
+
9
+ - `@objectstack/formula`: new `inferExpressionType()` (and lower-level `inferCelType()`) surfaces the cel-js type-checker's result for a CEL value/formula expression, mapped to `number | text | boolean | date | unknown`. Conservative — two `dyn` operands stay `unknown`; typed literals/stdlib returns pin a concrete type.
10
+ - `@objectstack/spec`: `FieldSchema` gains an optional `returnType` (`number|text|boolean|date`) so a formula field can carry its declared value type (the way Salesforce/Airtable do), letting consumers (dataset measures, formatting, validation) read a declared type instead of re-parsing the expression.
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies [ab5718a]
15
+ - Updated dependencies [4845c12]
16
+ - Updated dependencies [c1a754a]
17
+ - Updated dependencies [6fbe91f]
18
+ - Updated dependencies [715d667]
19
+ - Updated dependencies [5eef4cf]
20
+ - Updated dependencies [72759e1]
21
+ - Updated dependencies [6c4fbd9]
22
+ - Updated dependencies [ef3ed67]
23
+ - Updated dependencies [cd51229]
24
+ - Updated dependencies [7697a0e]
25
+ - Updated dependencies [e7e04f1]
26
+ - Updated dependencies [cfd5ac4]
27
+ - Updated dependencies [2be5c1f]
28
+ - Updated dependencies [ad143ce]
29
+ - Updated dependencies [5c4a8c8]
30
+ - Updated dependencies [3afaeed]
31
+ - Updated dependencies [8801c02]
32
+ - Updated dependencies [3d04e06]
33
+ - Updated dependencies [4a84c98]
34
+ - Updated dependencies [d980f0d]
35
+ - Updated dependencies [a658523]
36
+ - Updated dependencies [82ff91c]
37
+ - Updated dependencies [638f472]
38
+ - @objectstack/spec@11.0.0
39
+
3
40
  ## 10.3.0
4
41
 
5
42
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -32,9 +32,22 @@ interface EvalContext {
32
32
  * Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive.
33
33
  */
34
34
  timezone?: string;
35
- /** Current authenticated subject (hook / action / view contexts). */
35
+ /**
36
+ * Current authenticated subject (hook / action / view contexts).
37
+ *
38
+ * ADR-0068: the canonical user contract is {@link EvalUser} from
39
+ * `@objectstack/spec`, surfaced to predicates as `current_user` (aliases
40
+ * `user`, `ctx.user`). `roles: string[]` is the only canonical role field;
41
+ * the singular `role` is deprecated (its "overwritten to 'admin' on
42
+ * promotion" behavior is the footgun ADR-0068 eliminates).
43
+ */
36
44
  user?: {
37
45
  id: string;
46
+ /** CANONICAL (ADR-0068). Scope-resolved role names assigned to the user. */
47
+ roles?: string[];
48
+ /** Active organization ID (null = platform / unscoped). */
49
+ organizationId?: string | null;
50
+ /** @deprecated ADR-0068 — use {@link roles}. Retained for back-compat only. */
38
51
  role?: string;
39
52
  email?: string;
40
53
  [key: string]: unknown;
@@ -424,6 +437,14 @@ interface ExprSchemaHint {
424
437
  * did-you-mean *warning*. (Default.)
425
438
  */
426
439
  scope?: 'record' | 'flattened';
440
+ /**
441
+ * ADR-0068 D4 — the closed catalog of valid role names (built-in + declared).
442
+ * When supplied, a role-membership predicate testing a role NOT in this set
443
+ * (e.g. `'org_admni' in current_user.roles`) is flagged as an error. Closes
444
+ * the AI-hallucination hole where a model invents a plausible-but-nonexistent
445
+ * role that then silently never matches. Absent => role checks are skipped.
446
+ */
447
+ roleCatalog?: readonly string[];
427
448
  }
428
449
  interface ExprValidationError {
429
450
  /** Self-correcting message: what is wrong + the correct form. */
@@ -459,8 +480,27 @@ declare function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
459
480
  dialect: 'cel' | 'template';
460
481
  fields: string[];
461
482
  roots: string[];
483
+ roles: string[];
462
484
  functions: string[];
463
485
  };
486
+ /**
487
+ * Coarse value categories a `value`/formula expression can compute. `'unknown'`
488
+ * means cel-js could not prove a concrete type — either a `dyn` result (an
489
+ * ambiguous expression over untyped operands) or one that does not type-check.
490
+ */
491
+ type InferredValueType = 'number' | 'text' | 'boolean' | 'date' | 'unknown';
492
+ /**
493
+ * Infer the coarse value type a `value`/formula expression computes — `'number'`,
494
+ * `'text'`, `'boolean'`, `'date'`, or `'unknown'` when cel-js cannot prove a
495
+ * concrete type. `schema.fields` (the host object's field names) are declared so
496
+ * a bare `<field>` reference resolves the same as `record.<field>`.
497
+ *
498
+ * The motivating use is measure-eligibility: a dataset derives a SUM measure for
499
+ * a `formula` field ONLY when this returns `'number'`, so an ambiguous or
500
+ * non-numeric formula never yields an incoherent measure. Conservative by
501
+ * construction — see {@link inferCelType}.
502
+ */
503
+ declare function inferExpressionType(input: ExprInput, schema?: ExprSchemaHint): InferredValueType;
464
504
  /**
465
505
  * Public catalog of CEL functions available in expressions — what `introspectScope`
466
506
  * advertises to authors (incl. AI). Every entry MUST actually resolve at runtime:
@@ -469,4 +509,4 @@ declare function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
469
509
  */
470
510
  declare const CEL_STDLIB_FUNCTIONS: string[];
471
511
 
472
- export { CEL_STDLIB_FUNCTIONS, type CelFilterCompileOptions, type CelFilterCompileResult, type CelFilterFailReason, 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, compileCelToFilter, cronEngine, expectedDialect, formatValue, getEngine, hasDialect, introspectScope, isPushdownableCel, lowerCelAst, matchesFilterCondition, normalizeExpression, normalizeExpressionTree, register, registerStdLib, resolveSeed, resolveSeedRecord, templateEngine, validateExpression };
512
+ export { CEL_STDLIB_FUNCTIONS, type CelFilterCompileOptions, type CelFilterCompileResult, type CelFilterFailReason, DEFAULT_LIMITS, type DialectEngine, type EvalContext, type EvalError, type EvalResult, type ExprInput, type ExprSchemaHint, type ExprValidationError, type ExprValidationResult, ExpressionEngine, type FieldRole, type InferredValueType, type SeedPrimitive, type SeedValue, TEMPLATE_FORMATTERS, buildScope, celEngine, compileCelToFilter, cronEngine, expectedDialect, formatValue, getEngine, hasDialect, inferExpressionType, introspectScope, isPushdownableCel, lowerCelAst, matchesFilterCondition, normalizeExpression, normalizeExpressionTree, register, registerStdLib, resolveSeed, resolveSeedRecord, templateEngine, validateExpression };
package/dist/index.d.ts CHANGED
@@ -32,9 +32,22 @@ interface EvalContext {
32
32
  * Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive.
33
33
  */
34
34
  timezone?: string;
35
- /** Current authenticated subject (hook / action / view contexts). */
35
+ /**
36
+ * Current authenticated subject (hook / action / view contexts).
37
+ *
38
+ * ADR-0068: the canonical user contract is {@link EvalUser} from
39
+ * `@objectstack/spec`, surfaced to predicates as `current_user` (aliases
40
+ * `user`, `ctx.user`). `roles: string[]` is the only canonical role field;
41
+ * the singular `role` is deprecated (its "overwritten to 'admin' on
42
+ * promotion" behavior is the footgun ADR-0068 eliminates).
43
+ */
36
44
  user?: {
37
45
  id: string;
46
+ /** CANONICAL (ADR-0068). Scope-resolved role names assigned to the user. */
47
+ roles?: string[];
48
+ /** Active organization ID (null = platform / unscoped). */
49
+ organizationId?: string | null;
50
+ /** @deprecated ADR-0068 — use {@link roles}. Retained for back-compat only. */
38
51
  role?: string;
39
52
  email?: string;
40
53
  [key: string]: unknown;
@@ -424,6 +437,14 @@ interface ExprSchemaHint {
424
437
  * did-you-mean *warning*. (Default.)
425
438
  */
426
439
  scope?: 'record' | 'flattened';
440
+ /**
441
+ * ADR-0068 D4 — the closed catalog of valid role names (built-in + declared).
442
+ * When supplied, a role-membership predicate testing a role NOT in this set
443
+ * (e.g. `'org_admni' in current_user.roles`) is flagged as an error. Closes
444
+ * the AI-hallucination hole where a model invents a plausible-but-nonexistent
445
+ * role that then silently never matches. Absent => role checks are skipped.
446
+ */
447
+ roleCatalog?: readonly string[];
427
448
  }
428
449
  interface ExprValidationError {
429
450
  /** Self-correcting message: what is wrong + the correct form. */
@@ -459,8 +480,27 @@ declare function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
459
480
  dialect: 'cel' | 'template';
460
481
  fields: string[];
461
482
  roots: string[];
483
+ roles: string[];
462
484
  functions: string[];
463
485
  };
486
+ /**
487
+ * Coarse value categories a `value`/formula expression can compute. `'unknown'`
488
+ * means cel-js could not prove a concrete type — either a `dyn` result (an
489
+ * ambiguous expression over untyped operands) or one that does not type-check.
490
+ */
491
+ type InferredValueType = 'number' | 'text' | 'boolean' | 'date' | 'unknown';
492
+ /**
493
+ * Infer the coarse value type a `value`/formula expression computes — `'number'`,
494
+ * `'text'`, `'boolean'`, `'date'`, or `'unknown'` when cel-js cannot prove a
495
+ * concrete type. `schema.fields` (the host object's field names) are declared so
496
+ * a bare `<field>` reference resolves the same as `record.<field>`.
497
+ *
498
+ * The motivating use is measure-eligibility: a dataset derives a SUM measure for
499
+ * a `formula` field ONLY when this returns `'number'`, so an ambiguous or
500
+ * non-numeric formula never yields an incoherent measure. Conservative by
501
+ * construction — see {@link inferCelType}.
502
+ */
503
+ declare function inferExpressionType(input: ExprInput, schema?: ExprSchemaHint): InferredValueType;
464
504
  /**
465
505
  * Public catalog of CEL functions available in expressions — what `introspectScope`
466
506
  * advertises to authors (incl. AI). Every entry MUST actually resolve at runtime:
@@ -469,4 +509,4 @@ declare function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
469
509
  */
470
510
  declare const CEL_STDLIB_FUNCTIONS: string[];
471
511
 
472
- export { CEL_STDLIB_FUNCTIONS, type CelFilterCompileOptions, type CelFilterCompileResult, type CelFilterFailReason, 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, compileCelToFilter, cronEngine, expectedDialect, formatValue, getEngine, hasDialect, introspectScope, isPushdownableCel, lowerCelAst, matchesFilterCondition, normalizeExpression, normalizeExpressionTree, register, registerStdLib, resolveSeed, resolveSeedRecord, templateEngine, validateExpression };
512
+ export { CEL_STDLIB_FUNCTIONS, type CelFilterCompileOptions, type CelFilterCompileResult, type CelFilterFailReason, DEFAULT_LIMITS, type DialectEngine, type EvalContext, type EvalError, type EvalResult, type ExprInput, type ExprSchemaHint, type ExprValidationError, type ExprValidationResult, ExpressionEngine, type FieldRole, type InferredValueType, type SeedPrimitive, type SeedValue, TEMPLATE_FORMATTERS, buildScope, celEngine, compileCelToFilter, cronEngine, expectedDialect, formatValue, getEngine, hasDialect, inferExpressionType, introspectScope, isPushdownableCel, lowerCelAst, matchesFilterCondition, normalizeExpression, normalizeExpressionTree, register, registerStdLib, resolveSeed, resolveSeedRecord, templateEngine, validateExpression };
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ __export(index_exports, {
32
32
  formatValue: () => formatValue,
33
33
  getEngine: () => getEngine,
34
34
  hasDialect: () => hasDialect,
35
+ inferExpressionType: () => inferExpressionType,
35
36
  introspectScope: () => introspectScope,
36
37
  isPushdownableCel: () => isPushdownableCel,
37
38
  lowerCelAst: () => lowerCelAst,
@@ -51,6 +52,7 @@ module.exports = __toCommonJS(index_exports);
51
52
  var import_cel_js = require("@marcbachmann/cel-js");
52
53
 
53
54
  // src/stdlib.ts
55
+ var import_spec = require("@objectstack/spec");
54
56
  function partsInTz(d, tz) {
55
57
  const parts = new Intl.DateTimeFormat("en-US", {
56
58
  timeZone: tz,
@@ -171,13 +173,37 @@ function registerNumericCoercions(env) {
171
173
  }
172
174
  return env;
173
175
  }
176
+ function toEvalUser(u) {
177
+ const legacyRole = typeof u.role === "string" && u.role ? [u.role] : [];
178
+ const roles = Array.isArray(u.roles) ? u.roles : [];
179
+ const canonical = (0, import_spec.createEvalUser)({
180
+ id: u.id,
181
+ name: typeof u.name === "string" ? u.name : void 0,
182
+ email: typeof u.email === "string" ? u.email : void 0,
183
+ roles: [...roles, ...legacyRole],
184
+ organizationId: typeof u.organizationId === "string" || u.organizationId === null ? u.organizationId : void 0
185
+ });
186
+ if (typeof u.role === "string" && u.role) {
187
+ canonical.role = u.role;
188
+ }
189
+ return canonical;
190
+ }
174
191
  function buildScope(ctx) {
175
192
  const scope = {};
176
193
  if (ctx.record !== void 0) scope.record = ctx.record;
177
194
  if (ctx.previous !== void 0) scope.previous = ctx.previous;
178
195
  if (ctx.input !== void 0) scope.input = ctx.input;
179
196
  const os = {};
180
- if (ctx.user !== void 0) os.user = ctx.user;
197
+ if (ctx.user !== void 0) {
198
+ const currentUser = toEvalUser(ctx.user);
199
+ scope.current_user = currentUser;
200
+ scope.user = currentUser;
201
+ scope.ctx = {
202
+ ...typeof scope.ctx === "object" && scope.ctx !== null ? scope.ctx : {},
203
+ user: currentUser
204
+ };
205
+ os.user = currentUser;
206
+ }
181
207
  if (ctx.org !== void 0) os.org = ctx.org;
182
208
  if (ctx.env !== void 0) os.env = ctx.env;
183
209
  if (Object.keys(os).length > 0) scope.os = os;
@@ -267,6 +293,17 @@ function firstUndeclaredReference(source, knownFields = []) {
267
293
  }
268
294
  return null;
269
295
  }
296
+ function inferCelType(source, knownFields = []) {
297
+ if (typeof source !== "string" || !source.trim()) return null;
298
+ try {
299
+ const env = knownFields.length === 0 ? recordScopeEnv ?? (recordScopeEnv = buildScopedEnv([])) : buildScopedEnv(knownFields);
300
+ const result = env.parse(source).check?.();
301
+ if (!result || result.valid === false) return null;
302
+ return typeof result.type === "string" ? result.type : null;
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
270
307
  function coerce(value) {
271
308
  if (typeof value === "bigint") {
272
309
  if (value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)) {
@@ -704,12 +741,12 @@ var ExpressionEngine = {
704
741
  };
705
742
 
706
743
  // src/seed-eval.ts
707
- var import_spec = require("@objectstack/spec");
744
+ var import_spec2 = require("@objectstack/spec");
708
745
  function isExpressionLike(value) {
709
746
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
710
747
  const v = value;
711
748
  if (typeof v.dialect !== "string") return false;
712
- return import_spec.ExpressionSchema.safeParse(v).success;
749
+ return import_spec2.ExpressionSchema.safeParse(v).success;
713
750
  }
714
751
  function resolveSeed(value, ctx) {
715
752
  if (value === null || value === void 0) {
@@ -749,9 +786,9 @@ function resolveSeedRecord(record, ctx) {
749
786
  }
750
787
 
751
788
  // src/normalize.ts
752
- var import_spec2 = require("@objectstack/spec");
789
+ var import_spec3 = require("@objectstack/spec");
753
790
  function normalizeExpression(input) {
754
- const parsed = import_spec2.ExpressionInputSchema.safeParse(input);
791
+ const parsed = import_spec3.ExpressionInputSchema.safeParse(input);
755
792
  if (!parsed.success) {
756
793
  return {
757
794
  ok: false,
@@ -799,7 +836,7 @@ function looksLikeExpression(value) {
799
836
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
800
837
  const v = value;
801
838
  if (typeof v.dialect !== "string") return false;
802
- return import_spec2.ExpressionSchema.safeParse(v).success;
839
+ return import_spec3.ExpressionSchema.safeParse(v).success;
803
840
  }
804
841
 
805
842
  // src/cel-to-filter.ts
@@ -1213,6 +1250,30 @@ function levenshtein(a, b) {
1213
1250
  }
1214
1251
  return dp[m];
1215
1252
  }
1253
+ var ROLE_IN_RE = /(['"])([a-z0-9_]+)\1\s+in\s+(?:current_user|user|ctx\.user)\.roles\b/g;
1254
+ var ROLE_CONTAINS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*contains\(\s*(['"])([a-z0-9_]+)\1\s*\)/g;
1255
+ var ROLE_EXISTS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*exists\s*\([^,)]{0,64},[^)=]{0,128}==\s*(['"])([a-z0-9_]+)\1/g;
1256
+ var ROLE_EQ_RE = /(?:current_user|user|ctx\.user)\.role\s*==\s*(['"])([a-z0-9_]+)\1/g;
1257
+ function checkRoleCatalog(source, schema, errors) {
1258
+ const catalog = schema?.roleCatalog;
1259
+ if (!catalog || catalog.length === 0) return;
1260
+ const known = new Set(catalog);
1261
+ const seen = /* @__PURE__ */ new Set();
1262
+ for (const re of [ROLE_IN_RE, ROLE_CONTAINS_RE, ROLE_EXISTS_RE, ROLE_EQ_RE]) {
1263
+ re.lastIndex = 0;
1264
+ let m;
1265
+ while ((m = re.exec(source)) !== null) {
1266
+ const name = m[2];
1267
+ if (known.has(name) || seen.has(name)) continue;
1268
+ seen.add(name);
1269
+ const suggestion = nearest(name, catalog);
1270
+ errors.push({
1271
+ source,
1272
+ message: `unknown role \`${name}\` \u2014 not a defined role` + (suggestion ? `; did you mean \`${suggestion}\`?` : ".") + ` Valid roles: ${catalog.join(", ")}.`
1273
+ });
1274
+ }
1275
+ }
1276
+ }
1216
1277
  function validateExpression(role, input, schema) {
1217
1278
  const { dialect, source } = toSource2(input);
1218
1279
  const errors = [];
@@ -1244,6 +1305,7 @@ function validateExpression(role, input, schema) {
1244
1305
  });
1245
1306
  } else {
1246
1307
  checkFieldExistence(source, schema, errors);
1308
+ checkRoleCatalog(source, schema, errors);
1247
1309
  if (schema?.scope === "record") {
1248
1310
  const bare = firstUndeclaredReference(source);
1249
1311
  if (bare) {
@@ -1276,10 +1338,32 @@ function introspectScope(role, schema) {
1276
1338
  return {
1277
1339
  dialect: expectedDialect(role),
1278
1340
  fields: [...schema?.fields ?? []],
1279
- roots: ["record", "previous", "input", "os", "vars"],
1341
+ roots: ["record", "previous", "input", "os", "current_user", "user", "vars"],
1342
+ roles: [...schema?.roleCatalog ?? []],
1280
1343
  functions: CEL_STDLIB_FUNCTIONS
1281
1344
  };
1282
1345
  }
1346
+ function celTypeToValueType(celType) {
1347
+ switch (celType) {
1348
+ case "int":
1349
+ case "uint":
1350
+ case "double":
1351
+ return "number";
1352
+ case "string":
1353
+ return "text";
1354
+ case "bool":
1355
+ return "boolean";
1356
+ case "google.protobuf.Timestamp":
1357
+ return "date";
1358
+ default:
1359
+ return "unknown";
1360
+ }
1361
+ }
1362
+ function inferExpressionType(input, schema) {
1363
+ const { source } = toSource2(input);
1364
+ if (!source.trim()) return "unknown";
1365
+ return celTypeToValueType(inferCelType(source, schema?.fields));
1366
+ }
1283
1367
  var CEL_STDLIB_FUNCTIONS = [
1284
1368
  // Dates (registered stdlib)
1285
1369
  "now",
@@ -1334,6 +1418,7 @@ var CEL_STDLIB_FUNCTIONS = [
1334
1418
  formatValue,
1335
1419
  getEngine,
1336
1420
  hasDialect,
1421
+ inferExpressionType,
1337
1422
  introspectScope,
1338
1423
  isPushdownableCel,
1339
1424
  lowerCelAst,