@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/runtime.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 */
@@ -124,36 +128,697 @@ __export(runtime_exports, {
124
128
  FlowService: () => FlowService,
125
129
  GeocodingService: () => GeocodingService,
126
130
  GlobalSearchService: () => GlobalSearchService,
131
+ InvalidPathError: () => InvalidPathError,
132
+ MaxDepthExceededError: () => MaxDepthExceededError,
127
133
  NoopGeocodingAdapter: () => NoopGeocodingAdapter,
128
134
  NoopHookRegistry: () => NoopHookRegistry,
129
135
  ObjectSchemaService: () => ObjectSchemaService,
130
136
  PermissionService: () => PermissionService,
131
137
  RecordService: () => RecordService,
138
+ RelationResolverService: () => RelationResolverService,
132
139
  RelationService: () => RelationService,
140
+ RollupScheduler: () => RollupScheduler,
141
+ RollupService: () => RollupService,
133
142
  UserProfileService: () => UserProfileService,
143
+ UserService: () => UserService,
134
144
  ViewService: () => ViewService,
135
145
  buildAuditChanges: () => buildAuditChanges,
136
146
  createMockAdapter: () => createMockAdapter,
147
+ createRollupHooks: () => createRollupHooks,
137
148
  enrichValuesWithSelectLabels: () => enrichValuesWithSelectLabels,
149
+ evaluateFormula: () => evaluateFormula,
150
+ evaluateFormulaAttribute: () => evaluateFormulaAttribute,
151
+ evaluateFormulaAttributeWithRelations: () => evaluateFormulaAttributeWithRelations,
152
+ evaluateFormulaWithRelations: () => evaluateFormulaWithRelations,
153
+ evaluateFormulaWithResult: () => evaluateFormulaWithResult,
138
154
  extractAttributeNames: () => extractAttributeNames,
155
+ extractFormulaVariables: () => extractFormulaVariables,
156
+ extractRelationNames: () => extractRelationNames,
157
+ extractRelationReferences: () => extractRelationReferences,
158
+ flattenRelationsForEval: () => flattenRelationsForEval,
159
+ formatFormulaResult: () => formatFormulaResult,
160
+ getPathDepth: () => getPathDepth,
161
+ getRelationPath: () => getRelationPath,
139
162
  getSyncPreview: () => getSyncPreview,
163
+ getTargetAttributeName: () => getTargetAttributeName,
140
164
  getViewSyncPreview: () => getViewSyncPreview,
165
+ hasRelationReferences: () => hasRelationReferences,
141
166
  isLabelExpression: () => isLabelExpression,
167
+ parsePath: () => parsePath,
168
+ pathHasManyCardinality: () => pathHasManyCardinality,
169
+ registerAllRollupHooks: () => registerAllRollupHooks,
142
170
  renderLabelExpression: () => renderLabelExpression,
171
+ resolveMultiplePaths: () => resolveMultiplePaths,
172
+ resolveSingleValue: () => resolveSingleValue,
143
173
  syncAll: () => syncAll,
144
174
  syncNativeObjects: () => syncNativeObjects,
145
175
  syncNativeViews: () => syncNativeViews,
176
+ traversePath: () => traversePath,
177
+ validateFormulaExpression: () => validateFormulaExpression,
178
+ validatePath: () => validatePath,
146
179
  verifyNativeObjectsSync: () => verifyNativeObjectsSync,
147
180
  verifyNativeViewsSync: () => verifyNativeViewsSync
148
181
  });
149
182
  module.exports = __toCommonJS(runtime_exports);
150
183
 
