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

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.
@@ -307,8 +307,8 @@ var cacheKeys = {
307
307
  searchResults: (tenantId, objectId, hash) => `search:${tenantId}:${objectId}:${hash}`,
308
308
  /** All search results for an object (for invalidation) */
309
309
  allSearchResults: (tenantId, objectId) => `search:${tenantId}:${objectId}:*`,
310
- /** Global search results */
311
- globalSearch: (tenantId, hash) => `gsearch:${tenantId}:${hash}`,
310
+ /** Global search results (3-param signature to match cachedList pattern) */
311
+ globalSearch: (tenantId, _id, hash) => `gsearch:${tenantId}:${hash}`,
312
312
  /** All global search results for tenant (for invalidation) */
313
313
  allGlobalSearch: (tenantId) => `gsearch:${tenantId}:*`,
314
314
  // -------------------------------------------------------------------------
@@ -3949,7 +3949,6 @@ function createMockObjectRecordsRepository(stores) {
3949
3949
  objectLabel: obj?.label ?? "Unknown",
3950
3950
  label: renderLabelExpression(labelExpression, enrichedValues),
3951
3951
  recordId: r.id,
3952
- values: r.values,
3953
3952
  completionStatus: r.completionStatus,
3954
3953
  createdAt: r.createdAt,
3955
3954
  updatedAt: r.updatedAt
@@ -3957,6 +3956,36 @@ function createMockObjectRecordsRepository(stores) {
3957
3956
  });
3958
3957
  return Promise.resolve({ results, total });
3959
3958
  },
3959
+ globalSearchGrouped(query, options) {
3960
+ const limitPerGroup = options?.limitPerGroup ?? 5;
3961
+ return this.globalSearch(query, {
3962
+ objectNames: options?.objectNames,
3963
+ limit: 500,
3964
+ offset: 0
3965
+ }).then(({ results }) => {
3966
+ const groupMap = /* @__PURE__ */ new Map();
3967
+ for (const result of results) {
3968
+ let group2 = groupMap.get(result.objectName);
3969
+ if (!group2) {
3970
+ group2 = {
3971
+ objectName: result.objectName,
3972
+ objectLabel: result.objectLabel,
3973
+ results: [],
3974
+ totalInGroup: 0
3975
+ };
3976
+ groupMap.set(result.objectName, group2);
3977
+ }
3978
+ group2.totalInGroup++;
3979
+ if (group2.results.length < limitPerGroup) {
3980
+ group2.results.push(result);
3981
+ }
3982
+ }
3983
+ const groups = Array.from(groupMap.values());
3984
+ groups.sort((a, b) => b.totalInGroup - a.totalInGroup);
3985
+ const total = groups.reduce((sum, g) => sum + g.totalInGroup, 0);
3986
+ return { groups, total };
3987
+ });
3988
+ },
3960
3989
  // -------------------------------------------------------------------------
3961
3990
  // Schema Integrity Methods
3962
3991
  // -------------------------------------------------------------------------
@@ -4059,6 +4088,91 @@ function createMockObjectRecordsRepository(stores) {
4059
4088
  };
4060
4089
  }
4061
4090
 
4091
+ // src/runtime/mock/mock-relation-attributes.ts
4092
+ import { randomUUID } from "crypto";
4093
+ function createMockRelationAttributesRepository(stores) {
4094
+ return {
4095
+ async upsertBatch(items) {
4096
+ const context = getContext2();
4097
+ if (!context) {
4098
+ throw new Error("Context required for relationAttributes operations");
4099
+ }
4100
+ const results = [];
4101
+ for (const item of items) {
4102
+ const _key = `${context.tenantId}:${item.fromObject}:${item.fromId}:${item.fromAttribute}:${item.toId}`;
4103
+ const existing = Array.from(stores.relationAttributes.values()).find(
4104
+ (row) => row.tenantId === context.tenantId && row.fromObject === item.fromObject && row.fromId === item.fromId && row.fromAttribute === item.fromAttribute && row.toId === item.toId
4105
+ );
4106
+ if (existing) {
4107
+ existing.properties = item.properties ?? {};
4108
+ existing.updatedAt = /* @__PURE__ */ new Date();
4109
+ existing.updatedBy = item.updatedBy ?? context.userId ?? null;
4110
+ results.push(existing);
4111
+ } else {
4112
+ const row = {
4113
+ id: randomUUID(),
4114
+ tenantId: context.tenantId,
4115
+ fromObject: item.fromObject,
4116
+ fromId: item.fromId,
4117
+ fromAttribute: item.fromAttribute,
4118
+ toId: item.toId,
4119
+ properties: item.properties ?? {},
4120
+ createdAt: /* @__PURE__ */ new Date(),
4121
+ updatedAt: /* @__PURE__ */ new Date(),
4122
+ createdBy: item.createdBy ?? context.userId ?? null,
4123
+ updatedBy: item.updatedBy ?? context.userId ?? null
4124
+ };
4125
+ stores.relationAttributes.set(row.id, row);
4126
+ results.push(row);
4127
+ }
4128
+ }
4129
+ return results;
4130
+ },
4131
+ async findBySource(fromObject, fromId, fromAttribute) {
4132
+ const context = getContext2();
4133
+ if (!context) {
4134
+ throw new Error("Context required for relationAttributes operations");
4135
+ }
4136
+ return Array.from(stores.relationAttributes.values()).filter(
4137
+ (row) => row.tenantId === context.tenantId && row.fromObject === fromObject && row.fromId === fromId && row.fromAttribute === fromAttribute
4138
+ );
4139
+ },
4140
+ async findByTarget(toId) {
4141
+ const context = getContext2();
4142
+ if (!context) {
4143
+ throw new Error("Context required for relationAttributes operations");
4144
+ }
4145
+ return Array.from(stores.relationAttributes.values()).filter(
4146
+ (row) => row.tenantId === context.tenantId && row.toId === toId
4147
+ );
4148
+ },
4149
+ async deleteBySource(fromObject, fromId, fromAttribute) {
4150
+ const context = getContext2();
4151
+ if (!context) {
4152
+ throw new Error("Context required for relationAttributes operations");
4153
+ }
4154
+ const toDelete = Array.from(stores.relationAttributes.entries()).filter(
4155
+ ([_id, row]) => row.tenantId === context.tenantId && row.fromObject === fromObject && row.fromId === fromId && row.fromAttribute === fromAttribute
4156
+ );
4157
+ for (const [id] of toDelete) {
4158
+ stores.relationAttributes.delete(id);
4159
+ }
4160
+ },
4161
+ async deleteByTarget(toId) {
4162
+ const context = getContext2();
4163
+ if (!context) {
4164
+ throw new Error("Context required for relationAttributes operations");
4165
+ }
4166
+ const toDelete = Array.from(stores.relationAttributes.entries()).filter(
4167
+ ([_id, row]) => row.tenantId === context.tenantId && row.toId === toId
4168
+ );
4169
+ for (const [id] of toDelete) {
4170
+ stores.relationAttributes.delete(id);
4171
+ }
4172
+ }
4173
+ };
4174
+ }
4175
+
4062
4176
  // src/runtime/mock/mock-stores.ts
