@endora-commerce/mod-custom-fields 0.0.0-stage → 0.100.0

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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -2
  3. package/dist/admin/api/custom-fields-client.d.ts +10 -0
  4. package/dist/admin/api/custom-fields-client.d.ts.map +1 -0
  5. package/dist/admin/api/custom-fields-client.js +11 -0
  6. package/dist/admin/api/custom-fields-client.js.map +1 -0
  7. package/dist/admin/index.d.ts +44 -0
  8. package/dist/admin/index.d.ts.map +1 -0
  9. package/dist/admin/index.js +51 -0
  10. package/dist/admin/index.js.map +1 -0
  11. package/dist/admin/pages/CustomFieldsPage.d.ts +10 -0
  12. package/dist/admin/pages/CustomFieldsPage.d.ts.map +1 -0
  13. package/dist/admin/pages/CustomFieldsPage.js +118 -0
  14. package/dist/admin/pages/CustomFieldsPage.js.map +1 -0
  15. package/dist/backend/commands/definition-commands.d.ts +22 -0
  16. package/dist/backend/commands/definition-commands.d.ts.map +1 -0
  17. package/dist/backend/commands/definition-commands.js +115 -0
  18. package/dist/backend/commands/definition-commands.js.map +1 -0
  19. package/dist/backend/entities/custom-field-definition.entity.d.ts +37 -0
  20. package/dist/backend/entities/custom-field-definition.entity.d.ts.map +1 -0
  21. package/dist/backend/entities/custom-field-definition.entity.js +98 -0
  22. package/dist/backend/entities/custom-field-definition.entity.js.map +1 -0
  23. package/dist/backend/entities/custom-field-option.entity.d.ts +25 -0
  24. package/dist/backend/entities/custom-field-option.entity.d.ts.map +1 -0
  25. package/dist/backend/entities/custom-field-option.entity.js +79 -0
  26. package/dist/backend/entities/custom-field-option.entity.js.map +1 -0
  27. package/dist/backend/index.d.ts +79 -0
  28. package/dist/backend/index.d.ts.map +1 -0
  29. package/dist/backend/index.js +94 -0
  30. package/dist/backend/index.js.map +1 -0
  31. package/dist/backend/routes.admin.d.ts +14 -0
  32. package/dist/backend/routes.admin.d.ts.map +1 -0
  33. package/dist/backend/routes.admin.js +179 -0
  34. package/dist/backend/routes.admin.js.map +1 -0
  35. package/dist/backend/services/custom-field-definition-apply.d.ts +63 -0
  36. package/dist/backend/services/custom-field-definition-apply.d.ts.map +1 -0
  37. package/dist/backend/services/custom-field-definition-apply.js +221 -0
  38. package/dist/backend/services/custom-field-definition-apply.js.map +1 -0
  39. package/dist/backend/services/custom-field-definition.service.d.ts +68 -0
  40. package/dist/backend/services/custom-field-definition.service.d.ts.map +1 -0
  41. package/dist/backend/services/custom-field-definition.service.js +205 -0
  42. package/dist/backend/services/custom-field-definition.service.js.map +1 -0
  43. package/dist/backend/services/custom-field-definitions-cache.d.ts +46 -0
  44. package/dist/backend/services/custom-field-definitions-cache.d.ts.map +1 -0
  45. package/dist/backend/services/custom-field-definitions-cache.js +82 -0
  46. package/dist/backend/services/custom-field-definitions-cache.js.map +1 -0
  47. package/dist/backend/services/custom-field-read-port.d.ts +61 -0
  48. package/dist/backend/services/custom-field-read-port.d.ts.map +1 -0
  49. package/dist/backend/services/custom-field-read-port.js +82 -0
  50. package/dist/backend/services/custom-field-read-port.js.map +1 -0
  51. package/dist/backend/services/custom-field-registry.d.ts +41 -0
  52. package/dist/backend/services/custom-field-registry.d.ts.map +1 -0
  53. package/dist/backend/services/custom-field-registry.js +24 -0
  54. package/dist/backend/services/custom-field-registry.js.map +1 -0
  55. package/dist/backend/services/custom-field-value.service.d.ts +56 -0
  56. package/dist/backend/services/custom-field-value.service.d.ts.map +1 -0
  57. package/dist/backend/services/custom-field-value.service.js +175 -0
  58. package/dist/backend/services/custom-field-value.service.js.map +1 -0
  59. package/dist/manifest.d.ts +177 -0
  60. package/dist/manifest.d.ts.map +1 -0
  61. package/dist/manifest.js +130 -0
  62. package/dist/manifest.js.map +1 -0
  63. package/dist/migrations/20260718T200338_custom_fields_init.d.ts +20 -0
  64. package/dist/migrations/20260718T200338_custom_fields_init.d.ts.map +1 -0
  65. package/dist/migrations/20260718T200338_custom_fields_init.js +60 -0
  66. package/dist/migrations/20260718T200338_custom_fields_init.js.map +1 -0
  67. package/dist/migrations/index.d.ts +27 -0
  68. package/dist/migrations/index.d.ts.map +1 -0
  69. package/dist/migrations/index.js +29 -0
  70. package/dist/migrations/index.js.map +1 -0
  71. package/dist/ports/index.d.ts +123 -0
  72. package/dist/ports/index.d.ts.map +1 -0
  73. package/dist/ports/index.js +2 -0
  74. package/dist/ports/index.js.map +1 -0
  75. package/i18n/en.json +15 -0
  76. package/i18n/pl.json +15 -0
  77. package/package.json +99 -3
  78. package/tailwind.css +14 -0
