@stndrds/schema 0.1.0-alpha.63 → 0.1.0-alpha.64

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.
@@ -5,6 +5,7 @@ import {
5
5
  } from "./chunk-V2RPPE2Y.mjs";
6
6
  import {
7
7
  computeRecordStatus,
8
+ createFormAttributeValidator,
8
9
  formatZodErrors,
9
10
  parseAttributeConfig,
10
11
  validateDraftOrThrow,
@@ -4133,7 +4134,6 @@ function createMockRelationAttributesRepository(stores) {
4133
4134
  }
4134
4135
  const results = [];
4135
4136
  for (const item of items) {
4136
- const _key = `${context.tenantId}:${item.fromObject}:${item.fromId}:${item.fromAttribute}:${item.toId}`;
4137
4137
  const existing = Array.from(stores.relationAttributes.values()).find(
4138
4138
  (row) => row.tenantId === context.tenantId && row.fromObject === item.fromObject && row.fromId === item.fromId && row.fromAttribute === item.fromAttribute && row.toId === item.toId
4139
4139
  );
@@ -4180,6 +4180,36 @@ function createMockRelationAttributesRepository(stores) {
4180
4180
  (row) => row.tenantId === context.tenantId && row.toId === toId
4181
4181
  );
4182
4182
  },
4183
+ async findBySourceBatch(fromObject, fromIds, fromAttribute) {
4184
+ const context = getContext2();
4185
+ if (!context) {
4186
+ throw new Error("Context required for relationAttributes operations");
4187
+ }
4188
+ return Array.from(stores.relationAttributes.values()).filter(
4189
+ (row) => row.tenantId === context.tenantId && row.fromObject === fromObject && fromIds.includes(row.fromId) && row.fromAttribute === fromAttribute
4190
+ );
4191
+ },
4192
+ async findByTargetBatch(toIds) {
4193
+ const context = getContext2();
4194
+ if (!context) {
4195
+ throw new Error("Context required for relationAttributes operations");
4196
+ }
4197
+ return Array.from(stores.relationAttributes.values()).filter(
4198
+ (row) => row.tenantId === context.tenantId && toIds.includes(row.toId)
4199
+ );
4200
+ },
4201
+ async deleteBySourceAndTarget(fromObject, fromId, fromAttribute, toId) {
4202
+ const context = getContext2();
4203
+ if (!context) {
4204
+ throw new Error("Context required for relationAttributes operations");
4205
+ }
4206
+ const toDelete = Array.from(stores.relationAttributes.entries()).filter(
4207
+ ([_id, row]) => row.tenantId === context.tenantId && row.fromObject === fromObject && row.fromId === fromId && row.fromAttribute === fromAttribute && row.toId === toId
4208
+ );
4209
+ for (const [id] of toDelete) {
4210
+ stores.relationAttributes.delete(id);
4211
+ }
4212
+ },
4183
4213
  async deleteBySource(fromObject, fromId, fromAttribute) {
4184
4214
  const context = getContext2();
4185
4215
  if (!context) {
@@ -5244,92 +5274,6 @@ var PolicyRegistry = class {
5244
5274
  };
5245
5275
  var defaultPolicyRegistry = new PolicyRegistry();
5246
5276
 
5247
- // src/runtime/policies/notes.policy.ts
5248
- var notesPolicy = {
5249
- objectName: "notes",
5250
- /**
5251
- * Inject visibility filter for list/search operations.
5252
- *
5253
- * Creates: (visibility = 'shared') OR (createdBy = userId)
5254
- *
5255
- * This filters at the SQL level for better performance.
5256
- * canAccessRecord provides defense-in-depth as a secondary check.
5257
- *
5258
- * Note: When user filters exist, we preserve linkedTo filter (common for notes)
5259
- * but the visibility OR filter takes precedence. Full filter merging would require
5260
- * AdvancedFilterState support in the adapter.
5261
- */
5262
- applyListFilter(ctx, options) {
5263
- const visibilityRules = [
5264
- { attribute: "visibility", operator: "is", value: "shared" },
5265
- { attribute: "createdBy", operator: "is", value: ctx.userId }
5266
- ];
5267
- if (!options?.filters || options.filters.rules.length === 0) {
5268
- return {
5269
- ...options,
5270
- filters: { combinator: "or", rules: visibilityRules }
5271
- };
5272
- }
5273
- const linkedToFilter = options.filters.rules.find((r) => r.attribute === "linkedTo");
5274
- if (linkedToFilter) {
5275
- return {
5276
- ...options,
5277
- filters: {
5278
- combinator: "and",
5279
- rules: [linkedToFilter]
5280
- }
5281
- };
5282
- }
5283
- return {
5284
- ...options,
5285
- filters: { combinator: "or", rules: visibilityRules }
5286
- };
5287
- },
5288
- /**
5289
- * Check if user can access a specific note.
5290
- *
5291
- * Rules:
5292
- * - Shared notes: accessible by anyone in the tenant
5293
- * - Private notes: only accessible by the author (createdBy)
5294
- */
5295
- canAccessRecord(ctx, record) {
5296
- const visibility = record.values.visibility;
5297
- if (visibility === "shared") {
5298
- return true;
5299
- }
5300
- if (visibility === "private") {
5301
- return record.createdBy === ctx.userId;
5302
- }
5303
- return false;
5304
- },
5305
- /**
5306
- * Check if user can modify a note.
5307
- *
5308
- * Rules:
5309
- * - Private notes: only the author can modify
5310
- * - Shared notes: anyone can modify
5311
- */
5312
- canModifyRecord(ctx, record) {
5313
- const visibility = record.values.visibility;
5314
- if (visibility === "private") {
5315
- return record.createdBy === ctx.userId;
5316
- }
5317
- return true;
5318
- },
5319
- /**
5320
- * Check if user can delete a note.
5321
- *
5322
- * Rules:
5323
- * - Only the author (createdBy) can delete a note, regardless of visibility
5324
- */
5325
- canDeleteRecord(ctx, record) {
5326
- return record.createdBy === ctx.userId;
5327
- }
5328
- };
5329
-
5330
- // src/runtime/policies/index.ts
5331
- defaultPolicyRegistry.register(notesPolicy);
5332
-
5333
5277
  // src/runtime/services/base.service.ts
5334
5278
  var BaseService = class {
5335
5279
  constructor(adapter) {
@@ -5543,6 +5487,23 @@ var RELATION_TARGET_ANY = "*";
5543
5487
  function isUniversalRelation(attr) {
5544
5488
  return attr.targets.length === 1 && attr.targets[0].object === RELATION_TARGET_ANY;
5545
5489
  }
5490
+ function isBilateralRelation(attr) {
5491
+ return attr.bilateral !== void 0;
5492
+ }
5493
+ function inferInverseCardinality(cardinality) {
5494
+ return cardinality === "one" ? "many" : "many";
5495
+ }
5496
+
5497
+ // src/types/relation-properties.ts
5498
+ var FORBIDDEN_PROPERTY_TYPES = [
5499
+ "formula",
5500
+ "rollup",
5501
+ "relation",
5502
+ "file",
5503
+ "user",
5504
+ "document",
5505
+ "richtext"
5506
+ ];
5546
5507
 
5547
5508
  // src/builders/attribute-validators.ts
5548
5509
  import { ICONS } from "@stndrds/constants";
@@ -5649,292 +5610,6 @@ function validateOptions(options, attributeName) {
5649
5610
  }
5650
5611
  }
5651
5612
 
5652
- // src/types/relation-properties.ts
5653
- var FORBIDDEN_PROPERTY_TYPES = [
5654
- "formula",
5655
- "rollup",
5656
- "relation",
5657
- "file",
5658
- "user",
5659
- "document",
5660
- "richtext"
5661
- ];
5662
-
5663
- // src/builders/property-schema-builder.ts
5664
- var PropertySchemaBuilder = class {
5665
- constructor() {
5666
- this.definitions = [];
5667
- }
5668
- /**
5669
- * Add a property to the schema
5670
- * The type of property is automatically detected based on the builder methods used
5671
- */
5672
- add(name, configure) {
5673
- const builder = new PropertyTypeBuilder(name);
5674
- const configured = configure(builder);
5675
- const definition = configured.build();
5676
- this.definitions.push(definition);
5677
- return this;
5678
- }
5679
- /**
5680
- * Build the final PropertySchema
5681
- */
5682
- build() {
5683
- return {
5684
- definitions: this.definitions
5685
- };
5686
- }
5687
- };
5688
- var PropertyTypeBuilder = class {
5689
- constructor(name) {
5690
- this.name = name;
5691
- }
5692
- // Explicit type constructors
5693
- text() {
5694
- return new TextPropertyBuilder(this.name);
5695
- }
5696
- textarea() {
5697
- return new TextareaPropertyBuilder(this.name);
5698
- }
5699
- number() {
5700
- return new NumberPropertyBuilder(this.name);
5701
- }
5702
- checkbox() {
5703
- return new CheckboxPropertyBuilder(this.name);
5704
- }
5705
- date() {
5706
- return new DatePropertyBuilder(this.name);
5707
- }
5708
- phone() {
5709
- return new PhonePropertyBuilder(this.name);
5710
- }
5711
- currency() {
5712
- return new CurrencyPropertyBuilder(this.name);
5713
- }
5714
- status() {
5715
- return new StatusPropertyBuilder(this.name);
5716
- }
5717
- select() {
5718
- return new SelectPropertyBuilder(this.name);
5719
- }
5720
- multiselect() {
5721
- return new MultiselectPropertyBuilder(this.name);
5722
- }
5723
- rating() {
5724
- return new RatingPropertyBuilder(this.name);
5725
- }
5726
- location() {
5727
- return new LocationPropertyBuilder(this.name);
5728
- }
5729
- };
5730
- var BasePropertyBuilder = class {
5731
- constructor(name) {
5732
- this.definition = { name };
5733
- }
5734
- /**
5735
- * Set the label
5736
- */
5737
- label(label) {
5738
- this.definition.label = label;
5739
- return this;
5740
- }
5741
- /**
5742
- * Mark as required
5743
- */
5744
- required() {
5745
- this.definition.required = true;
5746
- return this;
5747
- }
5748
- /**
5749
- * Set description
5750
- */
5751
- description(description) {
5752
- this.definition.description = description;
5753
- return this;
5754
- }
5755
- /**
5756
- * Build the final definition
5757
- */
5758
- build() {
5759
- return this.definition;
5760
- }
5761
- };
5762
- var TextPropertyBuilder = class extends BasePropertyBuilder {
5763
- constructor(name) {
5764
- super(name);
5765
- this.definition.type = "text";
5766
- }
5767
- minLength(value) {
5768
- this.definition.minLength = value;
5769
- return this;
5770
- }
5771
- maxLength(value) {
5772
- this.definition.maxLength = value;
5773
- return this;
5774
- }
5775
- pattern(pattern) {
5776
- this.definition.pattern = pattern;
5777
- return this;
5778
- }
5779
- placeholder(value) {
5780
- this.definition.placeholder = value;
5781
- return this;
5782
- }
5783
- };
5784
- var TextareaPropertyBuilder = class extends BasePropertyBuilder {
5785
- constructor(name) {
5786
- super(name);
5787
- this.definition.type = "textarea";
5788
- }
5789
- minLength(value) {
5790
- this.definition.minLength = value;
5791
- return this;
5792
- }
5793
- maxLength(value) {
5794
- this.definition.maxLength = value;
5795
- return this;
5796
- }
5797
- placeholder(value) {
5798
- this.definition.placeholder = value;
5799
- return this;
5800
- }
5801
- };
5802
- var NumberPropertyBuilder = class extends BasePropertyBuilder {
5803
- constructor(name) {
5804
- super(name);
5805
- this.definition.type = "number";
5806
- }
5807
- min(value) {
5808
- this.definition.min = value;
5809
- return this;
5810
- }
5811
- max(value) {
5812
- this.definition.max = value;
5813
- return this;
5814
- }
5815
- decimal(places) {
5816
- this.definition.decimal = places;
5817
- return this;
5818
- }
5819
- integer() {
5820
- this.definition.integer = true;
5821
- return this;
5822
- }
5823
- placeholder(value) {
5824
- this.definition.placeholder = value;
5825
- return this;
5826
- }
5827
- };
5828
- var CheckboxPropertyBuilder = class extends BasePropertyBuilder {
5829
- constructor(name) {
5830
- super(name);
5831
- this.definition.type = "checkbox";
5832
- }
5833
- };
5834
- var DatePropertyBuilder = class extends BasePropertyBuilder {
5835
- constructor(name) {
5836
- super(name);
5837
- this.definition.type = "date";
5838
- }
5839
- includeTime() {
5840
- this.definition.includeTime = true;
5841
- return this;
5842
- }
5843
- min(date2) {
5844
- this.definition.min = date2;
5845
- return this;
5846
- }
5847
- max(date2) {
5848
- this.definition.max = date2;
5849
- return this;
5850
- }
5851
- };
5852
- var PhonePropertyBuilder = class extends BasePropertyBuilder {
5853
- constructor(name) {
5854
- super(name);
5855
- this.definition.type = "phone";
5856
- }
5857
- };
5858
- var CurrencyPropertyBuilder = class extends BasePropertyBuilder {
5859
- constructor(name) {
5860
- super(name);
5861
- this.definition.type = "currency";
5862
- }
5863
- currency(code) {
5864
- this.definition.currency = code;
5865
- return this;
5866
- }
5867
- min(value) {
5868
- this.definition.min = value;
5869
- return this;
5870
- }
5871
- max(value) {
5872
- this.definition.max = value;
5873
- return this;
5874
- }
5875
- };
5876
- var StatusPropertyBuilder = class extends BasePropertyBuilder {
5877
- constructor(name) {
5878
- super(name);
5879
- this.definition.type = "status";
5880
- }
5881
- options(options) {
5882
- this.definition.options = options;
5883
- return this;
5884
- }
5885
- };
5886
- var SelectPropertyBuilder = class extends BasePropertyBuilder {
5887
- constructor(name) {
5888
- super(name);
5889
- this.definition.type = "select";
5890
- }
5891
- options(options) {
5892
- this.definition.options = options;
5893
- return this;
5894
- }
5895
- };
5896
- var MultiselectPropertyBuilder = class extends BasePropertyBuilder {
5897
- constructor(name) {
5898
- super(name);
5899
- this.definition.type = "multiselect";
5900
- }
5901
- options(options) {
5902
- this.definition.options = options;
5903
- return this;
5904
- }
5905
- maxSelections(value) {
5906
- this.definition.maxSelections = value;
5907
- return this;
5908
- }
5909
- };
5910
- var RatingPropertyBuilder = class extends BasePropertyBuilder {
5911
- constructor(name) {
5912
- super(name);
5913
- this.definition.type = "rating";
5914
- }
5915
- max(value) {
5916
- this.definition.max = value;
5917
- return this;
5918
- }
5919
- icon(icon) {
5920
- this.definition.icon = icon;
5921
- return this;
5922
- }
5923
- };
5924
- var LocationPropertyBuilder = class extends BasePropertyBuilder {
5925
- constructor(name) {
5926
- super(name);
5927
- this.definition.type = "location";
5928
- }
5929
- };
5930
- function validatePropertyType(type) {
5931
- if (FORBIDDEN_PROPERTY_TYPES.includes(type)) {
5932
- throw new Error(
5933
- `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.`
5934
- );
5935
- }
5936
- }
5937
-
5938
5613
  // src/builders/attribute-builders.ts
5939
5614
  var BaseAttributeBuilder = class {
5940
5615
  constructor(type, name, label) {
@@ -6443,12 +6118,7 @@ var UserAttributeBuilder = class extends BaseAttributeBuilder {
6443
6118
  function user(config) {
6444
6119
  return new UserAttributeBuilder(config.name, config.label);
6445
6120
  }
6446
- var SingleRelationAttributeBuilder = class extends BaseAttributeBuilder {
6447
- constructor(name, label) {
6448
- super("relation", name, label);
6449
- this.attr.cardinality = "one";
6450
- this.attr.targets = [];
6451
- }
6121
+ var BaseRelationAttributeBuilder = class extends BaseAttributeBuilder {
6452
6122
  /**
6453
6123
  * Add a target object that can be linked
6454
6124
  * @param objectName - Name of the object (e.g., "companies")
@@ -6465,68 +6135,95 @@ var SingleRelationAttributeBuilder = class extends BaseAttributeBuilder {
6465
6135
  /**
6466
6136
  * Allow linking to any object in the tenant (universal polymorphic)
6467
6137
  * Creates a relation with `targets: [{ object: "*" }]`
6468
- *
6469
- * @example
6470
- * ```typescript
6471
- * relation({ name: "linkedTo", label: "Linked To" })
6472
- * .toAny()
6473
- * .hidden()
6474
- * ```
6475
6138
  */
6476
6139
  toAny() {
6477
6140
  this.attr.targets = [{ object: RELATION_TARGET_ANY }];
6478
6141
  return this;
6479
6142
  }
6480
6143
  /**
6481
- * Convert to a multi-relation (cardinality: "many")
6482
- */
6483
- many() {
6484
- const multiBuilder = new MultiRelationAttributeBuilder(
6485
- this.attr.name,
6486
- this.attr.label ?? "",
6487
- {
6488
- targets: this.attr.targets,
6489
- isRequired: this.attr.required
6490
- }
6491
- );
6492
- return multiBuilder;
6493
- }
6494
- /**
6495
- * Add properties to qualify the relation
6496
- * Must be called AFTER .to() to ensure targets are defined
6144
+ * Add properties to qualify the relation using existing attribute builders.
6145
+ * Must be called AFTER .to() to ensure targets are defined.
6497
6146
  *
6498
6147
  * @example
6499
6148
  * ```typescript
6500
- * relation({ name: "mainCompany", label: "Main Company" })
6501
- * .to("companies")
6502
- * .qualifyWith(props => props
6503
- * .add("role", select => select.options([...]).required())
6504
- * .add("shares", number => number.min(0))
6149
+ * relation({ name: "companies", label: "Companies" })
6150
+ * .to("companies").many()
6151
+ * .qualifyWith(
6152
+ * select({ name: "role", label: "Role" }).options([...]).required(),
6153
+ * number({ name: "shares", label: "Shares" }).min(0),
6505
6154
  * )
6506
6155
  * ```
6507
6156
  */
6508
- qualifyWith(configure) {
6157
+ qualifyWith(...builders) {
6509
6158
  const targets = this.attr.targets;
6510
6159
  if (!targets || targets.length === 0) {
6511
6160
  throw new Error(
6512
- '.qualifyWith() must be called AFTER .to(). Example: relation({ name: "mainCompany" }).to("companies").qualifyWith(...)'
6161
+ '.qualifyWith() must be called AFTER .to(). Example: relation({ name: "companies" }).to("companies").qualifyWith(...)'
6513
6162
  );
6514
6163
  }
6515
- const builder = new PropertySchemaBuilder();
6516
- const schema = configure(builder).build();
6517
- this.attr.properties = schema;
6518
- return this;
6519
- }
6520
- required() {
6521
- this.setRequired(true);
6522
- return this;
6523
- }
6524
- optional() {
6525
- this.setRequired(false);
6164
+ const definitions = builders.map((builder) => {
6165
+ const attr = builder.build();
6166
+ if (FORBIDDEN_PROPERTY_TYPES.includes(attr.type)) {
6167
+ throw new Error(`Property type "${attr.type}" is not supported in .qualifyWith()`);
6168
+ }
6169
+ return attr;
6170
+ });
6171
+ this.attr.properties = { definitions };
6172
+ return this;
6173
+ }
6174
+ /**
6175
+ * Enable bilateral synchronization for this relation.
6176
+ * Must be called AFTER .to() to ensure targets are defined.
6177
+ */
6178
+ bilateral(config) {
6179
+ const targets = this.attr.targets;
6180
+ if (!targets || targets.length === 0) {
6181
+ throw new Error(
6182
+ '.bilateral() must be called AFTER .to(). Example: relation({ name: "company" }).to("companies").bilateral({ ... })'
6183
+ );
6184
+ }
6185
+ if (targets.length > 1) {
6186
+ throw new Error(
6187
+ "Bilateral relations are not supported for polymorphic relations (multiple .to() targets)."
6188
+ );
6189
+ }
6190
+ if (isUniversalRelation(this.attr)) {
6191
+ throw new Error("Bilateral relations are not supported for universal relations (.toAny()).");
6192
+ }
6193
+ this.attr.bilateral = config;
6194
+ return this;
6195
+ }
6196
+ };
6197
+ var SingleRelationAttributeBuilder = class extends BaseRelationAttributeBuilder {
6198
+ constructor(name, label) {
6199
+ super("relation", name, label);
6200
+ this.attr.cardinality = "one";
6201
+ this.attr.targets = [];
6202
+ }
6203
+ /**
6204
+ * Convert to a multi-relation (cardinality: "many")
6205
+ */
6206
+ many() {
6207
+ const multiBuilder = new MultiRelationAttributeBuilder(
6208
+ this.attr.name,
6209
+ this.attr.label ?? "",
6210
+ {
6211
+ targets: this.attr.targets,
6212
+ isRequired: this.attr.required
6213
+ }
6214
+ );
6215
+ return multiBuilder;
6216
+ }
6217
+ required() {
6218
+ this.setRequired(true);
6219
+ return this;
6220
+ }
6221
+ optional() {
6222
+ this.setRequired(false);
6526
6223
  return this;
6527
6224
  }
6528
6225
  };
6529
- var MultiRelationAttributeBuilder = class extends BaseAttributeBuilder {
6226
+ var MultiRelationAttributeBuilder = class extends BaseRelationAttributeBuilder {
6530
6227
  constructor(name, label, initOptions) {
6531
6228
  super("relation", name, label);
6532
6229
  this.attr.cardinality = "many";
@@ -6536,34 +6233,6 @@ var MultiRelationAttributeBuilder = class extends BaseAttributeBuilder {
6536
6233
  this.setRequired(true);
6537
6234
  }
6538
6235
  }
6539
- /**
6540
- * Add a target object that can be linked
6541
- * @param objectName - Name of the object (e.g., "companies")
6542
- * @param options - Display template and filter options
6543
- */
6544
- to(objectName, options) {
6545
- const target = {
6546
- object: objectName,
6547
- ...options
6548
- };
6549
- this.attr.targets?.push(target);
6550
- return this;
6551
- }
6552
- /**
6553
- * Allow linking to any object in the tenant (universal polymorphic)
6554
- * Creates a relation with `targets: [{ object: "*" }]`
6555
- *
6556
- * @example
6557
- * ```typescript
6558
- * relation({ name: "linkedItems", label: "Linked Items" })
6559
- * .toAny()
6560
- * .many()
6561
- * ```
6562
- */
6563
- toAny() {
6564
- this.attr.targets = [{ object: RELATION_TARGET_ANY }];
6565
- return this;
6566
- }
6567
6236
  /**
6568
6237
  * Set minimum number of relations required
6569
6238
  */
@@ -6578,33 +6247,6 @@ var MultiRelationAttributeBuilder = class extends BaseAttributeBuilder {
6578
6247
  this.attr.maxItems = count;
6579
6248
  return this;
6580
6249
  }
6581
- /**
6582
- * Add properties to qualify the relation
6583
- * Must be called AFTER .to() or .many() to ensure targets are defined
6584
- *
6585
- * @example
6586
- * ```typescript
6587
- * relation({ name: "companies", label: "Companies" })
6588
- * .to("companies")
6589
- * .many()
6590
- * .qualifyWith(props => props
6591
- * .add("role", select => select.options([...]).required())
6592
- * .add("shares", number => number.min(0))
6593
- * )
6594
- * ```
6595
- */
6596
- qualifyWith(configure) {
6597
- const targets = this.attr.targets;
6598
- if (!targets || targets.length === 0) {
6599
- throw new Error(
6600
- '.qualifyWith() must be called AFTER .to() or .many(). Example: relation({ name: "companies" }).to("companies").many().qualifyWith(...)'
6601
- );
6602
- }
6603
- const builder = new PropertySchemaBuilder();
6604
- const schema = configure(builder).build();
6605
- this.attr.properties = schema;
6606
- return this;
6607
- }
6608
6250
  required() {
6609
6251
  this.setRequired(true);
6610
6252
  return this;
@@ -6672,8 +6314,9 @@ var FormulaAttributeBuilder = class extends BaseAttributeBuilder {
6672
6314
  // Override to prevent making formulas required
6673
6315
  // biome-ignore lint/suspicious/noExplicitAny: intentional override
6674
6316
  required() {
6675
- console.warn("Formula attributes cannot be required (they are read-only)");
6676
- return this;
6317
+ throw new Error(
6318
+ "Formula attributes cannot be required: they are computed read-only values. Remove .required() from this attribute definition."
6319
+ );
6677
6320
  }
6678
6321
  // biome-ignore lint/suspicious/noExplicitAny: intentional override
6679
6322
  optional() {
@@ -6747,8 +6390,9 @@ var RollupAttributeBuilder = class extends BaseAttributeBuilder {
6747
6390
  // Override to prevent making rollups required
6748
6391
  // biome-ignore lint/suspicious/noExplicitAny: intentional override
6749
6392
  required() {
6750
- console.warn("Rollup attributes cannot be required (they are read-only)");
6751
- return this;
6393
+ throw new Error(
6394
+ "Rollup attributes cannot be required: they are computed read-only values. Remove .required() from this attribute definition."
6395
+ );
6752
6396
  }
6753
6397
  // biome-ignore lint/suspicious/noExplicitAny: intentional override
6754
6398
  optional() {
@@ -7063,7 +6707,8 @@ var GroupBuilder = class {
7063
6707
  return this.data;
7064
6708
  }
7065
6709
  };
7066
- var BaseTableTabConfig = class {
6710
+ var TableTabConfig = class {
6711
+ /** @internal */
7067
6712
  constructor(view2, tabData) {
7068
6713
  this.view = view2;
7069
6714
  this.tabData = tabData;
@@ -7141,6 +6786,29 @@ var BaseTableTabConfig = class {
7141
6786
  this.tabData.sorts.push({ attribute, direction });
7142
6787
  return this;
7143
6788
  }
6789
+ /**
6790
+ * Traverse a 2nd-level relation to display nested data.
6791
+ * When active, `tab.columns` stores the 2nd-level object's attribute names.
6792
+ *
6793
+ * @param attribute - Relation attribute on the first-level target object
6794
+ * @example .table("members").through("companies").columns(["name", "sector"])
6795
+ */
6796
+ through(attribute) {
6797
+ this.tabData.through = { attribute };
6798
+ return this;
6799
+ }
6800
+ /**
6801
+ * Show _source and _target columns in flattened (through) view
6802
+ */
6803
+ showSourceTarget(value = true) {
6804
+ if (!this.tabData.through) {
6805
+ throw new Error(
6806
+ `[TableTabConfig] showSourceTarget() requires through() to be called first for tab "${this.tabData.name}"`
6807
+ );
6808
+ }
6809
+ this.tabData.through.showSourceTarget = value;
6810
+ return this;
6811
+ }
7144
6812
  /**
7145
6813
  * Continue building with a new tab
7146
6814
  */
@@ -7169,31 +6837,6 @@ var BaseTableTabConfig = class {
7169
6837
  this.view._addTab(this.tabData);
7170
6838
  }
7171
6839
  };
7172
- var DirectTableTabConfig = class extends BaseTableTabConfig {
7173
- /** @internal */
7174
- constructor(view2, base, relationAttribute) {
7175
- super(view2, {
7176
- ...base,
7177
- type: "table",
7178
- relationMode: "direct",
7179
- relationAttribute,
7180
- columns: []
7181
- });
7182
- }
7183
- };
7184
- var InverseTableTabConfig = class extends BaseTableTabConfig {
7185
- /** @internal */
7186
- constructor(view2, base, sourceObject, relationAttribute) {
7187
- super(view2, {
7188
- ...base,
7189
- type: "table",
7190
- relationMode: "inverse",
7191
- sourceObject,
7192
- relationAttribute,
7193
- columns: []
7194
- });
7195
- }
7196
- };
7197
6840
  var CustomTabConfig = class {
7198
6841
  /** @internal */
7199
6842
  constructor(view2, base, component) {
@@ -7229,24 +6872,17 @@ var CustomTabConfig = class {
7229
6872
  return this.view.build();
7230
6873
  }
7231
6874
  };
7232
- var NotesTabConfig = class {
6875
+ var RichtextTabConfig = class {
7233
6876
  /** @internal */
7234
- constructor(view2, base) {
6877
+ constructor(view2, base, attribute) {
7235
6878
  this.view = view2;
7236
- this.tabData = { ...base, type: "notes" };
7237
- }
7238
- /**
7239
- * Show only private notes of the current user
7240
- */
7241
- privateOnly() {
7242
- this.tabData.privateOnly = true;
7243
- return this;
6879
+ this.tabData = { ...base, type: "richtext", attribute };
7244
6880
  }
7245
6881
  /**
7246
- * Allow creating new notes from this tab
6882
+ * Set a text attribute to display as an editable title above the editor
7247
6883
  */
7248
- create() {
7249
- this.tabData.allowCreate = true;
6884
+ titleAttribute(name) {
6885
+ this.tabData.titleAttribute = name;
7250
6886
  return this;
7251
6887
  }
7252
6888
  /**
@@ -7485,27 +7121,37 @@ var TabBuilder = class {
7485
7121
  return this.view._addTab(tab);
7486
7122
  }
7487
7123
  /**
7488
- * Create a direct table tab for a relation attribute on the current object
7124
+ * Create a table tab for a relation attribute on the current object
7489
7125
  *
7490
7126
  * Use this when the current object has a relation attribute pointing to another object.
7491
7127
  *
7492
7128
  * @param relationAttribute - Name of the relation attribute on the current object
7493
- * @example .table("members").columns("name", "email").crud() // Show users from Project.members
7129
+ * @example .table("members").columns("name", "email").crud()
7494
7130
  */
7495
7131
  table(relationAttribute) {
7496
- return new DirectTableTabConfig(this.view, this.base, relationAttribute);
7132
+ return new TableTabConfig(this.view, {
7133
+ ...this.base,
7134
+ type: "table",
7135
+ source: { type: "relation", attribute: relationAttribute },
7136
+ columns: []
7137
+ });
7497
7138
  }
7498
7139
  /**
7499
- * Create an inverse table tab showing records from another object that have a relation to us
7140
+ * Create a table tab showing records from another object that have a relation to us
7500
7141
  *
7501
7142
  * Use this when another object has a relation attribute pointing to the current object.
7502
7143
  *
7503
7144
  * @param sourceObject - Name of the object that has the relation to us
7504
7145
  * @param relationAttribute - Name of the relation attribute on the source object
7505
- * @example .tableFrom("contacts", "company").columns("firstName", "lastName") // Show contacts where Contact.company = this
7146
+ * @example .tableFrom("contacts", "company").columns("firstName", "lastName")
7506
7147
  */
7507
7148
  tableFrom(sourceObject, relationAttribute) {
7508
- return new InverseTableTabConfig(this.view, this.base, sourceObject, relationAttribute);
7149
+ return new TableTabConfig(this.view, {
7150
+ ...this.base,
7151
+ type: "table",
7152
+ source: { type: "inverse", object: sourceObject, attribute: relationAttribute },
7153
+ columns: []
7154
+ });
7509
7155
  }
7510
7156
  /**
7511
7157
  * Create a custom tab with a component
@@ -7515,11 +7161,11 @@ var TabBuilder = class {
7515
7161
  return new CustomTabConfig(this.view, this.base, component);
7516
7162
  }
7517
7163
  /**
7518
- * Create a notes tab
7519
- * @example .notes().create().privateOnly()
7164
+ * Create a richtext tab for a richtext attribute
7165
+ * @example .richtext("content").titleAttribute("title")
7520
7166
  */
7521
- notes() {
7522
- return new NotesTabConfig(this.view, this.base);
7167
+ richtext(attribute) {
7168
+ return new RichtextTabConfig(this.view, this.base, attribute);
7523
7169
  }
7524
7170
  /**
7525
7171
  * Create an activity tab
@@ -7606,6 +7252,16 @@ var DetailViewBuilder = class {
7606
7252
  this.data.metadata = value;
7607
7253
  return this;
7608
7254
  }
7255
+ /**
7256
+ * Configure a side panel with flat attribute fields displayed alongside tab content.
7257
+ * Not available for modal layout.
7258
+ *
7259
+ * @example .sidePanel({ attributes: ["visibility", "linkedTo"] })
7260
+ */
7261
+ sidePanel(config) {
7262
+ this.data.sidePanel = config;
7263
+ return this;
7264
+ }
7609
7265
  /**
7610
7266
  * Start building a new tab
7611
7267
  */
@@ -7650,10 +7306,14 @@ var DetailViewBuilder = class {
7650
7306
  if (this.data.tabs[0].type !== "form") {
7651
7307
  throw new Error("[DetailViewBuilder] Modal views must have a form tab");
7652
7308
  }
7309
+ if (this.data.sidePanel) {
7310
+ throw new Error("[DetailViewBuilder] Modal views cannot have a side panel");
7311
+ }
7653
7312
  }
7654
7313
  const config = {
7655
7314
  layout: this.data.layout,
7656
- tabs: this.data.tabs
7315
+ tabs: this.data.tabs,
7316
+ sidePanel: this.data.sidePanel
7657
7317
  };
7658
7318
  return {
7659
7319
  name: this.data.name,
@@ -8648,12 +8308,125 @@ function buildAuditChanges(oldValues, newValues, fieldsToCheck) {
8648
8308
  return changes;
8649
8309
  }
8650
8310
 
8311
+ // src/runtime/services/bilateral/bilateral-validation.service.ts
8312
+ var BilateralValidationService = class extends BaseService {
8313
+ constructor(adapter, schemaService) {
8314
+ super(adapter);
8315
+ this.schemaService = schemaService;
8316
+ }
8317
+ /**
8318
+ * Validate a bilateral relation configuration.
8319
+ *
8320
+ * Performs comprehensive checks:
8321
+ * 1. Verifies target object exists
8322
+ * 2. Verifies inverse attribute exists on target object
8323
+ * 3. Verifies inverse attribute targets the source object
8324
+ * 4. Detects invalid circular bilateral declarations
8325
+ *
8326
+ * @param sourceSchema - Schema containing the relation attribute
8327
+ * @param sourceAttr - Relation attribute to validate
8328
+ * @returns Validation result with errors if any
8329
+ */
8330
+ async validateBilateralRelation(sourceSchema, sourceAttr) {
8331
+ const errors = [];
8332
+ if (!(isBilateralRelation(sourceAttr) && sourceAttr.bilateral)) {
8333
+ return { valid: true, errors: [] };
8334
+ }
8335
+ const { object: targetObjectName, attribute: inverseAttrName } = sourceAttr.bilateral;
8336
+ let targetSchema = null;
8337
+ try {
8338
+ targetSchema = await this.schemaService.getObjectSchemaByName(targetObjectName);
8339
+ } catch {
8340
+ targetSchema = null;
8341
+ }
8342
+ if (!targetSchema) {
8343
+ errors.push({
8344
+ code: "INVERSE_ATTR_NOT_FOUND",
8345
+ message: `Target object "${targetObjectName}" not found`,
8346
+ context: {
8347
+ sourceObject: sourceSchema.name,
8348
+ sourceAttribute: sourceAttr.name,
8349
+ targetObject: targetObjectName,
8350
+ targetAttribute: inverseAttrName
8351
+ }
8352
+ });
8353
+ return { valid: false, errors };
8354
+ }
8355
+ const inverseAttr = targetSchema.attributes.find(
8356
+ (a) => a.name === inverseAttrName && a.type === "relation"
8357
+ );
8358
+ if (!inverseAttr) {
8359
+ errors.push({
8360
+ code: "INVERSE_ATTR_NOT_FOUND",
8361
+ message: `Inverse attribute "${inverseAttrName}" not found on "${targetObjectName}"`,
8362
+ context: {
8363
+ sourceObject: sourceSchema.name,
8364
+ sourceAttribute: sourceAttr.name,
8365
+ targetObject: targetObjectName,
8366
+ targetAttribute: inverseAttrName
8367
+ }
8368
+ });
8369
+ return { valid: false, errors };
8370
+ }
8371
+ const inverseTargetsSource = inverseAttr.targets.some(
8372
+ (target) => target.object === sourceSchema.name
8373
+ );
8374
+ if (!inverseTargetsSource) {
8375
+ errors.push({
8376
+ code: "INVERSE_TARGET_MISMATCH",
8377
+ message: `Inverse attribute doesn't target source object`,
8378
+ context: {
8379
+ sourceObject: sourceSchema.name,
8380
+ sourceAttribute: sourceAttr.name,
8381
+ targetObject: targetObjectName,
8382
+ targetAttribute: inverseAttrName
8383
+ }
8384
+ });
8385
+ }
8386
+ if (isBilateralRelation(inverseAttr)) {
8387
+ const inverseBilateral = inverseAttr.bilateral;
8388
+ if (inverseBilateral.object === sourceSchema.name && inverseBilateral.attribute !== sourceAttr.name) {
8389
+ errors.push({
8390
+ code: "CIRCULAR_BILATERAL",
8391
+ message: "Both sides declare bilateral but point to different attributes",
8392
+ context: {
8393
+ sourceObject: sourceSchema.name,
8394
+ sourceAttribute: sourceAttr.name,
8395
+ targetObject: targetObjectName,
8396
+ targetAttribute: inverseAttrName
8397
+ }
8398
+ });
8399
+ }
8400
+ }
8401
+ return { valid: errors.length === 0, errors };
8402
+ }
8403
+ /**
8404
+ * Validate all bilateral relations in a schema.
8405
+ *
8406
+ * Iterates through all relation attributes and validates each bilateral configuration.
8407
+ *
8408
+ * @param schema - Object schema to validate
8409
+ * @returns Combined validation result with all errors
8410
+ */
8411
+ async validateAllBilateralRelations(schema) {
8412
+ const allErrors = [];
8413
+ for (const attr of schema.attributes) {
8414
+ if (attr.type === "relation" && isBilateralRelation(attr)) {
8415
+ const result = await this.validateBilateralRelation(schema, attr);
8416
+ allErrors.push(...result.errors);
8417
+ }
8418
+ }
8419
+ return { valid: allErrors.length === 0, errors: allErrors };
8420
+ }
8421
+ };
8422
+
8651
8423
  // src/runtime/services/schema/object-schema.service.ts
8652
8424
  var ObjectSchemaService = class extends BaseService {
8653
8425
  constructor(adapter, nativeRegistry, options) {
8654
8426
  super(adapter);
8655
8427
  this.nativeRegistry = nativeRegistry;
8656
8428
  this.auditService = options?.auditService;
8429
+ this.bilateralValidationService = new BilateralValidationService(adapter, this);
8657
8430
  }
8658
8431
  /**
8659
8432
  * Create a new custom object.
@@ -8677,6 +8450,15 @@ var ObjectSchemaService = class extends BaseService {
8677
8450
  builder.attributes(definition.attributes);
8678
8451
  }
8679
8452
  const objectDef = builder.build();
8453
+ const bilateralValidation = await this.bilateralValidationService.validateAllBilateralRelations(objectDef);
8454
+ if (!bilateralValidation.valid) {
8455
+ const errorMessages = bilateralValidation.errors.map((e) => `${e.code}: ${e.message}`).join("; ");
8456
+ throw new SchemaError(
8457
+ `Bilateral relation validation failed: ${errorMessages}`,
8458
+ SchemaErrorCode.VALIDATION_FAILED,
8459
+ { errors: bilateralValidation.errors }
8460
+ );
8461
+ }
8680
8462
  const existing = await this.adapter.objects.findByName(objectDef.name);
8681
8463
  if (existing) {
8682
8464
  throw new Error(`Object with name "${objectDef.name}" already exists`);
@@ -8766,6 +8548,28 @@ var ObjectSchemaService = class extends BaseService {
8766
8548
  );
8767
8549
  }
8768
8550
  const config = await this.validateAttributeInput(attribute);
8551
+ if (attribute.type === "relation" && attribute.bilateral) {
8552
+ const tempSchema = await this.getObjectSchema(objectId);
8553
+ const tempAttr = {
8554
+ ...attribute,
8555
+ id: "temp-id",
8556
+ // Temporary ID for validation
8557
+ system: false,
8558
+ config
8559
+ };
8560
+ const validation = await this.bilateralValidationService.validateBilateralRelation(
8561
+ tempSchema,
8562
+ tempAttr
8563
+ );
8564
+ if (!validation.valid) {
8565
+ const errorMessages = validation.errors.map((e) => `${e.code}: ${e.message}`).join("; ");
8566
+ throw new SchemaError(
8567
+ `Bilateral relation validation failed: ${errorMessages}`,
8568
+ SchemaErrorCode.VALIDATION_FAILED,
8569
+ { errors: validation.errors }
8570
+ );
8571
+ }
8572
+ }
8769
8573
  const dbAttr = await this.adapter.attributes.create({
8770
8574
  objectId,
8771
8575
  name: attribute.name,
@@ -9190,7 +8994,59 @@ Reserved names: ${RESERVED_ATTRIBUTE_NAMES.join(", ")}`
9190
8994
  async buildObjectDefinition(dbObject) {
9191
8995
  const dbAttributes = await this.adapter.attributes.findByObjectId(dbObject.id);
9192
8996
  const baseDef = dbObject.system ? this.mergeNativeObject(dbObject, dbAttributes) : this.convertDBObjectToDefinition(dbObject, dbAttributes);
9193
- return this.withSystemAttributes(baseDef);
8997
+ const enriched = await this.enrichBilateralProperties(baseDef);
8998
+ return this.withSystemAttributes(enriched);
8999
+ }
9000
+ /**
9001
+ * Enrich bilateral relation attributes that don't own property definitions.
9002
+ * Copies `properties` from the canonical side (the one with `.qualifyWith()`)
9003
+ * and sets `storageOwner: false` so the storage layer knows direction.
9004
+ *
9005
+ * Uses direct DB lookups to avoid circular recursion through `getObjectSchema`.
9006
+ * @internal
9007
+ */
9008
+ async enrichBilateralProperties(def) {
9009
+ const toEnrich = def.attributes.filter(
9010
+ (attr) => attr.type === "relation" && !!attr.bilateral && !attr.properties
9011
+ );
9012
+ if (toEnrich.length === 0) return def;
9013
+ const enrichedMap = /* @__PURE__ */ new Map();
9014
+ for (const attr of toEnrich) {
9015
+ const { bilateral } = attr;
9016
+ if (!bilateral) continue;
9017
+ const inverseObject = await this.adapter.objects.findByName(bilateral.object);
9018
+ if (!inverseObject) continue;
9019
+ const inverseDbAttrs = await this.adapter.attributes.findByObjectId(inverseObject.id);
9020
+ const inverseDbAttr = inverseDbAttrs.find((a) => a.name === bilateral.attribute);
9021
+ if (!inverseDbAttr) continue;
9022
+ let properties = inverseDbAttr.config.properties;
9023
+ if (!properties) {
9024
+ const nativeObj = this.nativeRegistry.getByName(bilateral.object);
9025
+ const nativeAttr = nativeObj?.attributes.find((a) => a.name === bilateral.attribute);
9026
+ if (nativeAttr && "properties" in nativeAttr) {
9027
+ properties = nativeAttr.properties;
9028
+ }
9029
+ }
9030
+ if (properties) {
9031
+ enrichedMap.set(attr.name, {
9032
+ properties,
9033
+ bilateral: { ...bilateral, storageOwner: false }
9034
+ });
9035
+ }
9036
+ }
9037
+ if (enrichedMap.size === 0) return def;
9038
+ return {
9039
+ ...def,
9040
+ attributes: def.attributes.map((attr) => {
9041
+ const enrichment = enrichedMap.get(attr.name);
9042
+ if (!enrichment) return attr;
9043
+ return {
9044
+ ...attr,
9045
+ properties: enrichment.properties,
9046
+ bilateral: enrichment.bilateral
9047
+ };
9048
+ })
9049
+ };
9194
9050
  }
9195
9051
  /**
9196
9052
  * Append system attributes to an ObjectDefinition
@@ -9785,30 +9641,327 @@ var AuditService = class extends BaseService {
9785
9641
  }
9786
9642
  };
9787
9643
 
9788
- // src/runtime/services/user/user.service.ts
9789
- var UserService = class extends BaseService {
9790
- constructor(adapter) {
9644
+ // src/runtime/services/bilateral/bilateral-sync.service.ts
9645
+ var browserStub4 = {
9646
+ getStore: () => void 0,
9647
+ run: (_store, callback) => callback()
9648
+ };
9649
+ var AsyncLocalStorageClass4 = null;
9650
+ if (typeof process !== "undefined" && process.versions?.node) {
9651
+ try {
9652
+ if (typeof __require !== "undefined") {
9653
+ const asyncHooks = __require("async_hooks");
9654
+ AsyncLocalStorageClass4 = asyncHooks.AsyncLocalStorage;
9655
+ }
9656
+ } catch {
9657
+ try {
9658
+ const dynamicRequire = new Function(
9659
+ "m",
9660
+ 'return typeof require!=="undefined"?require(m):null'
9661
+ );
9662
+ const asyncHooks = dynamicRequire("node:async_hooks");
9663
+ if (asyncHooks) {
9664
+ AsyncLocalStorageClass4 = asyncHooks.AsyncLocalStorage;
9665
+ }
9666
+ } catch {
9667
+ }
9668
+ }
9669
+ }
9670
+ var bilateralSyncContext = null;
9671
+ function getSyncContext() {
9672
+ if (bilateralSyncContext !== null) {
9673
+ return bilateralSyncContext;
9674
+ }
9675
+ if (AsyncLocalStorageClass4) {
9676
+ bilateralSyncContext = new AsyncLocalStorageClass4();
9677
+ return bilateralSyncContext;
9678
+ }
9679
+ bilateralSyncContext = browserStub4;
9680
+ return bilateralSyncContext;
9681
+ }
9682
+ var BilateralSyncService = class extends BaseService {
9683
+ constructor(adapter, schemaService, relationPropertiesService) {
9791
9684
  super(adapter);
9685
+ this.schemaService = schemaService;
9686
+ this.relationPropertiesService = relationPropertiesService;
9792
9687
  }
9688
+ // ============================================================================
9689
+ // PUBLIC API
9690
+ // ============================================================================
9793
9691
  /**
9794
- * Validate all user attributes in the data.
9692
+ * Synchronize a bilateral relation after modification.
9795
9693
  *
9796
- * Uses batch fetching (findByIds) to avoid N+1 query pattern.
9797
- *
9798
- * @param schema - Object schema containing attribute definitions
9799
- * @param data - Record data to validate
9800
- * @returns Validation result with errors if any
9801
- *
9802
- * @example
9803
- * ```typescript
9804
- * const result = await userService.validateUsers(schema, {
9805
- * assignee: "user-123",
9806
- * watchers: ["user-456", "user-789"]
9807
- * });
9808
- *
9809
- * if (!result.valid) {
9810
- * console.log(result.errors);
9811
- * // [{ attribute: "assignee", message: "User not found", invalidIds: ["user-123"] }]
9694
+ * @param sourceSchema - Schema of the object containing the relation
9695
+ * @param sourceRecordId - ID of the record being modified
9696
+ * @param attributeName - Name of the relation attribute
9697
+ * @param newValue - New value (ID, array of IDs, or hybrid format with properties)
9698
+ * @param oldValue - Old value (ID, array of IDs, or hybrid format with properties)
9699
+ */
9700
+ async syncBilateralRelation(sourceSchema, sourceRecordId, attributeName, newValue, oldValue) {
9701
+ const attribute = sourceSchema.attributes.find(
9702
+ (a) => a.name === attributeName && a.type === "relation"
9703
+ );
9704
+ if (!(attribute && isBilateralRelation(attribute))) {
9705
+ return;
9706
+ }
9707
+ const ctx = getSyncContext().getStore();
9708
+ const syncKey = `${sourceSchema.name}:${sourceRecordId}:${attributeName}`;
9709
+ if (ctx?.syncing.has(syncKey)) {
9710
+ return;
9711
+ }
9712
+ await this.runWithSyncContext(syncKey, async () => {
9713
+ await this.performBilateralSync(sourceSchema, sourceRecordId, attribute, newValue, oldValue);
9714
+ });
9715
+ }
9716
+ // ============================================================================
9717
+ // PRIVATE METHODS
9718
+ // ============================================================================
9719
+ /**
9720
+ * Perform the bidirectional synchronization.
9721
+ * @private
9722
+ */
9723
+ async performBilateralSync(sourceSchema, sourceRecordId, sourceAttr, newValue, oldValue) {
9724
+ const bilateral = sourceAttr.bilateral;
9725
+ const targetSchema = await this.schemaService.getObjectSchemaByName(bilateral.object);
9726
+ if (!targetSchema) {
9727
+ throw new Error(`Target object "${bilateral.object}" not found`);
9728
+ }
9729
+ const inverseAttr = targetSchema.attributes.find(
9730
+ (a) => a.name === bilateral.attribute && a.type === "relation"
9731
+ );
9732
+ if (!inverseAttr) {
9733
+ throw new Error(
9734
+ `Inverse attribute "${bilateral.attribute}" not found on "${bilateral.object}"`
9735
+ );
9736
+ }
9737
+ const newData = this.extractRelationData(newValue);
9738
+ const oldData = this.extractRelationData(oldValue);
9739
+ const addedIds = newData.ids.filter((id) => !oldData.ids.includes(id));
9740
+ const removedIds = oldData.ids.filter((id) => !newData.ids.includes(id));
9741
+ const commonIds = newData.ids.filter((id) => oldData.ids.includes(id));
9742
+ await Promise.all([
9743
+ // Add new relations
9744
+ ...addedIds.map(
9745
+ (targetId) => this.addInverseRelation(
9746
+ targetId,
9747
+ inverseAttr,
9748
+ sourceRecordId,
9749
+ sourceSchema.name,
9750
+ sourceAttr.name,
9751
+ newData.properties.get(targetId)
9752
+ )
9753
+ ),
9754
+ // Remove deleted relations
9755
+ ...removedIds.map(
9756
+ (targetId) => this.removeInverseRelation(targetId, inverseAttr, sourceRecordId)
9757
+ ),
9758
+ // Update properties for common IDs
9759
+ ...commonIds.map(
9760
+ (targetId) => this.updateInverseRelationProperties(
9761
+ targetId,
9762
+ inverseAttr,
9763
+ sourceRecordId,
9764
+ sourceSchema.name,
9765
+ sourceAttr.name,
9766
+ newData.properties.get(targetId),
9767
+ oldData.properties.get(targetId)
9768
+ )
9769
+ )
9770
+ ]);
9771
+ }
9772
+ /**
9773
+ * Extract IDs and properties from hybrid relation value.
9774
+ * @private
9775
+ */
9776
+ extractRelationData(value) {
9777
+ const ids = [];
9778
+ const properties = /* @__PURE__ */ new Map();
9779
+ if (value === null || value === void 0) {
9780
+ return { ids, properties };
9781
+ }
9782
+ if (typeof value === "string") {
9783
+ ids.push(value);
9784
+ return { ids, properties };
9785
+ }
9786
+ if (!Array.isArray(value) && typeof value === "object" && "id" in value) {
9787
+ ids.push(value.id);
9788
+ if (value.props) {
9789
+ properties.set(value.id, value.props);
9790
+ }
9791
+ return { ids, properties };
9792
+ }
9793
+ if (Array.isArray(value)) {
9794
+ for (const item of value) {
9795
+ if (typeof item === "string") {
9796
+ ids.push(item);
9797
+ } else if (typeof item === "object" && item !== null && "id" in item) {
9798
+ ids.push(item.id);
9799
+ if (item.props) {
9800
+ properties.set(item.id, item.props);
9801
+ }
9802
+ }
9803
+ }
9804
+ }
9805
+ return { ids, properties };
9806
+ }
9807
+ /**
9808
+ * Add an ID to an inverse relation (with properties).
9809
+ * @private
9810
+ */
9811
+ async addInverseRelation(targetRecordId, inverseAttr, sourceRecordId, sourceObject, sourceAttribute, properties) {
9812
+ const targetRecord = await this.adapter.objectRecords.findById(targetRecordId);
9813
+ if (!targetRecord) {
9814
+ return;
9815
+ }
9816
+ const currentValue = targetRecord.values[inverseAttr.name];
9817
+ let newValue;
9818
+ if (inverseAttr.cardinality === "one") {
9819
+ newValue = sourceRecordId;
9820
+ } else {
9821
+ const currentArray = this.normalizeToArray(currentValue);
9822
+ if (currentArray.includes(sourceRecordId)) {
9823
+ return;
9824
+ }
9825
+ newValue = [...currentArray, sourceRecordId];
9826
+ }
9827
+ await this.adapter.objectRecords.update(targetRecordId, {
9828
+ [inverseAttr.name]: newValue
9829
+ });
9830
+ await this.invalidateTargetRecordCaches(targetRecordId, targetRecord.objectId);
9831
+ if (properties && Object.keys(properties).length > 0 && this.adapter.relationAttributes) {
9832
+ const sourceSchema = await this.schemaService.getObjectSchemaByName(sourceObject);
9833
+ if (sourceSchema) {
9834
+ await this.relationPropertiesService.syncRelationProperties(
9835
+ sourceSchema,
9836
+ sourceRecordId,
9837
+ sourceAttribute,
9838
+ [{ id: targetRecordId, props: properties }],
9839
+ this.adapter
9840
+ );
9841
+ }
9842
+ }
9843
+ }
9844
+ /**
9845
+ * Update properties of an existing inverse relation.
9846
+ * @private
9847
+ */
9848
+ async updateInverseRelationProperties(targetRecordId, _inverseAttr, sourceRecordId, sourceObject, sourceAttribute, newProperties, oldProperties) {
9849
+ if (JSON.stringify(newProperties) === JSON.stringify(oldProperties)) {
9850
+ return;
9851
+ }
9852
+ if (!this.adapter.relationAttributes) {
9853
+ return;
9854
+ }
9855
+ const sourceSchema = await this.schemaService.getObjectSchemaByName(sourceObject);
9856
+ if (!sourceSchema) {
9857
+ return;
9858
+ }
9859
+ if (newProperties && Object.keys(newProperties).length > 0) {
9860
+ await this.relationPropertiesService.syncRelationProperties(
9861
+ sourceSchema,
9862
+ sourceRecordId,
9863
+ sourceAttribute,
9864
+ [{ id: targetRecordId, props: newProperties }],
9865
+ this.adapter
9866
+ );
9867
+ } else {
9868
+ await this.adapter.relationAttributes.deleteBySource(
9869
+ sourceObject,
9870
+ sourceRecordId,
9871
+ sourceAttribute
9872
+ );
9873
+ }
9874
+ }
9875
+ /**
9876
+ * Remove an ID from an inverse relation.
9877
+ * @private
9878
+ */
9879
+ async removeInverseRelation(targetRecordId, inverseAttr, sourceRecordId) {
9880
+ const targetRecord = await this.adapter.objectRecords.findById(targetRecordId);
9881
+ if (!targetRecord) {
9882
+ return;
9883
+ }
9884
+ const currentValue = targetRecord.values[inverseAttr.name];
9885
+ let newValue;
9886
+ if (inverseAttr.cardinality === "one") {
9887
+ if (currentValue === sourceRecordId) {
9888
+ newValue = null;
9889
+ } else {
9890
+ return;
9891
+ }
9892
+ } else {
9893
+ const currentArray = this.normalizeToArray(currentValue);
9894
+ newValue = currentArray.filter((id) => id !== sourceRecordId);
9895
+ if (newValue.length === currentArray.length) {
9896
+ return;
9897
+ }
9898
+ }
9899
+ await this.adapter.objectRecords.update(targetRecordId, {
9900
+ [inverseAttr.name]: newValue
9901
+ });
9902
+ await this.invalidateTargetRecordCaches(targetRecordId, targetRecord.objectId);
9903
+ }
9904
+ /**
9905
+ * Normalize a relation value to an array of IDs.
9906
+ * @private
9907
+ */
9908
+ normalizeToArray(value) {
9909
+ if (value === null || value === void 0) return [];
9910
+ if (typeof value === "string") return [value];
9911
+ if (Array.isArray(value)) return value;
9912
+ return [];
9913
+ }
9914
+ /**
9915
+ * Invalidate caches for a target record after bilateral update.
9916
+ * Mirrors RecordService.invalidateRecordCaches to ensure consistency.
9917
+ * @private
9918
+ */
9919
+ async invalidateTargetRecordCaches(recordId, objectId) {
9920
+ await this.invalidateCache(cacheKeys.record(this.tenantId, recordId));
9921
+ await this.invalidateLists("allRecordLists", objectId);
9922
+ await this.invalidateLists("allSearchResults", objectId);
9923
+ await this.invalidateCachePattern(cacheKeys.allGlobalSearch(this.tenantId));
9924
+ await this.invalidateCachePattern(cacheKeys.resolvedRelationsByRecord(this.tenantId, recordId));
9925
+ }
9926
+ /**
9927
+ * Execute a function with sync context.
9928
+ * @private
9929
+ */
9930
+ async runWithSyncContext(syncKey, fn) {
9931
+ const storage = getSyncContext();
9932
+ const existingCtx = storage.getStore();
9933
+ const ctx = {
9934
+ syncing: new Set(existingCtx?.syncing ?? [])
9935
+ };
9936
+ ctx.syncing.add(syncKey);
9937
+ return await storage.run(ctx, fn);
9938
+ }
9939
+ };
9940
+
9941
+ // src/runtime/services/user/user.service.ts
9942
+ var UserService = class extends BaseService {
9943
+ constructor(adapter) {
9944
+ super(adapter);
9945
+ }
9946
+ /**
9947
+ * Validate all user attributes in the data.
9948
+ *
9949
+ * Uses batch fetching (findByIds) to avoid N+1 query pattern.
9950
+ *
9951
+ * @param schema - Object schema containing attribute definitions
9952
+ * @param data - Record data to validate
9953
+ * @returns Validation result with errors if any
9954
+ *
9955
+ * @example
9956
+ * ```typescript
9957
+ * const result = await userService.validateUsers(schema, {
9958
+ * assignee: "user-123",
9959
+ * watchers: ["user-456", "user-789"]
9960
+ * });
9961
+ *
9962
+ * if (!result.valid) {
9963
+ * console.log(result.errors);
9964
+ * // [{ attribute: "assignee", message: "User not found", invalidIds: ["user-123"] }]
9812
9965
  * }
9813
9966
  * ```
9814
9967
  */
@@ -10185,6 +10338,359 @@ async function recalculateParentRollups(record, schema, ctx) {
10185
10338
  }
10186
10339
  }
10187
10340
 
10341
+ // src/runtime/services/record/relation-properties.service.ts
10342
+ import { z as z5 } from "zod";
10343
+ var RelationPropertiesService = class extends BaseService {
10344
+ constructor(adapter) {
10345
+ super(adapter);
10346
+ }
10347
+ // ============================================================================
10348
+ // PUBLIC API
10349
+ // ============================================================================
10350
+ /**
10351
+ * Get relation properties for a given attribute.
10352
+ *
10353
+ * Supports bidirectional relations: searches for properties in both directions
10354
+ * (forward: from_object/from_id → to_id, and inverse: to_id → from_id).
10355
+ *
10356
+ * This ensures that qualified properties are SHARED between both directions
10357
+ * of a bilateral relation, as they are stored in a single row in relation_attributes.
10358
+ *
10359
+ * @param objectName - Source object name
10360
+ * @param recordId - Source record ID
10361
+ * @param attributeName - Relation attribute name
10362
+ * @param targetIds - Array of target record IDs
10363
+ * @returns Map of target ID → properties
10364
+ *
10365
+ * @example
10366
+ * ```typescript
10367
+ * // Properties stored as: contacts/A/companies → X with { role: "CEO" }
10368
+ *
10369
+ * // Read from Contact A → Company X
10370
+ * const propsFromContact = await service.getRelationProperties(
10371
+ * "contacts", "A", "companies", ["X"]
10372
+ * );
10373
+ * // → Map { "X" => { role: "CEO" } }
10374
+ *
10375
+ * // Read from Company X → Contact A (inverse)
10376
+ * const propsFromCompany = await service.getRelationProperties(
10377
+ * "companies", "X", "contacts", ["A"]
10378
+ * );
10379
+ * // → Map { "A" => { role: "CEO" } } (same properties!)
10380
+ * ```
10381
+ */
10382
+ async getRelationProperties(objectName, recordId, attributeName, targetIds) {
10383
+ const properties = /* @__PURE__ */ new Map();
10384
+ if (!this.adapter.relationAttributes) {
10385
+ return properties;
10386
+ }
10387
+ const forwardProps = await this.adapter.relationAttributes.findBySource(
10388
+ objectName,
10389
+ recordId,
10390
+ attributeName
10391
+ );
10392
+ for (const prop of forwardProps) {
10393
+ if (targetIds.includes(prop.toId)) {
10394
+ properties.set(prop.toId, prop.properties);
10395
+ }
10396
+ }
10397
+ const inverseProps = await this.adapter.relationAttributes.findByTarget(recordId);
10398
+ for (const prop of inverseProps) {
10399
+ if (targetIds.includes(prop.fromId) && !properties.has(prop.fromId)) {
10400
+ properties.set(prop.fromId, prop.properties);
10401
+ }
10402
+ }
10403
+ return properties;
10404
+ }
10405
+ /**
10406
+ * Batch enrich records with qualified relation properties.
10407
+ *
10408
+ * Detects qualified attributes in the schema and fetches their properties
10409
+ * using batch queries (1 query per qualified attribute, not per record).
10410
+ * Returns records with values in hybrid format `{ id, props }`.
10411
+ *
10412
+ * For bilateral relations, also checks the inverse direction.
10413
+ *
10414
+ * @param records - Records to enrich
10415
+ * @param schema - Object schema
10416
+ * @returns Records with relation values enriched with properties
10417
+ */
10418
+ async enrichRecordsBatch(records, schema) {
10419
+ if (!this.adapter.relationAttributes) return records;
10420
+ if (records.length === 0) return records;
10421
+ const qualifiedAttrs = schema.attributes.filter(
10422
+ (a) => a.type === "relation" && (!!a.properties || !!a.bilateral)
10423
+ );
10424
+ if (qualifiedAttrs.length === 0) return records;
10425
+ const recordIds = records.map((r) => r.id);
10426
+ const relationAttrsRepo = this.adapter.relationAttributes;
10427
+ await Promise.all(
10428
+ qualifiedAttrs.map(async (attr) => {
10429
+ const forwardRows = await relationAttrsRepo.findBySourceBatch(
10430
+ schema.name,
10431
+ recordIds,
10432
+ attr.name
10433
+ );
10434
+ const inverseRows = attr.bilateral ? await relationAttrsRepo.findByTargetBatch(recordIds) : [];
10435
+ const byRecord = /* @__PURE__ */ new Map();
10436
+ for (const row of forwardRows) {
10437
+ let recordMap = byRecord.get(row.fromId);
10438
+ if (!recordMap) {
10439
+ recordMap = /* @__PURE__ */ new Map();
10440
+ byRecord.set(row.fromId, recordMap);
10441
+ }
10442
+ recordMap.set(row.toId, row.properties);
10443
+ }
10444
+ for (const row of inverseRows) {
10445
+ let recordMap = byRecord.get(row.toId);
10446
+ if (!recordMap) {
10447
+ recordMap = /* @__PURE__ */ new Map();
10448
+ byRecord.set(row.toId, recordMap);
10449
+ }
10450
+ if (!recordMap.has(row.fromId)) {
10451
+ recordMap.set(row.fromId, row.properties);
10452
+ }
10453
+ }
10454
+ for (const record of records) {
10455
+ const propsForRecord = byRecord.get(record.id);
10456
+ if (!propsForRecord) continue;
10457
+ const value = record.values[attr.name];
10458
+ if (Array.isArray(value)) {
10459
+ record.values = {
10460
+ ...record.values,
10461
+ [attr.name]: value.map((id) => {
10462
+ const props = propsForRecord.get(id);
10463
+ return props ? { id, props } : id;
10464
+ })
10465
+ };
10466
+ } else if (typeof value === "string") {
10467
+ const props = propsForRecord.get(value);
10468
+ if (props) {
10469
+ record.values = {
10470
+ ...record.values,
10471
+ [attr.name]: { id: value, props }
10472
+ };
10473
+ }
10474
+ }
10475
+ }
10476
+ })
10477
+ );
10478
+ return records;
10479
+ }
10480
+ /**
10481
+ * Normalize relation values for storage in object_records table.
10482
+ *
10483
+ * Extracts IDs from hybrid format ({ id, props }) and returns legacy format (string[] or string).
10484
+ * This ensures object_records.values only contains IDs, while properties are in relation_attributes.
10485
+ *
10486
+ * @param schema - Object schema
10487
+ * @param data - Record data with hybrid relation values
10488
+ * @returns Data with relation values normalized to ID-only format
10489
+ */
10490
+ normalizeRelationValuesForStorage(schema, data) {
10491
+ const normalized = { ...data };
10492
+ for (const attr of schema.attributes) {
10493
+ if (attr.type !== "relation") {
10494
+ continue;
10495
+ }
10496
+ const value = data[attr.name];
10497
+ if (value === null || value === void 0) {
10498
+ continue;
10499
+ }
10500
+ if (attr.cardinality === "many" && Array.isArray(value)) {
10501
+ normalized[attr.name] = value.map((item) => {
10502
+ if (typeof item === "string") return item;
10503
+ if (typeof item === "object" && item !== null && "id" in item) {
10504
+ return item.id;
10505
+ }
10506
+ return item;
10507
+ });
10508
+ } else if (typeof value === "object" && value !== null && "id" in value) {
10509
+ normalized[attr.name] = value.id;
10510
+ }
10511
+ }
10512
+ return normalized;
10513
+ }
10514
+ /**
10515
+ * Synchronize relation properties for a given attribute.
10516
+ *
10517
+ * Handles:
10518
+ * - Format normalization (legacy → new)
10519
+ * - Validation of properties
10520
+ * - Upsert for present IDs
10521
+ * - Delete for absent IDs
10522
+ *
10523
+ * @param schema - Object schema
10524
+ * @param recordId - Source record ID
10525
+ * @param attributeName - Relation attribute name
10526
+ * @param relationValue - Relation value (hybrid format)
10527
+ * @param adapter - Database adapter
10528
+ */
10529
+ async syncRelationProperties(schema, recordId, attributeName, relationValue, adapter) {
10530
+ const attribute = schema.attributes.find((a) => a.name === attributeName);
10531
+ if (!attribute || attribute.type !== "relation") {
10532
+ return;
10533
+ }
10534
+ const normalized = this.normalizeRelationValue(relationValue);
10535
+ const propertySchema = this.getPropertySchema(attribute);
10536
+ const hasPropsInInput = normalized.some((item) => item.props !== void 0);
10537
+ const hasSchema = Boolean(propertySchema);
10538
+ const canSync = hasSchema || hasPropsInInput;
10539
+ if (!canSync) {
10540
+ return;
10541
+ }
10542
+ if (propertySchema) {
10543
+ for (const item of normalized) {
10544
+ if (item.props && Object.keys(item.props).length > 0) {
10545
+ this.validateProperties(propertySchema, item.props);
10546
+ }
10547
+ }
10548
+ }
10549
+ const shouldStoreAsInverse = attribute.bilateral?.storageOwner === false;
10550
+ let storageFromObject = schema.name;
10551
+ let storageFromAttribute = attributeName;
10552
+ if (shouldStoreAsInverse && attribute.bilateral) {
10553
+ storageFromObject = attribute.bilateral.object;
10554
+ storageFromAttribute = attribute.bilateral.attribute;
10555
+ }
10556
+ let existing;
10557
+ if (adapter.relationAttributes) {
10558
+ if (shouldStoreAsInverse) {
10559
+ const results = await Promise.all(
10560
+ normalized.map(
10561
+ (item) => adapter.relationAttributes?.findBySource(
10562
+ storageFromObject,
10563
+ item.id,
10564
+ storageFromAttribute
10565
+ )
10566
+ )
10567
+ );
10568
+ existing = results.filter((r) => r !== void 0).flat().filter((r) => r.toId === recordId);
10569
+ } else {
10570
+ existing = await adapter.relationAttributes.findBySource(
10571
+ schema.name,
10572
+ recordId,
10573
+ attributeName
10574
+ );
10575
+ }
10576
+ }
10577
+ const hasChanges = existing && existing.length > 0;
10578
+ const toUpsert = normalized.filter((item) => {
10579
+ return item.props !== void 0 && Object.keys(item.props).length > 0;
10580
+ });
10581
+ if (!adapter.relationAttributes) {
10582
+ return;
10583
+ }
10584
+ if (hasChanges && existing) {
10585
+ if (shouldStoreAsInverse) {
10586
+ for (const item of normalized) {
10587
+ await adapter.relationAttributes.deleteBySourceAndTarget(
10588
+ storageFromObject,
10589
+ item.id,
10590
+ storageFromAttribute,
10591
+ recordId
10592
+ );
10593
+ }
10594
+ } else {
10595
+ await adapter.relationAttributes.deleteBySource(schema.name, recordId, attributeName);
10596
+ }
10597
+ }
10598
+ if (toUpsert.length > 0) {
10599
+ const inputs = toUpsert.map((item) => {
10600
+ if (shouldStoreAsInverse) {
10601
+ return {
10602
+ fromObject: storageFromObject,
10603
+ fromId: item.id,
10604
+ fromAttribute: storageFromAttribute,
10605
+ toId: recordId,
10606
+ properties: item.props ?? {},
10607
+ updatedBy: this.userId ?? void 0,
10608
+ createdBy: this.userId ?? void 0
10609
+ };
10610
+ }
10611
+ return {
10612
+ fromObject: schema.name,
10613
+ fromId: recordId,
10614
+ fromAttribute: attributeName,
10615
+ toId: item.id,
10616
+ properties: item.props ?? {},
10617
+ updatedBy: this.userId ?? void 0,
10618
+ createdBy: this.userId ?? void 0
10619
+ };
10620
+ });
10621
+ await adapter.relationAttributes.upsertBatch(inputs);
10622
+ }
10623
+ }
10624
+ /**
10625
+ * Validate relation properties against PropertySchema.
10626
+ *
10627
+ * Uses Zod for runtime validation based on PropertyAttribute types.
10628
+ *
10629
+ * @param propertySchema - Schema defining allowed properties
10630
+ * @param properties - Properties to validate
10631
+ * @throws {z.ZodError} if validation fails
10632
+ */
10633
+ validateProperties(propertySchema, properties) {
10634
+ const schema = this.buildZodSchema(propertySchema);
10635
+ schema.parse(properties);
10636
+ }
10637
+ // ============================================================================
10638
+ // PRIVATE HELPERS
10639
+ // ============================================================================
10640
+ /**
10641
+ * Get PropertySchema for a relation attribute.
10642
+ *
10643
+ * For bilateral relations without .qualifyWith(), returns undefined.
10644
+ * Properties will be stored/retrieved but not validated on the inverse side.
10645
+ */
10646
+ getPropertySchema(attribute) {
10647
+ return attribute.properties;
10648
+ }
10649
+ /**
10650
+ * Normalize relation value to unified internal format.
10651
+ *
10652
+ * Converts:
10653
+ * - string[] → Array<{ id, props?: undefined }>
10654
+ * - string → [{ id, props?: undefined }]
10655
+ * - null → []
10656
+ * - Array<{ id, props }> → Array<{ id, props }> (passthrough)
10657
+ * - { id, props } → [{ id, props }] (single to array)
10658
+ */
10659
+ normalizeRelationValue(value) {
10660
+ if (value === null || value === void 0) {
10661
+ return [];
10662
+ }
10663
+ if (typeof value === "string") {
10664
+ return [{ id: value }];
10665
+ }
10666
+ if (!Array.isArray(value) && typeof value === "object" && "id" in value) {
10667
+ return [value];
10668
+ }
10669
+ if (Array.isArray(value)) {
10670
+ return value.map((item) => {
10671
+ if (typeof item === "string") {
10672
+ return { id: item };
10673
+ }
10674
+ return item;
10675
+ });
10676
+ }
10677
+ return [];
10678
+ }
10679
+ /**
10680
+ * Build Zod schema from PropertySchema definition.
10681
+ *
10682
+ * Reuses createFormAttributeValidator() to avoid code duplication with validators.ts.
10683
+ * This validator handles null/undefined values correctly for optional fields.
10684
+ */
10685
+ buildZodSchema(propertySchema) {
10686
+ const shape = {};
10687
+ for (const def of propertySchema.definitions) {
10688
+ shape[def.name] = createFormAttributeValidator(def);
10689
+ }
10690
+ return z5.object(shape);
10691
+ }
10692
+ };
10693
+
10188
10694
  // src/runtime/services/record/query.service.ts
10189
10695
  var RecordQueryService = class extends BaseService {
10190
10696
  constructor(adapter, schemaService, options) {
@@ -10192,6 +10698,7 @@ var RecordQueryService = class extends BaseService {
10192
10698
  this.schemaService = schemaService;
10193
10699
  this.options = options;
10194
10700
  this.policyRegistry = options?.policyRegistry === null ? null : options?.policyRegistry ?? defaultPolicyRegistry;
10701
+ this.relationPropertiesService = new RelationPropertiesService(adapter);
10195
10702
  }
10196
10703
  // ============================================================================
10197
10704
  // LIST
@@ -10293,13 +10800,10 @@ var RecordQueryService = class extends BaseService {
10293
10800
  effectiveTotal = exhausted ? collected.length : Math.max(collected.length, result.total);
10294
10801
  filteredRecords = collected.slice(requestedOffset, requestedOffset + requestedLimit);
10295
10802
  }
10296
- if (options?.include && options.include.length > 0) {
10297
- filteredRecords = await this.includeRelationsWithProperties(
10298
- filteredRecords,
10299
- schema,
10300
- options.include
10301
- );
10302
- }
10803
+ filteredRecords = await this.relationPropertiesService.enrichRecordsBatch(
10804
+ filteredRecords,
10805
+ schema
10806
+ );
10303
10807
  if (!options?.skipFormulas) {
10304
10808
  return {
10305
10809
  records: enrichRecordsWithFormulas(filteredRecords, schema),
@@ -10367,86 +10871,20 @@ var RecordQueryService = class extends BaseService {
10367
10871
  [schema],
10368
10872
  () => this.adapter.objectRecords.search(objectId, query, options)
10369
10873
  );
10874
+ const enrichedRecords = await this.relationPropertiesService.enrichRecordsBatch(
10875
+ result.records,
10876
+ schema
10877
+ );
10370
10878
  if (!options?.skipFormulas) {
10371
10879
  return {
10372
- records: enrichRecordsWithFormulas(result.records, schema),
10880
+ records: enrichRecordsWithFormulas(enrichedRecords, schema),
10373
10881
  total: result.total
10374
10882
  };
10375
10883
  }
10376
- return result;
10377
- }
10378
- // ============================================================================
10379
- // INCLUDE RELATIONS WITH PROPERTIES
10380
- // ============================================================================
10381
- /**
10382
- * Include relation properties in records.
10383
- *
10384
- * For each requested relation attribute:
10385
- * - If attribute has properties → Fetch from relation_attributes and return hybrid format
10386
- * - If attribute has NO properties → Return legacy format (string[] or string)
10387
- *
10388
- * Uses batch loading to avoid N+1 queries.
10389
- *
10390
- * @param records - Records to enrich with relation properties
10391
- * @param schema - Object schema
10392
- * @param includes - Array of relation attribute names to include
10393
- * @returns Records enriched with relation properties in hybrid format
10394
- * @private
10395
- */
10396
- async includeRelationsWithProperties(records, schema, includes) {
10397
- if (records.length === 0 || includes.length === 0) {
10398
- return records;
10399
- }
10400
- for (const includeName of includes) {
10401
- const attr = schema.attributes.find((a) => a.name === includeName);
10402
- if (!attr || attr.type !== "relation") {
10403
- continue;
10404
- }
10405
- if (attr.properties && this.adapter.relationAttributes) {
10406
- const recordIds = records.map((r) => r.id);
10407
- const relationAttributesRepo = this.adapter.relationAttributes;
10408
- const allRelationProps = await Promise.all(
10409
- recordIds.map(
10410
- (recordId) => relationAttributesRepo.findBySource(schema.name, recordId, includeName)
10411
- )
10412
- );
10413
- const propsByRecord = /* @__PURE__ */ new Map();
10414
- allRelationProps.forEach((props, index) => {
10415
- const recordId = recordIds[index];
10416
- const propsMap = /* @__PURE__ */ new Map();
10417
- for (const prop of props) {
10418
- propsMap.set(prop.toId, prop.properties);
10419
- }
10420
- propsByRecord.set(recordId, propsMap);
10421
- });
10422
- for (const record of records) {
10423
- const currentValue = record.values[includeName];
10424
- const propsMap = propsByRecord.get(record.id);
10425
- if (!currentValue) {
10426
- continue;
10427
- }
10428
- if (!propsMap) {
10429
- continue;
10430
- }
10431
- if (attr.cardinality === "many" && Array.isArray(currentValue)) {
10432
- record.values[includeName] = currentValue.map((id) => {
10433
- if (typeof id === "string") {
10434
- const props = propsMap.get(id);
10435
- return props ? { id, props } : { id };
10436
- }
10437
- return id;
10438
- });
10439
- } else if (attr.cardinality === "one") {
10440
- const id = typeof currentValue === "string" ? currentValue : null;
10441
- if (id) {
10442
- const props = propsMap.get(id);
10443
- record.values[includeName] = props ? { id, props } : { id };
10444
- }
10445
- }
10446
- }
10447
- }
10448
- }
10449
- return records;
10884
+ return {
10885
+ records: enrichedRecords,
10886
+ total: result.total
10887
+ };
10450
10888
  }
10451
10889
  };
10452
10890
 
@@ -10542,280 +10980,6 @@ var RecordResolverService = class extends BaseService {
10542
10980
  }
10543
10981
  };
10544
10982
 
10545
- // src/runtime/services/record/relation-properties.service.ts
10546
- import { z as z5 } from "zod";
10547
- var RelationPropertiesService = class extends BaseService {
10548
- constructor(adapter) {
10549
- super(adapter);
10550
- }
10551
- // ============================================================================
10552
- // PUBLIC API
10553
- // ============================================================================
10554
- /**
10555
- * Normalize relation values for storage in object_records table.
10556
- *
10557
- * Extracts IDs from hybrid format ({ id, props }) and returns legacy format (string[] or string).
10558
- * This ensures object_records.values only contains IDs, while properties are in relation_attributes.
10559
- *
10560
- * @param schema - Object schema
10561
- * @param data - Record data with hybrid relation values
10562
- * @returns Data with relation values normalized to ID-only format
10563
- */
10564
- normalizeRelationValuesForStorage(schema, data) {
10565
- const normalized = { ...data };
10566
- for (const attr of schema.attributes) {
10567
- if (attr.type !== "relation" || !attr.properties) {
10568
- continue;
10569
- }
10570
- const value = data[attr.name];
10571
- if (value === null || value === void 0) {
10572
- continue;
10573
- }
10574
- if (attr.cardinality === "many" && Array.isArray(value)) {
10575
- normalized[attr.name] = value.map((item) => {
10576
- if (typeof item === "string") return item;
10577
- if (typeof item === "object" && item !== null && "id" in item) {
10578
- return item.id;
10579
- }
10580
- return item;
10581
- });
10582
- } else if (typeof value === "object" && value !== null && "id" in value) {
10583
- normalized[attr.name] = value.id;
10584
- }
10585
- }
10586
- return normalized;
10587
- }
10588
- /**
10589
- * Synchronize relation properties for a given attribute.
10590
- *
10591
- * Handles:
10592
- * - Format normalization (legacy → new)
10593
- * - Validation of properties
10594
- * - Upsert for present IDs
10595
- * - Delete for absent IDs
10596
- *
10597
- * @param schema - Object schema
10598
- * @param recordId - Source record ID
10599
- * @param attributeName - Relation attribute name
10600
- * @param relationValue - Relation value (hybrid format)
10601
- * @param adapter - Database adapter
10602
- */
10603
- async syncRelationProperties(schema, recordId, attributeName, relationValue, adapter) {
10604
- const attribute = schema.attributes.find((a) => a.name === attributeName);
10605
- if (!attribute || attribute.type !== "relation") {
10606
- return;
10607
- }
10608
- if (!attribute.properties) {
10609
- return;
10610
- }
10611
- const normalized = this.normalizeRelationValue(relationValue);
10612
- for (const item of normalized) {
10613
- if (item.props) {
10614
- this.validateProperties(attribute.properties, item.props);
10615
- }
10616
- }
10617
- const existing = await adapter.relationAttributes?.findBySource(
10618
- schema.name,
10619
- recordId,
10620
- attributeName
10621
- );
10622
- const existingIds = new Set((existing ?? []).map((r) => r.toId));
10623
- const newIds = new Set(normalized.map((item) => item.id));
10624
- const toUpsert = normalized.filter((item) => item.props !== void 0);
10625
- const toDelete = Array.from(existingIds).filter((id) => !newIds.has(id));
10626
- if (toUpsert.length > 0 && adapter.relationAttributes) {
10627
- const inputs = toUpsert.map((item) => ({
10628
- fromObject: schema.name,
10629
- fromId: recordId,
10630
- fromAttribute: attributeName,
10631
- toId: item.id,
10632
- properties: item.props ?? {},
10633
- updatedBy: this.userId ?? void 0,
10634
- createdBy: this.userId ?? void 0
10635
- }));
10636
- await adapter.relationAttributes.upsertBatch(inputs);
10637
- }
10638
- if (toDelete.length > 0 && adapter.relationAttributes && existing) {
10639
- for (const toId of toDelete) {
10640
- const relation2 = existing.find((r) => r.toId === toId);
10641
- if (relation2) {
10642
- }
10643
- }
10644
- await adapter.relationAttributes.deleteBySource(schema.name, recordId, attributeName);
10645
- if (toUpsert.length > 0) {
10646
- const inputs = toUpsert.map((item) => ({
10647
- fromObject: schema.name,
10648
- fromId: recordId,
10649
- fromAttribute: attributeName,
10650
- toId: item.id,
10651
- properties: item.props ?? {},
10652
- updatedBy: this.userId ?? void 0,
10653
- createdBy: this.userId ?? void 0
10654
- }));
10655
- await adapter.relationAttributes.upsertBatch(inputs);
10656
- }
10657
- }
10658
- }
10659
- /**
10660
- * Validate relation properties against PropertySchema.
10661
- *
10662
- * Uses Zod for runtime validation based on PropertyDefinition types.
10663
- *
10664
- * @param propertySchema - Schema defining allowed properties
10665
- * @param properties - Properties to validate
10666
- * @throws {z.ZodError} if validation fails
10667
- */
10668
- validateProperties(propertySchema, properties) {
10669
- const schema = this.buildZodSchema(propertySchema);
10670
- schema.parse(properties);
10671
- }
10672
- // ============================================================================
10673
- // PRIVATE HELPERS
10674
- // ============================================================================
10675
- /**
10676
- * Normalize relation value to unified internal format.
10677
- *
10678
- * Converts:
10679
- * - string[] → Array<{ id, props?: undefined }>
10680
- * - string → [{ id, props?: undefined }]
10681
- * - null → []
10682
- * - Array<{ id, props }> → Array<{ id, props }> (passthrough)
10683
- * - { id, props } → [{ id, props }] (single to array)
10684
- *
10685
- * @param value - Relation value in hybrid format
10686
- * @returns Normalized array of relation items
10687
- * @private
10688
- */
10689
- normalizeRelationValue(value) {
10690
- if (value === null || value === void 0) {
10691
- return [];
10692
- }
10693
- if (typeof value === "string") {
10694
- return [{ id: value }];
10695
- }
10696
- if (!Array.isArray(value) && typeof value === "object" && "id" in value) {
10697
- return [value];
10698
- }
10699
- if (Array.isArray(value)) {
10700
- return value.map((item) => {
10701
- if (typeof item === "string") {
10702
- return { id: item };
10703
- }
10704
- return item;
10705
- });
10706
- }
10707
- return [];
10708
- }
10709
- /**
10710
- * Build Zod schema from PropertySchema definition.
10711
- *
10712
- * Dynamically generates validation schema based on PropertyDefinition types.
10713
- *
10714
- * @param propertySchema - PropertySchema with definitions
10715
- * @returns Zod schema for validation
10716
- * @private
10717
- */
10718
- buildZodSchema(propertySchema) {
10719
- const shape = {};
10720
- for (const def of propertySchema.definitions) {
10721
- let fieldSchema = this.buildFieldSchema(def);
10722
- if (!def.required) {
10723
- fieldSchema = fieldSchema.optional();
10724
- }
10725
- shape[def.name] = fieldSchema;
10726
- }
10727
- return z5.object(shape);
10728
- }
10729
- /**
10730
- * Build Zod schema for a single property field.
10731
- *
10732
- * @param def - PropertyDefinition
10733
- * @returns Zod schema for the field
10734
- * @private
10735
- */
10736
- buildFieldSchema(def) {
10737
- switch (def.type) {
10738
- case "text":
10739
- case "textarea": {
10740
- let schema = z5.string();
10741
- if (def.minLength !== void 0) {
10742
- schema = schema.min(def.minLength);
10743
- }
10744
- if (def.maxLength !== void 0) {
10745
- schema = schema.max(def.maxLength);
10746
- }
10747
- if (def.type === "text" && def.pattern) {
10748
- schema = schema.regex(new RegExp(def.pattern));
10749
- }
10750
- return schema;
10751
- }
10752
- case "number": {
10753
- let schema = z5.number();
10754
- if (def.min !== void 0) {
10755
- schema = schema.min(def.min);
10756
- }
10757
- if (def.max !== void 0) {
10758
- schema = schema.max(def.max);
10759
- }
10760
- if (def.integer) {
10761
- schema = schema.int();
10762
- }
10763
- return schema;
10764
- }
10765
- case "checkbox": {
10766
- return z5.boolean();
10767
- }
10768
- case "date": {
10769
- const schema = z5.string().datetime();
10770
- return schema;
10771
- }
10772
- case "phone": {
10773
- return z5.string();
10774
- }
10775
- case "currency": {
10776
- let schema = z5.number();
10777
- if (def.min !== void 0) {
10778
- schema = schema.min(def.min);
10779
- }
10780
- if (def.max !== void 0) {
10781
- schema = schema.max(def.max);
10782
- }
10783
- return schema;
10784
- }
10785
- case "status":
10786
- case "select": {
10787
- const validValues = def.options.map((opt) => opt.value);
10788
- return z5.enum(validValues);
10789
- }
10790
- case "multiselect": {
10791
- const validValues = def.options.map((opt) => opt.value);
10792
- let schema = z5.array(z5.enum(validValues));
10793
- if (def.maxSelections !== void 0) {
10794
- schema = schema.max(def.maxSelections);
10795
- }
10796
- return schema;
10797
- }
10798
- case "rating": {
10799
- let schema = z5.number().int();
10800
- if (def.max !== void 0) {
10801
- schema = schema.max(def.max);
10802
- }
10803
- return schema.min(0);
10804
- }
10805
- case "location": {
10806
- return z5.object({
10807
- address: z5.string().optional(),
10808
- lat: z5.number().optional(),
10809
- lng: z5.number().optional()
10810
- });
10811
- }
10812
- default: {
10813
- return z5.unknown();
10814
- }
10815
- }
10816
- }
10817
- };
10818
-
10819
10983
  // src/runtime/services/record/relation.service.ts
10820
10984
  var RelationService = class extends BaseService {
10821
10985
  constructor(adapter, nativeRegistry, options) {
@@ -10823,6 +10987,7 @@ var RelationService = class extends BaseService {
10823
10987
  this.schemaService = new ObjectSchemaService(adapter, nativeRegistry);
10824
10988
  this.queryService = options.queryService;
10825
10989
  this.recordResolver = options.recordResolver;
10990
+ this.relationPropertiesService = options.relationPropertiesService;
10826
10991
  }
10827
10992
  /**
10828
10993
  * Set the query service after construction.
@@ -11190,20 +11355,33 @@ var RelationService = class extends BaseService {
11190
11355
  /**
11191
11356
  * Resolve the display label for a record.
11192
11357
  * Uses custom template if provided, otherwise falls back to pre-computed label.
11358
+ *
11359
+ * Preserves `{{ props.X }}` tokens for client-side substitution using a sentinel approach:
11360
+ * tokens are replaced with null-byte sentinels before template rendering, then restored after.
11361
+ * This lets `computeLabelWithRelations` resolve target fields while keeping prop placeholders intact.
11193
11362
  */
11194
11363
  async resolveLabel(record, objectSchema, customTemplate) {
11195
- if (customTemplate) {
11196
- return computeLabelWithRelations(
11197
- customTemplate,
11198
- record.values,
11199
- objectSchema.attributes,
11200
- async (nestedIds) => {
11201
- const linkedRecords = await this.recordResolver.findByIds(nestedIds);
11202
- return new Map(linkedRecords.map((r) => [r.id, r.label]));
11203
- }
11204
- );
11364
+ if (!customTemplate) return record.label;
11365
+ const preserved = [];
11366
+ let i = 0;
11367
+ const safeTemplate = customTemplate.replace(/\{\{\s*props\.\w+[^}]*\}\}/g, (match) => {
11368
+ const key = `\0PROP${i++}\0`;
11369
+ preserved.push([key, match]);
11370
+ return key;
11371
+ });
11372
+ let label = await computeLabelWithRelations(
11373
+ safeTemplate,
11374
+ record.values,
11375
+ objectSchema.attributes,
11376
+ async (nestedIds) => {
11377
+ const linkedRecords = await this.recordResolver.findByIds(nestedIds);
11378
+ return new Map(linkedRecords.map((r) => [r.id, r.label]));
11379
+ }
11380
+ );
11381
+ for (const [key, token] of preserved) {
11382
+ label = label.replace(key, token);
11205
11383
  }
11206
- return record.label;
11384
+ return label;
11207
11385
  }
11208
11386
  /**
11209
11387
  * Find a relation attribute by ID.
@@ -11650,6 +11828,11 @@ var RecordService = class extends BaseService {
11650
11828
  });
11651
11829
  this.userService = new UserService(adapter);
11652
11830
  this.hookRegistry = options?.hookRegistry ?? new NoopHookRegistry();
11831
+ this.bilateralSyncService = new BilateralSyncService(
11832
+ adapter,
11833
+ this.schemaService,
11834
+ this.relationPropertiesService
11835
+ );
11653
11836
  this.labelResolver = this.recordResolver.createLabelResolver(this.relationService);
11654
11837
  this.rollupContext = this.recordResolver.createRollupContext(
11655
11838
  this.rollupService,
@@ -11718,13 +11901,30 @@ var RecordService = class extends BaseService {
11718
11901
  });
11719
11902
  for (const [attrName, value] of Object.entries(dataWithDefaults)) {
11720
11903
  const attr = schema.attributes.find((a) => a.name === attrName);
11721
- if (attr?.type === "relation" && attr.properties) {
11722
- await this.relationPropertiesService.syncRelationProperties(
11904
+ if (attr?.type === "relation") {
11905
+ const hasProperties2 = attr.properties !== void 0;
11906
+ const isBilateral = isBilateralRelation(attr);
11907
+ if (hasProperties2 || isBilateral) {
11908
+ await this.relationPropertiesService.syncRelationProperties(
11909
+ schema,
11910
+ record.id,
11911
+ attrName,
11912
+ value,
11913
+ this.adapter
11914
+ );
11915
+ }
11916
+ }
11917
+ }
11918
+ for (const [attrName, value] of Object.entries(normalizedData)) {
11919
+ const attr = schema.attributes.find((a) => a.name === attrName);
11920
+ if (attr?.type === "relation" && isBilateralRelation(attr)) {
11921
+ await this.bilateralSyncService.syncBilateralRelation(
11723
11922
  schema,
11724
11923
  record.id,
11725
11924
  attrName,
11726
11925
  value,
11727
- this.adapter
11926
+ null
11927
+ // oldValue is null for create
11728
11928
  );
11729
11929
  }
11730
11930
  }
@@ -11747,11 +11947,7 @@ var RecordService = class extends BaseService {
11747
11947
  recordId: record.id,
11748
11948
  recordLabel: record.label,
11749
11949
  metadata: options?.hookMetadata
11750
- }).catch((err) => {
11751
- console.error(
11752
- "Audit log failed (record.created):",
11753
- err instanceof Error ? err.message : err
11754
- );
11950
+ }).catch(() => {
11755
11951
  });
11756
11952
  }
11757
11953
  return record;
@@ -11785,6 +11981,7 @@ var RecordService = class extends BaseService {
11785
11981
  if (!options?.skipFormulas) {
11786
11982
  enrichedRecord = enrichWithFormulas(record, schema);
11787
11983
  }
11984
+ enrichedRecord = await this.enrichRelationProperties(enrichedRecord, schema);
11788
11985
  if (options?.includeSchema) {
11789
11986
  const recordWithSchema = enrichedRecord;
11790
11987
  recordWithSchema.schema = schema;
@@ -11894,17 +12091,41 @@ var RecordService = class extends BaseService {
11894
12091
  );
11895
12092
  updatePayload.__metadata = cleanedMetadata;
11896
12093
  }
12094
+ const bilateralOldValues = {};
12095
+ for (const attrName of Object.keys(normalizedUpdate)) {
12096
+ const attr = schema.attributes.find((a) => a.name === attrName);
12097
+ if (attr?.type === "relation" && isBilateralRelation(attr)) {
12098
+ bilateralOldValues[attrName] = existing.values[attrName];
12099
+ }
12100
+ }
11897
12101
  const updated = await this.adapter.objectRecords.update(recordId, updatePayload);
11898
12102
  await this.invalidateRecordCaches(recordId, existing.objectId);
11899
12103
  for (const [attrName, value] of Object.entries(dataToUpdate)) {
11900
12104
  const attr = schema.attributes.find((a) => a.name === attrName);
11901
- if (attr?.type === "relation" && attr.properties) {
11902
- await this.relationPropertiesService.syncRelationProperties(
12105
+ if (attr?.type === "relation") {
12106
+ const hasProperties2 = attr.properties !== void 0;
12107
+ const isBilateral = isBilateralRelation(attr);
12108
+ if (hasProperties2 || isBilateral) {
12109
+ await this.relationPropertiesService.syncRelationProperties(
12110
+ schema,
12111
+ recordId,
12112
+ attrName,
12113
+ value,
12114
+ this.adapter
12115
+ );
12116
+ }
12117
+ }
12118
+ }
12119
+ for (const [attrName, value] of Object.entries(normalizedUpdate)) {
12120
+ const attr = schema.attributes.find((a) => a.name === attrName);
12121
+ if (attr?.type === "relation" && isBilateralRelation(attr)) {
12122
+ const oldValue = bilateralOldValues[attrName];
12123
+ await this.bilateralSyncService.syncBilateralRelation(
11903
12124
  schema,
11904
12125
  recordId,
11905
12126
  attrName,
11906
12127
  value,
11907
- this.adapter
12128
+ oldValue
11908
12129
  );
11909
12130
  }
11910
12131
  }
@@ -11935,11 +12156,7 @@ var RecordService = class extends BaseService {
11935
12156
  recordLabel: updated.label,
11936
12157
  changes,
11937
12158
  metadata: options?.hookMetadata
11938
- }).catch((err) => {
11939
- console.error(
11940
- "Audit log failed (record.updated):",
11941
- err instanceof Error ? err.message : err
11942
- );
12159
+ }).catch(() => {
11943
12160
  });
11944
12161
  }
11945
12162
  return updated;
@@ -11982,6 +12199,19 @@ var RecordService = class extends BaseService {
11982
12199
  if (!options?.skipHooks) {
11983
12200
  await this.hookRegistry.execute("beforeDelete", schema.name, hookCtx);
11984
12201
  }
12202
+ for (const attr of schema.attributes) {
12203
+ if (attr.type === "relation" && isBilateralRelation(attr)) {
12204
+ const currentValue = record.values[attr.name];
12205
+ await this.bilateralSyncService.syncBilateralRelation(
12206
+ schema,
12207
+ recordId,
12208
+ attr.name,
12209
+ null,
12210
+ // newValue is null
12211
+ currentValue
12212
+ );
12213
+ }
12214
+ }
11985
12215
  await this.adapter.objectRecords.delete(recordId);
11986
12216
  await this.invalidateRecordCaches(recordId, record.objectId);
11987
12217
  if (!options?.skipHooks) {
@@ -11997,11 +12227,7 @@ var RecordService = class extends BaseService {
11997
12227
  recordId: record.id,
11998
12228
  recordLabel: record.label,
11999
12229
  metadata: options?.hookMetadata
12000
- }).catch((err) => {
12001
- console.error(
12002
- "Audit log failed (record.deleted):",
12003
- err instanceof Error ? err.message : err
12004
- );
12230
+ }).catch(() => {
12005
12231
  });
12006
12232
  }
12007
12233
  }
@@ -12083,11 +12309,7 @@ var RecordService = class extends BaseService {
12083
12309
  recordId: restored.id,
12084
12310
  recordLabel: restored.label,
12085
12311
  metadata: options?.hookMetadata
12086
- }).catch((err) => {
12087
- console.error(
12088
- "Audit log failed (record.restored):",
12089
- err instanceof Error ? err.message : err
12090
- );
12312
+ }).catch(() => {
12091
12313
  });
12092
12314
  }
12093
12315
  return restored;
@@ -12095,6 +12317,60 @@ var RecordService = class extends BaseService {
12095
12317
  // ============================================================================
12096
12318
  // PRIVATE HELPERS
12097
12319
  // ============================================================================
12320
+ /**
12321
+ * Enrich relation attributes with their properties (for qualified relations).
12322
+ *
12323
+ * Transforms simple ID arrays into hybrid format { id, props } when properties exist.
12324
+ *
12325
+ * @param record - Record to enrich
12326
+ * @param schema - Object schema
12327
+ * @returns Enriched record with relation properties loaded
12328
+ * @private
12329
+ */
12330
+ async enrichRelationProperties(record, schema) {
12331
+ const enrichedValues = { ...record.values };
12332
+ for (const attr of schema.attributes) {
12333
+ if (attr.type !== "relation") {
12334
+ continue;
12335
+ }
12336
+ const hasProperties2 = attr.properties && attr.properties.definitions.length > 0;
12337
+ const isBilateral = isBilateralRelation(attr);
12338
+ const shouldEnrich = hasProperties2 || isBilateral;
12339
+ if (!shouldEnrich) {
12340
+ continue;
12341
+ }
12342
+ const value = record.values[attr.name];
12343
+ if (value === null || value === void 0) {
12344
+ continue;
12345
+ }
12346
+ const isMany = attr.cardinality === "many";
12347
+ const targetIds = isMany ? value : [value];
12348
+ if (!targetIds || targetIds.length === 0) {
12349
+ continue;
12350
+ }
12351
+ const propsMap = await this.relationPropertiesService.getRelationProperties(
12352
+ schema.name,
12353
+ record.id,
12354
+ attr.name,
12355
+ targetIds
12356
+ );
12357
+ if (isMany) {
12358
+ const hybridArray = targetIds.map((id) => {
12359
+ const props = propsMap.get(id);
12360
+ return props ? { id, props } : id;
12361
+ });
12362
+ enrichedValues[attr.name] = hybridArray;
12363
+ } else {
12364
+ const id = targetIds[0];
12365
+ const props = propsMap.get(id);
12366
+ enrichedValues[attr.name] = props ? { id, props } : id;
12367
+ }
12368
+ }
12369
+ return {
12370
+ ...record,
12371
+ values: enrichedValues
12372
+ };
12373
+ }
12098
12374
  /**
12099
12375
  * Invalidate all caches related to a record (record cache + lists + global search)
12100
12376
  * @private
@@ -17259,14 +17535,6 @@ var ViewService = class extends BaseService {
17259
17535
  type: "activity",
17260
17536
  order: 1
17261
17537
  });
17262
- tabs.push({
17263
- id: "notes",
17264
- name: "notes",
17265
- label: "Notes",
17266
- type: "notes",
17267
- order: 2,
17268
- allowCreate: true
17269
- });
17270
17538
  const hasDocuments = object2.attributes.some((attr) => attr.type === "document");
17271
17539
  if (hasDocuments) {
17272
17540
  tabs.push({
@@ -17991,6 +18259,8 @@ export {
17991
18259
  isPresentationProperty,
17992
18260
  RELATION_TARGET_ANY,
17993
18261
  isUniversalRelation,
18262
+ isBilateralRelation,
18263
+ inferInverseCardinality,
17994
18264
  RecordReferencedError,
17995
18265
  AttributeInUseError,
17996
18266
  ObjectReferencedError,
@@ -18076,6 +18346,14 @@ export {
18076
18346
  hasProperties,
18077
18347
  EMPTY_VALUE_PLACEHOLDER,
18078
18348
  formatAttributeValue,
18349
+ DEFAULT_LABEL_FALLBACK,
18350
+ renderLabelExpression,
18351
+ isLabelExpression,
18352
+ extractAttributeNames,
18353
+ enrichValuesForDisplay,
18354
+ enrichValuesWithSelectLabels,
18355
+ extractRelationIds,
18356
+ computeLabelWithRelations,
18079
18357
  SchemaErrorCode,
18080
18358
  SchemaError,
18081
18359
  NotFoundError,
@@ -18098,22 +18376,6 @@ export {
18098
18376
  RoleNotFoundError,
18099
18377
  isForbiddenError,
18100
18378
  ConcurrentModificationError,
18101
- PropertySchemaBuilder,
18102
- PropertyTypeBuilder,
18103
- BasePropertyBuilder,
18104
- TextPropertyBuilder,
18105
- TextareaPropertyBuilder,
18106
- NumberPropertyBuilder,
18107
- CheckboxPropertyBuilder,
18108
- DatePropertyBuilder,
18109
- PhonePropertyBuilder,
18110
- CurrencyPropertyBuilder,
18111
- StatusPropertyBuilder,
18112
- SelectPropertyBuilder,
18113
- MultiselectPropertyBuilder,
18114
- RatingPropertyBuilder,
18115
- LocationPropertyBuilder,
18116
- validatePropertyType,
18117
18379
  text,
18118
18380
  textarea,
18119
18381
  richtext,
@@ -18136,10 +18398,9 @@ export {
18136
18398
  ObjectBuilder,
18137
18399
  object,
18138
18400
  GroupBuilder,
18139
- DirectTableTabConfig,
18140
- InverseTableTabConfig,
18401
+ TableTabConfig,
18141
18402
  CustomTabConfig,
18142
- NotesTabConfig,
18403
+ RichtextTabConfig,
18143
18404
  ActivityTabConfig,
18144
18405
  FlowsTabConfig,
18145
18406
  DocumentsTabConfig,
@@ -18246,18 +18507,9 @@ export {
18246
18507
  resolveSingleValue,
18247
18508
  resolveMultiplePaths,
18248
18509
  NoopHookRegistry,
18249
- DEFAULT_LABEL_FALLBACK,
18250
- renderLabelExpression,
18251
- isLabelExpression,
18252
- extractAttributeNames,
18253
- enrichValuesForDisplay,
18254
- enrichValuesWithSelectLabels,
18255
- extractRelationIds,
18256
- computeLabelWithRelations,
18257
18510
  createMockAdapter,
18258
18511
  PolicyRegistry,
18259
18512
  defaultPolicyRegistry,
18260
- notesPolicy,
18261
18513
  BaseService,
18262
18514
  BaseRepository,
18263
18515
  SchemaContextAwareRepository,
@@ -18281,9 +18533,9 @@ export {
18281
18533
  createContextForDelete,
18282
18534
  createContextForRestore,
18283
18535
  recalculateParentRollups,
18536
+ RelationPropertiesService,
18284
18537
  RecordQueryService,
18285
18538
  RecordResolverService,
18286
- RelationPropertiesService,
18287
18539
  RelationService,
18288
18540
  RollupService,
18289
18541
  RecordService,