4063
4177
  function createEmptyStores() {
4064
4178
  return {
@@ -4079,7 +4193,8 @@ function createEmptyStores() {
4079
4193
  aiConversations: /* @__PURE__ */ new Map(),
4080
4194
  aiMessages: /* @__PURE__ */ new Map(),
4081
4195
  aiUserMemory: /* @__PURE__ */ new Map(),
4082
- aiUsageMetrics: /* @__PURE__ */ new Map()
4196
+ aiUsageMetrics: /* @__PURE__ */ new Map(),
4197
+ relationAttributes: /* @__PURE__ */ new Map()
4083
4198
  };
4084
4199
  }
4085
4200
 
@@ -4994,6 +5109,8 @@ function createMockAdapter() {
4994
5109
  aiConversations: createMockAIConversationsRepository(stores),
4995
5110
  aiUserMemory: createMockAIUserMemoryRepository(stores),
4996
5111
  aiUsageMetrics: createMockAIUsageMetricsRepository(stores),
5112
+ // Relation attributes repository
5113
+ relationAttributes: createMockRelationAttributesRepository(stores),
4997
5114
  async transaction(callback) {
4998
5115
  return await callback(adapter);
4999
5116
  },
@@ -5019,6 +5136,7 @@ function createMockAdapter() {
5019
5136
  stores.aiMessages.clear();
5020
5137
  stores.aiUserMemory.clear();
5021
5138
  stores.aiUsageMetrics.clear();
5139
+ stores.relationAttributes.clear();
5022
5140
  }
5023
5141
  };
5024
5142
  return adapter;
@@ -5497,6 +5615,292 @@ function validateOptions(options, attributeName) {
5497
5615
  }
5498
5616
  }
5499
5617
 