@@ -0,0 +1,46 @@
1
+ import type { Redis } from 'ioredis';
2
+ import type { CustomFieldDefinition } from '../entities/custom-field-definition.entity.js';
3
+ import type { CustomFieldOption } from '../entities/custom-field-option.entity.js';
4
+ /** Redis pub/sub channel that fans definition changes across processes (API + worker). */
5
+ export declare const CUSTOM_FIELDS_CHANGED_CHANNEL = "b2b:custom_fields:definitions-changed";
6
+ /** Degraded-mode fallback: drop cached entries after this age if pub/sub is unavailable. */
7
+ export declare const CUSTOM_FIELDS_CACHE_TTL_MS = 5000;
8
+ /** A definition with its options, as cached per entity type. */
9
+ export interface CachedDefinition {
10
+ readonly definition: CustomFieldDefinition;
11
+ readonly options: CustomFieldOption[];
12
+ }
13
+ /**
14
+ * Per-`entityType` in-process cache of custom-field definitions (feature 055).
15
+ *
16
+ * The hot path — validating a host write and rendering the admin form — reads
17
+ * definitions frequently, so they are cached in memory and invalidated
18
+ * cross-process on the {@link CUSTOM_FIELDS_CHANGED_CHANNEL} Redis channel
19
+ * (registry-cache pattern). A definition/option Command publishes on commit;
20
+ * every process's subscriber drops the affected entity-type entry. If the
21
+ * subscription connection drops, entries expire after {@link CUSTOM_FIELDS_CACHE_TTL_MS}.
22
+ *
23
+ * `now()` is injectable so tests can advance time without a real clock.
24
+ */
25
+ export declare class CustomFieldDefinitionsCache {
26
+ private readonly publisher?;
27
+ private readonly now;
28
+ private readonly entries;
29
+ private subscriber;
30
+ constructor(publisher?: Redis | undefined, now?: () => number);
31
+ /** Read cached definitions for an entity type, loading + caching on a miss or a stale entry. */
32
+ getForEntity(entityType: string, loader: () => Promise<CachedDefinition[]>): Promise<CachedDefinition[]>;
33
+ /** Drop the local cache for one entity type (or all when omitted). */
34
+ invalidateLocal(entityType?: string): void;
35
+ /**
36
+ * Invalidate locally AND fan the change out to other processes. Called by the
37
+ * definition service after a Command commits. Publish failures are swallowed —
38
+ * the TTL fallback keeps other processes eventually-consistent.
39
+ */
40
+ publishInvalidate(entityType: string): Promise<void>;
41
+ /** Arm the cross-process subscriber. Idempotent. */
42
+ start(redisSubscriber: Redis): Promise<void>;
43
+ /** Apply an invalidation message. Exposed for tests + a shared-subscriber wiring. */
44
+ handleMessage(message: string): void;
45
+ }
46
+ //# sourceMappingURL=custom-field-definitions-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"custom-field-definitions-cache.d.ts","sourceRoot":"","sources":["../../../src/backend/services/custom-field-definitions-cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,+CAA+C,CAAC;AAC3F,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2CAA2C,CAAC;AAEnF,0FAA0F;AAC1F,eAAO,MAAM,6BAA6B,0CAA0C,CAAC;AACrF,4FAA4F;AAC5F,eAAO,MAAM,0BAA0B,OAAQ,CAAC;AAEhD,gEAAgE;AAChE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,UAAU,EAAE,qBAAqB,CAAC;IAC3C,QAAQ,CAAC,OAAO,EAAE,iBAAiB,EAAE,CAAC;CACvC;AAOD;;;;;;;;;;;GAWG;AACH,qBAAa,2BAA2B;IAKpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC;IAC3B,OAAO,CAAC,QAAQ,CAAC,GAAG;IALtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiC;IACzD,OAAO,CAAC,UAAU,CAAsB;gBAGrB,SAAS,CAAC,EAAE,KAAK,YAAA,EACjB,GAAG,GAAE,MAAM,MAAyB;IAGvD,gGAAgG;IAC1F,YAAY,CAChB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,OAAO,CAAC,gBAAgB,EAAE,CAAC,GACxC,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAU9B,sEAAsE;IACtE,eAAe,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI;IAK1C;;;;OAIG;IACG,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAU1D,oDAAoD;IAC9C,KAAK,CAAC,eAAe,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;IAUlD,qFAAqF;IACrF,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;CAQrC"}
@@ -0,0 +1,82 @@
1
+ /** Redis pub/sub channel that fans definition changes across processes (API + worker). */
2
+ export const CUSTOM_FIELDS_CHANGED_CHANNEL = 'b2b:custom_fields:definitions-changed';
3
+ /** Degraded-mode fallback: drop cached entries after this age if pub/sub is unavailable. */
4
+ export const CUSTOM_FIELDS_CACHE_TTL_MS = 5_000;
5
+ /**
6
+ * Per-`entityType` in-process cache of custom-field definitions (feature 055).
7
+ *
8
+ * The hot path — validating a host write and rendering the admin form — reads
9
+ * definitions frequently, so they are cached in memory and invalidated
10
+ * cross-process on the {@link CUSTOM_FIELDS_CHANGED_CHANNEL} Redis channel
11
+ * (registry-cache pattern). A definition/option Command publishes on commit;
12
+ * every process's subscriber drops the affected entity-type entry. If the
13
+ * subscription connection drops, entries expire after {@link CUSTOM_FIELDS_CACHE_TTL_MS}.
14
+ *
15
+ * `now()` is injectable so tests can advance time without a real clock.
16
+ */
17
+ export class CustomFieldDefinitionsCache {
18
+ publisher;
19
+ now;
20
+ entries = new Map();
21
+ subscriber = null;
22
+ constructor(publisher, now = () => Date.now()) {
23
+ this.publisher = publisher;
24
+ this.now = now;
25
+ }
26
+ /** Read cached definitions for an entity type, loading + caching on a miss or a stale entry. */
27
+ async getForEntity(entityType, loader) {
28
+ const hit = this.entries.get(entityType);
29
+ if (hit && this.now() - hit.loadedAt < CUSTOM_FIELDS_CACHE_TTL_MS) {
30
+ return hit.value;
31
+ }
32
+ const value = await loader();
33
+ this.entries.set(entityType, { value, loadedAt: this.now() });
34
+ return value;
35
+ }
36
+ /** Drop the local cache for one entity type (or all when omitted). */
37
+ invalidateLocal(entityType) {
38
+ if (entityType === undefined)
39
+ this.entries.clear();
40
+ else
41
+ this.entries.delete(entityType);
42
+ }
43
+ /**
44
+ * Invalidate locally AND fan the change out to other processes. Called by the
45
+ * definition service after a Command commits. Publish failures are swallowed —
46
+ * the TTL fallback keeps other processes eventually-consistent.
47
+ */
48
+ async publishInvalidate(entityType) {
49
+ this.invalidateLocal(entityType);
50
+ if (!this.publisher)
51
+ return;
52
+ try {
53
+ await this.publisher.publish(CUSTOM_FIELDS_CHANGED_CHANNEL, JSON.stringify({ entityType }));
54
+ }
55
+ catch {
56
+ // Degraded: rely on the TTL fallback.
57
+ }
58
+ }
59
+ /** Arm the cross-process subscriber. Idempotent. */
60
+ async start(redisSubscriber) {
61
+ if (this.subscriber)
62
+ return;
63
+ this.subscriber = redisSubscriber;
64
+ await this.subscriber.subscribe(CUSTOM_FIELDS_CHANGED_CHANNEL);
65
+ this.subscriber.on('message', (channel, message) => {
66
+ if (channel !== CUSTOM_FIELDS_CHANGED_CHANNEL)
67
+ return;
68
+ this.handleMessage(message);
69
+ });
70
+ }
71
+ /** Apply an invalidation message. Exposed for tests + a shared-subscriber wiring. */
72
+ handleMessage(message) {
73
+ try {
74
+ const parsed = JSON.parse(message);
75
+ this.invalidateLocal(parsed.entityType);
76
+ }
77
+ catch {
78
+ this.invalidateLocal();
79
+ }
80
+ }
81
+ }
82
+ //# sourceMappingURL=custom-field-definitions-cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"custom-field-definitions-cache.js","sourceRoot":"","sources":["../../../src/backend/services/custom-field-definitions-cache.ts"],"names":[],"mappings":"AAIA,0FAA0F;AAC1F,MAAM,CAAC,MAAM,6BAA6B,GAAG,uCAAuC,CAAC;AACrF,4FAA4F;AAC5F,MAAM,CAAC,MAAM,0BAA0B,GAAG,KAAK,CAAC;AAahD;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,2BAA2B;IAKnB;IACA;IALF,OAAO,GAAG,IAAI,GAAG,EAAsB,CAAC;IACjD,UAAU,GAAiB,IAAI,CAAC;IAExC,YACmB,SAAiB,EACjB,MAAoB,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE;QADpC,cAAS,GAAT,SAAS,CAAQ;QACjB,QAAG,GAAH,GAAG,CAAiC;IACpD,CAAC;IAEJ,gGAAgG;IAChG,KAAK,CAAC,YAAY,CAChB,UAAkB,EAClB,MAAyC;QAEzC,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QACzC,IAAI,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,GAAG,CAAC,QAAQ,GAAG,0BAA0B,EAAE,CAAC;YAClE,OAAO,GAAG,CAAC,KAAK,CAAC;QACnB,CAAC;QACD,MAAM,KAAK,GAAG,MAAM,MAAM,EAAE,CAAC;QAC7B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QAC9D,OAAO,KAAK,CAAC;IACf,CAAC;IAED,sEAAsE;IACtE,eAAe,CAAC,UAAmB;QACjC,IAAI,UAAU,KAAK,SAAS;YAAE,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;;YAC9C,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IACvC,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,iBAAiB,CAAC,UAAkB;QACxC,IAAI,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC;QACjC,IAAI,CAAC,IAAI,CAAC,SAAS;YAAE,OAAO;QAC5B,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,6BAA6B,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC;QAC9F,CAAC;QAAC,MAAM,CAAC;YACP,sCAAsC;QACxC,CAAC;IACH,CAAC;IAED,oDAAoD;IACpD,KAAK,CAAC,KAAK,CAAC,eAAsB;QAChC,IAAI,IAAI,CAAC,UAAU;YAAE,OAAO;QAC5B,IAAI,CAAC,UAAU,GAAG,eAAe,CAAC;QAClC,MAAM,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,6BAA6B,CAAC,CAAC;QAC/D,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE;YACjD,IAAI,OAAO,KAAK,6BAA6B;gBAAE,OAAO;YACtD,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QAC9B,CAAC,CAAC,CAAC;IACL,CAAC;IAED,qFAAqF;IACrF,aAAa,CAAC,OAAe;QAC3B,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAA4B,CAAC;YAC9D,IAAI,CAAC,eAAe,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;QAC1C,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,eAAe,EAAE,CAAC;QACzB,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1,61 @@
1
+ import type { CustomFieldDefinitionReadPort, CustomFieldDefinitionRecord, CustomFieldDefinitionWithOptions, CustomFieldOptionRecord, SupportedEntityType } from '@endora-commerce/contracts';
2
+ import type { CustomFieldDefinition } from '../entities/custom-field-definition.entity.js';
3
+ import type { CustomFieldOption } from '../entities/custom-field-option.entity.js';
4
+ import type { CachedDefinition } from './custom-field-definitions-cache.js';
5
+ import type { DefinitionSource } from './custom-field-value.service.js';
6
+ /**
7
+ * What the read port needs beyond {@link DefinitionSource}: the cache-bypassing
8
+ * read D-97.1 publishes. `CustomFieldDefinitionService` satisfies both; the
9
+ * interface stays here so this adapter has no concrete dependency on it.
10
+ */
11
+ export interface FreshDefinitionSource extends DefinitionSource {
12
+ listForEntityFresh(entityType: SupportedEntityType): Promise<CachedDefinition[]>;
13
+ /**
14
+ * Feature 080, T053(b) — the single-definition read the apply seam used to
15
+ * answer for `catalog`. It returns `CachedDefinition`, which carries the two
16
+ * ORM entities, which is precisely what the adapter below maps away.
17
+ */
18
+ getById(id: string): Promise<CachedDefinition | null>;
19
+ }
20
+ /**
21
+ * The published face of `custom_fields`' definition read model (feature 075,
22
+ * Phase P).
23
+ *
24
+ * `DefinitionSource` already had the right *shape* — one question, taken as an
25
+ * interface so the value service has no concrete dependency on the definition
26
+ * service. What it did not have was a published one: `CachedDefinition` holds
27
+ * the two ORM entities, so a consumer typing itself against it imports both
28
+ * classes, which is what seven modules do today.
29
+ *
30
+ * The mapping is per definition rather than per read: the underlying call
31
+ * rides the per-entity in-process cache, so the entities are already in
32
+ * memory, and copying twenty fields off them costs nothing measurable against
33
+ * the query it replaces in each consumer.
34
+ */
35
+ export declare class CustomFieldDefinitionReadService implements CustomFieldDefinitionReadPort {
36
+ private readonly source;
37
+ constructor(source: FreshDefinitionSource);
38
+ listForEntity(entityType: SupportedEntityType): Promise<CustomFieldDefinitionWithOptions[]>;
39
+ /**
40
+ * D-97.1 — the freshness guarantee, expressed as a read.
41
+ *
42
+ * The cache, its local drop and its cross-process fan-out never leave this
43
+ * module: a consumer asks for definitions it can trust, not for somebody
44
+ * else's cache to be flushed.
45
+ */
46
+ listForEntityFresh(entityType: SupportedEntityType): Promise<CustomFieldDefinitionWithOptions[]>;
47
+ /**
48
+ * Feature 080, T053(b) — the committed-state read of one definition.
49
+ *
50
+ * `catalog`'s attribute Commands took this off the apply seam until now, and
51
+ * the seam handed back the managed entities: a record-shaped type over
52
+ * `CustomFieldDefinition` and `CustomFieldOption`, which are structurally
53
+ * assignable to the published records, so nothing complained. The mapping
54
+ * below is what makes the published type true.
55
+ */
56
+ getById(id: string): Promise<CustomFieldDefinitionWithOptions | null>;
57
+ }
58
+ export declare function toDefinitionWithOptions(cached: CachedDefinition): CustomFieldDefinitionWithOptions;
59
+ export declare function toCustomFieldDefinitionRecord(definition: CustomFieldDefinition): CustomFieldDefinitionRecord;
60
+ export declare function toCustomFieldOptionRecord(option: CustomFieldOption): CustomFieldOptionRecord;
61
+ //# sourceMappingURL=custom-field-read-port.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"custom-field-read-port.d.ts","sourceRoot":"","sources":["../../../src/backend/services/custom-field-read-port.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,6BAA6B,EAC7B,2BAA2B,EAC3B,gCAAgC,EAChC,uBAAuB,EACvB,mBAAmB,EACpB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,+CAA+C,CAAC;AAC3F,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2CAA2C,CAAC;AACnF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAC5E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAExE;;;;GAIG;AACH,MAAM,WAAW,qBAAsB,SAAQ,gBAAgB;IAC7D,kBAAkB,CAAC,UAAU,EAAE,mBAAmB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;IACjF;;;;OAIG;IACH,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;CACvD;AAED;;;;;;;;;;;;;;GAcG;AACH,qBAAa,gCAAiC,YAAW,6BAA6B;IACxE,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,qBAAqB;IAEpD,aAAa,CACjB,UAAU,EAAE,mBAAmB,GAC9B,OAAO,CAAC,gCAAgC,EAAE,CAAC;IAI9C;;;;;;OAMG;IACG,kBAAkB,CACtB,UAAU,EAAE,mBAAmB,GAC9B,OAAO,CAAC,gCAAgC,EAAE,CAAC;IAI9C;;;;;;;;OAQG;IACG,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,gCAAgC,GAAG,IAAI,CAAC;CAI5E;AAED,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,gBAAgB,GACvB,gCAAgC,CAKlC;AAED,wBAAgB,6BAA6B,CAC3C,UAAU,EAAE,qBAAqB,GAChC,2BAA2B,CAc7B;AAED,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,iBAAiB,GAAG,uBAAuB,CAY5F"}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The published face of `custom_fields`' definition read model (feature 075,
3
+ * Phase P).
4
+ *
5
+ * `DefinitionSource` already had the right *shape* — one question, taken as an
6
+ * interface so the value service has no concrete dependency on the definition
7
+ * service. What it did not have was a published one: `CachedDefinition` holds
8
+ * the two ORM entities, so a consumer typing itself against it imports both
9
+ * classes, which is what seven modules do today.
10
+ *
11
+ * The mapping is per definition rather than per read: the underlying call
12
+ * rides the per-entity in-process cache, so the entities are already in
13
+ * memory, and copying twenty fields off them costs nothing measurable against
14
+ * the query it replaces in each consumer.
15
+ */
16
+ export class CustomFieldDefinitionReadService {
17
+ source;
18
+ constructor(source) {
19
+ this.source = source;
20
+ }
21
+ async listForEntity(entityType) {
22
+ return (await this.source.listForEntity(entityType)).map(toDefinitionWithOptions);
23
+ }
24
+ /**
25
+ * D-97.1 — the freshness guarantee, expressed as a read.
26
+ *
27
+ * The cache, its local drop and its cross-process fan-out never leave this
28
+ * module: a consumer asks for definitions it can trust, not for somebody
29
+ * else's cache to be flushed.
30
+ */
31
+ async listForEntityFresh(entityType) {
32
+ return (await this.source.listForEntityFresh(entityType)).map(toDefinitionWithOptions);
33
+ }
34
+ /**
35
+ * Feature 080, T053(b) — the committed-state read of one definition.
36
+ *
37
+ * `catalog`'s attribute Commands took this off the apply seam until now, and
38
+ * the seam handed back the managed entities: a record-shaped type over
39
+ * `CustomFieldDefinition` and `CustomFieldOption`, which are structurally
40
+ * assignable to the published records, so nothing complained. The mapping
41
+ * below is what makes the published type true.
42
+ */
43
+ async getById(id) {
44
+ const cached = await this.source.getById(id);
45
+ return cached ? toDefinitionWithOptions(cached) : null;
46
+ }
47
+ }
48
+ export function toDefinitionWithOptions(cached) {
49
+ return {
50
+ definition: toCustomFieldDefinitionRecord(cached.definition),
51
+ options: cached.options.map(toCustomFieldOptionRecord),
52
+ };
53
+ }
54
+ export function toCustomFieldDefinitionRecord(definition) {
55
+ return {
56
+ id: definition.id,
57
+ entityType: definition.entityType,
58
+ key: definition.key,
59
+ label: definition.label ?? {},
60
+ labelDefault: definition.labelDefault,
61
+ valueType: definition.valueType,
62
+ required: definition.required,
63
+ sortOrder: definition.sortOrder,
64
+ config: definition.config ?? {},
65
+ createdAt: definition.createdAt,
66
+ updatedAt: definition.updatedAt,
67
+ };
68
+ }
69
+ export function toCustomFieldOptionRecord(option) {
70
+ return {
71
+ id: option.id,
72
+ definitionId: option.definitionId,
73
+ value: option.value,
74
+ label: option.label ?? {},
75
+ labelDefault: option.labelDefault,
76
+ isDefault: option.isDefault,
77
+ sortOrder: option.sortOrder,
78
+ createdAt: option.createdAt,
79
+ updatedAt: option.updatedAt,
80
+ };
81
+ }
82
+ //# sourceMappingURL=custom-field-read-port.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"custom-field-read-port.js","sourceRoot":"","sources":["../../../src/backend/services/custom-field-read-port.ts"],"names":[],"mappings":"AA2BA;;;;;;;;;;;;;;GAcG;AACH,MAAM,OAAO,gCAAgC;IACd;IAA7B,YAA6B,MAA6B;QAA7B,WAAM,GAAN,MAAM,CAAuB;IAAG,CAAC;IAE9D,KAAK,CAAC,aAAa,CACjB,UAA+B;QAE/B,OAAO,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;IACpF,CAAC;IAED;;;;;;OAMG;IACH,KAAK,CAAC,kBAAkB,CACtB,UAA+B;QAE/B,OAAO,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,kBAAkB,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;IACzF,CAAC;IAED;;;;;;;;OAQG;IACH,KAAK,CAAC,OAAO,CAAC,EAAU;QACtB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QAC7C,OAAO,MAAM,CAAC,CAAC,CAAC,uBAAuB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACzD,CAAC;CACF;AAED,MAAM,UAAU,uBAAuB,CACrC,MAAwB;IAExB,OAAO;QACL,UAAU,EAAE,6BAA6B,CAAC,MAAM,CAAC,UAAU,CAAC;QAC5D,OAAO,EAAE,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC;KACvD,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,6BAA6B,CAC3C,UAAiC;IAEjC,OAAO;QACL,EAAE,EAAE,UAAU,CAAC,EAAE;QACjB,UAAU,EAAE,UAAU,CAAC,UAAU;QACjC,GAAG,EAAE,UAAU,CAAC,GAAG;QACnB,KAAK,EAAE,UAAU,CAAC,KAAK,IAAI,EAAE;QAC7B,YAAY,EAAE,UAAU,CAAC,YAAY;QACrC,SAAS,EAAE,UAAU,CAAC,SAAS;QAC/B,QAAQ,EAAE,UAAU,CAAC,QAAQ;QAC7B,SAAS,EAAE,UAAU,CAAC,SAAS;QAC/B,MAAM,EAAE,UAAU,CAAC,MAAM,IAAI,EAAE;QAC/B,SAAS,EAAE,UAAU,CAAC,SAAS;QAC/B,SAAS,EAAE,UAAU,CAAC,SAAS;KAChC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,yBAAyB,CAAC,MAAyB;IACjE,OAAO;QACL,EAAE,EAAE,MAAM,CAAC,EAAE;QACb,YAAY,EAAE,MAAM,CAAC,YAAY;QACjC,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,KAAK,EAAE,MAAM,CAAC,KAAK,IAAI,EAAE;QACzB,YAAY,EAAE,MAAM,CAAC,YAAY;QACjC,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,SAAS,EAAE,MAAM,CAAC,SAAS;KAC5B,CAAC;AACJ,CAAC"}
@@ -0,0 +1,41 @@
1
+ import type { SupportedEntityType } from '@endora-commerce/contracts';
2
+ /**
3
+ * Supported-entity registry (feature 055, FR-001/FR-006).
4
+ *
5
+ * The single, extensible list of host entity types custom fields may attach to.
6
+ * The admin definition editor and the value validator both consult this map;
7
+ * adding an entity is one entry here plus wiring the host's read/write path
8
+ * (see specs/055-custom-fields-layer/quickstart.md). The generic core stays
9
+ * entity-agnostic — this map carries no host-specific capability logic.
10
+ *
11
+ * `orgOwned` is informational (it drives cross-tenant test selection). Actual
12
+ * tenant isolation is enforced by each host ENTITY's own scope decorator
13
+ * (Principle XI), because a custom-field value is a JSONB column ON the host
14
+ * row — never by this flag.
15
+ */
16
+ export interface SupportedEntityMeta {
17
+ /** i18n key for the operator-facing entity-type label. */
18
+ readonly labelKey: string;
19
+ /** True when the host entity is tenant-scoped (org/customer). Category & Organization are global. */
20
+ readonly orgOwned: boolean;
21
+ /**
22
+ * Optional host-managed marker (feature 061). When present, definitions of
23
+ * this entity type are created/edited/deleted ONLY by the named host
24
+ * module's own surface; the generic admin mutation routes refuse writes with
25
+ * 409 `host_managed` and the generic UI renders the type read-only with a
26
+ * link to `route`. Service-level `apply*` calls are NOT affected — they are
27
+ * the host's path. Generic: any future host may claim it; consumers check
28
+ * only for the marker's presence, never which module manages.
29
+ */
30
+ readonly managedBy?: {
31
+ readonly moduleId: string;
32
+ readonly labelKey: string;
33
+ readonly route: string;
34
+ };
35
+ }
36
+ export declare const SUPPORTED_ENTITIES: Record<SupportedEntityType, SupportedEntityMeta>;
37
+ /** All registered entity-type codes. */
38
+ export declare const SUPPORTED_ENTITY_TYPES: SupportedEntityType[];
39
+ /** True when `value` is a registered, live entity type. Definitions for unlisted types are inert. */
40
+ export declare function isSupportedEntityType(value: string): value is SupportedEntityType;
41
+ //# sourceMappingURL=custom-field-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"custom-field-registry.d.ts","sourceRoot":"","sources":["../../../src/backend/services/custom-field-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,mBAAmB;IAClC,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,qGAAqG;IACrG,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B;;;;;;;;OAQG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE;QACnB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;KACxB,CAAC;CACH;AAED,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,mBAAmB,EAAE,mBAAmB,CAgB/E,CAAC;AAEF,wCAAwC;AACxC,eAAO,MAAM,sBAAsB,EAAsC,mBAAmB,EAAE,CAAC;AAE/F,qGAAqG;AACrG,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,mBAAmB,CAEjF"}
@@ -0,0 +1,24 @@
1
+ export const SUPPORTED_ENTITIES = {
2
+ category: { labelKey: 'customFields.entity.category', orgOwned: false },
3
+ order: { labelKey: 'customFields.entity.order', orgOwned: true },
4
+ organization: { labelKey: 'customFields.entity.organization', orgOwned: false },
5
+ customer: { labelKey: 'customFields.entity.customer', orgOwned: true },
6
+ quote_request: { labelKey: 'customFields.entity.quoteRequest', orgOwned: true },
7
+ product: {
8
+ labelKey: 'customFields.entity.product',
9
+ // Definitions are platform-global; product rows are catalog data, not org-tenant rows.
10
+ orgOwned: false,
11
+ managedBy: {
12
+ moduleId: 'catalog',
13
+ labelKey: 'customFields.managedBy.catalogAttributes',
14
+ route: '/catalog/attributes',
15
+ },
16
+ },
17
+ };
18
+ /** All registered entity-type codes. */
19
+ export const SUPPORTED_ENTITY_TYPES = Object.keys(SUPPORTED_ENTITIES);
20
+ /** True when `value` is a registered, live entity type. Definitions for unlisted types are inert. */
21
+ export function isSupportedEntityType(value) {
22
+ return Object.prototype.hasOwnProperty.call(SUPPORTED_ENTITIES, value);
23
+ }
24
+ //# sourceMappingURL=custom-field-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"custom-field-registry.js","sourceRoot":"","sources":["../../../src/backend/services/custom-field-registry.ts"],"names":[],"mappings":"AAqCA,MAAM,CAAC,MAAM,kBAAkB,GAAqD;IAClF,QAAQ,EAAE,EAAE,QAAQ,EAAE,8BAA8B,EAAE,QAAQ,EAAE,KAAK,EAAE;IACvE,KAAK,EAAE,EAAE,QAAQ,EAAE,2BAA2B,EAAE,QAAQ,EAAE,IAAI,EAAE;IAChE,YAAY,EAAE,EAAE,QAAQ,EAAE,kCAAkC,EAAE,QAAQ,EAAE,KAAK,EAAE;IAC/E,QAAQ,EAAE,EAAE,QAAQ,EAAE,8BAA8B,EAAE,QAAQ,EAAE,IAAI,EAAE;IACtE,aAAa,EAAE,EAAE,QAAQ,EAAE,kCAAkC,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC/E,OAAO,EAAE;QACP,QAAQ,EAAE,6BAA6B;QACvC,uFAAuF;QACvF,QAAQ,EAAE,KAAK;QACf,SAAS,EAAE;YACT,QAAQ,EAAE,SAAS;YACnB,QAAQ,EAAE,0CAA0C;YACpD,KAAK,EAAE,qBAAqB;SAC7B;KACF;CACF,CAAC;AAEF,wCAAwC;AACxC,MAAM,CAAC,MAAM,sBAAsB,GAAG,MAAM,CAAC,IAAI,CAAC,kBAAkB,CAA0B,CAAC;AAE/F,qGAAqG;AACrG,MAAM,UAAU,qBAAqB,CAAC,KAAa;IACjD,OAAO,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,kBAAkB,EAAE,KAAK,CAAC,CAAC;AACzE,CAAC"}
@@ -0,0 +1,56 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { CustomFieldValidationError as CustomFieldValidationErrorDto, SupportedEntityType } from '@endora-commerce/contracts';
3
+ import type { CachedDefinition } from './custom-field-definitions-cache.js';
4
+ /**
5
+ * Provides the live definitions (with options) for an entity type. Implemented
6
+ * by `CustomFieldDefinitionService`; taken as an interface here so the value
7
+ * service has no concrete dependency on the definition service (and is trivially
8
+ * unit-testable with a stub).
9
+ */
10
+ export interface DefinitionSource {
11
+ listForEntity(entityType: SupportedEntityType): Promise<CachedDefinition[]>;
12
+ }
13
+ /** Thrown by {@link CustomFieldValueService.validateAndMerge}; serialized as HTTP 422 (FR-004). */
14
+ export declare class CustomFieldValidationError extends Error {
15
+ readonly errors: CustomFieldValidationErrorDto[];
16
+ constructor(errors: CustomFieldValidationErrorDto[]);
17
+ }
18
+ /**
19
+ * CustomFieldValueService (feature 055) — validates a host record's custom-field
20
+ * bag against its definitions and strips dormant keys on read.
21
+ *
22
+ * It performs NO database write and NO audit: the host persists its own record
23
+ * and (per Principle XIII) audits its own write. This service only validates the
24
+ * incoming values and returns the merged bag — keeping module boundaries intact
25
+ * (Principle I) and avoiding double-audit (Principle XIII / XIV).
26
+ */
27
+ export declare class CustomFieldValueService {
28
+ private readonly definitions;
29
+ constructor(definitions: DefinitionSource);
30
+ /**
31
+ * Validate an incoming patch against the entity's definitions and return the
32
+ * merged bag to persist. Throws {@link CustomFieldValidationError} with
33
+ * per-field errors on any violation (FR-004). Unknown patch keys (no matching
34
+ * definition) are ignored. Dormant keys already in the bag are retained (FR-010).
35
+ */
36
+ validateAndMerge(entityType: SupportedEntityType, currentBag: Record<string, unknown>, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
37
+ /**
38
+ * Strip keys whose definition no longer exists (dormant values, FR-010 / US2
39
+ * scenario 4). Hosts call this before returning `customFieldValues` on read.
40
+ */
41
+ project(entityType: SupportedEntityType, bag: Record<string, unknown>): Promise<Record<string, unknown>>;
42
+ /**
43
+ * Existence probe for definition-change guards (FR-010): is there any host row
44
+ * with a stored value at `key`? Read-only JSONB introspection on the host table.
45
+ */
46
+ hasStoredValues(em: EntityManager, entityType: SupportedEntityType, key: string): Promise<boolean>;
47
+ /**
48
+ * Is a specific option value in use for a `select`/`multiselect` field?
49
+ * (option-removal guard). Known limitation: the flat `->>key` probe does not
50
+ * see through nested per-language value shapes — a host whose storage nests
51
+ * values under the key runs its own authoritative in-use checks first; this
52
+ * probe is defense-in-depth for the flat shapes (feature 061, research §R9).
53
+ */
54
+ isOptionInUse(em: EntityManager, entityType: SupportedEntityType, key: string, optionValue: string): Promise<boolean>;
55
+ }
56
+ //# sourceMappingURL=custom-field-value.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"custom-field-value.service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/custom-field-value.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,0BAA0B,IAAI,6BAA6B,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACnI,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAE5E;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,aAAa,CAAC,UAAU,EAAE,mBAAmB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;CAC7E;AAED,mGAAmG;AACnG,qBAAa,0BAA2B,SAAQ,KAAK;IACvC,QAAQ,CAAC,MAAM,EAAE,6BAA6B,EAAE;gBAAvC,MAAM,EAAE,6BAA6B,EAAE;CAI7D;AAmBD;;;;;;;;GAQG;AACH,qBAAa,uBAAuB;IACtB,OAAO,CAAC,QAAQ,CAAC,WAAW;gBAAX,WAAW,EAAE,gBAAgB;IAE1D;;;;;OAKG;IACG,gBAAgB,CACpB,UAAU,EAAE,mBAAmB,EAC/B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IA+BnC;;;OAGG;IACG,OAAO,CACX,UAAU,EAAE,mBAAmB,EAC/B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAUnC;;;OAGG;IACG,eAAe,CACnB,EAAE,EAAE,aAAa,EACjB,UAAU,EAAE,mBAAmB,EAC/B,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,OAAO,CAAC;IASnB;;;;;;OAMG;IACG,aAAa,CACjB,EAAE,EAAE,aAAa,EACjB,UAAU,EAAE,mBAAmB,EAC/B,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,OAAO,CAAC;CAWpB"}
@@ -0,0 +1,175 @@
1
+ /** Thrown by {@link CustomFieldValueService.validateAndMerge}; serialized as HTTP 422 (FR-004). */
2
+ export class CustomFieldValidationError extends Error {
3
+ errors;
4
+ constructor(errors) {
5
+ super(`Custom field validation failed (${errors.length} error(s)).`);
6
+ this.errors = errors;
7
+ this.name = 'CustomFieldValidationError';
8
+ }
9
+ }
10
+ /**
11
+ * Host-table binding for the change-guard probes (read-only introspection,
12
+ * feature 055; generalized to a per-host `{table, column}` pair by feature 061
13
+ * — the `product` host stores its value bag in `products.attribute_values`).
14
+ * Purely a storage binding: no host logic lives here.
15
+ */
16
+ const HOST_TABLE_BY_ENTITY = {
17
+ category: { table: 'categories', column: 'custom_field_values' },
18
+ order: { table: 'orders', column: 'custom_field_values' },
19
+ organization: { table: 'organizations', column: 'custom_field_values' },
20
+ customer: { table: 'customer_accounts', column: 'custom_field_values' },
21
+ quote_request: { table: 'quote_requests', column: 'custom_field_values' },
22
+ product: { table: 'products', column: 'attribute_values' },
23
+ };
24
+ const DEFAULT_TEXT_MAX = 10_000;
25
+ /**
26
+ * CustomFieldValueService (feature 055) — validates a host record's custom-field
27
+ * bag against its definitions and strips dormant keys on read.
28
+ *
29
+ * It performs NO database write and NO audit: the host persists its own record
30
+ * and (per Principle XIII) audits its own write. This service only validates the
31
+ * incoming values and returns the merged bag — keeping module boundaries intact
32
+ * (Principle I) and avoiding double-audit (Principle XIII / XIV).
33
+ */
34
+ export class CustomFieldValueService {
35
+ definitions;
36
+ constructor(definitions) {
37
+ this.definitions = definitions;
38
+ }
39
+ /**
40
+ * Validate an incoming patch against the entity's definitions and return the
41
+ * merged bag to persist. Throws {@link CustomFieldValidationError} with
42
+ * per-field errors on any violation (FR-004). Unknown patch keys (no matching
43
+ * definition) are ignored. Dormant keys already in the bag are retained (FR-010).
44
+ */
45
+ async validateAndMerge(entityType, currentBag, patch) {
46
+ const defs = await this.definitions.listForEntity(entityType);
47
+ const merged = { ...currentBag };
48
+ const errors = [];
49
+ for (const { definition, options } of defs) {
50
+ const key = definition.key;
51
+ const inPatch = Object.prototype.hasOwnProperty.call(patch, key);
52
+ const raw = inPatch ? patch[key] : currentBag[key];
53
+ if (isEmpty(raw)) {
54
+ if (definition.required) {
55
+ errors.push({ field: key, code: 'missing_required', message: `Field "${key}" is required.` });
56
+ }
57
+ else {
58
+ delete merged[key];
59
+ }
60
+ continue;
61
+ }
62
+ const result = coerce(definition.valueType, raw, options);
63
+ if ('code' in result) {
64
+ errors.push({ field: key, code: result.code, message: result.message });
65
+ }
66
+ else {
67
+ merged[key] = result.value;
68
+ }
69
+ }
70
+ if (errors.length > 0)
71
+ throw new CustomFieldValidationError(errors);
72
+ return merged;
73
+ }
74
+ /**
75
+ * Strip keys whose definition no longer exists (dormant values, FR-010 / US2
76
+ * scenario 4). Hosts call this before returning `customFieldValues` on read.
77
+ */
78
+ async project(entityType, bag) {
79
+ const defs = await this.definitions.listForEntity(entityType);
80
+ const live = new Set(defs.map((d) => d.definition.key));
81
+ const out = {};
82
+ for (const [k, v] of Object.entries(bag)) {
83
+ if (live.has(k))
84
+ out[k] = v;
85
+ }
86
+ return out;
87
+ }
88
+ /**
89
+ * Existence probe for definition-change guards (FR-010): is there any host row
90
+ * with a stored value at `key`? Read-only JSONB introspection on the host table.
91
+ */
92
+ async hasStoredValues(em, entityType, key) {
93
+ const { table, column } = HOST_TABLE_BY_ENTITY[entityType];
94
+ const rows = await em.getConnection().execute(`select 1 as one from "${table}" where jsonb_exists("${column}", ?) limit 1`, [key]);
95
+ return rows.length > 0;
96
+ }
97
+ /**
98
+ * Is a specific option value in use for a `select`/`multiselect` field?
99
+ * (option-removal guard). Known limitation: the flat `->>key` probe does not
100
+ * see through nested per-language value shapes — a host whose storage nests
101
+ * values under the key runs its own authoritative in-use checks first; this
102
+ * probe is defense-in-depth for the flat shapes (feature 061, research §R9).
103
+ */
104
+ async isOptionInUse(em, entityType, key, optionValue) {
105
+ const { table, column } = HOST_TABLE_BY_ENTITY[entityType];
106
+ const rows = await em.getConnection().execute(`select 1 as one from "${table}" ` +
107
+ `where "${column}"->>? = ? ` +
108
+ `or (jsonb_typeof("${column}"->?) = 'array' and "${column}"->? @> to_jsonb(?::text)) ` +
109
+ `limit 1`, [key, optionValue, key, key, optionValue]);
110
+ return rows.length > 0;
111
+ }
112
+ }
113
+ /** A value is "empty" (⇒ absent) when undefined, null, '', or an empty array. */
114
+ function isEmpty(v) {
115
+ return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
116
+ }
117
+ /** Validate + coerce one value by its declared type. Returns the stored value or a typed error. */
118
+ function coerce(valueType, raw, options) {
119
+ switch (valueType) {
120
+ case 'text': {
121
+ if (typeof raw !== 'string')
122
+ return { code: 'wrong_type', message: 'Expected a text value.' };
123
+ if (raw.length > DEFAULT_TEXT_MAX)
124
+ return { code: 'out_of_range', message: 'Text value is too long.' };
125
+ return { value: raw };
126
+ }
127
+ case 'number': {
128
+ const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN;
129
+ if (!Number.isFinite(n))
130
+ return { code: 'wrong_type', message: 'Expected a number.' };
131
+ return { value: n };
132
+ }
133
+ case 'boolean': {
134
+ if (typeof raw === 'boolean')
135
+ return { value: raw };
136
+ if (raw === 'true')
137
+ return { value: true };
138
+ if (raw === 'false')
139
+ return { value: false };
140
+ return { code: 'wrong_type', message: 'Expected a boolean.' };
141
+ }
142
+ case 'date': {
143
+ if (typeof raw !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw) || Number.isNaN(Date.parse(raw))) {
144
+ return { code: 'wrong_type', message: 'Expected an ISO date (YYYY-MM-DD).' };
145
+ }
146
+ return { value: raw };
147
+ }
148
+ case 'select': {
149
+ if (typeof raw !== 'string')
150
+ return { code: 'wrong_type', message: 'Expected a single option value.' };
151
+ if (!options.some((o) => o.value === raw)) {
152
+ return { code: 'unknown_option', message: `Unknown option "${raw}".` };
153
+ }
154
+ return { value: raw };
155
+ }
156
+ case 'multiselect': {
157
+ if (!Array.isArray(raw) || !raw.every((v) => typeof v === 'string')) {
158
+ return { code: 'wrong_type', message: 'Expected an array of option values.' };
159
+ }
160
+ const seen = new Set();
161
+ for (const v of raw) {
162
+ if (seen.has(v))
163
+ return { code: 'duplicate_option', message: `Duplicate option "${v}".` };
164
+ seen.add(v);
165
+ if (!options.some((o) => o.value === v)) {
166
+ return { code: 'unknown_option', message: `Unknown option "${v}".` };
167
+ }
168
+ }
169
+ return { value: [...raw] };
170
+ }
171
+ default:
172
+ return { code: 'wrong_type', message: `Unsupported value type "${valueType}".` };
173
+ }
174
+ }
175
+ //# sourceMappingURL=custom-field-value.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"custom-field-value.service.js","sourceRoot":"","sources":["../../../src/backend/services/custom-field-value.service.ts"],"names":[],"mappings":"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"}