184
+ // src/runtime/formula/evaluator.ts
185
+ var import_expr_eval = require("expr-eval");
186
+ function createFormulaParser() {
187
+ const parser = new import_expr_eval.Parser();
188
+ parser.functions.IF = (condition, thenValue, elseValue) => condition ? thenValue : elseValue;
189
+ parser.functions.AND = (...args) => args.every(Boolean);
190
+ parser.functions.OR = (...args) => args.some(Boolean);
191
+ parser.functions.NOT = (value) => !value;
192
+ parser.functions.EMPTY = (value) => value === null || value === void 0 || value === "";
193
+ parser.functions.COALESCE = (...args) => args.find((a) => a != null) ?? null;
194
+ parser.functions.DEFAULT = (value, defaultValue) => value == null || value === "" ? defaultValue : value;
195
+ parser.functions.CONCAT = (...args) => args.filter((a) => a != null).map(String).join("");
196
+ parser.functions.UPPER = (value) => String(value ?? "").toUpperCase();
197
+ parser.functions.LOWER = (value) => String(value ?? "").toLowerCase();
198
+ parser.functions.TRIM = (value) => String(value ?? "").trim();
199
+ parser.functions.LENGTH = (value) => String(value ?? "").length;
200
+ parser.functions.LEFT = (value, count) => String(value ?? "").slice(0, count);
201
+ parser.functions.RIGHT = (value, count) => String(value ?? "").slice(-count);
202
+ parser.functions.REPLACE = (value, search, replacement) => String(value ?? "").replace(new RegExp(search, "g"), replacement);
203
+ parser.functions.CONTAINS = (value, search) => String(value ?? "").toLowerCase().includes(String(search).toLowerCase());
204
+ parser.functions.ROUND = (value, decimals = 0) => {
205
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
206
+ const factor = 10 ** decimals;
207
+ return Math.round(value * factor) / factor;
208
+ };
209
+ parser.functions.FLOOR = (value) => {
210
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
211
+ return Math.floor(value);
212
+ };
213
+ parser.functions.CEIL = (value) => {
214
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
215
+ return Math.ceil(value);
216
+ };
217
+ parser.functions.ABS = (value) => {
218
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
219
+ return Math.abs(value);
220
+ };
221
+ parser.functions.MIN = (...args) => {
222
+ const nums = args.filter((n) => typeof n === "number" && !Number.isNaN(n));
223
+ return nums.length > 0 ? Math.min(...nums) : null;
224
+ };
225
+ parser.functions.MAX = (...args) => {
226
+ const nums = args.filter((n) => typeof n === "number" && !Number.isNaN(n));
227
+ return nums.length > 0 ? Math.max(...nums) : null;
228
+ };
229
+ parser.functions.POW = (base, exponent) => {
230
+ if (typeof base !== "number" || typeof exponent !== "number") return null;
231
+ return base ** exponent;
232
+ };
233
+ parser.functions.MOD = (a, b) => {
234
+ if (typeof a !== "number" || typeof b !== "number" || b === 0) return null;
235
+ return a % b;
236
+ };
237
+ parser.functions.NOW = () => (/* @__PURE__ */ new Date()).toISOString();
238
+ parser.functions.TODAY = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
239
+ parser.functions.YEAR = (value) => {
240
+ const date = parseDate(value);
241
+ return date ? date.getFullYear() : null;
242
+ };
243
+ parser.functions.MONTH = (value) => {
244
+ const date = parseDate(value);
245
+ return date ? date.getMonth() + 1 : null;
246
+ };
247
+ parser.functions.DAY = (value) => {
248
+ const date = parseDate(value);
249
+ return date ? date.getDate() : null;
250
+ };
251
+ parser.functions.DATE_DIFF = (date1, date2, unit = "days") => {
252
+ const d1 = parseDate(date1);
253
+ const d2 = parseDate(date2);
254
+ if (d1 === null || d2 === null) return null;
255
+ const diffMs = d1.getTime() - d2.getTime();
256
+ const MS_PER_DAY = 1e3 * 60 * 60 * 24;
257
+ const conversions = {
258
+ years: MS_PER_DAY * 365,
259
+ months: MS_PER_DAY * 30,
260
+ weeks: MS_PER_DAY * 7,
261
+ days: MS_PER_DAY,
262
+ hours: 1e3 * 60 * 60,
263
+ minutes: 1e3 * 60
264
+ };
265
+ const divisor = conversions[unit] ?? MS_PER_DAY;
266
+ return diffMs / divisor;
267
+ };
268
+ return parser;
269
+ }
270
+ function parseDate(value) {
271
+ if (!value) return null;
272
+ if (value instanceof Date) return value;
273
+ if (typeof value === "string" || typeof value === "number") {
274
+ const date = new Date(value);
275
+ return Number.isNaN(date.getTime()) ? null : date;
276
+ }
277
+ return null;
278
+ }
279
+ var formulaParser = createFormulaParser();
280
+ function evaluateFormula(expression, values) {
281
+ try {
282
+ const parsed = formulaParser.parse(expression);
283
+ return parsed.evaluate(values);
284
+ } catch {
285
+ return null;
286
+ }
287
+ }
288
+ function evaluateFormulaWithResult(expression, values) {
289
+ try {
290
+ const parsed = formulaParser.parse(expression);
291
+ const value = parsed.evaluate(values);
292
+ return { value };
293
+ } catch (error) {
294
+ return {
295
+ value: null,
296
+ error: error instanceof Error ? error.message : "Unknown error"
297
+ };
298
+ }
299
+ }
300
+ function formatFormulaResult(value, returnType, decimals) {
301
+ if (value === null || value === void 0) {
302
+ return null;
303
+ }
304
+ switch (returnType) {
305
+ case "number":
306
+ return formatNumberResult(value, decimals);
307
+ case "boolean":
308
+ return Boolean(value);
309
+ case "date":
310
+ return formatDateResult(value);
311
+ case "text":
312
+ return String(value);
313
+ }
314
+ }
315
+ function formatNumberResult(value, decimals) {
316
+ const num = typeof value === "number" ? value : Number(value);
317
+ if (Number.isNaN(num)) return null;
318
+ return decimals !== void 0 ? Number(num.toFixed(decimals)) : num;
319
+ }
320
+ function formatDateResult(value) {
321
+ if (value instanceof Date) return value.toISOString();
322
+ if (typeof value === "string") return value;
323
+ return null;
324
+ }
325
+ function evaluateFormulaAttribute(attr, values) {
326
+ const raw = evaluateFormula(attr.expression, values);
327
+ return formatFormulaResult(raw, attr.returnType, attr.decimals);
328
+ }
329
+ function validateFormulaExpression(expression) {
330
+ try {
331
+ formulaParser.parse(expression);
332
+ return { valid: true };
333
+ } catch (error) {
334
+ return {
335
+ valid: false,
336
+ error: error instanceof Error ? error.message : "Invalid expression"
337
+ };
338
+ }
339
+ }
340
+ function extractFormulaVariables(expression) {
341
+ try {
342
+ const parsed = formulaParser.parse(expression);
343
+ return parsed.variables();
344
+ } catch {
345
+ return [];
346
+ }
347
+ }
348
+ var RELATION_REF_PATTERN = /\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b/g;
349
+ function extractRelationReferences(expression) {
350
+ const regex = new RegExp(RELATION_REF_PATTERN.source, "g");
351
+ const matches = expression.matchAll(regex);
352
+ return [...matches].map((m) => m[0]);
353
+ }
354
+ function extractRelationNames(expression) {
355
+ const refs = extractRelationReferences(expression);
356
+ const names = refs.map((ref) => ref.split(".")[0]);
357
+ return [...new Set(names)];
358
+ }
359
+ function hasRelationReferences(expression) {
360
+ const regex = new RegExp(RELATION_REF_PATTERN.source);
361
+ return regex.test(expression);
362
+ }
363
+ function flattenRelationsForEval(resolvedRelations) {
364
+ const flat = {};
365
+ for (const [relationName, values] of Object.entries(resolvedRelations)) {
366
+ for (const [key, value] of Object.entries(values)) {
367
+ flat[`${relationName}.${key}`] = value;
368
+ }
369
+ }
370
+ return flat;
371
+ }
372
+ async function evaluateFormulaWithRelations(expression, record, schema, resolver) {
373
+ const relationNames = extractRelationNames(expression);
374
+ if (relationNames.length === 0) {
375
+ return evaluateFormula(expression, record.values);
376
+ }
377
+ const resolvedRelations = await resolver.resolveRelationValues(record, schema, relationNames);
378
+ const flattenedRelations = flattenRelationsForEval(resolvedRelations);
379
+ const allValues = {
380
+ ...record.values,
381
+ ...flattenedRelations
382
+ };
383
+ return evaluateFormula(expression, allValues);
384
+ }
385
+ async function evaluateFormulaAttributeWithRelations(attr, record, schema, resolver) {
386
+ const raw = await evaluateFormulaWithRelations(attr.expression, record, schema, resolver);
387
+ return formatFormulaResult(raw, attr.returnType, attr.decimals);
388
+ }
389
+
390
+ // src/runtime/formula/path-parser.ts
391
+ var InvalidPathError = class extends Error {
392
+ constructor(path, segment, reason) {
393
+ super(`Invalid path "${path}" at "${segment}": ${reason}`);
394
+ this.path = path;
395
+ this.segment = segment;
396
+ this.reason = reason;
397
+ this.name = "InvalidPathError";
398
+ }
399
+ };
400
+ var MaxDepthExceededError = class extends Error {
401
+ constructor(path, maxDepth) {
402
+ super(`Path "${path}" exceeds maximum depth of ${maxDepth}`);
403
+ this.path = path;
404
+ this.maxDepth = maxDepth;
405
+ this.name = "MaxDepthExceededError";
406
+ }
407
+ };
408
+ async function parsePath(path, startSchema, getSchema, maxDepth = 5) {
409
+ const segments = path.split(".");
410
+ if (segments.length === 0 || segments.length === 1 && segments[0] === "") {
411
+ throw new InvalidPathError(path, path, "Path cannot be empty");
412
+ }
413
+ if (segments.length > maxDepth) {
414
+ throw new MaxDepthExceededError(path, maxDepth);
415
+ }
416
+ const result = [];
417
+ let currentSchema = startSchema;
418
+ for (let i = 0; i < segments.length; i++) {
419
+ const segmentName = segments[i];
420
+ const isLastSegment = i === segments.length - 1;
421
+ const attr = currentSchema.attributes.find((a) => a.name === segmentName);
422
+ if (!attr) {
423
+ throw new InvalidPathError(
424
+ path,
425
+ segmentName,
426
+ `Attribute "${segmentName}" not found in object "${currentSchema.name}"`
427
+ );
428
+ }
429
+ if (attr.type === "relation") {
430
+ const relationAttr = attr;
431
+ const targetObject = relationAttr.targets[0]?.object;
432
+ if (!targetObject) {
433
+ throw new InvalidPathError(path, segmentName, "Relation has no target object");
434
+ }
435
+ result.push({
436
+ name: segmentName,
437
+ type: "relation",
438
+ cardinality: relationAttr.cardinality,
439
+ targetObject
440
+ });
441
+ if (!isLastSegment) {
442
+ const nextSchema = await getSchema(targetObject);
443
+ if (!nextSchema) {
444
+ throw new InvalidPathError(
445
+ path,
446
+ segmentName,
447
+ `Target object "${targetObject}" schema not found`
448
+ );
449
+ }
450
+ currentSchema = nextSchema;
451
+ }
452
+ } else {
453
+ if (!isLastSegment) {
454
+ throw new InvalidPathError(
455
+ path,
456
+ segmentName,
457
+ `"${segmentName}" is not a relation but has segments after it`
458
+ );
459
+ }
460
+ result.push({
461
+ name: segmentName,
462
+ type: "attribute"
463
+ });
464
+ }
465
+ }
466
+ return result;
467
+ }
468
+ async function validatePath(path, startSchema, getSchema, maxDepth = 5) {
469
+ try {
470
+ await parsePath(path, startSchema, getSchema, maxDepth);
471
+ return true;
472
+ } catch {
473
+ return false;
474
+ }
475
+ }
476
+ function pathHasManyCardinality(segments) {
477
+ return segments.some((s) => s.type === "relation" && s.cardinality === "many");
478
+ }
479
+ function getPathDepth(segments) {
480
+ return segments.filter((s) => s.type === "relation").length;
481
+ }
482
+ function getTargetAttributeName(path) {
483
+ const parts = path.split(".");
484
+ return parts[parts.length - 1];
485
+ }
486
+ function getRelationPath(path) {
487
+ const parts = path.split(".");
488
+ if (parts.length <= 1) return null;
489
+ return parts.slice(0, -1).join(".");
490
+ }
491
+
492
+ // src/runtime/formula/path-traversal.ts
493
+ async function traversePath(record, path, startSchemaName, adapter, getSchema, options) {
494
+ const maxDepth = options?.maxDepth ?? 5;
495
+ const startSchema = await getSchema(startSchemaName);
496
+ if (!startSchema) {
497
+ return { values: [], recordCounts: [0] };
498
+ }
499
+ const segments = await parsePath(path, startSchema, getSchema, maxDepth);
500
+ if (segments.length > maxDepth) {
501
+ throw new MaxDepthExceededError(path, maxDepth);
502
+ }
503
+ return await executeTraversal([record], segments, adapter, 0);
504
+ }
505
+ async function executeTraversal(records, segments, adapter, depth) {
506
+ const recordCounts = [records.length];
507
+ if (depth >= segments.length || records.length === 0) {
508
+ return { values: [], recordCounts };
509
+ }
510
+ const segment = segments[depth];
511
+ const isLastSegment = depth === segments.length - 1;
512
+ if (segment.type === "attribute") {
513
+ const values = records.map((r) => r.values[segment.name]).filter((v) => v !== void 0);
514
+ return { values, recordCounts };
515
+ }
516
+ const relatedIds = collectRelatedIds(records, segment.name);
517
+ if (relatedIds.length === 0) {
518
+ return { values: [], recordCounts };
519
+ }
520
+ const relatedRecords = await adapter.objectRecords.findByIds(relatedIds);
521
+ recordCounts.push(relatedRecords.length);
522
+ if (isLastSegment) {
523
+ return { values: relatedIds, recordCounts };
524
+ }
525
+ const nestedResult = await executeTraversal(relatedRecords, segments, adapter, depth + 1);
526
+ return {
527
+ values: nestedResult.values,
528
+ recordCounts: [...recordCounts, ...nestedResult.recordCounts.slice(1)]
529
+ };
530
+ }
531
+ function collectRelatedIds(records, relationName) {
532
+ const ids = [];
533
+ for (const record of records) {
534
+ const value = record.values[relationName];
535
+ if (typeof value === "string" && value.length > 0) {
536
+ ids.push(value);
537
+ } else if (Array.isArray(value)) {
538
+ for (const id of value) {
539
+ if (typeof id === "string" && id.length > 0) {
540
+ ids.push(id);
541
+ }
542
+ }
543
+ }
544
+ }
545
+ return [...new Set(ids)];
546
+ }
547
+ async function resolveSingleValue(record, path, startSchemaName, adapter, getSchema) {
548
+ const result = await traversePath(record, path, startSchemaName, adapter, getSchema);
549
+ return result.values[0] ?? null;
550
+ }
551
+ async function resolveMultiplePaths(record, paths, startSchemaName, adapter, getSchema) {
552
+ const results = /* @__PURE__ */ new Map();
553
+ await Promise.all(
554
+ paths.map(async (path) => {
555
+ const result = await traversePath(record, path, startSchemaName, adapter, getSchema);
556
+ results.set(path, result.values);
557
+ })
558
+ );
559
+ return results;
560
+ }
561
+
151
562
  // src/runtime/hooks/hook.registry.ts
152
563
  var NoopHookRegistry = class {
153
564
  async execute() {
154
565
  }
155
566
  };
156
567
 
