@ankhorage/contracts 1.10.0 → 1.12.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.
@@ -1,96 +1,316 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
 
3
3
  import type {
4
+ AppManifest,
5
+ BindingInputMap,
4
6
  BindingValueSource,
5
- DbCollectionQueryBinding,
6
- NodePropBinding,
7
- UiNode,
7
+ ComponentDataBinding,
8
+ ComponentDataBindingRegistry,
9
+ EventBinding,
10
+ PropBinding,
8
11
  } from './index';
9
12
 
10
- describe('binding contracts', () => {
11
- it('serializes a node prop bound to state', () => {
12
- const binding: NodePropBinding = {
13
- prop: 'value',
14
- valueFrom: {
15
- source: 'state',
16
- path: 'forms.contact.values.firstname',
17
- },
18
- transform: 'trim',
19
- };
20
- const node: UiNode = {
21
- id: 'firstname-input',
22
- type: 'Input',
13
+ function assertSerializable<TValue>(value: TValue): void {
14
+ expect(JSON.parse(JSON.stringify(value))).toEqual(value);
15
+ }
16
+
17
+ describe('component data-binding contracts', () => {
18
+ it('serializes a component prop bound to an operation response path', () => {
19
+ const binding: ComponentDataBinding = {
20
+ componentId: 'hero-title',
21
+ componentType: 'Text',
23
22
  props: {
24
- label: 'Firstname',
23
+ children: {
24
+ source: {
25
+ kind: 'operation',
26
+ operation: {
27
+ dataSourceId: 'cms',
28
+ endpointId: 'pages',
29
+ operationId: 'pages.getHome',
30
+ },
31
+ path: '$.data.title',
32
+ },
33
+ fallback: {
34
+ value: 'Untitled',
35
+ },
36
+ loading: {
37
+ state: 'loading',
38
+ fallback: {
39
+ value: 'Loading…',
40
+ },
41
+ },
42
+ error: {
43
+ state: 'error',
44
+ message: 'Could not load title.',
45
+ fallback: {
46
+ value: 'Untitled',
47
+ },
48
+ },
49
+ transforms: ['trim'],
50
+ },
25
51
  },
26
- bindings: [binding],
27
52
  };
28
53
 
29
- expect(JSON.parse(JSON.stringify(node))).toEqual(node);
30
- expect(node.bindings?.[0]?.valueFrom.source).toBe('state');
54
+ assertSerializable(binding);
55
+ expect(binding.props?.children?.source.kind).toBe('operation');
31
56
  });
32
57
 
33
- it('serializes a node prop bound to a DB collection query', () => {
34
- const query: DbCollectionQueryBinding = {
35
- collection: 'posts',
36
- schema: 'public',
37
- columns: ['id', 'title', 'published'],
38
- filters: [{ field: 'published', operator: 'eq', value: true }],
39
- sort: [{ field: 'created_at', direction: 'desc' }],
40
- page: { limit: 10, offset: 0 },
41
- refresh: 'live',
42
- };
43
- const node: UiNode = {
44
- id: 'posts-list',
45
- type: 'CollectionList',
46
- bindings: [
47
- {
48
- prop: 'items',
49
- valueFrom: {
50
- source: 'db.collection',
51
- query,
58
+ it('serializes literal, context, event, state, and operation value sources', () => {
59
+ const sources: readonly BindingValueSource[] = [
60
+ { kind: 'literal', value: { fallback: 'Untitled' } },
61
+ { kind: 'context', path: 'auth.user.displayName' },
62
+ { kind: 'event', path: 'payload.values.search' },
63
+ { kind: 'state', path: 'forms.search.query' },
64
+ {
65
+ kind: 'operation',
66
+ operation: {
67
+ dataSourceId: 'search-api',
68
+ operationId: 'search.query',
69
+ },
70
+ path: '$.results',
71
+ },
72
+ ];
73
+
74
+ assertSerializable(sources);
75
+ expect(sources.map((source) => source.kind)).toEqual([
76
+ 'literal',
77
+ 'context',
78
+ 'event',
79
+ 'state',
80
+ 'operation',
81
+ ]);
82
+ });
83
+
84
+ it('serializes an event binding that executes a data-source operation', () => {
85
+ const input: BindingInputMap = {
86
+ email: {
87
+ kind: 'source',
88
+ source: {
89
+ kind: 'event',
90
+ path: 'payload.values.email',
91
+ },
92
+ transforms: ['trim', 'lowercase'],
93
+ },
94
+ message: {
95
+ kind: 'source',
96
+ source: {
97
+ kind: 'event',
98
+ path: 'payload.values.message',
99
+ },
100
+ },
101
+ source: {
102
+ kind: 'literal',
103
+ value: 'contact-form',
104
+ },
105
+ metadata: {
106
+ kind: 'object',
107
+ fields: {
108
+ userId: {
109
+ kind: 'source',
110
+ source: {
111
+ kind: 'context',
112
+ path: 'auth.user.id',
113
+ },
52
114
  },
53
115
  },
54
- ],
116
+ },
117
+ };
118
+
119
+ const binding: EventBinding = {
120
+ target: {
121
+ kind: 'operation',
122
+ operation: {
123
+ dataSourceId: 'contact-api',
124
+ endpointId: 'messages',
125
+ operationId: 'messages.create',
126
+ },
127
+ },
128
+ input,
129
+ when: {
130
+ source: {
131
+ kind: 'event',
132
+ path: 'payload.values.email',
133
+ },
134
+ operator: 'exists',
135
+ },
55
136
  };
56
137
 
57
- expect(JSON.parse(JSON.stringify(node))).toEqual(node);
58
- expect(node.bindings?.[0]?.valueFrom.source).toBe('db.collection');
138
+ assertSerializable(binding);
139
+ expect(binding.target.kind).toBe('operation');
140
+ expect(binding.input?.email?.kind).toBe('source');
59
141
  });
60
142
 
61
- it('supports literal, context, and event value sources', () => {
62
- const sources: readonly BindingValueSource[] = [
63
- { source: 'literal', value: { fallback: 'Untitled' } },
64
- { source: 'context', path: 'auth.user.displayName' },
65
- { source: 'event', path: 'payload.values.search' },
66
- ];
143
+ it('serializes an event binding that targets a named action', () => {
144
+ const binding: EventBinding = {
145
+ target: {
146
+ kind: 'action',
147
+ type: 'toast.show',
148
+ },
149
+ input: {
150
+ message: {
151
+ kind: 'literal',
152
+ value: 'Saved successfully.',
153
+ },
154
+ },
155
+ };
67
156
 
68
- expect(JSON.parse(JSON.stringify(sources))).toEqual(sources);
69
- expect(sources.map((source) => source.source)).toEqual(['literal', 'context', 'event']);
157
+ assertSerializable(binding);
158
+ expect(binding.target.kind).toBe('action');
70
159
  });
71
160
 
72
- it('supports static, focus, poll, and live refresh intent', () => {
73
- const refreshModes = ['static', 'focus', 'poll', 'live'] as const;
74
- const bindings = refreshModes.map(
75
- (refresh): NodePropBinding => ({
76
- prop: `items.${refresh}`,
77
- valueFrom: {
78
- source: 'db.collection',
79
- query: {
80
- collection: 'posts',
81
- refresh,
82
- pollIntervalMs: refresh === 'poll' ? 30_000 : undefined,
161
+ it('serializes component data-binding registries', () => {
162
+ const bindings: ComponentDataBindingRegistry = {
163
+ 'submit-button': {
164
+ componentId: 'submit-button',
165
+ componentType: 'Button',
166
+ props: {
167
+ children: {
168
+ source: {
169
+ kind: 'literal',
170
+ value: 'Send',
171
+ },
172
+ },
173
+ disabled: {
174
+ source: {
175
+ kind: 'state',
176
+ path: 'forms.contact.isSubmitting',
177
+ },
83
178
  },
84
179
  },
85
- }),
86
- );
180
+ events: {
181
+ press: [
182
+ {
183
+ target: {
184
+ kind: 'operation',
185
+ operation: {
186
+ dataSourceId: 'contact-api',
187
+ operationId: 'messages.create',
188
+ },
189
+ },
190
+ },
191
+ ],
192
+ },
193
+ },
194
+ };
87
195
 
88
- expect(JSON.parse(JSON.stringify(bindings))).toEqual(bindings);
89
- expect(bindings.map((binding) => binding.valueFrom.source)).toEqual([
90
- 'db.collection',
91
- 'db.collection',
92
- 'db.collection',
93
- 'db.collection',
94
- ]);
196
+ assertSerializable(bindings);
197
+ expect(bindings['submit-button']?.events?.press?.[0]?.target.kind).toBe('operation');
198
+ });
199
+
200
+ it('serializes app-level dataSources and dataBindings on the manifest', () => {
201
+ const propBinding: PropBinding = {
202
+ source: {
203
+ kind: 'operation',
204
+ operation: {
205
+ dataSourceId: 'cms',
206
+ endpointId: 'pages',
207
+ operationId: 'pages.getHome',
208
+ },
209
+ path: '$.data.heroTitle',
210
+ },
211
+ };
212
+
213
+ const manifest: AppManifest = {
214
+ metadata: {
215
+ name: 'Demo',
216
+ slug: 'demo',
217
+ version: '1.0.0',
218
+ themeId: 'default',
219
+ },
220
+ themes: [
221
+ {
222
+ id: 'default',
223
+ name: 'Default',
224
+ light: {
225
+ primaryColor: '#3366ff',
226
+ harmony: 'analogous',
227
+ },
228
+ dark: {
229
+ primaryColor: '#3366ff',
230
+ harmony: 'analogous',
231
+ },
232
+ },
233
+ ],
234
+ activeThemeId: 'default',
235
+ infra: {
236
+ plugins: [],
237
+ },
238
+ navigator: {
239
+ type: 'stack',
240
+ routes: [
241
+ {
242
+ name: 'home',
243
+ screenId: 'home',
244
+ },
245
+ ],
246
+ },
247
+ screens: {
248
+ home: {
249
+ id: 'home',
250
+ name: 'Home',
251
+ root: {
252
+ id: 'screen-root',
253
+ type: 'Screen',
254
+ children: [
255
+ {
256
+ id: 'hero-title',
257
+ type: 'Text',
258
+ },
259
+ ],
260
+ },
261
+ },
262
+ },
263
+ dataSources: {
264
+ cms: {
265
+ id: 'cms',
266
+ kind: 'rest',
267
+ baseUrl: 'https://cms.example.com',
268
+ endpoints: {
269
+ pages: {
270
+ id: 'pages',
271
+ kind: 'http',
272
+ path: '/pages/home',
273
+ operations: {
274
+ 'pages.getHome': {
275
+ id: 'pages.getHome',
276
+ endpointId: 'pages',
277
+ protocol: 'http',
278
+ intent: 'read',
279
+ method: 'GET',
280
+ path: '/pages/home',
281
+ },
282
+ },
283
+ },
284
+ },
285
+ },
286
+ },
287
+ dataBindings: {
288
+ 'hero-title': {
289
+ componentId: 'hero-title',
290
+ componentType: 'Text',
291
+ props: {
292
+ children: propBinding,
293
+ },
294
+ },
295
+ },
296
+ settings: {
297
+ localization: {
298
+ defaultLocale: 'en',
299
+ locales: ['en'],
300
+ },
301
+ authFlow: {
302
+ signInRoute: '/sign-in',
303
+ signUpRoute: '/sign-up',
304
+ signOutRoute: '/sign-out',
305
+ forgotPasswordRoute: '/forgot-password',
306
+ postSignInRoute: '/',
307
+ unauthorizedRoute: '/sign-in',
308
+ },
309
+ },
310
+ };
311
+
312
+ assertSerializable(manifest);
313
+ expect(manifest.dataBindings?.['hero-title']?.props?.children?.source.kind).toBe('operation');
314
+ expect(manifest.dataSources?.cms?.kind).toBe('rest');
95
315
  });
96
316
  });
package/src/bindings.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { DbFilter, DbPage, DbSort } from './db';
1
+ import type { DataSourceId, EndpointId, OperationId } from './data';
2
+
3
+ export type ComponentInstanceId = string;
4
+ export type ComponentTypeId = string;
2
5
 
3
6
  export type BindingValue =
4
7
  | string
@@ -10,45 +13,118 @@ export type BindingValue =
10
13
  readonly [key: string]: BindingValue;
11
14
  };
12
15
 
13
- export type BindingRefreshMode = 'focus' | 'live' | 'poll' | 'static';
16
+ export type BindingDataPath = string;
17
+
18
+ export type BindingValueTransform = 'lowercase' | 'trim' | 'uppercase';
14
19
 
15
- export interface DbCollectionQueryBinding {
16
- readonly collection: string;
17
- readonly schema?: string;
18
- readonly columns?: readonly string[];
19
- readonly filters?: readonly DbFilter[];
20
- readonly sort?: readonly DbSort[];
21
- readonly page?: DbPage;
22
- readonly refresh?: BindingRefreshMode;
23
- readonly pollIntervalMs?: number;
20
+ export interface BindingOperationRef {
21
+ readonly dataSourceId: DataSourceId;
22
+ readonly operationId: OperationId;
23
+ readonly endpointId?: EndpointId;
24
24
  }
25
25
 
26
26
  export type BindingValueSource =
27
27
  | {
28
- readonly source: 'context';
29
- readonly path: string;
28
+ readonly kind: 'context';
29
+ readonly path: BindingDataPath;
30
30
  }
31
31
  | {
32
- readonly source: 'db.collection';
33
- readonly query: DbCollectionQueryBinding;
32
+ readonly kind: 'event';
33
+ readonly path: BindingDataPath;
34
34
  }
35
35
  | {
36
- readonly source: 'event';
37
- readonly path: string;
36
+ readonly kind: 'literal';
37
+ readonly value: BindingValue;
38
+ }
39
+ | {
40
+ readonly kind: 'operation';
41
+ readonly operation: BindingOperationRef;
42
+ readonly path?: BindingDataPath;
43
+ }
44
+ | {
45
+ readonly kind: 'state';
46
+ readonly path: BindingDataPath;
47
+ };
48
+
49
+ export interface BindingValueExpression {
50
+ readonly source: BindingValueSource;
51
+ readonly transforms?: readonly BindingValueTransform[];
52
+ }
53
+
54
+ export interface BindingFallback {
55
+ readonly value?: BindingValue;
56
+ readonly source?: BindingValueSource;
57
+ }
58
+
59
+ export type BindingLifecycleState = 'empty' | 'error' | 'loading';
60
+
61
+ export interface BindingLifecycleBehavior {
62
+ readonly state: BindingLifecycleState;
63
+ readonly fallback?: BindingFallback;
64
+ readonly message?: string;
65
+ }
66
+
67
+ export interface PropBinding {
68
+ readonly source: BindingValueSource;
69
+ readonly fallback?: BindingFallback;
70
+ readonly loading?: BindingLifecycleBehavior;
71
+ readonly error?: BindingLifecycleBehavior;
72
+ readonly empty?: BindingLifecycleBehavior;
73
+ readonly transforms?: readonly BindingValueTransform[];
74
+ }
75
+
76
+ export type BindingInputValue =
77
+ | {
78
+ readonly kind: 'array';
79
+ readonly items: readonly BindingInputValue[];
38
80
  }
39
81
  | {
40
- readonly source: 'literal';
82
+ readonly kind: 'literal';
41
83
  readonly value: BindingValue;
42
84
  }
43
85
  | {
44
- readonly source: 'state';
45
- readonly path: string;
86
+ readonly kind: 'object';
87
+ readonly fields: Readonly<Record<string, BindingInputValue>>;
88
+ }
89
+ | {
90
+ readonly kind: 'source';
91
+ readonly source: BindingValueSource;
92
+ readonly transforms?: readonly BindingValueTransform[];
46
93
  };
47
94
 
48
- export type BindingValueTransform = 'lowercase' | 'trim' | 'uppercase';
95
+ export type BindingInputMap = Readonly<Record<string, BindingInputValue>>;
96
+
97
+ export type BindingConditionOperator = 'eq' | 'exists' | 'neq' | 'notExists';
49
98
 
50
- export interface NodePropBinding {
51
- readonly prop: string;
52
- readonly valueFrom: BindingValueSource;
53
- readonly transform?: BindingValueTransform | readonly BindingValueTransform[];
99
+ export interface BindingCondition {
100
+ readonly source: BindingValueSource;
101
+ readonly operator: BindingConditionOperator;
102
+ readonly value?: BindingValue;
54
103
  }
104
+
105
+ export type EventBindingTarget =
106
+ | {
107
+ readonly kind: 'action';
108
+ readonly type: string;
109
+ }
110
+ | {
111
+ readonly kind: 'operation';
112
+ readonly operation: BindingOperationRef;
113
+ };
114
+
115
+ export interface EventBinding {
116
+ readonly target: EventBindingTarget;
117
+ readonly input?: BindingInputMap;
118
+ readonly when?: BindingCondition;
119
+ }
120
+
121
+ export interface ComponentDataBinding {
122
+ readonly componentId: ComponentInstanceId;
123
+ readonly componentType?: ComponentTypeId;
124
+ readonly props?: Readonly<Record<string, PropBinding>>;
125
+ readonly events?: Readonly<Record<string, readonly EventBinding[]>>;
126
+ }
127
+
128
+ export type ComponentDataBindingRegistry = Readonly<
129
+ Record<ComponentInstanceId, ComponentDataBinding>
130
+ >;