@stndrds/schema 0.1.0-alpha.58 → 0.1.0-alpha.59

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.
@@ -4059,6 +4059,91 @@ function createMockObjectRecordsRepository(stores) {
4059
4059
  };
4060
4060
  }
4061
4061
 
4062
+ // src/runtime/mock/mock-relation-attributes.ts
4063
+ import { randomUUID } from "crypto";
4064
+ function createMockRelationAttributesRepository(stores) {
4065
+ return {
4066
+ async upsertBatch(items) {
4067
+ const context = getContext2();
4068
+ if (!context) {
4069
+ throw new Error("Context required for relationAttributes operations");
4070
+ }
4071
+ const results = [];
4072
+ for (const item of items) {
4073
+ const _key = `${context.tenantId}:${item.fromObject}:${item.fromId}:${item.fromAttribute}:${item.toId}`;
4074
+ const existing = Array.from(stores.relationAttributes.values()).find(
4075
+ (row) => row.tenantId === context.tenantId && row.fromObject === item.fromObject && row.fromId === item.fromId && row.fromAttribute === item.fromAttribute && row.toId === item.toId
4076
+ );
4077
+ if (existing) {
4078
+ existing.properties = item.properties ?? {};
4079
+ existing.updatedAt = /* @__PURE__ */ new Date();
4080
+ existing.updatedBy = item.updatedBy ?? context.userId ?? null;
4081
+ results.push(existing);
4082
+ } else {
4083
+ const row = {
4084
+ id: randomUUID(),
4085
+ tenantId: context.tenantId,
4086
+ fromObject: item.fromObject,
4087
+ fromId: item.fromId,
4088
+ fromAttribute: item.fromAttribute,
4089
+ toId: item.toId,
4090
+ properties: item.properties ?? {},
4091
+ createdAt: /* @__PURE__ */ new Date(),
4092
+ updatedAt: /* @__PURE__ */ new Date(),
4093
+ createdBy: item.createdBy ?? context.userId ?? null,
4094
+ updatedBy: item.updatedBy ?? context.userId ?? null
4095
+ };
4096
+ stores.relationAttributes.set(row.id, row);
4097
+ results.push(row);
4098
+ }
4099
+ }
4100
+ return results;
4101
+ },
4102
+ async findBySource(fromObject, fromId, fromAttribute) {
4103
+ const context = getContext2();
4104
+ if (!context) {
4105
+ throw new Error("Context required for relationAttributes operations");
4106
+ }
4107
+ return Array.from(stores.relationAttributes.values()).filter(
4108
+ (row) => row.tenantId === context.tenantId && row.fromObject === fromObject && row.fromId === fromId && row.fromAttribute === fromAttribute
4109
+ );
4110
+ },
4111
+ async findByTarget(toId) {
4112
+ const context = getContext2();
4113
+ if (!context) {
4114
+ throw new Error("Context required for relationAttributes operations");
4115
+ }
4116
+ return Array.from(stores.relationAttributes.values()).filter(
4117
+ (row) => row.tenantId === context.tenantId && row.toId === toId
4118
+ );
4119
+ },
4120
+ async deleteBySource(fromObject, fromId, fromAttribute) {
4121
+ const context = getContext2();
4122
+ if (!context) {
4123
+ throw new Error("Context required for relationAttributes operations");
4124
+ }
4125
+ const toDelete = Array.from(stores.relationAttributes.entries()).filter(
4126
+ ([_id, row]) => row.tenantId === context.tenantId && row.fromObject === fromObject && row.fromId === fromId && row.fromAttribute === fromAttribute
4127
+ );
4128
+ for (const [id] of toDelete) {
4129
+ stores.relationAttributes.delete(id);
4130
+ }
4131
+ },
4132
+ async deleteByTarget(toId) {
4133
+ const context = getContext2();
4134
+ if (!context) {
4135
+ throw new Error("Context required for relationAttributes operations");
4136
+ }
4137
+ const toDelete = Array.from(stores.relationAttributes.entries()).filter(
4138
+ ([_id, row]) => row.tenantId === context.tenantId && row.toId === toId
4139
+ );
4140
+ for (const [id] of toDelete) {
4141
+ stores.relationAttributes.delete(id);
4142
+ }
4143
+ }
4144
+ };
4145
+ }
4146
+
4062
4147
  // src/runtime/mock/mock-stores.ts
4063
4148
  function createEmptyStores() {
4064
4149
  return {
@@ -4079,7 +4164,8 @@ function createEmptyStores() {
4079
4164
  aiConversations: /* @__PURE__ */ new Map(),
4080
4165
  aiMessages: /* @__PURE__ */ new Map(),
4081
4166
  aiUserMemory: /* @__PURE__ */ new Map(),
4082
- aiUsageMetrics: /* @__PURE__ */ new Map()
4167
+ aiUsageMetrics: /* @__PURE__ */ new Map(),
4168
+ relationAttributes: /* @__PURE__ */ new Map()
4083
4169
  };
4084
4170
  }
4085
4171
 
@@ -4994,6 +5080,8 @@ function createMockAdapter() {
4994
5080
  aiConversations: createMockAIConversationsRepository(stores),
4995
5081
  aiUserMemory: createMockAIUserMemoryRepository(stores),
4996
5082
  aiUsageMetrics: createMockAIUsageMetricsRepository(stores),
5083
+ // Relation attributes repository
5084
+ relationAttributes: createMockRelationAttributesRepository(stores),
4997
5085
  async transaction(callback) {
4998
5086
  return await callback(adapter);
4999
5087
  },
@@ -5019,6 +5107,7 @@ function createMockAdapter() {
5019
5107
  stores.aiMessages.clear();
5020
5108
  stores.aiUserMemory.clear();
5021
5109
  stores.aiUsageMetrics.clear();
5110
+ stores.relationAttributes.clear();
5022
5111
  }
5023
5112
  };
5024
5113
  return adapter;
@@ -5497,6 +5586,292 @@ function validateOptions(options, attributeName) {
5497
5586
  }
5498
5587
  }