568
+ // src/runtime/services/rollup.service.ts
569
+ var RollupService = class {
570
+ constructor(adapter) {
571
+ this.adapter = adapter;
572
+ }
573
+ /**
574
+ * Calculate a rollup value for a record
575
+ *
576
+ * @param recordId - ID of the parent record
577
+ * @param rollupAttr - Rollup attribute definition
578
+ * @param schema - Schema of the parent object
579
+ * @returns Computed rollup value
580
+ *
581
+ * @example
582
+ * ```typescript
583
+ * // Sum all order amounts for a company
584
+ * const totalOrders = await rollupService.calculate(
585
+ * "company-123",
586
+ * {
587
+ * type: "rollup",
588
+ * name: "totalOrders",
589
+ * relationAttribute: "orders",
590
+ * targetAttribute: "amount",
591
+ * function: "sum",
592
+ * ...
593
+ * },
594
+ * companySchema
595
+ * );
596
+ * ```
597
+ */
598
+ async calculate(recordId, rollupAttr, schema, tenantId) {
599
+ const relationAttr = schema.attributes.find(
600
+ (a) => a.type === "relation" && a.name === rollupAttr.relationAttribute
601
+ );
602
+ if (!relationAttr) {
603
+ return { value: null, recordCount: 0 };
604
+ }
605
+ const targetObjectName = relationAttr.targets[0]?.object;
606
+ if (!targetObjectName) {
607
+ return { value: null, recordCount: 0 };
608
+ }
609
+ const targetObject = await this.adapter.objects.findByName(tenantId, targetObjectName);
610
+ if (!targetObject) {
611
+ return { value: null, recordCount: 0 };
612
+ }
613
+ const relatedRecords = await this.adapter.objectRecords.findByRelation(
614
+ tenantId,
615
+ targetObject.id,
616
+ rollupAttr.relationAttribute,
617
+ recordId
618
+ );
619
+ if (relatedRecords.length === 0) {
620
+ return { value: this.getEmptyValue(rollupAttr.function), recordCount: 0 };
621
+ }
622
+ const values = relatedRecords.map((r) => r.values[rollupAttr.targetAttribute]).filter((v) => v !== void 0);
623
+ const aggregated = this.aggregate(values, rollupAttr.function);
624
+ const result = typeof aggregated === "number" && rollupAttr.decimals !== void 0 ? Number(aggregated.toFixed(rollupAttr.decimals)) : aggregated;
625
+ return { value: result, recordCount: relatedRecords.length };
626
+ }
627
+ /**
628
+ * Calculate rollup values for multiple records (batched)
629
+ *
630
+ * More efficient than calling calculate() for each record individually.
631
+ */
632
+ async calculateForMany(recordIds, rollupAttr, schema, tenantId) {
633
+ const results = /* @__PURE__ */ new Map();
634
+ for (const id of recordIds) {
635
+ results.set(id, { value: this.getEmptyValue(rollupAttr.function), recordCount: 0 });
636
+ }
637
+ await Promise.all(
638
+ recordIds.map(async (id) => {
639
+ const result = await this.calculate(id, rollupAttr, schema, tenantId);
640
+ results.set(id, result);
641
+ })
642
+ );
643
+ return results;
644
+ }
645
+ /**
646
+ * Apply aggregation function to a set of values
647
+ */
648
+ aggregate(values, fn) {
649
+ switch (fn) {
650
+ case "sum":
651
+ return this.sumNumbers(values);
652
+ case "avg":
653
+ return this.averageNumbers(values);
654
+ case "min":
655
+ return this.minNumbers(values);
656
+ case "max":
657
+ return this.maxNumbers(values);
658
+ case "count":
659
+ return values.length;
660
+ case "countValues":
661
+ return values.filter((v) => v != null && v !== "").length;
662
+ case "countEmpty":
663
+ return values.filter((v) => v == null || v === "").length;
664
+ case "percentFilled": {
665
+ if (values.length === 0) return 0;
666
+ const filled = values.filter((v) => v != null && v !== "").length;
667
+ return filled / values.length * 100;
668
+ }
669
+ case "percentEmpty": {
670
+ if (values.length === 0) return 0;
671
+ const empty = values.filter((v) => v == null || v === "").length;
672
+ return empty / values.length * 100;
673
+ }
674
+ }
675
+ }
676
+ /**
677
+ * Get the default empty value for a rollup function
678
+ */
679
+ getEmptyValue(fn) {
680
+ switch (fn) {
681
+ case "sum":
682
+ case "count":
683
+ case "countValues":
684
+ case "countEmpty":
685
+ case "percentEmpty":
686
+ case "percentFilled":
687
+ return 0;
688
+ case "avg":
689
+ case "min":
690
+ case "max":
691
+ return null;
692
+ }
693
+ }
694
+ /**
695
+ * Sum numeric values
696
+ */
697
+ sumNumbers(values) {
698
+ return values.filter((v) => typeof v === "number" && !Number.isNaN(v)).reduce((sum, n) => sum + n, 0);
699
+ }
700
+ /**
701
+ * Average numeric values
702
+ */
703
+ averageNumbers(values) {
704
+ const nums = values.filter((v) => typeof v === "number" && !Number.isNaN(v));
705
+ if (nums.length === 0) return null;
706
+ return nums.reduce((sum, n) => sum + n, 0) / nums.length;
707
+ }
708
+ /**
709
+ * Minimum numeric value
710
+ */
711
+ minNumbers(values) {
712
+ const nums = values.filter((v) => typeof v === "number" && !Number.isNaN(v));
713
+ if (nums.length === 0) return null;
714
+ return Math.min(...nums);
715
+ }
716
+ /**
717
+ * Maximum numeric value
718
+ */
719
+ maxNumbers(values) {
720
+ const nums = values.filter((v) => typeof v === "number" && !Number.isNaN(v));
721
+ if (nums.length === 0) return null;
722
+ return Math.max(...nums);
723
+ }
724
+ /**
725
+ * Recalculate all rollup attributes for a record and update it
726
+ *
727
+ * Called after related records change to keep rollups up-to-date.
728
+ */
729
+ async recalculateAndUpdate(record, schema, tenantId) {
730
+ const rollupAttrs = schema.attributes.filter((a) => a.type === "rollup");
731
+ if (rollupAttrs.length === 0) {
732
+ return record;
733
+ }
734
+ const updates = {};
735
+ for (const attr of rollupAttrs) {
736
+ const result = await this.calculate(record.id, attr, schema, tenantId);
737
+ updates[attr.name] = result.value;
738
+ }
739
+ return await this.adapter.objectRecords.update(record.id, updates);
740
+ }
741
+ /**
742
+ * Find parent records that need rollup recalculation when a child record changes
743
+ *
744
+ * Used by hooks to determine which parent records to recalculate after
745
+ * a child record is created, updated, or deleted.
746
+ *
747
+ * @param changedRecord - The record that was modified
748
+ * @param changedSchema - Schema of the changed record's object
749
+ * @returns Array of parent record IDs that need recalculation
750
+ */
751
+ async findAffectedParentRecords(changedRecord, changedSchema) {
752
+ const affectedIds = [];
753
+ const relationAttrs = changedSchema.attributes.filter(
754
+ (a) => a.type === "relation"
755
+ );
756
+ for (const attr of relationAttrs) {
757
+ const relatedId = changedRecord.values[attr.name];
758
+ if (typeof relatedId === "string" && relatedId.length > 0) {
759
+ affectedIds.push(relatedId);
760
+ } else if (Array.isArray(relatedId)) {
761
+ affectedIds.push(...relatedId.filter((id) => typeof id === "string"));
762
+ }
763
+ }
764
+ return [...new Set(affectedIds)];
765
+ }
766
+ };
767
+
768
+ // src/runtime/hooks/rollup.hooks.ts
769
+ function createRollupHooks(objectName, config) {
770
+ const rollupService = new RollupService(config.adapter);
771
+ async function recalculateParentRollups(ctx) {
772
+ const schema = await config.getSchemaById(ctx.objectId);
773
+ if (!schema) return;
774
+ const affectedParentIds = await rollupService.findAffectedParentRecords(ctx.record, schema);
775
+ if (affectedParentIds.length === 0) return;
776
+ const parentRecords = await config.adapter.objectRecords.findByIds(affectedParentIds);
777
+ for (const parentRecord of parentRecords) {
778
+ const parentSchema = await config.getSchemaById(parentRecord.objectId);
779
+ if (!parentSchema) continue;
780
+ const hasRollups = parentSchema.attributes.some(
781
+ (a) => a.type === "rollup"
782
+ );
783
+ if (hasRollups) {
784
+ await rollupService.recalculateAndUpdate(parentRecord, parentSchema, config.tenantId);
785
+ }
786
+ }
787
+ }
788
+ return [
789
+ {
790
+ type: "afterCreate",
791
+ objectName,
792
+ handler: recalculateParentRollups,
793
+ priority: 100
794
+ // Run after other hooks
795
+ },
796
+ {
797
+ type: "afterUpdate",
798
+ objectName,
799
+ handler: recalculateParentRollups,
800
+ priority: 100
801
+ },
802
+ {
803
+ type: "afterDelete",
804
+ objectName,
805
+ handler: recalculateParentRollups,
806
+ priority: 100
807
+ }
808
+ ];
809
+ }
810
+ function registerAllRollupHooks(hookRegistry, schemas, config) {
811
+ for (const schema of schemas) {
812
+ const hasRelations = schema.attributes.some((a) => a.type === "relation");
813
+ if (hasRelations && hookRegistry.register) {
814
+ const hooks = createRollupHooks(schema.name, config);
815
+ for (const hook of hooks) {
816
+ hookRegistry.register(hook);
817
+ }
818
+ }
819
+ }
820
+ }
821
+
157
822
  // src/utils.ts
