@stndrds/schema 0.1.0-alpha.18 → 0.1.0-alpha.20

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.
Files changed (42) hide show
  1. package/dist/chunk-4SW4V3YU.mjs +8854 -0
  2. package/dist/chunk-7GSIXU67.mjs +8820 -0
  3. package/dist/chunk-BKOV4XVX.mjs +8816 -0
  4. package/dist/chunk-BQUYTOB3.mjs +8815 -0
  5. package/dist/chunk-BSAR4UKK.mjs +8851 -0
  6. package/dist/chunk-DPOXIK3M.mjs +8816 -0
  7. package/dist/chunk-DU2HLOQH.mjs +8813 -0
  8. package/dist/chunk-I4TTNDN7.mjs +8910 -0
  9. package/dist/chunk-JD2MVLTW.mjs +8814 -0
  10. package/dist/chunk-KDHA2N7D.mjs +8927 -0
  11. package/dist/chunk-MX4X4UEN.mjs +8854 -0
  12. package/dist/chunk-OJWVSONX.mjs +8900 -0
  13. package/dist/chunk-PMYSW54L.mjs +8855 -0
  14. package/dist/chunk-SD2UXOSF.mjs +8814 -0
  15. package/dist/chunk-Y5KIRAND.mjs +8916 -0
  16. package/dist/chunk-YLVZT3H4.mjs +8793 -0
  17. package/dist/chunk-ZU5SR355.mjs +8862 -0
  18. package/dist/index.d.mts +119 -6
  19. package/dist/index.d.ts +119 -6
  20. package/dist/index.js +1215 -11
  21. package/dist/index.mjs +83 -2
  22. package/dist/runtime-09fOm3VO.d.mts +6383 -0
  23. package/dist/runtime-09fOm3VO.d.ts +6383 -0
  24. package/dist/runtime-BMHqETm7.d.mts +6450 -0
  25. package/dist/runtime-BMHqETm7.d.ts +6450 -0
  26. package/dist/runtime-BpsaNQPs.d.mts +6397 -0
  27. package/dist/runtime-BpsaNQPs.d.ts +6397 -0
  28. package/dist/runtime-CHAkwiwa.d.mts +6450 -0
  29. package/dist/runtime-CHAkwiwa.d.ts +6450 -0
  30. package/dist/runtime-CPggQqoW.d.mts +6428 -0
  31. package/dist/runtime-CPggQqoW.d.ts +6428 -0
  32. package/dist/runtime-CfVfZtoq.d.mts +6437 -0
  33. package/dist/runtime-CfVfZtoq.d.ts +6437 -0
  34. package/dist/runtime-DSMMZRLE.d.mts +6430 -0
  35. package/dist/runtime-DSMMZRLE.d.ts +6430 -0
  36. package/dist/runtime-xtAfMn_f.d.mts +6436 -0
  37. package/dist/runtime-xtAfMn_f.d.ts +6436 -0
  38. package/dist/runtime.d.mts +1 -1
  39. package/dist/runtime.d.ts +1 -1
  40. package/dist/runtime.js +1075 -9
  41. package/dist/runtime.mjs +53 -1
  42. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -144,6 +144,8 @@ __export(index_exports, {
144
144
  GeocodingService: () => GeocodingService,
145
145
  GlobalSearchService: () => GlobalSearchService,
146
146
  GroupBuilder: () => GroupBuilder,
147
+ InvalidPathError: () => InvalidPathError,
148
+ MaxDepthExceededError: () => MaxDepthExceededError,
147
149
  NO_VALUE_OPERATORS: () => NO_VALUE_OPERATORS,
148
150
  NoopGeocodingAdapter: () => NoopGeocodingAdapter,
149
151
  NoopHookRegistry: () => NoopHookRegistry,
@@ -160,8 +162,11 @@ __export(index_exports, {
160
162
  RecordNotFoundError: () => RecordNotFoundError,
161
163
  RecordReferencedError: () => RecordReferencedError,
162
164
  RecordService: () => RecordService,
165
+ RelationResolverService: () => RelationResolverService,
163
166
  RelationService: () => RelationService,
164
167
  RoleNotFoundError: () => RoleNotFoundError,
168
+ RollupScheduler: () => RollupScheduler,
169
+ RollupService: () => RollupService,
165
170
  SYSTEM_RESOURCES: () => SYSTEM_RESOURCES,
166
171
  SYSTEM_RESOURCE_LABELS: () => SYSTEM_RESOURCE_LABELS,
167
172
  SchemaError: () => SchemaError,
@@ -186,6 +191,7 @@ __export(index_exports, {
186
191
  createDateValidator: () => createDateValidator,
187
192
  createDraftValidator: () => createDraftValidator,
188
193
  createFileValidator: () => createFileValidator,
194
+ createFormulaValidator: () => createFormulaValidator,
189
195
  createLocationValidator: () => createLocationValidator,
190
196
  createMockAdapter: () => createMockAdapter,
191
197
  createMultiRelationValidator: () => createMultiRelationValidator,
@@ -195,6 +201,7 @@ __export(index_exports, {
195
201
  createPhoneValidator: () => createPhoneValidator,
196
202
  createRatingValidator: () => createRatingValidator,
197
203
  createRelationValidator: () => createRelationValidator,
204
+ createRollupValidator: () => createRollupValidator,
198
205
  createSelectValidator: () => createSelectValidator,
199
206
  createSingleRelationValidator: () => createSingleRelationValidator,
200
207
  createStatusValidator: () => createStatusValidator,
@@ -206,18 +213,34 @@ __export(index_exports, {
206
213
  date: () => date,
207
214
  dateConfigSchema: () => dateConfigSchema,
208
215
  enrichValuesWithSelectLabels: () => enrichValuesWithSelectLabels,
216
+ evaluateFormula: () => evaluateFormula,
217
+ evaluateFormulaAttribute: () => evaluateFormulaAttribute,
218
+ evaluateFormulaAttributeWithRelations: () => evaluateFormulaAttributeWithRelations,
219
+ evaluateFormulaWithRelations: () => evaluateFormulaWithRelations,
220
+ evaluateFormulaWithResult: () => evaluateFormulaWithResult,
209
221
  extractAttributeNames: () => extractAttributeNames,
222
+ extractFormulaVariables: () => extractFormulaVariables,
223
+ extractRelationNames: () => extractRelationNames,
224
+ extractRelationReferences: () => extractRelationReferences,
210
225
  file: () => file,
211
226
  fileConfigSchema: () => fileConfigSchema,
227
+ flattenRelationsForEval: () => flattenRelationsForEval,
212
228
  flow: () => flow,
213
229
  formatAttributeValue: () => formatAttributeValue,
230
+ formatFormulaResult: () => formatFormulaResult,
231
+ formula: () => formula,
232
+ formulaConfigSchema: () => formulaConfigSchema,
214
233
  generateId: () => generateId,
215
234
  generatePrefixedId: () => generatePrefixedId,
216
235
  getAttributeConfigSchema: () => getAttributeConfigSchema,
217
236
  getMissingRequiredAttributes: () => getMissingRequiredAttributes,
237
+ getPathDepth: () => getPathDepth,
238
+ getRelationPath: () => getRelationPath,
218
239
  getSyncPreview: () => getSyncPreview,
240
+ getTargetAttributeName: () => getTargetAttributeName,
219
241
  getViewSyncPreview: () => getViewSyncPreview,
220
242
  group: () => group,
243
+ hasRelationReferences: () => hasRelationReferences,
221
244
  isActivityTab: () => isActivityTab,
222
245
  isAdvancedFilterState: () => isAdvancedFilterState,
223
246
  isCustomTab: () => isCustomTab,
@@ -243,6 +266,8 @@ __export(index_exports, {
243
266
  numberConfigSchema: () => numberConfigSchema,
244
267
  object: () => object,
245
268
  parseAttributeConfig: () => parseAttributeConfig,
269
+ parsePath: () => parsePath,
270
+ pathHasManyCardinality: () => pathHasManyCardinality,
246
271
  phone: () => phone,
247
272
  phoneConfigSchema: () => phoneConfigSchema,
248
273
  rating: () => rating,
@@ -251,6 +276,10 @@ __export(index_exports, {
251
276
  relation: () => relation,
252
277
  relationConfigSchema: () => relationConfigSchema,
253
278
  renderLabelExpression: () => renderLabelExpression,
279
+ resolveMultiplePaths: () => resolveMultiplePaths,
280
+ resolveSingleValue: () => resolveSingleValue,
281
+ rollup: () => rollup,
282
+ rollupConfigSchema: () => rollupConfigSchema,
254
283
  safeParseAttributeConfig: () => safeParseAttributeConfig,
255
284
  select: () => select,
256
285
  selectConfigSchema: () => selectConfigSchema,
@@ -267,14 +296,17 @@ __export(index_exports, {
267
296
  timestampConfigSchema: () => timestampConfigSchema,
268
297
  toAdvancedFilterState: () => toAdvancedFilterState,
269
298
  toSimpleFilterState: () => toSimpleFilterState,
299
+ traversePath: () => traversePath,
270
300
  user: () => user,
271
301
  userConfigSchema: () => userConfigSchema,
272
302
  validateAttribute: () => validateAttribute,
273
303
  validateAttributeConfig: () => validateAttributeConfig,
274
304
  validateDraft: () => validateDraft,
275
305
  validateDraftOrThrow: () => validateDraftOrThrow,
306
+ validateFormulaExpression: () => validateFormulaExpression,
276
307
  validateObject: () => validateObject,
277
308
  validateObjectOrThrow: () => validateObjectOrThrow,
309
+ validatePath: () => validatePath,
278
310
  verifyNativeObjectsSync: () => verifyNativeObjectsSync,
279
311
  verifyNativeViewsSync: () => verifyNativeViewsSync,
280
312
  view: () => view,
@@ -397,7 +429,24 @@ var OPERATORS_BY_TYPE = {
397
429
  file: ["is_empty", "is_not_empty"],
398
430
  user: ["contains", "not_contains", "is_empty", "is_not_empty"],
399
431
  relation: ["any_of", "none_of", "contains", "not_contains", "is_empty", "is_not_empty"],
400
- rating: ["eq", "neq", "lt", "gt", "lte", "gte", "is_empty", "is_not_empty"]
432
+ rating: ["eq", "neq", "lt", "gt", "lte", "gte", "is_empty", "is_not_empty"],
433
+ // Formula: operators depend on returnType, but we provide a general set
434
+ formula: [
435
+ "is",
436
+ "is_not",
437
+ "eq",
438
+ "neq",
439
+ "lt",
440
+ "gt",
441
+ "lte",
442
+ "gte",
443
+ "contains",
444
+ "not_contains",
445
+ "is_empty",
446
+ "is_not_empty"
447
+ ],
448
+ // Rollup: typically numeric aggregations
449
+ rollup: ["eq", "neq", "lt", "gt", "lte", "gte", "is_empty", "is_not_empty"]
401
450
  };
402
451
  var NO_VALUE_OPERATORS = [
403
452
  "is_empty",
@@ -956,7 +1005,9 @@ var BaseAttributeBuilder = class {
956
1005
  return this;
957
1006
  }
958
1007
  defaultValue(value) {
959
- this.attr.defaultValue = value;
1008
+ if ("defaultValue" in this.attr || !["formula", "rollup"].includes(this.attr.type)) {
1009
+ this.attr.defaultValue = value;
1010
+ }
960
1011
  return this;
961
1012
  }
962
1013
  metadata(value) {
@@ -1498,6 +1549,113 @@ var RatingAttributeBuilder = class extends BaseAttributeBuilder {
1498
1549
  function rating(config) {
1499
1550
  return new RatingAttributeBuilder(config.name, config.label);
1500
1551
  }
1552
+ var FormulaAttributeBuilder = class extends BaseAttributeBuilder {
1553
+ constructor(name, label) {
1554
+ super("formula", name, label);
1555
+ this.attr.required = false;
1556
+ }
1557
+ /**
1558
+ * Set the formula expression
1559
+ * @param expr - Expression to evaluate (e.g., "price * quantity")
1560
+ */
1561
+ expression(expr) {
1562
+ this.attr.expression = expr;
1563
+ return this;
1564
+ }
1565
+ /**
1566
+ * Set the expected return type for formatting
1567
+ * @param type - Return type: "text", "number", "boolean", or "date"
1568
+ */
1569
+ returns(type) {
1570
+ this.attr.returnType = type;
1571
+ return this;
1572
+ }
1573
+ /**
1574
+ * Set decimal places for number results
1575
+ */
1576
+ decimals(value) {
1577
+ this.attr.decimals = value;
1578
+ return this;
1579
+ }
1580
+ // Override to prevent making formulas required
1581
+ // biome-ignore lint/suspicious/noExplicitAny: intentional override
1582
+ required() {
1583
+ console.warn("Formula attributes cannot be required (they are read-only)");
1584
+ return this;
1585
+ }
1586
+ // biome-ignore lint/suspicious/noExplicitAny: intentional override
1587
+ optional() {
1588
+ return this;
1589
+ }
1590
+ };
1591
+ function formula(config) {
1592
+ return new FormulaAttributeBuilder(config.name, config.label);
1593
+ }
1594
+ var RollupAttributeBuilder = class extends BaseAttributeBuilder {
1595
+ constructor(name, label) {
1596
+ super("rollup", name, label);
1597
+ this.attr.required = false;
1598
+ }
1599
+ /**
1600
+ * Set the relation attribute to aggregate from
1601
+ * @param relationName - Name of the relation attribute on this object
1602
+ */
1603
+ from(relationName) {
1604
+ this.attr.relationAttribute = relationName;
1605
+ return this;
1606
+ }
1607
+ /**
1608
+ * Set the target attribute to aggregate
1609
+ * @param attrName - Name of the attribute on the related object
1610
+ */
1611
+ aggregate(attrName) {
1612
+ this.attr.targetAttribute = attrName;
1613
+ return this;
1614
+ }
1615
+ /**
1616
+ * Set the aggregation function
1617
+ */
1618
+ using(fn) {
1619
+ this.attr.function = fn;
1620
+ return this;
1621
+ }
1622
+ /**
1623
+ * Set decimal places for numeric results
1624
+ */
1625
+ decimals(value) {
1626
+ this.attr.decimals = value;
1627
+ return this;
1628
+ }
1629
+ /**
1630
+ * Display the rollup value using the target attribute's type formatter
1631
+ * instead of the default rollup-specific formatting
1632
+ */
1633
+ showAsOriginal() {
1634
+ this.attr.showAsOriginal = true;
1635
+ return this;
1636
+ }
1637
+ /**
1638
+ * Set multi-level path for traversing nested relations (Phase 4+)
1639
+ * @param path - Dot notation path (e.g., "orders.items")
1640
+ */
1641
+ path(relationPath) {
1642
+ this.attr.relationPath = relationPath;
1643
+ return this;
1644
+ }
1645
+ // Override to prevent making rollups required
1646
+ // biome-ignore lint/suspicious/noExplicitAny: intentional override
1647
+ required() {
1648
+ console.warn("Rollup attributes cannot be required (they are read-only)");
1649
+ return this;
1650
+ }
1651
+ // biome-ignore lint/suspicious/noExplicitAny: intentional override
1652
+ optional() {
1653
+ return this;
1654
+ }
1655
+ };
1656
+ function rollup(config) {
1657
+ return new RollupAttributeBuilder(config.name, config.label);
1658
+ }
1501
1659
 
1502
1660
  // src/builders/flow-builder.ts
1503
1661
  var import_zod = require("zod");
@@ -2794,6 +2952,32 @@ var ratingConfigSchema = baseConfigSchema.extend({
2794
2952
  max: import_zod4.z.number().int().min(1).optional(),
2795
2953
  iconType: import_zod4.z.enum(["star", "heart", "thumbs", "number"]).optional()
2796
2954
  });
2955
+ var formulaConfigSchema = baseConfigSchema.extend({
2956
+ expression: import_zod4.z.string().min(1),
2957
+ returnType: import_zod4.z.enum(["text", "number", "boolean", "date"]),
2958
+ decimals: import_zod4.z.number().int().min(0).max(10).optional(),
2959
+ allowRelations: import_zod4.z.boolean().optional()
2960
+ });
2961
+ var rollupConfigSchema = baseConfigSchema.extend({
2962
+ relationAttribute: import_zod4.z.string().min(1).optional(),
2963
+ relationPath: import_zod4.z.string().optional(),
2964
+ targetAttribute: import_zod4.z.string().min(1),
2965
+ function: import_zod4.z.enum([
2966
+ "sum",
2967
+ "count",
2968
+ "avg",
2969
+ "min",
2970
+ "max",
2971
+ "countValues",
2972
+ "countEmpty",
2973
+ "percentEmpty",
2974
+ "percentFilled",
2975
+ "collect"
2976
+ ]),
2977
+ decimals: import_zod4.z.number().int().min(0).max(10).optional(),
2978
+ showAsOriginal: import_zod4.z.boolean().optional(),
2979
+ targetAttributeType: import_zod4.z.string().optional()
2980
+ });
2797
2981
  var attributeConfigSchemas = {
2798
2982
  text: textConfigSchema,
2799
2983
  textarea: textareaConfigSchema,
@@ -2810,7 +2994,9 @@ var attributeConfigSchemas = {
2810
2994
  file: fileConfigSchema,
2811
2995
  user: userConfigSchema,
2812
2996
  relation: relationConfigSchema,
2813
- rating: ratingConfigSchema
2997
+ rating: ratingConfigSchema,
2998
+ formula: formulaConfigSchema,
2999
+ rollup: rollupConfigSchema
2814
3000
  };
2815
3001
  function getAttributeConfigSchema(type) {
2816
3002
  return attributeConfigSchemas[type];
@@ -2983,6 +3169,12 @@ function createRatingValidator(attr) {
2983
3169
  }
2984
3170
  return schema;
2985
3171
  }
3172
+ function createFormulaValidator(_attr) {
3173
+ return import_zod4.z.unknown();
3174
+ }
3175
+ function createRollupValidator(_attr) {
3176
+ return import_zod4.z.unknown();
3177
+ }
2986
3178
  function createAttributeValidator(attr) {
2987
3179
  switch (attr.type) {
2988
3180
  case "text":
@@ -3015,6 +3207,10 @@ function createAttributeValidator(attr) {
3015
3207
  return createRelationValidator(attr);
3016
3208
  case "rating":
3017
3209
  return createRatingValidator(attr);
3210
+ case "formula":
3211
+ return createFormulaValidator(attr);
3212
+ case "rollup":
3213
+ return createRollupValidator(attr);
3018
3214
  default:
3019
3215
  return import_zod4.z.unknown();
3020
3216
  }
@@ -3140,6 +3336,384 @@ function computeRecordStatus(objectDef, data) {
3140
3336
  return isRecordComplete(objectDef, data) ? "complete" : "draft";
3141
3337
  }
3142
3338
 
3339
+ // src/runtime/formula/evaluator.ts
3340
+ var import_expr_eval = require("expr-eval");
3341
+ function createFormulaParser() {
3342
+ const parser = new import_expr_eval.Parser();
3343
+ parser.functions.IF = (condition, thenValue, elseValue) => condition ? thenValue : elseValue;
3344
+ parser.functions.AND = (...args) => args.every(Boolean);
3345
+ parser.functions.OR = (...args) => args.some(Boolean);
3346
+ parser.functions.NOT = (value) => !value;
3347
+ parser.functions.EMPTY = (value) => value === null || value === void 0 || value === "";
3348
+ parser.functions.COALESCE = (...args) => args.find((a) => a != null) ?? null;
3349
+ parser.functions.DEFAULT = (value, defaultValue) => value == null || value === "" ? defaultValue : value;
3350
+ parser.functions.CONCAT = (...args) => args.filter((a) => a != null).map(String).join("");
3351
+ parser.functions.UPPER = (value) => String(value ?? "").toUpperCase();
3352
+ parser.functions.LOWER = (value) => String(value ?? "").toLowerCase();
3353
+ parser.functions.TRIM = (value) => String(value ?? "").trim();
3354
+ parser.functions.LENGTH = (value) => String(value ?? "").length;
3355
+ parser.functions.LEFT = (value, count) => String(value ?? "").slice(0, count);
3356
+ parser.functions.RIGHT = (value, count) => String(value ?? "").slice(-count);
3357
+ parser.functions.REPLACE = (value, search, replacement) => String(value ?? "").replace(new RegExp(search, "g"), replacement);
3358
+ parser.functions.CONTAINS = (value, search) => String(value ?? "").toLowerCase().includes(String(search).toLowerCase());
3359
+ parser.functions.ROUND = (value, decimals = 0) => {
3360
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
3361
+ const factor = 10 ** decimals;
3362
+ return Math.round(value * factor) / factor;
3363
+ };
3364
+ parser.functions.FLOOR = (value) => {
3365
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
3366
+ return Math.floor(value);
3367
+ };
3368
+ parser.functions.CEIL = (value) => {
3369
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
3370
+ return Math.ceil(value);
3371
+ };
3372
+ parser.functions.ABS = (value) => {
3373
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
3374
+ return Math.abs(value);
3375
+ };
3376
+ parser.functions.MIN = (...args) => {
3377
+ const nums = args.filter((n) => typeof n === "number" && !Number.isNaN(n));
3378
+ return nums.length > 0 ? Math.min(...nums) : null;
3379
+ };
3380
+ parser.functions.MAX = (...args) => {
3381
+ const nums = args.filter((n) => typeof n === "number" && !Number.isNaN(n));
3382
+ return nums.length > 0 ? Math.max(...nums) : null;
3383
+ };
3384
+ parser.functions.POW = (base, exponent) => {
3385
+ if (typeof base !== "number" || typeof exponent !== "number") return null;
3386
+ return base ** exponent;
3387
+ };
3388
+ parser.functions.MOD = (a, b) => {
3389
+ if (typeof a !== "number" || typeof b !== "number" || b === 0) return null;
3390
+ return a % b;
3391
+ };
3392
+ parser.functions.NOW = () => (/* @__PURE__ */ new Date()).toISOString();
3393
+ parser.functions.TODAY = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
3394
+ parser.functions.YEAR = (value) => {
3395
+ const date2 = parseDate(value);
3396
+ return date2 ? date2.getFullYear() : null;
3397
+ };
3398
+ parser.functions.MONTH = (value) => {
3399
+ const date2 = parseDate(value);
3400
+ return date2 ? date2.getMonth() + 1 : null;
3401
+ };
3402
+ parser.functions.DAY = (value) => {
3403
+ const date2 = parseDate(value);
3404
+ return date2 ? date2.getDate() : null;
3405
+ };
3406
+ parser.functions.DATE_DIFF = (date1, date2, unit = "days") => {
3407
+ const d1 = parseDate(date1);
3408
+ const d2 = parseDate(date2);
3409
+ if (d1 === null || d2 === null) return null;
3410
+ const diffMs = d1.getTime() - d2.getTime();
3411
+ const MS_PER_DAY = 1e3 * 60 * 60 * 24;
3412
+ const conversions = {
3413
+ years: MS_PER_DAY * 365,
3414
+ months: MS_PER_DAY * 30,
3415
+ weeks: MS_PER_DAY * 7,
3416
+ days: MS_PER_DAY,
3417
+ hours: 1e3 * 60 * 60,
3418
+ minutes: 1e3 * 60
3419
+ };
3420
+ const divisor = conversions[unit] ?? MS_PER_DAY;
3421
+ return diffMs / divisor;
3422
+ };
3423
+ return parser;
3424
+ }
3425
+ function parseDate(value) {
3426
+ if (!value) return null;
3427
+ if (value instanceof Date) return value;
3428
+ if (typeof value === "string" || typeof value === "number") {
3429
+ const date2 = new Date(value);
3430
+ return Number.isNaN(date2.getTime()) ? null : date2;
3431
+ }
3432
+ return null;
3433
+ }
3434
+ var formulaParser = createFormulaParser();
3435
+ function evaluateFormula(expression, values) {
3436
+ try {
3437
+ const parsed = formulaParser.parse(expression);
3438
+ return parsed.evaluate(values);
3439
+ } catch {
3440
+ return null;
3441
+ }
3442
+ }
3443
+ function evaluateFormulaWithResult(expression, values) {
3444
+ try {
3445
+ const parsed = formulaParser.parse(expression);
3446
+ const value = parsed.evaluate(values);
3447
+ return { value };
3448
+ } catch (error) {
3449
+ return {
3450
+ value: null,
3451
+ error: error instanceof Error ? error.message : "Unknown error"
3452
+ };
3453
+ }
3454
+ }
3455
+ function formatFormulaResult(value, returnType, decimals) {
3456
+ if (value === null || value === void 0) {
3457
+ return null;
3458
+ }
3459
+ switch (returnType) {
3460
+ case "number":
3461
+ return formatNumberResult(value, decimals);
3462
+ case "boolean":
3463
+ return Boolean(value);
3464
+ case "date":
3465
+ return formatDateResult(value);
3466
+ case "text":
3467
+ return String(value);
3468
+ }
3469
+ }
3470
+ function formatNumberResult(value, decimals) {
3471
+ const num = typeof value === "number" ? value : Number(value);
3472
+ if (Number.isNaN(num)) return null;
3473
+ return decimals !== void 0 ? Number(num.toFixed(decimals)) : num;
3474
+ }
3475
+ function formatDateResult(value) {
3476
+ if (value instanceof Date) return value.toISOString();
3477
+ if (typeof value === "string") return value;
3478
+ return null;
3479
+ }
3480
+ function evaluateFormulaAttribute(attr, values) {
3481
+ const raw = evaluateFormula(attr.expression, values);
3482
+ return formatFormulaResult(raw, attr.returnType, attr.decimals);
3483
+ }
3484
+ function validateFormulaExpression(expression) {
3485
+ try {
3486
+ formulaParser.parse(expression);
3487
+ return { valid: true };
3488
+ } catch (error) {
3489
+ return {
3490
+ valid: false,
3491
+ error: error instanceof Error ? error.message : "Invalid expression"
3492
+ };
3493
+ }
3494
+ }
3495
+ function extractFormulaVariables(expression) {
3496
+ try {
3497
+ const parsed = formulaParser.parse(expression);
3498
+ return parsed.variables();
3499
+ } catch {
3500
+ return [];
3501
+ }
3502
+ }
3503
+ var RELATION_REF_PATTERN = /\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b/g;
3504
+ function extractRelationReferences(expression) {
3505
+ const regex = new RegExp(RELATION_REF_PATTERN.source, "g");
3506
+ const matches = expression.matchAll(regex);
3507
+ return [...matches].map((m) => m[0]);
3508
+ }
3509
+ function extractRelationNames(expression) {
3510
+ const refs = extractRelationReferences(expression);
3511
+ const names = refs.map((ref) => ref.split(".")[0]);
3512
+ return [...new Set(names)];
3513
+ }
3514
+ function hasRelationReferences(expression) {
3515
+ const regex = new RegExp(RELATION_REF_PATTERN.source);
3516
+ return regex.test(expression);
3517
+ }
3518
+ function flattenRelationsForEval(resolvedRelations) {
3519
+ const flat = {};
3520
+ for (const [relationName, values] of Object.entries(resolvedRelations)) {
3521
+ for (const [key, value] of Object.entries(values)) {
3522
+ flat[`${relationName}.${key}`] = value;
3523
+ }
3524
+ }
3525
+ return flat;
3526
+ }
3527
+ async function evaluateFormulaWithRelations(expression, record, schema, resolver) {
3528
+ const relationNames = extractRelationNames(expression);
3529
+ if (relationNames.length === 0) {
3530
+ return evaluateFormula(expression, record.values);
3531
+ }
3532
+ const resolvedRelations = await resolver.resolveRelationValues(record, schema, relationNames);
3533
+ const flattenedRelations = flattenRelationsForEval(resolvedRelations);
3534
+ const allValues = {
3535
+ ...record.values,
3536
+ ...flattenedRelations
3537
+ };
3538
+ return evaluateFormula(expression, allValues);
3539
+ }
3540
+ async function evaluateFormulaAttributeWithRelations(attr, record, schema, resolver) {
3541
+ const raw = await evaluateFormulaWithRelations(attr.expression, record, schema, resolver);
3542
+ return formatFormulaResult(raw, attr.returnType, attr.decimals);
3543
+ }
3544
+
3545
+ // src/runtime/formula/path-parser.ts
3546
+ var InvalidPathError = class extends Error {
3547
+ constructor(path, segment, reason) {
3548
+ super(`Invalid path "${path}" at "${segment}": ${reason}`);
3549
+ this.path = path;
3550
+ this.segment = segment;
3551
+ this.reason = reason;
3552
+ this.name = "InvalidPathError";
3553
+ }
3554
+ };
3555
+ var MaxDepthExceededError = class extends Error {
3556
+ constructor(path, maxDepth) {
3557
+ super(`Path "${path}" exceeds maximum depth of ${maxDepth}`);
3558
+ this.path = path;
3559
+ this.maxDepth = maxDepth;
3560
+ this.name = "MaxDepthExceededError";
3561
+ }
3562
+ };
3563
+ async function parsePath(path, startSchema, getSchema, maxDepth = 5) {
3564
+ const segments = path.split(".");
3565
+ if (segments.length === 0 || segments.length === 1 && segments[0] === "") {
3566
+ throw new InvalidPathError(path, path, "Path cannot be empty");
3567
+ }
3568
+ if (segments.length > maxDepth) {
3569
+ throw new MaxDepthExceededError(path, maxDepth);
3570
+ }
3571
+ const result = [];
3572
+ let currentSchema = startSchema;
3573
+ for (let i = 0; i < segments.length; i++) {
3574
+ const segmentName = segments[i];
3575
+ const isLastSegment = i === segments.length - 1;
3576
+ const attr = currentSchema.attributes.find((a) => a.name === segmentName);
3577
+ if (!attr) {
3578
+ throw new InvalidPathError(
3579
+ path,
3580
+ segmentName,
3581
+ `Attribute "${segmentName}" not found in object "${currentSchema.name}"`
3582
+ );
3583
+ }
3584
+ if (attr.type === "relation") {
3585
+ const relationAttr = attr;
3586
+ const targetObject = relationAttr.targets[0]?.object;
3587
+ if (!targetObject) {
3588
+ throw new InvalidPathError(path, segmentName, "Relation has no target object");
3589
+ }
3590
+ result.push({
3591
+ name: segmentName,
3592
+ type: "relation",
3593
+ cardinality: relationAttr.cardinality,
3594
+ targetObject
3595
+ });
3596
+ if (!isLastSegment) {
3597
+ const nextSchema = await getSchema(targetObject);
3598
+ if (!nextSchema) {
3599
+ throw new InvalidPathError(
3600
+ path,
3601
+ segmentName,
3602
+ `Target object "${targetObject}" schema not found`
3603
+ );
3604
+ }
3605
+ currentSchema = nextSchema;
3606
+ }
3607
+ } else {
3608
+ if (!isLastSegment) {
3609
+ throw new InvalidPathError(
3610
+ path,
3611
+ segmentName,
3612
+ `"${segmentName}" is not a relation but has segments after it`
3613
+ );
3614
+ }
3615
+ result.push({
3616
+ name: segmentName,
3617
+ type: "attribute"
3618
+ });
3619
+ }
3620
+ }
3621
+ return result;
3622
+ }
3623
+ async function validatePath(path, startSchema, getSchema, maxDepth = 5) {
3624
+ try {
3625
+ await parsePath(path, startSchema, getSchema, maxDepth);
3626
+ return true;
3627
+ } catch {
3628
+ return false;
3629
+ }
3630
+ }
3631
+ function pathHasManyCardinality(segments) {
3632
+ return segments.some((s) => s.type === "relation" && s.cardinality === "many");
3633
+ }
3634
+ function getPathDepth(segments) {
3635
+ return segments.filter((s) => s.type === "relation").length;
3636
+ }
3637
+ function getTargetAttributeName(path) {
3638
+ const parts = path.split(".");
3639
+ return parts[parts.length - 1];
3640
+ }
3641
+ function getRelationPath(path) {
3642
+ const parts = path.split(".");
3643
+ if (parts.length <= 1) return null;
3644
+ return parts.slice(0, -1).join(".");
3645
+ }
3646
+
3647
+ // src/runtime/formula/path-traversal.ts
3648
+ async function traversePath(record, path, startSchemaName, adapter, getSchema, options) {
3649
+ const maxDepth = options?.maxDepth ?? 5;
3650
+ const startSchema = await getSchema(startSchemaName);
3651
+ if (!startSchema) {
3652
+ return { values: [], recordCounts: [0] };
3653
+ }
3654
+ const segments = await parsePath(path, startSchema, getSchema, maxDepth);
3655
+ if (segments.length > maxDepth) {
3656
+ throw new MaxDepthExceededError(path, maxDepth);
3657
+ }
3658
+ return await executeTraversal([record], segments, adapter, 0);
3659
+ }
3660
+ async function executeTraversal(records, segments, adapter, depth) {
3661
+ const recordCounts = [records.length];
3662
+ if (depth >= segments.length || records.length === 0) {
3663
+ return { values: [], recordCounts };
3664
+ }
3665
+ const segment = segments[depth];
3666
+ const isLastSegment = depth === segments.length - 1;
3667
+ if (segment.type === "attribute") {
3668
+ const values = records.map((r) => r.values[segment.name]).filter((v) => v !== void 0);
3669
+ return { values, recordCounts };
3670
+ }
3671
+ const relatedIds = collectRelatedIds(records, segment.name);
3672
+ if (relatedIds.length === 0) {
3673
+ return { values: [], recordCounts };
3674
+ }
3675
+ const relatedRecords = await adapter.objectRecords.findByIds(relatedIds);
3676
+ recordCounts.push(relatedRecords.length);
3677
+ if (isLastSegment) {
3678
+ return { values: relatedIds, recordCounts };
3679
+ }
3680
+ const nestedResult = await executeTraversal(relatedRecords, segments, adapter, depth + 1);
3681
+ return {
3682
+ values: nestedResult.values,
3683
+ recordCounts: [...recordCounts, ...nestedResult.recordCounts.slice(1)]
3684
+ };
3685
+ }
3686
+ function collectRelatedIds(records, relationName) {
3687
+ const ids = [];
3688
+ for (const record of records) {
3689
+ const value = record.values[relationName];
3690
+ if (typeof value === "string" && value.length > 0) {
3691
+ ids.push(value);
3692
+ } else if (Array.isArray(value)) {
3693
+ for (const id of value) {
3694
+ if (typeof id === "string" && id.length > 0) {
3695
+ ids.push(id);
3696
+ }
3697
+ }
3698
+ }
3699
+ }
3700
+ return [...new Set(ids)];
3701
+ }
3702
+ async function resolveSingleValue(record, path, startSchemaName, adapter, getSchema) {
3703
+ const result = await traversePath(record, path, startSchemaName, adapter, getSchema);
3704
+ return result.values[0] ?? null;
3705
+ }
3706
+ async function resolveMultiplePaths(record, paths, startSchemaName, adapter, getSchema) {
3707
+ const results = /* @__PURE__ */ new Map();
3708
+ await Promise.all(
3709
+ paths.map(async (path) => {
3710
+ const result = await traversePath(record, path, startSchemaName, adapter, getSchema);
3711
+ results.set(path, result.values);
3712
+ })
3713
+ );
3714
+ return results;
3715
+ }
3716
+
3143
3717
  // src/runtime/hooks/hook.registry.ts
3144
3718
  var NoopHookRegistry = class {
3145
3719
  async execute() {
@@ -3803,6 +4377,26 @@ function createMockObjectRecordsRepository(stores) {
3803
4377
  }
3804
4378
  }
3805
4379
  return Promise.resolve(updated);
4380
+ },
4381
+ findByRelation(objectName, relationAttributeName, targetRecordId) {
4382
+ const obj = Array.from(stores.objects.values()).find((o) => o.name === objectName);
4383
+ if (!obj) return Promise.resolve([]);
4384
+ const results = [];
4385
+ for (const record of stores.objectRecords.values()) {
4386
+ if (record.objectId !== obj.id) continue;
4387
+ const value = record.values[relationAttributeName];
4388
+ let matches = false;
4389
+ if (typeof value === "string" && value === targetRecordId) {
4390
+ matches = true;
4391
+ } else if (Array.isArray(value) && value.includes(targetRecordId)) {
4392
+ matches = true;
4393
+ }
4394
+ if (matches) {
4395
+ const { tenantId: _, ...publicRecord } = record;
4396
+ results.push(publicRecord);
4397
+ }
4398
+ }
4399
+ return Promise.resolve(results);
3806
4400
  }
3807
4401
  };
3808
4402
  }
@@ -6652,6 +7246,263 @@ var RelationService = class {
6652
7246
  }
6653
7247
  };
6654
7248
 
7249
+ // src/runtime/services/rollup.service.ts
7250
+ var RollupService = class {
7251
+ constructor(adapter) {
7252
+ this.adapter = adapter;
7253
+ }
7254
+ /**
7255
+ * Calculate a rollup value for a record
7256
+ *
7257
+ * @param recordId - ID of the parent record
7258
+ * @param rollupAttr - Rollup attribute definition
7259
+ * @param schema - Schema of the parent object
7260
+ * @returns Computed rollup value
7261
+ *
7262
+ * @example
7263
+ * ```typescript
7264
+ * // Sum all order amounts for a company
7265
+ * const totalOrders = await rollupService.calculate(
7266
+ * "company-123",
7267
+ * {
7268
+ * type: "rollup",
7269
+ * name: "totalOrders",
7270
+ * relationAttribute: "orders",
7271
+ * targetAttribute: "amount",
7272
+ * function: "sum",
7273
+ * ...
7274
+ * },
7275
+ * companySchema
7276
+ * );
7277
+ * ```
7278
+ */
7279
+ async calculate(recordId, rollupAttr, schema) {
7280
+ const relationAttr = schema.attributes.find(
7281
+ (a) => a.type === "relation" && a.name === rollupAttr.relationAttribute
7282
+ );
7283
+ if (relationAttr) {
7284
+ return this.calculateForward(recordId, rollupAttr);
7285
+ }
7286
+ return this.calculateReverse(recordId, rollupAttr, schema);
7287
+ }
7288
+ /**
7289
+ * Forward pattern: this record has a relation attribute pointing to other records
7290
+ * Example: entreprise222 has relation "entreprises" → companies, rollup collects from companies
7291
+ */
7292
+ async calculateForward(recordId, rollupAttr) {
7293
+ const record = await this.adapter.objectRecords.findById(recordId);
7294
+ if (!record) {
7295
+ return { value: null, recordCount: 0 };
7296
+ }
7297
+ const relationValue = record.values[rollupAttr.relationAttribute];
7298
+ let relatedIds = [];
7299
+ if (typeof relationValue === "string" && relationValue.length > 0) {
7300
+ relatedIds = [relationValue];
7301
+ } else if (Array.isArray(relationValue)) {
7302
+ relatedIds = relationValue.filter((id) => typeof id === "string" && id.length > 0);
7303
+ }
7304
+ if (relatedIds.length === 0) {
7305
+ return { value: this.getEmptyValue(rollupAttr.function), recordCount: 0 };
7306
+ }
7307
+ const relatedRecords = await this.adapter.objectRecords.findByIds(relatedIds);
7308
+ if (relatedRecords.length === 0) {
7309
+ return { value: this.getEmptyValue(rollupAttr.function), recordCount: 0 };
7310
+ }
7311
+ return this.aggregateValues(relatedRecords, rollupAttr);
7312
+ }
7313
+ /**
7314
+ * Reverse pattern: other records have a relation pointing to this record
7315
+ * Example: Company has rollup on "orders", Order has relation "company" → companies
7316
+ */
7317
+ async calculateReverse(recordId, rollupAttr, schema) {
7318
+ const record = await this.adapter.objectRecords.findById(recordId);
7319
+ if (!record) {
7320
+ return { value: null, recordCount: 0 };
7321
+ }
7322
+ const parentObject = await this.adapter.objects.findById(record.objectId);
7323
+ if (!parentObject) {
7324
+ return { value: null, recordCount: 0 };
7325
+ }
7326
+ const tenantId = parentObject.tenantId;
7327
+ const sourceObjectName = rollupAttr.relationAttribute;
7328
+ const sourceObject = await this.adapter.objects.findByName(tenantId, sourceObjectName);
7329
+ if (!sourceObject) {
7330
+ return { value: null, recordCount: 0 };
7331
+ }
7332
+ const sourceAttributes = await this.adapter.attributes.findByObjectId(sourceObject.id);
7333
+ const reverseRelationAttr = sourceAttributes.find((attr) => {
7334
+ if (attr.type !== "relation") return false;
7335
+ const relationConfig = attr.config;
7336
+ return relationConfig?.targets?.some((t) => t.object === schema.name);
7337
+ });
7338
+ if (!reverseRelationAttr) {
7339
+ return { value: null, recordCount: 0 };
7340
+ }
7341
+ const relatedRecords = await this.adapter.objectRecords.findByRelation(
7342
+ tenantId,
7343
+ sourceObject.id,
7344
+ reverseRelationAttr.name,
7345
+ recordId
7346
+ );
7347
+ if (relatedRecords.length === 0) {
7348
+ return { value: this.getEmptyValue(rollupAttr.function), recordCount: 0 };
7349
+ }
7350
+ return this.aggregateValues(relatedRecords, rollupAttr);
7351
+ }
7352
+ /**
7353
+ * Extract and aggregate values from related records
7354
+ */
7355
+ aggregateValues(relatedRecords, rollupAttr) {
7356
+ const values = relatedRecords.map((r) => r.values[rollupAttr.targetAttribute]).filter((v) => v !== void 0 && v !== null);
7357
+ const aggregated = this.aggregate(values, rollupAttr.function);
7358
+ const result = typeof aggregated === "number" && rollupAttr.decimals !== void 0 ? Number(aggregated.toFixed(rollupAttr.decimals)) : aggregated;
7359
+ return { value: result, recordCount: relatedRecords.length };
7360
+ }
7361
+ /**
7362
+ * Calculate rollup values for multiple records (batched)
7363
+ *
7364
+ * More efficient than calling calculate() for each record individually.
7365
+ */
7366
+ async calculateForMany(recordIds, rollupAttr, schema) {
7367
+ const results = /* @__PURE__ */ new Map();
7368
+ for (const id of recordIds) {
7369
+ results.set(id, { value: this.getEmptyValue(rollupAttr.function), recordCount: 0 });
7370
+ }
7371
+ await Promise.all(
7372
+ recordIds.map(async (id) => {
7373
+ const result = await this.calculate(id, rollupAttr, schema);
7374
+ results.set(id, result);
7375
+ })
7376
+ );
7377
+ return results;
7378
+ }
7379
+ /**
7380
+ * Apply aggregation function to a set of values
7381
+ */
7382
+ aggregate(values, fn) {
7383
+ switch (fn) {
7384
+ case "sum":
7385
+ return this.sumNumbers(values);
7386
+ case "avg":
7387
+ return this.averageNumbers(values);
7388
+ case "min":
7389
+ return this.minNumbers(values);
7390
+ case "max":
7391
+ return this.maxNumbers(values);
7392
+ case "count":
7393
+ return values.length;
7394
+ case "countValues":
7395
+ return values.filter((v) => v != null && v !== "").length;
7396
+ case "countEmpty":
7397
+ return values.filter((v) => v == null || v === "").length;
7398
+ case "percentFilled": {
7399
+ if (values.length === 0) return 0;
7400
+ const filled = values.filter((v) => v != null && v !== "").length;
7401
+ return filled / values.length * 100;
7402
+ }
7403
+ case "percentEmpty": {
7404
+ if (values.length === 0) return 0;
7405
+ const empty = values.filter((v) => v == null || v === "").length;
7406
+ return empty / values.length * 100;
7407
+ }
7408
+ case "collect":
7409
+ return values;
7410
+ }
7411
+ }
7412
+ /**
7413
+ * Get the default empty value for a rollup function
7414
+ */
7415
+ getEmptyValue(fn) {
7416
+ switch (fn) {
7417
+ case "sum":
7418
+ case "count":
7419
+ case "countValues":
7420
+ case "countEmpty":
7421
+ case "percentEmpty":
7422
+ case "percentFilled":
7423
+ return 0;
7424
+ case "avg":
7425
+ case "min":
7426
+ case "max":
7427
+ return null;
7428
+ case "collect":
7429
+ return [];
7430
+ }
7431
+ }
7432
+ /**
7433
+ * Sum numeric values
7434
+ */
7435
+ sumNumbers(values) {
7436
+ return values.filter((v) => typeof v === "number" && !Number.isNaN(v)).reduce((sum, n) => sum + n, 0);
7437
+ }
7438
+ /**
7439
+ * Average numeric values
7440
+ */
7441
+ averageNumbers(values) {
7442
+ const nums = values.filter((v) => typeof v === "number" && !Number.isNaN(v));
7443
+ if (nums.length === 0) return null;
7444
+ return nums.reduce((sum, n) => sum + n, 0) / nums.length;
7445
+ }
7446
+ /**
7447
+ * Minimum numeric value
7448
+ */
7449
+ minNumbers(values) {
7450
+ const nums = values.filter((v) => typeof v === "number" && !Number.isNaN(v));
7451
+ if (nums.length === 0) return null;
7452
+ return Math.min(...nums);
7453
+ }
7454
+ /**
7455
+ * Maximum numeric value
7456
+ */
7457
+ maxNumbers(values) {
7458
+ const nums = values.filter((v) => typeof v === "number" && !Number.isNaN(v));
7459
+ if (nums.length === 0) return null;
7460
+ return Math.max(...nums);
7461
+ }
7462
+ /**
7463
+ * Recalculate all rollup attributes for a record and update it
7464
+ *
7465
+ * Called after related records change to keep rollups up-to-date.
7466
+ */
7467
+ async recalculateAndUpdate(record, schema) {
7468
+ const rollupAttrs = schema.attributes.filter((a) => a.type === "rollup");
7469
+ if (rollupAttrs.length === 0) {
7470
+ return record;
7471
+ }
7472
+ const updates = {};
7473
+ for (const attr of rollupAttrs) {
7474
+ const result = await this.calculate(record.id, attr, schema);
7475
+ updates[attr.name] = result.value;
7476
+ }
7477
+ return await this.adapter.objectRecords.update(record.id, updates);
7478
+ }
7479
+ /**
7480
+ * Find parent records that need rollup recalculation when a child record changes
7481
+ *
7482
+ * Used by hooks to determine which parent records to recalculate after
7483
+ * a child record is created, updated, or deleted.
7484
+ *
7485
+ * @param changedRecord - The record that was modified
7486
+ * @param changedSchema - Schema of the changed record's object
7487
+ * @returns Array of parent record IDs that need recalculation
7488
+ */
7489
+ async findAffectedParentRecords(changedRecord, changedSchema) {
7490
+ const affectedIds = [];
7491
+ const relationAttrs = changedSchema.attributes.filter(
7492
+ (a) => a.type === "relation"
7493
+ );
7494
+ for (const attr of relationAttrs) {
7495
+ const relatedId = changedRecord.values[attr.name];
7496
+ if (typeof relatedId === "string" && relatedId.length > 0) {
7497
+ affectedIds.push(relatedId);
7498
+ } else if (Array.isArray(relatedId)) {
7499
+ affectedIds.push(...relatedId.filter((id) => typeof id === "string"));
7500
+ }
7501
+ }
7502
+ return [...new Set(affectedIds)];
7503
+ }
7504
+ };
7505
+
6655
7506
  // src/runtime/services/user.service.ts
6656
7507
  var UserService = class {
6657
7508
  constructor(adapter, tenantId) {
@@ -6777,6 +7628,7 @@ var RecordService = class {
6777
7628
  this.schemaService = new ObjectSchemaService(adapter, registry);
6778
7629
  this.relationService = new RelationService(adapter, registry);
6779
7630
  this.userService = new UserService(adapter, tenantId);
7631
+ this.rollupService = new RollupService(adapter);
6780
7632
  this.hookRegistry = options?.hookRegistry ?? new NoopHookRegistry();
6781
7633
  this.permissionService = options?.permissionService;
6782
7634
  this.auditService = options?.auditService ?? (adapter.audit ? new AuditService(adapter, tenantId) : void 0);
@@ -6938,6 +7790,7 @@ var RecordService = class {
6938
7790
  };
6939
7791
  await this.hookRegistry.execute("afterCreate", schema.name, afterCtx);
6940
7792
  }
7793
+ await this.recalculateParentRollups(record, schema);
6941
7794
  if (this.auditService && this.userId) {
6942
7795
  await this.auditService.logRecordAction({
6943
7796
  action: "record.created",
@@ -6964,13 +7817,17 @@ var RecordService = class {
6964
7817
  if (!record) {
6965
7818
  return null;
6966
7819
  }
7820
+ const schema = await this.schemaService.getObjectSchema(record.objectId);
7821
+ let enrichedRecord = record;
7822
+ if (!options?.skipFormulas) {
7823
+ enrichedRecord = this.enrichWithFormulas(record, schema);
7824
+ }
6967
7825
  if (options?.includeSchema) {
6968
- const schema = await this.schemaService.getObjectSchema(record.objectId);
6969
- const recordWithSchema = record;
7826
+ const recordWithSchema = enrichedRecord;
6970
7827
  recordWithSchema.schema = schema;
6971
7828
  return recordWithSchema;
6972
7829
  }
6973
- return record;
7830
+ return enrichedRecord;
6974
7831
  }
6975
7832
  /**
6976
7833
  * Get a record by ID or throw if not found
@@ -7049,6 +7906,7 @@ var RecordService = class {
7049
7906
  };
7050
7907
  await this.hookRegistry.execute("afterUpdate", schema.name, afterCtx);
7051
7908
  }
7909
+ await this.recalculateParentRollups(updated, schema);
7052
7910
  if (this.auditService && this.userId && changedAttributes.length > 0) {
7053
7911
  const changes = changedAttributes.map((attr) => ({
7054
7912
  field: attr,
@@ -7176,6 +8034,7 @@ var RecordService = class {
7176
8034
  if (!options?.skipHooks) {
7177
8035
  await this.hookRegistry.execute("afterDelete", schema.name, hookCtx);
7178
8036
  }
8037
+ await this.recalculateParentRollups(record, schema);
7179
8038
  if (this.auditService && this.userId) {
7180
8039
  await this.auditService.logRecordAction({
7181
8040
  action: "record.deleted",
@@ -7265,6 +8124,79 @@ var RecordService = class {
7265
8124
  timestamp: /* @__PURE__ */ new Date()
7266
8125
  };
7267
8126
  }
8127
+ /**
8128
+ * Enrich a record with computed formula values
8129
+ *
8130
+ * Formula attributes are calculated at read-time from the record's values.
8131
+ * This method adds the computed values to the record's values object.
8132
+ *
8133
+ * @param record - The record to enrich
8134
+ * @param schema - The object schema containing attribute definitions
8135
+ * @returns Record with formula values computed
8136
+ * @internal
8137
+ */
8138
+ enrichWithFormulas(record, schema) {
8139
+ const formulaAttrs = schema.attributes.filter(
8140
+ (a) => a.type === "formula"
8141
+ );
8142
+ if (formulaAttrs.length === 0) {
8143
+ return record;
8144
+ }
8145
+ const enrichedValues = { ...record.values };
8146
+ for (const attr of formulaAttrs) {
8147
+ enrichedValues[attr.name] = evaluateFormulaAttribute(attr, record.values);
8148
+ }
8149
+ return {
8150
+ ...record,
8151
+ values: enrichedValues
8152
+ };
8153
+ }
8154
+ /**
8155
+ * Enrich multiple records with computed formula values
8156
+ * @internal
8157
+ */
8158
+ enrichRecordsWithFormulas(records, schema) {
8159
+ const formulaAttrs = schema.attributes.filter(
8160
+ (a) => a.type === "formula"
8161
+ );
8162
+ if (formulaAttrs.length === 0) {
8163
+ return records;
8164
+ }
8165
+ return records.map((record) => this.enrichWithFormulas(record, schema));
8166
+ }
8167
+ /**
8168
+ * Recalculate rollups after a record changes
8169
+ *
8170
+ * This handles two cases:
8171
+ * 1. The record itself has rollups (e.g., aggregating from related records it points to)
8172
+ * 2. Parent records have rollups that aggregate from this record
8173
+ *
8174
+ * @param record - The record that was modified
8175
+ * @param schema - Schema of the record's object
8176
+ * @internal
8177
+ */
8178
+ async recalculateParentRollups(record, schema) {
8179
+ const ownRollupAttrs = schema.attributes.filter(
8180
+ (a) => a.type === "rollup"
8181
+ );
8182
+ if (ownRollupAttrs.length > 0) {
8183
+ await this.rollupService.recalculateAndUpdate(record, schema);
8184
+ }
8185
+ const affectedParentIds = await this.rollupService.findAffectedParentRecords(record, schema);
8186
+ if (affectedParentIds.length === 0) {
8187
+ return;
8188
+ }
8189
+ const parentRecords = await this.adapter.objectRecords.findByIds(affectedParentIds);
8190
+ for (const parentRecord of parentRecords) {
8191
+ const parentSchema = await this.schemaService.getObjectSchema(parentRecord.objectId);
8192
+ const rollupAttrs = parentSchema.attributes.filter(
8193
+ (a) => a.type === "rollup"
8194
+ );
8195
+ if (rollupAttrs.length > 0) {
8196
+ await this.rollupService.recalculateAndUpdate(parentRecord, parentSchema);
8197
+ }
8198
+ }
8199
+ }
7268
8200
  /**
7269
8201
  * Permanently delete a record (hard delete)
7270
8202
  *
@@ -7293,11 +8225,18 @@ var RecordService = class {
7293
8225
  * @returns Records and total count
7294
8226
  */
7295
8227
  async listRecords(objectId, options) {
8228
+ const schema = await this.schemaService.getObjectSchema(objectId);
7296
8229
  if (this.permissionService && this.userId) {
7297
- const schema = await this.schemaService.getObjectSchema(objectId);
7298
8230
  await this.checkPermission(schema.name, "read");
7299
8231
  }
7300
- return await this.adapter.objectRecords.list(this.tenantId, objectId, options);
8232
+ const result = await this.adapter.objectRecords.list(this.tenantId, objectId, options);
8233
+ if (!options?.skipFormulas) {
8234
+ return {
8235
+ records: this.enrichRecordsWithFormulas(result.records, schema),
8236
+ total: result.total
8237
+ };
8238
+ }
8239
+ return result;
7301
8240
  }
7302
8241
  /**
7303
8242
  * Search records using full-text search
@@ -7308,11 +8247,18 @@ var RecordService = class {
7308
8247
  * @returns Matching records and total count
7309
8248
  */
7310
8249
  async searchRecords(objectId, query, options) {
8250
+ const schema = await this.schemaService.getObjectSchema(objectId);
7311
8251
  if (this.permissionService && this.userId) {
7312
- const schema = await this.schemaService.getObjectSchema(objectId);
7313
8252
  await this.checkPermission(schema.name, "read");
7314
8253
  }
7315
- return await this.adapter.objectRecords.search(this.tenantId, objectId, query, options);
8254
+ const result = await this.adapter.objectRecords.search(this.tenantId, objectId, query, options);
8255
+ if (!options?.skipFormulas) {
8256
+ return {
8257
+ records: this.enrichRecordsWithFormulas(result.records, schema),
8258
+ total: result.total
8259
+ };
8260
+ }
8261
+ return result;
7316
8262
  }
7317
8263
  /**
7318
8264
  * Validate data against object schema without saving
@@ -7357,6 +8303,231 @@ var RecordService = class {
7357
8303
  }
7358
8304
  };
7359
8305
 
8306
+ // src/runtime/services/relation-resolver.service.ts
8307
+ var RelationResolverService = class {
8308
+ constructor(adapter) {
8309
+ this.adapter = adapter;
8310
+ }
8311
+ /**
8312
+ * Resolve values from related records for formula evaluation
8313
+ *
8314
+ * Phase 2: Supports 1 level of relation traversal only
8315
+ *
8316
+ * @param record - The source record
8317
+ * @param schema - Schema of the source object
8318
+ * @param relationNames - Names of relation attributes to resolve
8319
+ * @returns Map of relation name to related record's values
8320
+ *
8321
+ * @example
8322
+ * ```typescript
8323
+ * // For an order with company relation
8324
+ * const resolved = await resolver.resolveRelationValues(
8325
+ * orderRecord,
8326
+ * orderSchema,
8327
+ * ["company"]
8328
+ * );
8329
+ * // → { company: { id: "...", name: "Acme Corp", ... } }
8330
+ * ```
8331
+ */
8332
+ async resolveRelationValues(record, schema, relationNames) {
8333
+ const result = {};
8334
+ const relationAttrs = schema.attributes.filter(
8335
+ (a) => a.type === "relation" && relationNames.includes(a.name)
8336
+ );
8337
+ if (relationAttrs.length === 0) {
8338
+ return result;
8339
+ }
8340
+ const idsToFetch = [];
8341
+ const attrIdMap = /* @__PURE__ */ new Map();
8342
+ for (const attr of relationAttrs) {
8343
+ const relatedId = this.extractSingleId(record.values[attr.name]);
8344
+ if (relatedId) {
8345
+ idsToFetch.push(relatedId);
8346
+ attrIdMap.set(relatedId, attr.name);
8347
+ }
8348
+ }
8349
+ if (idsToFetch.length === 0) {
8350
+ return result;
8351
+ }
8352
+ const relatedRecords = await this.adapter.objectRecords.findByIds(idsToFetch);
8353
+ for (const relatedRecord of relatedRecords) {
8354
+ const attrName = attrIdMap.get(relatedRecord.id);
8355
+ if (attrName) {
8356
+ result[attrName] = relatedRecord.values;
8357
+ }
8358
+ }
8359
+ return result;
8360
+ }
8361
+ /**
8362
+ * Resolve relation values for multiple records (batched)
8363
+ *
8364
+ * Optimized for list operations - fetches all related records in one batch
8365
+ *
8366
+ * @param records - Source records
8367
+ * @param schema - Schema of the source object
8368
+ * @param relationNames - Names of relation attributes to resolve
8369
+ * @returns Map of record ID to resolved relations
8370
+ */
8371
+ async resolveRelationValuesForMany(records, schema, relationNames) {
8372
+ const resultMap = /* @__PURE__ */ new Map();
8373
+ for (const record of records) {
8374
+ resultMap.set(record.id, {});
8375
+ }
8376
+ const relationAttrs = schema.attributes.filter(
8377
+ (a) => a.type === "relation" && relationNames.includes(a.name)
8378
+ );
8379
+ if (relationAttrs.length === 0) {
8380
+ return resultMap;
8381
+ }
8382
+ const allIdsToFetch = /* @__PURE__ */ new Set();
8383
+ const recordRelationMap = /* @__PURE__ */ new Map();
8384
+ for (const record of records) {
8385
+ const idToAttr = /* @__PURE__ */ new Map();
8386
+ for (const attr of relationAttrs) {
8387
+ const relatedId = this.extractSingleId(record.values[attr.name]);
8388
+ if (relatedId) {
8389
+ allIdsToFetch.add(relatedId);
8390
+ idToAttr.set(relatedId, attr.name);
8391
+ }
8392
+ }
8393
+ recordRelationMap.set(record.id, idToAttr);
8394
+ }
8395
+ if (allIdsToFetch.size === 0) {
8396
+ return resultMap;
8397
+ }
8398
+ const relatedRecords = await this.adapter.objectRecords.findByIds([...allIdsToFetch]);
8399
+ const relatedRecordMap = new Map(relatedRecords.map((r) => [r.id, r]));
8400
+ for (const record of records) {
8401
+ const result = resultMap.get(record.id);
8402
+ const idToAttr = recordRelationMap.get(record.id);
8403
+ for (const [relatedId, attrName] of idToAttr) {
8404
+ const relatedRecord = relatedRecordMap.get(relatedId);
8405
+ if (relatedRecord) {
8406
+ result[attrName] = relatedRecord.values;
8407
+ }
8408
+ }
8409
+ }
8410
+ return resultMap;
8411
+ }
8412
+ /**
8413
+ * Flatten resolved relations for formula evaluation
8414
+ *
8415
+ * Converts nested structure to dot-notation keys:
8416
+ * { company: { name: "Acme" } } → { "company.name": "Acme" }
8417
+ *
8418
+ * @param resolved - Resolved relations from resolveRelationValues
8419
+ * @returns Flattened values suitable for formula evaluation
8420
+ */
8421
+ flattenResolvedRelations(resolved) {
8422
+ const flat = {};
8423
+ for (const [relationName, values] of Object.entries(resolved)) {
8424
+ for (const [key, value] of Object.entries(values)) {
8425
+ flat[`${relationName}.${key}`] = value;
8426
+ }
8427
+ }
8428
+ return flat;
8429
+ }
8430
+ /**
8431
+ * Extract a single relation ID from a value
8432
+ * Handles both single (string) and multi (array) relations
8433
+ * @internal
8434
+ */
8435
+ extractSingleId(value) {
8436
+ if (typeof value === "string" && value.length > 0) {
8437
+ return value;
8438
+ }
8439
+ if (Array.isArray(value) && value.length > 0 && typeof value[0] === "string") {
8440
+ return value[0];
8441
+ }
8442
+ return null;
8443
+ }
8444
+ };
8445
+
8446
+ // src/runtime/services/rollup-scheduler.ts
8447
+ var RollupScheduler = class {
8448
+ constructor(adapter, getSchemaById, options) {
8449
+ this.adapter = adapter;
8450
+ this.getSchemaById = getSchemaById;
8451
+ this.pending = /* @__PURE__ */ new Map();
8452
+ this.rollupService = new RollupService(adapter);
8453
+ this.debounceMs = options?.debounceMs ?? 100;
8454
+ this.maxPending = options?.maxPending ?? 100;
8455
+ }
8456
+ /**
8457
+ * Schedule a rollup recalculation for a parent record
8458
+ *
8459
+ * If the same record is already pending, the timer is reset.
8460
+ * If too many recalculations are pending, triggers an immediate flush.
8461
+ *
8462
+ * @param parentId - ID of the parent record to recalculate
8463
+ * @param parentObjectId - Object ID of the parent
8464
+ * @param tenantId - Tenant ID
8465
+ */
8466
+ scheduleRecalculation(parentId, parentObjectId, tenantId) {
8467
+ const key = `${parentId}:${parentObjectId}`;
8468
+ const existing = this.pending.get(key);
8469
+ if (existing) {
8470
+ clearTimeout(existing.timeout);
8471
+ }
8472
+ const timeout = setTimeout(async () => {
8473
+ await this.executeRecalculation(parentId, parentObjectId);
8474
+ this.pending.delete(key);
8475
+ }, this.debounceMs);
8476
+ this.pending.set(key, { parentId, parentObjectId, tenantId, timeout });
8477
+ if (this.pending.size >= this.maxPending) {
8478
+ this.flush();
8479
+ }
8480
+ }
8481
+ /**
8482
+ * Execute all pending recalculations immediately
8483
+ */
8484
+ async flush() {
8485
+ const entries = Array.from(this.pending.entries());
8486
+ for (const [, entry] of entries) {
8487
+ clearTimeout(entry.timeout);
8488
+ }
8489
+ this.pending.clear();
8490
+ const tenantMap = /* @__PURE__ */ new Map();
8491
+ for (const [, entry] of entries) {
8492
+ tenantMap.set(entry.parentId, entry.tenantId);
8493
+ }
8494
+ const allIds = entries.map(([, entry]) => entry.parentId);
8495
+ const records = await this.adapter.objectRecords.findByIds(allIds);
8496
+ for (const record of records) {
8497
+ const schema = await this.getSchemaById(record.objectId);
8498
+ const tenantId = tenantMap.get(record.id);
8499
+ if (schema && tenantId) {
8500
+ await this.rollupService.recalculateAndUpdate(record, schema);
8501
+ }
8502
+ }
8503
+ }
8504
+ /**
8505
+ * Execute a single recalculation
8506
+ */
8507
+ async executeRecalculation(parentId, parentObjectId) {
8508
+ const record = await this.adapter.objectRecords.findById(parentId);
8509
+ if (!record) return;
8510
+ const schema = await this.getSchemaById(parentObjectId);
8511
+ if (!schema) return;
8512
+ await this.rollupService.recalculateAndUpdate(record, schema);
8513
+ }
8514
+ /**
8515
+ * Get number of pending recalculations
8516
+ */
8517
+ get pendingCount() {
8518
+ return this.pending.size;
8519
+ }
8520
+ /**
8521
+ * Clear all pending recalculations without executing them
8522
+ */
8523
+ clear() {
8524
+ for (const [, entry] of this.pending) {
8525
+ clearTimeout(entry.timeout);
8526
+ }
8527
+ this.pending.clear();
8528
+ }
8529
+ };
8530
+
7360
8531
  // src/runtime/services/user-profile.service.ts
7361
8532
  var UserProfileService = class {
7362
8533
  constructor(adapter, tenantId, options) {
@@ -8054,7 +9225,8 @@ async function syncAttributes(adapter, nativeObject, dbObject, existingObject, r
8054
9225
  hidden: attr.hidden,
8055
9226
  archived: attr.archived,
8056
9227
  deprecated: attr.deprecated,
8057
- defaultValue: attr.defaultValue,
9228
+ // Formula and rollup attributes don't have defaultValue
9229
+ defaultValue: "defaultValue" in attr ? attr.defaultValue : void 0,
8058
9230
  config: extractAttributeConfig(attr)
8059
9231
  });
8060
9232
  if (existingAttr) {
@@ -8136,6 +9308,8 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
8136
9308
  GeocodingService,
8137
9309
  GlobalSearchService,
8138
9310
  GroupBuilder,
9311
+ InvalidPathError,
9312
+ MaxDepthExceededError,
8139
9313
  NO_VALUE_OPERATORS,
8140
9314
  NoopGeocodingAdapter,
8141
9315
  NoopHookRegistry,
@@ -8152,8 +9326,11 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
8152
9326
  RecordNotFoundError,
8153
9327
  RecordReferencedError,
8154
9328
  RecordService,
9329
+ RelationResolverService,
8155
9330
  RelationService,
8156
9331
  RoleNotFoundError,
9332
+ RollupScheduler,
9333
+ RollupService,
8157
9334
  SYSTEM_RESOURCES,
8158
9335
  SYSTEM_RESOURCE_LABELS,
8159
9336
  SchemaError,
@@ -8178,6 +9355,7 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
8178
9355
  createDateValidator,
8179
9356
  createDraftValidator,
8180
9357
  createFileValidator,
9358
+ createFormulaValidator,
8181
9359
  createLocationValidator,
8182
9360
  createMockAdapter,
8183
9361
  createMultiRelationValidator,
@@ -8187,6 +9365,7 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
8187
9365
  createPhoneValidator,
8188
9366
  createRatingValidator,
8189
9367
  createRelationValidator,
9368
+ createRollupValidator,
8190
9369
  createSelectValidator,
8191
9370
  createSingleRelationValidator,
8192
9371
  createStatusValidator,
@@ -8198,18 +9377,34 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
8198
9377
  date,
8199
9378
  dateConfigSchema,
8200
9379
  enrichValuesWithSelectLabels,
9380
+ evaluateFormula,
9381
+ evaluateFormulaAttribute,
9382
+ evaluateFormulaAttributeWithRelations,
9383
+ evaluateFormulaWithRelations,
9384
+ evaluateFormulaWithResult,
8201
9385
  extractAttributeNames,
9386
+ extractFormulaVariables,
9387
+ extractRelationNames,
9388
+ extractRelationReferences,
8202
9389
  file,
8203
9390
  fileConfigSchema,
9391
+ flattenRelationsForEval,
8204
9392
  flow,
8205
9393
  formatAttributeValue,
9394
+ formatFormulaResult,
9395
+ formula,
9396
+ formulaConfigSchema,
8206
9397
  generateId,
8207
9398
  generatePrefixedId,
8208
9399
  getAttributeConfigSchema,
8209
9400
  getMissingRequiredAttributes,
9401
+ getPathDepth,
9402
+ getRelationPath,
8210
9403
  getSyncPreview,
9404
+ getTargetAttributeName,
8211
9405
  getViewSyncPreview,
8212
9406
  group,
9407
+ hasRelationReferences,
8213
9408
  isActivityTab,
8214
9409
  isAdvancedFilterState,
8215
9410
  isCustomTab,
@@ -8235,6 +9430,8 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
8235
9430
  numberConfigSchema,
8236
9431
  object,
8237
9432
  parseAttributeConfig,
9433
+ parsePath,
9434
+ pathHasManyCardinality,
8238
9435
  phone,
8239
9436
  phoneConfigSchema,
8240
9437
  rating,
@@ -8243,6 +9440,10 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
8243
9440
  relation,
8244
9441
  relationConfigSchema,
8245
9442
  renderLabelExpression,
9443
+ resolveMultiplePaths,
9444
+ resolveSingleValue,
9445
+ rollup,
9446
+ rollupConfigSchema,
8246
9447
  safeParseAttributeConfig,
8247
9448
  select,
8248
9449
  selectConfigSchema,
@@ -8259,14 +9460,17 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
8259
9460
  timestampConfigSchema,
8260
9461
  toAdvancedFilterState,
8261
9462
  toSimpleFilterState,
9463
+ traversePath,
8262
9464
  user,
8263
9465
  userConfigSchema,
8264
9466
  validateAttribute,
8265
9467
  validateAttributeConfig,
8266
9468
  validateDraft,
8267
9469
  validateDraftOrThrow,
9470
+ validateFormulaExpression,
8268
9471
  validateObject,
8269
9472
  validateObjectOrThrow,
9473
+ validatePath,
8270
9474
  verifyNativeObjectsSync,
8271
9475
  verifyNativeViewsSync,
8272
9476
  view,