@stndrds/schema 0.1.0-alpha.63 → 0.1.0-alpha.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-MUJVBSBL.mjs → chunk-A6T4UUZ2.mjs} +1308 -970
- package/dist/{chunk-KMAKDHCL.js → chunk-TOTUS3YS.js} +1615 -1277
- package/dist/index.d.mts +240 -326
- package/dist/index.d.ts +240 -326
- package/dist/index.js +51 -64
- package/dist/index.mjs +61 -74
- package/dist/{runtime-7G9tZJYC.d.ts → runtime-BgtwoMx5.d.ts} +359 -213
- package/dist/{runtime-C4U2ppAk.d.mts → runtime-DQlTgVZJ.d.mts} +359 -213
- package/dist/runtime.d.mts +2 -2
- package/dist/runtime.d.ts +2 -2
- package/dist/runtime.js +2 -4
- package/dist/runtime.mjs +1 -3
- package/dist/validation/validators.d.mts +1 -1
- package/dist/validation/validators.d.ts +1 -1
- package/dist/{validators-BIAmz0CD.d.mts → validators-BROsLGn4.d.mts} +47 -122
- package/dist/{validators-BPIB7Miq.d.ts → validators-CEzdvxEq.d.ts} +47 -122
- package/package.json +2 -2
|
@@ -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
|
|
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
|
-
*
|
|
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: "
|
|
6501
|
-
* .to("companies")
|
|
6502
|
-
* .qualifyWith(
|
|
6503
|
-
*
|
|
6504
|
-
*
|
|
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(
|
|
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: "
|
|
6161
|
+
'.qualifyWith() must be called AFTER .to(). Example: relation({ name: "companies" }).to("companies").qualifyWith(...)'
|
|
6513
6162
|
);
|
|
6514
6163
|
}
|
|
6515
|
-
const
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
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
|
|
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
|
-
|
|
6676
|
-
|
|
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
|
-
|
|
6751
|
-
|
|
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,87 @@ var GroupBuilder = class {
|
|
|
7063
6707
|
return this.data;
|
|
7064
6708
|
}
|
|
7065
6709
|
};
|
|
7066
|
-
var
|
|
6710
|
+
var RelationGroupBuilder = class {
|
|
6711
|
+
constructor(id, label, attribute) {
|
|
6712
|
+
this.data = { type: "relation" };
|
|
6713
|
+
this.data.id = id;
|
|
6714
|
+
this.data.label = label;
|
|
6715
|
+
this.data.attribute = attribute;
|
|
6716
|
+
}
|
|
6717
|
+
/**
|
|
6718
|
+
* Set group description
|
|
6719
|
+
*/
|
|
6720
|
+
description(value) {
|
|
6721
|
+
this.data.description = value;
|
|
6722
|
+
return this;
|
|
6723
|
+
}
|
|
6724
|
+
/**
|
|
6725
|
+
* Make group collapsible
|
|
6726
|
+
* @param collapsed - Initial collapsed state (default: false)
|
|
6727
|
+
*/
|
|
6728
|
+
collapsible(collapsed = false) {
|
|
6729
|
+
this.data.collapsible = true;
|
|
6730
|
+
this.data.collapsed = collapsed;
|
|
6731
|
+
return this;
|
|
6732
|
+
}
|
|
6733
|
+
/**
|
|
6734
|
+
* Set display order
|
|
6735
|
+
*/
|
|
6736
|
+
order(value) {
|
|
6737
|
+
this.data.order = value;
|
|
6738
|
+
return this;
|
|
6739
|
+
}
|
|
6740
|
+
/**
|
|
6741
|
+
* Set columns to display in the relations table
|
|
6742
|
+
* @example .columns("name", "email", "phone")
|
|
6743
|
+
*/
|
|
6744
|
+
columns(...names) {
|
|
6745
|
+
this.data.columns = names;
|
|
6746
|
+
return this;
|
|
6747
|
+
}
|
|
6748
|
+
/**
|
|
6749
|
+
* Set the group as read-only (no add/remove/edit)
|
|
6750
|
+
*/
|
|
6751
|
+
readOnly(value = true) {
|
|
6752
|
+
this.data.readOnly = value;
|
|
6753
|
+
return this;
|
|
6754
|
+
}
|
|
6755
|
+
/**
|
|
6756
|
+
* Allow creating new related records inline
|
|
6757
|
+
*/
|
|
6758
|
+
allowCreate(value = true) {
|
|
6759
|
+
this.data.allowCreate = value;
|
|
6760
|
+
return this;
|
|
6761
|
+
}
|
|
6762
|
+
/**
|
|
6763
|
+
* Enable two-level traversal (through mode).
|
|
6764
|
+
* Parent rows become grouping headers; sub-rows from `attribute` are the primary display.
|
|
6765
|
+
*
|
|
6766
|
+
* @param attribute - Relation attribute on the first-level target object
|
|
6767
|
+
* @example .through("companies") — display companies linked via each member
|
|
6768
|
+
*/
|
|
6769
|
+
through(attribute) {
|
|
6770
|
+
this.data.through = { attribute };
|
|
6771
|
+
return this;
|
|
6772
|
+
}
|
|
6773
|
+
/**
|
|
6774
|
+
* Build the relation group definition
|
|
6775
|
+
*/
|
|
6776
|
+
build() {
|
|
6777
|
+
if (!this.data.id) {
|
|
6778
|
+
throw new Error("[RelationGroupBuilder] id is required");
|
|
6779
|
+
}
|
|
6780
|
+
if (!this.data.label) {
|
|
6781
|
+
throw new Error("[RelationGroupBuilder] label is required");
|
|
6782
|
+
}
|
|
6783
|
+
if (!this.data.attribute) {
|
|
6784
|
+
throw new Error("[RelationGroupBuilder] attribute is required");
|
|
6785
|
+
}
|
|
6786
|
+
return this.data;
|
|
6787
|
+
}
|
|
6788
|
+
};
|
|
6789
|
+
var TableTabConfig = class {
|
|
6790
|
+
/** @internal */
|
|
7067
6791
|
constructor(view2, tabData) {
|
|
7068
6792
|
this.view = view2;
|
|
7069
6793
|
this.tabData = tabData;
|
|
@@ -7141,6 +6865,29 @@ var BaseTableTabConfig = class {
|
|
|
7141
6865
|
this.tabData.sorts.push({ attribute, direction });
|
|
7142
6866
|
return this;
|
|
7143
6867
|
}
|
|
6868
|
+
/**
|
|
6869
|
+
* Traverse a 2nd-level relation to display nested data.
|
|
6870
|
+
* When active, `tab.columns` stores the 2nd-level object's attribute names.
|
|
6871
|
+
*
|
|
6872
|
+
* @param attribute - Relation attribute on the first-level target object
|
|
6873
|
+
* @example .table("members").through("companies").columns(["name", "sector"])
|
|
6874
|
+
*/
|
|
6875
|
+
through(attribute) {
|
|
6876
|
+
this.tabData.through = { attribute };
|
|
6877
|
+
return this;
|
|
6878
|
+
}
|
|
6879
|
+
/**
|
|
6880
|
+
* Show _source and _target columns in flattened (through) view
|
|
6881
|
+
*/
|
|
6882
|
+
showSourceTarget(value = true) {
|
|
6883
|
+
if (!this.tabData.through) {
|
|
6884
|
+
throw new Error(
|
|
6885
|
+
`[TableTabConfig] showSourceTarget() requires through() to be called first for tab "${this.tabData.name}"`
|
|
6886
|
+
);
|
|
6887
|
+
}
|
|
6888
|
+
this.tabData.through.showSourceTarget = value;
|
|
6889
|
+
return this;
|
|
6890
|
+
}
|
|
7144
6891
|
/**
|
|
7145
6892
|
* Continue building with a new tab
|
|
7146
6893
|
*/
|
|
@@ -7169,31 +6916,6 @@ var BaseTableTabConfig = class {
|
|
|
7169
6916
|
this.view._addTab(this.tabData);
|
|
7170
6917
|
}
|
|
7171
6918
|
};
|
|
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
6919
|
var CustomTabConfig = class {
|
|
7198
6920
|
/** @internal */
|
|
7199
6921
|
constructor(view2, base, component) {
|
|
@@ -7229,24 +6951,17 @@ var CustomTabConfig = class {
|
|
|
7229
6951
|
return this.view.build();
|
|
7230
6952
|
}
|
|
7231
6953
|
};
|
|
7232
|
-
var
|
|
6954
|
+
var RichtextTabConfig = class {
|
|
7233
6955
|
/** @internal */
|
|
7234
|
-
constructor(view2, base) {
|
|
6956
|
+
constructor(view2, base, attribute) {
|
|
7235
6957
|
this.view = view2;
|
|
7236
|
-
this.tabData = { ...base, type: "
|
|
7237
|
-
}
|
|
7238
|
-
/**
|
|
7239
|
-
* Show only private notes of the current user
|
|
7240
|
-
*/
|
|
7241
|
-
privateOnly() {
|
|
7242
|
-
this.tabData.privateOnly = true;
|
|
7243
|
-
return this;
|
|
6958
|
+
this.tabData = { ...base, type: "richtext", attribute };
|
|
7244
6959
|
}
|
|
7245
6960
|
/**
|
|
7246
|
-
*
|
|
6961
|
+
* Set a text attribute to display as an editable title above the editor
|
|
7247
6962
|
*/
|
|
7248
|
-
|
|
7249
|
-
this.tabData.
|
|
6963
|
+
titleAttribute(name) {
|
|
6964
|
+
this.tabData.titleAttribute = name;
|
|
7250
6965
|
return this;
|
|
7251
6966
|
}
|
|
7252
6967
|
/**
|
|
@@ -7469,7 +7184,7 @@ var TabBuilder = class {
|
|
|
7469
7184
|
// ─────────────────────────────────────────────────────────────────────────
|
|
7470
7185
|
/**
|
|
7471
7186
|
* Create a form tab with groups
|
|
7472
|
-
* @example .form(group("info", "Info").fields("name", "email"))
|
|
7187
|
+
* @example .form(group("info", "Info").fields("name", "email"), relationGroup("contacts", "Contacts", "contacts"))
|
|
7473
7188
|
*/
|
|
7474
7189
|
form(...groups) {
|
|
7475
7190
|
if (groups.length === 0) {
|
|
@@ -7480,32 +7195,44 @@ var TabBuilder = class {
|
|
|
7480
7195
|
const tab = {
|
|
7481
7196
|
...this.base,
|
|
7482
7197
|
type: "form",
|
|
7483
|
-
groups: groups.map(
|
|
7198
|
+
groups: groups.map(
|
|
7199
|
+
(g) => g instanceof GroupBuilder || g instanceof RelationGroupBuilder ? g.build() : g
|
|
7200
|
+
)
|
|
7484
7201
|
};
|
|
7485
7202
|
return this.view._addTab(tab);
|
|
7486
7203
|
}
|
|
7487
7204
|
/**
|
|
7488
|
-
* Create a
|
|
7205
|
+
* Create a table tab for a relation attribute on the current object
|
|
7489
7206
|
*
|
|
7490
7207
|
* Use this when the current object has a relation attribute pointing to another object.
|
|
7491
7208
|
*
|
|
7492
7209
|
* @param relationAttribute - Name of the relation attribute on the current object
|
|
7493
|
-
* @example .table("members").columns("name", "email").crud()
|
|
7210
|
+
* @example .table("members").columns("name", "email").crud()
|
|
7494
7211
|
*/
|
|
7495
7212
|
table(relationAttribute) {
|
|
7496
|
-
return new
|
|
7213
|
+
return new TableTabConfig(this.view, {
|
|
7214
|
+
...this.base,
|
|
7215
|
+
type: "table",
|
|
7216
|
+
source: { type: "relation", attribute: relationAttribute },
|
|
7217
|
+
columns: []
|
|
7218
|
+
});
|
|
7497
7219
|
}
|
|
7498
7220
|
/**
|
|
7499
|
-
* Create
|
|
7221
|
+
* Create a table tab showing records from another object that have a relation to us
|
|
7500
7222
|
*
|
|
7501
7223
|
* Use this when another object has a relation attribute pointing to the current object.
|
|
7502
7224
|
*
|
|
7503
7225
|
* @param sourceObject - Name of the object that has the relation to us
|
|
7504
7226
|
* @param relationAttribute - Name of the relation attribute on the source object
|
|
7505
|
-
* @example .tableFrom("contacts", "company").columns("firstName", "lastName")
|
|
7227
|
+
* @example .tableFrom("contacts", "company").columns("firstName", "lastName")
|
|
7506
7228
|
*/
|
|
7507
7229
|
tableFrom(sourceObject, relationAttribute) {
|
|
7508
|
-
return new
|
|
7230
|
+
return new TableTabConfig(this.view, {
|
|
7231
|
+
...this.base,
|
|
7232
|
+
type: "table",
|
|
7233
|
+
source: { type: "inverse", object: sourceObject, attribute: relationAttribute },
|
|
7234
|
+
columns: []
|
|
7235
|
+
});
|
|
7509
7236
|
}
|
|
7510
7237
|
/**
|
|
7511
7238
|
* Create a custom tab with a component
|
|
@@ -7515,11 +7242,11 @@ var TabBuilder = class {
|
|
|
7515
7242
|
return new CustomTabConfig(this.view, this.base, component);
|
|
7516
7243
|
}
|
|
7517
7244
|
/**
|
|
7518
|
-
* Create a
|
|
7519
|
-
* @example .
|
|
7245
|
+
* Create a richtext tab for a richtext attribute
|
|
7246
|
+
* @example .richtext("content").titleAttribute("title")
|
|
7520
7247
|
*/
|
|
7521
|
-
|
|
7522
|
-
return new
|
|
7248
|
+
richtext(attribute) {
|
|
7249
|
+
return new RichtextTabConfig(this.view, this.base, attribute);
|
|
7523
7250
|
}
|
|
7524
7251
|
/**
|
|
7525
7252
|
* Create an activity tab
|
|
@@ -7606,6 +7333,16 @@ var DetailViewBuilder = class {
|
|
|
7606
7333
|
this.data.metadata = value;
|
|
7607
7334
|
return this;
|
|
7608
7335
|
}
|
|
7336
|
+
/**
|
|
7337
|
+
* Configure a side panel with flat attribute fields displayed alongside tab content.
|
|
7338
|
+
* Not available for modal layout.
|
|
7339
|
+
*
|
|
7340
|
+
* @example .sidePanel({ attributes: ["visibility", "linkedTo"] })
|
|
7341
|
+
*/
|
|
7342
|
+
sidePanel(config) {
|
|
7343
|
+
this.data.sidePanel = config;
|
|
7344
|
+
return this;
|
|
7345
|
+
}
|
|
7609
7346
|
/**
|
|
7610
7347
|
* Start building a new tab
|
|
7611
7348
|
*/
|
|
@@ -7650,10 +7387,14 @@ var DetailViewBuilder = class {
|
|
|
7650
7387
|
if (this.data.tabs[0].type !== "form") {
|
|
7651
7388
|
throw new Error("[DetailViewBuilder] Modal views must have a form tab");
|
|
7652
7389
|
}
|
|
7390
|
+
if (this.data.sidePanel) {
|
|
7391
|
+
throw new Error("[DetailViewBuilder] Modal views cannot have a side panel");
|
|
7392
|
+
}
|
|
7653
7393
|
}
|
|
7654
7394
|
const config = {
|
|
7655
7395
|
layout: this.data.layout,
|
|
7656
|
-
tabs: this.data.tabs
|
|
7396
|
+
tabs: this.data.tabs,
|
|
7397
|
+
sidePanel: this.data.sidePanel
|
|
7657
7398
|
};
|
|
7658
7399
|
return {
|
|
7659
7400
|
name: this.data.name,
|
|
@@ -7988,6 +7729,9 @@ function listView(name, label) {
|
|
|
7988
7729
|
function group(id, label) {
|
|
7989
7730
|
return new GroupBuilder(id, label);
|
|
7990
7731
|
}
|
|
7732
|
+
function relationGroup(id, label, attribute) {
|
|
7733
|
+
return new RelationGroupBuilder(id, label, attribute);
|
|
7734
|
+
}
|
|
7991
7735
|
|
|
7992
7736
|
// src/builders/workflow-builder.ts
|
|
7993
7737
|
import { z as z4 } from "zod";
|
|
@@ -8648,12 +8392,125 @@ function buildAuditChanges(oldValues, newValues, fieldsToCheck) {
|
|
|
8648
8392
|
return changes;
|
|
8649
8393
|
}
|
|
8650
8394
|
|
|
8395
|
+
// src/runtime/services/bilateral/bilateral-validation.service.ts
|
|
8396
|
+
var BilateralValidationService = class extends BaseService {
|
|
8397
|
+
constructor(adapter, schemaService) {
|
|
8398
|
+
super(adapter);
|
|
8399
|
+
this.schemaService = schemaService;
|
|
8400
|
+
}
|
|
8401
|
+
/**
|
|
8402
|
+
* Validate a bilateral relation configuration.
|
|
8403
|
+
*
|
|
8404
|
+
* Performs comprehensive checks:
|
|
8405
|
+
* 1. Verifies target object exists
|
|
8406
|
+
* 2. Verifies inverse attribute exists on target object
|
|
8407
|
+
* 3. Verifies inverse attribute targets the source object
|
|
8408
|
+
* 4. Detects invalid circular bilateral declarations
|
|
8409
|
+
*
|
|
8410
|
+
* @param sourceSchema - Schema containing the relation attribute
|
|
8411
|
+
* @param sourceAttr - Relation attribute to validate
|
|
8412
|
+
* @returns Validation result with errors if any
|
|
8413
|
+
*/
|
|
8414
|
+
async validateBilateralRelation(sourceSchema, sourceAttr) {
|
|
8415
|
+
const errors = [];
|
|
8416
|
+
if (!(isBilateralRelation(sourceAttr) && sourceAttr.bilateral)) {
|
|
8417
|
+
return { valid: true, errors: [] };
|
|
8418
|
+
}
|
|
8419
|
+
const { object: targetObjectName, attribute: inverseAttrName } = sourceAttr.bilateral;
|
|
8420
|
+
let targetSchema = null;
|
|
8421
|
+
try {
|
|
8422
|
+
targetSchema = await this.schemaService.getObjectSchemaByName(targetObjectName);
|
|
8423
|
+
} catch {
|
|
8424
|
+
targetSchema = null;
|
|
8425
|
+
}
|
|
8426
|
+
if (!targetSchema) {
|
|
8427
|
+
errors.push({
|
|
8428
|
+
code: "INVERSE_ATTR_NOT_FOUND",
|
|
8429
|
+
message: `Target object "${targetObjectName}" not found`,
|
|
8430
|
+
context: {
|
|
8431
|
+
sourceObject: sourceSchema.name,
|
|
8432
|
+
sourceAttribute: sourceAttr.name,
|
|
8433
|
+
targetObject: targetObjectName,
|
|
8434
|
+
targetAttribute: inverseAttrName
|
|
8435
|
+
}
|
|
8436
|
+
});
|
|
8437
|
+
return { valid: false, errors };
|
|
8438
|
+
}
|
|
8439
|
+
const inverseAttr = targetSchema.attributes.find(
|
|
8440
|
+
(a) => a.name === inverseAttrName && a.type === "relation"
|
|
8441
|
+
);
|
|
8442
|
+
if (!inverseAttr) {
|
|
8443
|
+
errors.push({
|
|
8444
|
+
code: "INVERSE_ATTR_NOT_FOUND",
|
|
8445
|
+
message: `Inverse attribute "${inverseAttrName}" not found on "${targetObjectName}"`,
|
|
8446
|
+
context: {
|
|
8447
|
+
sourceObject: sourceSchema.name,
|
|
8448
|
+
sourceAttribute: sourceAttr.name,
|
|
8449
|
+
targetObject: targetObjectName,
|
|
8450
|
+
targetAttribute: inverseAttrName
|
|
8451
|
+
}
|
|
8452
|
+
});
|
|
8453
|
+
return { valid: false, errors };
|
|
8454
|
+
}
|
|
8455
|
+
const inverseTargetsSource = inverseAttr.targets.some(
|
|
8456
|
+
(target) => target.object === sourceSchema.name
|
|
8457
|
+
);
|
|
8458
|
+
if (!inverseTargetsSource) {
|
|
8459
|
+
errors.push({
|
|
8460
|
+
code: "INVERSE_TARGET_MISMATCH",
|
|
8461
|
+
message: `Inverse attribute doesn't target source object`,
|
|
8462
|
+
context: {
|
|
8463
|
+
sourceObject: sourceSchema.name,
|
|
8464
|
+
sourceAttribute: sourceAttr.name,
|
|
8465
|
+
targetObject: targetObjectName,
|
|
8466
|
+
targetAttribute: inverseAttrName
|
|
8467
|
+
}
|
|
8468
|
+
});
|
|
8469
|
+
}
|
|
8470
|
+
if (isBilateralRelation(inverseAttr)) {
|
|
8471
|
+
const inverseBilateral = inverseAttr.bilateral;
|
|
8472
|
+
if (inverseBilateral.object === sourceSchema.name && inverseBilateral.attribute !== sourceAttr.name) {
|
|
8473
|
+
errors.push({
|
|
8474
|
+
code: "CIRCULAR_BILATERAL",
|
|
8475
|
+
message: "Both sides declare bilateral but point to different attributes",
|
|
8476
|
+
context: {
|
|
8477
|
+
sourceObject: sourceSchema.name,
|
|
8478
|
+
sourceAttribute: sourceAttr.name,
|
|
8479
|
+
targetObject: targetObjectName,
|
|
8480
|
+
targetAttribute: inverseAttrName
|
|
8481
|
+
}
|
|
8482
|
+
});
|
|
8483
|
+
}
|
|
8484
|
+
}
|
|
8485
|
+
return { valid: errors.length === 0, errors };
|
|
8486
|
+
}
|
|
8487
|
+
/**
|
|
8488
|
+
* Validate all bilateral relations in a schema.
|
|
8489
|
+
*
|
|
8490
|
+
* Iterates through all relation attributes and validates each bilateral configuration.
|
|
8491
|
+
*
|
|
8492
|
+
* @param schema - Object schema to validate
|
|
8493
|
+
* @returns Combined validation result with all errors
|
|
8494
|
+
*/
|
|
8495
|
+
async validateAllBilateralRelations(schema) {
|
|
8496
|
+
const allErrors = [];
|
|
8497
|
+
for (const attr of schema.attributes) {
|
|
8498
|
+
if (attr.type === "relation" && isBilateralRelation(attr)) {
|
|
8499
|
+
const result = await this.validateBilateralRelation(schema, attr);
|
|
8500
|
+
allErrors.push(...result.errors);
|
|
8501
|
+
}
|
|
8502
|
+
}
|
|
8503
|
+
return { valid: allErrors.length === 0, errors: allErrors };
|
|
8504
|
+
}
|
|
8505
|
+
};
|
|
8506
|
+
|
|
8651
8507
|
// src/runtime/services/schema/object-schema.service.ts
|
|
8652
8508
|
var ObjectSchemaService = class extends BaseService {
|
|
8653
8509
|
constructor(adapter, nativeRegistry, options) {
|
|
8654
8510
|
super(adapter);
|
|
8655
8511
|
this.nativeRegistry = nativeRegistry;
|
|
8656
8512
|
this.auditService = options?.auditService;
|
|
8513
|
+
this.bilateralValidationService = new BilateralValidationService(adapter, this);
|
|
8657
8514
|
}
|
|
8658
8515
|
/**
|
|
8659
8516
|
* Create a new custom object.
|
|
@@ -8677,6 +8534,15 @@ var ObjectSchemaService = class extends BaseService {
|
|
|
8677
8534
|
builder.attributes(definition.attributes);
|
|
8678
8535
|
}
|
|
8679
8536
|
const objectDef = builder.build();
|
|
8537
|
+
const bilateralValidation = await this.bilateralValidationService.validateAllBilateralRelations(objectDef);
|
|
8538
|
+
if (!bilateralValidation.valid) {
|
|
8539
|
+
const errorMessages = bilateralValidation.errors.map((e) => `${e.code}: ${e.message}`).join("; ");
|
|
8540
|
+
throw new SchemaError(
|
|
8541
|
+
`Bilateral relation validation failed: ${errorMessages}`,
|
|
8542
|
+
SchemaErrorCode.VALIDATION_FAILED,
|
|
8543
|
+
{ errors: bilateralValidation.errors }
|
|
8544
|
+
);
|
|
8545
|
+
}
|
|
8680
8546
|
const existing = await this.adapter.objects.findByName(objectDef.name);
|
|
8681
8547
|
if (existing) {
|
|
8682
8548
|
throw new Error(`Object with name "${objectDef.name}" already exists`);
|
|
@@ -8766,6 +8632,28 @@ var ObjectSchemaService = class extends BaseService {
|
|
|
8766
8632
|
);
|
|
8767
8633
|
}
|
|
8768
8634
|
const config = await this.validateAttributeInput(attribute);
|
|
8635
|
+
if (attribute.type === "relation" && attribute.bilateral) {
|
|
8636
|
+
const tempSchema = await this.getObjectSchema(objectId);
|
|
8637
|
+
const tempAttr = {
|
|
8638
|
+
...attribute,
|
|
8639
|
+
id: "temp-id",
|
|
8640
|
+
// Temporary ID for validation
|
|
8641
|
+
system: false,
|
|
8642
|
+
config
|
|
8643
|
+
};
|
|
8644
|
+
const validation = await this.bilateralValidationService.validateBilateralRelation(
|
|
8645
|
+
tempSchema,
|
|
8646
|
+
tempAttr
|
|
8647
|
+
);
|
|
8648
|
+
if (!validation.valid) {
|
|
8649
|
+
const errorMessages = validation.errors.map((e) => `${e.code}: ${e.message}`).join("; ");
|
|
8650
|
+
throw new SchemaError(
|
|
8651
|
+
`Bilateral relation validation failed: ${errorMessages}`,
|
|
8652
|
+
SchemaErrorCode.VALIDATION_FAILED,
|
|
8653
|
+
{ errors: validation.errors }
|
|
8654
|
+
);
|
|
8655
|
+
}
|
|
8656
|
+
}
|
|
8769
8657
|
const dbAttr = await this.adapter.attributes.create({
|
|
8770
8658
|
objectId,
|
|
8771
8659
|
name: attribute.name,
|
|
@@ -9190,18 +9078,70 @@ Reserved names: ${RESERVED_ATTRIBUTE_NAMES.join(", ")}`
|
|
|
9190
9078
|
async buildObjectDefinition(dbObject) {
|
|
9191
9079
|
const dbAttributes = await this.adapter.attributes.findByObjectId(dbObject.id);
|
|
9192
9080
|
const baseDef = dbObject.system ? this.mergeNativeObject(dbObject, dbAttributes) : this.convertDBObjectToDefinition(dbObject, dbAttributes);
|
|
9193
|
-
|
|
9081
|
+
const enriched = await this.enrichBilateralProperties(baseDef);
|
|
9082
|
+
return this.withSystemAttributes(enriched);
|
|
9194
9083
|
}
|
|
9195
9084
|
/**
|
|
9196
|
-
*
|
|
9197
|
-
*
|
|
9085
|
+
* Enrich bilateral relation attributes that don't own property definitions.
|
|
9086
|
+
* Copies `properties` from the canonical side (the one with `.qualifyWith()`)
|
|
9087
|
+
* and sets `storageOwner: false` so the storage layer knows direction.
|
|
9088
|
+
*
|
|
9089
|
+
* Uses direct DB lookups to avoid circular recursion through `getObjectSchema`.
|
|
9198
9090
|
* @internal
|
|
9199
9091
|
*/
|
|
9200
|
-
|
|
9201
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
9204
|
-
|
|
9092
|
+
async enrichBilateralProperties(def) {
|
|
9093
|
+
const toEnrich = def.attributes.filter(
|
|
9094
|
+
(attr) => attr.type === "relation" && !!attr.bilateral && !attr.properties
|
|
9095
|
+
);
|
|
9096
|
+
if (toEnrich.length === 0) return def;
|
|
9097
|
+
const enrichedMap = /* @__PURE__ */ new Map();
|
|
9098
|
+
for (const attr of toEnrich) {
|
|
9099
|
+
const { bilateral } = attr;
|
|
9100
|
+
if (!bilateral) continue;
|
|
9101
|
+
const inverseObject = await this.adapter.objects.findByName(bilateral.object);
|
|
9102
|
+
if (!inverseObject) continue;
|
|
9103
|
+
const inverseDbAttrs = await this.adapter.attributes.findByObjectId(inverseObject.id);
|
|
9104
|
+
const inverseDbAttr = inverseDbAttrs.find((a) => a.name === bilateral.attribute);
|
|
9105
|
+
if (!inverseDbAttr) continue;
|
|
9106
|
+
let properties = inverseDbAttr.config.properties;
|
|
9107
|
+
if (!properties) {
|
|
9108
|
+
const nativeObj = this.nativeRegistry.getByName(bilateral.object);
|
|
9109
|
+
const nativeAttr = nativeObj?.attributes.find((a) => a.name === bilateral.attribute);
|
|
9110
|
+
if (nativeAttr && "properties" in nativeAttr) {
|
|
9111
|
+
properties = nativeAttr.properties;
|
|
9112
|
+
}
|
|
9113
|
+
}
|
|
9114
|
+
if (properties) {
|
|
9115
|
+
enrichedMap.set(attr.name, {
|
|
9116
|
+
properties,
|
|
9117
|
+
bilateral: { ...bilateral, storageOwner: false }
|
|
9118
|
+
});
|
|
9119
|
+
}
|
|
9120
|
+
}
|
|
9121
|
+
if (enrichedMap.size === 0) return def;
|
|
9122
|
+
return {
|
|
9123
|
+
...def,
|
|
9124
|
+
attributes: def.attributes.map((attr) => {
|
|
9125
|
+
const enrichment = enrichedMap.get(attr.name);
|
|
9126
|
+
if (!enrichment) return attr;
|
|
9127
|
+
return {
|
|
9128
|
+
...attr,
|
|
9129
|
+
properties: enrichment.properties,
|
|
9130
|
+
bilateral: enrichment.bilateral
|
|
9131
|
+
};
|
|
9132
|
+
})
|
|
9133
|
+
};
|
|
9134
|
+
}
|
|
9135
|
+
/**
|
|
9136
|
+
* Append system attributes to an ObjectDefinition
|
|
9137
|
+
* System attributes are always available on all records (createdAt, updatedAt, createdBy, lastUpdatedBy)
|
|
9138
|
+
* @internal
|
|
9139
|
+
*/
|
|
9140
|
+
withSystemAttributes(def) {
|
|
9141
|
+
return {
|
|
9142
|
+
...def,
|
|
9143
|
+
attributes: [...def.attributes, ...getSystemAttributeList()]
|
|
9144
|
+
};
|
|
9205
9145
|
}
|
|
9206
9146
|
/**
|
|
9207
9147
|
* Merge native object from registry with custom attributes from DB
|
|
@@ -9785,6 +9725,303 @@ var AuditService = class extends BaseService {
|
|
|
9785
9725
|
}
|
|
9786
9726
|
};
|
|
9787
9727
|
|
|
9728
|
+
// src/runtime/services/bilateral/bilateral-sync.service.ts
|
|
9729
|
+
var browserStub4 = {
|
|
9730
|
+
getStore: () => void 0,
|
|
9731
|
+
run: (_store, callback) => callback()
|
|
9732
|
+
};
|
|
9733
|
+
var AsyncLocalStorageClass4 = null;
|
|
9734
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
9735
|
+
try {
|
|
9736
|
+
if (typeof __require !== "undefined") {
|
|
9737
|
+
const asyncHooks = __require("async_hooks");
|
|
9738
|
+
AsyncLocalStorageClass4 = asyncHooks.AsyncLocalStorage;
|
|
9739
|
+
}
|
|
9740
|
+
} catch {
|
|
9741
|
+
try {
|
|
9742
|
+
const dynamicRequire = new Function(
|
|
9743
|
+
"m",
|
|
9744
|
+
'return typeof require!=="undefined"?require(m):null'
|
|
9745
|
+
);
|
|
9746
|
+
const asyncHooks = dynamicRequire("node:async_hooks");
|
|
9747
|
+
if (asyncHooks) {
|
|
9748
|
+
AsyncLocalStorageClass4 = asyncHooks.AsyncLocalStorage;
|
|
9749
|
+
}
|
|
9750
|
+
} catch {
|
|
9751
|
+
}
|
|
9752
|
+
}
|
|
9753
|
+
}
|
|
9754
|
+
var bilateralSyncContext = null;
|
|
9755
|
+
function getSyncContext() {
|
|
9756
|
+
if (bilateralSyncContext !== null) {
|
|
9757
|
+
return bilateralSyncContext;
|
|
9758
|
+
}
|
|
9759
|
+
if (AsyncLocalStorageClass4) {
|
|
9760
|
+
bilateralSyncContext = new AsyncLocalStorageClass4();
|
|
9761
|
+
return bilateralSyncContext;
|
|
9762
|
+
}
|
|
9763
|
+
bilateralSyncContext = browserStub4;
|
|
9764
|
+
return bilateralSyncContext;
|
|
9765
|
+
}
|
|
9766
|
+
var BilateralSyncService = class extends BaseService {
|
|
9767
|
+
constructor(adapter, schemaService, relationPropertiesService) {
|
|
9768
|
+
super(adapter);
|
|
9769
|
+
this.schemaService = schemaService;
|
|
9770
|
+
this.relationPropertiesService = relationPropertiesService;
|
|
9771
|
+
}
|
|
9772
|
+
// ============================================================================
|
|
9773
|
+
// PUBLIC API
|
|
9774
|
+
// ============================================================================
|
|
9775
|
+
/**
|
|
9776
|
+
* Synchronize a bilateral relation after modification.
|
|
9777
|
+
*
|
|
9778
|
+
* @param sourceSchema - Schema of the object containing the relation
|
|
9779
|
+
* @param sourceRecordId - ID of the record being modified
|
|
9780
|
+
* @param attributeName - Name of the relation attribute
|
|
9781
|
+
* @param newValue - New value (ID, array of IDs, or hybrid format with properties)
|
|
9782
|
+
* @param oldValue - Old value (ID, array of IDs, or hybrid format with properties)
|
|
9783
|
+
*/
|
|
9784
|
+
async syncBilateralRelation(sourceSchema, sourceRecordId, attributeName, newValue, oldValue) {
|
|
9785
|
+
const attribute = sourceSchema.attributes.find(
|
|
9786
|
+
(a) => a.name === attributeName && a.type === "relation"
|
|
9787
|
+
);
|
|
9788
|
+
if (!(attribute && isBilateralRelation(attribute))) {
|
|
9789
|
+
return;
|
|
9790
|
+
}
|
|
9791
|
+
const ctx = getSyncContext().getStore();
|
|
9792
|
+
const syncKey = `${sourceSchema.name}:${sourceRecordId}:${attributeName}`;
|
|
9793
|
+
if (ctx?.syncing.has(syncKey)) {
|
|
9794
|
+
return;
|
|
9795
|
+
}
|
|
9796
|
+
await this.runWithSyncContext(syncKey, async () => {
|
|
9797
|
+
await this.performBilateralSync(sourceSchema, sourceRecordId, attribute, newValue, oldValue);
|
|
9798
|
+
});
|
|
9799
|
+
}
|
|
9800
|
+
// ============================================================================
|
|
9801
|
+
// PRIVATE METHODS
|
|
9802
|
+
// ============================================================================
|
|
9803
|
+
/**
|
|
9804
|
+
* Perform the bidirectional synchronization.
|
|
9805
|
+
* @private
|
|
9806
|
+
*/
|
|
9807
|
+
async performBilateralSync(sourceSchema, sourceRecordId, sourceAttr, newValue, oldValue) {
|
|
9808
|
+
const bilateral = sourceAttr.bilateral;
|
|
9809
|
+
const targetSchema = await this.schemaService.getObjectSchemaByName(bilateral.object);
|
|
9810
|
+
if (!targetSchema) {
|
|
9811
|
+
throw new Error(`Target object "${bilateral.object}" not found`);
|
|
9812
|
+
}
|
|
9813
|
+
const inverseAttr = targetSchema.attributes.find(
|
|
9814
|
+
(a) => a.name === bilateral.attribute && a.type === "relation"
|
|
9815
|
+
);
|
|
9816
|
+
if (!inverseAttr) {
|
|
9817
|
+
throw new Error(
|
|
9818
|
+
`Inverse attribute "${bilateral.attribute}" not found on "${bilateral.object}"`
|
|
9819
|
+
);
|
|
9820
|
+
}
|
|
9821
|
+
const newData = this.extractRelationData(newValue);
|
|
9822
|
+
const oldData = this.extractRelationData(oldValue);
|
|
9823
|
+
const addedIds = newData.ids.filter((id) => !oldData.ids.includes(id));
|
|
9824
|
+
const removedIds = oldData.ids.filter((id) => !newData.ids.includes(id));
|
|
9825
|
+
const commonIds = newData.ids.filter((id) => oldData.ids.includes(id));
|
|
9826
|
+
await Promise.all([
|
|
9827
|
+
// Add new relations
|
|
9828
|
+
...addedIds.map(
|
|
9829
|
+
(targetId) => this.addInverseRelation(
|
|
9830
|
+
targetId,
|
|
9831
|
+
inverseAttr,
|
|
9832
|
+
sourceRecordId,
|
|
9833
|
+
sourceSchema.name,
|
|
9834
|
+
sourceAttr.name,
|
|
9835
|
+
newData.properties.get(targetId)
|
|
9836
|
+
)
|
|
9837
|
+
),
|
|
9838
|
+
// Remove deleted relations
|
|
9839
|
+
...removedIds.map(
|
|
9840
|
+
(targetId) => this.removeInverseRelation(targetId, inverseAttr, sourceRecordId)
|
|
9841
|
+
),
|
|
9842
|
+
// Update properties for common IDs
|
|
9843
|
+
...commonIds.map(
|
|
9844
|
+
(targetId) => this.updateInverseRelationProperties(
|
|
9845
|
+
targetId,
|
|
9846
|
+
inverseAttr,
|
|
9847
|
+
sourceRecordId,
|
|
9848
|
+
sourceSchema.name,
|
|
9849
|
+
sourceAttr.name,
|
|
9850
|
+
newData.properties.get(targetId),
|
|
9851
|
+
oldData.properties.get(targetId)
|
|
9852
|
+
)
|
|
9853
|
+
)
|
|
9854
|
+
]);
|
|
9855
|
+
}
|
|
9856
|
+
/**
|
|
9857
|
+
* Extract IDs and properties from hybrid relation value.
|
|
9858
|
+
* @private
|
|
9859
|
+
*/
|
|
9860
|
+
extractRelationData(value) {
|
|
9861
|
+
const ids = [];
|
|
9862
|
+
const properties = /* @__PURE__ */ new Map();
|
|
9863
|
+
if (value === null || value === void 0) {
|
|
9864
|
+
return { ids, properties };
|
|
9865
|
+
}
|
|
9866
|
+
if (typeof value === "string") {
|
|
9867
|
+
ids.push(value);
|
|
9868
|
+
return { ids, properties };
|
|
9869
|
+
}
|
|
9870
|
+
if (!Array.isArray(value) && typeof value === "object" && "id" in value) {
|
|
9871
|
+
ids.push(value.id);
|
|
9872
|
+
if (value.props) {
|
|
9873
|
+
properties.set(value.id, value.props);
|
|
9874
|
+
}
|
|
9875
|
+
return { ids, properties };
|
|
9876
|
+
}
|
|
9877
|
+
if (Array.isArray(value)) {
|
|
9878
|
+
for (const item of value) {
|
|
9879
|
+
if (typeof item === "string") {
|
|
9880
|
+
ids.push(item);
|
|
9881
|
+
} else if (typeof item === "object" && item !== null && "id" in item) {
|
|
9882
|
+
ids.push(item.id);
|
|
9883
|
+
if (item.props) {
|
|
9884
|
+
properties.set(item.id, item.props);
|
|
9885
|
+
}
|
|
9886
|
+
}
|
|
9887
|
+
}
|
|
9888
|
+
}
|
|
9889
|
+
return { ids, properties };
|
|
9890
|
+
}
|
|
9891
|
+
/**
|
|
9892
|
+
* Add an ID to an inverse relation (with properties).
|
|
9893
|
+
* @private
|
|
9894
|
+
*/
|
|
9895
|
+
async addInverseRelation(targetRecordId, inverseAttr, sourceRecordId, sourceObject, sourceAttribute, properties) {
|
|
9896
|
+
const targetRecord = await this.adapter.objectRecords.findById(targetRecordId);
|
|
9897
|
+
if (!targetRecord) {
|
|
9898
|
+
return;
|
|
9899
|
+
}
|
|
9900
|
+
const currentValue = targetRecord.values[inverseAttr.name];
|
|
9901
|
+
let newValue;
|
|
9902
|
+
if (inverseAttr.cardinality === "one") {
|
|
9903
|
+
newValue = sourceRecordId;
|
|
9904
|
+
} else {
|
|
9905
|
+
const currentArray = this.normalizeToArray(currentValue);
|
|
9906
|
+
if (currentArray.includes(sourceRecordId)) {
|
|
9907
|
+
return;
|
|
9908
|
+
}
|
|
9909
|
+
newValue = [...currentArray, sourceRecordId];
|
|
9910
|
+
}
|
|
9911
|
+
await this.adapter.objectRecords.update(targetRecordId, {
|
|
9912
|
+
[inverseAttr.name]: newValue
|
|
9913
|
+
});
|
|
9914
|
+
await this.invalidateTargetRecordCaches(targetRecordId, targetRecord.objectId);
|
|
9915
|
+
if (properties && Object.keys(properties).length > 0 && this.adapter.relationAttributes) {
|
|
9916
|
+
const sourceSchema = await this.schemaService.getObjectSchemaByName(sourceObject);
|
|
9917
|
+
if (sourceSchema) {
|
|
9918
|
+
await this.relationPropertiesService.syncRelationProperties(
|
|
9919
|
+
sourceSchema,
|
|
9920
|
+
sourceRecordId,
|
|
9921
|
+
sourceAttribute,
|
|
9922
|
+
[{ id: targetRecordId, props: properties }],
|
|
9923
|
+
this.adapter
|
|
9924
|
+
);
|
|
9925
|
+
}
|
|
9926
|
+
}
|
|
9927
|
+
}
|
|
9928
|
+
/**
|
|
9929
|
+
* Update properties of an existing inverse relation.
|
|
9930
|
+
* @private
|
|
9931
|
+
*/
|
|
9932
|
+
async updateInverseRelationProperties(targetRecordId, _inverseAttr, sourceRecordId, sourceObject, sourceAttribute, newProperties, oldProperties) {
|
|
9933
|
+
if (JSON.stringify(newProperties) === JSON.stringify(oldProperties)) {
|
|
9934
|
+
return;
|
|
9935
|
+
}
|
|
9936
|
+
if (!this.adapter.relationAttributes) {
|
|
9937
|
+
return;
|
|
9938
|
+
}
|
|
9939
|
+
const sourceSchema = await this.schemaService.getObjectSchemaByName(sourceObject);
|
|
9940
|
+
if (!sourceSchema) {
|
|
9941
|
+
return;
|
|
9942
|
+
}
|
|
9943
|
+
if (newProperties && Object.keys(newProperties).length > 0) {
|
|
9944
|
+
await this.relationPropertiesService.syncRelationProperties(
|
|
9945
|
+
sourceSchema,
|
|
9946
|
+
sourceRecordId,
|
|
9947
|
+
sourceAttribute,
|
|
9948
|
+
[{ id: targetRecordId, props: newProperties }],
|
|
9949
|
+
this.adapter
|
|
9950
|
+
);
|
|
9951
|
+
} else {
|
|
9952
|
+
await this.adapter.relationAttributes.deleteBySource(
|
|
9953
|
+
sourceObject,
|
|
9954
|
+
sourceRecordId,
|
|
9955
|
+
sourceAttribute
|
|
9956
|
+
);
|
|
9957
|
+
}
|
|
9958
|
+
}
|
|
9959
|
+
/**
|
|
9960
|
+
* Remove an ID from an inverse relation.
|
|
9961
|
+
* @private
|
|
9962
|
+
*/
|
|
9963
|
+
async removeInverseRelation(targetRecordId, inverseAttr, sourceRecordId) {
|
|
9964
|
+
const targetRecord = await this.adapter.objectRecords.findById(targetRecordId);
|
|
9965
|
+
if (!targetRecord) {
|
|
9966
|
+
return;
|
|
9967
|
+
}
|
|
9968
|
+
const currentValue = targetRecord.values[inverseAttr.name];
|
|
9969
|
+
let newValue;
|
|
9970
|
+
if (inverseAttr.cardinality === "one") {
|
|
9971
|
+
if (currentValue === sourceRecordId) {
|
|
9972
|
+
newValue = null;
|
|
9973
|
+
} else {
|
|
9974
|
+
return;
|
|
9975
|
+
}
|
|
9976
|
+
} else {
|
|
9977
|
+
const currentArray = this.normalizeToArray(currentValue);
|
|
9978
|
+
newValue = currentArray.filter((id) => id !== sourceRecordId);
|
|
9979
|
+
if (newValue.length === currentArray.length) {
|
|
9980
|
+
return;
|
|
9981
|
+
}
|
|
9982
|
+
}
|
|
9983
|
+
await this.adapter.objectRecords.update(targetRecordId, {
|
|
9984
|
+
[inverseAttr.name]: newValue
|
|
9985
|
+
});
|
|
9986
|
+
await this.invalidateTargetRecordCaches(targetRecordId, targetRecord.objectId);
|
|
9987
|
+
}
|
|
9988
|
+
/**
|
|
9989
|
+
* Normalize a relation value to an array of IDs.
|
|
9990
|
+
* @private
|
|
9991
|
+
*/
|
|
9992
|
+
normalizeToArray(value) {
|
|
9993
|
+
if (value === null || value === void 0) return [];
|
|
9994
|
+
if (typeof value === "string") return [value];
|
|
9995
|
+
if (Array.isArray(value)) return value;
|
|
9996
|
+
return [];
|
|
9997
|
+
}
|
|
9998
|
+
/**
|
|
9999
|
+
* Invalidate caches for a target record after bilateral update.
|
|
10000
|
+
* Mirrors RecordService.invalidateRecordCaches to ensure consistency.
|
|
10001
|
+
* @private
|
|
10002
|
+
*/
|
|
10003
|
+
async invalidateTargetRecordCaches(recordId, objectId) {
|
|
10004
|
+
await this.invalidateCache(cacheKeys.record(this.tenantId, recordId));
|
|
10005
|
+
await this.invalidateLists("allRecordLists", objectId);
|
|
10006
|
+
await this.invalidateLists("allSearchResults", objectId);
|
|
10007
|
+
await this.invalidateCachePattern(cacheKeys.allGlobalSearch(this.tenantId));
|
|
10008
|
+
await this.invalidateCachePattern(cacheKeys.resolvedRelationsByRecord(this.tenantId, recordId));
|
|
10009
|
+
}
|
|
10010
|
+
/**
|
|
10011
|
+
* Execute a function with sync context.
|
|
10012
|
+
* @private
|
|
10013
|
+
*/
|
|
10014
|
+
async runWithSyncContext(syncKey, fn) {
|
|
10015
|
+
const storage = getSyncContext();
|
|
10016
|
+
const existingCtx = storage.getStore();
|
|
10017
|
+
const ctx = {
|
|
10018
|
+
syncing: new Set(existingCtx?.syncing ?? [])
|
|
10019
|
+
};
|
|
10020
|
+
ctx.syncing.add(syncKey);
|
|
10021
|
+
return await storage.run(ctx, fn);
|
|
10022
|
+
}
|
|
10023
|
+
};
|
|
10024
|
+
|
|
9788
10025
|
// src/runtime/services/user/user.service.ts
|
|
9789
10026
|
var UserService = class extends BaseService {
|
|
9790
10027
|
constructor(adapter) {
|
|
@@ -10185,6 +10422,359 @@ async function recalculateParentRollups(record, schema, ctx) {
|
|
|
10185
10422
|
}
|
|
10186
10423
|
}
|
|
10187
10424
|
|
|
10425
|
+
// src/runtime/services/record/relation-properties.service.ts
|
|
10426
|
+
import { z as z5 } from "zod";
|
|
10427
|
+
var RelationPropertiesService = class extends BaseService {
|
|
10428
|
+
constructor(adapter) {
|
|
10429
|
+
super(adapter);
|
|
10430
|
+
}
|
|
10431
|
+
// ============================================================================
|
|
10432
|
+
// PUBLIC API
|
|
10433
|
+
// ============================================================================
|
|
10434
|
+
/**
|
|
10435
|
+
* Get relation properties for a given attribute.
|
|
10436
|
+
*
|
|
10437
|
+
* Supports bidirectional relations: searches for properties in both directions
|
|
10438
|
+
* (forward: from_object/from_id → to_id, and inverse: to_id → from_id).
|
|
10439
|
+
*
|
|
10440
|
+
* This ensures that qualified properties are SHARED between both directions
|
|
10441
|
+
* of a bilateral relation, as they are stored in a single row in relation_attributes.
|
|
10442
|
+
*
|
|
10443
|
+
* @param objectName - Source object name
|
|
10444
|
+
* @param recordId - Source record ID
|
|
10445
|
+
* @param attributeName - Relation attribute name
|
|
10446
|
+
* @param targetIds - Array of target record IDs
|
|
10447
|
+
* @returns Map of target ID → properties
|
|
10448
|
+
*
|
|
10449
|
+
* @example
|
|
10450
|
+
* ```typescript
|
|
10451
|
+
* // Properties stored as: contacts/A/companies → X with { role: "CEO" }
|
|
10452
|
+
*
|
|
10453
|
+
* // Read from Contact A → Company X
|
|
10454
|
+
* const propsFromContact = await service.getRelationProperties(
|
|
10455
|
+
* "contacts", "A", "companies", ["X"]
|
|
10456
|
+
* );
|
|
10457
|
+
* // → Map { "X" => { role: "CEO" } }
|
|
10458
|
+
*
|
|
10459
|
+
* // Read from Company X → Contact A (inverse)
|
|
10460
|
+
* const propsFromCompany = await service.getRelationProperties(
|
|
10461
|
+
* "companies", "X", "contacts", ["A"]
|
|
10462
|
+
* );
|
|
10463
|
+
* // → Map { "A" => { role: "CEO" } } (same properties!)
|
|
10464
|
+
* ```
|
|
10465
|
+
*/
|
|
10466
|
+
async getRelationProperties(objectName, recordId, attributeName, targetIds) {
|
|
10467
|
+
const properties = /* @__PURE__ */ new Map();
|
|
10468
|
+
if (!this.adapter.relationAttributes) {
|
|
10469
|
+
return properties;
|
|
10470
|
+
}
|
|
10471
|
+
const forwardProps = await this.adapter.relationAttributes.findBySource(
|
|
10472
|
+
objectName,
|
|
10473
|
+
recordId,
|
|
10474
|
+
attributeName
|
|
10475
|
+
);
|
|
10476
|
+
for (const prop of forwardProps) {
|
|
10477
|
+
if (targetIds.includes(prop.toId)) {
|
|
10478
|
+
properties.set(prop.toId, prop.properties);
|
|
10479
|
+
}
|
|
10480
|
+
}
|
|
10481
|
+
const inverseProps = await this.adapter.relationAttributes.findByTarget(recordId);
|
|
10482
|
+
for (const prop of inverseProps) {
|
|
10483
|
+
if (targetIds.includes(prop.fromId) && !properties.has(prop.fromId)) {
|
|
10484
|
+
properties.set(prop.fromId, prop.properties);
|
|
10485
|
+
}
|
|
10486
|
+
}
|
|
10487
|
+
return properties;
|
|
10488
|
+
}
|
|
10489
|
+
/**
|
|
10490
|
+
* Batch enrich records with qualified relation properties.
|
|
10491
|
+
*
|
|
10492
|
+
* Detects qualified attributes in the schema and fetches their properties
|
|
10493
|
+
* using batch queries (1 query per qualified attribute, not per record).
|
|
10494
|
+
* Returns records with values in hybrid format `{ id, props }`.
|
|
10495
|
+
*
|
|
10496
|
+
* For bilateral relations, also checks the inverse direction.
|
|
10497
|
+
*
|
|
10498
|
+
* @param records - Records to enrich
|
|
10499
|
+
* @param schema - Object schema
|
|
10500
|
+
* @returns Records with relation values enriched with properties
|
|
10501
|
+
*/
|
|
10502
|
+
async enrichRecordsBatch(records, schema) {
|
|
10503
|
+
if (!this.adapter.relationAttributes) return records;
|
|
10504
|
+
if (records.length === 0) return records;
|
|
10505
|
+
const qualifiedAttrs = schema.attributes.filter(
|
|
10506
|
+
(a) => a.type === "relation" && (!!a.properties || !!a.bilateral)
|
|
10507
|
+
);
|
|
10508
|
+
if (qualifiedAttrs.length === 0) return records;
|
|
10509
|
+
const recordIds = records.map((r) => r.id);
|
|
10510
|
+
const relationAttrsRepo = this.adapter.relationAttributes;
|
|
10511
|
+
await Promise.all(
|
|
10512
|
+
qualifiedAttrs.map(async (attr) => {
|
|
10513
|
+
const forwardRows = await relationAttrsRepo.findBySourceBatch(
|
|
10514
|
+
schema.name,
|
|
10515
|
+
recordIds,
|
|
10516
|
+
attr.name
|
|
10517
|
+
);
|
|
10518
|
+
const inverseRows = attr.bilateral ? await relationAttrsRepo.findByTargetBatch(recordIds) : [];
|
|
10519
|
+
const byRecord = /* @__PURE__ */ new Map();
|
|
10520
|
+
for (const row of forwardRows) {
|
|
10521
|
+
let recordMap = byRecord.get(row.fromId);
|
|
10522
|
+
if (!recordMap) {
|
|
10523
|
+
recordMap = /* @__PURE__ */ new Map();
|
|
10524
|
+
byRecord.set(row.fromId, recordMap);
|
|
10525
|
+
}
|
|
10526
|
+
recordMap.set(row.toId, row.properties);
|
|
10527
|
+
}
|
|
10528
|
+
for (const row of inverseRows) {
|
|
10529
|
+
let recordMap = byRecord.get(row.toId);
|
|
10530
|
+
if (!recordMap) {
|
|
10531
|
+
recordMap = /* @__PURE__ */ new Map();
|
|
10532
|
+
byRecord.set(row.toId, recordMap);
|
|
10533
|
+
}
|
|
10534
|
+
if (!recordMap.has(row.fromId)) {
|
|
10535
|
+
recordMap.set(row.fromId, row.properties);
|
|
10536
|
+
}
|
|
10537
|
+
}
|
|
10538
|
+
for (const record of records) {
|
|
10539
|
+
const propsForRecord = byRecord.get(record.id);
|
|
10540
|
+
if (!propsForRecord) continue;
|
|
10541
|
+
const value = record.values[attr.name];
|
|
10542
|
+
if (Array.isArray(value)) {
|
|
10543
|
+
record.values = {
|
|
10544
|
+
...record.values,
|
|
10545
|
+
[attr.name]: value.map((id) => {
|
|
10546
|
+
const props = propsForRecord.get(id);
|
|
10547
|
+
return props ? { id, props } : id;
|
|
10548
|
+
})
|
|
10549
|
+
};
|
|
10550
|
+
} else if (typeof value === "string") {
|
|
10551
|
+
const props = propsForRecord.get(value);
|
|
10552
|
+
if (props) {
|
|
10553
|
+
record.values = {
|
|
10554
|
+
...record.values,
|
|
10555
|
+
[attr.name]: { id: value, props }
|
|
10556
|
+
};
|
|
10557
|
+
}
|
|
10558
|
+
}
|
|
10559
|
+
}
|
|
10560
|
+
})
|
|
10561
|
+
);
|
|
10562
|
+
return records;
|
|
10563
|
+
}
|
|
10564
|
+
/**
|
|
10565
|
+
* Normalize relation values for storage in object_records table.
|
|
10566
|
+
*
|
|
10567
|
+
* Extracts IDs from hybrid format ({ id, props }) and returns legacy format (string[] or string).
|
|
10568
|
+
* This ensures object_records.values only contains IDs, while properties are in relation_attributes.
|
|
10569
|
+
*
|
|
10570
|
+
* @param schema - Object schema
|
|
10571
|
+
* @param data - Record data with hybrid relation values
|
|
10572
|
+
* @returns Data with relation values normalized to ID-only format
|
|
10573
|
+
*/
|
|
10574
|
+
normalizeRelationValuesForStorage(schema, data) {
|
|
10575
|
+
const normalized = { ...data };
|
|
10576
|
+
for (const attr of schema.attributes) {
|
|
10577
|
+
if (attr.type !== "relation") {
|
|
10578
|
+
continue;
|
|
10579
|
+
}
|
|
10580
|
+
const value = data[attr.name];
|
|
10581
|
+
if (value === null || value === void 0) {
|
|
10582
|
+
continue;
|
|
10583
|
+
}
|
|
10584
|
+
if (attr.cardinality === "many" && Array.isArray(value)) {
|
|
10585
|
+
normalized[attr.name] = value.map((item) => {
|
|
10586
|
+
if (typeof item === "string") return item;
|
|
10587
|
+
if (typeof item === "object" && item !== null && "id" in item) {
|
|
10588
|
+
return item.id;
|
|
10589
|
+
}
|
|
10590
|
+
return item;
|
|
10591
|
+
});
|
|
10592
|
+
} else if (typeof value === "object" && value !== null && "id" in value) {
|
|
10593
|
+
normalized[attr.name] = value.id;
|
|
10594
|
+
}
|
|
10595
|
+
}
|
|
10596
|
+
return normalized;
|
|
10597
|
+
}
|
|
10598
|
+
/**
|
|
10599
|
+
* Synchronize relation properties for a given attribute.
|
|
10600
|
+
*
|
|
10601
|
+
* Handles:
|
|
10602
|
+
* - Format normalization (legacy → new)
|
|
10603
|
+
* - Validation of properties
|
|
10604
|
+
* - Upsert for present IDs
|
|
10605
|
+
* - Delete for absent IDs
|
|
10606
|
+
*
|
|
10607
|
+
* @param schema - Object schema
|
|
10608
|
+
* @param recordId - Source record ID
|
|
10609
|
+
* @param attributeName - Relation attribute name
|
|
10610
|
+
* @param relationValue - Relation value (hybrid format)
|
|
10611
|
+
* @param adapter - Database adapter
|
|
10612
|
+
*/
|
|
10613
|
+
async syncRelationProperties(schema, recordId, attributeName, relationValue, adapter) {
|
|
10614
|
+
const attribute = schema.attributes.find((a) => a.name === attributeName);
|
|
10615
|
+
if (!attribute || attribute.type !== "relation") {
|
|
10616
|
+
return;
|
|
10617
|
+
}
|
|
10618
|
+
const normalized = this.normalizeRelationValue(relationValue);
|
|
10619
|
+
const propertySchema = this.getPropertySchema(attribute);
|
|
10620
|
+
const hasPropsInInput = normalized.some((item) => item.props !== void 0);
|
|
10621
|
+
const hasSchema = Boolean(propertySchema);
|
|
10622
|
+
const canSync = hasSchema || hasPropsInInput;
|
|
10623
|
+
if (!canSync) {
|
|
10624
|
+
return;
|
|
10625
|
+
}
|
|
10626
|
+
if (propertySchema) {
|
|
10627
|
+
for (const item of normalized) {
|
|
10628
|
+
if (item.props && Object.keys(item.props).length > 0) {
|
|
10629
|
+
this.validateProperties(propertySchema, item.props);
|
|
10630
|
+
}
|
|
10631
|
+
}
|
|
10632
|
+
}
|
|
10633
|
+
const shouldStoreAsInverse = attribute.bilateral?.storageOwner === false;
|
|
10634
|
+
let storageFromObject = schema.name;
|
|
10635
|
+
let storageFromAttribute = attributeName;
|
|
10636
|
+
if (shouldStoreAsInverse && attribute.bilateral) {
|
|
10637
|
+
storageFromObject = attribute.bilateral.object;
|
|
10638
|
+
storageFromAttribute = attribute.bilateral.attribute;
|
|
10639
|
+
}
|
|
10640
|
+
let existing;
|
|
10641
|
+
if (adapter.relationAttributes) {
|
|
10642
|
+
if (shouldStoreAsInverse) {
|
|
10643
|
+
const results = await Promise.all(
|
|
10644
|
+
normalized.map(
|
|
10645
|
+
(item) => adapter.relationAttributes?.findBySource(
|
|
10646
|
+
storageFromObject,
|
|
10647
|
+
item.id,
|
|
10648
|
+
storageFromAttribute
|
|
10649
|
+
)
|
|
10650
|
+
)
|
|
10651
|
+
);
|
|
10652
|
+
existing = results.filter((r) => r !== void 0).flat().filter((r) => r.toId === recordId);
|
|
10653
|
+
} else {
|
|
10654
|
+
existing = await adapter.relationAttributes.findBySource(
|
|
10655
|
+
schema.name,
|
|
10656
|
+
recordId,
|
|
10657
|
+
attributeName
|
|
10658
|
+
);
|
|
10659
|
+
}
|
|
10660
|
+
}
|
|
10661
|
+
const hasChanges = existing && existing.length > 0;
|
|
10662
|
+
const toUpsert = normalized.filter((item) => {
|
|
10663
|
+
return item.props !== void 0 && Object.keys(item.props).length > 0;
|
|
10664
|
+
});
|
|
10665
|
+
if (!adapter.relationAttributes) {
|
|
10666
|
+
return;
|
|
10667
|
+
}
|
|
10668
|
+
if (hasChanges && existing) {
|
|
10669
|
+
if (shouldStoreAsInverse) {
|
|
10670
|
+
for (const item of normalized) {
|
|
10671
|
+
await adapter.relationAttributes.deleteBySourceAndTarget(
|
|
10672
|
+
storageFromObject,
|
|
10673
|
+
item.id,
|
|
10674
|
+
storageFromAttribute,
|
|
10675
|
+
recordId
|
|
10676
|
+
);
|
|
10677
|
+
}
|
|
10678
|
+
} else {
|
|
10679
|
+
await adapter.relationAttributes.deleteBySource(schema.name, recordId, attributeName);
|
|
10680
|
+
}
|
|
10681
|
+
}
|
|
10682
|
+
if (toUpsert.length > 0) {
|
|
10683
|
+
const inputs = toUpsert.map((item) => {
|
|
10684
|
+
if (shouldStoreAsInverse) {
|
|
10685
|
+
return {
|
|
10686
|
+
fromObject: storageFromObject,
|
|
10687
|
+
fromId: item.id,
|
|
10688
|
+
fromAttribute: storageFromAttribute,
|
|
10689
|
+
toId: recordId,
|
|
10690
|
+
properties: item.props ?? {},
|
|
10691
|
+
updatedBy: this.userId ?? void 0,
|
|
10692
|
+
createdBy: this.userId ?? void 0
|
|
10693
|
+
};
|
|
10694
|
+
}
|
|
10695
|
+
return {
|
|
10696
|
+
fromObject: schema.name,
|
|
10697
|
+
fromId: recordId,
|
|
10698
|
+
fromAttribute: attributeName,
|
|
10699
|
+
toId: item.id,
|
|
10700
|
+
properties: item.props ?? {},
|
|
10701
|
+
updatedBy: this.userId ?? void 0,
|
|
10702
|
+
createdBy: this.userId ?? void 0
|
|
10703
|
+
};
|
|
10704
|
+
});
|
|
10705
|
+
await adapter.relationAttributes.upsertBatch(inputs);
|
|
10706
|
+
}
|
|
10707
|
+
}
|
|
10708
|
+
/**
|
|
10709
|
+
* Validate relation properties against PropertySchema.
|
|
10710
|
+
*
|
|
10711
|
+
* Uses Zod for runtime validation based on PropertyAttribute types.
|
|
10712
|
+
*
|
|
10713
|
+
* @param propertySchema - Schema defining allowed properties
|
|
10714
|
+
* @param properties - Properties to validate
|
|
10715
|
+
* @throws {z.ZodError} if validation fails
|
|
10716
|
+
*/
|
|
10717
|
+
validateProperties(propertySchema, properties) {
|
|
10718
|
+
const schema = this.buildZodSchema(propertySchema);
|
|
10719
|
+
schema.parse(properties);
|
|
10720
|
+
}
|
|
10721
|
+
// ============================================================================
|
|
10722
|
+
// PRIVATE HELPERS
|
|
10723
|
+
// ============================================================================
|
|
10724
|
+
/**
|
|
10725
|
+
* Get PropertySchema for a relation attribute.
|
|
10726
|
+
*
|
|
10727
|
+
* For bilateral relations without .qualifyWith(), returns undefined.
|
|
10728
|
+
* Properties will be stored/retrieved but not validated on the inverse side.
|
|
10729
|
+
*/
|
|
10730
|
+
getPropertySchema(attribute) {
|
|
10731
|
+
return attribute.properties;
|
|
10732
|
+
}
|
|
10733
|
+
/**
|
|
10734
|
+
* Normalize relation value to unified internal format.
|
|
10735
|
+
*
|
|
10736
|
+
* Converts:
|
|
10737
|
+
* - string[] → Array<{ id, props?: undefined }>
|
|
10738
|
+
* - string → [{ id, props?: undefined }]
|
|
10739
|
+
* - null → []
|
|
10740
|
+
* - Array<{ id, props }> → Array<{ id, props }> (passthrough)
|
|
10741
|
+
* - { id, props } → [{ id, props }] (single to array)
|
|
10742
|
+
*/
|
|
10743
|
+
normalizeRelationValue(value) {
|
|
10744
|
+
if (value === null || value === void 0) {
|
|
10745
|
+
return [];
|
|
10746
|
+
}
|
|
10747
|
+
if (typeof value === "string") {
|
|
10748
|
+
return [{ id: value }];
|
|
10749
|
+
}
|
|
10750
|
+
if (!Array.isArray(value) && typeof value === "object" && "id" in value) {
|
|
10751
|
+
return [value];
|
|
10752
|
+
}
|
|
10753
|
+
if (Array.isArray(value)) {
|
|
10754
|
+
return value.map((item) => {
|
|
10755
|
+
if (typeof item === "string") {
|
|
10756
|
+
return { id: item };
|
|
10757
|
+
}
|
|
10758
|
+
return item;
|
|
10759
|
+
});
|
|
10760
|
+
}
|
|
10761
|
+
return [];
|
|
10762
|
+
}
|
|
10763
|
+
/**
|
|
10764
|
+
* Build Zod schema from PropertySchema definition.
|
|
10765
|
+
*
|
|
10766
|
+
* Reuses createFormAttributeValidator() to avoid code duplication with validators.ts.
|
|
10767
|
+
* This validator handles null/undefined values correctly for optional fields.
|
|
10768
|
+
*/
|
|
10769
|
+
buildZodSchema(propertySchema) {
|
|
10770
|
+
const shape = {};
|
|
10771
|
+
for (const def of propertySchema.definitions) {
|
|
10772
|
+
shape[def.name] = createFormAttributeValidator(def);
|
|
10773
|
+
}
|
|
10774
|
+
return z5.object(shape);
|
|
10775
|
+
}
|
|
10776
|
+
};
|
|
10777
|
+
|
|
10188
10778
|
// src/runtime/services/record/query.service.ts
|
|
10189
10779
|
var RecordQueryService = class extends BaseService {
|
|
10190
10780
|
constructor(adapter, schemaService, options) {
|
|
@@ -10192,6 +10782,7 @@ var RecordQueryService = class extends BaseService {
|
|
|
10192
10782
|
this.schemaService = schemaService;
|
|
10193
10783
|
this.options = options;
|
|
10194
10784
|
this.policyRegistry = options?.policyRegistry === null ? null : options?.policyRegistry ?? defaultPolicyRegistry;
|
|
10785
|
+
this.relationPropertiesService = new RelationPropertiesService(adapter);
|
|
10195
10786
|
}
|
|
10196
10787
|
// ============================================================================
|
|
10197
10788
|
// LIST
|
|
@@ -10293,13 +10884,10 @@ var RecordQueryService = class extends BaseService {
|
|
|
10293
10884
|
effectiveTotal = exhausted ? collected.length : Math.max(collected.length, result.total);
|
|
10294
10885
|
filteredRecords = collected.slice(requestedOffset, requestedOffset + requestedLimit);
|
|
10295
10886
|
}
|
|
10296
|
-
|
|
10297
|
-
filteredRecords
|
|
10298
|
-
|
|
10299
|
-
|
|
10300
|
-
options.include
|
|
10301
|
-
);
|
|
10302
|
-
}
|
|
10887
|
+
filteredRecords = await this.relationPropertiesService.enrichRecordsBatch(
|
|
10888
|
+
filteredRecords,
|
|
10889
|
+
schema
|
|
10890
|
+
);
|
|
10303
10891
|
if (!options?.skipFormulas) {
|
|
10304
10892
|
return {
|
|
10305
10893
|
records: enrichRecordsWithFormulas(filteredRecords, schema),
|
|
@@ -10367,86 +10955,20 @@ var RecordQueryService = class extends BaseService {
|
|
|
10367
10955
|
[schema],
|
|
10368
10956
|
() => this.adapter.objectRecords.search(objectId, query, options)
|
|
10369
10957
|
);
|
|
10958
|
+
const enrichedRecords = await this.relationPropertiesService.enrichRecordsBatch(
|
|
10959
|
+
result.records,
|
|
10960
|
+
schema
|
|
10961
|
+
);
|
|
10370
10962
|
if (!options?.skipFormulas) {
|
|
10371
10963
|
return {
|
|
10372
|
-
records: enrichRecordsWithFormulas(
|
|
10964
|
+
records: enrichRecordsWithFormulas(enrichedRecords, schema),
|
|
10373
10965
|
total: result.total
|
|
10374
10966
|
};
|
|
10375
10967
|
}
|
|
10376
|
-
return
|
|
10377
|
-
|
|
10378
|
-
|
|
10379
|
-
|
|
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;
|
|
10968
|
+
return {
|
|
10969
|
+
records: enrichedRecords,
|
|
10970
|
+
total: result.total
|
|
10971
|
+
};
|
|
10450
10972
|
}
|
|
10451
10973
|
};
|
|
10452
10974
|
|
|
@@ -10542,280 +11064,6 @@ var RecordResolverService = class extends BaseService {
|
|
|
10542
11064
|
}
|
|
10543
11065
|
};
|
|
10544
11066
|
|
|
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
11067
|
// src/runtime/services/record/relation.service.ts
|
|
10820
11068
|
var RelationService = class extends BaseService {
|
|
10821
11069
|
constructor(adapter, nativeRegistry, options) {
|
|
@@ -10823,6 +11071,7 @@ var RelationService = class extends BaseService {
|
|
|
10823
11071
|
this.schemaService = new ObjectSchemaService(adapter, nativeRegistry);
|
|
10824
11072
|
this.queryService = options.queryService;
|
|
10825
11073
|
this.recordResolver = options.recordResolver;
|
|
11074
|
+
this.relationPropertiesService = options.relationPropertiesService;
|
|
10826
11075
|
}
|
|
10827
11076
|
/**
|
|
10828
11077
|
* Set the query service after construction.
|
|
@@ -11190,20 +11439,33 @@ var RelationService = class extends BaseService {
|
|
|
11190
11439
|
/**
|
|
11191
11440
|
* Resolve the display label for a record.
|
|
11192
11441
|
* Uses custom template if provided, otherwise falls back to pre-computed label.
|
|
11442
|
+
*
|
|
11443
|
+
* Preserves `{{ props.X }}` tokens for client-side substitution using a sentinel approach:
|
|
11444
|
+
* tokens are replaced with null-byte sentinels before template rendering, then restored after.
|
|
11445
|
+
* This lets `computeLabelWithRelations` resolve target fields while keeping prop placeholders intact.
|
|
11193
11446
|
*/
|
|
11194
11447
|
async resolveLabel(record, objectSchema, customTemplate) {
|
|
11195
|
-
if (customTemplate)
|
|
11196
|
-
|
|
11197
|
-
|
|
11198
|
-
|
|
11199
|
-
|
|
11200
|
-
|
|
11201
|
-
|
|
11202
|
-
|
|
11203
|
-
|
|
11204
|
-
|
|
11448
|
+
if (!customTemplate) return record.label;
|
|
11449
|
+
const preserved = [];
|
|
11450
|
+
let i = 0;
|
|
11451
|
+
const safeTemplate = customTemplate.replace(/\{\{\s*props\.\w+[^}]*\}\}/g, (match) => {
|
|
11452
|
+
const key = `\0PROP${i++}\0`;
|
|
11453
|
+
preserved.push([key, match]);
|
|
11454
|
+
return key;
|
|
11455
|
+
});
|
|
11456
|
+
let label = await computeLabelWithRelations(
|
|
11457
|
+
safeTemplate,
|
|
11458
|
+
record.values,
|
|
11459
|
+
objectSchema.attributes,
|
|
11460
|
+
async (nestedIds) => {
|
|
11461
|
+
const linkedRecords = await this.recordResolver.findByIds(nestedIds);
|
|
11462
|
+
return new Map(linkedRecords.map((r) => [r.id, r.label]));
|
|
11463
|
+
}
|
|
11464
|
+
);
|
|
11465
|
+
for (const [key, token] of preserved) {
|
|
11466
|
+
label = label.replace(key, token);
|
|
11205
11467
|
}
|
|
11206
|
-
return
|
|
11468
|
+
return label;
|
|
11207
11469
|
}
|
|
11208
11470
|
/**
|
|
11209
11471
|
* Find a relation attribute by ID.
|
|
@@ -11650,6 +11912,11 @@ var RecordService = class extends BaseService {
|
|
|
11650
11912
|
});
|
|
11651
11913
|
this.userService = new UserService(adapter);
|
|
11652
11914
|
this.hookRegistry = options?.hookRegistry ?? new NoopHookRegistry();
|
|
11915
|
+
this.bilateralSyncService = new BilateralSyncService(
|
|
11916
|
+
adapter,
|
|
11917
|
+
this.schemaService,
|
|
11918
|
+
this.relationPropertiesService
|
|
11919
|
+
);
|
|
11653
11920
|
this.labelResolver = this.recordResolver.createLabelResolver(this.relationService);
|
|
11654
11921
|
this.rollupContext = this.recordResolver.createRollupContext(
|
|
11655
11922
|
this.rollupService,
|
|
@@ -11718,13 +11985,30 @@ var RecordService = class extends BaseService {
|
|
|
11718
11985
|
});
|
|
11719
11986
|
for (const [attrName, value] of Object.entries(dataWithDefaults)) {
|
|
11720
11987
|
const attr = schema.attributes.find((a) => a.name === attrName);
|
|
11721
|
-
if (attr?.type === "relation"
|
|
11722
|
-
|
|
11988
|
+
if (attr?.type === "relation") {
|
|
11989
|
+
const hasProperties2 = attr.properties !== void 0;
|
|
11990
|
+
const isBilateral = isBilateralRelation(attr);
|
|
11991
|
+
if (hasProperties2 || isBilateral) {
|
|
11992
|
+
await this.relationPropertiesService.syncRelationProperties(
|
|
11993
|
+
schema,
|
|
11994
|
+
record.id,
|
|
11995
|
+
attrName,
|
|
11996
|
+
value,
|
|
11997
|
+
this.adapter
|
|
11998
|
+
);
|
|
11999
|
+
}
|
|
12000
|
+
}
|
|
12001
|
+
}
|
|
12002
|
+
for (const [attrName, value] of Object.entries(normalizedData)) {
|
|
12003
|
+
const attr = schema.attributes.find((a) => a.name === attrName);
|
|
12004
|
+
if (attr?.type === "relation" && isBilateralRelation(attr)) {
|
|
12005
|
+
await this.bilateralSyncService.syncBilateralRelation(
|
|
11723
12006
|
schema,
|
|
11724
12007
|
record.id,
|
|
11725
12008
|
attrName,
|
|
11726
12009
|
value,
|
|
11727
|
-
|
|
12010
|
+
null
|
|
12011
|
+
// oldValue is null for create
|
|
11728
12012
|
);
|
|
11729
12013
|
}
|
|
11730
12014
|
}
|
|
@@ -11747,11 +12031,7 @@ var RecordService = class extends BaseService {
|
|
|
11747
12031
|
recordId: record.id,
|
|
11748
12032
|
recordLabel: record.label,
|
|
11749
12033
|
metadata: options?.hookMetadata
|
|
11750
|
-
}).catch((
|
|
11751
|
-
console.error(
|
|
11752
|
-
"Audit log failed (record.created):",
|
|
11753
|
-
err instanceof Error ? err.message : err
|
|
11754
|
-
);
|
|
12034
|
+
}).catch(() => {
|
|
11755
12035
|
});
|
|
11756
12036
|
}
|
|
11757
12037
|
return record;
|
|
@@ -11785,6 +12065,7 @@ var RecordService = class extends BaseService {
|
|
|
11785
12065
|
if (!options?.skipFormulas) {
|
|
11786
12066
|
enrichedRecord = enrichWithFormulas(record, schema);
|
|
11787
12067
|
}
|
|
12068
|
+
enrichedRecord = await this.enrichRelationProperties(enrichedRecord, schema);
|
|
11788
12069
|
if (options?.includeSchema) {
|
|
11789
12070
|
const recordWithSchema = enrichedRecord;
|
|
11790
12071
|
recordWithSchema.schema = schema;
|
|
@@ -11894,17 +12175,41 @@ var RecordService = class extends BaseService {
|
|
|
11894
12175
|
);
|
|
11895
12176
|
updatePayload.__metadata = cleanedMetadata;
|
|
11896
12177
|
}
|
|
12178
|
+
const bilateralOldValues = {};
|
|
12179
|
+
for (const attrName of Object.keys(normalizedUpdate)) {
|
|
12180
|
+
const attr = schema.attributes.find((a) => a.name === attrName);
|
|
12181
|
+
if (attr?.type === "relation" && isBilateralRelation(attr)) {
|
|
12182
|
+
bilateralOldValues[attrName] = existing.values[attrName];
|
|
12183
|
+
}
|
|
12184
|
+
}
|
|
11897
12185
|
const updated = await this.adapter.objectRecords.update(recordId, updatePayload);
|
|
11898
12186
|
await this.invalidateRecordCaches(recordId, existing.objectId);
|
|
11899
12187
|
for (const [attrName, value] of Object.entries(dataToUpdate)) {
|
|
11900
12188
|
const attr = schema.attributes.find((a) => a.name === attrName);
|
|
11901
|
-
if (attr?.type === "relation"
|
|
11902
|
-
|
|
12189
|
+
if (attr?.type === "relation") {
|
|
12190
|
+
const hasProperties2 = attr.properties !== void 0;
|
|
12191
|
+
const isBilateral = isBilateralRelation(attr);
|
|
12192
|
+
if (hasProperties2 || isBilateral) {
|
|
12193
|
+
await this.relationPropertiesService.syncRelationProperties(
|
|
12194
|
+
schema,
|
|
12195
|
+
recordId,
|
|
12196
|
+
attrName,
|
|
12197
|
+
value,
|
|
12198
|
+
this.adapter
|
|
12199
|
+
);
|
|
12200
|
+
}
|
|
12201
|
+
}
|
|
12202
|
+
}
|
|
12203
|
+
for (const [attrName, value] of Object.entries(normalizedUpdate)) {
|
|
12204
|
+
const attr = schema.attributes.find((a) => a.name === attrName);
|
|
12205
|
+
if (attr?.type === "relation" && isBilateralRelation(attr)) {
|
|
12206
|
+
const oldValue = bilateralOldValues[attrName];
|
|
12207
|
+
await this.bilateralSyncService.syncBilateralRelation(
|
|
11903
12208
|
schema,
|
|
11904
12209
|
recordId,
|
|
11905
12210
|
attrName,
|
|
11906
12211
|
value,
|
|
11907
|
-
|
|
12212
|
+
oldValue
|
|
11908
12213
|
);
|
|
11909
12214
|
}
|
|
11910
12215
|
}
|
|
@@ -11935,11 +12240,7 @@ var RecordService = class extends BaseService {
|
|
|
11935
12240
|
recordLabel: updated.label,
|
|
11936
12241
|
changes,
|
|
11937
12242
|
metadata: options?.hookMetadata
|
|
11938
|
-
}).catch((
|
|
11939
|
-
console.error(
|
|
11940
|
-
"Audit log failed (record.updated):",
|
|
11941
|
-
err instanceof Error ? err.message : err
|
|
11942
|
-
);
|
|
12243
|
+
}).catch(() => {
|
|
11943
12244
|
});
|
|
11944
12245
|
}
|
|
11945
12246
|
return updated;
|
|
@@ -11982,6 +12283,19 @@ var RecordService = class extends BaseService {
|
|
|
11982
12283
|
if (!options?.skipHooks) {
|
|
11983
12284
|
await this.hookRegistry.execute("beforeDelete", schema.name, hookCtx);
|
|
11984
12285
|
}
|
|
12286
|
+
for (const attr of schema.attributes) {
|
|
12287
|
+
if (attr.type === "relation" && isBilateralRelation(attr)) {
|
|
12288
|
+
const currentValue = record.values[attr.name];
|
|
12289
|
+
await this.bilateralSyncService.syncBilateralRelation(
|
|
12290
|
+
schema,
|
|
12291
|
+
recordId,
|
|
12292
|
+
attr.name,
|
|
12293
|
+
null,
|
|
12294
|
+
// newValue is null
|
|
12295
|
+
currentValue
|
|
12296
|
+
);
|
|
12297
|
+
}
|
|
12298
|
+
}
|
|
11985
12299
|
await this.adapter.objectRecords.delete(recordId);
|
|
11986
12300
|
await this.invalidateRecordCaches(recordId, record.objectId);
|
|
11987
12301
|
if (!options?.skipHooks) {
|
|
@@ -11997,11 +12311,7 @@ var RecordService = class extends BaseService {
|
|
|
11997
12311
|
recordId: record.id,
|
|
11998
12312
|
recordLabel: record.label,
|
|
11999
12313
|
metadata: options?.hookMetadata
|
|
12000
|
-
}).catch((
|
|
12001
|
-
console.error(
|
|
12002
|
-
"Audit log failed (record.deleted):",
|
|
12003
|
-
err instanceof Error ? err.message : err
|
|
12004
|
-
);
|
|
12314
|
+
}).catch(() => {
|
|
12005
12315
|
});
|
|
12006
12316
|
}
|
|
12007
12317
|
}
|
|
@@ -12083,11 +12393,7 @@ var RecordService = class extends BaseService {
|
|
|
12083
12393
|
recordId: restored.id,
|
|
12084
12394
|
recordLabel: restored.label,
|
|
12085
12395
|
metadata: options?.hookMetadata
|
|
12086
|
-
}).catch((
|
|
12087
|
-
console.error(
|
|
12088
|
-
"Audit log failed (record.restored):",
|
|
12089
|
-
err instanceof Error ? err.message : err
|
|
12090
|
-
);
|
|
12396
|
+
}).catch(() => {
|
|
12091
12397
|
});
|
|
12092
12398
|
}
|
|
12093
12399
|
return restored;
|
|
@@ -12095,6 +12401,60 @@ var RecordService = class extends BaseService {
|
|
|
12095
12401
|
// ============================================================================
|
|
12096
12402
|
// PRIVATE HELPERS
|
|
12097
12403
|
// ============================================================================
|
|
12404
|
+
/**
|
|
12405
|
+
* Enrich relation attributes with their properties (for qualified relations).
|
|
12406
|
+
*
|
|
12407
|
+
* Transforms simple ID arrays into hybrid format { id, props } when properties exist.
|
|
12408
|
+
*
|
|
12409
|
+
* @param record - Record to enrich
|
|
12410
|
+
* @param schema - Object schema
|
|
12411
|
+
* @returns Enriched record with relation properties loaded
|
|
12412
|
+
* @private
|
|
12413
|
+
*/
|
|
12414
|
+
async enrichRelationProperties(record, schema) {
|
|
12415
|
+
const enrichedValues = { ...record.values };
|
|
12416
|
+
for (const attr of schema.attributes) {
|
|
12417
|
+
if (attr.type !== "relation") {
|
|
12418
|
+
continue;
|
|
12419
|
+
}
|
|
12420
|
+
const hasProperties2 = attr.properties && attr.properties.definitions.length > 0;
|
|
12421
|
+
const isBilateral = isBilateralRelation(attr);
|
|
12422
|
+
const shouldEnrich = hasProperties2 || isBilateral;
|
|
12423
|
+
if (!shouldEnrich) {
|
|
12424
|
+
continue;
|
|
12425
|
+
}
|
|
12426
|
+
const value = record.values[attr.name];
|
|
12427
|
+
if (value === null || value === void 0) {
|
|
12428
|
+
continue;
|
|
12429
|
+
}
|
|
12430
|
+
const isMany = attr.cardinality === "many";
|
|
12431
|
+
const targetIds = isMany ? value : [value];
|
|
12432
|
+
if (!targetIds || targetIds.length === 0) {
|
|
12433
|
+
continue;
|
|
12434
|
+
}
|
|
12435
|
+
const propsMap = await this.relationPropertiesService.getRelationProperties(
|
|
12436
|
+
schema.name,
|
|
12437
|
+
record.id,
|
|
12438
|
+
attr.name,
|
|
12439
|
+
targetIds
|
|
12440
|
+
);
|
|
12441
|
+
if (isMany) {
|
|
12442
|
+
const hybridArray = targetIds.map((id) => {
|
|
12443
|
+
const props = propsMap.get(id);
|
|
12444
|
+
return props ? { id, props } : id;
|
|
12445
|
+
});
|
|
12446
|
+
enrichedValues[attr.name] = hybridArray;
|
|
12447
|
+
} else {
|
|
12448
|
+
const id = targetIds[0];
|
|
12449
|
+
const props = propsMap.get(id);
|
|
12450
|
+
enrichedValues[attr.name] = props ? { id, props } : id;
|
|
12451
|
+
}
|
|
12452
|
+
}
|
|
12453
|
+
return {
|
|
12454
|
+
...record,
|
|
12455
|
+
values: enrichedValues
|
|
12456
|
+
};
|
|
12457
|
+
}
|
|
12098
12458
|
/**
|
|
12099
12459
|
* Invalidate all caches related to a record (record cache + lists + global search)
|
|
12100
12460
|
* @private
|
|
@@ -17259,14 +17619,6 @@ var ViewService = class extends BaseService {
|
|
|
17259
17619
|
type: "activity",
|
|
17260
17620
|
order: 1
|
|
17261
17621
|
});
|
|
17262
|
-
tabs.push({
|
|
17263
|
-
id: "notes",
|
|
17264
|
-
name: "notes",
|
|
17265
|
-
label: "Notes",
|
|
17266
|
-
type: "notes",
|
|
17267
|
-
order: 2,
|
|
17268
|
-
allowCreate: true
|
|
17269
|
-
});
|
|
17270
17622
|
const hasDocuments = object2.attributes.some((attr) => attr.type === "document");
|
|
17271
17623
|
if (hasDocuments) {
|
|
17272
17624
|
tabs.push({
|
|
@@ -17991,6 +18343,8 @@ export {
|
|
|
17991
18343
|
isPresentationProperty,
|
|
17992
18344
|
RELATION_TARGET_ANY,
|
|
17993
18345
|
isUniversalRelation,
|
|
18346
|
+
isBilateralRelation,
|
|
18347
|
+
inferInverseCardinality,
|
|
17994
18348
|
RecordReferencedError,
|
|
17995
18349
|
AttributeInUseError,
|
|
17996
18350
|
ObjectReferencedError,
|
|
@@ -18076,6 +18430,14 @@ export {
|
|
|
18076
18430
|
hasProperties,
|
|
18077
18431
|
EMPTY_VALUE_PLACEHOLDER,
|
|
18078
18432
|
formatAttributeValue,
|
|
18433
|
+
DEFAULT_LABEL_FALLBACK,
|
|
18434
|
+
renderLabelExpression,
|
|
18435
|
+
isLabelExpression,
|
|
18436
|
+
extractAttributeNames,
|
|
18437
|
+
enrichValuesForDisplay,
|
|
18438
|
+
enrichValuesWithSelectLabels,
|
|
18439
|
+
extractRelationIds,
|
|
18440
|
+
computeLabelWithRelations,
|
|
18079
18441
|
SchemaErrorCode,
|
|
18080
18442
|
SchemaError,
|
|
18081
18443
|
NotFoundError,
|
|
@@ -18098,22 +18460,6 @@ export {
|
|
|
18098
18460
|
RoleNotFoundError,
|
|
18099
18461
|
isForbiddenError,
|
|
18100
18462
|
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
18463
|
text,
|
|
18118
18464
|
textarea,
|
|
18119
18465
|
richtext,
|
|
@@ -18136,10 +18482,10 @@ export {
|
|
|
18136
18482
|
ObjectBuilder,
|
|
18137
18483
|
object,
|
|
18138
18484
|
GroupBuilder,
|
|
18139
|
-
|
|
18140
|
-
|
|
18485
|
+
RelationGroupBuilder,
|
|
18486
|
+
TableTabConfig,
|
|
18141
18487
|
CustomTabConfig,
|
|
18142
|
-
|
|
18488
|
+
RichtextTabConfig,
|
|
18143
18489
|
ActivityTabConfig,
|
|
18144
18490
|
FlowsTabConfig,
|
|
18145
18491
|
DocumentsTabConfig,
|
|
@@ -18152,6 +18498,7 @@ export {
|
|
|
18152
18498
|
ListViewTabConfigBuilder,
|
|
18153
18499
|
listView,
|
|
18154
18500
|
group,
|
|
18501
|
+
relationGroup,
|
|
18155
18502
|
WorkflowFormRowBuilder,
|
|
18156
18503
|
WorkflowFormBuilder,
|
|
18157
18504
|
WorkflowSimpleFormBuilder,
|
|
@@ -18246,18 +18593,9 @@ export {
|
|
|
18246
18593
|
resolveSingleValue,
|
|
18247
18594
|
resolveMultiplePaths,
|
|
18248
18595
|
NoopHookRegistry,
|
|
18249
|
-
DEFAULT_LABEL_FALLBACK,
|
|
18250
|
-
renderLabelExpression,
|
|
18251
|
-
isLabelExpression,
|
|
18252
|
-
extractAttributeNames,
|
|
18253
|
-
enrichValuesForDisplay,
|
|
18254
|
-
enrichValuesWithSelectLabels,
|
|
18255
|
-
extractRelationIds,
|
|
18256
|
-
computeLabelWithRelations,
|
|
18257
18596
|
createMockAdapter,
|
|
18258
18597
|
PolicyRegistry,
|
|
18259
18598
|
defaultPolicyRegistry,
|
|
18260
|
-
notesPolicy,
|
|
18261
18599
|
BaseService,
|
|
18262
18600
|
BaseRepository,
|
|
18263
18601
|
SchemaContextAwareRepository,
|
|
@@ -18281,9 +18619,9 @@ export {
|
|
|
18281
18619
|
createContextForDelete,
|
|
18282
18620
|
createContextForRestore,
|
|
18283
18621
|
recalculateParentRollups,
|
|
18622
|
+
RelationPropertiesService,
|
|
18284
18623
|
RecordQueryService,
|
|
18285
18624
|
RecordResolverService,
|
|
18286
|
-
RelationPropertiesService,
|
|
18287
18625
|
RelationService,
|
|
18288
18626
|
RollupService,
|
|
18289
18627
|
RecordService,
|