5618
+ // src/types/relation-properties.ts
5619
+ var FORBIDDEN_PROPERTY_TYPES = [
5620
+ "formula",
5621
+ "rollup",
5622
+ "relation",
5623
+ "file",
5624
+ "user",
5625
+ "document",
5626
+ "richtext"
5627
+ ];
5628
+
5629
+ // src/builders/property-schema-builder.ts
5630
+ var PropertySchemaBuilder = class {
5631
+ constructor() {
5632
+ this.definitions = [];
5633
+ }
5634
+ /**
5635
+ * Add a property to the schema
5636
+ * The type of property is automatically detected based on the builder methods used
5637
+ */
5638
+ add(name, configure) {
5639
+ const builder = new PropertyTypeBuilder(name);
5640
+ const configured = configure(builder);
5641
+ const definition = configured.build();
5642
+ this.definitions.push(definition);
5643
+ return this;
5644
+ }
5645
+ /**
5646
+ * Build the final PropertySchema
5647
+ */
5648
+ build() {
5649
+ return {
5650
+ definitions: this.definitions
5651
+ };
5652
+ }
5653
+ };
5654
+ var PropertyTypeBuilder = class {
5655
+ constructor(name) {
5656
+ this.name = name;
5657
+ }
5658
+ // Explicit type constructors
5659
+ text() {
5660
+ return new TextPropertyBuilder(this.name);
5661
+ }
5662
+ textarea() {
5663
+ return new TextareaPropertyBuilder(this.name);
5664
+ }
5665
+ number() {
5666
+ return new NumberPropertyBuilder(this.name);
5667
+ }
5668
+ checkbox() {
5669
+ return new CheckboxPropertyBuilder(this.name);
5670
+ }
5671
+ date() {
5672
+ return new DatePropertyBuilder(this.name);
5673
+ }
5674
+ phone() {
5675
+ return new PhonePropertyBuilder(this.name);
5676
+ }
5677
+ currency() {
5678
+ return new CurrencyPropertyBuilder(this.name);
5679
+ }
5680
+ status() {
5681
+ return new StatusPropertyBuilder(this.name);
5682
+ }
5683
+ select() {
5684
+ return new SelectPropertyBuilder(this.name);
5685
+ }
5686
+ multiselect() {
5687
+ return new MultiselectPropertyBuilder(this.name);
5688
+ }
5689
+ rating() {
5690
+ return new RatingPropertyBuilder(this.name);
5691
+ }
5692
+ location() {
5693
+ return new LocationPropertyBuilder(this.name);
5694
+ }
5695
+ };
5696
+ var BasePropertyBuilder = class {
5697
+ constructor(name) {
5698
+ this.definition = { name };
5699
+ }
5700
+ /**
5701
+ * Set the label
5702
+ */
5703
+ label(label) {
5704
+ this.definition.label = label;
5705
+ return this;
5706
+ }
5707
+ /**
5708
+ * Mark as required
5709
+ */
5710
+ required() {
5711
+ this.definition.required = true;
5712
+ return this;
5713
+ }
5714
+ /**
5715
+ * Set description
5716
+ */
5717
+ description(description) {
5718
+ this.definition.description = description;
5719
+ return this;
5720
+ }
5721
+ /**
5722
+ * Build the final definition
5723
+ */
5724
+ build() {
5725
+ return this.definition;
5726
+ }
5727
+ };
5728
+ var TextPropertyBuilder = class extends BasePropertyBuilder {
5729
+ constructor(name) {
5730
+ super(name);
5731
+ this.definition.type = "text";
5732
+ }
5733
+ minLength(value) {
5734
+ this.definition.minLength = value;
5735
+ return this;
5736
+ }
5737
+ maxLength(value) {
5738
+ this.definition.maxLength = value;
5739
+ return this;
5740
+ }
5741
+ pattern(pattern) {
5742
+ this.definition.pattern = pattern;
5743
+ return this;
5744
+ }
5745
+ placeholder(value) {
5746
+ this.definition.placeholder = value;
5747
+ return this;
5748
+ }
5749
+ };
5750
+ var TextareaPropertyBuilder = class extends BasePropertyBuilder {
5751
+ constructor(name) {
5752
+ super(name);
5753
+ this.definition.type = "textarea";
5754
+ }
5755
+ minLength(value) {
5756
+ this.definition.minLength = value;
5757
+ return this;
5758
+ }
5759
+ maxLength(value) {
5760
+ this.definition.maxLength = value;
5761
+ return this;
5762
+ }
5763
+ placeholder(value) {
5764
+ this.definition.placeholder = value;
5765
+ return this;
5766
+ }
5767
+ };
5768
+ var NumberPropertyBuilder = class extends BasePropertyBuilder {
5769
+ constructor(name) {
5770
+ super(name);
5771
+ this.definition.type = "number";
5772
+ }
5773
+ min(value) {
5774
+ this.definition.min = value;
5775
+ return this;
5776
+ }
5777
+ max(value) {
5778
+ this.definition.max = value;
5779
+ return this;
5780
+ }
5781
+ decimal(places) {
5782
+ this.definition.decimal = places;
5783
+ return this;
5784
+ }
5785
+ integer() {
5786
+ this.definition.integer = true;
5787
+ return this;
5788
+ }
5789
+ placeholder(value) {
5790
+ this.definition.placeholder = value;
5791
+ return this;
5792
+ }
5793
+ };
5794
+ var CheckboxPropertyBuilder = class extends BasePropertyBuilder {
5795
+ constructor(name) {
5796
+ super(name);
5797
+ this.definition.type = "checkbox";
5798
+ }
5799
+ };
5800
+ var DatePropertyBuilder = class extends BasePropertyBuilder {
5801
+ constructor(name) {
5802
+ super(name);
5803
+ this.definition.type = "date";
5804
+ }
5805
+ includeTime() {
5806
+ this.definition.includeTime = true;
5807
+ return this;
5808
+ }
5809
+ min(date2) {
5810
+ this.definition.min = date2;
5811
+ return this;
5812
+ }
5813
+ max(date2) {
5814
+ this.definition.max = date2;
5815
+ return this;
5816
+ }
5817
+ };
5818
+ var PhonePropertyBuilder = class extends BasePropertyBuilder {
5819
+ constructor(name) {
5820
+ super(name);
5821
+ this.definition.type = "phone";
5822
+ }
5823
+ };
5824
+ var CurrencyPropertyBuilder = class extends BasePropertyBuilder {
5825
+ constructor(name) {
5826
+ super(name);
5827
+ this.definition.type = "currency";
5828
+ }
5829
+ currency(code) {
5830
+ this.definition.currency = code;
5831
+ return this;
5832
+ }
5833
+ min(value) {
5834
+ this.definition.min = value;
5835
+ return this;
5836
+ }
5837
+ max(value) {
5838
+ this.definition.max = value;
5839
+ return this;
5840
+ }
5841
+ };
5842
+ var StatusPropertyBuilder = class extends BasePropertyBuilder {
5843
+ constructor(name) {
5844
+ super(name);
5845
+ this.definition.type = "status";
5846
+ }
5847
+ options(options) {
5848
+ this.definition.options = options;
5849
+ return this;
5850
+ }
5851
+ };
5852
+ var SelectPropertyBuilder = class extends BasePropertyBuilder {
5853
+ constructor(name) {
5854
+ super(name);
5855
+ this.definition.type = "select";
5856
+ }
5857
+ options(options) {
5858
+ this.definition.options = options;
5859
+ return this;
5860
+ }
5861
+ };
5862
+ var MultiselectPropertyBuilder = class extends BasePropertyBuilder {
5863
+ constructor(name) {
5864
+ super(name);
5865
+ this.definition.type = "multiselect";
5866
+ }
5867
+ options(options) {
5868
+ this.definition.options = options;
5869
+ return this;
5870
+ }
5871
+ maxSelections(value) {
5872
+ this.definition.maxSelections = value;
5873
+ return this;
5874
+ }
5875
+ };
5876
+ var RatingPropertyBuilder = class extends BasePropertyBuilder {
5877
+ constructor(name) {
5878
+ super(name);
5879
+ this.definition.type = "rating";
5880
+ }
5881
+ max(value) {
5882
+ this.definition.max = value;
5883
+ return this;
5884
+ }
5885
+ icon(icon) {
5886
+ this.definition.icon = icon;
5887
+ return this;
5888
+ }
5889
+ };
5890
+ var LocationPropertyBuilder = class extends BasePropertyBuilder {
5891
+ constructor(name) {
5892
+ super(name);
5893
+ this.definition.type = "location";
5894
+ }
5895
+ };
5896
+ function validatePropertyType(type) {
5897
+ if (FORBIDDEN_PROPERTY_TYPES.includes(type)) {
5898
+ throw new Error(
5899
+ `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.`
5900
+ );
5901
+ }
5902
+ }
5903
+
5500
5904
  // src/builders/attribute-builders.ts
