@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,177 @@
1
+ import { type ModuleUninstallHook } from '@endora-commerce/contracts';
2
+ /**
3
+ * Custom Fields module — manifest (feature 055).
4
+ *
5
+ * Owns the entity-agnostic custom-field definition/option registry and the
6
+ * value-validation service consumed by host modules. Platform-global; values
7
+ * live on host rows and inherit the host's tenant scope (Principle XI).
8
+ * Definition/option mutations run through the Command Bus (Principle XIII).
9
+ */
10
+ export declare const manifest: {
11
+ id: string;
12
+ name: string;
13
+ version: string;
14
+ dependencies: string[];
15
+ description?: string | undefined;
16
+ acknowledgedDependencies?: {
17
+ moduleId: string;
18
+ port: string;
19
+ reason: string;
20
+ }[] | undefined;
21
+ nonBindingDependencies?: {
22
+ moduleId: string;
23
+ name: string;
24
+ kind: "contributes-to" | "degrades-without" | "refuses-without";
25
+ reason: string;
26
+ whenAbsent?: string | undefined;
27
+ }[] | undefined;
28
+ activation?: {
29
+ settingCode: string;
30
+ default: boolean;
31
+ } | {
32
+ nonDeactivatable: true;
33
+ reason: string;
34
+ } | undefined;
35
+ settings?: {
36
+ moduleCode: string;
37
+ groups: {
38
+ code: string;
39
+ name: string;
40
+ salesChannelCodes?: string[] | undefined;
41
+ isSystemProtected?: boolean | undefined;
42
+ }[];
43
+ settings: {
44
+ code: string;
45
+ name: string;
46
+ valueType: "string" | "number" | "boolean" | "json" | "string_list" | "secret" | "credential_ref";
47
+ defaultValue: unknown;
48
+ description?: string | undefined;
49
+ groupCode?: string | undefined;
50
+ previousDefaultValues?: unknown[] | undefined;
51
+ salesChannelCodes?: string[] | undefined;
52
+ enumOptions?: string[] | undefined;
53
+ configurationType?: string | undefined;
54
+ hidden?: boolean | undefined;
55
+ }[];
56
+ } | undefined;
57
+ i18n?: {
58
+ bundlesDir: string;
59
+ } | undefined;
60
+ docs?: false | {
61
+ dir: string;
62
+ } | undefined;
63
+ demo?: false | {
64
+ summary: string;
65
+ seed: (context: import("@endora-commerce/contracts").ModuleDemoContext<never>) => Promise<import("@endora-commerce/contracts").DemoSeedResult>;
66
+ reset: (context: import("@endora-commerce/contracts").ModuleDemoContext<never>) => Promise<import("@endora-commerce/contracts").DemoResetResult>;
67
+ after?: readonly string[] | undefined;
68
+ package?: string | undefined;
69
+ } | undefined;
70
+ actions?: {
71
+ id: string;
72
+ labelKey: string;
73
+ icon: "Plus" | "Sparkles" | "Settings" | "Search" | "Boxes" | "Layers" | "Menu" | "PlusCircle" | "PlusSquare" | "FilePlus" | "FolderPlus" | "Upload" | "FileUp" | "CloudUpload" | "Download" | "FileDown" | "FileText" | "BookOpen" | "Rss" | "Package" | "Tag" | "ShoppingCart" | "Receipt" | "CreditCard" | "Users" | "UserPlus" | "Inbox" | "ListChecks" | "ClipboardList" | "Image" | "Video" | "LayoutDashboard" | "PanelLeft" | "KeyRound" | "ShieldCheck" | "Edit" | "Archive" | "Box" | "Truck" | "CircleDollarSign" | "Activity" | "LineChart" | "Smartphone" | "Webhook" | "Scale" | "PlugZap" | "PercentDiamond" | "Newspaper" | "Languages" | "Eraser" | "Warehouse" | "TrendingDown" | "Bell" | "PackageOpen" | "Building2" | "Store" | "ClipboardCheck";
74
+ targetRoute: string;
75
+ keywords: string[];
76
+ weight: number;
77
+ descriptionKey?: string | undefined;
78
+ requiredPermission?: string | undefined;
79
+ }[] | undefined;
80
+ permissions?: {
81
+ code: string;
82
+ label: string;
83
+ module?: string | undefined;
84
+ description?: string | undefined;
85
+ requires?: string[] | undefined;
86
+ }[] | undefined;
87
+ transactionalEmails?: {
88
+ code: string;
89
+ name: string;
90
+ variables: {
91
+ key: string;
92
+ label: string;
93
+ sampleValue?: string | undefined;
94
+ description?: string | undefined;
95
+ }[];
96
+ description?: string | undefined;
97
+ group?: string | undefined;
98
+ }[] | undefined;
99
+ capabilities?: string[] | undefined;
100
+ exclusiveCapabilities?: {
101
+ key: string;
102
+ errorCode: string;
103
+ }[] | undefined;
104
+ errorCodes?: {
105
+ code: string;
106
+ tokens?: string[] | undefined;
107
+ }[] | undefined;
108
+ blocks?: {
109
+ name: string;
110
+ labelKey: string;
111
+ category: string;
112
+ contexts: ("invoice" | "email" | "cms" | "newsletter")[];
113
+ fields: Record<string, {
114
+ type: "number" | "object" | "array" | "text" | "textarea" | "select" | "radio" | "external" | "uuid" | "richtext";
115
+ label?: string | undefined;
116
+ required?: boolean | undefined;
117
+ options?: {
118
+ label: string;
119
+ value: string | number;
120
+ }[] | undefined;
121
+ refKind?: string | undefined;
122
+ }>;
123
+ descriptionKey?: string | undefined;
124
+ defaultProps?: Record<string, unknown> | undefined;
125
+ responsiveFields?: string[] | undefined;
126
+ previewIcon?: string | undefined;
127
+ weight?: number | undefined;
128
+ }[] | undefined;
129
+ blockCategories?: {
130
+ key: string;
131
+ titleKey: string;
132
+ contexts: ("invoice" | "email" | "cms" | "newsletter")[];
133
+ weight?: number | undefined;
134
+ visible?: boolean | undefined;
135
+ }[] | undefined;
136
+ env?: {
137
+ name: string;
138
+ describes: {
139
+ en: string;
140
+ pl: string;
141
+ };
142
+ requirement: {
143
+ kind: "required";
144
+ } | {
145
+ kind: "requiredWhen";
146
+ input: string;
147
+ equals: string;
148
+ } | {
149
+ kind: "optional";
150
+ without: {
151
+ en: string;
152
+ pl: string;
153
+ };
154
+ };
155
+ secret: boolean;
156
+ generable: boolean;
157
+ owner: {
158
+ kind: "platform";
159
+ } | {
160
+ kind: "application";
161
+ application: "admin" | "backend" | "storefront";
162
+ } | {
163
+ kind: "module";
164
+ moduleId: string;
165
+ };
166
+ consumers: ("admin" | "backend" | "storefront")[];
167
+ addressOf: "admin" | "backend" | "storefront" | null;
168
+ }[] | undefined;
169
+ };
170
+ /**
171
+ * Hard-uninstall cleanup (feature 055). A soft uninstall keeps definitions so a
172
+ * re-install restores them; a hard uninstall drops all definitions (options
173
+ * cascade via FK). Host `custom_field_values` columns are owned by their host
174
+ * modules and removed with them, so nothing dangles either way.
175
+ */
176
+ export declare const uninstallHook: ModuleUninstallHook;
177
+ //# sourceMappingURL=manifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAG5F;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyGnB,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,aAAa,EAAE,mBAO3B,CAAC"}
@@ -0,0 +1,130 @@
1
+ import { defineModuleManifest } from '@endora-commerce/contracts';
2
+ /**
3
+ * Custom Fields module — manifest (feature 055).
4
+ *
5
+ * Owns the entity-agnostic custom-field definition/option registry and the
6
+ * value-validation service consumed by host modules. Platform-global; values
7
+ * live on host rows and inherit the host's tenant scope (Principle XI).
8
+ * Definition/option mutations run through the Command Bus (Principle XIII).
9
+ */
10
+ export const manifest = defineModuleManifest({
11
+ id: 'custom_fields',
12
+ name: 'Custom Fields',
13
+ description: 'Entity-agnostic runtime custom fields for core entities.',
14
+ version: '1.0.0',
15
+ // `auth` owns the `requireAdmin` port the admin routes are gated by.
16
+ dependencies: ['auth'],
17
+ i18n: { bundlesDir: 'i18n' },
18
+ /**
19
+ * The `CUSTOM_FIELD_*` family — D-129's remaining sweep, Tier A
20
+ * (`specs/090-module-owned-error-codes/d129-sweep.md` §5.2, Appendix A;
21
+ * MR 3).
22
+ *
23
+ * All five were declared by `_i18n` until this merge request, not because
24
+ * anybody judged them the platform's but because the deleted prefix chain
25
+ * had no rule for them and its last line was `return 'core'`. **D-121 T1
26
+ * puts them here**: the noun in every one of them is a custom field — its
27
+ * definition, its key, its option set or a value being written against it —
28
+ * and this module owns the definition registry, the option rows and the
29
+ * validation service that decides all five refusals.
30
+ *
31
+ * **`CUSTOM_FIELD_VALUE_INVALID` is the one a raise-site count gets wrong,
32
+ * and it is the reason the family is declared as a family.** Five modules
33
+ * raise it — `catalog`, `customers`, `orders`, `organizations` and
34
+ * `quote_requests` — and not one of them decides it: each calls this
35
+ * module's validation seam over its own host rows and re-answers the
36
+ * failures it gets back. The judgement, the rules it is made against and the
37
+ * definitions those rules come from are all here, so T1 puts the code here
38
+ * and T2's "sole thrower" question never arises. `d129-sweep.md` §2.3 flags
39
+ * it by name for exactly that reason. `CUSTOM_FIELD_HOST_MANAGED` is the
40
+ * mirror image: it is raised only here, and it refuses a definition whose
41
+ * entity type another module manages — the marker is read
42
+ * entity-agnostically, so the refusal is about this registry rather than
43
+ * about whichever module happens to be named in it.
44
+ *
45
+ * **No sentence moves with them.** None of the five has a sentence in either
46
+ * language anywhere in the tree; all five were already on
47
+ * `UNTRANSLATED_ERROR_CODES` under `_i18n` and move to this module's group
48
+ * there, so the bundle this module already ships gains no key.
49
+ *
50
+ * **`tokens` is derived from the raise sites, not from the bundle**
51
+ * (runbook §5), and there are none to declare. `CUSTOM_FIELD_VALUE_INVALID`
52
+ * is the one that carries `details`, and it is the Zod-shaped
53
+ * `{ path, issue }[]` array, which `refusalToken` reads as no token at all
54
+ * (`packages/platform/src/http/error-envelope.ts`); the other four are bare
55
+ * `HttpError(status, code, message)`.
56
+ */
57
+ errorCodes: [
58
+ { code: 'CUSTOM_FIELD_DEFINITION_INVALID' },
59
+ { code: 'CUSTOM_FIELD_HOST_MANAGED' },
60
+ { code: 'CUSTOM_FIELD_KEY_CONFLICT' },
61
+ { code: 'CUSTOM_FIELD_NOT_FOUND' },
62
+ { code: 'CUSTOM_FIELD_VALUE_INVALID' },
63
+ ],
64
+ permissions: [
65
+ { code: 'custom_fields:read', label: 'View custom fields' },
66
+ { code: 'custom_fields:write', label: 'Manage custom fields (definitions and options)' },
67
+ ],
68
+ actions: [
69
+ {
70
+ id: 'open-custom-fields',
71
+ labelKey: 'actions.openCustomFields.label',
72
+ descriptionKey: 'actions.openCustomFields.description',
73
+ icon: 'Layers',
74
+ targetRoute: '/custom-fields',
75
+ requiredPermission: 'custom_fields:read',
76
+ keywords: ['custom fields', 'attributes', 'pola', 'niestandardowe'],
77
+ weight: 240,
78
+ },
79
+ ],
80
+ // Feature 074 (Constitution XVII), test C3 — platform primitive. The flag
81
+ // used to rest on `organizations` declaring this module; ruling 2 withdraws
82
+ // a dependent's authority to impose the lock, so the ground is now this
83
+ // module's own. It is Principle XIV's extensibility mechanism: the answer
84
+ // the platform gives to "add a field" instead of a bespoke column. Switching
85
+ // it off does not remove a capability a client chose, it makes the values
86
+ // already stored against every host entity unreachable.
87
+ /**
88
+ * Nothing to demonstrate of its own (feature 113, T224 — contract §1.2).
89
+ *
90
+ * The demo shop does have seven product-host custom-field definitions, and
91
+ * they are not this module's demo data: a product attribute is one of those
92
+ * definitions paired 1:1 with a `catalog` extension row and written in one
93
+ * call (feature 061), so it is two modules' rows in one statement and belongs
94
+ * to whoever owns the instance (§5.1). It is a step of
95
+ * `backend/src/seeds/demo-composition.ts`, guarded on both modules, and this
96
+ * module does not declare `catalog` — §2.3's rule that `demo` may not become a
97
+ * way of acquiring a dependency.
98
+ *
99
+ * That is a **correction to §3.3**, which proposed that this module seed the
100
+ * definitions and `catalog` seed the extensions behind an advisory `after`
101
+ * edge. `catalog` would then have had to read `custom_field_definitions` to
102
+ * find the id its extension row references, which §2.2 forbids, so the split
103
+ * has no implementation.
104
+ *
105
+ * `false` rather than absent, because the two are different states: this is a
106
+ * decision that the module owes nothing, not a module nobody has looked at.
107
+ */
108
+ demo: false,
109
+ activation: {
110
+ nonDeactivatable: true,
111
+ reason: 'The platform\'s extensibility mechanism; the custom values already stored against ' +
112
+ 'every host entity become unreachable without it.',
113
+ },
114
+ });
115
+ /**
116
+ * Hard-uninstall cleanup (feature 055). A soft uninstall keeps definitions so a
117
+ * re-install restores them; a hard uninstall drops all definitions (options
118
+ * cascade via FK). Host `custom_field_values` columns are owned by their host
119
+ * modules and removed with them, so nothing dangles either way.
120
+ */
121
+ export const uninstallHook = async (ctx) => {
122
+ if (!ctx.hard)
123
+ return;
124
+ const em = ctx.em;
125
+ await em
126
+ .getConnection()
127
+ .execute('truncate table "custom_field_options", "custom_field_definitions" cascade');
128
+ ctx.log.info('custom_fields: removed all custom-field definitions on hard uninstall');
129
+ };
130
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAA4B,MAAM,4BAA4B,CAAC;AAG5F;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,oBAAoB,CAAC;IAC3C,EAAE,EAAE,eAAe;IACnB,IAAI,EAAE,eAAe;IACrB,WAAW,EAAE,0DAA0D;IACvE,OAAO,EAAE,OAAO;IAChB,qEAAqE;IACrE,YAAY,EAAE,CAAC,MAAM,CAAC;IACtB,IAAI,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE;IAC5B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAsCG;IACH,UAAU,EAAE;QACV,EAAE,IAAI,EAAE,iCAAiC,EAAE;QAC3C,EAAE,IAAI,EAAE,2BAA2B,EAAE;QACrC,EAAE,IAAI,EAAE,2BAA2B,EAAE;QACrC,EAAE,IAAI,EAAE,wBAAwB,EAAE;QAClC,EAAE,IAAI,EAAE,4BAA4B,EAAE;KACvC;IACD,WAAW,EAAE;QACX,EAAE,IAAI,EAAE,oBAAoB,EAAE,KAAK,EAAE,oBAAoB,EAAE;QAC3D,EAAE,IAAI,EAAE,qBAAqB,EAAE,KAAK,EAAE,gDAAgD,EAAE;KACzF;IACD,OAAO,EAAE;QACP;YACE,EAAE,EAAE,oBAAoB;YACxB,QAAQ,EAAE,gCAAgC;YAC1C,cAAc,EAAE,sCAAsC;YACtD,IAAI,EAAE,QAAQ;YACd,WAAW,EAAE,gBAAgB;YAC7B,kBAAkB,EAAE,oBAAoB;YACxC,QAAQ,EAAE,CAAC,eAAe,EAAE,YAAY,EAAE,MAAM,EAAE,gBAAgB,CAAC;YACnE,MAAM,EAAE,GAAG;SACZ;KACF;IACD,0EAA0E;IAC1E,4EAA4E;IAC5E,wEAAwE;IACxE,0EAA0E;IAC1E,6EAA6E;IAC7E,0EAA0E;IAC1E,wDAAwD;IACxD;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,IAAI,EAAE,KAAK;IACX,UAAU,EAAE;QACV,gBAAgB,EAAE,IAAI;QACtB,MAAM,EACJ,oFAAoF;YACpF,kDAAkD;KACrD;CACF,CAAC,CAAC;AAEH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,aAAa,GAAwB,KAAK,EAAE,GAAG,EAAE,EAAE;IAC9D,IAAI,CAAC,GAAG,CAAC,IAAI;QAAE,OAAO;IACtB,MAAM,EAAE,GAAG,GAAG,CAAC,EAAmB,CAAC;IACnC,MAAM,EAAE;SACL,aAAa,EAAE;SACf,OAAO,CAAC,2EAA2E,CAAC,CAAC;IACxF,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,uEAAuE,CAAC,CAAC;AACxF,CAAC,CAAC"}
@@ -0,0 +1,20 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * Feature 055 — Custom Fields Layer (init).
4
+ *
5
+ * Creates the two entity-agnostic module tables:
6
+ * - `custom_field_definitions` — operator-defined typed fields per host
7
+ * entity type (unique `(entity_type, key)`), with per-locale label +
8
+ * fallback, value type, required flag, ordering, and an opaque `config`
9
+ * JSONB for host-capability opt-in the generic core never interprets.
10
+ * - `custom_field_options` — select/multiselect option lists (unique
11
+ * `(definition_id, value)`, FK ON DELETE CASCADE).
12
+ *
13
+ * The per-host `custom_field_values` JSONB columns are added additively by the
14
+ * host modules (migrations 092–096). `product_attributes` is untouched (FR-011).
15
+ */
16
+ export declare class Migration20260718T200338CustomFieldsInit extends Migration {
17
+ up(): Promise<void>;
18
+ down(): Promise<void>;
19
+ }
20
+ //# sourceMappingURL=20260718T200338_custom_fields_init.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260718T200338_custom_fields_init.d.ts","sourceRoot":"","sources":["../../src/migrations/20260718T200338_custom_fields_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;GAaG;AACH,qBAAa,wCAAyC,SAAQ,SAAS;IACtD,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;IAkDnB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAIrC"}
@@ -0,0 +1,60 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * Feature 055 — Custom Fields Layer (init).
4
+ *
5
+ * Creates the two entity-agnostic module tables:
6
+ * - `custom_field_definitions` — operator-defined typed fields per host
7
+ * entity type (unique `(entity_type, key)`), with per-locale label +
8
+ * fallback, value type, required flag, ordering, and an opaque `config`
9
+ * JSONB for host-capability opt-in the generic core never interprets.
10
+ * - `custom_field_options` — select/multiselect option lists (unique
11
+ * `(definition_id, value)`, FK ON DELETE CASCADE).
12
+ *
13
+ * The per-host `custom_field_values` JSONB columns are added additively by the
14
+ * host modules (migrations 092–096). `product_attributes` is untouched (FR-011).
15
+ */
16
+ export class Migration20260718T200338CustomFieldsInit extends Migration {
17
+ async up() {
18
+ this.addSql(`
19
+ create table "custom_field_definitions" (
20
+ "id" uuid not null,
21
+ "entity_type" varchar(32) not null,
22
+ "key" varchar(64) not null,
23
+ "label" jsonb not null default '{}',
24
+ "label_default" varchar(200) not null,
25
+ "value_type" varchar(16) not null,
26
+ "required" boolean not null default false,
27
+ "sort_order" int not null default 0,
28
+ "config" jsonb not null default '{}',
29
+ "created_at" timestamptz not null,
30
+ "updated_at" timestamptz not null,
31
+ constraint "custom_field_definitions_pkey" primary key ("id")
32
+ );
33
+ `);
34
+ this.addSql(`alter table "custom_field_definitions" add constraint "custom_field_definitions_entity_key_unique" unique ("entity_type", "key");`);
35
+ this.addSql(`create index "custom_field_definitions_entity_type_idx" on "custom_field_definitions" ("entity_type");`);
36
+ this.addSql(`
37
+ create table "custom_field_options" (
38
+ "id" uuid not null,
39
+ "definition_id" uuid not null,
40
+ "value" varchar(200) not null,
41
+ "label" jsonb not null default '{}',
42
+ "label_default" varchar(200) not null,
43
+ "is_default" boolean not null default false,
44
+ "sort_order" int not null default 0,
45
+ "created_at" timestamptz not null,
46
+ "updated_at" timestamptz not null,
47
+ constraint "custom_field_options_pkey" primary key ("id")
48
+ );
49
+ `);
50
+ this.addSql(`alter table "custom_field_options" add constraint "custom_field_options_definition_value_unique" unique ("definition_id", "value");`);
51
+ this.addSql(`create index "custom_field_options_definition_idx" on "custom_field_options" ("definition_id");`);
52
+ this.addSql(`alter table "custom_field_options" add constraint "custom_field_options_definition_fk" ` +
53
+ `foreign key ("definition_id") references "custom_field_definitions" ("id") on delete cascade;`);
54
+ }
55
+ async down() {
56
+ this.addSql(`drop table if exists "custom_field_options" cascade;`);
57
+ this.addSql(`drop table if exists "custom_field_definitions" cascade;`);
58
+ }
59
+ }
60
+ //# sourceMappingURL=20260718T200338_custom_fields_init.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260718T200338_custom_fields_init.js","sourceRoot":"","sources":["../../src/migrations/20260718T200338_custom_fields_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;GAaG;AACH,MAAM,OAAO,wCAAyC,SAAQ,SAAS;IAC5D,KAAK,CAAC,EAAE;QACf,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;KAeX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT,mIAAmI,CACpI,CAAC;QACF,IAAI,CAAC,MAAM,CACT,wGAAwG,CACzG,CAAC;QAEF,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;KAaX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT,qIAAqI,CACtI,CAAC;QACF,IAAI,CAAC,MAAM,CACT,iGAAiG,CAClG,CAAC;QACF,IAAI,CAAC,MAAM,CACT,yFAAyF;YACvF,+FAA+F,CAClG,CAAC;IACJ,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,IAAI,CAAC,MAAM,CAAC,sDAAsD,CAAC,CAAC;QACpE,IAAI,CAAC,MAAM,CAAC,0DAA0D,CAAC,CAAC;IAC1E,CAAC;CACF"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The `./migrations` subpath — every migration class this module owns, as one
3
+ * ordered `migrations` array.
4
+ *
5
+ * The array is what the platform reads when this module is **installed**:
6
+ * `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
7
+ * the package outright when it is absent (D-168).
8
+ *
9
+ * Listed in ascending timestamp, which is the order of this module's own
10
+ * migrations and of nothing else (feature 081): a manifest `dependencies` array
11
+ * is the only thing ordering this block against another module's.
12
+ *
13
+ * The **named** exports stay beside the array, and the asymmetry with
14
+ * `./backend` — which publishes an array and no named class (D-168) — is
15
+ * deliberate. `db/migrations-registry.generated.ts` imports each class by name
16
+ * from this specifier, and a migration class name is contract in a way an entity
17
+ * class name is not: `mikro_orm_migrations` persists it, so it is a string every
18
+ * already-migrated database holds.
19
+ *
20
+ * A class that is in neither the array nor the barrel is a migration that does
21
+ * not run: `migration:pending` reports nothing pending and the first symptom is
22
+ * a query against a table nobody created.
23
+ */
24
+ import { Migration20260718T200338CustomFieldsInit } from './20260718T200338_custom_fields_init.js';
25
+ export declare const migrations: (typeof Migration20260718T200338CustomFieldsInit)[];
26
+ export { Migration20260718T200338CustomFieldsInit, };
27
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,wCAAwC,EAAE,MAAM,yCAAyC,CAAC;AAEnG,eAAO,MAAM,UAAU,qDAEtB,CAAC;AAEF,OAAO,EACL,wCAAwC,GACzC,CAAC"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The `./migrations` subpath — every migration class this module owns, as one
3
+ * ordered `migrations` array.
4
+ *
5
+ * The array is what the platform reads when this module is **installed**:
6
+ * `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
7
+ * the package outright when it is absent (D-168).
8
+ *
9
+ * Listed in ascending timestamp, which is the order of this module's own
10
+ * migrations and of nothing else (feature 081): a manifest `dependencies` array
11
+ * is the only thing ordering this block against another module's.
12
+ *
13
+ * The **named** exports stay beside the array, and the asymmetry with
14
+ * `./backend` — which publishes an array and no named class (D-168) — is
15
+ * deliberate. `db/migrations-registry.generated.ts` imports each class by name
16
+ * from this specifier, and a migration class name is contract in a way an entity
17
+ * class name is not: `mikro_orm_migrations` persists it, so it is a string every
18
+ * already-migrated database holds.
19
+ *
20
+ * A class that is in neither the array nor the barrel is a migration that does
21
+ * not run: `migration:pending` reports nothing pending and the first symptom is
22
+ * a query against a table nobody created.
23
+ */
24
+ import { Migration20260718T200338CustomFieldsInit } from './20260718T200338_custom_fields_init.js';
25
+ export const migrations = [
26
+ Migration20260718T200338CustomFieldsInit,
27
+ ];
28
+ export { Migration20260718T200338CustomFieldsInit, };
29
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,wCAAwC,EAAE,MAAM,yCAAyC,CAAC;AAEnG,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,wCAAwC;CACzC,CAAC;AAEF,OAAO,EACL,wCAAwC,GACzC,CAAC"}
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The port interfaces `custom_fields` publishes, and **nothing that exists at
3
+ * runtime** (feature 080, T053(b); D-169, D-171).
4
+ *
5
+ * `tsc` compiles this file to `export {};`. That is the property D-171 makes
6
+ * the boundary decision on — *a subpath is contract surface iff the module it
7
+ * resolves to exports no runtime binding* — and it is why this declaration has
8
+ * its own file rather than sitting on top of `custom-field-definition.service.ts`,
9
+ * which exports the service class and an error class beside it. A consumer
10
+ * naming that file names the owner's implementation, whatever `import type`
11
+ * erases; a consumer naming this one names a declaration and can name nothing
12
+ * else.
13
+ *
14
+ * **This is not yet a supported specifier and the ledger still counts it.**
15
+ * `custom_fields` is not a workspace package, so there is no `exports` map for
16
+ * this to be a subpath of, and `check:module-boundary` reads `catalog`'s
17
+ * relative import exactly as it read the old one — D-171 says so in as many
18
+ * words: `resolveModulePackage` returns `null` for any specifier starting with
19
+ * `.`, so an unconverted reach has no subpath for the exemption to apply to,
20
+ * and reaching the exempt state takes three separable edits (package the owner,
21
+ * publish the interface, rewrite the specifier). This file is the second of
22
+ * those three, taken early because it is the one that does not need the module
23
+ * to move: when `custom_fields` is packaged, this directory becomes the
24
+ * package's `./ports` and the consumer's edit is one specifier.
25
+ *
26
+ * No entity class leaves by this door, type-only included (D-168).
27
+ */
28
+ import type { EntityManager } from '@mikro-orm/postgresql';
29
+ import type { CreateCustomFieldDefinitionRequest, CustomFieldDefinitionRecord, CustomFieldOptionDto, CustomFieldOptionRecord, SupportedEntityType, UpdateCustomFieldDefinitionRequest } from '@endora-commerce/contracts';
30
+ /**
31
+ * Container name: `customFieldDefinitionService`. Owner: `custom_fields`.
32
+ *
33
+ * Transactional apply seam for host modules (feature 061,
34
+ * contracts/custom-fields-product-host.md §3). Every `apply*` call runs inside
35
+ * the CALLER's transactional EM (a host Command's `run({ em })`), performs the
36
+ * same invariants as the public CRUD, and does NO command dispatch, NO audit
37
+ * (the host command audits the composite operation), and NO cache publish.
38
+ * The caller MUST invoke {@link CustomFieldDefinitionApplyApi.publishInvalidate}
39
+ * after its transaction commits.
40
+ *
41
+ * **Which transaction it runs in, said on this side too** (D-77). The caller's:
42
+ * a host Command's `run({ em })`, the same `EntityManager` the host writes its
43
+ * own row on. That is not a convenience — `fk_product_attributes_custom_field_definition`
44
+ * is `on delete restrict` with a `unique` on the same column, so the child
45
+ * insert must see its parent inside one transaction, and a second transaction
46
+ * cannot satisfy a foreign key against a row it cannot see. The seam is
47
+ * therefore permanent, declared, and named on both sides; what would retire it
48
+ * is F4's package entry points, or dropping the constraint.
49
+ *
50
+ * **The `EntityManager` is a required parameter on every `apply*` method and
51
+ * never an optional one** (D-169). Open Mercato's `transactionalEm?` is
52
+ * optional on the context *and* in the handler, so a caller may hand a
53
+ * transaction to a handler that ignores it and receive a silently non-atomic
54
+ * write. Do not relax this to match it.
55
+ *
56
+ * **This shape may not live in `@endora-commerce/contracts`**, which is the whole
57
+ * reason it is declared here: `admin` and `storefront` both compile that
58
+ * package, so it holds zero `@mikro-orm` imports and FR-034 keeps it that way.
59
+ * The qualifying test D-171 states is not *"is this a real published port"* but
60
+ * *"does this signature stop the interface living in `packages/contracts`"*,
61
+ * and seven of these eight methods do.
62
+ *
63
+ * **The returns are published records, not live entities** (D-77's first
64
+ * narrowing). A host reads `id`, `sortOrder`, `labelDefault` and the rest off
65
+ * what comes back; handing it a managed entity also handed it the ability to
66
+ * mutate a definition outside the seam, and the ability to persist that change
67
+ * on the transaction it happens to be holding.
68
+ *
69
+ * **The definition *read* is not here** and is not a seventh `apply*`. It is
70
+ * `CustomFieldDefinitionReadPort.getById` in `@endora-commerce/contracts`, under the
71
+ * container name `customFieldDefinitionReadPort`: a read handed an
72
+ * `EntityManager` is a write seam re-opened to serve a read (D-169), and until
73
+ * T053(b) this interface was where `catalog` got it, typed as a record and
74
+ * answered with the owner's two managed entities.
75
+ *
76
+ * **Owner off:** the seam fails closed — resolving this port throws
77
+ * `ModuleDisabledError` and the call answers 503 `MODULE_DISABLED`, so nothing
78
+ * half-executes. Whether `custom_fields` has an off state at all is its
79
+ * manifest's `activation` to say, not this line's: a module declaring
80
+ * `nonDeactivatable` never enters one.
81
+ */
82
+ export interface CustomFieldDefinitionApplyApi {
83
+ applyCreate(em: EntityManager, input: CreateCustomFieldDefinitionRequest): Promise<CustomFieldDefinitionRecord>;
84
+ applyUpdate(em: EntityManager, id: string, patch: UpdateCustomFieldDefinitionRequest): Promise<CustomFieldDefinitionRecord>;
85
+ applyDelete(em: EntityManager, id: string): Promise<void>;
86
+ /**
87
+ * Rename a definition's key — the one path by which a key changes after
88
+ * create, and **not** an edit: {@link applyUpdate}'s patch omits `key`
89
+ * deliberately, because every host stores its values under the key and an
90
+ * ordinary rename would orphan them (`specs/134-paid-module-extraction/`
91
+ * research D12).
92
+ *
93
+ * The caller is expected to rename the host's values **in the same
94
+ * transaction** — for `product`, through `catalog`'s value-key seam on
95
+ * `@endora-commerce/mod-catalog/ports` — and to call
96
+ * {@link publishInvalidate} after it commits.
97
+ *
98
+ * `expectedKey` is the key the caller planned from: a definition whose key
99
+ * has moved since is refused (`key_changed`) rather than renamed from a
100
+ * state the caller never saw. A key held by another definition of the same
101
+ * entity type is refused (`duplicate_key`), and so is one outside the key
102
+ * grammar (`invalid_key`). Flushes before it returns, so a two-phase rename
103
+ * (park every key, then move it) runs its statements in the order written.
104
+ */
105
+ applyRenameKey(em: EntityManager, id: string, expectedKey: string, newKey: string): Promise<CustomFieldDefinitionRecord>;
106
+ applyCreateOption(em: EntityManager, definitionId: string, input: CustomFieldOptionDto): Promise<CustomFieldOptionRecord>;
107
+ applyUpdateOption(em: EntityManager, definitionId: string, optionId: string, patch: Partial<Pick<CustomFieldOptionDto, 'label' | 'labelDefault' | 'isDefault' | 'sortOrder'>>): Promise<CustomFieldOptionRecord>;
108
+ applyDeleteOption(em: EntityManager, definitionId: string, optionId: string): Promise<void>;
109
+ /**
110
+ * Post-commit responsibility of the caller, and the one method here that
111
+ * takes no `EntityManager` — by construction, since it must run **after** the
112
+ * caller's transaction commits.
113
+ *
114
+ * It stays on the apply seam rather than moving to the read port with
115
+ * `getById`, because it is a step of this seam's protocol and not a question
116
+ * anybody else has: D-97.1 refused to publish the cache mechanism to
117
+ * consumers at all, and answers the reader's real question with
118
+ * `CustomFieldDefinitionReadPort.listForEntityFresh` instead. Only a caller
119
+ * that has just written through `apply*` owes this call.
120
+ */
121
+ publishInvalidate(entityType: SupportedEntityType): Promise<void>;
122
+ }
123
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ports/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EACV,kCAAkC,EAClC,2BAA2B,EAC3B,oBAAoB,EACpB,uBAAuB,EACvB,mBAAmB,EACnB,kCAAkC,EACnC,MAAM,4BAA4B,CAAC;AAEpC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,MAAM,WAAW,6BAA6B;IAC5C,WAAW,CACT,EAAE,EAAE,aAAa,EACjB,KAAK,EAAE,kCAAkC,GACxC,OAAO,CAAC,2BAA2B,CAAC,CAAC;IACxC,WAAW,CACT,EAAE,EAAE,aAAa,EACjB,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,kCAAkC,GACxC,OAAO,CAAC,2BAA2B,CAAC,CAAC;IACxC,WAAW,CAAC,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D;;;;;;;;;;;;;;;;;;OAkBG;IACH,cAAc,CACZ,EAAE,EAAE,aAAa,EACjB,EAAE,EAAE,MAAM,EACV,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,2BAA2B,CAAC,CAAC;IACxC,iBAAiB,CACf,EAAE,EAAE,aAAa,EACjB,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,oBAAoB,GAC1B,OAAO,CAAC,uBAAuB,CAAC,CAAC;IACpC,iBAAiB,CACf,EAAE,EAAE,aAAa,EACjB,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,oBAAoB,EAAE,OAAO,GAAG,cAAc,GAAG,WAAW,GAAG,WAAW,CAAC,CAAC,GAC/F,OAAO,CAAC,uBAAuB,CAAC,CAAC;IACpC,iBAAiB,CAAC,EAAE,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5F;;;;;;;;;;;OAWG;IACH,iBAAiB,CAAC,UAAU,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnE"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ports/index.ts"],"names":[],"mappings":""}
package/i18n/en.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "customFields.title": "Custom Fields",
3
+ "customFields.description": "Define runtime typed fields on core entities without a code change or migration.",
4
+ "customFields.entity.category": "Category",
5
+ "customFields.entity.order": "Order",
6
+ "customFields.entity.organization": "Organization",
7
+ "customFields.entity.customer": "Customer",
8
+ "customFields.entity.quoteRequest": "Quote request",
9
+ "customFields.entity.product": "Product",
10
+ "customFields.managedBy.catalogAttributes": "Managed on Catalog → Attributes",
11
+ "customFields.managedBy.notice": "Definitions for this entity type are managed by another module and are read-only here.",
12
+ "actions.openCustomFields.label": "Custom fields",
13
+ "actions.openCustomFields.description": "Define runtime fields on core entities.",
14
+ "nav.customFields.label": "Custom Fields"
15
+ }
package/i18n/pl.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "customFields.title": "Pola niestandardowe",
3
+ "customFields.description": "Definiuj typowane pola na kluczowych encjach bez zmiany kodu i migracji.",
4
+ "customFields.entity.category": "Kategoria",
5
+ "customFields.entity.order": "Zamówienie",
6
+ "customFields.entity.organization": "Organizacja",
7
+ "customFields.entity.customer": "Klient",
8
+ "customFields.entity.quoteRequest": "Zapytanie ofertowe",
9
+ "customFields.entity.product": "Produkt",
10
+ "customFields.managedBy.catalogAttributes": "Zarządzane w Katalog → Atrybuty",
11
+ "customFields.managedBy.notice": "Definicje dla tego typu encji są zarządzane przez inny moduł i są tutaj tylko do odczytu.",
12
+ "actions.openCustomFields.label": "Pola niestandardowe",
13
+ "actions.openCustomFields.description": "Definiuj pola na kluczowych encjach.",
14
+ "nav.customFields.label": "Pola niestandardowe"
15
+ }