158
823
  function generateId() {
159
824
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
@@ -178,6 +843,13 @@ function formatCheckbox(value) {
178
843
  function formatNumber(value, attribute) {
179
844
  if (typeof value !== "number") return String(value);
180
845
  const decimals = attribute.decimals;
846
+ if (attribute.unit === "percentage") {
847
+ return value.toLocaleString(void 0, {
848
+ style: "percent",
849
+ minimumFractionDigits: decimals,
850
+ maximumFractionDigits: decimals
851
+ });
852
+ }
181
853
  return value.toLocaleString(void 0, {
182
854
  minimumFractionDigits: decimals,
183
855
  maximumFractionDigits: decimals
@@ -985,6 +1657,26 @@ function createMockObjectRecordsRepository(stores) {
985
1657
  }
986
1658
  }
987
1659
  return Promise.resolve(updated);
1660
+ },
1661
+ findByRelation(objectName, relationAttributeName, targetRecordId) {
1662
+ const obj = Array.from(stores.objects.values()).find((o) => o.name === objectName);
1663
+ if (!obj) return Promise.resolve([]);
1664
+ const results = [];
1665
+ for (const record of stores.objectRecords.values()) {
1666
+ if (record.objectId !== obj.id) continue;
1667
+ const value = record.values[relationAttributeName];
1668
+ let matches = false;
1669
+ if (typeof value === "string" && value === targetRecordId) {
1670
+ matches = true;
1671
+ } else if (Array.isArray(value) && value.includes(targetRecordId)) {
1672
+ matches = true;
1673
+ }
1674
+ if (matches) {
1675
+ const { tenantId: _, ...publicRecord } = record;
1676
+ results.push(publicRecord);
1677
+ }
1678
+ }
1679
+ return Promise.resolve(results);
988
1680
  }
989
1681
  };
990
1682
  }
@@ -1404,6 +2096,24 @@ var AuditService = class {
1404
2096
  };
1405
2097
  await this.log(entry);
1406
2098
  }
2099
+ /**
2100
+ * Log a file action (upload, update, delete)
2101
+ */
2102
+ async logFileAction(params) {
2103
+ const entry = {
2104
+ tenantId: this.tenantId,
2105
+ actorId: params.actorId,
2106
+ actorEmail: params.actorEmail,
2107
+ actorType: "user",
2108
+ action: params.action,
2109
+ resourceType: "file",
2110
+ resourceId: params.fileId,
2111
+ resourceLabel: params.fileName,
2112
+ changes: params.changes ? this.redactSensitiveFields(params.changes) : void 0,
2113
+ metadata: params.metadata
2114
+ };
2115
+ await this.log(entry);
2116
+ }
1407
2117
  /**
1408
2118
  * Log a system action (no actor)
1409
2119
  */
