@open-mercato/shared 0.6.7-develop.6758.1.697eade236 → 0.6.7-develop.6768.1.9d2c4efc43

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.
@@ -0,0 +1,101 @@
1
+ import {
2
+ CRUD_FORM_EXTENSION_SURFACES,
3
+ CRUD_FORM_LIFECYCLE_PHASES,
4
+ CRUD_FORM_OPERATIONS,
5
+ DATA_TABLE_EXTENSION_SURFACES,
6
+ crudFormExtensionHost,
7
+ crudFormExtensionSpotId,
8
+ dataTableExtensionHost,
9
+ dataTableExtensionSpotId,
10
+ defineModuleExtensionPoints,
11
+ extensionSpotChildId,
12
+ injectionExtensionHost,
13
+ resolveExtensionPointPattern,
14
+ } from '@open-mercato/shared/modules/widgets/extension-points'
15
+
16
+ describe('module extension point declarations', () => {
17
+ it('preserves exact ids and immutable declarations', () => {
18
+ const extensionPoints = defineModuleExtensionPoints({
19
+ moduleId: 'catalog',
20
+ hosts: {
21
+ products: dataTableExtensionHost({
22
+ baseSpotId: 'data-table:catalog.products',
23
+ tableId: 'catalog.products.list',
24
+ source: 'components/products/ProductsDataTable.tsx',
25
+ }),
26
+ productForm: crudFormExtensionHost({
27
+ entityId: 'catalog.product',
28
+ spotId: 'crud-form:catalog.product',
29
+ source: 'backend/catalog/products/[id]/page.tsx',
30
+ }),
31
+ },
32
+ })
33
+
34
+ expect(extensionPoints.moduleId).toBe('catalog')
35
+ expect(extensionPoints.hosts.products.tableId).toBe('catalog.products.list')
36
+ expect(Object.isFrozen(extensionPoints)).toBe(true)
37
+ expect(Object.isFrozen(extensionPoints.hosts)).toBe(true)
38
+ })
39
+
40
+ it('requires named parameters for dynamic patterns', () => {
41
+ // @ts-expect-error patterned declarations require named parameters
42
+ expect(() => injectionExtensionHost({
43
+ family: 'integration',
44
+ pattern: 'integrations.detail:{integrationId}',
45
+ supported: ['render-widget'],
46
+ source: 'backend/integrations/[id]/page.tsx',
47
+ })).toThrow('patterned injection extension hosts require named parameters')
48
+ })
49
+
50
+ it('describes only the surfaces bound by DataTable and CrudForm', () => {
51
+ expect(DATA_TABLE_EXTENSION_SURFACES.filter((surface) => surface.bound).map((surface) => surface.key)).toEqual([
52
+ 'header',
53
+ 'footer',
54
+ 'toolbar',
55
+ 'searchTrailing',
56
+ 'columns',
57
+ 'rowActions',
58
+ 'bulkActions',
59
+ 'filters',
60
+ 'replacement',
61
+ ])
62
+ expect(DATA_TABLE_EXTENSION_SURFACES.find((surface) => surface.key === 'emptyState')?.bound).toBe(false)
63
+ expect(CRUD_FORM_EXTENSION_SURFACES.filter((surface) => !surface.bound).map((surface) => surface.key)).toEqual([
64
+ 'beforeFields',
65
+ 'afterFields',
66
+ 'footer',
67
+ 'sidebar',
68
+ 'group',
69
+ 'fieldBefore',
70
+ 'fieldAfter',
71
+ ])
72
+ expect(CRUD_FORM_LIFECYCLE_PHASES).toEqual([
73
+ 'transformValidation',
74
+ 'transformDisplayData',
75
+ 'onBeforeNavigate',
76
+ 'onAppEvent',
77
+ 'onVisibilityChange',
78
+ 'onBeforeDelete',
79
+ 'onDelete',
80
+ 'onAfterDelete',
81
+ 'onDeleteError',
82
+ 'onFieldChange',
83
+ 'transformFormData',
84
+ 'onBeforeSave',
85
+ 'onSave',
86
+ 'onAfterSave',
87
+ ])
88
+ expect(CRUD_FORM_OPERATIONS).toEqual(['create', 'update', 'delete'])
89
+ })
90
+
91
+ it('builds standard family ids without changing punctuation', () => {
92
+ expect(dataTableExtensionSpotId('catalog.products.list', 'columns')).toBe(
93
+ 'data-table:catalog.products.list:columns',
94
+ )
95
+ expect(crudFormExtensionSpotId('catalog.product', 'fields')).toBe('crud-form:catalog.product:fields')
96
+ expect(extensionSpotChildId('legacy:custom-host', 'header')).toBe('legacy:custom-host:header')
97
+ expect(resolveExtensionPointPattern('integrations.detail:{integrationId}', {
98
+ integrationId: 'stripe',
99
+ })).toBe('integrations.detail:stripe')
100
+ })
101
+ })
@@ -0,0 +1,421 @@
1
+ export type ExtensionHostFamily =
2
+ | 'generic'
3
+ | 'menu'
4
+ | 'data-table'
5
+ | 'crud-form'
6
+ | 'detail'
7
+ | 'portal-page'
8
+ | 'component-handle'
9
+ | 'entity'
10
+ | 'api-route'
11
+ | 'command'
12
+ | 'event'
13
+ | 'query-lifecycle'
14
+ | 'dashboard'
15
+ | 'notification'
16
+ | 'integration'
17
+ | 'specialized-registry'
18
+ | 'module-override'
19
+
20
+ export type ExtensionHostCapability =
21
+ | 'render-widget'
22
+ | 'headless-widget'
23
+ | 'menu-item'
24
+ | 'column-widget'
25
+ | 'row-action'
26
+ | 'bulk-action'
27
+ | 'filter-widget'
28
+ | 'toolbar-widget'
29
+ | 'field-widget'
30
+ | 'lifecycle-handler'
31
+ | 'component-replacement'
32
+ | 'response-enricher'
33
+ | 'query-enricher'
34
+ | 'api-interceptor'
35
+ | 'command-interceptor'
36
+ | 'mutation-guard'
37
+ | 'entity-extension'
38
+ | 'async-subscriber'
39
+ | 'sync-subscriber'
40
+ | 'browser-client'
41
+ | 'browser-portal'
42
+ | 'registry-contribution'
43
+ | 'module-override'
44
+
45
+ export type ExtensionHostActivation = 'always' | 'host-opt-in' | 'caller-opt-in' | 'feature-gated'
46
+
47
+ export type ExtensionPointPatternParameter = {
48
+ source: string
49
+ pattern?: string
50
+ }
51
+
52
+ type ExtensionHostDeclarationBase = {
53
+ source: string
54
+ contextContract?: string
55
+ dataContract?: string
56
+ scopeContract?: string
57
+ runtimeContract?: string
58
+ activation?: ExtensionHostActivation
59
+ aliases?: readonly string[]
60
+ fallbacks?: readonly string[]
61
+ }
62
+
63
+ type InjectionExtensionHostDeclarationBase = ExtensionHostDeclarationBase & {
64
+ family: Exclude<ExtensionHostFamily, 'data-table' | 'crud-form' | 'component-handle'>
65
+ supported: readonly ExtensionHostCapability[]
66
+ }
67
+
68
+ export type InjectionExtensionHostDeclaration = InjectionExtensionHostDeclarationBase & (
69
+ | {
70
+ spotId: string
71
+ pattern?: never
72
+ parameters?: never
73
+ }
74
+ | {
75
+ spotId?: never
76
+ pattern: string
77
+ parameters: Readonly<Record<string, ExtensionPointPatternParameter>>
78
+ }
79
+ )
80
+
81
+ export type DataTableExtensionHostDeclaration = ExtensionHostDeclarationBase & {
82
+ family: 'data-table'
83
+ tableId: string
84
+ baseSpotId?: string
85
+ }
86
+
87
+ export type CrudFormExtensionHostDeclaration = ExtensionHostDeclarationBase & {
88
+ family: 'crud-form'
89
+ entityId: string
90
+ spotId?: string
91
+ }
92
+
93
+ export type ComponentExtensionHostDeclaration = ExtensionHostDeclarationBase & {
94
+ family: 'component-handle'
95
+ componentId: string
96
+ propsContract?: string
97
+ }
98
+
99
+ export type ModuleExtensionHostDeclaration =
100
+ | InjectionExtensionHostDeclaration
101
+ | DataTableExtensionHostDeclaration
102
+ | CrudFormExtensionHostDeclaration
103
+ | ComponentExtensionHostDeclaration
104
+
105
+ export type ModuleExtensionPoints<
106
+ TModuleId extends string = string,
107
+ THosts extends Readonly<Record<string, ModuleExtensionHostDeclaration>> = Readonly<Record<string, ModuleExtensionHostDeclaration>>,
108
+ > = {
109
+ moduleId: TModuleId
110
+ hosts: THosts
111
+ }
112
+
113
+ export function defineModuleExtensionPoints<
114
+ const TModuleId extends string,
115
+ const THosts extends Readonly<Record<string, ModuleExtensionHostDeclaration>>,
116
+ >(declaration: ModuleExtensionPoints<TModuleId, THosts>): ModuleExtensionPoints<TModuleId, THosts> {
117
+ return Object.freeze({
118
+ ...declaration,
119
+ hosts: Object.freeze({ ...declaration.hosts }),
120
+ })
121
+ }
122
+
123
+ export function injectionExtensionHost<const TDeclaration extends InjectionExtensionHostDeclaration>(
124
+ declaration: TDeclaration,
125
+ ): Readonly<TDeclaration> {
126
+ const hasExactId = typeof declaration.spotId === 'string' && declaration.spotId.length > 0
127
+ const hasPattern = typeof declaration.pattern === 'string' && declaration.pattern.length > 0
128
+ if (hasExactId === hasPattern) {
129
+ throw new Error('[internal] injection extension hosts require exactly one of spotId or pattern')
130
+ }
131
+ if (hasPattern && (!declaration.parameters || Object.keys(declaration.parameters).length === 0)) {
132
+ throw new Error('[internal] patterned injection extension hosts require named parameters')
133
+ }
134
+ return Object.freeze({ ...declaration })
135
+ }
136
+
137
+ export function dataTableExtensionHost<
138
+ const TDeclaration extends Omit<DataTableExtensionHostDeclaration, 'family'>,
139
+ >(
140
+ declaration: TDeclaration,
141
+ ): Readonly<TDeclaration & { family: 'data-table' }> {
142
+ return Object.freeze({ family: 'data-table', ...declaration })
143
+ }
144
+
145
+ export function crudFormExtensionHost<
146
+ const TDeclaration extends Omit<CrudFormExtensionHostDeclaration, 'family'>,
147
+ >(
148
+ declaration: TDeclaration,
149
+ ): Readonly<TDeclaration & { family: 'crud-form' }> {
150
+ return Object.freeze({ family: 'crud-form', ...declaration })
151
+ }
152
+
153
+ export function componentExtensionHost<
154
+ const TDeclaration extends Omit<ComponentExtensionHostDeclaration, 'family'>,
155
+ >(
156
+ declaration: TDeclaration,
157
+ ): Readonly<TDeclaration & { family: 'component-handle' }> {
158
+ return Object.freeze({ family: 'component-handle', ...declaration })
159
+ }
160
+
161
+ export type BoundExtensionSurface = {
162
+ key: string
163
+ suffix: string | null
164
+ capabilities: readonly ExtensionHostCapability[]
165
+ bound: boolean
166
+ phases?: readonly string[]
167
+ operations?: readonly string[]
168
+ }
169
+
170
+ export const DATA_TABLE_EXTENSION_SURFACES = [
171
+ { key: 'header', suffix: 'header', capabilities: ['render-widget'], bound: true },
172
+ { key: 'footer', suffix: 'footer', capabilities: ['render-widget'], bound: true },
173
+ { key: 'toolbar', suffix: 'toolbar', capabilities: ['toolbar-widget'], bound: true },
174
+ { key: 'searchTrailing', suffix: 'search-trailing', capabilities: ['render-widget'], bound: true },
175
+ { key: 'columns', suffix: 'columns', capabilities: ['column-widget'], bound: true },
176
+ { key: 'rowActions', suffix: 'row-actions', capabilities: ['row-action'], bound: true },
177
+ { key: 'bulkActions', suffix: 'bulk-actions', capabilities: ['bulk-action'], bound: true },
178
+ { key: 'filters', suffix: 'filters', capabilities: ['filter-widget'], bound: true },
179
+ { key: 'replacement', suffix: null, capabilities: ['component-replacement'], bound: true },
180
+ { key: 'emptyState', suffix: 'empty-state', capabilities: ['render-widget'], bound: false },
181
+ ] as const satisfies readonly BoundExtensionSurface[]
182
+
183
+ export const CRUD_FORM_EXTENSION_SURFACES = [
184
+ { key: 'base', suffix: null, capabilities: ['render-widget', 'lifecycle-handler'], bound: true },
185
+ { key: 'header', suffix: 'header', capabilities: ['render-widget'], bound: true },
186
+ { key: 'fields', suffix: 'fields', capabilities: ['field-widget'], bound: true },
187
+ { key: 'replacement', suffix: null, capabilities: ['component-replacement'], bound: true },
188
+ { key: 'beforeFields', suffix: 'before-fields', capabilities: ['render-widget'], bound: false },
189
+ { key: 'afterFields', suffix: 'after-fields', capabilities: ['render-widget'], bound: false },
190
+ { key: 'footer', suffix: 'footer', capabilities: ['render-widget'], bound: false },
191
+ { key: 'sidebar', suffix: 'sidebar', capabilities: ['render-widget'], bound: false },
192
+ { key: 'group', suffix: 'group:{groupId}', capabilities: ['render-widget'], bound: false },
193
+ { key: 'fieldBefore', suffix: 'field:{fieldId}:before', capabilities: ['render-widget'], bound: false },
194
+ { key: 'fieldAfter', suffix: 'field:{fieldId}:after', capabilities: ['render-widget'], bound: false },
195
+ ] as const satisfies readonly BoundExtensionSurface[]
196
+
197
+ export const CRUD_FORM_LIFECYCLE_PHASES = [
198
+ 'transformValidation',
199
+ 'transformDisplayData',
200
+ 'onBeforeNavigate',
201
+ 'onAppEvent',
202
+ 'onVisibilityChange',
203
+ 'onBeforeDelete',
204
+ 'onDelete',
205
+ 'onAfterDelete',
206
+ 'onDeleteError',
207
+ 'onFieldChange',
208
+ 'transformFormData',
209
+ 'onBeforeSave',
210
+ 'onSave',
211
+ 'onAfterSave',
212
+ ] as const
213
+
214
+ export const CRUD_FORM_OPERATIONS = ['create', 'update', 'delete'] as const
215
+
216
+ export function dataTableExtensionSpotId(tableId: string, suffix?: string): string {
217
+ return suffix ? `data-table:${tableId}:${suffix}` : `data-table:${tableId}`
218
+ }
219
+
220
+ export function crudFormExtensionSpotId(entityId: string, suffix?: string): string {
221
+ return suffix ? `crud-form:${entityId}:${suffix}` : `crud-form:${entityId}`
222
+ }
223
+
224
+ export function extensionSpotChildId(spotId: string, suffix: string): string {
225
+ return `${spotId}:${suffix}`
226
+ }
227
+
228
+ export function resolveExtensionPointPattern(
229
+ pattern: string,
230
+ parameters: Readonly<Record<string, string>>,
231
+ ): string {
232
+ return pattern.replace(/\{([^}]+)\}/g, (token, parameterName: string) => parameters[parameterName] ?? token)
233
+ }
234
+
235
+ export type ModuleExtensionSurfaceFacts = {
236
+ hosts: ModuleExtensionHostFact[]
237
+ contributions: ModuleExtensionContributionFact[]
238
+ unresolved: ModuleExtensionUnresolvedFact[]
239
+ }
240
+
241
+ export type ModuleExtensionHostFact = {
242
+ key: string
243
+ id: string
244
+ resolution: 'exact' | 'pattern' | 'framework' | 'fact-ref'
245
+ family: ExtensionHostFamily
246
+ ownerModule: string
247
+ capabilities: ExtensionHostCapability[]
248
+ phases?: string[]
249
+ operations?: string[]
250
+ contextContract?: string
251
+ dataContract?: string
252
+ scopeContract?: string
253
+ runtimeContract?: string
254
+ activation?: ExtensionHostActivation
255
+ bound: boolean
256
+ stability: 'frozen' | 'stable'
257
+ source:
258
+ | { kind: 'declaration'; path: string; symbol: string }
259
+ | { kind: 'fact-ref'; factSection: string; factKey: string }
260
+ | { kind: 'framework'; path: string; symbol: string }
261
+ aliases?: string[]
262
+ patternParameters?: Record<string, ExtensionPointPatternParameter>
263
+ fallbacks?: string[]
264
+ }
265
+
266
+ export type ModuleExtensionTargetFact = {
267
+ id: string
268
+ resolution: 'exact' | 'pattern' | 'framework' | 'fact-ref' | 'optional-external' | 'unresolved'
269
+ factRef?: { factSection: string; factKey: string }
270
+ optionalOwnerPackage?: string
271
+ }
272
+
273
+ export type ModuleExtensionContributionBase = {
274
+ id: string
275
+ targets: ModuleExtensionTargetFact[]
276
+ phases?: string[]
277
+ operations?: string[]
278
+ features?: string[]
279
+ scopeContract: string
280
+ activation?: ExtensionHostActivation
281
+ placement?: { relativeTo?: string; position?: 'first' | 'last' | 'before' | 'after'; priority?: number }
282
+ roundTripId?: string
283
+ override?: { domain: string; key: string; mode: 'disable-replace' | 'replace' | 'additive' }
284
+ source: { path: string; symbol?: string }
285
+ }
286
+
287
+ export type ModuleExtensionContributionFact = ModuleExtensionContributionBase & (
288
+ | {
289
+ kind: 'widget'
290
+ details: {
291
+ payload: 'render' | 'headless' | 'menu' | 'dashboard' | 'notification' | 'integration'
292
+ registryKey: string
293
+ itemIds?: string[]
294
+ labelKeys?: string[]
295
+ contextContract?: string
296
+ dataContract?: string
297
+ executionGuard: 'host' | 'contribution' | 'both'
298
+ }
299
+ }
300
+ | {
301
+ kind: 'data-table'
302
+ details: {
303
+ payload: 'column' | 'row-action' | 'bulk-action' | 'filter' | 'toolbar' | 'render'
304
+ tableId: string
305
+ executionGuard: 'host' | 'contribution' | 'both'
306
+ }
307
+ }
308
+ | {
309
+ kind: 'crud-form'
310
+ details: {
311
+ payload: 'render' | 'field' | 'lifecycle-handler'
312
+ entityId: string
313
+ fieldIds?: string[]
314
+ groupIds?: string[]
315
+ requestHeaderCapability: boolean
316
+ }
317
+ }
318
+ | {
319
+ kind: 'component-override'
320
+ details: { handle: string; mode: 'replace' | 'wrapper' | 'props'; propsContract: string }
321
+ }
322
+ | {
323
+ kind: 'response-enricher'
324
+ details: {
325
+ targetEntity: string
326
+ surfaces: Array<'list' | 'detail'>
327
+ timeoutMs: number
328
+ fallback: 'none' | 'configured'
329
+ critical: boolean
330
+ cachePosture: 'record-pure' | 'rerun-on-list-cache-hit'
331
+ queryEngine?: {
332
+ engines: string[]
333
+ applyOn: Array<'list' | 'detail'>
334
+ activation: 'caller-opt-in'
335
+ }
336
+ }
337
+ }
338
+ | {
339
+ kind: 'api-interceptor'
340
+ details: {
341
+ route: string
342
+ methods: string[]
343
+ phases: Array<'before' | 'after'>
344
+ activation: 'crud-pipeline' | 'custom-route-bridge'
345
+ timeoutMs: number
346
+ failurePosture: 'fail-closed' | 'fallback'
347
+ }
348
+ }
349
+ | {
350
+ kind: 'command-interceptor'
351
+ details: {
352
+ targetCommand: string
353
+ phases: Array<'before-execute' | 'after-execute' | 'before-undo' | 'after-undo'>
354
+ }
355
+ }
356
+ | {
357
+ kind: 'mutation-guard'
358
+ details: {
359
+ entityId: string
360
+ operations: Array<'create' | 'update' | 'delete'>
361
+ capabilities: Array<'block' | 'rewrite' | 'after-success'>
362
+ optimisticLock: 'preserved'
363
+ }
364
+ }
365
+ | {
366
+ kind: 'entity-extension'
367
+ details: {
368
+ hostEntityId: string
369
+ extensionEntityId: string
370
+ linkId: string
371
+ scopeContract: string
372
+ orphanContract: string
373
+ }
374
+ }
375
+ | {
376
+ kind: 'subscriber'
377
+ details: {
378
+ event: string
379
+ subscriberId: string
380
+ persistent: boolean
381
+ sync: boolean
382
+ priority?: number
383
+ }
384
+ }
385
+ | {
386
+ kind: 'browser-reaction'
387
+ details: {
388
+ transports: Array<'client' | 'portal' | 'notification-effect'>
389
+ hooks: string[]
390
+ audienceScopeContract: string
391
+ maxPayloadBytes?: number
392
+ dedupWindowMs?: number
393
+ }
394
+ }
395
+ | {
396
+ kind: 'specialized-registry'
397
+ details: {
398
+ registry: 'notification' | 'integration' | 'search' | 'vector' | 'ai' | 'payment' | 'shipping' | 'currency' | 'workflow'
399
+ registryId: string
400
+ specialistRoute: string
401
+ }
402
+ }
403
+ | {
404
+ kind: 'module-override'
405
+ details: {
406
+ domain: string
407
+ key: string
408
+ mode: 'disable-replace' | 'replace' | 'additive'
409
+ }
410
+ }
411
+ )
412
+
413
+ export type ModuleExtensionUnresolvedFact = {
414
+ key: string
415
+ source: { path: string; symbol?: string }
416
+ reason:
417
+ | 'unclassified-binding'
418
+ | 'unbound-declaration'
419
+ | 'dynamic-without-pattern'
420
+ | 'unresolved-first-party-target'
421
+ }