@ankhorage/contracts 25.2.0 → 26.0.1

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.
@@ -1,15 +1,13 @@
1
- import { readOwnProperty } from '@ankhorage/utility/object';
2
1
  import { describe, expect, it } from 'bun:test';
3
2
 
3
+ import {
4
+ isComponentDataBindingRegistry,
5
+ isScreenDataLoaderDefinition,
6
+ } from './appManifest/bindings';
4
7
  import type {
5
- AppManifest,
6
- BindingInputMap,
7
- BindingValueSource,
8
- ComponentDataBinding,
8
+ BindingExpression,
9
+ BindingInvocation,
9
10
  ComponentDataBindingRegistry,
10
- EventBinding,
11
- OperationScreenDataLoaderDefinition,
12
- PropBinding,
13
11
  ScreenDataLoaderDefinition,
14
12
  } from './index';
15
13
 
@@ -17,420 +15,80 @@ function assertSerializable<TValue>(value: TValue): void {
17
15
  expect(JSON.parse(JSON.stringify(value))).toEqual(value);
18
16
  }
19
17
 
20
- describe('component data-binding contracts', () => {
21
- it('serializes a component prop bound to an operation response path', () => {
22
- const binding: ComponentDataBinding = {
23
- componentId: 'hero-title',
24
- componentType: 'Text',
25
- props: {
26
- children: {
27
- source: {
28
- kind: 'operation',
29
- operation: {
30
- apiId: 'cms',
31
- endpointId: 'pages',
32
- operationId: 'pages.getHome',
33
- },
34
- path: '$.data.title',
35
- },
36
- fallback: {
37
- value: 'Untitled',
38
- },
39
- loading: {
40
- state: 'loading',
41
- fallback: {
42
- value: 'Loading…',
43
- },
44
- },
45
- error: {
46
- state: 'error',
47
- message: 'Could not load title.',
48
- fallback: {
49
- value: 'Untitled',
50
- },
51
- },
52
- transforms: ['trim'],
53
- },
54
- },
55
- };
56
-
57
- assertSerializable(binding);
58
- expect(binding.props?.children?.source.kind).toBe('operation');
59
- });
60
-
61
- it('serializes literal, context, event, state, and operation value sources', () => {
62
- const sources: readonly BindingValueSource[] = [
63
- { kind: 'literal', value: { fallback: 'Untitled' } },
64
- { kind: 'context', path: 'auth.user.displayName' },
65
- { kind: 'event', path: 'payload.values.search' },
66
- { kind: 'state', path: 'forms.search.query' },
67
- {
68
- kind: 'operation',
69
- operation: {
70
- apiId: 'search-api',
71
- operationId: 'search.query',
72
- },
73
- path: '$.results',
74
- },
75
- ];
76
-
77
- assertSerializable(sources);
78
- expect(sources.map((source) => source.kind)).toEqual([
79
- 'literal',
80
- 'context',
81
- 'event',
82
- 'state',
83
- 'operation',
84
- ]);
85
- });
86
-
87
- it('serializes an event binding that executes an API operation', () => {
88
- const input: BindingInputMap = {
89
- email: {
90
- kind: 'source',
91
- source: {
92
- kind: 'event',
93
- path: 'payload.values.email',
94
- },
95
- transforms: ['trim', 'lowercase'],
96
- },
97
- message: {
98
- kind: 'source',
99
- source: {
100
- kind: 'event',
101
- path: 'payload.values.message',
102
- },
103
- },
104
- source: {
105
- kind: 'literal',
106
- value: 'contact-form',
107
- },
108
- metadata: {
109
- kind: 'object',
110
- fields: {
111
- userId: {
112
- kind: 'source',
113
- source: {
114
- kind: 'context',
115
- path: 'auth.user.id',
116
- },
117
- },
118
- },
119
- },
18
+ describe('capability binding contracts', () => {
19
+ it('uses an emitted capability as a readable reference with a projected path', () => {
20
+ const reference: BindingExpression = {
21
+ capability: 'event.formChanged',
22
+ path: 'payload.values.email',
120
23
  };
121
24
 
122
- const binding: EventBinding = {
123
- target: {
124
- kind: 'operation',
125
- operation: {
126
- apiId: 'contact-api',
127
- endpointId: 'messages',
128
- operationId: 'messages.create',
129
- },
130
- },
131
- input,
132
- when: {
133
- source: {
134
- kind: 'event',
135
- path: 'payload.values.email',
136
- },
137
- operator: 'exists',
138
- },
139
- };
140
-
141
- assertSerializable(binding);
142
- expect(binding.target.kind).toBe('operation');
143
- expect(binding.input?.email?.kind).toBe('source');
25
+ assertSerializable(reference);
26
+ expect(reference).toEqual({ capability: 'event.formChanged', path: 'payload.values.email' });
144
27
  });
145
28
 
146
- it('serializes an event binding that targets a named action', () => {
147
- const binding: EventBinding = {
148
- target: {
149
- kind: 'action',
150
- type: 'toast.show',
151
- },
29
+ it('uses an invoke capability as the one event target shape', () => {
30
+ const target: BindingInvocation = {
31
+ capability: 'api.contact.createMessage',
152
32
  input: {
153
- message: {
154
- kind: 'literal',
155
- value: 'Saved successfully.',
156
- },
33
+ email: { capability: 'event.formChanged', path: 'payload.values.email' },
157
34
  },
35
+ result: 'submittedMessage',
158
36
  };
159
37
 
160
- assertSerializable(binding);
161
- expect(binding.target.kind).toBe('action');
162
- });
163
-
164
- it('serializes canonical operation screen data loaders', () => {
165
- const loader: ScreenDataLoaderDefinition = {
166
- kind: 'operation',
167
- id: 'product-detail',
168
- operation: {
169
- apiId: 'nutrition',
170
- endpointId: 'products',
171
- operationId: 'nutrition.products.getById',
172
- },
173
- input: {
174
- id: {
175
- kind: 'source',
176
- source: {
177
- kind: 'context',
178
- path: 'route.params.id',
179
- },
180
- },
181
- },
182
- } satisfies OperationScreenDataLoaderDefinition;
183
-
184
- assertSerializable(loader);
185
- expect(loader.kind).toBe('operation');
38
+ assertSerializable(target);
39
+ expect(target.capability).toBe('api.contact.createMessage');
186
40
  });
187
41
 
188
- it('serializes scanner lookup and conditional navigation intent using existing generic bindings', () => {
189
- const productLookupOperation = {
190
- apiId: 'nutrition',
191
- endpointId: 'products',
192
- operationId: 'products.lookupByBarcode',
193
- } as const;
194
-
195
- const bindings: ComponentDataBindingRegistry = {
196
- scanner: {
197
- componentId: 'scanner',
198
- componentType: 'BarcodeScannerView',
199
- events: {
200
- barcodeScanned: [
201
- {
202
- target: {
203
- kind: 'operation',
204
- operation: productLookupOperation,
205
- },
206
- input: {
207
- barcode: {
208
- kind: 'source',
209
- source: {
210
- kind: 'event',
211
- path: 'payload.value',
212
- },
213
- transforms: ['trim'],
214
- },
215
- },
216
- when: {
217
- source: {
218
- kind: 'event',
219
- path: 'payload.value',
220
- },
221
- operator: 'exists',
222
- },
223
- },
224
- {
225
- target: {
226
- kind: 'action',
227
- type: 'navigate',
228
- },
229
- input: {
230
- route: {
231
- kind: 'literal',
232
- value: 'product-detail',
233
- },
234
- params: {
235
- kind: 'object',
236
- fields: {
237
- barcode: {
238
- kind: 'source',
239
- source: {
240
- kind: 'event',
241
- path: 'payload.value',
242
- },
243
- transforms: ['trim'],
244
- },
245
- productId: {
246
- kind: 'source',
247
- source: {
248
- kind: 'operation',
249
- operation: productLookupOperation,
250
- path: 'product.id',
251
- },
252
- },
253
- },
254
- },
255
- },
256
- when: {
257
- source: {
258
- kind: 'operation',
259
- operation: productLookupOperation,
260
- path: 'product.id',
261
- },
262
- operator: 'exists',
263
- },
264
- },
265
- {
266
- target: {
267
- kind: 'action',
268
- type: 'navigate',
269
- },
270
- input: {
271
- route: {
272
- kind: 'literal',
273
- value: 'product-capture',
274
- },
275
- params: {
276
- kind: 'object',
277
- fields: {
278
- barcode: {
279
- kind: 'source',
280
- source: {
281
- kind: 'event',
282
- path: 'payload.value',
283
- },
284
- transforms: ['trim'],
285
- },
286
- },
287
- },
288
- },
289
- when: {
290
- source: {
291
- kind: 'operation',
292
- operation: productLookupOperation,
293
- path: 'product.id',
294
- },
295
- operator: 'notExists',
296
- },
297
- },
298
- ],
299
- },
300
- },
42
+ it('references a cached invocation result without modelling another execution target', () => {
43
+ const reference: BindingExpression = {
44
+ capability: 'api.products.lookup',
45
+ result: 'lookup',
46
+ path: 'product.id',
301
47
  };
302
48
 
303
- assertSerializable(bindings);
304
- expect(bindings.scanner?.events?.barcodeScanned).toHaveLength(3);
305
- expect(bindings.scanner?.events?.barcodeScanned?.[0]?.target.kind).toBe('operation');
306
- expect(bindings.scanner?.events?.barcodeScanned?.[1]?.when?.source.kind).toBe('operation');
307
- expect(bindings.scanner?.events?.barcodeScanned?.[2]?.target.type).toBe('navigate');
49
+ assertSerializable(reference);
50
+ expect(reference.result).toBe('lookup');
308
51
  });
309
52
 
310
- it('serializes repeated container nodes with generic item-context bindings', () => {
311
- const productsListOperation = {
312
- apiId: 'catalog-api',
313
- endpointId: 'products',
314
- operationId: 'products.list',
315
- } as const;
316
-
317
- const root: AppManifest['screens'][string]['root'] = {
318
- id: 'products-grid',
319
- type: 'Grid',
320
- repeat: {
321
- source: {
322
- kind: 'operation',
323
- operation: productsListOperation,
324
- },
325
- itemAlias: 'item',
326
- keyPath: 'id',
327
- },
328
- children: [
329
- {
330
- id: 'product-card-template',
331
- type: 'ProductCard',
332
- },
333
- ],
334
- };
335
-
336
- const bindings: ComponentDataBindingRegistry = {
337
- 'product-card-template': {
338
- componentId: 'product-card-template',
339
- componentType: 'ProductCard',
340
- props: {
341
- title: {
342
- source: {
343
- kind: 'context',
344
- path: 'item.name',
345
- },
346
- },
347
- brand: {
348
- source: {
349
- kind: 'context',
350
- path: 'item.brand',
351
- },
352
- },
353
- description: {
354
- source: {
355
- kind: 'context',
356
- path: 'item.barcode',
53
+ it('keeps nested literal and reference objects and arrays expressible', () => {
54
+ const expression: BindingExpression = {
55
+ kind: 'object',
56
+ fields: {
57
+ route: { kind: 'literal', value: '/products/[id]' },
58
+ params: {
59
+ kind: 'object',
60
+ fields: {
61
+ id: { capability: 'api.products.lookup', result: 'lookup', path: 'product.id' },
62
+ tags: {
63
+ kind: 'array',
64
+ items: [
65
+ { kind: 'literal', value: 'featured' },
66
+ { capability: 'state.search', path: 'selectedTag' },
67
+ ],
357
68
  },
358
69
  },
359
70
  },
360
- events: {
361
- press: [
362
- {
363
- target: {
364
- kind: 'action',
365
- type: 'navigate',
366
- },
367
- input: {
368
- route: {
369
- kind: 'literal',
370
- value: '/products/[id]',
371
- },
372
- params: {
373
- kind: 'object',
374
- fields: {
375
- id: {
376
- kind: 'source',
377
- source: {
378
- kind: 'context',
379
- path: 'item.id',
380
- },
381
- },
382
- },
383
- },
384
- },
385
- },
386
- ],
387
- },
388
71
  },
389
72
  };
390
73
 
391
- assertSerializable({ root, bindings });
392
- expect(root.repeat).toEqual({
393
- source: {
394
- kind: 'operation',
395
- operation: productsListOperation,
396
- },
397
- itemAlias: 'item',
398
- keyPath: 'id',
399
- });
400
- expect(bindings['product-card-template']?.props?.title?.source).toEqual({
401
- kind: 'context',
402
- path: 'item.name',
403
- });
404
- expect(bindings['product-card-template']?.events?.press?.[0]?.input?.params).toEqual({
405
- kind: 'object',
406
- fields: {
407
- id: {
408
- kind: 'source',
409
- source: {
410
- kind: 'context',
411
- path: 'item.id',
412
- },
413
- },
414
- },
415
- });
74
+ assertSerializable(expression);
75
+ expect(expression.kind).toBe('object');
416
76
  });
417
77
 
418
- it('serializes component data-binding registries', () => {
78
+ it('uses recursive expressions for transforms, conditions and fallbacks', () => {
419
79
  const bindings: ComponentDataBindingRegistry = {
420
- 'submit-button': {
421
- componentId: 'submit-button',
422
- componentType: 'Button',
80
+ submit: {
81
+ componentId: 'submit',
423
82
  props: {
424
83
  children: {
425
- source: {
426
- kind: 'literal',
427
- value: 'Send',
428
- },
429
- },
430
- disabled: {
431
- source: {
432
- kind: 'state',
433
- path: 'forms.contact.isSubmitting',
84
+ value: {
85
+ kind: 'fallback',
86
+ value: {
87
+ kind: 'transform',
88
+ value: { capability: 'state.profile', path: 'displayName' },
89
+ transforms: ['trim'],
90
+ },
91
+ fallback: { kind: 'literal', value: 'Guest' },
434
92
  },
435
93
  },
436
94
  },
@@ -438,11 +96,13 @@ describe('component data-binding contracts', () => {
438
96
  press: [
439
97
  {
440
98
  target: {
441
- kind: 'operation',
442
- operation: {
443
- apiId: 'contact-api',
444
- operationId: 'messages.create',
445
- },
99
+ capability: 'navigator.navigate',
100
+ input: { route: { kind: 'literal', value: 'home' } },
101
+ },
102
+ when: {
103
+ source: { capability: 'event.press', path: 'payload.enabled' },
104
+ operator: 'eq',
105
+ value: { kind: 'literal', value: true },
446
106
  },
447
107
  },
448
108
  ],
@@ -451,124 +111,34 @@ describe('component data-binding contracts', () => {
451
111
  };
452
112
 
453
113
  assertSerializable(bindings);
454
- expect(bindings['submit-button']?.events?.press?.[0]?.target.kind).toBe('operation');
114
+ expect(isComponentDataBindingRegistry(bindings)).toBe(true);
455
115
  });
456
116
 
457
- it('serializes app-level APIs and dataBindings on the manifest', () => {
458
- const propBinding: PropBinding = {
459
- source: {
460
- kind: 'operation',
461
- operation: {
462
- apiId: 'cms',
463
- endpointId: 'pages',
464
- operationId: 'pages.getHome',
465
- },
466
- path: '$.data.heroTitle',
467
- },
117
+ it('uses the same invocation shape for screen loaders', () => {
118
+ const loader: ScreenDataLoaderDefinition = {
119
+ capability: 'api.products.getById',
120
+ result: 'product',
121
+ input: { id: { capability: 'context.route', path: 'params.id' } },
468
122
  };
469
123
 
470
- const manifest: AppManifest = {
471
- metadata: {
472
- name: 'Demo',
473
- slug: 'demo',
474
- version: '1.0.0',
475
- category: 'developer_tools',
476
- themeId: 'default',
477
- },
478
- themes: {
479
- default: {
480
- id: 'default',
481
- name: 'Default',
482
- light: {
483
- primaryColor: '#3366ff',
484
- harmony: 'analogous',
485
- },
486
- dark: {
487
- primaryColor: '#3366ff',
488
- harmony: 'analogous',
489
- },
490
- },
491
- },
492
- activeThemeId: 'default',
493
- infra: {
494
- environments: {
495
- local: {
496
- deployment: { compute: { provider: 'local' }, runtime: { provider: 'minikube' } },
497
- },
498
- },
499
- apis: {
500
- cms: {
501
- id: 'cms',
502
- origin: 'external',
503
- protocol: 'rest',
504
- baseUrl: 'https://cms.example.com',
505
- endpoints: {
506
- pages: {
507
- id: 'pages',
508
- kind: 'http',
509
- path: '/pages/home',
510
- operations: {
511
- 'pages.getHome': {
512
- id: 'pages.getHome',
513
- endpointId: 'pages',
514
- protocol: 'http',
515
- intent: 'read',
516
- method: 'GET',
517
- path: '/pages/home',
518
- },
519
- },
520
- },
521
- },
522
- },
523
- },
524
- modules: {},
525
- },
526
- navigator: {
527
- type: 'stack',
528
- routes: [
529
- {
530
- name: 'home',
531
- screenId: 'home',
532
- },
533
- ],
534
- },
535
- screens: {
536
- home: {
537
- id: 'home',
538
- name: 'Home',
539
- root: {
540
- id: 'screen-root',
541
- type: 'Screen',
542
- children: [
543
- {
544
- id: 'hero-title',
545
- type: 'Text',
546
- },
547
- ],
548
- },
549
- },
550
- },
551
- dataBindings: {
552
- 'hero-title': {
553
- componentId: 'hero-title',
554
- componentType: 'Text',
555
- props: {
556
- children: propBinding,
557
- },
558
- },
559
- },
560
- settings: {
561
- localization: {
562
- defaultLocale: 'en',
563
- locales: ['en'],
564
- },
565
- },
566
- };
124
+ assertSerializable(loader);
125
+ expect(isScreenDataLoaderDefinition(loader)).toBe(true);
126
+ });
567
127
 
568
- assertSerializable(manifest);
569
- expect(manifest.dataBindings?.['hero-title']?.props?.children?.source.kind).toBe('operation');
570
- expect(manifest.infra.apis ? readOwnProperty(manifest.infra.apis, 'cms')?.id : undefined).toBe(
571
- 'cms',
572
- );
128
+ it('rejects superseded action, operation and source union shapes', () => {
129
+ expect(
130
+ isComponentDataBindingRegistry({
131
+ submit: {
132
+ componentId: 'submit',
133
+ events: { press: [{ target: { kind: 'action', type: 'navigate' } }] },
134
+ },
135
+ }),
136
+ ).toBe(false);
137
+ expect(
138
+ isScreenDataLoaderDefinition({
139
+ kind: 'operation',
140
+ operation: { apiId: 'products', operationId: 'products.list' },
141
+ }),
142
+ ).toBe(false);
573
143
  });
574
144
  });