@stndrds/schema 0.1.0-alpha.18 → 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-BKOV4XVX.mjs +8816 -0
- package/dist/chunk-DPOXIK3M.mjs +8816 -0
- package/dist/chunk-DU2HLOQH.mjs +8813 -0
- package/dist/chunk-JD2MVLTW.mjs +8814 -0
- package/dist/chunk-SD2UXOSF.mjs +8814 -0
- package/dist/chunk-YLVZT3H4.mjs +8793 -0
- package/dist/index.d.mts +114 -6
- package/dist/index.d.ts +114 -6
- package/dist/index.js +1169 -11
- package/dist/index.mjs +87 -2
- 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.d.mts +1 -1
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +1037 -9
- package/dist/runtime.mjs +57 -1
- package/package.json +3 -2
package/dist/runtime.js
CHANGED
|
@@ -128,37 +128,697 @@ __export(runtime_exports, {
|
|
|
128
128
|
FlowService: () => FlowService,
|
|
129
129
|
GeocodingService: () => GeocodingService,
|
|
130
130
|
GlobalSearchService: () => GlobalSearchService,
|
|
131
|
+
InvalidPathError: () => InvalidPathError,
|
|
132
|
+
MaxDepthExceededError: () => MaxDepthExceededError,
|
|
131
133
|
NoopGeocodingAdapter: () => NoopGeocodingAdapter,
|
|
132
134
|
NoopHookRegistry: () => NoopHookRegistry,
|
|
133
135
|
ObjectSchemaService: () => ObjectSchemaService,
|
|
134
136
|
PermissionService: () => PermissionService,
|
|
135
137
|
RecordService: () => RecordService,
|
|
138
|
+
RelationResolverService: () => RelationResolverService,
|
|
136
139
|
RelationService: () => RelationService,
|
|
140
|
+
RollupScheduler: () => RollupScheduler,
|
|
141
|
+
RollupService: () => RollupService,
|
|
137
142
|
UserProfileService: () => UserProfileService,
|
|
138
143
|
UserService: () => UserService,
|
|
139
144
|
ViewService: () => ViewService,
|
|
140
145
|
buildAuditChanges: () => buildAuditChanges,
|
|
141
146
|
createMockAdapter: () => createMockAdapter,
|
|
147
|
+
createRollupHooks: () => createRollupHooks,
|
|
142
148
|
enrichValuesWithSelectLabels: () => enrichValuesWithSelectLabels,
|
|
149
|
+
evaluateFormula: () => evaluateFormula,
|
|
150
|
+
evaluateFormulaAttribute: () => evaluateFormulaAttribute,
|
|
151
|
+
evaluateFormulaAttributeWithRelations: () => evaluateFormulaAttributeWithRelations,
|
|
152
|
+
evaluateFormulaWithRelations: () => evaluateFormulaWithRelations,
|
|
153
|
+
evaluateFormulaWithResult: () => evaluateFormulaWithResult,
|
|
143
154
|
extractAttributeNames: () => extractAttributeNames,
|
|
155
|
+
extractFormulaVariables: () => extractFormulaVariables,
|
|
156
|
+
extractRelationNames: () => extractRelationNames,
|
|
157
|
+
extractRelationReferences: () => extractRelationReferences,
|
|
158
|
+
flattenRelationsForEval: () => flattenRelationsForEval,
|
|
159
|
+
formatFormulaResult: () => formatFormulaResult,
|
|
160
|
+
getPathDepth: () => getPathDepth,
|
|
161
|
+
getRelationPath: () => getRelationPath,
|
|
144
162
|
getSyncPreview: () => getSyncPreview,
|
|
163
|
+
getTargetAttributeName: () => getTargetAttributeName,
|
|
145
164
|
getViewSyncPreview: () => getViewSyncPreview,
|
|
165
|
+
hasRelationReferences: () => hasRelationReferences,
|
|
146
166
|
isLabelExpression: () => isLabelExpression,
|
|
167
|
+
parsePath: () => parsePath,
|
|
168
|
+
pathHasManyCardinality: () => pathHasManyCardinality,
|
|
169
|
+
registerAllRollupHooks: () => registerAllRollupHooks,
|
|
147
170
|
renderLabelExpression: () => renderLabelExpression,
|
|
171
|
+
resolveMultiplePaths: () => resolveMultiplePaths,
|
|
172
|
+
resolveSingleValue: () => resolveSingleValue,
|
|
148
173
|
syncAll: () => syncAll,
|
|
149
174
|
syncNativeObjects: () => syncNativeObjects,
|
|
150
175
|
syncNativeViews: () => syncNativeViews,
|
|
176
|
+
traversePath: () => traversePath,
|
|
177
|
+
validateFormulaExpression: () => validateFormulaExpression,
|
|
178
|
+
validatePath: () => validatePath,
|
|
151
179
|
verifyNativeObjectsSync: () => verifyNativeObjectsSync,
|
|
152
180
|
verifyNativeViewsSync: () => verifyNativeViewsSync
|
|
153
181
|
});
|
|
154
182
|
module.exports = __toCommonJS(runtime_exports);
|
|
155
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
|
+
|
|
156
562
|
// src/runtime/hooks/hook.registry.ts
|
|
157
563
|
var NoopHookRegistry = class {
|
|
158
564
|
async execute() {
|
|
159
565
|
}
|
|
160
566
|
};
|
|
161
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
|
+
|
|
162
822
|
// src/utils.ts
|
|
163
823
|
function generateId() {
|
|
164
824
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
@@ -997,6 +1657,26 @@ function createMockObjectRecordsRepository(stores) {
|
|
|
997
1657
|
}
|
|
998
1658
|
}
|
|
999
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);
|
|
1000
1680
|
}
|
|
1001
1681
|
};
|
|
1002
1682
|
}
|
|
@@ -2902,6 +3582,30 @@ var ratingConfigSchema = baseConfigSchema.extend({
|
|
|
2902
3582
|
max: import_zod4.z.number().int().min(1).optional(),
|
|
2903
3583
|
iconType: import_zod4.z.enum(["star", "heart", "thumbs", "number"]).optional()
|
|
2904
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
|
+
});
|
|
2905
3609
|
var attributeConfigSchemas = {
|
|
2906
3610
|
text: textConfigSchema,
|
|
2907
3611
|
textarea: textareaConfigSchema,
|
|
@@ -2918,7 +3622,9 @@ var attributeConfigSchemas = {
|
|
|
2918
3622
|
file: fileConfigSchema,
|
|
2919
3623
|
user: userConfigSchema,
|
|
2920
3624
|
relation: relationConfigSchema,
|
|
2921
|
-
rating: ratingConfigSchema
|
|
3625
|
+
rating: ratingConfigSchema,
|
|
3626
|
+
formula: formulaConfigSchema,
|
|
3627
|
+
rollup: rollupConfigSchema
|
|
2922
3628
|
};
|
|
2923
3629
|
function getAttributeConfigSchema(type) {
|
|
2924
3630
|
return attributeConfigSchemas[type];
|
|
@@ -3075,6 +3781,12 @@ function createRatingValidator(attr) {
|
|
|
3075
3781
|
}
|
|
3076
3782
|
return schema;
|
|
3077
3783
|
}
|
|
3784
|
+
function createFormulaValidator(_attr) {
|
|
3785
|
+
return import_zod4.z.unknown();
|
|
3786
|
+
}
|
|
3787
|
+
function createRollupValidator(_attr) {
|
|
3788
|
+
return import_zod4.z.unknown();
|
|
3789
|
+
}
|
|
3078
3790
|
function createAttributeValidator(attr) {
|
|
3079
3791
|
switch (attr.type) {
|
|
3080
3792
|
case "text":
|
|
@@ -3107,6 +3819,10 @@ function createAttributeValidator(attr) {
|
|
|
3107
3819
|
return createRelationValidator(attr);
|
|
3108
3820
|
case "rating":
|
|
3109
3821
|
return createRatingValidator(attr);
|
|
3822
|
+
case "formula":
|
|
3823
|
+
return createFormulaValidator(attr);
|
|
3824
|
+
case "rollup":
|
|
3825
|
+
return createRollupValidator(attr);
|
|
3110
3826
|
default:
|
|
3111
3827
|
return import_zod4.z.unknown();
|
|
3112
3828
|
}
|
|
@@ -5046,13 +5762,17 @@ var RecordService = class {
|
|
|
5046
5762
|
if (!record) {
|
|
5047
5763
|
return null;
|
|
5048
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
|
+
}
|
|
5049
5770
|
if (options?.includeSchema) {
|
|
5050
|
-
const
|
|
5051
|
-
const recordWithSchema = record;
|
|
5771
|
+
const recordWithSchema = enrichedRecord;
|
|
5052
5772
|
recordWithSchema.schema = schema;
|
|
5053
5773
|
return recordWithSchema;
|
|
5054
5774
|
}
|
|
5055
|
-
return
|
|
5775
|
+
return enrichedRecord;
|
|
5056
5776
|
}
|
|
5057
5777
|
/**
|
|
5058
5778
|
* Get a record by ID or throw if not found
|
|
@@ -5347,6 +6067,46 @@ var RecordService = class {
|
|
|
5347
6067
|
timestamp: /* @__PURE__ */ new Date()
|
|
5348
6068
|
};
|
|
5349
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
|
+
}
|
|
5350
6110
|
/**
|
|
5351
6111
|
* Permanently delete a record (hard delete)
|
|
5352
6112
|
*
|
|
@@ -5375,11 +6135,18 @@ var RecordService = class {
|
|
|
5375
6135
|
* @returns Records and total count
|
|
5376
6136
|
*/
|
|
5377
6137
|
async listRecords(objectId, options) {
|
|
6138
|
+
const schema = await this.schemaService.getObjectSchema(objectId);
|
|
5378
6139
|
if (this.permissionService && this.userId) {
|
|
5379
|
-
const schema = await this.schemaService.getObjectSchema(objectId);
|
|
5380
6140
|
await this.checkPermission(schema.name, "read");
|
|
5381
6141
|
}
|
|
5382
|
-
|
|
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;
|
|
5383
6150
|
}
|
|
5384
6151
|
/**
|
|
5385
6152
|
* Search records using full-text search
|
|
@@ -5390,11 +6157,18 @@ var RecordService = class {
|
|
|
5390
6157
|
* @returns Matching records and total count
|
|
5391
6158
|
*/
|
|
5392
6159
|
async searchRecords(objectId, query, options) {
|
|
6160
|
+
const schema = await this.schemaService.getObjectSchema(objectId);
|
|
5393
6161
|
if (this.permissionService && this.userId) {
|
|
5394
|
-
const schema = await this.schemaService.getObjectSchema(objectId);
|
|
5395
6162
|
await this.checkPermission(schema.name, "read");
|
|
5396
6163
|
}
|
|
5397
|
-
|
|
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;
|
|
5398
6172
|
}
|
|
5399
6173
|
/**
|
|
5400
6174
|
* Validate data against object schema without saving
|
|
@@ -5439,6 +6213,231 @@ var RecordService = class {
|
|
|
5439
6213
|
}
|
|
5440
6214
|
};
|
|
5441
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
|
+
|
|
5442
6441
|
// src/runtime/services/user-profile.service.ts
|
|
5443
6442
|
var UserProfileService = class {
|
|
5444
6443
|
constructor(adapter, tenantId, options) {
|
|
@@ -6136,7 +7135,8 @@ async function syncAttributes(adapter, nativeObject, dbObject, existingObject, r
|
|
|
6136
7135
|
hidden: attr.hidden,
|
|
6137
7136
|
archived: attr.archived,
|
|
6138
7137
|
deprecated: attr.deprecated,
|
|
6139
|
-
|
|
7138
|
+
// Formula and rollup attributes don't have defaultValue
|
|
7139
|
+
defaultValue: "defaultValue" in attr ? attr.defaultValue : void 0,
|
|
6140
7140
|
config: extractAttributeConfig(attr)
|
|
6141
7141
|
});
|
|
6142
7142
|
if (existingAttr) {
|
|
@@ -6215,26 +7215,54 @@ var NoopGeocodingAdapter = class {
|
|
|
6215
7215
|
FlowService,
|
|
6216
7216
|
GeocodingService,
|
|
6217
7217
|
GlobalSearchService,
|
|
7218
|
+
InvalidPathError,
|
|
7219
|
+
MaxDepthExceededError,
|
|
6218
7220
|
NoopGeocodingAdapter,
|
|
6219
7221
|
NoopHookRegistry,
|
|
6220
7222
|
ObjectSchemaService,
|
|
6221
7223
|
PermissionService,
|
|
6222
7224
|
RecordService,
|
|
7225
|
+
RelationResolverService,
|
|
6223
7226
|
RelationService,
|
|
7227
|
+
RollupScheduler,
|
|
7228
|
+
RollupService,
|
|
6224
7229
|
UserProfileService,
|
|
6225
7230
|
UserService,
|
|
6226
7231
|
ViewService,
|
|
6227
7232
|
buildAuditChanges,
|
|
6228
7233
|
createMockAdapter,
|
|
7234
|
+
createRollupHooks,
|
|
6229
7235
|
enrichValuesWithSelectLabels,
|
|
7236
|
+
evaluateFormula,
|
|
7237
|
+
evaluateFormulaAttribute,
|
|
7238
|
+
evaluateFormulaAttributeWithRelations,
|
|
7239
|
+
evaluateFormulaWithRelations,
|
|
7240
|
+
evaluateFormulaWithResult,
|
|
6230
7241
|
extractAttributeNames,
|
|
7242
|
+
extractFormulaVariables,
|
|
7243
|
+
extractRelationNames,
|
|
7244
|
+
extractRelationReferences,
|
|
7245
|
+
flattenRelationsForEval,
|
|
7246
|
+
formatFormulaResult,
|
|
7247
|
+
getPathDepth,
|
|
7248
|
+
getRelationPath,
|
|
6231
7249
|
getSyncPreview,
|
|
7250
|
+
getTargetAttributeName,
|
|
6232
7251
|
getViewSyncPreview,
|
|
7252
|
+
hasRelationReferences,
|
|
6233
7253
|
isLabelExpression,
|
|
7254
|
+
parsePath,
|
|
7255
|
+
pathHasManyCardinality,
|
|
7256
|
+
registerAllRollupHooks,
|
|
6234
7257
|
renderLabelExpression,
|
|
7258
|
+
resolveMultiplePaths,
|
|
7259
|
+
resolveSingleValue,
|
|
6235
7260
|
syncAll,
|
|
6236
7261
|
syncNativeObjects,
|
|
6237
7262
|
syncNativeViews,
|
|
7263
|
+
traversePath,
|
|
7264
|
+
validateFormulaExpression,
|
|
7265
|
+
validatePath,
|
|
6238
7266
|
verifyNativeObjectsSync,
|
|
6239
7267
|
verifyNativeViewsSync
|
|
6240
7268
|
});
|