@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.
- package/dist/chunk-2PBQNUTS.mjs +7400 -0
- package/dist/chunk-4D476S25.mjs +7713 -0
- package/dist/chunk-BKOV4XVX.mjs +8816 -0
- package/dist/chunk-CWPOGEK2.mjs +7707 -0
- package/dist/chunk-DPOXIK3M.mjs +8816 -0
- package/dist/chunk-DU2HLOQH.mjs +8813 -0
- package/dist/chunk-EWNSOD22.mjs +7707 -0
- package/dist/chunk-JD2MVLTW.mjs +8814 -0
- package/dist/chunk-KZSM5546.mjs +7706 -0
- package/dist/chunk-L7AQ7SZC.mjs +7432 -0
- package/dist/chunk-LZW2MI43.mjs +7404 -0
- package/dist/chunk-SD2UXOSF.mjs +8814 -0
- package/dist/chunk-V5QXU2OK.mjs +82 -0
- package/dist/chunk-XMGMXAHW.mjs +7706 -0
- package/dist/chunk-YLVZT3H4.mjs +8793 -0
- package/dist/default-roles-AKWIWTLE.mjs +20 -0
- package/dist/index.d.mts +127 -8
- package/dist/index.d.ts +127 -8
- package/dist/index.js +1787 -182
- package/dist/index.mjs +90 -3
- package/dist/runtime-CPggQqoW.d.mts +6428 -0
- package/dist/runtime-CPggQqoW.d.ts +6428 -0
- package/dist/runtime-DSMMZRLE.d.mts +6430 -0
- package/dist/runtime-DSMMZRLE.d.ts +6430 -0
- package/dist/runtime-DWhSpnn4.d.mts +5256 -0
- package/dist/runtime-DWhSpnn4.d.ts +5256 -0
- package/dist/runtime-Dw2exb5q.d.mts +5601 -0
- package/dist/runtime-Dw2exb5q.d.ts +5601 -0
- package/dist/runtime-DySuNV6Y.d.mts +5245 -0
- package/dist/runtime-DySuNV6Y.d.ts +5245 -0
- package/dist/runtime-DzdsFCyL.d.mts +5601 -0
- package/dist/runtime-DzdsFCyL.d.ts +5601 -0
- package/dist/runtime-uXhTgSIx.d.mts +5260 -0
- package/dist/runtime-uXhTgSIx.d.ts +5260 -0
- package/dist/runtime.d.mts +1 -1
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +1520 -59
- package/dist/runtime.mjs +59 -1
- 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
|
-
*
|
|
2277
|
+
* Upload a file to storage and create metadata record.
|
|
1562
2278
|
*
|
|
1563
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1660
|
-
return
|
|
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
|
-
|
|
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(
|
|
2727
|
-
|
|
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(
|
|
2730
|
-
|
|
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.
|
|
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.
|
|
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
|
|
4622
|
-
const recordWithSchema = record;
|
|
5771
|
+
const recordWithSchema = enrichedRecord;
|
|
4623
5772
|
recordWithSchema.schema = schema;
|
|
4624
5773
|
return recordWithSchema;
|
|
4625
5774
|
}
|
|
4626
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
});
|