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

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/index.js CHANGED
@@ -129,6 +129,7 @@ __export(index_exports, {
129
129
  DEFAULT_ROLE_LABELS: () => DEFAULT_ROLE_LABELS,
130
130
  DEFAULT_ROLE_PERMISSIONS: () => DEFAULT_ROLE_PERMISSIONS,
131
131
  DuplicateError: () => DuplicateError,
132
+ EMPTY_VALUE_PLACEHOLDER: () => EMPTY_VALUE_PLACEHOLDER,
132
133
  FileNotFoundError: () => FileNotFoundError,
133
134
  FileService: () => FileService,
134
135
  FlowBuilder: () => FlowBuilder,
@@ -199,10 +200,12 @@ __export(index_exports, {
199
200
  currencyConfigSchema: () => currencyConfigSchema,
200
201
  date: () => date,
201
202
  dateConfigSchema: () => dateConfigSchema,
203
+ enrichValuesWithSelectLabels: () => enrichValuesWithSelectLabels,
202
204
  extractAttributeNames: () => extractAttributeNames,
203
205
  file: () => file,
204
206
  fileConfigSchema: () => fileConfigSchema,
205
207
  flow: () => flow,
208
+ formatAttributeValue: () => formatAttributeValue,
206
209
  generateId: () => generateId,
207
210
  generatePrefixedId: () => generatePrefixedId,
208
211
  getAttributeConfigSchema: () => getAttributeConfigSchema,
@@ -210,6 +213,7 @@ __export(index_exports, {
210
213
  getSyncPreview: () => getSyncPreview,
211
214
  getViewSyncPreview: () => getViewSyncPreview,
212
215
  group: () => group,
216
+ isActivityTab: () => isActivityTab,
213
217
  isAdvancedFilterState: () => isAdvancedFilterState,
214
218
  isCustomTab: () => isCustomTab,
215
219
  isDefaultRole: () => isDefaultRole,
@@ -434,6 +438,9 @@ function isTableTab(tab) {
434
438
  function isCustomTab(tab) {
435
439
  return tab.type === "custom";
436
440
  }
441
+ function isActivityTab(tab) {
442
+ return tab.type === "activity";
443
+ }
437
444
 
438
445
  // src/utils.ts
439
446
  function generateId() {
@@ -450,6 +457,168 @@ function generatePrefixedId(prefix) {
450
457
  return `${prefix}_${generateId()}`;
451
458
  }
452
459
 
460
+ // src/format.ts
461
+ var import_constants = require("@stndrds/constants");
462
+ var EMPTY_VALUE_PLACEHOLDER = "\u2014";
463
+ function formatText(value) {
464
+ return String(value);
465
+ }
466
+ function formatCheckbox(value) {
467
+ return value ? "Yes" : "No";
468
+ }
469
+ function formatNumber(value, attribute) {
470
+ if (typeof value !== "number") return String(value);
471
+ const decimals = attribute.decimals;
472
+ return value.toLocaleString(void 0, {
473
+ minimumFractionDigits: decimals,
474
+ maximumFractionDigits: decimals
475
+ });
476
+ }
477
+ function formatCurrency(value, _attribute) {
478
+ if (typeof value !== "object" || value === null) return String(value);
479
+ const currency2 = value;
480
+ if (!("value" in currency2 && "code" in currency2)) return String(value);
481
+ const formattedValue = currency2.value.toLocaleString(void 0, {
482
+ minimumFractionDigits: 2,
483
+ maximumFractionDigits: 2
484
+ });
485
+ return `${formattedValue} ${currency2.code}`;
486
+ }
487
+ function formatDate(value) {
488
+ if (value instanceof Date) {
489
+ return value.toISOString().split("T")[0];
490
+ }
491
+ if (typeof value === "string") {
492
+ const date2 = new Date(value);
493
+ if (!Number.isNaN(date2.getTime())) {
494
+ return date2.toISOString().split("T")[0];
495
+ }
496
+ }
497
+ return String(value);
498
+ }
499
+ function formatTimestamp(value) {
500
+ if (typeof value === "number") {
501
+ return new Date(value).toISOString();
502
+ }
503
+ if (value instanceof Date) {
504
+ return value.toISOString();
505
+ }
506
+ return String(value);
507
+ }
508
+ function formatPhone(value) {
509
+ if (typeof value !== "object" || value === null) return String(value);
510
+ const phone2 = value;
511
+ if (!("phoneNumber" in phone2)) return String(value);
512
+ if (phone2.countryCode) {
513
+ const country = (0, import_constants.getCountryByIso3)(phone2.countryCode);
514
+ const dial = country?.phoneCode ?? "";
515
+ return `${dial} ${phone2.phoneNumber}`.trim();
516
+ }
517
+ return phone2.phoneNumber;
518
+ }
519
+ function formatLocation(value, attribute) {
520
+ if (typeof value !== "object" || value === null) return String(value);
521
+ const loc = value;
522
+ const granularity = attribute.granularity ?? "full";
523
+ const parts = [];
524
+ switch (granularity) {
525
+ case "country":
526
+ if (loc.country) parts.push(loc.country);
527
+ break;
528
+ case "state":
529
+ if (loc.state) parts.push(loc.state);
530
+ if (loc.country) parts.push(loc.country);
531
+ break;
532
+ case "city":
533
+ if (loc.city) parts.push(loc.city);
534
+ if (loc.state) parts.push(loc.state);
535
+ if (loc.country) parts.push(loc.country);
536
+ break;
537
+ case "coordinates":
538
+ if (loc.latitude !== void 0 && loc.longitude !== void 0) {
539
+ parts.push(`${loc.latitude}, ${loc.longitude}`);
540
+ }
541
+ break;
542
+ case "address":
543
+ if (loc.address) parts.push(loc.address);
544
+ if (loc.city) parts.push(loc.city);
545
+ if (loc.state) parts.push(loc.state);
546
+ if (loc.country) parts.push(loc.country);
547
+ break;
548
+ default:
549
+ if (loc.address) parts.push(loc.address);
550
+ if (loc.city) parts.push(loc.city);
551
+ if (loc.state) parts.push(loc.state);
552
+ if (loc.postalCode) parts.push(loc.postalCode);
553
+ if (loc.country) parts.push(loc.country);
554
+ break;
555
+ }
556
+ return parts.join(", ") || EMPTY_VALUE_PLACEHOLDER;
557
+ }
558
+ function formatSelect(value, attribute) {
559
+ if (typeof value !== "string") return String(value);
560
+ const option = attribute.options?.find((o) => o.value === value);
561
+ return option?.label ?? String(value);
562
+ }
563
+ function formatMultiselect(value, attribute) {
564
+ if (!Array.isArray(value)) return String(value);
565
+ if (attribute.options) {
566
+ const labels = value.map((v) => attribute.options.find((o) => o.value === v)?.label).filter(Boolean);
567
+ return labels.join(", ");
568
+ }
569
+ return value.join(", ");
570
+ }
571
+ function formatRating(value, attribute) {
572
+ if (typeof value !== "number") return String(value);
573
+ const max = attribute.max ?? 5;
574
+ return `${value}/${max}`;
575
+ }
576
+ function formatAttributeValue(value, attribute) {
577
+ if (value === null || value === void 0 || value === "") {
578
+ return EMPTY_VALUE_PLACEHOLDER;
579
+ }
580
+ switch (attribute.type) {
581
+ case "text":
582
+ case "textarea":
583
+ return formatText(value);
584
+ case "checkbox":
585
+ return formatCheckbox(value);
586
+ case "number":
587
+ return formatNumber(value, attribute);
588
+ case "currency":
589
+ return formatCurrency(value, attribute);
590
+ case "date":
591
+ return formatDate(value);
592
+ case "timestamp":
593
+ return formatTimestamp(value);
594
+ case "phone":
595
+ return formatPhone(value);
596
+ case "location":
597
+ return formatLocation(value, attribute);
598
+ case "select":
599
+ case "status":
600
+ return formatSelect(value, attribute);
601
+ case "multiselect":
602
+ return formatMultiselect(value, attribute);
603
+ case "rating":
604
+ return formatRating(value, attribute);
605
+ // Unsupported types - return value as-is or placeholder
606
+ case "file":
607
+ case "user":
608
+ case "relation":
609
+ if (Array.isArray(value)) {
610
+ return value.join(", ");
611
+ }
612
+ return String(value);
613
+ default: {
614
+ if (Array.isArray(value)) {
615
+ return value.join(", ");
616
+ }
617
+ return String(value);
618
+ }
619
+ }
620
+ }
621
+
453
622
  // src/constants/index.ts
454
623
  init_default_roles();
455
624
 
@@ -653,8 +822,8 @@ function isForbiddenError(error) {
653
822
  }
654
823
 
655
824
  // src/builders/attribute-validators.ts
656
- var import_constants = require("@stndrds/constants");
657
- var VALID_ICONS = new Set(import_constants.ICONS);
825
+ var import_constants2 = require("@stndrds/constants");
826
+ var VALID_ICONS = new Set(import_constants2.ICONS);
658
827
  var ATTRIBUTE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
659
828
  var MAX_NAME_LENGTH = 63;
660
829
  var MAX_LABEL_LENGTH = 128;
@@ -2491,6 +2660,13 @@ var optionSchema = import_zod4.z.object({
2491
2660
  description: import_zod4.z.string().optional(),
2492
2661
  group: import_zod4.z.enum(["idle", "in_progress", "finished"]).optional()
2493
2662
  });
2663
+ var optionsArraySchema = import_zod4.z.array(optionSchema).min(1).refine(
2664
+ (options) => {
2665
+ const values = options.map((o) => o.value);
2666
+ return new Set(values).size === values.length;
2667
+ },
2668
+ { message: "Duplicate option values are not allowed" }
2669
+ );
2494
2670
  var relationTargetSchema = import_zod4.z.object({
2495
2671
  object: import_zod4.z.string().min(1),
2496
2672
  displayTemplate: import_zod4.z.string().optional(),
@@ -2551,7 +2727,7 @@ var currencyConfigSchema = baseConfigSchema.extend({
2551
2727
  allowedCurrencies: import_zod4.z.array(import_zod4.z.string().length(3)).optional()
2552
2728
  });
2553
2729
  var statusConfigSchema = baseConfigSchema.extend({
2554
- options: import_zod4.z.array(optionSchema).min(1)
2730
+ options: optionsArraySchema
2555
2731
  });
2556
2732
  var locationConfigSchema = baseConfigSchema.extend({
2557
2733
  granularity: import_zod4.z.enum(["full", "address", "city", "state", "country", "coordinates"]),
@@ -2565,10 +2741,10 @@ var timestampConfigSchema = baseConfigSchema.extend({
2565
2741
  autoUpdate: import_zod4.z.boolean().optional()
2566
2742
  });
2567
2743
  var selectConfigSchema = baseConfigSchema.extend({
2568
- options: import_zod4.z.array(optionSchema).min(1)
2744
+ options: optionsArraySchema
2569
2745
  });
2570
2746
  var multiselectConfigSchema = baseConfigSchema.extend({
2571
- options: import_zod4.z.array(optionSchema).min(1)
2747
+ options: optionsArraySchema
2572
2748
  });
2573
2749
  var fileConfigSchema = baseConfigSchema.extend({
2574
2750
  maxFiles: import_zod4.z.number().int().min(1).optional(),
@@ -2973,6 +3149,24 @@ function extractAttributeNames(template) {
2973
3149
  }
2974
3150
  return names;
2975
3151
  }
3152
+ function hasOptions(attr) {
3153
+ return "options" in attr && Array.isArray(attr.options) && attr.options.length > 0;
3154
+ }
3155
+ function enrichValuesWithSelectLabels(values, attributes) {
3156
+ const enriched = { ...values };
3157
+ for (const attr of attributes) {
3158
+ const value = values[attr.name];
3159
+ if (value == null) continue;
3160
+ const isSelectLike = attr.type === "select" || attr.type === "status" || attr.type === "multiselect";
3161
+ if (!(isSelectLike && hasOptions(attr))) continue;
3162
+ if (attr.type === "multiselect" && Array.isArray(value) && value.length === 0) continue;
3163
+ const formatted = formatAttributeValue(value, attr);
3164
+ if (formatted && formatted !== EMPTY_VALUE_PLACEHOLDER) {
3165
+ enriched[attr.name] = formatted;
3166
+ }
3167
+ }
3168
+ return enriched;
3169
+ }
2976
3170
 
2977
3171
  // src/runtime/mock-adapter.ts
2978
3172
  function createMockObjectsRepository(stores) {
@@ -3449,14 +3643,31 @@ function createMockObjectRecordsRepository(stores) {
3449
3643
  (options.offset ?? 0) + options.limit
3450
3644
  );
3451
3645
  }
3646
+ const attributesByObjectId = /* @__PURE__ */ new Map();
3647
+ for (const attr of stores.attributes.values()) {
3648
+ if (!attributesByObjectId.has(attr.objectId)) {
3649
+ attributesByObjectId.set(attr.objectId, []);
3650
+ }
3651
+ attributesByObjectId.get(attr.objectId)?.push(attr);
3652
+ }
3452
3653
  const results = matchingRecords.map((r) => {
3453
3654
  const obj = objectsMap.get(r.objectId);
3454
3655
  const labelExpression = obj?.labelExpression ?? "{{ name }}";
3656
+ const dbAttrs = attributesByObjectId.get(r.objectId) ?? [];
3657
+ const attrs = dbAttrs.map((a) => ({
3658
+ ...a.config,
3659
+ id: a.id,
3660
+ name: a.name,
3661
+ type: a.type,
3662
+ label: a.config.label ?? a.name,
3663
+ required: a.config.required ?? false
3664
+ }));
3665
+ const enrichedValues = enrichValuesWithSelectLabels(r.values, attrs);
3455
3666
  return {
3456
3667
  objectId: r.objectId,
3457
3668
  objectName: obj?.name ?? "unknown",
3458
3669
  objectLabel: obj?.label ?? "Unknown",
3459
- label: renderLabelExpression(labelExpression, r.values),
3670
+ label: renderLabelExpression(labelExpression, enrichedValues),
3460
3671
  recordId: r.id,
3461
3672
  values: r.values,
3462
3673
  completionStatus: r.completionStatus,
@@ -3873,6 +4084,10 @@ var AuditService = class {
3873
4084
  this.options = options;
3874
4085
  this.buffer = [];
3875
4086
  this.flushTimer = null;
4087
+ /** Prevents concurrent flush operations */
4088
+ this.isFlushing = false;
4089
+ /** Pending flush promise to allow waiting on concurrent flush */
4090
+ this.flushPromise = null;
3876
4091
  if (options?.async && options.flushIntervalMs) {
3877
4092
  this.startFlushTimer();
3878
4093
  }
@@ -4025,14 +4240,23 @@ var AuditService = class {
4025
4240
  // ============================================================================
4026
4241
  /**
4027
4242
  * Flush buffered logs to the database
4243
+ * Protected against concurrent flush calls
4028
4244
  */
4029
4245
  async flush() {
4246
+ if (this.isFlushing && this.flushPromise) {
4247
+ return this.flushPromise;
4248
+ }
4030
4249
  if (this.buffer.length === 0 || !this.adapter.audit) {
4031
4250
  return;
4032
4251
  }
4252
+ this.isFlushing = true;
4033
4253
  const entries = [...this.buffer];
4034
4254
  this.buffer = [];
4035
- await this.adapter.audit.createMany(entries);
4255
+ this.flushPromise = this.adapter.audit.createMany(entries).finally(() => {
4256
+ this.isFlushing = false;
4257
+ this.flushPromise = null;
4258
+ });
4259
+ return this.flushPromise;
4036
4260
  }
4037
4261
  /**
4038
4262
  * Clean up resources (stop timer, flush remaining logs)
@@ -4054,6 +4278,12 @@ var AuditService = class {
4054
4278
  if (!this.adapter.audit) {
4055
4279
  return;
4056
4280
  }
4281
+ if (entry.actorId && !entry.actorEmail && entry.actorType === "user") {
4282
+ const profile = await this.adapter.userProfiles.findById(entry.actorId);
4283
+ if (profile) {
4284
+ entry.actorEmail = profile.email;
4285
+ }
4286
+ }
4057
4287
  if (this.options?.async) {
4058
4288
  this.buffer.push(entry);
4059
4289
  const batchSize = this.options.batchSize ?? 10;
@@ -4351,16 +4581,26 @@ var FlowService = class {
4351
4581
  */
4352
4582
  async createFlow(input, tenantId) {
4353
4583
  if (!this.adapter.flows) {
4354
- throw new Error("Flows feature is not enabled. Database adapter does not support flows.");
4584
+ throw new SchemaError(
4585
+ "Flows feature is not enabled. Database adapter does not support flows.",
4586
+ SchemaErrorCode.UNKNOWN,
4587
+ { feature: "flows" }
4588
+ );
4355
4589
  }
4356
4590
  this.validateFlowName(input.name);
4357
4591
  const existing = await this.adapter.flows.findByName(tenantId, input.name);
4358
4592
  if (existing) {
4359
- throw new Error(`Flow "${input.name}" already exists`);
4593
+ throw new SchemaError(
4594
+ `Flow "${input.name}" already exists`,
4595
+ SchemaErrorCode.DUPLICATE_OBJECT,
4596
+ { flowName: input.name }
4597
+ );
4360
4598
  }
4361
4599
  if (this.systemFlows.has(input.name)) {
4362
- throw new Error(
4363
- `Cannot create flow "${input.name}": a system flow with this name already exists`
4600
+ throw new SchemaError(
4601
+ `Cannot create flow "${input.name}": a system flow with this name already exists`,
4602
+ SchemaErrorCode.DUPLICATE_OBJECT,
4603
+ { flowName: input.name, system: true }
4364
4604
  );
4365
4605
  }
4366
4606
  this.validateFlowStructure(input);
@@ -4385,14 +4625,16 @@ var FlowService = class {
4385
4625
  */
4386
4626
  async updateFlow(flowId, input) {
4387
4627
  if (!this.adapter.flows) {
4388
- throw new Error("Flows feature is not enabled.");
4628
+ throw new SchemaError("Flows feature is not enabled.", SchemaErrorCode.UNKNOWN, {
4629
+ feature: "flows"
4630
+ });
4389
4631
  }
4390
4632
  const dbFlow = await this.adapter.flows.findById(flowId);
4391
4633
  if (!dbFlow) {
4392
- throw new Error(`Flow with id "${flowId}" not found`);
4634
+ throw new NotFoundError("Flow", flowId);
4393
4635
  }
4394
4636
  if (dbFlow.system) {
4395
- throw new Error("Cannot modify system flows. System flows are protected.");
4637
+ throw new ProtectedResourceError("object", dbFlow.name, "modify");
4396
4638
  }
4397
4639
  if (input.slots || input.pages || input.relations) {
4398
4640
  this.validateFlowStructure({
@@ -4419,14 +4661,16 @@ var FlowService = class {
4419
4661
  */
4420
4662
  async publishFlow(flowId) {
4421
4663
  if (!this.adapter.flows) {
4422
- throw new Error("Flows feature is not enabled.");
4664
+ throw new SchemaError("Flows feature is not enabled.", SchemaErrorCode.UNKNOWN, {
4665
+ feature: "flows"
4666
+ });
4423
4667
  }
4424
4668
  const dbFlow = await this.adapter.flows.findById(flowId);
4425
4669
  if (!dbFlow) {
4426
- throw new Error(`Flow with id "${flowId}" not found`);
4670
+ throw new NotFoundError("Flow", flowId);
4427
4671
  }
4428
4672
  if (dbFlow.system) {
4429
- throw new Error("Cannot publish system flows. They are always published.");
4673
+ throw new ProtectedResourceError("object", dbFlow.name, "modify");
4430
4674
  }
4431
4675
  if (dbFlow.status === "published") {
4432
4676
  return this.convertDBFlowToDefinition(dbFlow);
@@ -4449,14 +4693,16 @@ var FlowService = class {
4449
4693
  */
4450
4694
  async archiveFlow(flowId) {
4451
4695
  if (!this.adapter.flows) {
4452
- throw new Error("Flows feature is not enabled.");
4696
+ throw new SchemaError("Flows feature is not enabled.", SchemaErrorCode.UNKNOWN, {
4697
+ feature: "flows"
4698
+ });
4453
4699
  }
4454
4700
  const dbFlow = await this.adapter.flows.findById(flowId);
4455
4701
  if (!dbFlow) {
4456
- throw new Error(`Flow with id "${flowId}" not found`);
4702
+ throw new NotFoundError("Flow", flowId);
4457
4703
  }
4458
4704
  if (dbFlow.system) {
4459
- throw new Error("Cannot archive system flows.");
4705
+ throw new ProtectedResourceError("object", dbFlow.name, "modify");
4460
4706
  }
4461
4707
  const updated = await this.adapter.flows.update(flowId, {
4462
4708
  status: "archived"
@@ -4468,14 +4714,16 @@ var FlowService = class {
4468
4714
  */
4469
4715
  async deleteFlow(flowId) {
4470
4716
  if (!this.adapter.flows) {
4471
- throw new Error("Flows feature is not enabled.");
4717
+ throw new SchemaError("Flows feature is not enabled.", SchemaErrorCode.UNKNOWN, {
4718
+ feature: "flows"
4719
+ });
4472
4720
  }
4473
4721
  const dbFlow = await this.adapter.flows.findById(flowId);
4474
4722
  if (!dbFlow) {
4475
- throw new Error(`Flow with id "${flowId}" not found`);
4723
+ throw new NotFoundError("Flow", flowId);
4476
4724
  }
4477
4725
  if (dbFlow.system) {
4478
- throw new Error("Cannot delete system flows. System flows are protected.");
4726
+ throw new ProtectedResourceError("object", dbFlow.name, "delete");
4479
4727
  }
4480
4728
  await this.adapter.flows.delete(flowId);
4481
4729
  }
@@ -4487,16 +4735,23 @@ var FlowService = class {
4487
4735
  */
4488
4736
  validateFlowName(name) {
4489
4737
  if (!name || name.length === 0) {
4490
- throw new Error("Flow name cannot be empty");
4738
+ throw new ValidationError("Flow name cannot be empty", [
4739
+ { path: ["name"], message: "Flow name cannot be empty" }
4740
+ ]);
4491
4741
  }
4492
4742
  if (name.length > 63) {
4493
- throw new Error("Flow name is too long (max 63 characters)");
4743
+ throw new ValidationError("Flow name is too long", [
4744
+ { path: ["name"], message: "Flow name is too long (max 63 characters)" }
4745
+ ]);
4494
4746
  }
4495
4747
  const kebabCaseRegex = /^[a-z][a-z0-9-]*$/;
4496
4748
  if (!kebabCaseRegex.test(name)) {
4497
- throw new Error(
4498
- "Invalid flow name format. Name must be in kebab-case (e.g., 'couple-creation', 'new-contact')"
4499
- );
4749
+ throw new ValidationError("Invalid flow name format", [
4750
+ {
4751
+ path: ["name"],
4752
+ message: "Flow name must be in kebab-case (e.g., 'couple-creation', 'new-contact')"
4753
+ }
4754
+ ]);
4500
4755
  }
4501
4756
  }
4502
4757
  /**
@@ -4504,33 +4759,59 @@ var FlowService = class {
4504
4759
  */
4505
4760
  validateFlowStructure(input) {
4506
4761
  if (!input.slots || input.slots.length === 0) {
4507
- throw new Error("Flow must have at least one slot");
4762
+ throw new ValidationError("Flow must have at least one slot", [
4763
+ { path: ["slots"], message: "Flow must have at least one slot" }
4764
+ ]);
4508
4765
  }
4509
4766
  if (!input.pages || input.pages.length === 0) {
4510
- throw new Error("Flow must have at least one page");
4767
+ throw new ValidationError("Flow must have at least one page", [
4768
+ { path: ["pages"], message: "Flow must have at least one page" }
4769
+ ]);
4511
4770
  }
4512
4771
  const slotIds = new Set(input.slots.map((s) => s.id));
4513
4772
  if (slotIds.size !== input.slots.length) {
4514
- throw new Error("Duplicate slot IDs detected");
4773
+ throw new ValidationError("Duplicate slot IDs detected", [
4774
+ { path: ["slots"], message: "Duplicate slot IDs detected" }
4775
+ ]);
4515
4776
  }
4516
4777
  for (const page of input.pages) {
4517
4778
  for (const row of page.rows) {
4518
4779
  for (const field of row.fields) {
4519
4780
  if (!slotIds.has(field.slotId)) {
4520
- throw new Error(`Field "${field.id}" references unknown slot "${field.slotId}"`);
4781
+ throw new ValidationError("Field references unknown slot", [
4782
+ {
4783
+ path: ["pages", page.id, "rows", row.id, "fields", field.id],
4784
+ message: `Field "${field.id}" references unknown slot "${field.slotId}"`
4785
+ }
4786
+ ]);
4521
4787
  }
4522
4788
  }
4523
4789
  }
4524
4790
  }
4525
4791
  for (const relation2 of input.relations) {
4526
4792
  if (!slotIds.has(relation2.sourceSlotId)) {
4527
- throw new Error(`Relation references unknown source slot "${relation2.sourceSlotId}"`);
4793
+ throw new ValidationError("Relation references unknown source slot", [
4794
+ {
4795
+ path: ["relations", relation2.id, "sourceSlotId"],
4796
+ message: `Relation references unknown source slot "${relation2.sourceSlotId}"`
4797
+ }
4798
+ ]);
4528
4799
  }
4529
4800
  if (!slotIds.has(relation2.targetSlotId)) {
4530
- throw new Error(`Relation references unknown target slot "${relation2.targetSlotId}"`);
4801
+ throw new ValidationError("Relation references unknown target slot", [
4802
+ {
4803
+ path: ["relations", relation2.id, "targetSlotId"],
4804
+ message: `Relation references unknown target slot "${relation2.targetSlotId}"`
4805
+ }
4806
+ ]);
4531
4807
  }
4532
4808
  if (relation2.sourceSlotId === relation2.targetSlotId) {
4533
- throw new Error(`Relation cannot link a slot to itself: "${relation2.sourceSlotId}"`);
4809
+ throw new ValidationError("Relation cannot link a slot to itself", [
4810
+ {
4811
+ path: ["relations", relation2.id],
4812
+ message: `Relation cannot link a slot to itself: "${relation2.sourceSlotId}"`
4813
+ }
4814
+ ]);
4534
4815
  }
4535
4816
  }
4536
4817
  }
@@ -5023,14 +5304,15 @@ var ObjectSchemaService = class {
5023
5304
  });
5024
5305
  }
5025
5306
  }
5307
+ const dbAttributes = await this.adapter.attributes.findByObjectId(objectId);
5308
+ const attributes = dbAttributes.map((attr) => this.convertDBAttributeToAttribute(attr));
5026
5309
  if (updates.labelExpression !== void 0 && updates.labelExpression !== oldValues.labelExpression) {
5027
5310
  const newExpression = updates.labelExpression;
5028
- await this.adapter.objectRecords.batchRefreshLabels(
5029
- objectId,
5030
- (values) => renderLabelExpression(newExpression, values)
5031
- );
5311
+ await this.adapter.objectRecords.batchRefreshLabels(objectId, (values) => {
5312
+ const enrichedValues = enrichValuesWithSelectLabels(values, attributes);
5313
+ return renderLabelExpression(newExpression, enrichedValues);
5314
+ });
5032
5315
  }
5033
- const dbAttributes = await this.adapter.attributes.findByObjectId(objectId);
5034
5316
  return this.convertDBObjectToDefinition(updatedDbObject, dbAttributes);
5035
5317
  }
5036
5318
  /**
@@ -5349,6 +5631,8 @@ var PermissionService = class {
5349
5631
  this.adapter = adapter;
5350
5632
  this.tenantId = tenantId;
5351
5633
  this.cache = /* @__PURE__ */ new Map();
5634
+ /** Track pending permission fetches to prevent duplicate concurrent requests */
5635
+ this.pendingFetches = /* @__PURE__ */ new Map();
5352
5636
  if (!adapter.permissions) {
5353
5637
  throw new Error(
5354
5638
  "PermissionService requires a DatabaseAdapter with permissions repository. Make sure your adapter implements the permissions property."
@@ -5497,6 +5781,23 @@ var PermissionService = class {
5497
5781
  if (cached && cached.expiresAt > Date.now()) {
5498
5782
  return cached.permissions;
5499
5783
  }
5784
+ const pendingFetch = this.pendingFetches.get(cacheKey);
5785
+ if (pendingFetch) {
5786
+ return pendingFetch;
5787
+ }
5788
+ const fetchPromise = this.fetchAndCachePermissions(userProfileId, cacheKey);
5789
+ this.pendingFetches.set(cacheKey, fetchPromise);
5790
+ try {
5791
+ return await fetchPromise;
5792
+ } finally {
5793
+ this.pendingFetches.delete(cacheKey);
5794
+ }
5795
+ }
5796
+ /**
5797
+ * Fetch permissions from database and cache the result
5798
+ * @internal
5799
+ */
5800
+ async fetchAndCachePermissions(userProfileId, cacheKey) {
5500
5801
  const permissions = await this.permissionsRepo.getEffectivePermissions(
5501
5802
  userProfileId,
5502
5803
  this.tenantId
@@ -5879,8 +6180,11 @@ var RelationService = class {
5879
6180
  async validateRelationsOrThrow(schema, data) {
5880
6181
  const result = await this.validateRelations(schema, data);
5881
6182
  if (!result.valid) {
5882
- const messages = result.errors.map((e) => `${e.attribute}: ${e.message}`).join("; ");
5883
- throw new Error(`Relation validation failed: ${messages}`);
6183
+ const errors = result.errors.map((e) => ({
6184
+ path: [e.attribute],
6185
+ message: e.message
6186
+ }));
6187
+ throw new ValidationError("Relation validation failed", errors);
5884
6188
  }
5885
6189
  }
5886
6190
  // ============================================================================
@@ -5928,7 +6232,8 @@ var RelationService = class {
5928
6232
  totalCount += result.total;
5929
6233
  for (const record of result.records) {
5930
6234
  const template = target.displayTemplate || objectSchema.labelExpression;
5931
- const label = renderLabelExpression(template, record.values);
6235
+ const enrichedValues = enrichValuesWithSelectLabels(record.values, objectSchema.attributes);
6236
+ const label = renderLabelExpression(template, enrichedValues);
5932
6237
  allOptions.push({
5933
6238
  id: record.id,
5934
6239
  objectId: objectSchema.id,
@@ -5992,7 +6297,8 @@ var RelationService = class {
5992
6297
  }
5993
6298
  }
5994
6299
  for (const record of objectRecords) {
5995
- const label = renderLabelExpression(template, record.values);
6300
+ const enrichedValues = enrichValuesWithSelectLabels(record.values, objectSchema.attributes);
6301
+ const label = renderLabelExpression(template, enrichedValues);
5996
6302
  resolved.push({
5997
6303
  id: record.id,
5998
6304
  objectId: record.objectId,
@@ -6013,10 +6319,14 @@ var RelationService = class {
6013
6319
  if (!attribute || attribute.type !== "relation") {
6014
6320
  return null;
6015
6321
  }
6016
- return {
6322
+ const merged = {
6017
6323
  ...attribute.config,
6018
6324
  ...attribute
6019
6325
  };
6326
+ if (!("targets" in merged && Array.isArray(merged.targets) && "cardinality" in merged)) {
6327
+ return null;
6328
+ }
6329
+ return merged;
6020
6330
  }
6021
6331
  };
6022
6332
 
@@ -6029,7 +6339,7 @@ var RecordService = class {
6029
6339
  this.relationService = new RelationService(adapter, registry);
6030
6340
  this.hookRegistry = options?.hookRegistry ?? new NoopHookRegistry();
6031
6341
  this.permissionService = options?.permissionService;
6032
- this.auditService = options?.auditService;
6342
+ this.auditService = options?.auditService ?? (adapter.audit ? new AuditService(adapter, tenantId) : void 0);
6033
6343
  this.userId = options?.userId;
6034
6344
  this.userEmail = options?.userEmail;
6035
6345
  }
@@ -6096,21 +6406,23 @@ var RecordService = class {
6096
6406
  /**
6097
6407
  * Compute display label from schema expression
6098
6408
  * Automatically resolves relation attribute values to their labels
6409
+ * and select/multiselect values to their option labels
6099
6410
  * @internal
6100
6411
  */
6101
6412
  async computeLabel(schema, values) {
6102
6413
  const attrNames = extractAttributeNames(schema.labelExpression);
6414
+ let enrichedValues = enrichValuesWithSelectLabels(values, schema.attributes);
6103
6415
  const relationAttrs = schema.attributes.filter(
6104
6416
  (attr) => attr.type === "relation" && attrNames.includes(attr.name)
6105
6417
  );
6106
6418
  if (relationAttrs.length === 0) {
6107
- return renderLabelExpression(schema.labelExpression, values);
6419
+ return renderLabelExpression(schema.labelExpression, enrichedValues);
6108
6420
  }
6109
6421
  const resolvedMap = await this.resolveRelationLabels(relationAttrs, values);
6110
6422
  if (resolvedMap.size === 0) {
6111
- return renderLabelExpression(schema.labelExpression, values);
6423
+ return renderLabelExpression(schema.labelExpression, enrichedValues);
6112
6424
  }
6113
- const enrichedValues = { ...values };
6425
+ enrichedValues = { ...enrichedValues };
6114
6426
  for (const attr of relationAttrs) {
6115
6427
  const val = values[attr.name];
6116
6428
  const ids = this.extractRelationIds(val);
@@ -6223,7 +6535,7 @@ var RecordService = class {
6223
6535
  async getRecordOrThrow(recordId) {
6224
6536
  const record = await this.getRecord(recordId);
6225
6537
  if (!record) {
6226
- throw new Error(`Record with id "${recordId}" not found`);
6538
+ throw new RecordNotFoundError(recordId);
6227
6539
  }
6228
6540
  return record;
6229
6541
  }
@@ -6401,7 +6713,7 @@ var RecordService = class {
6401
6713
  await this.checkPermission(schema.name, "delete");
6402
6714
  if (options?.checkSystem) {
6403
6715
  if (schema.system) {
6404
- throw new Error(`Cannot delete record of system object "${schema.label}"`);
6716
+ throw new ProtectedResourceError("object", schema.name, "delete");
6405
6717
  }
6406
6718
  }
6407
6719
  if (!options?.skipReferenceCheck) {
@@ -6450,7 +6762,11 @@ var RecordService = class {
6450
6762
  async restoreRecord(recordId, options) {
6451
6763
  const record = await this.getRecordOrThrow(recordId);
6452
6764
  if (!record.deletedAt) {
6453
- throw new Error(`Record "${recordId}" is not deleted`);
6765
+ throw new SchemaError(
6766
+ `Record "${recordId}" is not deleted`,
6767
+ SchemaErrorCode.VALIDATION_FAILED,
6768
+ { recordId, reason: "not_deleted" }
6769
+ );
6454
6770
  }
6455
6771
  const schema = await this.schemaService.getObjectSchema(record.objectId);
6456
6772
  await this.checkPermission(schema.name, "update");
@@ -6888,11 +7204,17 @@ var ViewService = class {
6888
7204
  this.validateViewName(input.name);
6889
7205
  const existing = await this.adapter.views.findByName(tenantId, input.objectName, input.name);
6890
7206
  if (existing) {
6891
- throw new Error(`View "${input.name}" already exists for object "${input.objectName}"`);
7207
+ throw new SchemaError(
7208
+ `View "${input.name}" already exists for object "${input.objectName}"`,
7209
+ SchemaErrorCode.DUPLICATE_ATTRIBUTE,
7210
+ { viewName: input.name, objectName: input.objectName }
7211
+ );
6892
7212
  }
6893
7213
  if (this.nativeViews.has(input.objectName, input.name)) {
6894
- throw new Error(
6895
- `Cannot create view "${input.name}": a system view with this name already exists`
7214
+ throw new SchemaError(
7215
+ `Cannot create view "${input.name}": a system view with this name already exists`,
7216
+ SchemaErrorCode.DUPLICATE_ATTRIBUTE,
7217
+ { viewName: input.name, objectName: input.objectName, system: true }
6896
7218
  );
6897
7219
  }
6898
7220
  const dbView = await this.adapter.views.create({
@@ -6920,10 +7242,10 @@ var ViewService = class {
6920
7242
  async updateView(viewId, input) {
6921
7243
  const dbView = await this.adapter.views.findById(viewId);
6922
7244
  if (!dbView) {
6923
- throw new Error(`View with id "${viewId}" not found`);
7245
+ throw new NotFoundError("View", viewId);
6924
7246
  }
6925
7247
  if (dbView.system) {
6926
- throw new Error("Cannot modify system views. System views are protected.");
7248
+ throw new ProtectedResourceError("attribute", dbView.name, "modify");
6927
7249
  }
6928
7250
  const updated = await this.adapter.views.update(viewId, {
6929
7251
  label: input.label,
@@ -6943,10 +7265,10 @@ var ViewService = class {
6943
7265
  async deleteView(viewId) {
6944
7266
  const dbView = await this.adapter.views.findById(viewId);
6945
7267
  if (!dbView) {
6946
- throw new Error(`View with id "${viewId}" not found`);
7268
+ throw new NotFoundError("View", viewId);
6947
7269
  }
6948
7270
  if (dbView.system) {
6949
- throw new Error("Cannot delete system views. System views are protected.");
7271
+ throw new ProtectedResourceError("attribute", dbView.name, "delete");
6950
7272
  }
6951
7273
  await this.adapter.views.delete(viewId);
6952
7274
  }
@@ -6960,7 +7282,7 @@ var ViewService = class {
6960
7282
  async setDefaultView(viewId, tenantId) {
6961
7283
  const dbView = await this.adapter.views.findById(viewId);
6962
7284
  if (!dbView) {
6963
- throw new Error(`View with id "${viewId}" not found`);
7285
+ throw new NotFoundError("View", viewId);
6964
7286
  }
6965
7287
  const currentViews = await this.adapter.views.findByObjectName(tenantId, dbView.objectName);
6966
7288
  for (const v of currentViews) {
@@ -6979,16 +7301,23 @@ var ViewService = class {
6979
7301
  */
6980
7302
  validateViewName(name) {
6981
7303
  if (!name || name.length === 0) {
6982
- throw new Error("View name cannot be empty");
7304
+ throw new ValidationError("View name cannot be empty", [
7305
+ { path: ["name"], message: "View name cannot be empty" }
7306
+ ]);
6983
7307
  }
6984
7308
  if (name.length > 63) {
6985
- throw new Error("View name is too long (max 63 characters)");
7309
+ throw new ValidationError("View name is too long", [
7310
+ { path: ["name"], message: "View name is too long (max 63 characters)" }
7311
+ ]);
6986
7312
  }
6987
7313
  const kebabCaseRegex = /^[a-z][a-z0-9-]*$/;
6988
7314
  if (!kebabCaseRegex.test(name)) {
6989
- throw new Error(
6990
- "Invalid view name format. Name must be in kebab-case (e.g., 'detail', 'list-view')"
6991
- );
7315
+ throw new ValidationError("Invalid view name format", [
7316
+ {
7317
+ path: ["name"],
7318
+ message: "View name must be in kebab-case (e.g., 'detail', 'list-view')"
7319
+ }
7320
+ ]);
6992
7321
  }
6993
7322
  }
6994
7323
  /**
@@ -7350,6 +7679,7 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
7350
7679
  DEFAULT_ROLE_LABELS,
7351
7680
  DEFAULT_ROLE_PERMISSIONS,
7352
7681
  DuplicateError,
7682
+ EMPTY_VALUE_PLACEHOLDER,
7353
7683
  FileNotFoundError,
7354
7684
  FileService,
7355
7685
  FlowBuilder,
@@ -7420,10 +7750,12 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
7420
7750
  currencyConfigSchema,
7421
7751
  date,
7422
7752
  dateConfigSchema,
7753
+ enrichValuesWithSelectLabels,
7423
7754
  extractAttributeNames,
7424
7755
  file,
7425
7756
  fileConfigSchema,
7426
7757
  flow,
7758
+ formatAttributeValue,
7427
7759
  generateId,
7428
7760
  generatePrefixedId,
7429
7761
  getAttributeConfigSchema,
@@ -7431,6 +7763,7 @@ async function syncAll(adapter, objectRegistry, nativeViewRegistry, options = {}
7431
7763
  getSyncPreview,
7432
7764
  getViewSyncPreview,
7433
7765
  group,
7766
+ isActivityTab,
7434
7767
  isAdvancedFilterState,
7435
7768
  isCustomTab,
7436
7769
  isDefaultRole,