@stndrds/schema 0.1.0-alpha.17 → 0.1.0-alpha.19

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 (39) hide show
  1. package/dist/chunk-2PBQNUTS.mjs +7400 -0
  2. package/dist/chunk-4D476S25.mjs +7713 -0
  3. package/dist/chunk-BKOV4XVX.mjs +8816 -0
  4. package/dist/chunk-CWPOGEK2.mjs +7707 -0
  5. package/dist/chunk-DPOXIK3M.mjs +8816 -0
  6. package/dist/chunk-DU2HLOQH.mjs +8813 -0
  7. package/dist/chunk-EWNSOD22.mjs +7707 -0
  8. package/dist/chunk-JD2MVLTW.mjs +8814 -0
  9. package/dist/chunk-KZSM5546.mjs +7706 -0
  10. package/dist/chunk-L7AQ7SZC.mjs +7432 -0
  11. package/dist/chunk-LZW2MI43.mjs +7404 -0
  12. package/dist/chunk-SD2UXOSF.mjs +8814 -0
  13. package/dist/chunk-V5QXU2OK.mjs +82 -0
  14. package/dist/chunk-XMGMXAHW.mjs +7706 -0
  15. package/dist/chunk-YLVZT3H4.mjs +8793 -0
  16. package/dist/default-roles-AKWIWTLE.mjs +20 -0
  17. package/dist/index.d.mts +127 -8
  18. package/dist/index.d.ts +127 -8
  19. package/dist/index.js +1787 -182
  20. package/dist/index.mjs +90 -3
  21. package/dist/runtime-CPggQqoW.d.mts +6428 -0
  22. package/dist/runtime-CPggQqoW.d.ts +6428 -0
  23. package/dist/runtime-DSMMZRLE.d.mts +6430 -0
  24. package/dist/runtime-DSMMZRLE.d.ts +6430 -0
  25. package/dist/runtime-DWhSpnn4.d.mts +5256 -0
  26. package/dist/runtime-DWhSpnn4.d.ts +5256 -0
  27. package/dist/runtime-Dw2exb5q.d.mts +5601 -0
  28. package/dist/runtime-Dw2exb5q.d.ts +5601 -0
  29. package/dist/runtime-DySuNV6Y.d.mts +5245 -0
  30. package/dist/runtime-DySuNV6Y.d.ts +5245 -0
  31. package/dist/runtime-DzdsFCyL.d.mts +5601 -0
  32. package/dist/runtime-DzdsFCyL.d.ts +5601 -0
  33. package/dist/runtime-uXhTgSIx.d.mts +5260 -0
  34. package/dist/runtime-uXhTgSIx.d.ts +5260 -0
  35. package/dist/runtime.d.mts +1 -1
  36. package/dist/runtime.d.ts +1 -1
  37. package/dist/runtime.js +1520 -59
  38. package/dist/runtime.mjs +59 -1
  39. package/package.json +4 -3