5501
5905
  var BaseAttributeBuilder = class {
5502
5906
  constructor(type, name, label) {
@@ -6053,6 +6457,32 @@ var SingleRelationAttributeBuilder = class extends BaseAttributeBuilder {
6053
6457
  );
6054
6458
  return multiBuilder;
6055
6459
  }
6460
+ /**
6461
+ * Add properties to qualify the relation
6462
+ * Must be called AFTER .to() to ensure targets are defined
6463
+ *
6464
+ * @example
6465
+ * ```typescript
6466
+ * relation({ name: "mainCompany", label: "Main Company" })
6467
+ * .to("companies")
6468
+ * .qualifyWith(props => props
6469
+ * .add("role", select => select.options([...]).required())
6470
+ * .add("shares", number => number.min(0))
6471
+ * )
6472
+ * ```
6473
+ */
6474
+ qualifyWith(configure) {
6475
+ const targets = this.attr.targets;
6476
+ if (!targets || targets.length === 0) {
6477
+ throw new Error(
6478
+ '.qualifyWith() must be called AFTER .to(). Example: relation({ name: "mainCompany" }).to("companies").qualifyWith(...)'
6479
+ );
6480
+ }
6481
+ const builder = new PropertySchemaBuilder();
6482
+ const schema = configure(builder).build();
6483
+ this.attr.properties = schema;
6484
+ return this;
6485
+ }
6056
6486
  required() {
6057
6487
  this.setRequired(true);
6058
6488
  return this;
@@ -6114,6 +6544,33 @@ var MultiRelationAttributeBuilder = class extends BaseAttributeBuilder {
6114
6544
  this.attr.maxItems = count;
6115
6545
  return this;
6116
6546
  }
6547
+ /**
6548
+ * Add properties to qualify the relation
6549
+ * Must be called AFTER .to() or .many() to ensure targets are defined
6550
+ *
6551
+ * @example
6552
+ * ```typescript
6553
+ * relation({ name: "companies", label: "Companies" })
6554
+ * .to("companies")
6555
+ * .many()
6556
+ * .qualifyWith(props => props
6557
+ * .add("role", select => select.options([...]).required())
6558
+ * .add("shares", number => number.min(0))
6559
+ * )
6560
+ * ```
6561
+ */
6562
+ qualifyWith(configure) {
6563
+ const targets = this.attr.targets;
6564
+ if (!targets || targets.length === 0) {
6565
+ throw new Error(
6566
+ '.qualifyWith() must be called AFTER .to() or .many(). Example: relation({ name: "companies" }).to("companies").many().qualifyWith(...)'
6567
+ );
6568
+ }
6569
+ const builder = new PropertySchemaBuilder();
6570
+ const schema = configure(builder).build();
6571
+ this.attr.properties = schema;
6572
+ return this;
6573
+ }
6117
6574
  required() {
6118
6575
  this.setRequired(true);
6119
6576
  return this;
@@ -6499,7 +6956,7 @@ function object(config) {
6499
6956
  }
6500
6957
 
6501
6958
  // src/builders/view-builder.ts
6502
- import { randomUUID } from "crypto";
6959
+ import { randomUUID as randomUUID2 } from "crypto";
6503
6960
  import { z as z3 } from "zod";
6504
6961
  var GroupBuilder = class {
6505
6962
  constructor(id, label) {
@@ -7359,7 +7816,7 @@ var ListViewBuilder = class {
7359
7816
  columns: this.data.columns,
7360
7817
  columnSizing: this.data.columnSizing,
7361
7818
  defaultFilters: this.data.defaultFilters ? {
7362
- id: randomUUID(),
7819
+ id: randomUUID2(),
7363
7820
  combinator: this.data.defaultFilters.combinator,
7364
7821
  rules: this.data.defaultFilters.rules
7365
7822
  } : void 0,
@@ -7370,7 +7827,7 @@ var ListViewBuilder = class {
7370
7827
  label: tab.label,
7371
7828
  icon: tab.icon,
7372
7829
  filters: tab.filters ? {
7373
- id: randomUUID(),
7830
+ id: randomUUID2(),
7374
7831
  combinator: tab.filters.combinator,
7375
7832
  rules: tab.filters.rules
7376
7833
  } : void 0,
@@ -9799,6 +10256,13 @@ var RecordQueryService = class extends BaseService {
9799
10256
  effectiveTotal = exhausted ? collected.length : Math.max(collected.length, result.total);
9800
10257
  filteredRecords = collected.slice(requestedOffset, requestedOffset + requestedLimit);
9801
10258
  }
10259
+ if (options?.include && options.include.length > 0) {
10260
+ filteredRecords = await this.includeRelationsWithProperties(
10261
+ filteredRecords,
10262
+ schema,
10263
+ options.include
10264
+ );
10265
+ }
9802
10266
  if (!options?.skipFormulas) {
9803
10267
  return {
9804
10268
  records: enrichRecordsWithFormulas(filteredRecords, schema),
@@ -9874,6 +10338,79 @@ var RecordQueryService = class extends BaseService {
9874
10338
  }
9875
10339
  return result;
9876
10340
  }
10341
+ // ============================================================================
10342
+ // INCLUDE RELATIONS WITH PROPERTIES
10343
+ // ============================================================================
10344
+ /**
10345
+ * Include relation properties in records.
10346
+ *
10347
+ * For each requested relation attribute:
10348
+ * - If attribute has properties → Fetch from relation_attributes and return hybrid format
10349
+ * - If attribute has NO properties → Return legacy format (string[] or string)
10350
+ *
10351
+ * Uses batch loading to avoid N+1 queries.
10352
+ *
10353
+ * @param records - Records to enrich with relation properties
10354
+ * @param schema - Object schema
10355
+ * @param includes - Array of relation attribute names to include
10356
+ * @returns Records enriched with relation properties in hybrid format
10357
+ * @private
10358
+ */
10359
+ async includeRelationsWithProperties(records, schema, includes) {
10360
+ if (records.length === 0 || includes.length === 0) {
10361
+ return records;
10362
+ }
10363
+ for (const includeName of includes) {
10364
+ const attr = schema.attributes.find((a) => a.name === includeName);
10365
+ if (!attr || attr.type !== "relation") {
10366
+ continue;
10367
+ }
10368
+ if (attr.properties && this.adapter.relationAttributes) {
10369
+ const recordIds = records.map((r) => r.id);
10370
+ const relationAttributesRepo = this.adapter.relationAttributes;
10371
+ const allRelationProps = await Promise.all(
10372
+ recordIds.map(
10373
+ (recordId) => relationAttributesRepo.findBySource(schema.name, recordId, includeName)
10374
+ )
10375
+ );
10376
+ const propsByRecord = /* @__PURE__ */ new Map();
10377
+ allRelationProps.forEach((props, index) => {
10378
+ const recordId = recordIds[index];
10379
+ const propsMap = /* @__PURE__ */ new Map();
10380
+ for (const prop of props) {
10381
+ propsMap.set(prop.toId, prop.properties);
10382
+ }
10383
+ propsByRecord.set(recordId, propsMap);
10384
+ });
10385
+ for (const record of records) {
10386
+ const currentValue = record.values[includeName];
10387
+ const propsMap = propsByRecord.get(record.id);
10388
+ if (!currentValue) {
10389
+ continue;
10390
+ }
10391
+ if (!propsMap) {
10392
+ continue;
10393
+ }
10394
+ if (attr.cardinality === "many" && Array.isArray(currentValue)) {
10395
+ record.values[includeName] = currentValue.map((id) => {
10396
+ if (typeof id === "string") {
10397
+ const props = propsMap.get(id);
10398
+ return props ? { id, props } : { id };
10399
+ }
10400
+ return id;
10401
+ });
10402
+ } else if (attr.cardinality === "one") {
10403
+ const id = typeof currentValue === "string" ? currentValue : null;
10404
+ if (id) {
10405
+ const props = propsMap.get(id);
10406
+ record.values[includeName] = props ? { id, props } : { id };
10407
+ }
10408
+ }
10409
+ }
10410
+ }
10411
+ }
10412
+ return records;
10413
+ }
9877
10414
  };
9878
10415
 
9879
10416
  // src/runtime/services/record/record-resolver.service.ts
@@ -9968,6 +10505,280 @@ var RecordResolverService = class extends BaseService {
9968
10505
  }
9969
10506
  };
9970
10507
 
10508
+ // src/runtime/services/record/relation-properties.service.ts
10509
+ import { z as z5 } from "zod";
10510
+ var RelationPropertiesService = class extends BaseService {
10511
+ constructor(adapter) {
10512
+ super(adapter);
10513
+ }
10514
+ // ============================================================================
10515
+ // PUBLIC API
10516
+ // ============================================================================
10517
+ /**
10518
+ * Normalize relation values for storage in object_records table.
10519
+ *
10520
+ * Extracts IDs from hybrid format ({ id, props }) and returns legacy format (string[] or string).
10521
+ * This ensures object_records.values only contains IDs, while properties are in relation_attributes.
10522
+ *
10523
+ * @param schema - Object schema
10524
+ * @param data - Record data with hybrid relation values
10525
+ * @returns Data with relation values normalized to ID-only format
10526
+ */
10527
+ normalizeRelationValuesForStorage(schema, data) {
10528
+ const normalized = { ...data };
10529
+ for (const attr of schema.attributes) {
10530
+ if (attr.type !== "relation" || !attr.properties) {
10531
+ continue;
10532
+ }
10533
+ const value = data[attr.name];
10534
+ if (value === null || value === void 0) {
10535
+ continue;
10536
+ }
10537
+ if (attr.cardinality === "many" && Array.isArray(value)) {
10538
+ normalized[attr.name] = value.map((item) => {
10539
+ if (typeof item === "string") return item;
10540
+ if (typeof item === "object" && item !== null && "id" in item) {
10541
+ return item.id;
10542
+ }
10543
+ return item;
10544
+ });
10545
+ } else if (typeof value === "object" && value !== null && "id" in value) {
10546
+ normalized[attr.name] = value.id;
10547
+ }
10548
+ }
10549
+ return normalized;
10550
+ }
10551
+ /**
10552
+ * Synchronize relation properties for a given attribute.
10553
+ *
10554
+ * Handles:
10555
+ * - Format normalization (legacy → new)
10556
+ * - Validation of properties
10557
+ * - Upsert for present IDs
10558
+ * - Delete for absent IDs
10559
+ *
10560
+ * @param schema - Object schema
10561
+ * @param recordId - Source record ID
10562
+ * @param attributeName - Relation attribute name
10563
+ * @param relationValue - Relation value (hybrid format)
10564
+ * @param adapter - Database adapter
10565
+ */
10566
+ async syncRelationProperties(schema, recordId, attributeName, relationValue, adapter) {
10567
+ const attribute = schema.attributes.find((a) => a.name === attributeName);
10568
+ if (!attribute || attribute.type !== "relation") {
10569
+ return;
10570
+ }
10571
+ if (!attribute.properties) {
10572
+ return;
10573
+ }
10574
+ const normalized = this.normalizeRelationValue(relationValue);
10575
+ for (const item of normalized) {
10576
+ if (item.props) {
10577
+ this.validateProperties(attribute.properties, item.props);
10578
+ }
10579
+ }
10580
+ const existing = await adapter.relationAttributes?.findBySource(
10581
+ schema.name,
10582
+ recordId,
10583
+ attributeName
10584
+ );
10585
+ const existingIds = new Set((existing ?? []).map((r) => r.toId));
10586
+ const newIds = new Set(normalized.map((item) => item.id));
10587
+ const toUpsert = normalized.filter((item) => item.props !== void 0);
10588
+ const toDelete = Array.from(existingIds).filter((id) => !newIds.has(id));
10589
+ if (toUpsert.length > 0 && adapter.relationAttributes) {
10590
+ const inputs = toUpsert.map((item) => ({
10591
+ fromObject: schema.name,
10592
+ fromId: recordId,
10593
+ fromAttribute: attributeName,
10594
+ toId: item.id,
10595
+ properties: item.props ?? {},
10596
+ updatedBy: this.userId ?? void 0,
10597
+ createdBy: this.userId ?? void 0
10598
+ }));
10599
+ await adapter.relationAttributes.upsertBatch(inputs);
10600
+ }
10601
+ if (toDelete.length > 0 && adapter.relationAttributes && existing) {
10602
+ for (const toId of toDelete) {
10603
+ const relation2 = existing.find((r) => r.toId === toId);
10604
+ if (relation2) {
10605
+ }
10606
+ }
10607
+ await adapter.relationAttributes.deleteBySource(schema.name, recordId, attributeName);
10608
+ if (toUpsert.length > 0) {
10609
+ const inputs = toUpsert.map((item) => ({
10610
+ fromObject: schema.name,
10611
+ fromId: recordId,
10612
+ fromAttribute: attributeName,
10613
+ toId: item.id,
10614
+ properties: item.props ?? {},
10615
+ updatedBy: this.userId ?? void 0,
10616
+ createdBy: this.userId ?? void 0
10617
+ }));
10618
+ await adapter.relationAttributes.upsertBatch(inputs);
10619
+ }
10620
+ }
10621
+ }
10622
+ /**
10623
+ * Validate relation properties against PropertySchema.
10624
+ *
10625
+ * Uses Zod for runtime validation based on PropertyDefinition types.
10626
+ *
10627
+ * @param propertySchema - Schema defining allowed properties
10628
+ * @param properties - Properties to validate
10629
+ * @throws {z.ZodError} if validation fails
10630
+ */
10631
+ validateProperties(propertySchema, properties) {
10632
+ const schema = this.buildZodSchema(propertySchema);
10633
+ schema.parse(properties);
10634
+ }
10635
+ // ============================================================================
10636
+ // PRIVATE HELPERS
10637
+ // ============================================================================
10638
+ /**
10639
+ * Normalize relation value to unified internal format.
10640
+ *
10641
+ * Converts:
10642
+ * - string[] → Array<{ id, props?: undefined }>
10643
+ * - string → [{ id, props?: undefined }]
10644
+ * - null → []
10645
+ * - Array<{ id, props }> → Array<{ id, props }> (passthrough)
10646
+ * - { id, props } → [{ id, props }] (single to array)
10647
+ *
10648
+ * @param value - Relation value in hybrid format
10649
+ * @returns Normalized array of relation items
10650
+ * @private
10651
+ */
10652
+ normalizeRelationValue(value) {
10653
+ if (value === null || value === void 0) {
10654
+ return [];
10655
+ }
10656
+ if (typeof value === "string") {
10657
+ return [{ id: value }];
10658
+ }
10659
+ if (!Array.isArray(value) && typeof value === "object" && "id" in value) {
10660
+ return [value];
10661
+ }
10662
+ if (Array.isArray(value)) {
10663
+ return value.map((item) => {
10664
+ if (typeof item === "string") {
10665
+ return { id: item };
10666
+ }
10667
+ return item;
10668
+ });
10669
+ }
10670
+ return [];
10671
+ }
10672
+ /**
10673
+ * Build Zod schema from PropertySchema definition.
10674
+ *
10675
+ * Dynamically generates validation schema based on PropertyDefinition types.
10676
+ *
10677
+ * @param propertySchema - PropertySchema with definitions
10678
+ * @returns Zod schema for validation
10679
+ * @private
10680
+ */
10681
+ buildZodSchema(propertySchema) {
10682
+ const shape = {};
10683
+ for (const def of propertySchema.definitions) {
10684
+ let fieldSchema = this.buildFieldSchema(def);
10685
+ if (!def.required) {
10686
+ fieldSchema = fieldSchema.optional();
10687
+ }
10688
+ shape[def.name] = fieldSchema;
10689
+ }
10690
+ return z5.object(shape);
10691
+ }
10692
+ /**
10693
+ * Build Zod schema for a single property field.
10694
+ *
10695
+ * @param def - PropertyDefinition
10696
+ * @returns Zod schema for the field
10697
+ * @private
10698
+ */
10699
+ buildFieldSchema(def) {
10700
+ switch (def.type) {
10701
+ case "text":
10702
+ case "textarea": {
10703
+ let schema = z5.string();
10704
+ if (def.minLength !== void 0) {
10705
+ schema = schema.min(def.minLength);
10706
+ }
10707
+ if (def.maxLength !== void 0) {
10708
+ schema = schema.max(def.maxLength);
10709
+ }
10710
+ if (def.type === "text" && def.pattern) {
10711
+ schema = schema.regex(new RegExp(def.pattern));
10712
+ }
10713
+ return schema;
10714
+ }
10715
+ case "number": {
10716
+ let schema = z5.number();
10717
+ if (def.min !== void 0) {
10718
+ schema = schema.min(def.min);
10719
+ }
10720
+ if (def.max !== void 0) {
10721
+ schema = schema.max(def.max);
10722
+ }
10723
+ if (def.integer) {
10724
+ schema = schema.int();
10725
+ }
10726
+ return schema;
10727
+ }
10728
+ case "checkbox": {
10729
+ return z5.boolean();
10730
+ }
10731
+ case "date": {
10732
+ const schema = z5.string().datetime();
10733
+ return schema;
10734
+ }
10735
+ case "phone": {
10736
+ return z5.string();
10737
+ }
10738
+ case "currency": {
10739
+ let schema = z5.number();
10740
+ if (def.min !== void 0) {
10741
+ schema = schema.min(def.min);
10742
+ }
10743
+ if (def.max !== void 0) {
10744
+ schema = schema.max(def.max);
10745
+ }
10746
+ return schema;
10747
+ }
10748
+ case "status":
10749
+ case "select": {
10750
+ const validValues = def.options.map((opt) => opt.value);
10751
+ return z5.enum(validValues);
10752
+ }
10753
+ case "multiselect": {
10754
+ const validValues = def.options.map((opt) => opt.value);
10755
+ let schema = z5.array(z5.enum(validValues));
10756
+ if (def.maxSelections !== void 0) {
10757
+ schema = schema.max(def.maxSelections);
10758
+ }
10759
+ return schema;
10760
+ }
10761
+ case "rating": {
10762
+ let schema = z5.number().int();
10763
+ if (def.max !== void 0) {
10764
+ schema = schema.max(def.max);
10765
+ }
10766
+ return schema.min(0);
10767
+ }
10768
+ case "location": {
10769
+ return z5.object({
10770
+ address: z5.string().optional(),
10771
+ lat: z5.number().optional(),
10772
+ lng: z5.number().optional()
10773
+ });
10774
+ }
10775
+ default: {
10776
+ return z5.unknown();
10777
+ }
10778
+ }
10779
+ }
10780
+ };
10781
+
9971
10782
  // src/runtime/services/record/relation.service.ts
9972
10783
  var RelationService = class extends BaseService {
9973
10784
  constructor(adapter, nativeRegistry, options) {
@@ -10796,6 +11607,7 @@ var RecordService = class extends BaseService {
10796
11607
  queryService: this.queryService,
10797
11608
  recordResolver: this.recordResolver
10798
11609
  });
11610
+ this.relationPropertiesService = new RelationPropertiesService(adapter);
10799
11611
  this.rollupService = new RollupService(adapter, {
10800
11612
  recordResolver: this.recordResolver
10801
11613
  });
@@ -10840,29 +11652,45 @@ var RecordService = class extends BaseService {
10840
11652
  if (!options?.skipHooks) {
10841
11653
  await this.hookRegistry.execute("beforeCreate", schema.name, hookCtx);
10842
11654
  }
11655
+ const normalizedData = this.relationPropertiesService.normalizeRelationValuesForStorage(
11656
+ schema,
11657
+ dataWithDefaults
11658
+ );
10843
11659
  if (options?.validate !== false) {
10844
11660
  if (options?.allowDraft) {
10845
- validateDraftOrThrow(schema, dataWithDefaults);
11661
+ validateDraftOrThrow(schema, normalizedData);
10846
11662
  } else {
10847
- validateObjectOrThrow(schema, dataWithDefaults);
11663
+ validateObjectOrThrow(schema, normalizedData);
10848
11664
  }
10849
11665
  if (!options?.skipRelationValidation) {
10850
- await this.relationService.validateRelationsOrThrow(schema, dataWithDefaults);
11666
+ await this.relationService.validateRelationsOrThrow(schema, normalizedData);
10851
11667
  }
10852
11668
  if (!options?.skipUserValidation) {
10853
- await this.userService.validateUsersOrThrow(schema, dataWithDefaults);
11669
+ await this.userService.validateUsersOrThrow(schema, normalizedData);
10854
11670
  }
10855
11671
  }
10856
- const completionStatus = computeRecordStatus(schema, dataWithDefaults);
10857
- const label = await computeLabel(schema, dataWithDefaults, this.labelResolver);
11672
+ const completionStatus = computeRecordStatus(schema, normalizedData);
11673
+ const label = await computeLabel(schema, normalizedData, this.labelResolver);
10858
11674
  const record = await this.adapter.objectRecords.create({
10859
11675
  objectId,
10860
- data: dataWithDefaults,
11676
+ data: normalizedData,
10861
11677
  label,
10862
11678
  completionStatus,
10863
11679
  metadata: options?.metadata,
10864
11680
  createdBy: this.userId
10865
11681
  });
11682
+ for (const [attrName, value] of Object.entries(dataWithDefaults)) {
11683
+ const attr = schema.attributes.find((a) => a.name === attrName);
11684
+ if (attr?.type === "relation" && attr.properties) {
11685
+ await this.relationPropertiesService.syncRelationProperties(
11686
+ schema,
11687
+ record.id,
11688
+ attrName,
11689
+ value,
11690
+ this.adapter
11691
+ );
11692
+ }
11693
+ }
10866
11694
  if (!options?.skipHooks) {
10867
11695
  const afterCtx = {
10868
11696
  ...hookCtx,
@@ -10993,30 +11821,29 @@ var RecordService = class extends BaseService {
10993
11821
  hookModifiedValues[key] = hookCtx.newValues[key];
10994
11822
  }
10995
11823
  }
11824
+ const dataToUpdate = { ...data, ...hookModifiedValues };
11825
+ const normalizedUpdate = this.relationPropertiesService.normalizeRelationValuesForStorage(
11826
+ schema,
11827
+ dataToUpdate
11828
+ );
11829
+ const normalizedMergedData = { ...existing.values, ...normalizedUpdate };
10996
11830
  if (options?.validate !== false) {
10997
11831
  if (options?.partial) {
10998
- validateDraftOrThrow(schema, mergedData);
11832
+ validateDraftOrThrow(schema, normalizedMergedData);
10999
11833
  } else {
11000
- validateObjectOrThrow(schema, mergedData);
11834
+ validateObjectOrThrow(schema, normalizedMergedData);
11001
11835
  }
11002
11836
  if (!options?.skipRelationValidation) {
11003
- await this.relationService.validateRelationsOrThrow(schema, {
11004
- ...data,
11005
- ...hookModifiedValues
11006
- });
11837
+ await this.relationService.validateRelationsOrThrow(schema, normalizedUpdate);
11007
11838
  }
11008
11839
  if (!options?.skipUserValidation) {
11009
- await this.userService.validateUsersOrThrow(schema, {
11010
- ...data,
11011
- ...hookModifiedValues
11012
- });
11840
+ await this.userService.validateUsersOrThrow(schema, normalizedUpdate);
11013
11841
  }
11014
11842
  }
11015
- const completionStatus = computeRecordStatus(schema, mergedData);
11016
- const label = await computeLabel(schema, mergedData, this.labelResolver);
11843
+ const completionStatus = computeRecordStatus(schema, normalizedMergedData);
11844
+ const label = await computeLabel(schema, normalizedMergedData, this.labelResolver);
11017
11845
  const updatePayload = {
11018
- ...data,
11019
- ...hookModifiedValues,
11846
+ ...normalizedUpdate,
11020
11847
  __completionStatus: completionStatus,
11021
11848
  __label: label,
11022
11849
  __lastUpdatedBy: this.userId,
@@ -11032,6 +11859,18 @@ var RecordService = class extends BaseService {
11032
11859
  }
11033
11860
  const updated = await this.adapter.objectRecords.update(recordId, updatePayload);
11034
11861
  await this.invalidateRecordCaches(recordId, existing.objectId);
11862
+ for (const [attrName, value] of Object.entries(dataToUpdate)) {
11863
+ const attr = schema.attributes.find((a) => a.name === attrName);
11864
+ if (attr?.type === "relation" && attr.properties) {
11865
+ await this.relationPropertiesService.syncRelationProperties(
11866
+ schema,
11867
+ recordId,
11868
+ attrName,
11869
+ value,
11870
+ this.adapter
11871
+ );
11872
+ }
11873
+ }
11035
11874
  if (!options?.skipHooks) {
11036
11875
  const afterCtx = {
11037
11876
  ...hookCtx,
@@ -15707,23 +16546,6 @@ var GlobalSearchService = class extends BaseService {
15707
16546
  * @param query - Search query string
15708
16547
  * @param options - Search options (pagination, object filters)
15709
16548
  * @returns Matching records with object metadata and total count
15710
- *
15711
- * @example
15712
- * ```typescript
15713
- * // Basic search
15714
- * const { results, total } = await service.search("nike air");
15715
- *
15716
- * // With pagination
15717
- * const { results, total } = await service.search("nike", {
15718
- * limit: 10,
15719
- * offset: 20
15720
- * });
15721
- *
15722
- * // Filter by object types
15723
- * const { results, total } = await service.search("nike", {
15724
- * objectNames: ["products", "orders"]
15725
- * });
15726
- * ```
15727
16549
  */
15728
16550
  async search(query, options) {
15729
16551
  if (!query || query.trim().length === 0) {
@@ -15731,58 +16553,36 @@ var GlobalSearchService = class extends BaseService {
15731
16553
  }
15732
16554
  return this.cachedList(
15733
16555
  "globalSearch",
15734
- "global",
16556
+ "search",
15735
16557
  { query: query.trim(), ...options },
15736
- () => this.executeSearch(query.trim(), options)
16558
+ () => this.adapter.objectRecords.globalSearch(query.trim(), {
16559
+ limit: options?.limit ?? 20,
16560
+ offset: options?.offset ?? 0,
16561
+ objectNames: options?.objectNames
16562
+ })
15737
16563
  );
15738
16564
  }
15739
16565
  /**
15740
- * Internal search execution (extracted for caching)
15741
- */
15742
- async executeSearch(query, options) {
15743
- return await this.adapter.objectRecords.globalSearch(query, {
15744
- limit: options?.limit ?? 20,
15745
- offset: options?.offset ?? 0,
15746
- objectNames: options?.objectNames,
15747
- includeObjectInfo: options?.includeObjectInfo ?? true
15748
- });
15749
- }
15750
- /**
15751
- * Search and group results by object type
16566
+ * Search and group results by object type.
16567
+ * Delegates grouping to the database for accurate per-group counts.
15752
16568
  *
15753
16569
  * @param query - Search query string
15754
- * @param options - Search options
15755
- * @returns Results grouped by object name
16570
+ * @param options - Search options (object filters, limit per group)
16571
+ * @returns Results grouped by object name with per-group totals
15756
16572
  */
15757
16573
  async searchGrouped(query, options) {
15758
- const limitPerGroup = options?.limitPerGroup ?? 5;
15759
- const estimatedGroupCount = 10;
15760
- const fetchLimit = Math.min(limitPerGroup * estimatedGroupCount, 100);
15761
- const { results, total } = await this.search(query, {
15762
- ...options,
15763
- limit: fetchLimit,
15764
- offset: 0
15765
- });
15766
- const groupMap = /* @__PURE__ */ new Map();
15767
- for (const result of results) {
15768
- const existing = groupMap.get(result.objectName);
15769
- if (existing) {
15770
- existing.results.push(result);
15771
- } else {
15772
- groupMap.set(result.objectName, {
15773
- objectName: result.objectName,
15774
- objectLabel: result.objectLabel,
15775
- results: [result]
15776
- });
15777
- }
16574
+ if (!query || query.trim().length === 0) {
16575
+ return { groups: [], total: 0 };
15778
16576
  }
15779
- const groups = Array.from(groupMap.values()).map((g) => ({
15780
- ...g,
15781
- results: g.results.slice(0, limitPerGroup),
15782
- count: g.results.length
15783
- }));
15784
- groups.sort((a, b) => b.count - a.count);
15785
- return { groups, total };
16577
+ return this.cachedList(
16578
+ "globalSearch",
16579
+ "grouped",
16580
+ { query: query.trim(), ...options },
16581
+ () => this.adapter.objectRecords.globalSearchGrouped(query.trim(), {
16582
+ objectNames: options?.objectNames,
16583
+ limitPerGroup: options?.limitPerGroup ?? 5
16584
+ })
16585
+ );
15786
16586
  }
15787
16587
  };
15788
16588
 
@@ -17132,6 +17932,7 @@ export {
17132
17932
  SYSTEM_FIELD_NAMES,
17133
17933
  RESERVED_ATTRIBUTE_NAMES,
17134
17934
  PolicyViolationError,
17935
+ FORBIDDEN_PROPERTY_TYPES,
17135
17936
  SYSTEM_ATTRIBUTES,
17136
17937
  getSystemAttributeList,
17137
17938
  isSystemAttribute,
@@ -17230,6 +18031,22 @@ export {
17230
18031
  RoleNotFoundError,
17231
18032
  isForbiddenError,
17232
18033
  ConcurrentModificationError,
18034
+ PropertySchemaBuilder,
18035
+ PropertyTypeBuilder,
18036
+ BasePropertyBuilder,
18037
+ TextPropertyBuilder,
18038
+ TextareaPropertyBuilder,
18039
+ NumberPropertyBuilder,
18040
+ CheckboxPropertyBuilder,
18041
+ DatePropertyBuilder,
18042
+ PhonePropertyBuilder,
18043
+ CurrencyPropertyBuilder,
18044
+ StatusPropertyBuilder,
18045
+ SelectPropertyBuilder,
18046
+ MultiselectPropertyBuilder,
18047
+ RatingPropertyBuilder,
18048
+ LocationPropertyBuilder,
18049
+ validatePropertyType,
17233
18050
  text,
17234
18051
  textarea,
17235
18052
  richtext,
@@ -17399,6 +18216,7 @@ export {
17399
18216
  recalculateParentRollups,
17400
18217
  RecordQueryService,
17401
18218
  RecordResolverService,
18219
+ RelationPropertiesService,
17402
18220
  RelationService,
17403
18221
  RollupService,
17404
18222
  RecordService,