5499
5588
 
5589
+ // src/types/relation-properties.ts
5590
+ var FORBIDDEN_PROPERTY_TYPES = [
5591
+ "formula",
5592
+ "rollup",
5593
+ "relation",
5594
+ "file",
5595
+ "user",
5596
+ "document",
5597
+ "richtext"
5598
+ ];
5599
+
5600
+ // src/builders/property-schema-builder.ts
5601
+ var PropertySchemaBuilder = class {
5602
+ constructor() {
5603
+ this.definitions = [];
5604
+ }
5605
+ /**
5606
+ * Add a property to the schema
5607
+ * The type of property is automatically detected based on the builder methods used
5608
+ */
5609
+ add(name, configure) {
5610
+ const builder = new PropertyTypeBuilder(name);
5611
+ const configured = configure(builder);
5612
+ const definition = configured.build();
5613
+ this.definitions.push(definition);
5614
+ return this;
5615
+ }
5616
+ /**
5617
+ * Build the final PropertySchema
5618
+ */
5619
+ build() {
5620
+ return {
5621
+ definitions: this.definitions
5622
+ };
5623
+ }
5624
+ };
5625
+ var PropertyTypeBuilder = class {
5626
+ constructor(name) {
5627
+ this.name = name;
5628
+ }
5629
+ // Explicit type constructors
5630
+ text() {
5631
+ return new TextPropertyBuilder(this.name);
5632
+ }
5633
+ textarea() {
5634
+ return new TextareaPropertyBuilder(this.name);
5635
+ }
5636
+ number() {
5637
+ return new NumberPropertyBuilder(this.name);
5638
+ }
5639
+ checkbox() {
5640
+ return new CheckboxPropertyBuilder(this.name);
5641
+ }
5642
+ date() {
5643
+ return new DatePropertyBuilder(this.name);
5644
+ }
5645
+ phone() {
5646
+ return new PhonePropertyBuilder(this.name);
5647
+ }
5648
+ currency() {
5649
+ return new CurrencyPropertyBuilder(this.name);
5650
+ }
5651
+ status() {
5652
+ return new StatusPropertyBuilder(this.name);
5653
+ }
5654
+ select() {
5655
+ return new SelectPropertyBuilder(this.name);
5656
+ }
5657
+ multiselect() {
5658
+ return new MultiselectPropertyBuilder(this.name);
5659
+ }
5660
+ rating() {
5661
+ return new RatingPropertyBuilder(this.name);
5662
+ }
5663
+ location() {
5664
+ return new LocationPropertyBuilder(this.name);
5665
+ }
5666
+ };
5667
+ var BasePropertyBuilder = class {
5668
+ constructor(name) {
5669
+ this.definition = { name };
5670
+ }
5671
+ /**
5672
+ * Set the label
5673
+ */
5674
+ label(label) {
5675
+ this.definition.label = label;
5676
+ return this;
5677
+ }
5678
+ /**
5679
+ * Mark as required
5680
+ */
5681
+ required() {
5682
+ this.definition.required = true;
5683
+ return this;
5684
+ }
5685
+ /**
5686
+ * Set description
5687
+ */
5688
+ description(description) {
5689
+ this.definition.description = description;
5690
+ return this;
5691
+ }
5692
+ /**
5693
+ * Build the final definition
5694
+ */
5695
+ build() {
5696
+ return this.definition;
5697
+ }
5698
+ };
5699
+ var TextPropertyBuilder = class extends BasePropertyBuilder {
5700
+ constructor(name) {
5701
+ super(name);
5702
+ this.definition.type = "text";
5703
+ }
5704
+ minLength(value) {
5705
+ this.definition.minLength = value;
5706
+ return this;
5707
+ }
5708
+ maxLength(value) {
5709
+ this.definition.maxLength = value;
5710
+ return this;
5711
+ }
5712
+ pattern(pattern) {
5713
+ this.definition.pattern = pattern;
5714
+ return this;
5715
+ }
5716
+ placeholder(value) {
5717
+ this.definition.placeholder = value;
5718
+ return this;
5719
+ }
5720
+ };
5721
+ var TextareaPropertyBuilder = class extends BasePropertyBuilder {
5722
+ constructor(name) {
5723
+ super(name);
5724
+ this.definition.type = "textarea";
5725
+ }
5726
+ minLength(value) {
5727
+ this.definition.minLength = value;
5728
+ return this;
5729
+ }
5730
+ maxLength(value) {
5731
+ this.definition.maxLength = value;
5732
+ return this;
5733
+ }
5734
+ placeholder(value) {
5735
+ this.definition.placeholder = value;
5736
+ return this;
5737
+ }
5738
+ };
5739
+ var NumberPropertyBuilder = class extends BasePropertyBuilder {
5740
+ constructor(name) {
5741
+ super(name);
5742
+ this.definition.type = "number";
5743
+ }
5744
+ min(value) {
5745
+ this.definition.min = value;
5746
+ return this;
5747
+ }
5748
+ max(value) {
5749
+ this.definition.max = value;
5750
+ return this;
5751
+ }
5752
+ decimal(places) {
5753
+ this.definition.decimal = places;
5754
+ return this;
5755
+ }
5756
+ integer() {
5757
+ this.definition.integer = true;
5758
+ return this;
5759
+ }
5760
+ placeholder(value) {
5761
+ this.definition.placeholder = value;
5762
+ return this;
5763
+ }
5764
+ };
5765
+ var CheckboxPropertyBuilder = class extends BasePropertyBuilder {
5766
+ constructor(name) {
5767
+ super(name);
5768
+ this.definition.type = "checkbox";
5769
+ }
5770
+ };
5771
+ var DatePropertyBuilder = class extends BasePropertyBuilder {
5772
+ constructor(name) {
5773
+ super(name);
5774
+ this.definition.type = "date";
5775
+ }
5776
+ includeTime() {
5777
+ this.definition.includeTime = true;
5778
+ return this;
5779
+ }
5780
+ min(date2) {
5781
+ this.definition.min = date2;
5782
+ return this;
5783
+ }
5784
+ max(date2) {
5785
+ this.definition.max = date2;
5786
+ return this;
5787
+ }
5788
+ };
5789
+ var PhonePropertyBuilder = class extends BasePropertyBuilder {
5790
+ constructor(name) {
5791
+ super(name);
5792
+ this.definition.type = "phone";
5793
+ }
5794
+ };
5795
+ var CurrencyPropertyBuilder = class extends BasePropertyBuilder {
5796
+ constructor(name) {
5797
+ super(name);
5798
+ this.definition.type = "currency";
5799
+ }
5800
+ currency(code) {
5801
+ this.definition.currency = code;
5802
+ return this;
5803
+ }
5804
+ min(value) {
5805
+ this.definition.min = value;
5806
+ return this;
5807
+ }
5808
+ max(value) {
5809
+ this.definition.max = value;
5810
+ return this;
5811
+ }
5812
+ };
5813
+ var StatusPropertyBuilder = class extends BasePropertyBuilder {
5814
+ constructor(name) {
5815
+ super(name);
5816
+ this.definition.type = "status";
5817
+ }
5818
+ options(options) {
5819
+ this.definition.options = options;
5820
+ return this;
5821
+ }
5822
+ };
5823
+ var SelectPropertyBuilder = class extends BasePropertyBuilder {
5824
+ constructor(name) {
5825
+ super(name);
5826
+ this.definition.type = "select";
5827
+ }
5828
+ options(options) {
5829
+ this.definition.options = options;
5830
+ return this;
5831
+ }
5832
+ };
5833
+ var MultiselectPropertyBuilder = class extends BasePropertyBuilder {
5834
+ constructor(name) {
5835
+ super(name);
5836
+ this.definition.type = "multiselect";
5837
+ }
5838
+ options(options) {
5839
+ this.definition.options = options;
5840
+ return this;
5841
+ }
5842
+ maxSelections(value) {
5843
+ this.definition.maxSelections = value;
5844
+ return this;
5845
+ }
5846
+ };
5847
+ var RatingPropertyBuilder = class extends BasePropertyBuilder {
5848
+ constructor(name) {
5849
+ super(name);
5850
+ this.definition.type = "rating";
5851
+ }
5852
+ max(value) {
5853
+ this.definition.max = value;
5854
+ return this;
5855
+ }
5856
+ icon(icon) {
5857
+ this.definition.icon = icon;
5858
+ return this;
5859
+ }
5860
+ };
5861
+ var LocationPropertyBuilder = class extends BasePropertyBuilder {
5862
+ constructor(name) {
5863
+ super(name);
5864
+ this.definition.type = "location";
5865
+ }
5866
+ };
5867
+ function validatePropertyType(type) {
5868
+ if (FORBIDDEN_PROPERTY_TYPES.includes(type)) {
5869
+ throw new Error(
5870
+ `Property type "${type}" is not supported in .qualifyWith(). Only simple types (text, number, date, select, etc.) are allowed. Complex types (formula, rollup, relation, file, user, document) would require duplicating backend behavior.`
5871
+ );
5872
+ }
5873
+ }
5874
+
5500
5875
  // src/builders/attribute-builders.ts