@@ -1553,21 +2263,105 @@ var AuditService = class {
1553
2263
 
1554
2264
  // src/runtime/services/file.service.ts
1555
2265
  var FileService = class {
1556
- constructor(adapter, tenantId) {
2266
+ constructor(adapter, tenantId, options) {
1557
2267
  this.adapter = adapter;
1558
2268
  this.tenantId = tenantId;
2269
+ this.auditService = options?.auditService ?? (adapter.audit ? new AuditService(adapter, tenantId) : void 0);
2270
+ this.userId = options?.userId;
2271
+ this.userEmail = options?.userEmail;
1559
2272
  }
2273
+ // ============================================================================
2274
+ // UPLOAD (requires StorageAdapter)
2275
+ // ============================================================================
1560
2276
  /**
1561
- * Create a new file record (after upload to storage)
2277
+ * Upload a file to storage and create metadata record.
1562
2278
  *
1563
- * @param data - File metadata
2279
+ * This method orchestrates:
2280
+ * 1. Upload to storage (via StorageAdapter)
2281
+ * 2. Create file metadata in database
2282
+ * 3. Audit log the operation
2283
+ *
2284
+ * Requires `adapter.storage` to be configured.
2285
+ *
2286
+ * @param input - File content and metadata
1564
2287
  * @returns Created file record
2288
+ * @throws Error if StorageAdapter is not configured
1565
2289
  *
1566
2290
  * @example
1567
2291
  * ```typescript
1568
- * const service = new FileService(adapter, "tenant-123");
2292
+ * const file = await service.uploadFile({
2293
+ * content: fileBuffer,
2294
+ * fileName: "document.pdf",
2295
+ * mimeType: "application/pdf",
2296
+ * size: 12345,
2297
+ * uploadedBy: "user-123",
2298
+ * visibility: "private",
2299
+ * folderPath: "/documents",
2300
+ * tags: ["contract", "2025"],
2301
+ * });
2302
+ * ```
2303
+ */
2304
+ async uploadFile(input) {
2305
+ if (!this.adapter.storage) {
2306
+ throw new Error(
2307
+ "StorageAdapter is not configured. Provide adapter.storage to use uploadFile()."
2308
+ );
2309
+ }
2310
+ const uploadResult = await this.adapter.storage.upload({
2311
+ content: input.content,
2312
+ fileName: input.fileName,
2313
+ mimeType: input.mimeType,
2314
+ size: input.size,
2315
+ tenantId: this.tenantId,
2316
+ folderPath: input.folderPath
2317
+ });
2318
+ const file = await this.adapter.files.create({
2319
+ tenantId: this.tenantId,
2320
+ name: input.fileName,
2321
+ originalName: input.fileName,
2322
+ mimeType: input.mimeType,
2323
+ size: input.size,
2324
+ storageProvider: uploadResult.storageProvider,
2325
+ storagePath: uploadResult.storagePath,
2326
+ storageBucket: uploadResult.storageBucket,
2327
+ url: uploadResult.url,
2328
+ uploadedBy: input.uploadedBy,
2329
+ folderPath: input.folderPath,
2330
+ tags: input.tags,
2331
+ visibility: input.visibility ?? "private",
2332
+ allowedUsers: input.allowedUsers
2333
+ });
2334
+ if (this.auditService && this.userId) {
2335
+ await this.auditService.logFileAction({
2336
+ action: "file.uploaded",
2337
+ actorId: this.userId,
2338
+ actorEmail: this.userEmail,
2339
+ fileId: file.id,
2340
+ fileName: file.name,
2341
+ metadata: {
2342
+ mimeType: file.mimeType,
2343
+ size: file.size,
2344
+ visibility: file.visibility
2345
+ }
2346
+ });
2347
+ }
2348
+ return file;
2349
+ }
2350
+ // ============================================================================
2351
+ // CREATE (metadata only, for external storage)
2352
+ // ============================================================================
2353
+ /**
2354
+ * Create a new file record (after upload to storage).
1569
2355
  *
1570
- * // After uploading to S3
2356
+ * Use this method when handling storage externally (e.g., with Multer + S3).
2357
+ * For integrated upload, use `uploadFile()` instead.
2358
+ *
2359
+ * @param data - File metadata
2360
+ * @returns Created file record
2361
+ *
2362
+ * @example
2363
+ * ```typescript
2364
+ * // After uploading to S3 with Multer
1571
2365
  * const file = await service.createFile({
1572
2366
  * tenantId: "tenant-123",
1573
2367
  * name: "contract-2025.pdf",
@@ -1589,8 +2383,26 @@ var FileService = class {
1589
2383
  `Tenant mismatch: service initialized with "${this.tenantId}" but data has "${data.tenantId}"`
1590
2384
  );
1591
2385
  }
1592
- return await this.adapter.files.create(data);
2386
+ const file = await this.adapter.files.create(data);
2387
+ if (this.auditService && this.userId) {
2388
+ await this.auditService.logFileAction({
2389
+ action: "file.uploaded",
2390
+ actorId: this.userId,
2391
+ actorEmail: this.userEmail,
2392
+ fileId: file.id,
2393
+ fileName: file.name,
2394
+ metadata: {
2395
+ mimeType: file.mimeType,
2396
+ size: file.size,
2397
+ visibility: file.visibility
2398
+ }
2399
+ });
2400
+ }
2401
+ return file;
1593
2402
  }
2403
+ // ============================================================================
2404
+ // READ
2405
+ // ============================================================================
1594
2406
  /**
1595
2407
  * Get file by ID
1596
2408
  */
@@ -1611,6 +2423,9 @@ var FileService = class {
1611
2423
  }
1612
2424
  return file;
1613
2425
  }
2426
+ // ============================================================================
2427
+ // UPDATE
2428
+ // ============================================================================
1614
2429
  /**
1615
2430
  * Update file metadata
1616
2431
  *
@@ -1619,18 +2434,44 @@ var FileService = class {
1619
2434
  * @returns Updated file
1620
2435
  */
1621
2436
  async updateFile(fileId, data) {
1622
- await this.getFileOrThrow(fileId);
1623
- return await this.adapter.files.update(fileId, data);
2437
+ const existingFile = await this.getFileOrThrow(fileId);
2438
+ const updatedFile = await this.adapter.files.update(fileId, data);
2439
+ if (this.auditService && this.userId) {
2440
+ const changes = [];
2441
+ for (const key of Object.keys(data)) {
2442
+ if (data[key] !== void 0 && data[key] !== existingFile[key]) {
2443
+ changes.push({
2444
+ field: key,
2445
+ oldValue: existingFile[key],
2446
+ newValue: data[key]
2447
+ });
2448
+ }
2449
+ }
2450
+ if (changes.length > 0) {
2451
+ await this.auditService.logFileAction({
2452
+ action: "file.updated",
2453
+ actorId: this.userId,
2454
+ actorEmail: this.userEmail,
2455
+ fileId,
2456
+ fileName: updatedFile.name,
2457
+ changes
2458
+ });
2459
+ }
2460
+ }
2461
+ return updatedFile;
1624
2462
  }
2463
+ // ============================================================================
2464
+ // DELETE
2465
+ // ============================================================================
1625
2466
  /**
1626
- * Delete file (soft delete)
2467
+ * Delete file (soft delete by default)
1627
2468
  *
1628
2469
  * @param fileId - File UUID
1629
2470
  * @param options - Delete options
1630
2471
  */
1631
2472
  async deleteFile(fileId, options) {
2473
+ const file = await this.getFileOrThrow(fileId);
1632
2474
  if (options?.checkOwnership && options.userId) {
1633
- const file = await this.getFileOrThrow(fileId);
1634
2475
  if (file.uploadedBy !== options.userId) {
1635
2476
  throw new Error("You can only delete files you uploaded");
1636
2477
  }
@@ -1640,7 +2481,84 @@ var FileService = class {
1640
2481
  } else {
1641
2482
  await this.adapter.files.delete(fileId);
1642
2483
  }
2484
+ if (this.auditService && this.userId) {
2485
+ await this.auditService.logFileAction({
2486
+ action: "file.deleted",
2487
+ actorId: this.userId,
2488
+ actorEmail: this.userEmail,
2489
+ fileId,
2490
+ fileName: file.name
2491
+ });
2492
+ }
1643
2493
  }
2494
+ /**
2495
+ * Delete file from both storage and database.
2496
+ *
2497
+ * Requires `adapter.storage` to be configured.
2498
+ *
2499
+ * @param fileId - File UUID
2500
+ * @param options - Delete options
2501
+ * @throws Error if StorageAdapter is not configured
2502
+ */
2503
+ async deleteFileWithStorage(fileId, options) {
2504
+ if (!this.adapter.storage) {
2505
+ throw new Error(
2506
+ "StorageAdapter is not configured. Provide adapter.storage to use deleteFileWithStorage()."
2507
+ );
2508
+ }
2509
+ const file = await this.getFileOrThrow(fileId);
2510
+ await this.adapter.storage.delete(file.storagePath);
2511
+ if (options?.hard) {
2512
+ await this.adapter.files.hardDelete(fileId);
2513
+ } else {
2514
+ await this.adapter.files.delete(fileId);
2515
+ }
2516
+ if (this.auditService && this.userId) {
2517
+ await this.auditService.logFileAction({
2518
+ action: "file.deleted",
2519
+ actorId: this.userId,
2520
+ actorEmail: this.userEmail,
2521
+ fileId,
2522
+ fileName: file.name,
2523
+ metadata: { deletedFromStorage: true }
2524
+ });
2525
+ }
2526
+ }
2527
+ /**
2528
+ * Delete multiple files
2529
+ *
2530
+ * @param fileIds - Array of file UUIDs
2531
+ * @param options - Delete options
2532
+ */
2533
+ async bulkDelete(fileIds, options) {
2534
+ for (const fileId of fileIds) {
2535
+ const file = await this.getFile(fileId);
2536
+ if (!file) {
2537
+ continue;
2538
+ }
2539
+ if (options?.deleteFromStorage && this.adapter.storage) {
2540
+ await this.adapter.storage.delete(file.storagePath);
2541
+ }
2542
+ if (options?.hard) {
2543
+ await this.adapter.files.hardDelete(fileId);
2544
+ } else {
2545
+ await this.adapter.files.delete(fileId);
2546
+ }
2547
+ if (this.auditService && this.userId) {
2548
+ await this.auditService.logFileAction({
2549
+ action: "file.deleted",
2550
+ actorId: this.userId,
2551
+ actorEmail: this.userEmail,
2552
+ fileId,
2553
+ fileName: file.name,
2554
+ metadata: { deletedFromStorage: options?.deleteFromStorage ?? false }
2555
+ });
2556
+ }
2557
+ }
2558
+ }
2559
+ // ============================================================================
2560
+ // LIST
2561
+ // ============================================================================
1644
2562
  /**
1645
2563
  * List files for the tenant
1646
2564
  */
@@ -1654,10 +2572,78 @@ var FileService = class {
1654
2572
  return await this.adapter.files.findByFolder(this.tenantId, folderPath);
1655
2573
  }
1656
2574
  /**
1657
- * List files uploaded by a specific user
2575
+ * List files uploaded by a specific user
2576
+ */
2577
+ async listFilesByUploader(uploadedBy) {
2578
+ return await this.adapter.files.findByUploader(uploadedBy);
2579
+ }
2580
+ // ============================================================================
2581
+ // SIGNED URL
2582
+ // ============================================================================
2583
+ /**
2584
+ * Get a signed URL for private file access.
2585
+ *
2586
+ * Checks access permissions before generating URL.
2587
+ * Requires `adapter.storage` to be configured.
2588
+ *
2589
+ * @param fileId - File UUID
2590
+ * @param userId - User requesting access
2591
+ * @param options - Signed URL options
2592
+ * @returns Signed URL
2593
+ * @throws Error if user doesn't have access or StorageAdapter is not configured
2594
+ *
2595
+ * @example
2596
+ * ```typescript
2597
+ * const url = await service.getSignedUrl("file-123", "user-456", {
2598
+ * expiresIn: 3600, // 1 hour
2599
+ * });
2600
+ * ```
2601
+ */
2602
+ async getSignedUrl(fileId, userId, options) {
2603
+ const file = await this.getFileOrThrow(fileId);
2604
+ const hasAccess = await this.checkAccess(fileId, userId);
2605
+ if (!hasAccess) {
2606
+ throw new Error("Access denied to this file");
2607
+ }
2608
+ if (file.visibility === "public") {
2609
+ return file.url;
2610
+ }
2611
+ if (!this.adapter.storage) {
2612
+ return file.url;
2613
+ }
2614
+ return await this.adapter.storage.getSignedUrl(file.storagePath, options);
2615
+ }
2616
+ // ============================================================================
2617
+ // ACCESS CONTROL
2618
+ // ============================================================================
2619
+ /**
2620
+ * Check if user has access to a file
2621
+ *
2622
+ * @param fileId - File UUID
2623
+ * @param userId - User ID to check
2624
+ * @returns true if user can access the file
2625
+ */
2626
+ async checkAccess(fileId, userId) {
2627
+ const file = await this.getFile(fileId);
2628
+ if (!file) {
2629
+ return false;
2630
+ }
2631
+ if (file.visibility === "public") {
2632
+ return true;
2633
+ }
2634
+ if (file.uploadedBy === userId) {
2635
+ return true;
2636
+ }
2637
+ if (file.visibility === "restricted") {
2638
+ return file.allowedUsers?.includes(userId) ?? false;
2639
+ }
2640
+ return false;
2641
+ }
2642
+ /**
2643
+ * @deprecated Use checkAccess() instead
1658
2644
  */
1659
- async listFilesByUploader(uploadedBy) {
1660
- return await this.adapter.files.findByUploader(uploadedBy);
2645
+ canAccess(fileId, userId) {
2646
+ return this.checkAccess(fileId, userId);
1661
2647
  }
1662
2648
  /**
1663
2649
  * Change file visibility
@@ -1702,29 +2688,9 @@ var FileService = class {
1702
2688
  allowedUsers: newAllowed
1703
2689
  });
1704
2690
  }
1705
- /**
1706
- * Check if user has access to a file
1707
- *
1708
- * @param fileId - File UUID
1709
- * @param userId - User ID to check
1710
- * @returns true if user can access the file
1711
- */
1712
- async canAccess(fileId, userId) {
1713
- const file = await this.getFile(fileId);
1714
- if (!file) {
1715
- return false;
1716
- }
1717
- if (file.visibility === "public") {
1718
- return true;
1719
- }
1720
- if (file.uploadedBy === userId) {
1721
- return true;
1722
- }
1723
- if (file.visibility === "restricted") {
1724
- return file.allowedUsers?.includes(userId) ?? false;
1725
- }
1726
- return false;
1727
- }
2691
+ // ============================================================================
2692
+ // ORGANIZATION
2693
+ // ============================================================================
1728
2694
  /**
1729
2695
  * Move file to different folder
1730
2696
  */
@@ -1769,6 +2735,7 @@ var SchemaErrorCode = {
1769
2735
  // Protected Resources
1770
2736
  PROTECTED_OBJECT: "SCHEMA_PROTECTED_OBJECT",
1771
2737
  PROTECTED_ATTRIBUTE: "SCHEMA_PROTECTED_ATTRIBUTE",
2738
+ PROTECTED_VIEW: "SCHEMA_PROTECTED_VIEW",
1772
2739
  PROTECTED_ROLE: "SCHEMA_PROTECTED_ROLE",
1773
2740
  // Permissions
1774
2741
  FORBIDDEN: "SCHEMA_FORBIDDEN",
@@ -1833,7 +2800,7 @@ var ValidationError = class _ValidationError extends SchemaError {
1833
2800
  };
1834
2801
  var ProtectedResourceError = class extends SchemaError {
1835
2802
  constructor(resourceType, resourceName, operation) {
1836
- const code = resourceType === "object" ? SchemaErrorCode.PROTECTED_OBJECT : SchemaErrorCode.PROTECTED_ATTRIBUTE;
2803
+ const code = resourceType === "object" ? SchemaErrorCode.PROTECTED_OBJECT : resourceType === "view" ? SchemaErrorCode.PROTECTED_VIEW : SchemaErrorCode.PROTECTED_ATTRIBUTE;
1837
2804
  super(`Cannot ${operation} system ${resourceType} "${resourceName}"`, code, {
1838
2805
  resourceType,
1839
2806
  resourceName,
@@ -2598,10 +3565,12 @@ var fileConfigSchema = baseConfigSchema.extend({
2598
3565
  maxFiles: import_zod4.z.number().int().min(1).optional(),
2599
3566
  maxSize: import_zod4.z.number().int().min(1).optional(),
2600
3567
  allowedTypes: import_zod4.z.array(import_zod4.z.string()).optional(),
2601
- verification: fileVerificationConfigSchema.optional()
3568
+ verification: fileVerificationConfigSchema.optional(),
3569
+ multiple: import_zod4.z.boolean().optional()
2602
3570
  });
2603
3571
  var userConfigSchema = baseConfigSchema.extend({
2604
- allowedRoles: import_zod4.z.array(import_zod4.z.string()).optional()
3572
+ allowedRoles: import_zod4.z.array(import_zod4.z.string()).optional(),
3573
+ multiple: import_zod4.z.boolean().optional()
2605
3574
  });
2606
3575
  var relationConfigSchema = baseConfigSchema.extend({
2607
3576
  targets: import_zod4.z.array(relationTargetSchema).min(1),
@@ -2613,6 +3582,30 @@ var ratingConfigSchema = baseConfigSchema.extend({
2613
3582
  max: import_zod4.z.number().int().min(1).optional(),
2614
3583
  iconType: import_zod4.z.enum(["star", "heart", "thumbs", "number"]).optional()
2615
3584
  });
3585
+ var formulaConfigSchema = baseConfigSchema.extend({
3586
+ expression: import_zod4.z.string().min(1),
3587
+ returnType: import_zod4.z.enum(["text", "number", "boolean", "date"]),
3588
+ decimals: import_zod4.z.number().int().min(0).max(10).optional(),
3589
+ allowRelations: import_zod4.z.boolean().optional()
3590
+ });
3591
+ var rollupConfigSchema = baseConfigSchema.extend({
3592
+ relationAttribute: import_zod4.z.string().min(1).optional(),
3593
+ relationPath: import_zod4.z.string().optional(),
3594
+ targetAttribute: import_zod4.z.string().min(1),
3595
+ function: import_zod4.z.enum([
3596
+ "sum",
3597
+ "count",
3598
+ "avg",
3599
+ "min",
3600
+ "max",
3601
+ "countValues",
3602
+ "countEmpty",
3603
+ "percentEmpty",
3604
+ "percentFilled"
3605
+ ]),
3606
+ decimals: import_zod4.z.number().int().min(0).max(10).optional(),
3607
+ materialize: import_zod4.z.boolean().optional()
3608
+ });
2616
3609
  var attributeConfigSchemas = {
2617
3610
  text: textConfigSchema,
2618
3611
  textarea: textareaConfigSchema,
@@ -2629,7 +3622,9 @@ var attributeConfigSchemas = {
2629
3622
  file: fileConfigSchema,
2630
3623
  user: userConfigSchema,
2631
3624
  relation: relationConfigSchema,
2632
- rating: ratingConfigSchema
3625
+ rating: ratingConfigSchema,
3626
+ formula: formulaConfigSchema,
3627
+ rollup: rollupConfigSchema
2633
3628
  };
2634
3629
  function getAttributeConfigSchema(type) {
2635
3630
  return attributeConfigSchemas[type];
@@ -2723,20 +3718,39 @@ function createLocationValidator(_attr) {
2723
3718
  function createTimestampValidator(_attr) {
2724
3719
  return import_zod4.z.number().int().positive();
2725
3720
  }
2726
- function createFileValidator(_attr) {
2727
- return import_zod4.z.string().uuid();
3721
+ function createFileValidator(attr) {
3722
+ const uuidSchema = import_zod4.z.uuid({
3723
+ message: `${attr.label} must be a valid file ID`
3724
+ });
3725
+ if (attr.multiple) {
3726
+ let arraySchema = import_zod4.z.array(uuidSchema);
3727
+ if (attr.maxFiles) {
3728
+ arraySchema = arraySchema.max(
3729
+ attr.maxFiles,
3730
+ `${attr.label} cannot have more than ${attr.maxFiles} file${attr.maxFiles > 1 ? "s" : ""}`
3731
+ );
3732
+ }
3733
+ return arraySchema;
3734
+ }
3735
+ return uuidSchema;
2728
3736
  }
2729
- function createUserValidator(_attr) {
2730
- return import_zod4.z.string().uuid();
3737
+ function createUserValidator(attr) {
3738
+ const uuidSchema = import_zod4.z.uuid({
3739
+ message: `${attr.label} must be a valid user ID`
3740
+ });
3741
+ if (attr.multiple) {
3742
+ return import_zod4.z.array(uuidSchema);
3743
+ }
3744
+ return uuidSchema;
2731
3745
  }
2732
3746
  function createSingleRelationValidator(attr) {
2733
- const uuidSchema = import_zod4.z.string().uuid({
3747
+ const uuidSchema = import_zod4.z.uuid({
2734
3748
  message: `${attr.label} must be a valid record ID`
2735
3749
  });
2736
3750
  return import_zod4.z.union([uuidSchema, import_zod4.z.null()]);
2737
3751
  }
2738
3752
  function createMultiRelationValidator(attr) {
2739
- const uuidSchema = import_zod4.z.string().uuid({
3753
+ const uuidSchema = import_zod4.z.uuid({
2740
3754
  message: `Each ${attr.label} item must be a valid record ID`
2741
3755
  });
2742
3756
  let arraySchema = import_zod4.z.array(uuidSchema);
@@ -2767,6 +3781,12 @@ function createRatingValidator(attr) {
2767
3781
  }
2768
3782
  return schema;
2769
3783
  }
3784
+ function createFormulaValidator(_attr) {
3785
+ return import_zod4.z.unknown();
3786
+ }
3787
+ function createRollupValidator(_attr) {
3788
+ return import_zod4.z.unknown();
3789
+ }
2770
3790
  function createAttributeValidator(attr) {
2771
3791
  switch (attr.type) {
2772
3792
  case "text":
@@ -2799,6 +3819,10 @@ function createAttributeValidator(attr) {
2799
3819
  return createRelationValidator(attr);
2800
3820
  case "rating":
2801
3821
  return createRatingValidator(attr);
3822
+ case "formula":
3823
+ return createFormulaValidator(attr);
3824
+ case "rollup":
3825
+ return createRollupValidator(attr);
2802
3826
  default:
2803
3827
  return import_zod4.z.unknown();
2804
3828
  }
@@ -4426,6 +5450,123 @@ var RelationService = class {
4426
5450
  }
4427
5451
  };
4428
5452
 
5453
+ // src/runtime/services/user.service.ts
5454
+ var UserService = class {
5455
+ constructor(adapter, tenantId) {
5456
+ this.adapter = adapter;
5457
+ this.tenantId = tenantId;
5458
+ }
5459
+ /**
5460
+ * Validate all user attributes in the data
5461
+ *
5462
+ * @param schema - Object schema containing attribute definitions
5463
+ * @param data - Record data to validate
5464
+ * @returns Validation result with errors if any
5465
+ *
5466
+ * @example
5467
+ * ```typescript
5468
+ * const result = await userService.validateUsers(schema, {
5469
+ * assignee: "user-123",
5470
+ * watchers: ["user-456", "user-789"]
5471
+ * });
5472
+ *
5473
+ * if (!result.valid) {
5474
+ * console.log(result.errors);
5475
+ * // [{ attribute: "assignee", message: "User not found", invalidIds: ["user-123"] }]
5476
+ * }
5477
+ * ```
5478
+ */
5479
+ async validateUsers(schema, data) {
5480
+ const errors = [];
5481
+ const userAttrs = schema.attributes.filter(
5482
+ (attr) => attr.type === "user"
5483
+ );
5484
+ for (const attr of userAttrs) {
5485
+ const value = data[attr.name];
5486
+ if (value === void 0 || value === null) {
5487
+ continue;
5488
+ }
5489
+ const attrErrors = await this.validateUserAttribute(attr, value);
5490
+ errors.push(...attrErrors);
5491
+ }
5492
+ return {
5493
+ valid: errors.length === 0,
5494
+ errors
5495
+ };
5496
+ }
5497
+ /**
5498
+ * Validate a single user attribute value
5499
+ */
5500
+ async validateUserAttribute(attr, value) {
5501
+ const errors = [];
5502
+ const ids = this.extractIds(attr, value);
5503
+ if (ids.length === 0) {
5504
+ return errors;
5505
+ }
5506
+ const invalidIds = [];
5507
+ const roleErrors = [];
5508
+ for (const id of ids) {
5509
+ const user = await this.adapter.userProfiles.findById(id);
5510
+ if (!user) {
5511
+ invalidIds.push(id);
5512
+ continue;
5513
+ }
5514
+ if (user.tenantId !== this.tenantId) {
5515
+ invalidIds.push(id);
5516
+ continue;
5517
+ }
5518
+ if (attr.allowedRoles && attr.allowedRoles.length > 0) {
5519
+ if (!attr.allowedRoles.includes(user.role)) {
5520
+ roleErrors.push(id);
5521
+ }
5522
+ }
5523
+ }
5524
+ if (invalidIds.length > 0) {
5525
+ errors.push({
5526
+ attribute: attr.name,
5527
+ message: `Invalid or non-existent users for ${attr.label}`,
5528
+ invalidIds
5529
+ });
5530
+ }
5531
+ if (roleErrors.length > 0) {
5532
+ errors.push({
5533
+ attribute: attr.name,
5534
+ message: `Users do not have required role for ${attr.label}. Allowed roles: ${attr.allowedRoles?.join(", ")}`,
5535
+ invalidIds: roleErrors
5536
+ });
5537
+ }
5538
+ return errors;
5539
+ }
5540
+ /**
5541
+ * Extract IDs from user value based on multiple flag
5542
+ */
5543
+ extractIds(attr, value) {
5544
+ if (attr.multiple) {
5545
+ if (!Array.isArray(value)) {
5546
+ return [];
5547
+ }
5548
+ return value.filter((v) => typeof v === "string" && v !== "");
5549
+ }
5550
+ if (typeof value === "string" && value !== "") {
5551
+ return [value];
5552
+ }
5553
+ return [];
5554
+ }
5555
+ /**
5556
+ * Validate users and throw if invalid
5557
+ */
5558
+ async validateUsersOrThrow(schema, data) {
5559
+ const result = await this.validateUsers(schema, data);
5560
+ if (!result.valid) {
5561
+ const errors = result.errors.map((e) => ({
5562
+ path: [e.attribute],
5563
+ message: e.message
5564
+ }));
5565
+ throw new ValidationError("User validation failed", errors);
5566
+ }
5567
+ }
5568
+ };
5569
+
4429
5570
  // src/runtime/services/record.service.ts
4430
5571
  var RecordService = class {
4431
5572
  constructor(adapter, tenantId, options) {
@@ -4433,6 +5574,7 @@ var RecordService = class {
4433
5574
  this.tenantId = tenantId;
4434
5575
  this.schemaService = new ObjectSchemaService(adapter, registry);
4435
5576
  this.relationService = new RelationService(adapter, registry);
5577
+ this.userService = new UserService(adapter, tenantId);
4436
5578
  this.hookRegistry = options?.hookRegistry ?? new NoopHookRegistry();
4437
5579
  this.permissionService = options?.permissionService;
4438
5580
  this.auditService = options?.auditService ?? (adapter.audit ? new AuditService(adapter, tenantId) : void 0);
@@ -4573,6 +5715,9 @@ var RecordService = class {
4573
5715
  if (!options?.skipRelationValidation) {
4574
5716
  await this.relationService.validateRelationsOrThrow(schema, data);
4575
5717
  }
5718
+ if (!options?.skipUserValidation) {
5719
+ await this.userService.validateUsersOrThrow(schema, data);
5720
+ }
4576
5721
  }
4577
5722
  const completionStatus = computeRecordStatus(schema, data);
4578
5723
  const label = await this.computeLabel(schema, data);
@@ -4617,13 +5762,17 @@ var RecordService = class {
4617
5762
  if (!record) {
4618
5763
  return null;
4619
5764
  }
5765
+ const schema = await this.schemaService.getObjectSchema(record.objectId);
5766
+ let enrichedRecord = record;
5767
+ if (!options?.skipFormulas) {
5768
+ enrichedRecord = this.enrichWithFormulas(record, schema);
5769
+ }
4620
5770
  if (options?.includeSchema) {
4621
- const schema = await this.schemaService.getObjectSchema(record.objectId);
4622
- const recordWithSchema = record;
5771
+ const recordWithSchema = enrichedRecord;
4623
5772
  recordWithSchema.schema = schema;
4624
5773
  return recordWithSchema;
4625
5774
  }
4626
- return record;
5775
+ return enrichedRecord;
4627
5776
  }
4628
5777
  /**
4629
5778
  * Get a record by ID or throw if not found
@@ -4684,6 +5833,9 @@ var RecordService = class {
4684
5833
  if (!options?.skipRelationValidation) {
4685
5834
  await this.relationService.validateRelationsOrThrow(schema, data);
4686
5835
  }
5836
+ if (!options?.skipUserValidation) {
5837
+ await this.userService.validateUsersOrThrow(schema, data);
5838
+ }
4687
5839
  }
4688
5840
  const completionStatus = computeRecordStatus(schema, mergedData);
4689
5841
  const label = await this.computeLabel(schema, mergedData);
@@ -4915,6 +6067,46 @@ var RecordService = class {
4915
6067
  timestamp: /* @__PURE__ */ new Date()
4916
6068
  };
4917
6069
  }
6070
+ /**
6071
+ * Enrich a record with computed formula values
6072
+ *
6073
+ * Formula attributes are calculated at read-time from the record's values.
6074
+ * This method adds the computed values to the record's values object.
6075
+ *
6076
+ * @param record - The record to enrich
6077
+ * @param schema - The object schema containing attribute definitions
6078
+ * @returns Record with formula values computed
6079
+ * @internal
6080
+ */
6081
+ enrichWithFormulas(record, schema) {
6082
+ const formulaAttrs = schema.attributes.filter(
6083
+ (a) => a.type === "formula"
6084
+ );
6085
+ if (formulaAttrs.length === 0) {
6086
+ return record;
6087
+ }
6088
+ const enrichedValues = { ...record.values };
6089
+ for (const attr of formulaAttrs) {
6090
+ enrichedValues[attr.name] = evaluateFormulaAttribute(attr, record.values);
6091
+ }
6092
+ return {
6093
+ ...record,
6094
+ values: enrichedValues
6095
+ };
6096
+ }
6097
+ /**
6098
+ * Enrich multiple records with computed formula values
6099
+ * @internal
6100
+ */
6101
+ enrichRecordsWithFormulas(records, schema) {
6102
+ const formulaAttrs = schema.attributes.filter(
6103
+ (a) => a.type === "formula"
6104
+ );
6105
+ if (formulaAttrs.length === 0) {
6106
+ return records;
6107
+ }
6108
+ return records.map((record) => this.enrichWithFormulas(record, schema));
6109
+ }
4918
6110
  /**
4919
6111
  * Permanently delete a record (hard delete)
4920
6112
  *
@@ -4943,11 +6135,18 @@ var RecordService = class {
4943
6135
  * @returns Records and total count
4944
6136
  */
4945
6137
  async listRecords(objectId, options) {
6138
+ const schema = await this.schemaService.getObjectSchema(objectId);
4946
6139
  if (this.permissionService && this.userId) {
4947
- const schema = await this.schemaService.getObjectSchema(objectId);
4948
6140
  await this.checkPermission(schema.name, "read");
4949
6141
  }
4950
- return await this.adapter.objectRecords.list(this.tenantId, objectId, options);
6142
+ const result = await this.adapter.objectRecords.list(this.tenantId, objectId, options);
6143
+ if (!options?.skipFormulas) {
6144
+ return {
6145
+ records: this.enrichRecordsWithFormulas(result.records, schema),
6146
+ total: result.total
6147
+ };
6148
+ }
6149
+ return result;
4951
6150
  }
4952
6151
  /**
4953
6152
  * Search records using full-text search
@@ -4958,11 +6157,18 @@ var RecordService = class {
4958
6157
  * @returns Matching records and total count
4959
6158
  */
4960
6159
  async searchRecords(objectId, query, options) {
6160
+ const schema = await this.schemaService.getObjectSchema(objectId);
4961
6161
  if (this.permissionService && this.userId) {
4962
- const schema = await this.schemaService.getObjectSchema(objectId);
4963
6162
  await this.checkPermission(schema.name, "read");
4964
6163
  }
4965
- return await this.adapter.objectRecords.search(this.tenantId, objectId, query, options);
6164
+ const result = await this.adapter.objectRecords.search(this.tenantId, objectId, query, options);
6165
+ if (!options?.skipFormulas) {
6166
+ return {
6167
+ records: this.enrichRecordsWithFormulas(result.records, schema),
6168
+ total: result.total
6169
+ };
6170
+ }
6171
+ return result;
4966
6172
  }
4967
6173
  /**
4968
6174
  * Validate data against object schema without saving
@@ -5007,6 +6213,231 @@ var RecordService = class {
5007
6213
  }
5008
6214
  };
5009
6215
 
6216
+ // src/runtime/services/relation-resolver.service.ts
6217
+ var RelationResolverService = class {
6218
+ constructor(adapter) {
6219
+ this.adapter = adapter;
6220
+ }
6221
+ /**
6222
+ * Resolve values from related records for formula evaluation
6223
+ *
6224
+ * Phase 2: Supports 1 level of relation traversal only
6225
+ *
6226
+ * @param record - The source record
6227
+ * @param schema - Schema of the source object
6228
+ * @param relationNames - Names of relation attributes to resolve
6229
+ * @returns Map of relation name to related record's values
6230
+ *
6231
+ * @example
6232
+ * ```typescript
6233
+ * // For an order with company relation
6234
+ * const resolved = await resolver.resolveRelationValues(
6235
+ * orderRecord,
6236
+ * orderSchema,
6237
+ * ["company"]
6238
+ * );
6239
+ * // → { company: { id: "...", name: "Acme Corp", ... } }
6240
+ * ```
6241
+ */
6242
+ async resolveRelationValues(record, schema, relationNames) {
6243
+ const result = {};
6244
+ const relationAttrs = schema.attributes.filter(
6245
+ (a) => a.type === "relation" && relationNames.includes(a.name)
6246
+ );
6247
+ if (relationAttrs.length === 0) {
6248
+ return result;
6249
+ }
6250
+ const idsToFetch = [];
6251
+ const attrIdMap = /* @__PURE__ */ new Map();
6252
+ for (const attr of relationAttrs) {
6253
+ const relatedId = this.extractSingleId(record.values[attr.name]);
6254
+ if (relatedId) {
6255
+ idsToFetch.push(relatedId);
6256
+ attrIdMap.set(relatedId, attr.name);
6257
+ }
6258
+ }
6259
+ if (idsToFetch.length === 0) {
6260
+ return result;
6261
+ }
6262
+ const relatedRecords = await this.adapter.objectRecords.findByIds(idsToFetch);
6263
+ for (const relatedRecord of relatedRecords) {
6264
+ const attrName = attrIdMap.get(relatedRecord.id);
6265
+ if (attrName) {
6266
+ result[attrName] = relatedRecord.values;
6267
+ }
6268
+ }
6269
+ return result;
6270
+ }
6271
+ /**
6272
+ * Resolve relation values for multiple records (batched)
6273
+ *
6274
+ * Optimized for list operations - fetches all related records in one batch
6275
+ *
6276
+ * @param records - Source records
6277
+ * @param schema - Schema of the source object
6278
+ * @param relationNames - Names of relation attributes to resolve
6279
+ * @returns Map of record ID to resolved relations
6280
+ */
6281
+ async resolveRelationValuesForMany(records, schema, relationNames) {
6282
+ const resultMap = /* @__PURE__ */ new Map();
6283
+ for (const record of records) {
6284
+ resultMap.set(record.id, {});
6285
+ }
6286
+ const relationAttrs = schema.attributes.filter(
6287
+ (a) => a.type === "relation" && relationNames.includes(a.name)
6288
+ );
6289
+ if (relationAttrs.length === 0) {
6290
+ return resultMap;
6291
+ }
6292
+ const allIdsToFetch = /* @__PURE__ */ new Set();
6293
+ const recordRelationMap = /* @__PURE__ */ new Map();
6294
+ for (const record of records) {
6295
+ const idToAttr = /* @__PURE__ */ new Map();
6296
+ for (const attr of relationAttrs) {
6297
+ const relatedId = this.extractSingleId(record.values[attr.name]);
6298
+ if (relatedId) {
6299
+ allIdsToFetch.add(relatedId);
6300
+ idToAttr.set(relatedId, attr.name);
6301
+ }
6302
+ }
6303
+ recordRelationMap.set(record.id, idToAttr);
6304
+ }
6305
+ if (allIdsToFetch.size === 0) {
6306
+ return resultMap;
6307
+ }
6308
+ const relatedRecords = await this.adapter.objectRecords.findByIds([...allIdsToFetch]);
6309
+ const relatedRecordMap = new Map(relatedRecords.map((r) => [r.id, r]));
6310
+ for (const record of records) {
6311
+ const result = resultMap.get(record.id);
6312
+ const idToAttr = recordRelationMap.get(record.id);
6313
+ for (const [relatedId, attrName] of idToAttr) {
6314
+ const relatedRecord = relatedRecordMap.get(relatedId);
6315
+ if (relatedRecord) {
6316
+ result[attrName] = relatedRecord.values;
6317
+ }
6318
+ }
6319
+ }
6320
+ return resultMap;
6321
+ }
6322
+ /**
6323
+ * Flatten resolved relations for formula evaluation
6324
+ *
6325
+ * Converts nested structure to dot-notation keys:
6326
+ * { company: { name: "Acme" } } → { "company.name": "Acme" }
6327
+ *
6328
+ * @param resolved - Resolved relations from resolveRelationValues
6329
+ * @returns Flattened values suitable for formula evaluation
6330
+ */
6331
+ flattenResolvedRelations(resolved) {
6332
+ const flat = {};
6333
+ for (const [relationName, values] of Object.entries(resolved)) {
6334
+ for (const [key, value] of Object.entries(values)) {
6335
+ flat[`${relationName}.${key}`] = value;
6336
+ }
6337
+ }
6338
+ return flat;
6339
+ }
6340
+ /**
6341
+ * Extract a single relation ID from a value
6342
+ * Handles both single (string) and multi (array) relations
6343
+ * @internal
6344
+ */
6345
+ extractSingleId(value) {
6346
+ if (typeof value === "string" && value.length > 0) {
6347
+ return value;
6348
+ }
6349
+ if (Array.isArray(value) && value.length > 0 && typeof value[0] === "string") {
6350
+ return value[0];
6351
+ }
6352
+ return null;
6353
+ }
6354
+ };
6355
+
6356
+ // src/runtime/services/rollup-scheduler.ts
6357
+ var RollupScheduler = class {
6358
+ constructor(adapter, getSchemaById, options) {
6359
+ this.adapter = adapter;
6360
+ this.getSchemaById = getSchemaById;
6361
+ this.pending = /* @__PURE__ */ new Map();
6362
+ this.rollupService = new RollupService(adapter);
6363
+ this.debounceMs = options?.debounceMs ?? 100;
6364
+ this.maxPending = options?.maxPending ?? 100;
6365
+ }
6366
+ /**
6367
+ * Schedule a rollup recalculation for a parent record
6368
+ *
6369
+ * If the same record is already pending, the timer is reset.
6370
+ * If too many recalculations are pending, triggers an immediate flush.
6371
+ *
6372
+ * @param parentId - ID of the parent record to recalculate
6373
+ * @param parentObjectId - Object ID of the parent
6374
+ * @param tenantId - Tenant ID
6375
+ */
6376
+ scheduleRecalculation(parentId, parentObjectId, tenantId) {
6377
+ const key = `${parentId}:${parentObjectId}`;
6378
+ const existing = this.pending.get(key);
6379
+ if (existing) {
6380
+ clearTimeout(existing.timeout);
6381
+ }
6382
+ const timeout = setTimeout(async () => {
6383
+ await this.executeRecalculation(parentId, parentObjectId, tenantId);
6384
+ this.pending.delete(key);
6385
+ }, this.debounceMs);
6386
+ this.pending.set(key, { parentId, parentObjectId, tenantId, timeout });
6387
+ if (this.pending.size >= this.maxPending) {
6388
+ this.flush();
6389
+ }
6390
+ }
6391
+ /**
6392
+ * Execute all pending recalculations immediately
6393
+ */
6394
+ async flush() {
6395
+ const entries = Array.from(this.pending.entries());
6396
+ for (const [, entry] of entries) {
6397
+ clearTimeout(entry.timeout);
6398
+ }
6399
+ this.pending.clear();
6400
+ const tenantMap = /* @__PURE__ */ new Map();
6401
+ for (const [, entry] of entries) {
6402
+ tenantMap.set(entry.parentId, entry.tenantId);
6403
+ }
6404
+ const allIds = entries.map(([, entry]) => entry.parentId);
6405
+ const records = await this.adapter.objectRecords.findByIds(allIds);
6406
+ for (const record of records) {
6407
+ const schema = await this.getSchemaById(record.objectId);
6408
+ const tenantId = tenantMap.get(record.id);
6409
+ if (schema && tenantId) {
6410
+ await this.rollupService.recalculateAndUpdate(record, schema, tenantId);
6411
+ }
6412
+ }
6413
+ }
6414
+ /**
6415
+ * Execute a single recalculation
6416
+ */
6417
+ async executeRecalculation(parentId, parentObjectId, tenantId) {
6418
+ const record = await this.adapter.objectRecords.findById(parentId);
6419
+ if (!record) return;
6420
+ const schema = await this.getSchemaById(parentObjectId);
6421
+ if (!schema) return;
6422
+ await this.rollupService.recalculateAndUpdate(record, schema, tenantId);
6423
+ }
6424
+ /**
6425
+ * Get number of pending recalculations
6426
+ */
6427
+ get pendingCount() {
6428
+ return this.pending.size;
6429
+ }
6430
+ /**
6431
+ * Clear all pending recalculations without executing them
6432
+ */
6433
+ clear() {
6434
+ for (const [, entry] of this.pending) {
6435
+ clearTimeout(entry.timeout);
6436
+ }
6437
+ this.pending.clear();
6438
+ }
6439
+ };
6440
+
5010
6441
  // src/runtime/services/user-profile.service.ts
5011
6442
  var UserProfileService = class {
5012
6443
  constructor(adapter, tenantId, options) {
@@ -5341,7 +6772,7 @@ var ViewService = class {
5341
6772
  throw new NotFoundError("View", viewId);
5342
6773
  }
5343
6774
  if (dbView.system) {
5344
- throw new ProtectedResourceError("attribute", dbView.name, "modify");
6775
+ throw new ProtectedResourceError("view", dbView.name, "modify");
5345
6776
  }
5346
6777
  const updated = await this.adapter.views.update(viewId, {
5347
6778
  label: input.label,
@@ -5364,7 +6795,7 @@ var ViewService = class {
5364
6795
  throw new NotFoundError("View", viewId);
5365
6796
  }
5366
6797
  if (dbView.system) {
5367
- throw new ProtectedResourceError("attribute", dbView.name, "delete");
6798
+ throw new ProtectedResourceError("view", dbView.name, "delete");
5368
6799
  }
5369
6800
  await this.adapter.views.delete(viewId);
5370
6801
  }
@@ -5704,7 +7135,8 @@ async function syncAttributes(adapter, nativeObject, dbObject, existingObject, r
5704
7135
  hidden: attr.hidden,
5705
7136
  archived: attr.archived,
5706
7137
  deprecated: attr.deprecated,
5707
- defaultValue: attr.defaultValue,
7138
+ // Formula and rollup attributes don't have defaultValue
7139
+ defaultValue: "defaultValue" in attr ? attr.defaultValue : void 0,
5708
7140
  config: extractAttributeConfig(attr)
5709
7141
  });
5710
7142
  if (existingAttr) {
@@ -5783,25 +7215,54 @@ var NoopGeocodingAdapter = class {
5783
7215
  FlowService,
5784
7216
  GeocodingService,
5785
7217
  GlobalSearchService,
7218
+ InvalidPathError,
7219
+ MaxDepthExceededError,
5786
7220
  NoopGeocodingAdapter,
5787
7221
  NoopHookRegistry,
5788
7222
  ObjectSchemaService,
5789
7223
  PermissionService,
5790
7224
  RecordService,
7225
+ RelationResolverService,
5791
7226
  RelationService,
7227
+ RollupScheduler,
7228
+ RollupService,
5792
7229
  UserProfileService,
7230
+ UserService,
5793
7231
  ViewService,
5794
7232
  buildAuditChanges,
5795
7233
  createMockAdapter,
7234
+ createRollupHooks,
5796
7235
  enrichValuesWithSelectLabels,
7236
+ evaluateFormula,
7237
+ evaluateFormulaAttribute,
7238
+ evaluateFormulaAttributeWithRelations,
7239
+ evaluateFormulaWithRelations,
7240
+ evaluateFormulaWithResult,
5797
7241
  extractAttributeNames,
7242
+ extractFormulaVariables,
7243
+ extractRelationNames,
7244
+ extractRelationReferences,
7245
+ flattenRelationsForEval,
7246
+ formatFormulaResult,
7247
+ getPathDepth,
7248
+ getRelationPath,
5798
7249
  getSyncPreview,
7250
+ getTargetAttributeName,
5799
7251
  getViewSyncPreview,
7252
+ hasRelationReferences,
5800
7253
  isLabelExpression,
7254
+ parsePath,
7255
+ pathHasManyCardinality,
7256
+ registerAllRollupHooks,
5801
7257
  renderLabelExpression,
7258
+ resolveMultiplePaths,
7259
+ resolveSingleValue,
5802
7260
  syncAll,
5803
7261
  syncNativeObjects,
5804
7262
  syncNativeViews,
7263
+ traversePath,
7264
+ validateFormulaExpression,
7265
+ validatePath,
5805
7266
  verifyNativeObjectsSync,
5806
7267
  verifyNativeViewsSync
5807
7268
  });