package/dist/index.js CHANGED
@@ -59,21 +59,25 @@ var init_default_roles = __esm({
59
59
  /** Tenant settings and configuration */
60
60
  SETTINGS: "settings",
61
61
  /** Audit logs and activity trail */
62
- AUDIT: "audit"
62
+ AUDIT: "audit",
63
+ /** File uploads and storage management */
64
+ FILES: "files"
63
65
  };
64
66
  ALL_SYSTEM_RESOURCES = [
65
67
  SYSTEM_RESOURCES.USERS,
66
68
  SYSTEM_RESOURCES.SCHEMA,
67
69
  SYSTEM_RESOURCES.ROLES,
68
70
  SYSTEM_RESOURCES.SETTINGS,
69
- SYSTEM_RESOURCES.AUDIT
71
+ SYSTEM_RESOURCES.AUDIT,
72
+ SYSTEM_RESOURCES.FILES
70
73
  ];
71
74
  SYSTEM_RESOURCE_LABELS = {
72
75
  users: "Users",
73
76
  schema: "Schema",
74
77
  roles: "Roles & Permissions",
75
78
  settings: "Settings",
76
- audit: "Audit Logs"
79
+ audit: "Audit Logs",
80
+ files: "Files"
77
81
  };
78
82
  DEFAULT_ROLES = {
79
83
  /** Full platform access - can manage everything */
@@ -140,6 +144,8 @@ __export(index_exports, {
140
144
  GeocodingService: () => GeocodingService,
141
145
  GlobalSearchService: () => GlobalSearchService,
142
146
  GroupBuilder: () => GroupBuilder,
147
+ InvalidPathError: () => InvalidPathError,
148
+ MaxDepthExceededError: () => MaxDepthExceededError,
143
149
  NO_VALUE_OPERATORS: () => NO_VALUE_OPERATORS,
144
150
  NoopGeocodingAdapter: () => NoopGeocodingAdapter,
145
151
  NoopHookRegistry: () => NoopHookRegistry,
@@ -156,8 +162,11 @@ __export(index_exports, {
156
162
  RecordNotFoundError: () => RecordNotFoundError,
157
163
  RecordReferencedError: () => RecordReferencedError,
158
164
  RecordService: () => RecordService,
165
+ RelationResolverService: () => RelationResolverService,
159
166
  RelationService: () => RelationService,
160
167
  RoleNotFoundError: () => RoleNotFoundError,
168
+ RollupScheduler: () => RollupScheduler,
169
+ RollupService: () => RollupService,
161
170
  SYSTEM_RESOURCES: () => SYSTEM_RESOURCES,
162
171
  SYSTEM_RESOURCE_LABELS: () => SYSTEM_RESOURCE_LABELS,
163
172
  SchemaError: () => SchemaError,
@@ -167,6 +176,7 @@ __export(index_exports, {
167
176
  TableTabConfig: () => TableTabConfig,
168
177
  UserProfileNotFoundError: () => UserProfileNotFoundError,
169
178
  UserProfileService: () => UserProfileService,
179
+ UserService: () => UserService,
170
180
  ValidationError: () => ValidationError,
171
181
  ViewBuilder: () => ViewBuilder,
172
182
  ViewService: () => ViewService,
@@ -181,6 +191,7 @@ __export(index_exports, {
181
191
  createDateValidator: () => createDateValidator,
182
192
  createDraftValidator: () => createDraftValidator,
183
193
  createFileValidator: () => createFileValidator,
194
+ createFormulaValidator: () => createFormulaValidator,
184
195
  createLocationValidator: () => createLocationValidator,
185
196
  createMockAdapter: () => createMockAdapter,
186
197
  createMultiRelationValidator: () => createMultiRelationValidator,
@@ -190,6 +201,8 @@ __export(index_exports, {
190
201
  createPhoneValidator: () => createPhoneValidator,
191
202
  createRatingValidator: () => createRatingValidator,
192
203
  createRelationValidator: () => createRelationValidator,
204
+ createRollupHooks: () => createRollupHooks,
205
+ createRollupValidator: () => createRollupValidator,
193
206
  createSelectValidator: () => createSelectValidator,
194
207
  createSingleRelationValidator: () => createSingleRelationValidator,
195
208
  createStatusValidator: () => createStatusValidator,
@@ -201,18 +214,34 @@ __export(index_exports, {
201
214
  date: () => date,
202
215
  dateConfigSchema: () => dateConfigSchema,
203
216
  enrichValuesWithSelectLabels: () => enrichValuesWithSelectLabels,
217
+ evaluateFormula: () => evaluateFormula,
218
+ evaluateFormulaAttribute: () => evaluateFormulaAttribute,
219
+ evaluateFormulaAttributeWithRelations: () => evaluateFormulaAttributeWithRelations,
220
+ evaluateFormulaWithRelations: () => evaluateFormulaWithRelations,
221
+ evaluateFormulaWithResult: () => evaluateFormulaWithResult,
204
222
  extractAttributeNames: () => extractAttributeNames,
223
+ extractFormulaVariables: () => extractFormulaVariables,
224
+ extractRelationNames: () => extractRelationNames,
225
+ extractRelationReferences: () => extractRelationReferences,
205
226
  file: () => file,
206
227
  fileConfigSchema: () => fileConfigSchema,
228
+ flattenRelationsForEval: () => flattenRelationsForEval,
207
229
  flow: () => flow,
208
230
  formatAttributeValue: () => formatAttributeValue,
231
+ formatFormulaResult: () => formatFormulaResult,
232
+ formula: () => formula,
233
+ formulaConfigSchema: () => formulaConfigSchema,
209
234
  generateId: () => generateId,
210
235
  generatePrefixedId: () => generatePrefixedId,
211
236
  getAttributeConfigSchema: () => getAttributeConfigSchema,
212
237
  getMissingRequiredAttributes: () => getMissingRequiredAttributes,
238
+ getPathDepth: () => getPathDepth,
239
+ getRelationPath: () => getRelationPath,
213
240
  getSyncPreview: () => getSyncPreview,
241
+ getTargetAttributeName: () => getTargetAttributeName,
214
242
  getViewSyncPreview: () => getViewSyncPreview,
215
243
  group: () => group,
244
+ hasRelationReferences: () => hasRelationReferences,
216
245
  isActivityTab: () => isActivityTab,
217
246
  isAdvancedFilterState: () => isAdvancedFilterState,
218
247
  isCustomTab: () => isCustomTab,
@@ -238,14 +267,21 @@ __export(index_exports, {
238
267
  numberConfigSchema: () => numberConfigSchema,
239
268
  object: () => object,
240
269
  parseAttributeConfig: () => parseAttributeConfig,
270
+ parsePath: () => parsePath,
271
+ pathHasManyCardinality: () => pathHasManyCardinality,
241
272
  phone: () => phone,
242
273
  phoneConfigSchema: () => phoneConfigSchema,
243
274
  rating: () => rating,
244
275
  ratingConfigSchema: () => ratingConfigSchema,
276
+ registerAllRollupHooks: () => registerAllRollupHooks,
245
277
  registry: () => registry,
246
278
  relation: () => relation,
247
279
  relationConfigSchema: () => relationConfigSchema,
248
280
  renderLabelExpression: () => renderLabelExpression,
281
+ resolveMultiplePaths: () => resolveMultiplePaths,
282
+ resolveSingleValue: () => resolveSingleValue,
283
+ rollup: () => rollup,
284
+ rollupConfigSchema: () => rollupConfigSchema,
249
285
  safeParseAttributeConfig: () => safeParseAttributeConfig,
250
286
  select: () => select,
251
287
  selectConfigSchema: () => selectConfigSchema,
@@ -262,14 +298,17 @@ __export(index_exports, {
262
298
  timestampConfigSchema: () => timestampConfigSchema,
263
299
  toAdvancedFilterState: () => toAdvancedFilterState,
264
300
  toSimpleFilterState: () => toSimpleFilterState,
301
+ traversePath: () => traversePath,
265
302
  user: () => user,
266
303
  userConfigSchema: () => userConfigSchema,
267
304
  validateAttribute: () => validateAttribute,
268
305
  validateAttributeConfig: () => validateAttributeConfig,
269
306
  validateDraft: () => validateDraft,
270
307
  validateDraftOrThrow: () => validateDraftOrThrow,
308
+ validateFormulaExpression: () => validateFormulaExpression,
271
309
  validateObject: () => validateObject,
272
310
  validateObjectOrThrow: () => validateObjectOrThrow,
311
+ validatePath: () => validatePath,
273
312
  verifyNativeObjectsSync: () => verifyNativeObjectsSync,
274
313
  verifyNativeViewsSync: () => verifyNativeViewsSync,
275
314
  view: () => view,
@@ -392,7 +431,24 @@ var OPERATORS_BY_TYPE = {
392
431
  file: ["is_empty", "is_not_empty"],
393
432
  user: ["contains", "not_contains", "is_empty", "is_not_empty"],
394
433
  relation: ["any_of", "none_of", "contains", "not_contains", "is_empty", "is_not_empty"],
395
- rating: ["eq", "neq", "lt", "gt", "lte", "gte", "is_empty", "is_not_empty"]
434
+ rating: ["eq", "neq", "lt", "gt", "lte", "gte", "is_empty", "is_not_empty"],
435
+ // Formula: operators depend on returnType, but we provide a general set
436
+ formula: [
437
+ "is",
438
+ "is_not",
439
+ "eq",
440
+ "neq",
441
+ "lt",
442
+ "gt",
443
+ "lte",
444
+ "gte",
445
+ "contains",
446
+ "not_contains",
447
+ "is_empty",
448
+ "is_not_empty"
449
+ ],
450
+ // Rollup: typically numeric aggregations
451
+ rollup: ["eq", "neq", "lt", "gt", "lte", "gte", "is_empty", "is_not_empty"]
396
452
  };
397
453
  var NO_VALUE_OPERATORS = [
398
454
  "is_empty",
@@ -469,6 +525,13 @@ function formatCheckbox(value) {
469
525
  function formatNumber(value, attribute) {
470
526
  if (typeof value !== "number") return String(value);
471
527
  const decimals = attribute.decimals;
528
+ if (attribute.unit === "percentage") {
529
+ return value.toLocaleString(void 0, {
530
+ style: "percent",
531
+ minimumFractionDigits: decimals,
532
+ maximumFractionDigits: decimals
533
+ });
534
+ }
472
535
  return value.toLocaleString(void 0, {
473
536
  minimumFractionDigits: decimals,
474
537
  maximumFractionDigits: decimals
@@ -640,6 +703,7 @@ var SchemaErrorCode = {
640
703
  // Protected Resources
641
704
  PROTECTED_OBJECT: "SCHEMA_PROTECTED_OBJECT",
642
705
  PROTECTED_ATTRIBUTE: "SCHEMA_PROTECTED_ATTRIBUTE",
706
+ PROTECTED_VIEW: "SCHEMA_PROTECTED_VIEW",
643
707
  PROTECTED_ROLE: "SCHEMA_PROTECTED_ROLE",
644
708
  // Permissions
645
709
  FORBIDDEN: "SCHEMA_FORBIDDEN",
@@ -728,7 +792,7 @@ var ValidationError = class _ValidationError extends SchemaError {
728
792
  };
729
793
  var ProtectedResourceError = class extends SchemaError {
730
794
  constructor(resourceType, resourceName, operation) {
731
- const code = resourceType === "object" ? SchemaErrorCode.PROTECTED_OBJECT : SchemaErrorCode.PROTECTED_ATTRIBUTE;
795
+ const code = resourceType === "object" ? SchemaErrorCode.PROTECTED_OBJECT : resourceType === "view" ? SchemaErrorCode.PROTECTED_VIEW : SchemaErrorCode.PROTECTED_ATTRIBUTE;
732
796
  super(`Cannot ${operation} system ${resourceType} "${resourceName}"`, code, {
733
797
  resourceType,
734
798
  resourceName,
@@ -943,7 +1007,9 @@ var BaseAttributeBuilder = class {
943
1007
  return this;
944
1008
  }
945
1009
  defaultValue(value) {
946
- this.attr.defaultValue = value;
1010
+ if ("defaultValue" in this.attr || !["formula", "rollup"].includes(this.attr.type)) {
1011
+ this.attr.defaultValue = value;
1012
+ }
947
1013
  return this;
948
1014
  }
949
1015
  metadata(value) {
@@ -1324,6 +1390,13 @@ var FileAttributeBuilder = class extends BaseAttributeBuilder {
1324
1390
  this.attr.verification = config;
1325
1391
  return this;
1326
1392
  }
1393
+ /**
1394
+ * Allow multiple files to be uploaded
1395
+ */
1396
+ multiple() {
1397
+ this.attr.multiple = true;
1398
+ return this;
1399
+ }
1327
1400
  required() {
1328
1401
  this.setRequired(true);
1329
1402
  return this;
@@ -1344,6 +1417,13 @@ var UserAttributeBuilder = class extends BaseAttributeBuilder {
1344
1417
  this.attr.allowedRoles = roles;
1345
1418
  return this;
1346
1419
  }
1420
+ /**
1421
+ * Allow multiple users to be selected
1422
+ */
1423
+ multiple() {
1424
+ this.attr.multiple = true;
1425
+ return this;
1426
+ }
1347
1427
  required() {
1348
1428
  this.setRequired(true);
1349
1429
  return this;
@@ -1471,6 +1551,105 @@ var RatingAttributeBuilder = class extends BaseAttributeBuilder {
1471
1551
  function rating(config) {
1472
1552
  return new RatingAttributeBuilder(config.name, config.label);
1473
1553
  }
1554
+ var FormulaAttributeBuilder = class extends BaseAttributeBuilder {
1555
+ constructor(name, label) {
1556
+ super("formula", name, label);
1557
+ this.attr.required = false;
1558
+ }
1559
+ /**
1560
+ * Set the formula expression
1561
+ * @param expr - Expression to evaluate (e.g., "price * quantity")
1562
+ */
1563
+ expression(expr) {
1564
+ this.attr.expression = expr;
1565
+ return this;
1566
+ }
1567
+ /**
1568
+ * Set the expected return type for formatting
1569
+ * @param type - Return type: "text", "number", "boolean", or "date"
1570
+ */
1571
+ returns(type) {
1572
+ this.attr.returnType = type;
1573
+ return this;
1574
+ }
1575
+ /**
1576
+ * Set decimal places for number results
1577
+ */
1578
+ decimals(value) {
1579
+ this.attr.decimals = value;
1580
+ return this;
1581
+ }
1582
+ // Override to prevent making formulas required
1583
+ // biome-ignore lint/suspicious/noExplicitAny: intentional override
1584
+ required() {
1585
+ console.warn("Formula attributes cannot be required (they are read-only)");
1586
+ return this;
1587
+ }
1588
+ // biome-ignore lint/suspicious/noExplicitAny: intentional override
1589
+ optional() {
1590
+ return this;
1591
+ }
1592
+ };
1593
+ function formula(config) {
1594
+ return new FormulaAttributeBuilder(config.name, config.label);
1595
+ }
1596
+ var RollupAttributeBuilder = class extends BaseAttributeBuilder {
1597
+ constructor(name, label) {
1598
+ super("rollup", name, label);
1599
+ this.attr.required = false;
1600
+ }
1601
+ /**
1602
+ * Set the relation attribute to aggregate from
1603
+ * @param relationName - Name of the relation attribute on this object
1604
+ */
1605
+ from(relationName) {
1606
+ this.attr.relationAttribute = relationName;
1607
+ return this;
1608
+ }
1609
+ /**
1610
+ * Set the target attribute to aggregate
1611
+ * @param attrName - Name of the attribute on the related object
1612
+ */
1613
+ aggregate(attrName) {
1614
+ this.attr.targetAttribute = attrName;
1615
+ return this;
1616
+ }
1617
+ /**
1618
+ * Set the aggregation function
1619
+ */
1620
+ using(fn) {
1621
+ this.attr.function = fn;
1622
+ return this;
1623
+ }
1624
+ /**
1625
+ * Set decimal places for numeric results
1626
+ */
1627
+ decimals(value) {
1628
+ this.attr.decimals = value;
1629
+ return this;
1630
+ }
1631
+ /**
1632
+ * Set multi-level path for traversing nested relations (Phase 4+)
1633
+ * @param path - Dot notation path (e.g., "orders.items")
1634
+ */
1635
+ path(relationPath) {
1636
+ this.attr.relationPath = relationPath;
1637
+ return this;
1638
+ }
1639
+ // Override to prevent making rollups required
1640
+ // biome-ignore lint/suspicious/noExplicitAny: intentional override
1641
+ required() {
1642
+ console.warn("Rollup attributes cannot be required (they are read-only)");
1643
+ return this;
1644
+ }
1645
+ // biome-ignore lint/suspicious/noExplicitAny: intentional override
1646
+ optional() {
1647
+ return this;
1648
+ }
1649
+ };
1650
+ function rollup(config) {
1651
+ return new RollupAttributeBuilder(config.name, config.label);
1652
+ }
1474
1653
 
1475
1654
  // src/builders/flow-builder.ts
1476
1655
  var import_zod = require("zod");
@@ -2750,10 +2929,12 @@ var fileConfigSchema = baseConfigSchema.extend({
2750
2929
  maxFiles: import_zod4.z.number().int().min(1).optional(),
2751
2930
  maxSize: import_zod4.z.number().int().min(1).optional(),
2752
2931
  allowedTypes: import_zod4.z.array(import_zod4.z.string()).optional(),
2753
- verification: fileVerificationConfigSchema.optional()
2932
+ verification: fileVerificationConfigSchema.optional(),
2933
+ multiple: import_zod4.z.boolean().optional()
2754
2934
  });
2755
2935
  var userConfigSchema = baseConfigSchema.extend({
2756
- allowedRoles: import_zod4.z.array(import_zod4.z.string()).optional()
2936
+ allowedRoles: import_zod4.z.array(import_zod4.z.string()).optional(),
2937
+ multiple: import_zod4.z.boolean().optional()
2757
2938
  });
2758
2939
  var relationConfigSchema = baseConfigSchema.extend({
2759
2940
  targets: import_zod4.z.array(relationTargetSchema).min(1),
@@ -2765,6 +2946,30 @@ var ratingConfigSchema = baseConfigSchema.extend({
2765
2946
  max: import_zod4.z.number().int().min(1).optional(),
2766
2947
  iconType: import_zod4.z.enum(["star", "heart", "thumbs", "number"]).optional()
2767
2948
  });
2949
+ var formulaConfigSchema = baseConfigSchema.extend({
2950
+ expression: import_zod4.z.string().min(1),
2951
+ returnType: import_zod4.z.enum(["text", "number", "boolean", "date"]),
2952
+ decimals: import_zod4.z.number().int().min(0).max(10).optional(),
2953
+ allowRelations: import_zod4.z.boolean().optional()
2954
+ });
2955
+ var rollupConfigSchema = baseConfigSchema.extend({
2956
+ relationAttribute: import_zod4.z.string().min(1).optional(),
2957
+ relationPath: import_zod4.z.string().optional(),
2958
+ targetAttribute: import_zod4.z.string().min(1),
2959
+ function: import_zod4.z.enum([
2960
+ "sum",
2961
+ "count",
2962
+ "avg",
2963
+ "min",
2964
+ "max",
2965
+ "countValues",
2966
+ "countEmpty",
2967
+ "percentEmpty",
2968
+ "percentFilled"
2969
+ ]),
2970
+ decimals: import_zod4.z.number().int().min(0).max(10).optional(),
2971
+ materialize: import_zod4.z.boolean().optional()
2972
+ });
2768
2973
  var attributeConfigSchemas = {
2769
2974
  text: textConfigSchema,
2770
2975
  textarea: textareaConfigSchema,
@@ -2781,7 +2986,9 @@ var attributeConfigSchemas = {
2781
2986
  file: fileConfigSchema,
2782
2987
  user: userConfigSchema,
2783
2988
  relation: relationConfigSchema,
2784
- rating: ratingConfigSchema
2989
+ rating: ratingConfigSchema,
2990
+ formula: formulaConfigSchema,
2991
+ rollup: rollupConfigSchema
2785
2992
  };
2786
2993
  function getAttributeConfigSchema(type) {
2787
2994
  return attributeConfigSchemas[type];
@@ -2891,20 +3098,39 @@ function createLocationValidator(_attr) {
2891
3098
  function createTimestampValidator(_attr) {
2892
3099
  return import_zod4.z.number().int().positive();
2893
3100
  }
2894
- function createFileValidator(_attr) {
2895
- return import_zod4.z.string().uuid();
3101
+ function createFileValidator(attr) {
3102
+ const uuidSchema = import_zod4.z.uuid({
3103
+ message: `${attr.label} must be a valid file ID`
3104
+ });
3105
+ if (attr.multiple) {
3106
+ let arraySchema = import_zod4.z.array(uuidSchema);
3107
+ if (attr.maxFiles) {
3108
+ arraySchema = arraySchema.max(
3109
+ attr.maxFiles,
3110
+ `${attr.label} cannot have more than ${attr.maxFiles} file${attr.maxFiles > 1 ? "s" : ""}`
3111
+ );
3112
+ }
3113
+ return arraySchema;
3114
+ }
3115
+ return uuidSchema;
2896
3116
  }
2897
- function createUserValidator(_attr) {
2898
- return import_zod4.z.string().uuid();
3117
+ function createUserValidator(attr) {
3118
+ const uuidSchema = import_zod4.z.uuid({
3119
+ message: `${attr.label} must be a valid user ID`
3120
+ });
3121
+ if (attr.multiple) {
3122
+ return import_zod4.z.array(uuidSchema);
3123
+ }
3124
+ return uuidSchema;
2899
3125
  }
2900
3126
  function createSingleRelationValidator(attr) {
2901
- const uuidSchema = import_zod4.z.string().uuid({
3127
+ const uuidSchema = import_zod4.z.uuid({
2902
3128
  message: `${attr.label} must be a valid record ID`
2903
3129
  });
2904
3130
  return import_zod4.z.union([uuidSchema, import_zod4.z.null()]);
2905
3131
  }
2906
3132
  function createMultiRelationValidator(attr) {
2907
- const uuidSchema = import_zod4.z.string().uuid({
3133
+ const uuidSchema = import_zod4.z.uuid({
2908
3134
  message: `Each ${attr.label} item must be a valid record ID`
2909
3135
  });
2910
3136
  let arraySchema = import_zod4.z.array(uuidSchema);
@@ -2935,6 +3161,12 @@ function createRatingValidator(attr) {
2935
3161
  }
2936
3162
  return schema;
2937
3163
  }
3164
+ function createFormulaValidator(_attr) {
3165
+ return import_zod4.z.unknown();
3166
+ }
3167
+ function createRollupValidator(_attr) {
3168
+ return import_zod4.z.unknown();
3169
+ }
2938
3170
  function createAttributeValidator(attr) {
2939
3171
  switch (attr.type) {
2940
3172
  case "text":
@@ -2967,6 +3199,10 @@ function createAttributeValidator(attr) {
2967
3199
  return createRelationValidator(attr);
2968
3200
  case "rating":
2969
3201
  return createRatingValidator(attr);
3202
+ case "formula":
3203
+ return createFormulaValidator(attr);
3204
+ case "rollup":
3205
+ return createRollupValidator(attr);
2970
3206
  default:
2971
3207
  return import_zod4.z.unknown();
2972
3208
  }
@@ -3092,139 +3328,771 @@ function computeRecordStatus(objectDef, data) {
3092
3328
  return isRecordComplete(objectDef, data) ? "complete" : "draft";
3093
3329
  }
3094
3330
 
3095
- // src/runtime/hooks/hook.registry.ts
3096
- var NoopHookRegistry = class {
3097
- async execute() {
3331
+ // src/runtime/formula/evaluator.ts
3332
+ var import_expr_eval = require("expr-eval");
3333
+ function createFormulaParser() {
3334
+ const parser = new import_expr_eval.Parser();
3335
+ parser.functions.IF = (condition, thenValue, elseValue) => condition ? thenValue : elseValue;
3336
+ parser.functions.AND = (...args) => args.every(Boolean);
3337
+ parser.functions.OR = (...args) => args.some(Boolean);
3338
+ parser.functions.NOT = (value) => !value;
3339
+ parser.functions.EMPTY = (value) => value === null || value === void 0 || value === "";
3340
+ parser.functions.COALESCE = (...args) => args.find((a) => a != null) ?? null;
3341
+ parser.functions.DEFAULT = (value, defaultValue) => value == null || value === "" ? defaultValue : value;
3342
+ parser.functions.CONCAT = (...args) => args.filter((a) => a != null).map(String).join("");
3343
+ parser.functions.UPPER = (value) => String(value ?? "").toUpperCase();
3344
+ parser.functions.LOWER = (value) => String(value ?? "").toLowerCase();
3345
+ parser.functions.TRIM = (value) => String(value ?? "").trim();
3346
+ parser.functions.LENGTH = (value) => String(value ?? "").length;
3347
+ parser.functions.LEFT = (value, count) => String(value ?? "").slice(0, count);
3348
+ parser.functions.RIGHT = (value, count) => String(value ?? "").slice(-count);
3349
+ parser.functions.REPLACE = (value, search, replacement) => String(value ?? "").replace(new RegExp(search, "g"), replacement);
3350
+ parser.functions.CONTAINS = (value, search) => String(value ?? "").toLowerCase().includes(String(search).toLowerCase());
3351
+ parser.functions.ROUND = (value, decimals = 0) => {
3352
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
3353
+ const factor = 10 ** decimals;
3354
+ return Math.round(value * factor) / factor;
3355
+ };
3356
+ parser.functions.FLOOR = (value) => {
3357
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
3358
+ return Math.floor(value);
3359
+ };
3360
+ parser.functions.CEIL = (value) => {
3361
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
3362
+ return Math.ceil(value);
3363
+ };
3364
+ parser.functions.ABS = (value) => {
3365
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
3366
+ return Math.abs(value);
3367
+ };
3368
+ parser.functions.MIN = (...args) => {
3369
+ const nums = args.filter((n) => typeof n === "number" && !Number.isNaN(n));
3370
+ return nums.length > 0 ? Math.min(...nums) : null;
3371
+ };
3372
+ parser.functions.MAX = (...args) => {
3373
+ const nums = args.filter((n) => typeof n === "number" && !Number.isNaN(n));
3374
+ return nums.length > 0 ? Math.max(...nums) : null;
3375
+ };
3376
+ parser.functions.POW = (base, exponent) => {
3377
+ if (typeof base !== "number" || typeof exponent !== "number") return null;
3378
+ return base ** exponent;
3379
+ };
3380
+ parser.functions.MOD = (a, b) => {
3381
+ if (typeof a !== "number" || typeof b !== "number" || b === 0) return null;
3382
+ return a % b;
3383
+ };
3384
+ parser.functions.NOW = () => (/* @__PURE__ */ new Date()).toISOString();
3385
+ parser.functions.TODAY = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
3386
+ parser.functions.YEAR = (value) => {
3387
+ const date2 = parseDate(value);
3388
+ return date2 ? date2.getFullYear() : null;
3389
+ };
3390
+ parser.functions.MONTH = (value) => {
3391
+ const date2 = parseDate(value);
3392
+ return date2 ? date2.getMonth() + 1 : null;
3393
+ };
3394
+ parser.functions.DAY = (value) => {
3395
+ const date2 = parseDate(value);
3396
+ return date2 ? date2.getDate() : null;
3397
+ };
3398
+ parser.functions.DATE_DIFF = (date1, date2, unit = "days") => {
3399
+ const d1 = parseDate(date1);
3400
+ const d2 = parseDate(date2);
3401
+ if (d1 === null || d2 === null) return null;
3402
+ const diffMs = d1.getTime() - d2.getTime();
3403
+ const MS_PER_DAY = 1e3 * 60 * 60 * 24;
3404
+ const conversions = {
3405
+ years: MS_PER_DAY * 365,
3406
+ months: MS_PER_DAY * 30,
3407
+ weeks: MS_PER_DAY * 7,
3408
+ days: MS_PER_DAY,
3409
+ hours: 1e3 * 60 * 60,
3410
+ minutes: 1e3 * 60
3411
+ };
3412
+ const divisor = conversions[unit] ?? MS_PER_DAY;
3413
+ return diffMs / divisor;
3414
+ };
3415
+ return parser;
3416
+ }
3417
+ function parseDate(value) {
3418
+ if (!value) return null;
3419
+ if (value instanceof Date) return value;
3420
+ if (typeof value === "string" || typeof value === "number") {
3421
+ const date2 = new Date(value);
3422
+ return Number.isNaN(date2.getTime()) ? null : date2;
3098
3423
  }
3099
- };
3100
-
3101
- // src/runtime/template.ts
3102
- var pipes = {
3103
- /** Convert to uppercase */
3104
- UPPER: (v) => String(v).toUpperCase(),
3105
- /** Convert to lowercase */
3106
- LOWER: (v) => String(v).toLowerCase(),
3107
- /** Capitalize first letter of each word */
3108
- capitalize: (v) => String(v).replace(/\b\w/g, (c) => c.toUpperCase()),
3109
- /** Trim whitespace from both ends */
3110
- trim: (v) => String(v).trim()
3111
- };
3112
- function getValue(obj, path) {
3113
- return path.split(".").reduce((acc, key) => {
3114
- if (acc == null || typeof acc !== "object") return void 0;
3115
- return acc[key];
3116
- }, obj);
3424
+ return null;
3117
3425
  }
3118
- var DEFAULT_LABEL_FALLBACK = "(Untitled)";
3119
- function renderLabelExpression(template, values, fallback = DEFAULT_LABEL_FALLBACK) {
3120
- const result = template.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_, expr) => {
3121
- const parts = expr.split("|").map((s) => s.trim());
3122
- const path = parts[0];
3123
- let value = getValue(values, path);
3124
- if (value == null || value === "") return "";
3125
- for (let i = 1; i < parts.length; i++) {
3126
- const pipeName = parts[i].trim();
3127
- const fn = pipes[pipeName];
3128
- if (fn) {
3129
- value = fn(String(value));
3130
- }
3131
- }
3132
- return String(value);
3133
- }).trim();
3134
- return result || fallback;
3426
+ var formulaParser = createFormulaParser();
3427
+ function evaluateFormula(expression, values) {
3428
+ try {
3429
+ const parsed = formulaParser.parse(expression);
3430
+ return parsed.evaluate(values);
3431
+ } catch {
3432
+ return null;
3433
+ }
3135
3434
  }
3136
- function isLabelExpression(value) {
3137
- return /\{\{\s*\S+.*\}\}/.test(value);
3435
+ function evaluateFormulaWithResult(expression, values) {
3436
+ try {
3437
+ const parsed = formulaParser.parse(expression);
3438
+ const value = parsed.evaluate(values);
3439
+ return { value };
3440
+ } catch (error) {
3441
+ return {
3442
+ value: null,
3443
+ error: error instanceof Error ? error.message : "Unknown error"
3444
+ };
3445
+ }
3138
3446
  }
3139
- function extractAttributeNames(template) {
3140
- const names = [];
3141
- const regex = /\{\{\s*([^|}]+)/g;
3142
- let match;
3143
- while ((match = regex.exec(template)) !== null) {
3144
- const path = match[1].trim();
3145
- const rootName = path.split(".")[0];
3146
- if (rootName && !names.includes(rootName)) {
3147
- names.push(rootName);
3148
- }
3447
+ function formatFormulaResult(value, returnType, decimals) {
3448
+ if (value === null || value === void 0) {
3449
+ return null;
3450
+ }
3451
+ switch (returnType) {
3452
+ case "number":
3453
+ return formatNumberResult(value, decimals);
3454
+ case "boolean":
3455
+ return Boolean(value);
3456
+ case "date":
3457
+ return formatDateResult(value);
3458
+ case "text":
3459
+ return String(value);
3149
3460
  }
3150
- return names;
3151
3461
  }
3152
- function hasOptions(attr) {
3153
- return "options" in attr && Array.isArray(attr.options) && attr.options.length > 0;
3462
+ function formatNumberResult(value, decimals) {
3463
+ const num = typeof value === "number" ? value : Number(value);
3464
+ if (Number.isNaN(num)) return null;
3465
+ return decimals !== void 0 ? Number(num.toFixed(decimals)) : num;
3154
3466
  }
3155
- function enrichValuesWithSelectLabels(values, attributes) {
3156
- const enriched = { ...values };
3157
- for (const attr of attributes) {
3158
- const value = values[attr.name];
3159
- if (value == null) continue;
3160
- const isSelectLike = attr.type === "select" || attr.type === "status" || attr.type === "multiselect";
3161
- if (!(isSelectLike && hasOptions(attr))) continue;
3162
- if (attr.type === "multiselect" && Array.isArray(value) && value.length === 0) continue;
3163
- const formatted = formatAttributeValue(value, attr);
3164
- if (formatted && formatted !== EMPTY_VALUE_PLACEHOLDER) {
3165
- enriched[attr.name] = formatted;
3467
+ function formatDateResult(value) {
3468
+ if (value instanceof Date) return value.toISOString();
3469
+ if (typeof value === "string") return value;
3470
+ return null;
3471
+ }
3472
+ function evaluateFormulaAttribute(attr, values) {
3473
+ const raw = evaluateFormula(attr.expression, values);
3474
+ return formatFormulaResult(raw, attr.returnType, attr.decimals);
3475
+ }
3476
+ function validateFormulaExpression(expression) {
3477
+ try {
3478
+ formulaParser.parse(expression);
3479
+ return { valid: true };
3480
+ } catch (error) {
3481
+ return {
3482
+ valid: false,
3483
+ error: error instanceof Error ? error.message : "Invalid expression"
3484
+ };
3485
+ }
3486
+ }
3487
+ function extractFormulaVariables(expression) {
3488
+ try {
3489
+ const parsed = formulaParser.parse(expression);
3490
+ return parsed.variables();
3491
+ } catch {
3492
+ return [];
3493
+ }
3494
+ }
3495
+ var RELATION_REF_PATTERN = /\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b/g;
3496
+ function extractRelationReferences(expression) {
3497
+ const regex = new RegExp(RELATION_REF_PATTERN.source, "g");
3498
+ const matches = expression.matchAll(regex);
3499
+ return [...matches].map((m) => m[0]);
3500
+ }
3501
+ function extractRelationNames(expression) {
3502
+ const refs = extractRelationReferences(expression);
3503
+ const names = refs.map((ref) => ref.split(".")[0]);
3504
+ return [...new Set(names)];
3505
+ }
3506
+ function hasRelationReferences(expression) {
3507
+ const regex = new RegExp(RELATION_REF_PATTERN.source);
3508
+ return regex.test(expression);
3509
+ }
3510
+ function flattenRelationsForEval(resolvedRelations) {
3511
+ const flat = {};
3512
+ for (const [relationName, values] of Object.entries(resolvedRelations)) {
3513
+ for (const [key, value] of Object.entries(values)) {
3514
+ flat[`${relationName}.${key}`] = value;
3166
3515
  }
3167
3516
  }
3168
- return enriched;
3517
+ return flat;
3518
+ }
3519
+ async function evaluateFormulaWithRelations(expression, record, schema, resolver) {
3520
+ const relationNames = extractRelationNames(expression);
3521
+ if (relationNames.length === 0) {
3522
+ return evaluateFormula(expression, record.values);
3523
+ }
3524
+ const resolvedRelations = await resolver.resolveRelationValues(record, schema, relationNames);
3525
+ const flattenedRelations = flattenRelationsForEval(resolvedRelations);
3526
+ const allValues = {
3527
+ ...record.values,
3528
+ ...flattenedRelations
3529
+ };
3530
+ return evaluateFormula(expression, allValues);
3531
+ }
3532
+ async function evaluateFormulaAttributeWithRelations(attr, record, schema, resolver) {
3533
+ const raw = await evaluateFormulaWithRelations(attr.expression, record, schema, resolver);
3534
+ return formatFormulaResult(raw, attr.returnType, attr.decimals);
3169
3535
  }
3170
3536
 
3171
- // src/runtime/mock-adapter.ts
3172
- function createMockObjectsRepository(stores) {
3173
- return {
3174
- findById(id) {
3175
- return Promise.resolve(stores.objects.get(id) ?? null);
3176
- },
3177
- findByName(tenantId, name) {
3178
- for (const obj of stores.objects.values()) {
3179
- if (obj.tenantId === tenantId && obj.name === name) {
3180
- return Promise.resolve(obj);
3181
- }
3537
+ // src/runtime/formula/path-parser.ts
3538
+ var InvalidPathError = class extends Error {
3539
+ constructor(path, segment, reason) {
3540
+ super(`Invalid path "${path}" at "${segment}": ${reason}`);
3541
+ this.path = path;
3542
+ this.segment = segment;
3543
+ this.reason = reason;
3544
+ this.name = "InvalidPathError";
3545
+ }
3546
+ };
3547
+ var MaxDepthExceededError = class extends Error {
3548
+ constructor(path, maxDepth) {
3549
+ super(`Path "${path}" exceeds maximum depth of ${maxDepth}`);
3550
+ this.path = path;
3551
+ this.maxDepth = maxDepth;
3552
+ this.name = "MaxDepthExceededError";
3553
+ }
3554
+ };
3555
+ async function parsePath(path, startSchema, getSchema, maxDepth = 5) {
3556
+ const segments = path.split(".");
3557
+ if (segments.length === 0 || segments.length === 1 && segments[0] === "") {
3558
+ throw new InvalidPathError(path, path, "Path cannot be empty");
3559
+ }
3560
+ if (segments.length > maxDepth) {
3561
+ throw new MaxDepthExceededError(path, maxDepth);
3562
+ }
3563
+ const result = [];
3564
+ let currentSchema = startSchema;
3565
+ for (let i = 0; i < segments.length; i++) {
3566
+ const segmentName = segments[i];
3567
+ const isLastSegment = i === segments.length - 1;
3568
+ const attr = currentSchema.attributes.find((a) => a.name === segmentName);
3569
+ if (!attr) {
3570
+ throw new InvalidPathError(
3571
+ path,
3572
+ segmentName,
3573
+ `Attribute "${segmentName}" not found in object "${currentSchema.name}"`
3574
+ );
3575
+ }
3576
+ if (attr.type === "relation") {
3577
+ const relationAttr = attr;
3578
+ const targetObject = relationAttr.targets[0]?.object;
3579
+ if (!targetObject) {
3580
+ throw new InvalidPathError(path, segmentName, "Relation has no target object");
3182
3581
  }
3183
- return Promise.resolve(null);
3184
- },
3185
- findSystemByName(name) {
3186
- for (const obj of stores.objects.values()) {
3187
- if (obj.system && obj.name === name) {
3188
- return Promise.resolve(obj);
3582
+ result.push({
3583
+ name: segmentName,
3584
+ type: "relation",
3585
+ cardinality: relationAttr.cardinality,
3586
+ targetObject
3587
+ });
3588
+ if (!isLastSegment) {
3589
+ const nextSchema = await getSchema(targetObject);
3590
+ if (!nextSchema) {
3591
+ throw new InvalidPathError(
3592
+ path,
3593
+ segmentName,
3594
+ `Target object "${targetObject}" schema not found`
3595
+ );
3189
3596
  }
3597
+ currentSchema = nextSchema;
3190
3598
  }
3191
- return Promise.resolve(null);
3192
- },
3193
- create(data) {
3194
- const obj = {
3195
- id: generateId(),
3196
- tenantId: data.tenantId,
3197
- name: data.name,
3198
- label: data.label,
3199
- pluralLabel: data.pluralLabel,
3200
- description: data.description,
3201
- icon: data.icon,
3202
- labelExpression: data.labelExpression,
3203
- system: data.system ?? false,
3204
- metadata: data.metadata,
3205
- createdAt: /* @__PURE__ */ new Date(),
3206
- updatedAt: /* @__PURE__ */ new Date()
3207
- };
3208
- stores.objects.set(obj.id, obj);
3209
- return Promise.resolve(obj);
3210
- },
3211
- update(id, data) {
3212
- const existing = stores.objects.get(id);
3213
- if (!existing) {
3214
- return Promise.reject(new Error(`Object ${id} not found`));
3599
+ } else {
3600
+ if (!isLastSegment) {
3601
+ throw new InvalidPathError(
3602
+ path,
3603
+ segmentName,
3604
+ `"${segmentName}" is not a relation but has segments after it`
3605
+ );
3215
3606
  }
3216
- const updated = {
3217
- ...existing,
3218
- ...data,
3219
- updatedAt: /* @__PURE__ */ new Date()
3220
- };
3221
- stores.objects.set(id, updated);
3222
- return Promise.resolve(updated);
3223
- },
3224
- delete(id) {
3225
- stores.objects.delete(id);
3226
- return Promise.resolve();
3227
- },
3607
+ result.push({
3608
+ name: segmentName,
3609
+ type: "attribute"
3610
+ });
3611
+ }
3612
+ }
3613
+ return result;
3614
+ }
3615
+ async function validatePath(path, startSchema, getSchema, maxDepth = 5) {
3616
+ try {
3617
+ await parsePath(path, startSchema, getSchema, maxDepth);
3618
+ return true;
3619
+ } catch {
3620
+ return false;
3621
+ }
3622
+ }
3623
+ function pathHasManyCardinality(segments) {
3624
+ return segments.some((s) => s.type === "relation" && s.cardinality === "many");
3625
+ }
3626
+ function getPathDepth(segments) {
3627
+ return segments.filter((s) => s.type === "relation").length;
3628
+ }
3629
+ function getTargetAttributeName(path) {
3630
+ const parts = path.split(".");
3631
+ return parts[parts.length - 1];
3632
+ }
3633
+ function getRelationPath(path) {
3634
+ const parts = path.split(".");
3635
+ if (parts.length <= 1) return null;
3636
+ return parts.slice(0, -1).join(".");
3637
+ }
3638
+
3639
+ // src/runtime/formula/path-traversal.ts
3640
+ async function traversePath(record, path, startSchemaName, adapter, getSchema, options) {
3641
+ const maxDepth = options?.maxDepth ?? 5;
3642
+ const startSchema = await getSchema(startSchemaName);
3643
+ if (!startSchema) {
3644
+ return { values: [], recordCounts: [0] };
3645
+ }
3646
+ const segments = await parsePath(path, startSchema, getSchema, maxDepth);
3647
+ if (segments.length > maxDepth) {
3648
+ throw new MaxDepthExceededError(path, maxDepth);
3649
+ }
3650
+ return await executeTraversal([record], segments, adapter, 0);
3651
+ }
3652
+ async function executeTraversal(records, segments, adapter, depth) {
3653
+ const recordCounts = [records.length];
3654
+ if (depth >= segments.length || records.length === 0) {
3655
+ return { values: [], recordCounts };
3656
+ }
3657
+ const segment = segments[depth];
3658
+ const isLastSegment = depth === segments.length - 1;
3659
+ if (segment.type === "attribute") {
3660
+ const values = records.map((r) => r.values[segment.name]).filter((v) => v !== void 0);
3661
+ return { values, recordCounts };
3662
+ }
3663
+ const relatedIds = collectRelatedIds(records, segment.name);
3664
+ if (relatedIds.length === 0) {
3665
+ return { values: [], recordCounts };
3666
+ }
3667
+ const relatedRecords = await adapter.objectRecords.findByIds(relatedIds);
3668
+ recordCounts.push(relatedRecords.length);
3669
+ if (isLastSegment) {
3670
+ return { values: relatedIds, recordCounts };
3671
+ }
3672
+ const nestedResult = await executeTraversal(relatedRecords, segments, adapter, depth + 1);
3673
+ return {
3674
+ values: nestedResult.values,
3675
+ recordCounts: [...recordCounts, ...nestedResult.recordCounts.slice(1)]
3676
+ };
3677
+ }
3678
+ function collectRelatedIds(records, relationName) {
3679
+ const ids = [];
3680
+ for (const record of records) {
3681
+ const value = record.values[relationName];
3682
+ if (typeof value === "string" && value.length > 0) {
3683
+ ids.push(value);
3684
+ } else if (Array.isArray(value)) {
3685
+ for (const id of value) {
3686
+ if (typeof id === "string" && id.length > 0) {
3687
+ ids.push(id);
3688
+ }
3689
+ }
3690
+ }
3691
+ }
3692
+ return [...new Set(ids)];
3693
+ }
3694
+ async function resolveSingleValue(record, path, startSchemaName, adapter, getSchema) {
3695
+ const result = await traversePath(record, path, startSchemaName, adapter, getSchema);
3696
+ return result.values[0] ?? null;
3697
+ }
3698
+ async function resolveMultiplePaths(record, paths, startSchemaName, adapter, getSchema) {
3699
+ const results = /* @__PURE__ */ new Map();
3700
+ await Promise.all(
3701
+ paths.map(async (path) => {
3702
+ const result = await traversePath(record, path, startSchemaName, adapter, getSchema);
3703
+ results.set(path, result.values);
3704
+ })
3705
+ );
3706
+ return results;
3707
+ }
3708
+
3709
+ // src/runtime/hooks/hook.registry.ts
3710
+ var NoopHookRegistry = class {
3711
+ async execute() {
3712
+ }
3713
+ };
3714
+
3715
+ // src/runtime/services/rollup.service.ts
3716
+ var RollupService = class {
3717
+ constructor(adapter) {
3718
+ this.adapter = adapter;
3719
+ }
3720
+ /**
3721
+ * Calculate a rollup value for a record
3722
+ *
3723
+ * @param recordId - ID of the parent record
3724
+ * @param rollupAttr - Rollup attribute definition
3725
+ * @param schema - Schema of the parent object
3726
+ * @returns Computed rollup value
3727
+ *
3728
+ * @example
3729
+ * ```typescript
3730
+ * // Sum all order amounts for a company
3731
+ * const totalOrders = await rollupService.calculate(
3732
+ * "company-123",
3733
+ * {
3734
+ * type: "rollup",
3735
+ * name: "totalOrders",
3736
+ * relationAttribute: "orders",
3737
+ * targetAttribute: "amount",
3738
+ * function: "sum",
3739
+ * ...
3740
+ * },
3741
+ * companySchema
3742
+ * );
3743
+ * ```
3744
+ */
3745
+ async calculate(recordId, rollupAttr, schema, tenantId) {
3746
+ const relationAttr = schema.attributes.find(
3747
+ (a) => a.type === "relation" && a.name === rollupAttr.relationAttribute
3748
+ );
3749
+ if (!relationAttr) {
3750
+ return { value: null, recordCount: 0 };
3751
+ }
3752
+ const targetObjectName = relationAttr.targets[0]?.object;
3753
+ if (!targetObjectName) {
3754
+ return { value: null, recordCount: 0 };
3755
+ }
3756
+ const targetObject = await this.adapter.objects.findByName(tenantId, targetObjectName);
3757
+ if (!targetObject) {
3758
+ return { value: null, recordCount: 0 };
3759
+ }
3760
+ const relatedRecords = await this.adapter.objectRecords.findByRelation(
3761
+ tenantId,
3762
+ targetObject.id,
3763
+ rollupAttr.relationAttribute,
3764
+ recordId
3765
+ );
3766
+ if (relatedRecords.length === 0) {
3767
+ return { value: this.getEmptyValue(rollupAttr.function), recordCount: 0 };
3768
+ }
3769
+ const values = relatedRecords.map((r) => r.values[rollupAttr.targetAttribute]).filter((v) => v !== void 0);
3770
+ const aggregated = this.aggregate(values, rollupAttr.function);
3771
+ const result = typeof aggregated === "number" && rollupAttr.decimals !== void 0 ? Number(aggregated.toFixed(rollupAttr.decimals)) : aggregated;
3772
+ return { value: result, recordCount: relatedRecords.length };
3773
+ }
3774
+ /**
3775
+ * Calculate rollup values for multiple records (batched)
3776
+ *
3777
+ * More efficient than calling calculate() for each record individually.
3778
+ */
3779
+ async calculateForMany(recordIds, rollupAttr, schema, tenantId) {
3780
+ const results = /* @__PURE__ */ new Map();
3781
+ for (const id of recordIds) {
3782
+ results.set(id, { value: this.getEmptyValue(rollupAttr.function), recordCount: 0 });
3783
+ }
3784
+ await Promise.all(
3785
+ recordIds.map(async (id) => {
3786
+ const result = await this.calculate(id, rollupAttr, schema, tenantId);
3787
+ results.set(id, result);
3788
+ })
3789
+ );
3790
+ return results;
3791
+ }
3792
+ /**
3793
+ * Apply aggregation function to a set of values
3794
+ */
3795
+ aggregate(values, fn) {
3796
+ switch (fn) {
3797
+ case "sum":
3798
+ return this.sumNumbers(values);
3799
+ case "avg":
3800
+ return this.averageNumbers(values);
3801
+ case "min":
3802
+ return this.minNumbers(values);
3803
+ case "max":
3804
+ return this.maxNumbers(values);
3805
+ case "count":
3806
+ return values.length;
3807
+ case "countValues":
3808
+ return values.filter((v) => v != null && v !== "").length;
3809
+ case "countEmpty":
3810
+ return values.filter((v) => v == null || v === "").length;
3811
+ case "percentFilled": {
3812
+ if (values.length === 0) return 0;
3813
+ const filled = values.filter((v) => v != null && v !== "").length;
3814
+ return filled / values.length * 100;
3815
+ }
3816
+ case "percentEmpty": {
3817
+ if (values.length === 0) return 0;
3818
+ const empty = values.filter((v) => v == null || v === "").length;
3819
+ return empty / values.length * 100;
3820
+ }
3821
+ }
3822
+ }
3823
+ /**
3824
+ * Get the default empty value for a rollup function
3825
+ */
3826
+ getEmptyValue(fn) {
3827
+ switch (fn) {
3828
+ case "sum":
3829
+ case "count":
3830
+ case "countValues":
3831
+ case "countEmpty":
3832
+ case "percentEmpty":
3833
+ case "percentFilled":
3834
+ return 0;
3835
+ case "avg":
3836
+ case "min":
3837
+ case "max":
3838
+ return null;
3839
+ }
3840
+ }
3841
+ /**
3842
+ * Sum numeric values
3843
+ */
3844
+ sumNumbers(values) {
3845
+ return values.filter((v) => typeof v === "number" && !Number.isNaN(v)).reduce((sum, n) => sum + n, 0);
3846
+ }
3847
+ /**
3848
+ * Average numeric values
3849
+ */
3850
+ averageNumbers(values) {
3851
+ const nums = values.filter((v) => typeof v === "number" && !Number.isNaN(v));
3852
+ if (nums.length === 0) return null;
3853
+ return nums.reduce((sum, n) => sum + n, 0) / nums.length;
3854
+ }
3855
+ /**
3856
+ * Minimum numeric value
3857
+ */
3858
+ minNumbers(values) {
3859
+ const nums = values.filter((v) => typeof v === "number" && !Number.isNaN(v));
3860
+ if (nums.length === 0) return null;
3861
+ return Math.min(...nums);
3862
+ }
3863
+ /**
3864
+ * Maximum numeric value
3865
+ */
3866
+ maxNumbers(values) {
3867
+ const nums = values.filter((v) => typeof v === "number" && !Number.isNaN(v));
3868
+ if (nums.length === 0) return null;
3869
+ return Math.max(...nums);
3870
+ }
3871
+ /**
3872
+ * Recalculate all rollup attributes for a record and update it
3873
+ *
3874
+ * Called after related records change to keep rollups up-to-date.
3875
+ */
3876
+ async recalculateAndUpdate(record, schema, tenantId) {
3877
+ const rollupAttrs = schema.attributes.filter((a) => a.type === "rollup");
3878
+ if (rollupAttrs.length === 0) {
3879
+ return record;
3880
+ }
3881
+ const updates = {};
3882
+ for (const attr of rollupAttrs) {
3883
+ const result = await this.calculate(record.id, attr, schema, tenantId);
3884
+ updates[attr.name] = result.value;
3885
+ }
3886
+ return await this.adapter.objectRecords.update(record.id, updates);
3887
+ }
3888
+ /**
3889
+ * Find parent records that need rollup recalculation when a child record changes
3890
+ *
3891
+ * Used by hooks to determine which parent records to recalculate after
3892
+ * a child record is created, updated, or deleted.
3893
+ *
3894
+ * @param changedRecord - The record that was modified
3895
+ * @param changedSchema - Schema of the changed record's object
3896
+ * @returns Array of parent record IDs that need recalculation
3897
+ */
3898
+ async findAffectedParentRecords(changedRecord, changedSchema) {
3899
+ const affectedIds = [];
3900
+ const relationAttrs = changedSchema.attributes.filter(
3901
+ (a) => a.type === "relation"
3902
+ );
3903
+ for (const attr of relationAttrs) {
3904
+ const relatedId = changedRecord.values[attr.name];
3905
+ if (typeof relatedId === "string" && relatedId.length > 0) {
3906
+ affectedIds.push(relatedId);
3907
+ } else if (Array.isArray(relatedId)) {
3908
+ affectedIds.push(...relatedId.filter((id) => typeof id === "string"));
3909
+ }
3910
+ }
3911
+ return [...new Set(affectedIds)];
3912
+ }
3913
+ };
3914
+
3915
+ // src/runtime/hooks/rollup.hooks.ts
3916
+ function createRollupHooks(objectName, config) {
3917
+ const rollupService = new RollupService(config.adapter);
3918
+ async function recalculateParentRollups(ctx) {
3919
+ const schema = await config.getSchemaById(ctx.objectId);
3920
+ if (!schema) return;
3921
+ const affectedParentIds = await rollupService.findAffectedParentRecords(ctx.record, schema);
3922
+ if (affectedParentIds.length === 0) return;
3923
+ const parentRecords = await config.adapter.objectRecords.findByIds(affectedParentIds);
3924
+ for (const parentRecord of parentRecords) {
3925
+ const parentSchema = await config.getSchemaById(parentRecord.objectId);
3926
+ if (!parentSchema) continue;
3927
+ const hasRollups = parentSchema.attributes.some(
3928
+ (a) => a.type === "rollup"
3929
+ );
3930
+ if (hasRollups) {
3931
+ await rollupService.recalculateAndUpdate(parentRecord, parentSchema, config.tenantId);
3932
+ }
3933
+ }
3934
+ }
3935
+ return [
3936
+ {
3937
+ type: "afterCreate",
3938
+ objectName,
3939
+ handler: recalculateParentRollups,
3940
+ priority: 100
3941
+ // Run after other hooks
3942
+ },
3943
+ {
3944
+ type: "afterUpdate",
3945
+ objectName,
3946
+ handler: recalculateParentRollups,
3947
+ priority: 100
3948
+ },
3949
+ {
3950
+ type: "afterDelete",
3951
+ objectName,
3952
+ handler: recalculateParentRollups,
3953
+ priority: 100
3954
+ }
3955
+ ];
3956
+ }
3957
+ function registerAllRollupHooks(hookRegistry, schemas, config) {
3958
+ for (const schema of schemas) {
3959
+ const hasRelations = schema.attributes.some((a) => a.type === "relation");
3960
+ if (hasRelations && hookRegistry.register) {
3961
+ const hooks = createRollupHooks(schema.name, config);
3962
+ for (const hook of hooks) {
3963
+ hookRegistry.register(hook);
3964
+ }
3965
+ }
3966
+ }
3967
+ }
3968
+
3969
+ // src/runtime/template.ts
3970
+ var pipes = {
3971
+ /** Convert to uppercase */
3972
+ UPPER: (v) => String(v).toUpperCase(),
3973
+ /** Convert to lowercase */
3974
+ LOWER: (v) => String(v).toLowerCase(),
3975
+ /** Capitalize first letter of each word */
3976
+ capitalize: (v) => String(v).replace(/\b\w/g, (c) => c.toUpperCase()),
3977
+ /** Trim whitespace from both ends */
3978
+ trim: (v) => String(v).trim()
3979
+ };
3980
+ function getValue(obj, path) {
3981
+ return path.split(".").reduce((acc, key) => {
3982
+ if (acc == null || typeof acc !== "object") return void 0;
3983
+ return acc[key];
3984
+ }, obj);
3985
+ }
3986
+ var DEFAULT_LABEL_FALLBACK = "(Untitled)";
3987
+ function renderLabelExpression(template, values, fallback = DEFAULT_LABEL_FALLBACK) {
3988
+ const result = template.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_, expr) => {
3989
+ const parts = expr.split("|").map((s) => s.trim());
3990
+ const path = parts[0];
3991
+ let value = getValue(values, path);
3992
+ if (value == null || value === "") return "";
3993
+ for (let i = 1; i < parts.length; i++) {
3994
+ const pipeName = parts[i].trim();
3995
+ const fn = pipes[pipeName];
3996
+ if (fn) {
3997
+ value = fn(String(value));
3998
+ }
3999
+ }
4000
+ return String(value);
4001
+ }).trim();
4002
+ return result || fallback;
4003
+ }
4004
+ function isLabelExpression(value) {
4005
+ return /\{\{\s*\S+.*\}\}/.test(value);
4006
+ }
4007
+ function extractAttributeNames(template) {
4008
+ const names = [];
4009
+ const regex = /\{\{\s*([^|}]+)/g;
4010
+ let match;
4011
+ while ((match = regex.exec(template)) !== null) {
4012
+ const path = match[1].trim();
4013
+ const rootName = path.split(".")[0];
4014
+ if (rootName && !names.includes(rootName)) {
4015
+ names.push(rootName);
4016
+ }
4017
+ }
4018
+ return names;
4019
+ }
4020
+ function hasOptions(attr) {
4021
+ return "options" in attr && Array.isArray(attr.options) && attr.options.length > 0;
4022
+ }
4023
+ function enrichValuesWithSelectLabels(values, attributes) {
4024
+ const enriched = { ...values };
4025
+ for (const attr of attributes) {
4026
+ const value = values[attr.name];
4027
+ if (value == null) continue;
4028
+ const isSelectLike = attr.type === "select" || attr.type === "status" || attr.type === "multiselect";
4029
+ if (!(isSelectLike && hasOptions(attr))) continue;
4030
+ if (attr.type === "multiselect" && Array.isArray(value) && value.length === 0) continue;
4031
+ const formatted = formatAttributeValue(value, attr);
4032
+ if (formatted && formatted !== EMPTY_VALUE_PLACEHOLDER) {
4033
+ enriched[attr.name] = formatted;
4034
+ }
4035
+ }
4036
+ return enriched;
4037
+ }
4038
+
4039
+ // src/runtime/mock-adapter.ts
4040
+ function createMockObjectsRepository(stores) {
4041
+ return {
4042
+ findById(id) {
4043
+ return Promise.resolve(stores.objects.get(id) ?? null);
4044
+ },
4045
+ findByName(tenantId, name) {
4046
+ for (const obj of stores.objects.values()) {
4047
+ if (obj.tenantId === tenantId && obj.name === name) {
4048
+ return Promise.resolve(obj);
4049
+ }
4050
+ }
4051
+ return Promise.resolve(null);
4052
+ },
4053
+ findSystemByName(name) {
4054
+ for (const obj of stores.objects.values()) {
4055
+ if (obj.system && obj.name === name) {
4056
+ return Promise.resolve(obj);
4057
+ }
4058
+ }
4059
+ return Promise.resolve(null);
4060
+ },
4061
+ create(data) {
4062
+ const obj = {
4063
+ id: generateId(),
4064
+ tenantId: data.tenantId,
4065
+ name: data.name,
4066
+ label: data.label,
4067
+ pluralLabel: data.pluralLabel,
4068
+ description: data.description,
4069
+ icon: data.icon,
4070
+ labelExpression: data.labelExpression,
4071
+ system: data.system ?? false,
4072
+ metadata: data.metadata,
4073
+ createdAt: /* @__PURE__ */ new Date(),
4074
+ updatedAt: /* @__PURE__ */ new Date()
4075
+ };
4076
+ stores.objects.set(obj.id, obj);
4077
+ return Promise.resolve(obj);
4078
+ },
4079
+ update(id, data) {
4080
+ const existing = stores.objects.get(id);
4081
+ if (!existing) {
4082
+ return Promise.reject(new Error(`Object ${id} not found`));
4083
+ }
4084
+ const updated = {
4085
+ ...existing,
4086
+ ...data,
4087
+ updatedAt: /* @__PURE__ */ new Date()
4088
+ };
4089
+ stores.objects.set(id, updated);
4090
+ return Promise.resolve(updated);
4091
+ },
4092
+ delete(id) {
4093
+ stores.objects.delete(id);
4094
+ return Promise.resolve();
4095
+ },
3228
4096
  list(tenantId) {
3229
4097
  const results = Array.from(stores.objects.values()).filter(
3230
4098
  (obj) => obj.tenantId === tenantId
@@ -3755,6 +4623,26 @@ function createMockObjectRecordsRepository(stores) {
3755
4623
  }
3756
4624
  }
3757
4625
  return Promise.resolve(updated);
4626
+ },
4627
+ findByRelation(objectName, relationAttributeName, targetRecordId) {
4628
+ const obj = Array.from(stores.objects.values()).find((o) => o.name === objectName);
4629
+ if (!obj) return Promise.resolve([]);
4630
+ const results = [];
4631
+ for (const record of stores.objectRecords.values()) {
4632
+ if (record.objectId !== obj.id) continue;
4633
+ const value = record.values[relationAttributeName];
4634
+ let matches = false;
4635
+ if (typeof value === "string" && value === targetRecordId) {
4636
+ matches = true;
4637
+ } else if (Array.isArray(value) && value.includes(targetRecordId)) {
4638
+ matches = true;
4639
+ }
4640
+ if (matches) {
4641
+ const { tenantId: _, ...publicRecord } = record;
4642
+ results.push(publicRecord);
4643
+ }
4644
+ }
4645
+ return Promise.resolve(results);
3758
4646
  }
3759
4647
  };
3760
4648
  }
@@ -4174,6 +5062,24 @@ var AuditService = class {
4174
5062
  };
4175
5063
  await this.log(entry);
4176
5064
  }
5065
+ /**
5066
+ * Log a file action (upload, update, delete)
5067
+ */
5068
+ async logFileAction(params) {
5069
+ const entry = {
5070
+ tenantId: this.tenantId,
5071
+ actorId: params.actorId,
5072
+ actorEmail: params.actorEmail,
5073
+ actorType: "user",
5074
+ action: params.action,
5075
+ resourceType: "file",
5076
+ resourceId: params.fileId,
5077
+ resourceLabel: params.fileName,
5078
+ changes: params.changes ? this.redactSensitiveFields(params.changes) : void 0,
5079
+ metadata: params.metadata
5080
+ };
5081
+ await this.log(entry);
5082
+ }
4177
5083
  /**
4178
5084
  * Log a system action (no actor)
4179
5085
  */
@@ -4323,21 +5229,105 @@ var AuditService = class {
4323
5229
 
4324
5230
  // src/runtime/services/file.service.ts
4325
5231
  var FileService = class {
4326
- constructor(adapter, tenantId) {
5232
+ constructor(adapter, tenantId, options) {
4327
5233
  this.adapter = adapter;
4328
5234
  this.tenantId = tenantId;
5235
+ this.auditService = options?.auditService ?? (adapter.audit ? new AuditService(adapter, tenantId) : void 0);
5236
+ this.userId = options?.userId;
5237
+ this.userEmail = options?.userEmail;
4329
5238
  }
5239
+ // ============================================================================
5240
+ // UPLOAD (requires StorageAdapter)
5241
+ // ============================================================================
4330
5242
  /**
4331
- * Create a new file record (after upload to storage)
5243
+ * Upload a file to storage and create metadata record.
4332
5244
  *
4333
- * @param data - File metadata
5245
+ * This method orchestrates:
5246
+ * 1. Upload to storage (via StorageAdapter)
5247
+ * 2. Create file metadata in database
5248
+ * 3. Audit log the operation
5249
+ *
5250
+ * Requires `adapter.storage` to be configured.
5251
+ *
5252
+ * @param input - File content and metadata
4334
5253
  * @returns Created file record
5254
+ * @throws Error if StorageAdapter is not configured
4335
5255
  *
4336
5256
  * @example
4337
5257
  * ```typescript
4338
- * const service = new FileService(adapter, "tenant-123");
5258
+ * const file = await service.uploadFile({
5259
+ * content: fileBuffer,
5260
+ * fileName: "document.pdf",
5261
+ * mimeType: "application/pdf",
5262
+ * size: 12345,
5263
+ * uploadedBy: "user-123",
5264
+ * visibility: "private",
5265
+ * folderPath: "/documents",
5266
+ * tags: ["contract", "2025"],
5267
+ * });
5268
+ * ```
5269
+ */
5270
+ async uploadFile(input) {
5271
+ if (!this.adapter.storage) {
5272
+ throw new Error(
5273
+ "StorageAdapter is not configured. Provide adapter.storage to use uploadFile()."
5274
+ );
5275
+ }
5276
+ const uploadResult = await this.adapter.storage.upload({
5277
+ content: input.content,
5278
+ fileName: input.fileName,
5279
+ mimeType: input.mimeType,
5280
+ size: input.size,
5281
+ tenantId: this.tenantId,
5282
+ folderPath: input.folderPath
5283
+ });
5284
+ const file2 = await this.adapter.files.create({
5285
+ tenantId: this.tenantId,
5286
+ name: input.fileName,
5287
+ originalName: input.fileName,
5288
+ mimeType: input.mimeType,
5289
+ size: input.size,
5290
+ storageProvider: uploadResult.storageProvider,
5291
+ storagePath: uploadResult.storagePath,
5292
+ storageBucket: uploadResult.storageBucket,
5293
+ url: uploadResult.url,
5294
+ uploadedBy: input.uploadedBy,
5295
+ folderPath: input.folderPath,
5296
+ tags: input.tags,
5297
+ visibility: input.visibility ?? "private",
5298
+ allowedUsers: input.allowedUsers
5299
+ });
5300
+ if (this.auditService && this.userId) {
5301
+ await this.auditService.logFileAction({
5302
+ action: "file.uploaded",
5303
+ actorId: this.userId,
5304
+ actorEmail: this.userEmail,
5305
+ fileId: file2.id,
5306
+ fileName: file2.name,
5307
+ metadata: {
5308
+ mimeType: file2.mimeType,
5309
+ size: file2.size,
5310
+ visibility: file2.visibility
5311
+ }
5312
+ });
5313
+ }
5314
+ return file2;
5315
+ }
5316
+ // ============================================================================
5317
+ // CREATE (metadata only, for external storage)
5318
+ // ============================================================================
5319
+ /**
5320
+ * Create a new file record (after upload to storage).
5321
+ *
5322
+ * Use this method when handling storage externally (e.g., with Multer + S3).
5323
+ * For integrated upload, use `uploadFile()` instead.
4339
5324
  *
4340
- * // After uploading to S3
5325
+ * @param data - File metadata
5326
+ * @returns Created file record
5327
+ *
5328
+ * @example
5329
+ * ```typescript
5330
+ * // After uploading to S3 with Multer
4341
5331
  * const file = await service.createFile({
4342
5332
  * tenantId: "tenant-123",
4343
5333
  * name: "contract-2025.pdf",
@@ -4359,8 +5349,26 @@ var FileService = class {
4359
5349
  `Tenant mismatch: service initialized with "${this.tenantId}" but data has "${data.tenantId}"`
4360
5350
  );
4361
5351
  }
4362
- return await this.adapter.files.create(data);
5352
+ const file2 = await this.adapter.files.create(data);
5353
+ if (this.auditService && this.userId) {
5354
+ await this.auditService.logFileAction({
5355
+ action: "file.uploaded",
5356
+ actorId: this.userId,
5357
+ actorEmail: this.userEmail,
5358
+ fileId: file2.id,
5359
+ fileName: file2.name,
5360
+ metadata: {
5361
+ mimeType: file2.mimeType,
5362
+ size: file2.size,
5363
+ visibility: file2.visibility
5364
+ }
5365
+ });
5366
+ }
5367
+ return file2;
4363
5368
  }
5369
+ // ============================================================================
5370
+ // READ
5371
+ // ============================================================================
4364
5372
  /**
4365
5373
  * Get file by ID
4366
5374
  */
@@ -4381,6 +5389,9 @@ var FileService = class {
4381
5389
  }
4382
5390
  return file2;
4383
5391
  }
5392
+ // ============================================================================
5393
+ // UPDATE
5394
+ // ============================================================================
4384
5395
  /**
4385
5396
  * Update file metadata
4386
5397
  *
@@ -4389,18 +5400,44 @@ var FileService = class {
4389
5400
  * @returns Updated file
4390
5401
  */
4391
5402
  async updateFile(fileId, data) {
4392
- await this.getFileOrThrow(fileId);
4393
- return await this.adapter.files.update(fileId, data);
5403
+ const existingFile = await this.getFileOrThrow(fileId);
5404
+ const updatedFile = await this.adapter.files.update(fileId, data);
5405
+ if (this.auditService && this.userId) {
5406
+ const changes = [];
5407
+ for (const key of Object.keys(data)) {
5408
+ if (data[key] !== void 0 && data[key] !== existingFile[key]) {
5409
+ changes.push({
5410
+ field: key,
5411
+ oldValue: existingFile[key],
5412
+ newValue: data[key]
5413
+ });
5414
+ }
5415
+ }
5416
+ if (changes.length > 0) {
5417
+ await this.auditService.logFileAction({
5418
+ action: "file.updated",
5419
+ actorId: this.userId,
5420
+ actorEmail: this.userEmail,
5421
+ fileId,
5422
+ fileName: updatedFile.name,
5423
+ changes
5424
+ });
5425
+ }
5426
+ }
5427
+ return updatedFile;
4394
5428
  }
5429
+ // ============================================================================
5430
+ // DELETE
5431
+ // ============================================================================
4395
5432
  /**
4396
- * Delete file (soft delete)
5433
+ * Delete file (soft delete by default)
4397
5434
  *
4398
5435
  * @param fileId - File UUID
4399
5436
  * @param options - Delete options
4400
5437
  */
4401
5438
  async deleteFile(fileId, options) {
5439
+ const file2 = await this.getFileOrThrow(fileId);
4402
5440
  if (options?.checkOwnership && options.userId) {
4403
- const file2 = await this.getFileOrThrow(fileId);
4404
5441
  if (file2.uploadedBy !== options.userId) {
4405
5442
  throw new Error("You can only delete files you uploaded");
4406
5443
  }
@@ -4410,7 +5447,84 @@ var FileService = class {
4410
5447
  } else {
4411
5448
  await this.adapter.files.delete(fileId);
4412
5449
  }
5450
+ if (this.auditService && this.userId) {
5451
+ await this.auditService.logFileAction({
5452
+ action: "file.deleted",
5453
+ actorId: this.userId,
5454
+ actorEmail: this.userEmail,
5455
+ fileId,
5456
+ fileName: file2.name
5457
+ });
5458
+ }
4413
5459
  }
5460
+ /**
5461
+ * Delete file from both storage and database.
5462
+ *
5463
+ * Requires `adapter.storage` to be configured.
5464
+ *
5465
+ * @param fileId - File UUID
5466
+ * @param options - Delete options
5467
+ * @throws Error if StorageAdapter is not configured
5468
+ */
5469
+ async deleteFileWithStorage(fileId, options) {
5470
+ if (!this.adapter.storage) {
5471
+ throw new Error(
5472
+ "StorageAdapter is not configured. Provide adapter.storage to use deleteFileWithStorage()."
5473
+ );
5474
+ }
5475
+ const file2 = await this.getFileOrThrow(fileId);
5476
+ await this.adapter.storage.delete(file2.storagePath);
5477
+ if (options?.hard) {
5478
+ await this.adapter.files.hardDelete(fileId);
5479
+ } else {
5480
+ await this.adapter.files.delete(fileId);
5481
+ }
5482
+ if (this.auditService && this.userId) {
5483
+ await this.auditService.logFileAction({
5484
+ action: "file.deleted",
5485
+ actorId: this.userId,
5486
+ actorEmail: this.userEmail,
5487
+ fileId,
5488
+ fileName: file2.name,
5489
+ metadata: { deletedFromStorage: true }
5490
+ });
5491
+ }
5492
+ }
5493
+ /**
5494
+ * Delete multiple files
5495
+ *
5496
+ * @param fileIds - Array of file UUIDs
5497
+ * @param options - Delete options
5498
+ */
5499
+ async bulkDelete(fileIds, options) {
5500
+ for (const fileId of fileIds) {
5501
+ const file2 = await this.getFile(fileId);
5502
+ if (!file2) {
5503
+ continue;
5504
+ }
5505
+ if (options?.deleteFromStorage && this.adapter.storage) {
5506
+ await this.adapter.storage.delete(file2.storagePath);
5507
+ }
5508
+ if (options?.hard) {
5509
+ await this.adapter.files.hardDelete(fileId);
5510
+ } else {
5511
+ await this.adapter.files.delete(fileId);
5512
+ }
5513
+ if (this.auditService && this.userId) {
5514
+ await this.auditService.logFileAction({
5515
+ action: "file.deleted",
5516
+ actorId: this.userId,
5517
+ actorEmail: this.userEmail,
5518
+ fileId,
5519
+ fileName: file2.name,
5520
+ metadata: { deletedFromStorage: options?.deleteFromStorage ?? false }
5521
+ });
5522
+ }
5523
+ }
5524
+ }
5525
+ // ============================================================================
5526
+ // LIST
5527
+ // ============================================================================
4414
5528
  /**
4415
5529
  * List files for the tenant
4416
5530
  */
@@ -4418,16 +5532,84 @@ var FileService = class {
4418
5532
  return await this.adapter.files.list(this.tenantId, options);
4419
5533
  }
4420
5534
  /**
4421
- * List files by folder
5535
+ * List files by folder
5536
+ */
5537
+ async listFilesByFolder(folderPath) {
5538
+ return await this.adapter.files.findByFolder(this.tenantId, folderPath);
5539
+ }
5540
+ /**
5541
+ * List files uploaded by a specific user
5542
+ */
5543
+ async listFilesByUploader(uploadedBy) {
5544
+ return await this.adapter.files.findByUploader(uploadedBy);
5545
+ }
5546
+ // ============================================================================
5547
+ // SIGNED URL
5548
+ // ============================================================================
5549
+ /**
5550
+ * Get a signed URL for private file access.
5551
+ *
5552
+ * Checks access permissions before generating URL.
5553
+ * Requires `adapter.storage` to be configured.
5554
+ *
5555
+ * @param fileId - File UUID
5556
+ * @param userId - User requesting access
5557
+ * @param options - Signed URL options
5558
+ * @returns Signed URL
5559
+ * @throws Error if user doesn't have access or StorageAdapter is not configured
5560
+ *
5561
+ * @example
5562
+ * ```typescript
5563
+ * const url = await service.getSignedUrl("file-123", "user-456", {
5564
+ * expiresIn: 3600, // 1 hour
5565
+ * });
5566
+ * ```
5567
+ */
5568
+ async getSignedUrl(fileId, userId, options) {
5569
+ const file2 = await this.getFileOrThrow(fileId);
5570
+ const hasAccess = await this.checkAccess(fileId, userId);
5571
+ if (!hasAccess) {
5572
+ throw new Error("Access denied to this file");
5573
+ }
5574
+ if (file2.visibility === "public") {
5575
+ return file2.url;
5576
+ }
5577
+ if (!this.adapter.storage) {
5578
+ return file2.url;
5579
+ }
5580
+ return await this.adapter.storage.getSignedUrl(file2.storagePath, options);
5581
+ }
5582
+ // ============================================================================
5583
+ // ACCESS CONTROL
5584
+ // ============================================================================
5585
+ /**
5586
+ * Check if user has access to a file
5587
+ *
5588
+ * @param fileId - File UUID
5589
+ * @param userId - User ID to check
5590
+ * @returns true if user can access the file
4422
5591
  */
4423
- async listFilesByFolder(folderPath) {
4424
- return await this.adapter.files.findByFolder(this.tenantId, folderPath);
5592
+ async checkAccess(fileId, userId) {
5593
+ const file2 = await this.getFile(fileId);
5594
+ if (!file2) {
5595
+ return false;
5596
+ }
5597
+ if (file2.visibility === "public") {
5598
+ return true;
5599
+ }
5600
+ if (file2.uploadedBy === userId) {
5601
+ return true;
5602
+ }
5603
+ if (file2.visibility === "restricted") {
5604
+ return file2.allowedUsers?.includes(userId) ?? false;
5605
+ }
5606
+ return false;
4425
5607
  }
4426
5608
  /**
4427
- * List files uploaded by a specific user
5609
+ * @deprecated Use checkAccess() instead
4428
5610
  */
4429
- async listFilesByUploader(uploadedBy) {
4430
- return await this.adapter.files.findByUploader(uploadedBy);
5611
+ canAccess(fileId, userId) {
5612
+ return this.checkAccess(fileId, userId);
4431
5613
  }
4432
5614
  /**
4433
5615
  * Change file visibility
@@ -4472,29 +5654,9 @@ var FileService = class {
4472
5654
  allowedUsers: newAllowed
4473
5655
  });
4474
5656
  }
4475
- /**
4476
- * Check if user has access to a file
4477
- *
4478
- * @param fileId - File UUID
4479
- * @param userId - User ID to check
4480
- * @returns true if user can access the file
4481
- */
4482
- async canAccess(fileId, userId) {
4483
- const file2 = await this.getFile(fileId);
4484
- if (!file2) {
4485
- return false;
4486
- }
4487
- if (file2.visibility === "public") {
4488
- return true;
4489
- }
4490
- if (file2.uploadedBy === userId) {
4491
- return true;
4492
- }
4493
- if (file2.visibility === "restricted") {
4494
- return file2.allowedUsers?.includes(userId) ?? false;
4495
- }
4496
- return false;
4497
- }
5657
+ // ============================================================================
5658
+ // ORGANIZATION
5659
+ // ============================================================================
4498
5660
  /**
4499
5661
  * Move file to different folder
4500
5662
  */
@@ -6330,6 +7492,123 @@ var RelationService = class {
6330
7492
  }
6331
7493
  };
6332
7494
 
7495
+ // src/runtime/services/user.service.ts
7496
+ var UserService = class {
7497
+ constructor(adapter, tenantId) {
7498
+ this.adapter = adapter;
7499
+ this.tenantId = tenantId;
7500
+ }
7501
+ /**
7502
+ * Validate all user attributes in the data
7503
+ *
7504
+ * @param schema - Object schema containing attribute definitions
7505
+ * @param data - Record data to validate
7506
+ * @returns Validation result with errors if any
7507
+ *
7508
+ * @example
7509
+ * ```typescript
7510
+ * const result = await userService.validateUsers(schema, {
7511
+ * assignee: "user-123",
7512
+ * watchers: ["user-456", "user-789"]
7513
+ * });
7514
+ *
7515
+ * if (!result.valid) {
7516
+ * console.log(result.errors);
7517
+ * // [{ attribute: "assignee", message: "User not found", invalidIds: ["user-123"] }]
7518
+ * }
7519
+ * ```
7520
+ */
7521
+ async validateUsers(schema, data) {
7522
+ const errors = [];
7523
+ const userAttrs = schema.attributes.filter(
7524
+ (attr) => attr.type === "user"
7525
+ );
7526
+ for (const attr of userAttrs) {
7527
+ const value = data[attr.name];
7528
+ if (value === void 0 || value === null) {
7529
+ continue;
7530
+ }
7531
+ const attrErrors = await this.validateUserAttribute(attr, value);
7532
+ errors.push(...attrErrors);
7533
+ }
7534
+ return {
7535
+ valid: errors.length === 0,
7536
+ errors
7537
+ };
7538
+ }
7539
+ /**
7540
+ * Validate a single user attribute value
7541
+ */
7542
+ async validateUserAttribute(attr, value) {
7543
+ const errors = [];
7544
+ const ids = this.extractIds(attr, value);
7545
+ if (ids.length === 0) {
7546
+ return errors;
7547
+ }
7548
+ const invalidIds = [];
7549
+ const roleErrors = [];
7550
+ for (const id of ids) {
7551
+ const user2 = await this.adapter.userProfiles.findById(id);
7552
+ if (!user2) {
7553
+ invalidIds.push(id);
7554
+ continue;
7555
+ }
7556
+ if (user2.tenantId !== this.tenantId) {
7557
+ invalidIds.push(id);
7558
+ continue;
7559
+ }
7560
+ if (attr.allowedRoles && attr.allowedRoles.length > 0) {
7561
+ if (!attr.allowedRoles.includes(user2.role)) {
7562
+ roleErrors.push(id);
7563
+ }
7564
+ }
7565
+ }
7566
+ if (invalidIds.length > 0) {
7567
+ errors.push({
7568
+ attribute: attr.name,
7569
+ message: `Invalid or non-existent users for ${attr.label}`,
7570
+ invalidIds
7571
+ });
7572
+ }
7573
+ if (roleErrors.length > 0) {
7574
+ errors.push({
7575
+ attribute: attr.name,
7576
+ message: `Users do not have required role for ${attr.label}. Allowed roles: ${attr.allowedRoles?.join(", ")}`,
7577
+ invalidIds: roleErrors
7578
+ });
7579
+ }
7580
+ return errors;
7581
+ }
7582
+ /**
7583
+ * Extract IDs from user value based on multiple flag
7584
+ */
7585
+ extractIds(attr, value) {
7586
+ if (attr.multiple) {
7587
+ if (!Array.isArray(value)) {
7588
+ return [];
7589
+ }
7590
+ return value.filter((v) => typeof v === "string" && v !== "");
7591
+ }
7592
+ if (typeof value === "string" && value !== "") {
7593
+ return [value];
7594
+ }
7595
+ return [];
7596
+ }
7597
+ /**
7598
+ * Validate users and throw if invalid
7599
+ */
7600
+ async validateUsersOrThrow(schema, data) {
7601
+ const result = await this.validateUsers(schema, data);
7602
+ if (!result.valid) {
7603
+ const errors = result.errors.map((e) => ({
7604
+ path: [e.attribute],
7605
+ message: e.message
7606
+ }));
7607
+ throw new ValidationError("User validation failed", errors);
7608
+ }
7609
+ }
7610
+ };
7611
+
6333
7612
  // src/runtime/services/record.service.ts
6334
7613
  var RecordService = class {
6335
7614
  constructor(adapter, tenantId, options) {
@@ -6337,6 +7616,7 @@ var RecordService = class {
6337
7616
  this.tenantId = tenantId;
6338
7617
  this.schemaService = new ObjectSchemaService(adapter, registry);
6339
7618
  this.relationService = new RelationService(adapter, registry);
7619
+ this.userService = new UserService(adapter, tenantId);
6340
7620
  this.hookRegistry = options?.hookRegistry ?? new NoopHookRegistry();
6341
7621
  this.permissionService = options?.permissionService;
6342
7622
  this.auditService = options?.auditService ?? (adapter.audit ? new AuditService(adapter, tenantId) : void 0);
@@ -6477,6 +7757,9 @@ var RecordService = class {
6477
7757
  if (!options?.skipRelationValidation) {
6478
7758
  await this.relationService.validateRelationsOrThrow(schema, data);
6479
7759
  }
7760
+ if (!options?.skipUserValidation) {
7761
+ await this.userService.validateUsersOrThrow(schema, data);
7762
+ }
6480
7763
  }
6481
7764
  const completionStatus = computeRecordStatus(schema, data);
6482
7765
  const label = await this.computeLabel(schema, data);
@@ -6521,13 +7804,17 @@ var RecordService = class {
6521
7804
  if (!record) {
6522
7805
  return null;
6523
7806
  }
7807
+ const schema = await this.schemaService.getObjectSchema(record.objectId);
7808
+ let enrichedRecord = record;
7809
+ if (!options?.skipFormulas) {
7810
+ enrichedRecord = this.enrichWithFormulas(record, schema);
7811
+ }
6524
7812
  if (options?.includeSchema) {
6525
- const schema = await this.schemaService.getObjectSchema(record.objectId);
6526
- const recordWithSchema = record;
7813
+ const recordWithSchema = enrichedRecord;
6527
7814
  recordWithSchema.schema = schema;
6528
7815
  return recordWithSchema;
6529
7816
  }
6530
- return record;
7817
+ return enrichedRecord;
6531
7818
  }
6532
7819
  /**
6533
7820
  * Get a record by ID or throw if not found
@@ -6588,6 +7875,9 @@ var RecordService = class {
6588
7875
  if (!options?.skipRelationValidation) {
6589
7876
  await this.relationService.validateRelationsOrThrow(schema, data);
6590
7877
  }
7878
+ if (!options?.skipUserValidation) {
7879
+ await this.userService.validateUsersOrThrow(schema, data);
7880
+ }
6591
7881
  }
6592
7882
  const completionStatus = computeRecordStatus(schema, mergedData);
6593
7883
  const label = await this.computeLabel(schema, mergedData);
@@ -6819,6 +8109,46 @@ var RecordService = class {
6819
8109
  timestamp: /* @__PURE__ */ new Date()
6820
8110
  };
6821
8111
  }
8112
+ /**
8113
+ * Enrich a record with computed formula values
8114
+ *
8115
+ * Formula attributes are calculated at read-time from the record's values.
8116
+ * This method adds the computed values to the record's values object.
8117
+ *
8118
+ * @param record - The record to enrich
8119
+ * @param schema - The object schema containing attribute definitions
8120
+ * @returns Record with formula values computed
8121
+ * @internal
8122
+ */
8123
+ enrichWithFormulas(record, schema) {
8124
+ const formulaAttrs = schema.attributes.filter(
8125
+ (a) => a.type === "formula"
8126
+ );
8127
+ if (formulaAttrs.length === 0) {
8128
+ return record;
8129
+ }
8130
+ const enrichedValues = { ...record.values };
8131
+ for (const attr of formulaAttrs) {
8132
+ enrichedValues[attr.name] = evaluateFormulaAttribute(attr, record.values);
8133
+ }
8134
+ return {
8135
+ ...record,
8136
+ values: enrichedValues
8137
+ };
8138
+ }
8139
+ /**
8140
+ * Enrich multiple records with computed formula values
8141
+ * @internal
8142
+ */
8143
+ enrichRecordsWithFormulas(records, schema) {
8144
+ const formulaAttrs = schema.attributes.filter(
8145
+ (a) => a.type === "formula"
8146
+ );
8147
+ if (formulaAttrs.length === 0) {
8148
+ return records;
8149
+ }
8150
+ return records.map((record) => this.enrichWithFormulas(record, schema));
8151
+ }
6822
8152
  /**
6823
8153
  * Permanently delete a record (hard delete)
6824
8154
  *
@@ -6847,11 +8177,18 @@ var RecordService = class {
6847
8177
  * @returns Records and total count
6848
8178
  */
6849
8179
  async listRecords(objectId, options) {
8180
+ const schema = await this.schemaService.getObjectSchema(objectId);
6850
8181
  if (this.permissionService && this.userId) {
6851
- const schema = await this.schemaService.getObjectSchema(objectId);
6852
8182
  await this.checkPermission(schema.name, "read");
6853
8183
  }
6854
- return await this.adapter.objectRecords.list(this.tenantId, objectId, options);
8184
+ const result = await this.adapter.objectRecords.list(this.tenantId, objectId, options);
8185
+ if (!options?.skipFormulas) {
8186
+ return {
8187
+ records: this.enrichRecordsWithFormulas(result.records, schema),
8188
+ total: result.total
8189
+ };
8190
+ }
8191
+ return result;
6855
8192
  }
6856
8193
  /**
6857
8194
  * Search records using full-text search
@@ -6862,11 +8199,18 @@ var RecordService = class {
6862
8199
  * @returns Matching records and total count
6863
8200
  */
6864
8201
  async searchRecords(objectId, query, options) {
8202
+ const schema = await this.schemaService.getObjectSchema(objectId);
6865
8203
  if (this.permissionService && this.userId) {
6866
- const schema = await this.schemaService.getObjectSchema(objectId);
6867
8204
  await this.checkPermission(schema.name, "read");
6868
8205
  }
6869
- return await this.adapter.objectRecords.search(this.tenantId, objectId, query, options);
8206
+ const result = await this.adapter.objectRecords.search(this.tenantId, objectId, query, options);
8207
+ if (!options?.skipFormulas) {
8208
+ return {
8209
+ records: this.enrichRecordsWithFormulas(result.records, schema),
8210
+ total: result.total
8211
+ };
8212
+ }
8213
+ return result;
6870
8214
  }
6871
8215
  /**
6872
8216
  * Validate data against object schema without saving
@@ -6911,6 +8255,231 @@ var RecordService = class {
6911
8255
  }
6912
8256
  };
6913
8257
 
8258
+ // src/runtime/services/relation-resolver.service.ts
8259
+ var RelationResolverService = class {
8260
+ constructor(adapter) {
8261
+ this.adapter = adapter;
8262
+ }
8263
+ /**
8264
+ * Resolve values from related records for formula evaluation
8265
+ *
8266
+ * Phase 2: Supports 1 level of relation traversal only
8267
+ *
8268
+ * @param record - The source record
8269
+ * @param schema - Schema of the source object
8270
+ * @param relationNames - Names of relation attributes to resolve
8271
+ * @returns Map of relation name to related record's values
8272
+ *
8273
+ * @example
8274
+ * ```typescript
8275
+ * // For an order with company relation
8276
+ * const resolved = await resolver.resolveRelationValues(
8277
+ * orderRecord,
8278
+ * orderSchema,
8279
+ * ["company"]
8280
+ * );
8281
+ * // → { company: { id: "...", name: "Acme Corp", ... } }
8282
+ * ```
8283
+ */
8284
+ async resolveRelationValues(record, schema, relationNames) {
8285
+ const result = {};
8286
+ const relationAttrs = schema.attributes.filter(
8287
+ (a) => a.type === "relation" && relationNames.includes(a.name)
8288
+ );
8289
+ if (relationAttrs.length === 0) {
8290
+ return result;
8291
+ }
8292
+ const idsToFetch = [];
8293
+ const attrIdMap = /* @__PURE__ */ new Map();
8294
+ for (const attr of relationAttrs) {
8295
+ const relatedId = this.extractSingleId(record.values[attr.name]);
8296
+ if (relatedId) {
8297
+ idsToFetch.push(relatedId);
8298
+ attrIdMap.set(relatedId, attr.name);
8299
+ }
8300
+ }
8301
+ if (idsToFetch.length === 0) {
8302
+ return result;
8303
+ }
8304
+ const relatedRecords = await this.adapter.objectRecords.findByIds(idsToFetch);
8305
+ for (const relatedRecord of relatedRecords) {
8306
+ const attrName = attrIdMap.get(relatedRecord.id);
8307
+ if (attrName) {
8308
+ result[attrName] = relatedRecord.values;
8309
+ }
8310
+ }
8311
+ return result;
8312
+ }
8313
+ /**
8314
+ * Resolve relation values for multiple records (batched)
8315
+ *
8316
+ * Optimized for list operations - fetches all related records in one batch
8317
+ *
8318
+ * @param records - Source records
8319
+ * @param schema - Schema of the source object
8320
+ * @param relationNames - Names of relation attributes to resolve
8321
+ * @returns Map of record ID to resolved relations
8322
+ */
8323
+ async resolveRelationValuesForMany(records, schema, relationNames) {
8324
+ const resultMap = /* @__PURE__ */ new Map();
8325
+ for (const record of records) {
8326
+ resultMap.set(record.id, {});
8327
+ }
8328
+ const relationAttrs = schema.attributes.filter(
8329
+ (a) => a.type === "relation" && relationNames.includes(a.name)
8330
+ );
8331
+ if (relationAttrs.length === 0) {
8332
+ return resultMap;
8333
+ }
8334
+ const allIdsToFetch = /* @__PURE__ */ new Set();
8335
+ const recordRelationMap = /* @__PURE__ */ new Map();
8336
+ for (const record of records) {
8337
+ const idToAttr = /* @__PURE__ */ new Map();
8338
+ for (const attr of relationAttrs) {
8339
+ const relatedId = this.extractSingleId(record.values[attr.name]);
8340
+ if (relatedId) {
8341
+ allIdsToFetch.add(relatedId);
8342
+ idToAttr.set(relatedId, attr.name);
8343
+ }
8344
+ }
8345
+ recordRelationMap.set(record.id, idToAttr);
8346
+ }
8347
+ if (allIdsToFetch.size === 0) {
8348
+ return resultMap;
8349
+ }
8350
+ const relatedRecords = await this.adapter.objectRecords.findByIds([...allIdsToFetch]);
8351
+ const relatedRecordMap = new Map(relatedRecords.map((r) => [r.id, r]));
8352
+ for (const record of records) {
8353
+ const result = resultMap.get(record.id);
8354
+ const idToAttr = recordRelationMap.get(record.id);
8355
+ for (const [relatedId, attrName] of idToAttr) {
8356
+ const relatedRecord = relatedRecordMap.get(relatedId);
8357
+ if (relatedRecord) {
8358
+ result[attrName] = relatedRecord.values;
8359
+ }
8360
+ }
8361
+ }
8362
+ return resultMap;
8363
+ }
8364
+ /**
8365
+ * Flatten resolved relations for formula evaluation
8366
+ *
8367
+ * Converts nested structure to dot-notation keys:
8368
+ * { company: { name: "Acme" } } → { "company.name": "Acme" }
8369
+ *
8370
+ * @param resolved - Resolved relations from resolveRelationValues
8371
+ * @returns Flattened values suitable for formula evaluation
8372
+ */
8373
+ flattenResolvedRelations(resolved) {
8374
+ const flat = {};
8375
+ for (const [relationName, values] of Object.entries(resolved)) {
8376
+ for (const [key, value] of Object.entries(values)) {
8377
+ flat[`${relationName}.${key}`] = value;
8378
+ }
8379
+ }
8380
+ return flat;
8381
+ }
8382
+ /**
8383
+ * Extract a single relation ID from a value
8384
+ * Handles both single (string) and multi (array) relations
8385
+ * @internal
8386
+ */
8387
+ extractSingleId(value) {
8388
+ if (typeof value === "string" && value.length > 0) {
8389
+ return value;
8390
+ }
8391
+ if (Array.isArray(value) && value.length > 0 && typeof value[0] === "string") {
8392
+ return value[0];
8393
+ }
8394
+ return null;
8395
+ }
8396
+ };
8397
+
8398
+ // src/runtime/services/rollup-scheduler.ts
8399
+ var RollupScheduler = class {
8400
+ constructor(adapter, getSchemaById, options) {
8401
+ this.adapter = adapter;
8402
+ this.getSchemaById = getSchemaById;
8403
+ this.pending = /* @__PURE__ */ new Map();
8404
+ this.rollupService = new RollupService(adapter);
8405
+ this.debounceMs = options?.debounceMs ?? 100;
8406
+ this.maxPending = options?.maxPending ?? 100;
8407
+ }
8408
+ /**
8409
+ * Schedule a rollup recalculation for a parent record
8410
+ *
8411
+ * If the same record is already pending, the timer is reset.
8412
+ * If too many recalculations are pending, triggers an immediate flush.
8413
+ *
8414
+ * @param parentId - ID of the parent record to recalculate
8415
+ * @param parentObjectId - Object ID of the parent
8416
+ * @param tenantId - Tenant ID
8417
+ */
8418
+ scheduleRecalculation(parentId, parentObjectId, tenantId) {
8419
+ const key = `${parentId}:${parentObjectId}`;
8420
+ const existing = this.pending.get(key);
8421
+ if (existing) {
8422
+ clearTimeout(existing.timeout);
8423
+ }
8424
+ const timeout = setTimeout(async () => {
8425
+ await this.executeRecalculation(parentId, parentObjectId, tenantId);
8426
+ this.pending.delete(key);
8427
+ }, this.debounceMs);
8428
+ this.pending.set(key, { parentId, parentObjectId, tenantId, timeout });
8429
+ if (this.pending.size >= this.maxPending) {
8430
+ this.flush();
8431
+ }
8432
+ }
8433
+ /**
8434
+ * Execute all pending recalculations immediately
8435
+ */
8436
+ async flush() {
8437
+ const entries = Array.from(this.pending.entries());
8438
+ for (const [, entry] of entries) {
8439
+ clearTimeout(entry.timeout);
8440
+ }
8441
+ this.pending.clear();
8442
+ const tenantMap = /* @__PURE__ */ new Map();
8443
+ for (const [, entry] of entries) {
8444
+ tenantMap.set(entry.parentId, entry.tenantId);
8445
+ }
8446
+ const allIds = entries.map(([, entry]) => entry.parentId);
8447
+ const records = await this.adapter.objectRecords.findByIds(allIds);
8448
+ for (const record of records) {
8449
+ const schema = await this.getSchemaById(record.objectId);
8450
+ const tenantId = tenantMap.get(record.id);
8451
+ if (schema && tenantId) {
8452
+ await this.rollupService.recalculateAndUpdate(record, schema, tenantId);
8453
+ }
8454
+ }
8455
+ }
8456
+ /**
8457
+ * Execute a single recalculation
8458
+ */
8459
+ async executeRecalculation(parentId, parentObjectId, tenantId) {
8460
+ const record = await this.adapter.objectRecords.findById(parentId);
8461
+ if (!record) return;
8462
+ const schema = await this.getSchemaById(parentObjectId);
8463
+ if (!schema) return;
8464
+ await this.rollupService.recalculateAndUpdate(record, schema, tenantId);
8465
+ }
8466
+ /**
8467
+ * Get number of pending recalculations
8468
+ */
8469
+ get pendingCount() {
8470
+ return this.pending.size;
8471
+ }
8472
+ /**
8473
+ * Clear all pending recalculations without executing them
8474
+ */
8475
+ clear() {
8476
+ for (const [, entry] of this.pending) {
8477
+ clearTimeout(entry.timeout);
8478
+ }
8479
+ this.pending.clear();
8480
+ }
8481
+ };
8482
+
6914
8483
  // src/runtime/services/user-profile.service.ts
6915
8484
  var UserProfileService = class {
6916
8485
  constructor(adapter, tenantId, options) {
@@ -7245,7 +8814,7 @@ var ViewService = class {
7245
8814
  throw new NotFoundError("View", viewId);
7246
8815
  }
7247
8816
  if (dbView.system) {
7248
- throw new ProtectedResourceError("attribute", dbView.name, "modify");
8817
+ throw new ProtectedResourceError("view", dbView.name, "modify");
7249
8818
  }
7250
8819
  const updated = await this.adapter.views.update(viewId, {
7251
8820
  label: input.label,
@@ -7268,7 +8837,7 @@ var ViewService = class {
7268
8837
  throw new NotFoundError("View", viewId);
7269
8838
  }
7270
8839
  if (dbView.system) {
7271
- throw new ProtectedResourceError("attribute", dbView.name, "delete");
8840
+ throw new ProtectedResourceError("view", dbView.name, "delete");
7272
8841
  }
7273
8842
  await this.adapter.views.delete(viewId);
7274
8843
  }
@@ -7608,7 +9177,8 @@ async function syncAttributes(adapter, nativeObject, dbObject, existingObject, r
7608
9177
  hidden: attr.hidden,
7609
9178
  archived: attr.archived,
7610
9179
  deprecated: attr.deprecated,
7611
- defaultValue: attr.defaultValue,
9180
+ // Formula and rollup attributes don't have defaultValue
9181
+ defaultValue: "defaultValue" in attr ? attr.defaultValue : void 0,
7612
9182
  config: extractAttributeConfig(attr)
7613
9183
  });
7614
9184
  if (existingAttr) {
@@ -7690,6 +9260,8 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
7690
9260
  GeocodingService,
7691
9261
  GlobalSearchService,
7692
9262
  GroupBuilder,
9263
+ InvalidPathError,
9264
+ MaxDepthExceededError,
7693
9265
  NO_VALUE_OPERATORS,
7694
9266
  NoopGeocodingAdapter,
7695
9267
  NoopHookRegistry,
@@ -7706,8 +9278,11 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
7706
9278
  RecordNotFoundError,
7707
9279
  RecordReferencedError,
7708
9280
  RecordService,
9281
+ RelationResolverService,
7709
9282
  RelationService,
7710
9283
  RoleNotFoundError,
9284
+ RollupScheduler,
9285
+ RollupService,
7711
9286
  SYSTEM_RESOURCES,
7712
9287
  SYSTEM_RESOURCE_LABELS,
7713
9288
  SchemaError,
@@ -7717,6 +9292,7 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
7717
9292
  TableTabConfig,
7718
9293
  UserProfileNotFoundError,
7719
9294
  UserProfileService,
9295
+ UserService,
7720
9296
  ValidationError,
7721
9297
  ViewBuilder,
7722
9298
  ViewService,
@@ -7731,6 +9307,7 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
7731
9307
  createDateValidator,
7732
9308
  createDraftValidator,
7733
9309
  createFileValidator,
9310
+ createFormulaValidator,
7734
9311
  createLocationValidator,
7735
9312
  createMockAdapter,
7736
9313
  createMultiRelationValidator,
@@ -7740,6 +9317,8 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
7740
9317
  createPhoneValidator,
7741
9318
  createRatingValidator,
7742
9319
  createRelationValidator,
9320
+ createRollupHooks,
9321
+ createRollupValidator,
7743
9322
  createSelectValidator,
7744
9323
  createSingleRelationValidator,
7745
9324
  createStatusValidator,
@@ -7751,18 +9330,34 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
7751
9330
  date,
7752
9331
  dateConfigSchema,
7753
9332
  enrichValuesWithSelectLabels,
9333
+ evaluateFormula,
9334
+ evaluateFormulaAttribute,
9335
+ evaluateFormulaAttributeWithRelations,
9336
+ evaluateFormulaWithRelations,
9337
+ evaluateFormulaWithResult,
7754
9338
  extractAttributeNames,
9339
+ extractFormulaVariables,
9340
+ extractRelationNames,
9341
+ extractRelationReferences,
7755
9342
  file,
7756
9343
  fileConfigSchema,
9344
+ flattenRelationsForEval,
7757
9345
  flow,
7758
9346
  formatAttributeValue,
9347
+ formatFormulaResult,
9348
+ formula,
9349
+ formulaConfigSchema,
7759
9350
  generateId,
7760
9351
  generatePrefixedId,
7761
9352
  getAttributeConfigSchema,
7762
9353
  getMissingRequiredAttributes,
9354
+ getPathDepth,
9355
+ getRelationPath,
7763
9356
  getSyncPreview,
9357
+ getTargetAttributeName,
7764
9358
  getViewSyncPreview,
7765
9359
  group,
9360
+ hasRelationReferences,
7766
9361
  isActivityTab,
7767
9362
  isAdvancedFilterState,
7768
9363
  isCustomTab,
@@ -7788,14 +9383,21 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
7788
9383
  numberConfigSchema,
7789
9384
  object,
7790
9385
  parseAttributeConfig,
9386
+ parsePath,
9387
+ pathHasManyCardinality,
7791
9388
  phone,
7792
9389
  phoneConfigSchema,
7793
9390
  rating,
7794
9391
  ratingConfigSchema,
9392
+ registerAllRollupHooks,
7795
9393
  registry,
7796
9394
  relation,
7797
9395
  relationConfigSchema,
7798
9396
  renderLabelExpression,
9397
+ resolveMultiplePaths,
9398
+ resolveSingleValue,
9399
+ rollup,
9400
+ rollupConfigSchema,
7799
9401
  safeParseAttributeConfig,
7800
9402
  select,
7801
9403
  selectConfigSchema,
@@ -7812,14 +9414,17 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
7812
9414
  timestampConfigSchema,
7813
9415
  toAdvancedFilterState,
7814
9416
  toSimpleFilterState,
9417
+ traversePath,
7815
9418
  user,
7816
9419
  userConfigSchema,
7817
9420
  validateAttribute,
7818
9421
  validateAttributeConfig,
7819
9422
  validateDraft,
7820
9423
  validateDraftOrThrow,
9424
+ validateFormulaExpression,
7821
9425
  validateObject,
7822
9426
  validateObjectOrThrow,
9427
+ validatePath,
7823
9428
  verifyNativeObjectsSync,
7824
9429
  verifyNativeViewsSync,
7825
9430
  view,