@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.
@@ -5,8 +5,6 @@ import { COLOR_HARMONIES } from '@ankhorage/color-theory';
5
5
  import { describe, expect, it } from 'bun:test';
6
6
 
7
7
  import {
8
- type ActionBinding,
9
- type ActionValueSource,
10
8
  APP_CATEGORIES,
11
9
  type AppCategory,
12
10
  AUTH_PROVIDERS,
@@ -21,22 +19,16 @@ import {
21
19
  type DbAdapter,
22
20
  type DbAdminAdapter,
23
21
  type DbChangeEvent,
24
- type DbPersistActionBinding,
25
- type DbPersistCommand,
26
22
  type DbRealtimeAdapter,
27
23
  DEPLOYMENT_TARGETS,
28
- type EmailSendActionBinding,
29
24
  type FormSubmitEventDto,
30
25
  type ImageAssetSource,
31
- type KnownActionBinding,
32
- type KnownCommandDto,
33
26
  NAVIGATOR_TYPES,
34
27
  type StoragePublicUrlResult,
35
28
  type StorageResult,
36
29
  type StorageUploadResult,
37
30
  type ThemeConfig,
38
31
  type ThemeModeConfig,
39
- type UiNode,
40
32
  } from './index';
41
33
 
42
34
  async function collectTypeScriptFiles(directory: string): Promise<string[]> {
@@ -236,55 +228,6 @@ describe('contracts', () => {
236
228
  expect(JSON.parse(JSON.stringify(source))).toEqual(source);
237
229
  });
238
230
 
239
- it('serializes node event bindings for submit actions', () => {
240
- const node: UiNode = {
241
- id: 'contact-form',
242
- type: 'Form',
243
- props: {
244
- title: 'Contact us',
245
- },
246
- events: {
247
- submit: [
248
- {
249
- type: 'email.send',
250
- payload: {
251
- to: 'info@example.com',
252
- subject: 'New contact message',
253
- bodyFrom: 'payload.values.message',
254
- },
255
- },
256
- {
257
- type: 'db.persist',
258
- payload: {
259
- collection: 'contact_messages',
260
- mode: 'columns',
261
- },
262
- },
263
- ],
264
- },
265
- };
266
-
267
- expect(JSON.parse(JSON.stringify(node))).toEqual(node);
268
- expect(node.events?.submit?.map((action) => action.type)).toEqual(['email.send', 'db.persist']);
269
- });
270
-
271
- it('accepts conditional node event bindings', () => {
272
- const binding: ActionBinding = {
273
- type: 'db.persist',
274
- payload: {
275
- collection: 'contact_messages',
276
- },
277
- when: {
278
- source: 'event',
279
- path: 'payload.values.email',
280
- operator: 'exists',
281
- },
282
- };
283
-
284
- expect(binding.when?.operator).toBe('exists');
285
- expect(binding.payload?.collection).toBe('contact_messages');
286
- });
287
-
288
231
  it('serializes normalized form submit event DTOs', () => {
289
232
  const event: FormSubmitEventDto = {
290
233
  type: 'form.submit',
@@ -344,121 +287,6 @@ describe('contracts', () => {
344
287
  expect(knownEventType).toBe('form.submit');
345
288
  });
346
289
 
347
- it('serializes provider-neutral value sources for action payloads', () => {
348
- const sources: readonly ActionValueSource[] = [
349
- { source: 'event', path: 'payload.values.email' },
350
- { source: 'literal', value: 'website-contact-form' },
351
- { source: 'context', path: 'auth.user.id' },
352
- { source: 'state', path: 'forms.contact.isSubmitting' },
353
- ];
354
-
355
- expect(JSON.parse(JSON.stringify(sources))).toEqual(sources);
356
- expect(sources.map((source) => source.source)).toEqual([
357
- 'event',
358
- 'literal',
359
- 'context',
360
- 'state',
361
- ]);
362
- });
363
-
364
- it('serializes a typed db.persist action binding', () => {
365
- const binding: DbPersistActionBinding = {
366
- type: 'db.persist',
367
- payload: {
368
- collection: 'contact_messages',
369
- mode: 'columns',
370
- values: [
371
- {
372
- field: 'firstname',
373
- valueFrom: { source: 'event', path: 'payload.values.firstname' },
374
- transform: 'trim',
375
- },
376
- {
377
- field: 'message',
378
- valueFrom: { source: 'event', path: 'payload.values.message' },
379
- },
380
- {
381
- field: 'source',
382
- valueFrom: { source: 'literal', value: 'website-contact-form' },
383
- },
384
- ],
385
- },
386
- };
387
-
388
- expect(JSON.parse(JSON.stringify(binding))).toEqual(binding);
389
- expect(binding.payload.values.map((value) => value.field)).toEqual([
390
- 'firstname',
391
- 'message',
392
- 'source',
393
- ]);
394
- });
395
-
396
- it('serializes a typed email.send action binding', () => {
397
- const binding: EmailSendActionBinding = {
398
- type: 'email.send',
399
- payload: {
400
- to: 'info@example.com',
401
- subject: 'New contact message',
402
- bodyFrom: { source: 'event', path: 'payload.values.message' },
403
- },
404
- };
405
-
406
- expect(JSON.parse(JSON.stringify(binding))).toEqual(binding);
407
- expect(binding.payload.bodyFrom?.source).toBe('event');
408
- });
409
-
410
- it('accepts typed action bindings in node event bindings', () => {
411
- const persist: DbPersistActionBinding = {
412
- type: 'db.persist',
413
- payload: {
414
- collection: 'contact_messages',
415
- values: [
416
- {
417
- field: 'message',
418
- valueFrom: { source: 'event', path: 'payload.values.message' },
419
- },
420
- ],
421
- },
422
- };
423
- const email: EmailSendActionBinding = {
424
- type: 'email.send',
425
- payload: {
426
- to: 'info@example.com',
427
- bodyFrom: { source: 'event', path: 'payload.values.message' },
428
- },
429
- };
430
- const node: UiNode = {
431
- id: 'contact-form',
432
- type: 'Form',
433
- events: {
434
- submit: [persist, email],
435
- },
436
- };
437
- const known: KnownActionBinding = persist;
438
-
439
- expect(node.events?.submit?.map((action) => action.type)).toEqual(['db.persist', 'email.send']);
440
- expect(known.type).toBe('db.persist');
441
- });
442
-
443
- it('serializes a normalized db.persist command DTO', () => {
444
- const command: DbPersistCommand = {
445
- type: 'db.persist.command',
446
- payload: {
447
- operation: 'insert',
448
- collection: 'contact_messages',
449
- mode: 'columns',
450
- values: {
451
- firstname: 'Fabio',
452
- message: 'This is my contact message',
453
- },
454
- },
455
- };
456
- const knownCommand: KnownCommandDto = command;
457
-
458
- expect(JSON.parse(JSON.stringify(command))).toEqual(command);
459
- expect(knownCommand.type).toBe('db.persist.command');
460
- });
461
-
462
290
  it('accepts a provider-neutral CRUD database adapter', async () => {
463
291
  const adapter: DbAdapter = {
464
292
  capabilities: {
package/src/types.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ColorHarmony } from '@ankhorage/color-theory';
2
2
 
3
3
  import type { AuthFlowConfig, AuthIdentifierKind, AuthSignUpField } from './auth';
4
- import type { NodePropBinding } from './bindings';
4
+ import type { ComponentDataBindingRegistry } from './bindings';
5
+ import type { DataSourceRegistry } from './data';
5
6
 
6
7
  export interface ThemeModeConfig {
7
8
  primaryColor: string;
@@ -89,121 +90,6 @@ export type ManifestValue =
89
90
  | readonly ManifestValue[]
90
91
  | { readonly [key: string]: ManifestValue };
91
92
 
92
- export type ActionValue = ManifestValue;
93
-
94
- export type ActionBindingPayload = Record<string, ActionValue>;
95
-
96
- export type ActionConditionSource = 'context' | 'event' | 'state';
97
-
98
- export type ActionConditionOperator = 'eq' | 'exists' | 'neq' | 'notExists';
99
-
100
- export interface ActionCondition {
101
- readonly source: ActionConditionSource;
102
- readonly path: string;
103
- readonly operator: ActionConditionOperator;
104
- readonly value?: ActionValue;
105
- }
106
-
107
- export type ActionValueSource =
108
- | {
109
- readonly source: 'context';
110
- readonly path: string;
111
- }
112
- | {
113
- readonly source: 'event';
114
- readonly path: string;
115
- }
116
- | {
117
- readonly source: 'literal';
118
- readonly value: ActionValue;
119
- }
120
- | {
121
- readonly source: 'state';
122
- readonly path: string;
123
- };
124
-
125
- export type ActionValueTransform = 'lowercase' | 'trim' | 'uppercase';
126
-
127
- export interface ActionValueBinding {
128
- readonly valueFrom: ActionValueSource;
129
- readonly transform?: ActionValueTransform | readonly ActionValueTransform[];
130
- }
131
-
132
- export interface ActionBinding<
133
- TType extends string = string,
134
- TPayload extends object = ActionBindingPayload,
135
- > {
136
- readonly type: TType;
137
- readonly payload?: TPayload;
138
- readonly when?: ActionCondition;
139
- }
140
-
141
- export type DbPersistMode = 'columns' | 'json';
142
-
143
- export interface DbPersistFieldBinding {
144
- readonly field: string;
145
- readonly valueFrom: ActionValueSource;
146
- readonly transform?: ActionValueTransform | readonly ActionValueTransform[];
147
- }
148
-
149
- export interface DbPersistActionPayload {
150
- readonly collection: string;
151
- readonly kind?: string;
152
- readonly mode?: DbPersistMode;
153
- readonly jsonField?: string;
154
- readonly values: readonly DbPersistFieldBinding[];
155
- }
156
-
157
- export interface DbPersistActionBinding extends ActionBinding<
158
- 'db.persist',
159
- DbPersistActionPayload
160
- > {
161
- readonly payload: DbPersistActionPayload;
162
- }
163
-
164
- export interface EmailSendActionPayload {
165
- readonly to: string;
166
- readonly subject?: string;
167
- readonly body?: string;
168
- readonly bodyFrom?: ActionValueSource;
169
- }
170
-
171
- export interface EmailSendActionBinding extends ActionBinding<
172
- 'email.send',
173
- EmailSendActionPayload
174
- > {
175
- readonly payload: EmailSendActionPayload;
176
- }
177
-
178
- export type KnownActionBinding = DbPersistActionBinding | EmailSendActionBinding;
179
-
180
- export type UiNodeEventBindings = Record<string, readonly ActionBinding<string, object>[]>;
181
-
182
- export interface CommandDto<
183
- TType extends string = string,
184
- TPayload extends object = Record<string, ActionValue>,
185
- > {
186
- readonly type: TType;
187
- readonly payload: TPayload;
188
- }
189
-
190
- export type DbPersistOperation = 'insert';
191
-
192
- export type DbPersistCommandValues = Record<string, ActionValue>;
193
-
194
- export interface DbPersistCommandPayload {
195
- readonly operation: DbPersistOperation;
196
- readonly collection: string;
197
- readonly kind?: string;
198
- readonly mode?: DbPersistMode;
199
- readonly jsonField?: string;
200
- readonly values: DbPersistCommandValues;
201
- }
202
-
203
- export type DbPersistCommand = CommandDto<'db.persist.command', DbPersistCommandPayload>;
204
-
205
- export type KnownCommandDto = DbPersistCommand;
206
-
207
93
  export type ComponentEventPayloadValue = ManifestValue;
208
94
 
209
95
  export interface ComponentEventDto<
@@ -331,10 +217,8 @@ export interface UiNode {
331
217
  type: string;
332
218
  alias?: string;
333
219
  props?: Record<string, unknown>;
334
- bindings?: readonly NodePropBinding[];
335
220
  children?: UiNode[];
336
221
  style?: Record<string, number | string>;
337
- events?: UiNodeEventBindings;
338
222
  }
339
223
 
340
224
  export interface ScreenSpec {
@@ -437,6 +321,8 @@ export interface AppManifest {
437
321
  infra: InfraManifest;
438
322
  navigator: NavigatorSpec;
439
323
  screens: Record<string, ScreenSpec>;
324
+ dataSources?: DataSourceRegistry;
325
+ dataBindings?: ComponentDataBindingRegistry;
440
326
  settings: {
441
327
  apiBaseUrl?: string;
442
328
  localization: {
package/src/ui.test.ts CHANGED
@@ -113,6 +113,134 @@ describe('ui component metadata contracts', () => {
113
113
  expect(meta.props.columns?.itemSchema?.map((item) => item.key)).toEqual(['id', 'label']);
114
114
  });
115
115
 
116
+ it('serializes bindable prop and event metadata on component metadata', () => {
117
+ const meta: UiComponentMeta = {
118
+ name: 'Button',
119
+ category: 'component',
120
+ directManifestNode: true,
121
+ allowedChildren: [],
122
+ bindings: {
123
+ props: {
124
+ children: {
125
+ label: 'Label',
126
+ description: 'Text content rendered inside the button.',
127
+ acceptsFallback: true,
128
+ acceptsTransforms: true,
129
+ value: {
130
+ type: 'string',
131
+ label: 'Button label',
132
+ },
133
+ },
134
+ disabled: {
135
+ label: 'Disabled',
136
+ value: {
137
+ type: 'boolean',
138
+ },
139
+ },
140
+ },
141
+ events: {
142
+ press: {
143
+ label: 'Press',
144
+ description: 'Runs when the button is pressed.',
145
+ payload: {
146
+ eventType: 'button.press',
147
+ fields: [],
148
+ },
149
+ },
150
+ },
151
+ },
152
+ events: {
153
+ press: {
154
+ label: 'Press',
155
+ eventType: 'button.press',
156
+ },
157
+ },
158
+ props: {
159
+ children: {
160
+ type: 'string',
161
+ category: 'Content',
162
+ label: 'Label',
163
+ },
164
+ disabled: {
165
+ type: 'boolean',
166
+ category: 'State',
167
+ label: 'Disabled',
168
+ },
169
+ },
170
+ };
171
+
172
+ assertSerializable(meta);
173
+ expect(meta.bindings?.props?.children?.value.type).toBe('string');
174
+ expect(meta.bindings?.events?.press?.payload?.eventType).toBe('button.press');
175
+ });
176
+
177
+ it('serializes bindable collection item metadata with nested value fields', () => {
178
+ const meta: UiComponentMeta = {
179
+ name: 'List',
180
+ category: 'pattern',
181
+ directManifestNode: true,
182
+ allowedChildren: [],
183
+ bindings: {
184
+ props: {
185
+ items: {
186
+ label: 'Items',
187
+ value: {
188
+ type: 'array',
189
+ itemType: 'object',
190
+ fields: [
191
+ {
192
+ path: '$.id',
193
+ type: 'string',
194
+ label: 'ID',
195
+ required: true,
196
+ },
197
+ {
198
+ path: '$.title',
199
+ type: 'string',
200
+ label: 'Title',
201
+ },
202
+ ],
203
+ },
204
+ },
205
+ },
206
+ events: {
207
+ itemPress: {
208
+ label: 'Item press',
209
+ payload: {
210
+ eventType: 'collection.itemPress',
211
+ fields: [
212
+ {
213
+ path: 'payload.itemId',
214
+ type: 'string',
215
+ label: 'Item ID',
216
+ },
217
+ {
218
+ path: 'payload.item',
219
+ type: 'record',
220
+ label: 'Item',
221
+ },
222
+ ],
223
+ },
224
+ },
225
+ },
226
+ },
227
+ props: {
228
+ items: {
229
+ type: 'array',
230
+ category: 'Data',
231
+ label: 'Items',
232
+ },
233
+ },
234
+ };
235
+
236
+ assertSerializable(meta);
237
+ expect(meta.bindings?.props?.items?.value.fields?.map((field) => field.path)).toEqual([
238
+ '$.id',
239
+ '$.title',
240
+ ]);
241
+ expect(meta.bindings?.events?.itemPress?.payload?.eventType).toBe('collection.itemPress');
242
+ });
243
+
116
244
  it('serializes a component metadata registry', () => {
117
245
  const registry: UiComponentMetaRegistry = {
118
246
  Text: {
@@ -120,6 +248,15 @@ describe('ui component metadata contracts', () => {
120
248
  category: 'component',
121
249
  directManifestNode: true,
122
250
  allowedChildren: [],
251
+ bindings: {
252
+ props: {
253
+ children: {
254
+ value: {
255
+ type: 'string',
256
+ },
257
+ },
258
+ },
259
+ },
123
260
  props: {
124
261
  children: {
125
262
  type: 'string',
@@ -139,6 +276,7 @@ describe('ui component metadata contracts', () => {
139
276
 
140
277
  assertSerializable(registry);
141
278
  expect(registry.Screen?.allowedChildren).toEqual(['Text']);
279
+ expect(registry.Text?.bindings?.props?.children?.value.type).toBe('string');
142
280
  });
143
281
 
144
282
  it('serializes a component package manifest for extension packages', () => {
@@ -151,6 +289,36 @@ describe('ui component metadata contracts', () => {
151
289
  category: 'component',
152
290
  directManifestNode: true,
153
291
  allowedChildren: [],
292
+ bindings: {
293
+ props: {
294
+ fen: {
295
+ value: {
296
+ type: 'string',
297
+ label: 'FEN',
298
+ },
299
+ },
300
+ },
301
+ events: {
302
+ move: {
303
+ label: 'Move',
304
+ payload: {
305
+ eventType: 'chess.move',
306
+ fields: [
307
+ {
308
+ path: 'payload.from',
309
+ type: 'string',
310
+ label: 'From square',
311
+ },
312
+ {
313
+ path: 'payload.to',
314
+ type: 'string',
315
+ label: 'To square',
316
+ },
317
+ ],
318
+ },
319
+ },
320
+ },
321
+ },
154
322
  events: {
155
323
  move: {
156
324
  label: 'Move',
@@ -189,5 +357,6 @@ describe('ui component metadata contracts', () => {
189
357
 
190
358
  assertSerializable(manifest);
191
359
  expect(manifest.components.Chessboard?.events?.move?.eventType).toBe('chess.move');
360
+ expect(manifest.components.Chessboard?.bindings?.props?.fen?.value.type).toBe('string');
192
361
  });
193
362
  });
package/src/ui.ts CHANGED
@@ -85,6 +85,58 @@ export interface UiComponentEventMeta {
85
85
  readonly payloadFields?: readonly UiComponentEventPayloadFieldMeta[];
86
86
  }
87
87
 
88
+ export type UiBindableValueType =
89
+ | 'array'
90
+ | 'boolean'
91
+ | 'date'
92
+ | 'imageAsset'
93
+ | 'number'
94
+ | 'object'
95
+ | 'record'
96
+ | 'string'
97
+ | 'unknown';
98
+
99
+ export interface UiBindableValueFieldMeta {
100
+ readonly path: string;
101
+ readonly type: UiBindableValueType;
102
+ readonly label?: string;
103
+ readonly description?: string;
104
+ readonly required?: boolean;
105
+ }
106
+
107
+ export interface UiBindableValueMeta {
108
+ readonly type: UiBindableValueType;
109
+ readonly label?: string;
110
+ readonly description?: string;
111
+ readonly fields?: readonly UiBindableValueFieldMeta[];
112
+ readonly itemType?: UiBindableValueType;
113
+ }
114
+
115
+ export interface UiBindablePropMeta {
116
+ readonly value: UiBindableValueMeta;
117
+ readonly label?: string;
118
+ readonly description?: string;
119
+ readonly required?: boolean;
120
+ readonly acceptsFallback?: boolean;
121
+ readonly acceptsTransforms?: boolean;
122
+ }
123
+
124
+ export interface UiBindableEventPayloadMeta {
125
+ readonly eventType: UiComponentEventPayloadKind;
126
+ readonly fields?: readonly UiComponentEventPayloadFieldMeta[];
127
+ }
128
+
129
+ export interface UiBindableEventMeta {
130
+ readonly label?: string;
131
+ readonly description?: string;
132
+ readonly payload?: UiBindableEventPayloadMeta;
133
+ }
134
+
135
+ export interface UiComponentBindingMeta {
136
+ readonly props?: Readonly<Record<string, UiBindablePropMeta>>;
137
+ readonly events?: Readonly<Record<string, UiBindableEventMeta>>;
138
+ }
139
+
88
140
  export interface UiComponentSlotMeta {
89
141
  readonly label?: string;
90
142
  readonly allowedChildren?: readonly string[];
@@ -96,6 +148,7 @@ export interface UiComponentMeta {
96
148
  readonly description?: string;
97
149
  readonly directManifestNode: boolean;
98
150
  readonly allowedChildren: readonly string[];
151
+ readonly bindings?: UiComponentBindingMeta;
99
152
  readonly blueprint?: UiComponentBlueprint;
100
153
  readonly events?: Readonly<Record<string, UiComponentEventMeta>>;
101
154
  readonly i18n?: UiComponentI18nMeta;