5501
5876
  var BaseAttributeBuilder = class {
5502
5877
  constructor(type, name, label) {
@@ -6053,6 +6428,32 @@ var SingleRelationAttributeBuilder = class extends BaseAttributeBuilder {
6053
6428
  );
6054
6429
  return multiBuilder;
6055
6430
  }
6431
+ /**
6432
+ * Add properties to qualify the relation
6433
+ * Must be called AFTER .to() to ensure targets are defined
6434
+ *
6435
+ * @example
6436
+ * ```typescript
6437
+ * relation({ name: "mainCompany", label: "Main Company" })
6438
+ * .to("companies")
6439
+ * .qualifyWith(props => props
6440
+ * .add("role", select => select.options([...]).required())
6441
+ * .add("shares", number => number.min(0))
6442
+ * )
6443
+ * ```
6444
+ */
6445
+ qualifyWith(configure) {
6446
+ const targets = this.attr.targets;
6447
+ if (!targets || targets.length === 0) {
6448
+ throw new Error(
6449
+ '.qualifyWith() must be called AFTER .to(). Example: relation({ name: "mainCompany" }).to("companies").qualifyWith(...)'
6450
+ );
6451
+ }
6452
+ const builder = new PropertySchemaBuilder();
6453
+ const schema = configure(builder).build();
6454
+ this.attr.properties = schema;
6455
+ return this;
6456
+ }
6056
6457
  required() {
6057
6458
  this.setRequired(true);
6058
6459
  return this;
@@ -6114,6 +6515,33 @@ var MultiRelationAttributeBuilder = class extends BaseAttributeBuilder {
6114
6515
  this.attr.maxItems = count;
6115
6516
  return this;
6116
6517
  }
6518
+ /**
6519
+ * Add properties to qualify the relation
6520
+ * Must be called AFTER .to() or .many() to ensure targets are defined
6521
+ *
6522
+ * @example
6523
+ * ```typescript
6524
+ * relation({ name: "companies", label: "Companies" })
6525
+ * .to("companies")
6526
+ * .many()
6527
+ * .qualifyWith(props => props
6528
+ * .add("role", select => select.options([...]).required())
6529
+ * .add("shares", number => number.min(0))
6530
+ * )
6531
+ * ```
6532
+ */
6533
+ qualifyWith(configure) {
6534
+ const targets = this.attr.targets;
6535
+ if (!targets || targets.length === 0) {
6536
+ throw new Error(
6537
+ '.qualifyWith() must be called AFTER .to() or .many(). Example: relation({ name: "companies" }).to("companies").many().qualifyWith(...)'
6538
+ );
6539
+ }
6540
+ const builder = new PropertySchemaBuilder();
6541
+ const schema = configure(builder).build();
6542
+ this.attr.properties = schema;
6543
+ return this;
6544
+ }
6117
6545
  required() {
6118
6546
  this.setRequired(true);
6119
6547
  return this;
@@ -6499,7 +6927,7 @@ function object(config) {
6499
6927
  }
6500
6928
 
6501
6929
  // src/builders/view-builder.ts
6502
- import { randomUUID } from "crypto";
6930
+ import { randomUUID as randomUUID2 } from "crypto";
6503
6931
  import { z as z3 } from "zod";
6504
6932
  var GroupBuilder = class {
6505
6933
  constructor(id, label) {
@@ -7359,7 +7787,7 @@ var ListViewBuilder = class {
7359
7787
  columns: this.data.columns,
7360
7788
  columnSizing: this.data.columnSizing,
7361
7789
  defaultFilters: this.data.defaultFilters ? {
7362
- id: randomUUID(),
7790
+ id: randomUUID2(),
7363
7791
  combinator: this.data.defaultFilters.combinator,
7364
7792
  rules: this.data.defaultFilters.rules
7365
7793
  } : void 0,
@@ -7370,7 +7798,7 @@ var ListViewBuilder = class {
7370
7798
  label: tab.label,
7371
7799
  icon: tab.icon,
7372
7800
  filters: tab.filters ? {
7373
- id: randomUUID(),
7801
+ id: randomUUID2(),
7374
7802
  combinator: tab.filters.combinator,
7375
7803
  rules: tab.filters.rules
7376
7804
  } : void 0,
@@ -9799,6 +10227,13 @@ var RecordQueryService = class extends BaseService {
9799
10227
  effectiveTotal = exhausted ? collected.length : Math.max(collected.length, result.total);
9800
10228
  filteredRecords = collected.slice(requestedOffset, requestedOffset + requestedLimit);
9801
10229
  }
10230
+ if (options?.include && options.include.length > 0) {
10231
+ filteredRecords = await this.includeRelationsWithProperties(
10232
+ filteredRecords,
10233
+ schema,
10234
+ options.include
10235
+ );
10236
+ }
9802
10237
  if (!options?.skipFormulas) {
9803
10238
  return {
9804
10239
  records: enrichRecordsWithFormulas(filteredRecords, schema),
@@ -9874,6 +10309,79 @@ var RecordQueryService = class extends BaseService {
9874
10309
  }
9875
10310
  return result;
9876
10311
  }
10312
+ // ============================================================================
10313
+ // INCLUDE RELATIONS WITH PROPERTIES
10314
+ // ============================================================================
10315
+ /**
10316
+ * Include relation properties in records.
10317
+ *
10318
+ * For each requested relation attribute:
10319
+ * - If attribute has properties → Fetch from relation_attributes and return hybrid format
10320
+ * - If attribute has NO properties → Return legacy format (string[] or string)
10321
+ *
10322
+ * Uses batch loading to avoid N+1 queries.
10323
+ *
10324
+ * @param records - Records to enrich with relation properties
10325
+ * @param schema - Object schema
10326
+ * @param includes - Array of relation attribute names to include
10327
+ * @returns Records enriched with relation properties in hybrid format
10328
+ * @private
10329
+ */
10330
+ async includeRelationsWithProperties(records, schema, includes) {
10331
+ if (records.length === 0 || includes.length === 0) {
10332
+ return records;
10333
+ }
10334
+ for (const includeName of includes) {
10335
+ const attr = schema.attributes.find((a) => a.name === includeName);
10336
+ if (!attr || attr.type !== "relation") {
10337
+ continue;
10338
+ }
10339
+ if (attr.properties && this.adapter.relationAttributes) {
10340
+ const recordIds = records.map((r) => r.id);
10341
+ const relationAttributesRepo = this.adapter.relationAttributes;
10342
+ const allRelationProps = await Promise.all(
10343
+ recordIds.map(
10344
+ (recordId) => relationAttributesRepo.findBySource(schema.name, recordId, includeName)
10345
+ )
10346
+ );
10347
+ const propsByRecord = /* @__PURE__ */ new Map();
10348
+ allRelationProps.forEach((props, index) => {
10349
+ const recordId = recordIds[index];
10350
+ const propsMap = /* @__PURE__ */ new Map();
10351
+ for (const prop of props) {
10352
+ propsMap.set(prop.toId, prop.properties);
10353
+ }
10354
+ propsByRecord.set(recordId, propsMap);
10355
+ });
10356
+ for (const record of records) {
10357
+ const currentValue = record.values[includeName];
10358
+ const propsMap = propsByRecord.get(record.id);
10359
+ if (!currentValue) {
10360
+ continue;
10361
+ }
10362
+ if (!propsMap) {
10363
+ continue;
10364
+ }
10365
+ if (attr.cardinality === "many" && Array.isArray(currentValue)) {
10366
+ record.values[includeName] = currentValue.map((id) => {
10367
+ if (typeof id === "string") {
10368
+ const props = propsMap.get(id);
10369
+ return props ? { id, props } : { id };
10370
+ }
10371
+ return id;
10372
+ });
10373
+ } else if (attr.cardinality === "one") {
10374
+ const id = typeof currentValue === "string" ? currentValue : null;
10375
+ if (id) {
10376
+ const props = propsMap.get(id);
10377
+ record.values[includeName] = props ? { id, props } : { id };
10378
+ }
10379
+ }
10380
+ }
10381
+ }
10382
+ }
10383
+ return records;
10384
+ }
9877
10385
  };
9878
10386
 
9879
10387
  // src/runtime/services/record/record-resolver.service.ts
@@ -9968,6 +10476,280 @@ var RecordResolverService = class extends BaseService {
9968
10476
  }
9969
10477
  };
9970
10478
 
10479
+ // src/runtime/services/record/relation-properties.service.ts
10480
+ import { z as z5 } from "zod";
10481
+ var RelationPropertiesService = class extends BaseService {
10482
+ constructor(adapter) {
10483
+ super(adapter);
10484
+ }
10485
+ // ============================================================================
10486
+ // PUBLIC API
10487
+ // ============================================================================
10488
+ /**
10489
+ * Normalize relation values for storage in object_records table.
10490
+ *
10491
+ * Extracts IDs from hybrid format ({ id, props }) and returns legacy format (string[] or string).
10492
+ * This ensures object_records.values only contains IDs, while properties are in relation_attributes.
10493
+ *
10494
+ * @param schema - Object schema
10495
+ * @param data - Record data with hybrid relation values
10496
+ * @returns Data with relation values normalized to ID-only format
10497
+ */
10498
+ normalizeRelationValuesForStorage(schema, data) {
10499
+ const normalized = { ...data };
10500
+ for (const attr of schema.attributes) {
10501
+ if (attr.type !== "relation" || !attr.properties) {
10502
+ continue;
10503
+ }
10504
+ const value = data[attr.name];
10505
+ if (value === null || value === void 0) {
10506
+ continue;
10507
+ }
10508
+ if (attr.cardinality === "many" && Array.isArray(value)) {
10509
+ normalized[attr.name] = value.map((item) => {
10510
+ if (typeof item === "string") return item;
10511
+ if (typeof item === "object" && item !== null && "id" in item) {
10512
+ return item.id;
10513
+ }
10514
+ return item;
10515
+ });
10516
+ } else if (typeof value === "object" && value !== null && "id" in value) {
10517
+ normalized[attr.name] = value.id;
10518
+ }
10519
+ }
10520
+ return normalized;
10521
+ }
10522
+ /**
10523
+ * Synchronize relation properties for a given attribute.
10524
+ *
10525
+ * Handles:
10526
+ * - Format normalization (legacy → new)
10527
+ * - Validation of properties
10528
+ * - Upsert for present IDs
10529
+ * - Delete for absent IDs
10530
+ *
10531
+ * @param schema - Object schema
10532
+ * @param recordId - Source record ID
10533
+ * @param attributeName - Relation attribute name
10534
+ * @param relationValue - Relation value (hybrid format)
10535
+ * @param adapter - Database adapter
10536
+ */
10537
+ async syncRelationProperties(schema, recordId, attributeName, relationValue, adapter) {
10538
+ const attribute = schema.attributes.find((a) => a.name === attributeName);
10539
+ if (!attribute || attribute.type !== "relation") {
10540
+ return;
10541
+ }
10542
+ if (!attribute.properties) {
10543
+ return;
10544
+ }
10545
+ const normalized = this.normalizeRelationValue(relationValue);
10546
+ for (const item of normalized) {
10547
+ if (item.props) {
10548
+ this.validateProperties(attribute.properties, item.props);
10549
+ }
10550
+ }
10551
+ const existing = await adapter.relationAttributes?.findBySource(
10552
+ schema.name,
10553
+ recordId,
10554
+ attributeName
10555
+ );
10556
+ const existingIds = new Set((existing ?? []).map((r) => r.toId));
10557
+ const newIds = new Set(normalized.map((item) => item.id));
10558
+ const toUpsert = normalized.filter((item) => item.props !== void 0);
10559
+ const toDelete = Array.from(existingIds).filter((id) => !newIds.has(id));
10560
+ if (toUpsert.length > 0 && adapter.relationAttributes) {
10561
+ const inputs = toUpsert.map((item) => ({
10562
+ fromObject: schema.name,
10563
+ fromId: recordId,
10564
+ fromAttribute: attributeName,
10565
+ toId: item.id,
10566
+ properties: item.props ?? {},
10567
+ updatedBy: this.userId ?? void 0,
10568
+ createdBy: this.userId ?? void 0
10569
+ }));
10570
+ await adapter.relationAttributes.upsertBatch(inputs);
10571
+ }
10572
+ if (toDelete.length > 0 && adapter.relationAttributes && existing) {
10573
+ for (const toId of toDelete) {
10574
+ const relation2 = existing.find((r) => r.toId === toId);
10575
+ if (relation2) {
10576
+ }
10577
+ }
10578
+ await adapter.relationAttributes.deleteBySource(schema.name, recordId, attributeName);
10579
+ if (toUpsert.length > 0) {
10580
+ const inputs = toUpsert.map((item) => ({
10581
+ fromObject: schema.name,
10582
+ fromId: recordId,
10583
+ fromAttribute: attributeName,
10584
+ toId: item.id,
10585
+ properties: item.props ?? {},
10586
+ updatedBy: this.userId ?? void 0,
10587
+ createdBy: this.userId ?? void 0
10588
+ }));
10589
+ await adapter.relationAttributes.upsertBatch(inputs);
10590
+ }
10591
+ }
10592
+ }
10593
+ /**
10594
+ * Validate relation properties against PropertySchema.
10595
+ *
10596
+ * Uses Zod for runtime validation based on PropertyDefinition types.
10597
+ *
10598
+ * @param propertySchema - Schema defining allowed properties
10599
+ * @param properties - Properties to validate
10600
+ * @throws {z.ZodError} if validation fails
10601
+ */
10602
+ validateProperties(propertySchema, properties) {
10603
+ const schema = this.buildZodSchema(propertySchema);
10604
+ schema.parse(properties);
10605
+ }
10606
+ // ============================================================================
10607
+ // PRIVATE HELPERS
10608
+ // ============================================================================
10609
+ /**
10610
+ * Normalize relation value to unified internal format.
10611
+ *
10612
+ * Converts:
10613
+ * - string[] → Array<{ id, props?: undefined }>
10614
+ * - string → [{ id, props?: undefined }]
10615
+ * - null → []
10616
+ * - Array<{ id, props }> → Array<{ id, props }> (passthrough)
10617
+ * - { id, props } → [{ id, props }] (single to array)
10618
+ *
10619
+ * @param value - Relation value in hybrid format
10620
+ * @returns Normalized array of relation items
10621
+ * @private
10622
+ */
10623
+ normalizeRelationValue(value) {
10624
+ if (value === null || value === void 0) {
10625
+ return [];
10626
+ }
10627
+ if (typeof value === "string") {
10628
+ return [{ id: value }];
10629
+ }
10630
+ if (!Array.isArray(value) && typeof value === "object" && "id" in value) {
10631
+ return [value];
10632
+ }
10633
+ if (Array.isArray(value)) {
10634
+ return value.map((item) => {
10635
+ if (typeof item === "string") {
10636
+ return { id: item };
10637
+ }
10638
+ return item;
10639
+ });
10640
+ }
10641
+ return [];
10642
+ }
10643
+ /**
10644
+ * Build Zod schema from PropertySchema definition.
10645
+ *
10646
+ * Dynamically generates validation schema based on PropertyDefinition types.
10647
+ *
10648
+ * @param propertySchema - PropertySchema with definitions
10649
+ * @returns Zod schema for validation
10650
+ * @private
10651
+ */
10652
+ buildZodSchema(propertySchema) {
10653
+ const shape = {};
10654
+ for (const def of propertySchema.definitions) {
10655
+ let fieldSchema = this.buildFieldSchema(def);
10656
+ if (!def.required) {
10657
+ fieldSchema = fieldSchema.optional();
10658
+ }
10659
+ shape[def.name] = fieldSchema;
10660
+ }
10661
+ return z5.object(shape);
10662
+ }
10663
+ /**
10664
+ * Build Zod schema for a single property field.
10665
+ *
10666
+ * @param def - PropertyDefinition
10667
+ * @returns Zod schema for the field
10668
+ * @private
10669
+ */
10670
+ buildFieldSchema(def) {
10671
+ switch (def.type) {
10672
+ case "text":
10673
+ case "textarea": {
10674
+ let schema = z5.string();
10675
+ if (def.minLength !== void 0) {
10676
+ schema = schema.min(def.minLength);
10677
+ }
10678
+ if (def.maxLength !== void 0) {
10679
+ schema = schema.max(def.maxLength);
10680
+ }
10681
+ if (def.type === "text" && def.pattern) {
10682
+ schema = schema.regex(new RegExp(def.pattern));
10683
+ }
10684
+ return schema;
10685
+ }
10686
+ case "number": {
10687
+ let schema = z5.number();
10688
+ if (def.min !== void 0) {
10689
+ schema = schema.min(def.min);
10690
+ }
10691
+ if (def.max !== void 0) {
10692
+ schema = schema.max(def.max);
10693
+ }
10694
+ if (def.integer) {
10695
+ schema = schema.int();
10696
+ }
10697
+ return schema;
10698
+ }
10699
+ case "checkbox": {
10700
+ return z5.boolean();
10701
+ }
10702
+ case "date": {
10703
+ const schema = z5.string().datetime();
10704
+ return schema;
10705
+ }
10706
+ case "phone": {
10707
+ return z5.string();
10708
+ }
10709
+ case "currency": {
10710
+ let schema = z5.number();
10711
+ if (def.min !== void 0) {
10712
+ schema = schema.min(def.min);
10713
+ }
10714
+ if (def.max !== void 0) {
10715
+ schema = schema.max(def.max);
10716
+ }
10717
+ return schema;
10718
+ }
10719
+ case "status":
10720
+ case "select": {
10721
+ const validValues = def.options.map((opt) => opt.value);
10722
+ return z5.enum(validValues);
10723
+ }
10724
+ case "multiselect": {
10725
+ const validValues = def.options.map((opt) => opt.value);
10726
+ let schema = z5.array(z5.enum(validValues));
10727
+ if (def.maxSelections !== void 0) {
10728
+ schema = schema.max(def.maxSelections);
10729
+ }
10730
+ return schema;
10731
+ }
10732
+ case "rating": {
10733
+ let schema = z5.number().int();
10734
+ if (def.max !== void 0) {
10735
+ schema = schema.max(def.max);
10736
+ }
10737
+ return schema.min(0);
10738
+ }
10739
+ case "location": {
10740
+ return z5.object({
10741
+ address: z5.string().optional(),
10742
+ lat: z5.number().optional(),
10743
+ lng: z5.number().optional()
10744
+ });
10745
+ }
10746
+ default: {
10747
+ return z5.unknown();
10748
+ }
10749
+ }
10750
+ }
10751
+ };
10752
+
9971
10753
  // src/runtime/services/record/relation.service.ts
9972
10754
  var RelationService = class extends BaseService {
9973
10755
  constructor(adapter, nativeRegistry, options) {
@@ -10796,6 +11578,7 @@ var RecordService = class extends BaseService {
10796
11578
  queryService: this.queryService,
10797
11579
  recordResolver: this.recordResolver
10798
11580
  });
11581
+ this.relationPropertiesService = new RelationPropertiesService(adapter);
10799
11582
  this.rollupService = new RollupService(adapter, {
10800
11583
  recordResolver: this.recordResolver
10801
11584
  });
@@ -10840,29 +11623,45 @@ var RecordService = class extends BaseService {
10840
11623
  if (!options?.skipHooks) {
10841
11624
  await this.hookRegistry.execute("beforeCreate", schema.name, hookCtx);
10842
11625
  }
11626
+ const normalizedData = this.relationPropertiesService.normalizeRelationValuesForStorage(
11627
+ schema,
11628
+ dataWithDefaults
11629
+ );
10843
11630
  if (options?.validate !== false) {
10844
11631
  if (options?.allowDraft) {
10845
- validateDraftOrThrow(schema, dataWithDefaults);
11632
+ validateDraftOrThrow(schema, normalizedData);
10846
11633
  } else {
10847
- validateObjectOrThrow(schema, dataWithDefaults);
11634
+ validateObjectOrThrow(schema, normalizedData);
10848
11635
  }
10849
11636
  if (!options?.skipRelationValidation) {
10850
- await this.relationService.validateRelationsOrThrow(schema, dataWithDefaults);
11637
+ await this.relationService.validateRelationsOrThrow(schema, normalizedData);
10851
11638
  }
10852
11639
  if (!options?.skipUserValidation) {
10853
- await this.userService.validateUsersOrThrow(schema, dataWithDefaults);
11640
+ await this.userService.validateUsersOrThrow(schema, normalizedData);
10854
11641
  }
10855
11642
  }
10856
- const completionStatus = computeRecordStatus(schema, dataWithDefaults);
10857
- const label = await computeLabel(schema, dataWithDefaults, this.labelResolver);
11643
+ const completionStatus = computeRecordStatus(schema, normalizedData);
11644
+ const label = await computeLabel(schema, normalizedData, this.labelResolver);
10858
11645
  const record = await this.adapter.objectRecords.create({
10859
11646
  objectId,
10860
- data: dataWithDefaults,
11647
+ data: normalizedData,
10861
11648
  label,
10862
11649
  completionStatus,
10863
11650
  metadata: options?.metadata,
10864
11651
  createdBy: this.userId
10865
11652
  });
11653
+ for (const [attrName, value] of Object.entries(dataWithDefaults)) {
11654
+ const attr = schema.attributes.find((a) => a.name === attrName);
11655
+ if (attr?.type === "relation" && attr.properties) {
11656
+ await this.relationPropertiesService.syncRelationProperties(
11657
+ schema,
11658
+ record.id,
11659
+ attrName,
11660
+ value,
11661
+ this.adapter
11662
+ );
11663
+ }
11664
+ }
10866
11665
  if (!options?.skipHooks) {
10867
11666
  const afterCtx = {
10868
11667
  ...hookCtx,
@@ -10993,30 +11792,29 @@ var RecordService = class extends BaseService {
10993
11792
  hookModifiedValues[key] = hookCtx.newValues[key];
10994
11793
  }
10995
11794
  }
11795
+ const dataToUpdate = { ...data, ...hookModifiedValues };
11796
+ const normalizedUpdate = this.relationPropertiesService.normalizeRelationValuesForStorage(
11797
+ schema,
11798
+ dataToUpdate
11799
+ );
11800
+ const normalizedMergedData = { ...existing.values, ...normalizedUpdate };
10996
11801
  if (options?.validate !== false) {
10997
11802
  if (options?.partial) {
10998
- validateDraftOrThrow(schema, mergedData);
11803
+ validateDraftOrThrow(schema, normalizedMergedData);
10999
11804
  } else {
11000
- validateObjectOrThrow(schema, mergedData);
11805
+ validateObjectOrThrow(schema, normalizedMergedData);
11001
11806
  }
11002
11807
  if (!options?.skipRelationValidation) {
11003
- await this.relationService.validateRelationsOrThrow(schema, {
11004
- ...data,
11005
- ...hookModifiedValues
11006
- });
11808
+ await this.relationService.validateRelationsOrThrow(schema, normalizedUpdate);
11007
11809
  }
11008
11810
  if (!options?.skipUserValidation) {
11009
- await this.userService.validateUsersOrThrow(schema, {
11010
- ...data,
11011
- ...hookModifiedValues
11012
- });
11811
+ await this.userService.validateUsersOrThrow(schema, normalizedUpdate);
11013
11812
  }
11014
11813
  }
11015
- const completionStatus = computeRecordStatus(schema, mergedData);
11016
- const label = await computeLabel(schema, mergedData, this.labelResolver);
11814
+ const completionStatus = computeRecordStatus(schema, normalizedMergedData);
11815
+ const label = await computeLabel(schema, normalizedMergedData, this.labelResolver);
11017
11816
  const updatePayload = {
11018
- ...data,
11019
- ...hookModifiedValues,
11817
+ ...normalizedUpdate,
11020
11818
  __completionStatus: completionStatus,
11021
11819
  __label: label,
11022
11820
  __lastUpdatedBy: this.userId,
@@ -11032,6 +11830,18 @@ var RecordService = class extends BaseService {
11032
11830
  }
11033
11831
  const updated = await this.adapter.objectRecords.update(recordId, updatePayload);
11034
11832
  await this.invalidateRecordCaches(recordId, existing.objectId);
11833
+ for (const [attrName, value] of Object.entries(dataToUpdate)) {
11834
+ const attr = schema.attributes.find((a) => a.name === attrName);
11835
+ if (attr?.type === "relation" && attr.properties) {
11836
+ await this.relationPropertiesService.syncRelationProperties(
11837
+ schema,
11838
+ recordId,
11839
+ attrName,
11840
+ value,
11841
+ this.adapter
11842
+ );
11843
+ }
11844
+ }
11035
11845
  if (!options?.skipHooks) {
11036
11846
  const afterCtx = {
11037
11847
  ...hookCtx,
@@ -17132,6 +17942,7 @@ export {
17132
17942
  SYSTEM_FIELD_NAMES,
17133
17943
  RESERVED_ATTRIBUTE_NAMES,
17134
17944
  PolicyViolationError,
17945
+ FORBIDDEN_PROPERTY_TYPES,
17135
17946
  SYSTEM_ATTRIBUTES,
17136
17947
  getSystemAttributeList,
17137
17948
  isSystemAttribute,
@@ -17230,6 +18041,22 @@ export {
17230
18041
  RoleNotFoundError,
17231
18042
  isForbiddenError,
17232
18043
  ConcurrentModificationError,
18044
+ PropertySchemaBuilder,
18045
+ PropertyTypeBuilder,
18046
+ BasePropertyBuilder,
18047
+ TextPropertyBuilder,
18048
+ TextareaPropertyBuilder,
18049
+ NumberPropertyBuilder,
18050
+ CheckboxPropertyBuilder,
18051
+ DatePropertyBuilder,
18052
+ PhonePropertyBuilder,
18053
+ CurrencyPropertyBuilder,
18054
+ StatusPropertyBuilder,
18055
+ SelectPropertyBuilder,
18056
+ MultiselectPropertyBuilder,
18057
+ RatingPropertyBuilder,
18058
+ LocationPropertyBuilder,
18059
+ validatePropertyType,
17233
18060
  text,
17234
18061
  textarea,
17235
18062
  richtext,
@@ -17399,6 +18226,7 @@ export {
17399
18226
  recalculateParentRollups,
17400
18227
  RecordQueryService,
17401
18228
  RecordResolverService,
18229
+ RelationPropertiesService,
17402
18230
  RelationService,
17403
18231
  RollupService,
17404
18232
  RecordService,