@ankhorage/contracts 21.0.0 → 22.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.
Files changed (106) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -1
  3. package/dist/appManifest/apis.d.ts +3 -3
  4. package/dist/appManifest/apis.d.ts.map +1 -1
  5. package/dist/appManifest/apis.js +4 -6
  6. package/dist/appManifest/apis.js.map +1 -1
  7. package/dist/appManifest/bindings.d.ts.map +1 -1
  8. package/dist/appManifest/bindings.js +2 -1
  9. package/dist/appManifest/bindings.js.map +1 -1
  10. package/dist/appManifest/data.d.ts.map +1 -1
  11. package/dist/appManifest/data.js +5 -4
  12. package/dist/appManifest/data.js.map +1 -1
  13. package/dist/appManifest/isAppManifest.js +3 -3
  14. package/dist/appManifest/isAppManifest.js.map +1 -1
  15. package/dist/appManifest/isManifestValue.d.ts +1 -1
  16. package/dist/appManifest/isManifestValue.d.ts.map +1 -1
  17. package/dist/appManifest/isManifestValue.js +3 -12
  18. package/dist/appManifest/isManifestValue.js.map +1 -1
  19. package/dist/appManifest/screens.d.ts +3 -2
  20. package/dist/appManifest/screens.d.ts.map +1 -1
  21. package/dist/appManifest/screens.js +15 -7
  22. package/dist/appManifest/screens.js.map +1 -1
  23. package/dist/appManifest/splashScreen.d.ts +8 -11
  24. package/dist/appManifest/splashScreen.d.ts.map +1 -1
  25. package/dist/appManifest/splashScreen.js.map +1 -1
  26. package/dist/bindings.d.ts +2 -3
  27. package/dist/bindings.d.ts.map +1 -1
  28. package/dist/bindings.js.map +1 -1
  29. package/dist/collections.d.ts +4 -0
  30. package/dist/collections.d.ts.map +1 -0
  31. package/dist/collections.js +6 -0
  32. package/dist/collections.js.map +1 -0
  33. package/dist/data/apis.d.ts +1 -1
  34. package/dist/data/apis.d.ts.map +1 -1
  35. package/dist/data/apis.js.map +1 -1
  36. package/dist/data/values.d.ts +2 -3
  37. package/dist/data/values.d.ts.map +1 -1
  38. package/dist/data/values.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/infra/isInfraEnvironmentSpec.d.ts.map +1 -1
  44. package/dist/infra/isInfraEnvironmentSpec.js +10 -12
  45. package/dist/infra/isInfraEnvironmentSpec.js.map +1 -1
  46. package/dist/infra/isInfraManifest.d.ts.map +1 -1
  47. package/dist/infra/isInfraManifest.js +14 -5
  48. package/dist/infra/isInfraManifest.js.map +1 -1
  49. package/dist/infra/isInfraWorkloadSpec.d.ts.map +1 -1
  50. package/dist/infra/isInfraWorkloadSpec.js +26 -20
  51. package/dist/infra/isInfraWorkloadSpec.js.map +1 -1
  52. package/dist/requirements.d.ts +5 -10
  53. package/dist/requirements.d.ts.map +1 -1
  54. package/dist/requirements.js.map +1 -1
  55. package/dist/serializable.d.ts +6 -0
  56. package/dist/serializable.d.ts.map +1 -0
  57. package/dist/serializable.js +14 -0
  58. package/dist/serializable.js.map +1 -0
  59. package/dist/state.d.ts +2 -3
  60. package/dist/state.d.ts.map +1 -1
  61. package/dist/state.js.map +1 -1
  62. package/dist/types/infraManifest.d.ts +14 -13
  63. package/dist/types/infraManifest.d.ts.map +1 -1
  64. package/dist/types/infraManifest.js.map +1 -1
  65. package/dist/types/infraWorkload.d.ts +11 -11
  66. package/dist/types/infraWorkload.d.ts.map +1 -1
  67. package/dist/types/infraWorkload.js.map +1 -1
  68. package/dist/types.d.ts +9 -8
  69. package/dist/types.d.ts.map +1 -1
  70. package/dist/types.js.map +1 -1
  71. package/package.json +2 -3
  72. package/src/appManifest/apis.ts +9 -6
  73. package/src/appManifest/bindings.ts +7 -1
  74. package/src/appManifest/data.ts +13 -4
  75. package/src/appManifest/isAppManifest.ts +3 -3
  76. package/src/appManifest/isManifestValue.ts +3 -16
  77. package/src/appManifest/screens.ts +21 -10
  78. package/src/appManifest/splashScreen.test.ts +3 -3
  79. package/src/appManifest/splashScreen.ts +8 -18
  80. package/src/appManifest.test.ts +21 -16
  81. package/src/bindings.test.ts +11 -8
  82. package/src/bindings.ts +2 -9
  83. package/src/collections.ts +8 -0
  84. package/src/contracts.test.ts +16 -13
  85. package/src/data/apis.test.ts +6 -4
  86. package/src/data/apis.ts +1 -1
  87. package/src/data/values.ts +3 -9
  88. package/src/deploy.test.ts +4 -4
  89. package/src/index.ts +2 -0
  90. package/src/infra/isInfraEnvironmentSpec.ts +16 -16
  91. package/src/infra/isInfraManifest.ts +17 -6
  92. package/src/infra/isInfraWorkloadSpec.ts +39 -24
  93. package/src/infra-published-port.test.ts +8 -8
  94. package/src/infra-workload-persistence.test.ts +4 -4
  95. package/src/infra-workload.test.ts +24 -22
  96. package/src/infra.test.ts +16 -20
  97. package/src/infraPersistence.test.ts +1 -1
  98. package/src/infraServices.test.ts +1 -1
  99. package/src/requirements.test.ts +2 -2
  100. package/src/requirements.ts +6 -12
  101. package/src/secrets.test.ts +1 -1
  102. package/src/serializable.ts +25 -0
  103. package/src/state.ts +3 -6
  104. package/src/types/infraManifest.ts +17 -13
  105. package/src/types/infraWorkload.ts +12 -11
  106. package/src/types.ts +11 -12
@@ -2,8 +2,10 @@ import { COLOR_HARMONIES } from '@ankhorage/color-theory';
2
2
  import { isRecord } from '@ankhorage/utility/object';
3
3
  import { isOptionalString } from '@ankhorage/utility/string';
4
4
 
5
+ import { isSerializableSet } from '../collections';
5
6
  import { isMediaAssetReference } from '../media';
6
- import { APP_CATEGORIES } from '../types';
7
+ import { ANKHORAGE_CAPABILITY_NAMES, ANKHORAGE_PERMISSION_NAMES } from '../requirements';
8
+ import { APP_CATEGORIES, type ThemeRegistry } from '../types';
7
9
  import { isBindingValueSource, isScreenDataLoaderDefinition } from './bindings';
8
10
 
9
11
  export { isAppNavigatorManifest } from './navigator';
@@ -11,6 +13,8 @@ export { isAppNavigatorManifest } from './navigator';
11
13
  const APP_CATEGORY_SET = new Set<string>(APP_CATEGORIES);
12
14
  const COLOR_HARMONY_SET = new Set<string>(COLOR_HARMONIES);
13
15
  const SPLASH_SCREEN_RESIZE_MODE_SET = new Set<string>(['contain', 'cover', 'native']);
16
+ const PERMISSION_NAME_SET = new Set<string>(ANKHORAGE_PERMISSION_NAMES);
17
+ const CAPABILITY_NAME_SET = new Set<string>(ANKHORAGE_CAPABILITY_NAMES);
14
18
 
15
19
  /*** Validate application identity and optional authored metadata. */
16
20
  export function isManifestMetadata(value: unknown): boolean {
@@ -28,7 +32,7 @@ export function isManifestMetadata(value: unknown): boolean {
28
32
  }
29
33
 
30
34
  /*** Validate theme identity and its required light and dark modes. */
31
- export function isThemeConfig(value: unknown): boolean {
35
+ function isThemeConfig(value: unknown): boolean {
32
36
  return (
33
37
  isRecord(value) &&
34
38
  typeof value.id === 'string' &&
@@ -38,6 +42,16 @@ export function isThemeConfig(value: unknown): boolean {
38
42
  );
39
43
  }
40
44
 
45
+ /*** Validate themes in the authored theme registry and require registry keys to match theme identity. */
46
+ export function isThemeRegistry(value: unknown): value is ThemeRegistry {
47
+ return (
48
+ isRecord(value) &&
49
+ Object.entries(value).every(
50
+ ([registryKey, theme]) => isThemeConfig(theme) && isRecord(theme) && registryKey === theme.id,
51
+ )
52
+ );
53
+ }
54
+
41
55
  /*** Validate screens in the authored screen registry. */
42
56
  export function isScreenRegistry(value: unknown): boolean {
43
57
  return (
@@ -127,15 +141,12 @@ function isSplashScreenModeSpec(value: unknown): boolean {
127
141
  function isScreenRequirements(value: unknown): boolean {
128
142
  return (
129
143
  isRecord(value) &&
130
- (value.permissions === undefined || isRequirementArray(value.permissions, 'permission')) &&
131
- (value.capabilities === undefined || isRequirementArray(value.capabilities, 'capability'))
144
+ (value.permissions === undefined || isRequirementSet(value.permissions, PERMISSION_NAME_SET)) &&
145
+ (value.capabilities === undefined || isRequirementSet(value.capabilities, CAPABILITY_NAME_SET))
132
146
  );
133
147
  }
134
148
 
135
- /*** Validate the entries of a capability or permission requirement list. */
136
- function isRequirementArray(value: unknown, key: 'capability' | 'permission'): boolean {
137
- return (
138
- Array.isArray(value) &&
139
- value.every((entry) => isRecord(entry) && typeof entry[key] === 'string')
140
- );
149
+ /*** Validate unordered requirement membership against the supported capability or permission names. */
150
+ function isRequirementSet(value: unknown, allowedNames: ReadonlySet<string>): boolean {
151
+ return isSerializableSet(value) && Object.keys(value).every((name) => allowedNames.has(name));
141
152
  }
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'bun:test';
3
3
  import type { AppManifest, SplashScreenSpec } from '../index';
4
4
 
5
5
  describe('AppManifest splash screen contract', () => {
6
- it('accepts portable media references with Expo-owned option types', () => {
6
+ it('accepts portable media references with the canonical splash option shape', () => {
7
7
  const splashScreen: SplashScreenSpec = {
8
8
  backgroundColor: '#ffffff',
9
9
  image: { mediaId: 'splash-logo' },
@@ -23,13 +23,13 @@ describe('AppManifest splash screen contract', () => {
23
23
  it('keeps root-only Expo options out of dark-mode overrides', () => {
24
24
  const splashWithDarkImageWidth: SplashScreenSpec = {
25
25
  dark: {
26
- // @ts-expect-error Expo dark-mode options do not include image sizing.
26
+ // @ts-expect-error Dark-mode overrides do not include image sizing.
27
27
  imageWidth: 160,
28
28
  },
29
29
  };
30
30
  const splashWithDarkResizeMode: SplashScreenSpec = {
31
31
  dark: {
32
- // @ts-expect-error Expo dark-mode options do not include resize mode.
32
+ // @ts-expect-error Dark-mode overrides do not include resize mode.
33
33
  resizeMode: 'contain',
34
34
  },
35
35
  };
@@ -1,24 +1,14 @@
1
- import type { Props as ExpoSplashScreenPluginProps } from 'expo-splash-screen/plugin';
2
-
3
1
  import type { MediaAssetReference } from '../media';
4
2
 
5
- type ExpoSplashScreenRootProps = Pick<
6
- ExpoSplashScreenPluginProps,
7
- 'backgroundColor' | 'image' | 'imageWidth' | 'resizeMode'
8
- >;
3
+ export type SplashScreenResizeMode = 'contain' | 'cover' | 'native';
9
4
 
10
- type PortableSplashScreenProps<T extends { readonly image?: string }> = Readonly<
11
- Omit<T, 'image'>
12
- > & {
5
+ export interface SplashScreenModeSpec {
6
+ readonly backgroundColor?: string;
13
7
  readonly image?: MediaAssetReference;
14
- };
15
-
16
- export type SplashScreenResizeMode = NonNullable<ExpoSplashScreenPluginProps['resizeMode']>;
17
-
18
- export type SplashScreenModeSpec = PortableSplashScreenProps<
19
- NonNullable<ExpoSplashScreenPluginProps['dark']>
20
- >;
8
+ }
21
9
 
22
- export type SplashScreenSpec = PortableSplashScreenProps<ExpoSplashScreenRootProps> & {
10
+ export interface SplashScreenSpec extends SplashScreenModeSpec {
11
+ readonly imageWidth?: number;
12
+ readonly resizeMode?: SplashScreenResizeMode;
23
13
  readonly dark?: SplashScreenModeSpec;
24
- };
14
+ }
@@ -1,3 +1,4 @@
1
+ import { readOwnProperty } from '@ankhorage/utility/object';
1
2
  import { describe, expect, it } from 'bun:test';
2
3
 
3
4
  import { isAppManifest, parseAppManifest } from './appManifest';
@@ -11,14 +12,14 @@ function createManifest(): Record<string, unknown> {
11
12
  category: 'developer_tools',
12
13
  themeId: 'default',
13
14
  },
14
- themes: [
15
- {
15
+ themes: {
16
+ default: {
16
17
  id: 'default',
17
18
  name: 'Default',
18
19
  light: { primaryColor: '#3366ff', harmony: 'analogous' },
19
20
  dark: { primaryColor: '#6699ff', harmony: 'analogous' },
20
21
  },
21
- ],
22
+ },
22
23
  activeThemeId: 'default',
23
24
  activeThemeMode: 'light',
24
25
  splashScreen: {
@@ -43,12 +44,12 @@ function createManifest(): Record<string, unknown> {
43
44
  local: {
44
45
  deployment: { compute: { provider: 'local' }, runtime: { provider: 'minikube' } },
45
46
  database: { provider: 'supabase', tier: 'dev' },
46
- objectStorage: { provider: 'supabase', buckets: ['media'] },
47
+ objectStorage: { provider: 'supabase', buckets: { media: true } },
47
48
  networking: { domain: 'example.test' },
48
49
  },
49
50
  },
50
- apis: [
51
- {
51
+ apis: {
52
+ nutrition: {
52
53
  id: 'nutrition',
53
54
  origin: 'external',
54
55
  protocol: 'rest',
@@ -81,9 +82,10 @@ function createManifest(): Record<string, unknown> {
81
82
  },
82
83
  },
83
84
  },
84
- ],
85
- modules: ['expo-localization'],
86
- modulesConfig: { localization: { defaultLocale: 'en' } },
85
+ },
86
+ modules: {
87
+ 'expo-localization': { config: { defaultLocale: 'en' } },
88
+ },
87
89
  },
88
90
  navigator: {
89
91
  type: 'stack',
@@ -120,8 +122,8 @@ function createManifest(): Record<string, unknown> {
120
122
  },
121
123
  ],
122
124
  requires: {
123
- permissions: [{ permission: 'camera' }],
124
- capabilities: [{ capability: 'barcodeScanner' }, { capability: 'ebookReader' }],
125
+ permissions: { camera: true },
126
+ capabilities: { barcodeScanner: true, ebookReader: true },
125
127
  },
126
128
  },
127
129
  },
@@ -199,8 +201,9 @@ describe('AppManifest runtime parsing', () => {
199
201
 
200
202
  it('rejects malformed nested canonical structures', () => {
201
203
  const manifest = createManifest();
202
- const themes = manifest.themes as Record<string, unknown>[];
203
- const light = themes[0]?.light as Record<string, unknown>;
204
+ const themes = manifest.themes as Record<string, unknown>;
205
+ const theme = readOwnProperty<Record<string, unknown>>(themes, 'default');
206
+ const light = theme?.light as Record<string, unknown>;
204
207
  light.harmony = 'not-a-harmony';
205
208
 
206
209
  expect(parseAppManifest(manifest)).toEqual({
@@ -209,11 +212,13 @@ describe('AppManifest runtime parsing', () => {
209
212
  });
210
213
  });
211
214
 
212
- it('rejects duplicate canonical API ids', () => {
215
+ it('rejects API registries whose key disagrees with API id', () => {
213
216
  const manifest = createManifest();
214
217
  const infra = manifest.infra as Record<string, unknown>;
215
- const apis = infra.apis as Record<string, unknown>[];
216
- apis.push(structuredClone(apis[0]));
218
+ const apis = infra.apis as Record<string, unknown>;
219
+ const nutrition = readOwnProperty(apis, 'nutrition');
220
+ apis.catalog = nutrition;
221
+ delete apis.nutrition;
217
222
 
218
223
  expect(isAppManifest(manifest)).toBe(false);
219
224
  });
@@ -1,3 +1,4 @@
1
+ import { readOwnProperty } from '@ankhorage/utility/object';
1
2
  import { describe, expect, it } from 'bun:test';
2
3
 
3
4
  import type {
@@ -474,8 +475,8 @@ describe('component data-binding contracts', () => {
474
475
  category: 'developer_tools',
475
476
  themeId: 'default',
476
477
  },
477
- themes: [
478
- {
478
+ themes: {
479
+ default: {
479
480
  id: 'default',
480
481
  name: 'Default',
481
482
  light: {
@@ -487,7 +488,7 @@ describe('component data-binding contracts', () => {
487
488
  harmony: 'analogous',
488
489
  },
489
490
  },
490
- ],
491
+ },
491
492
  activeThemeId: 'default',
492
493
  infra: {
493
494
  environments: {
@@ -495,8 +496,8 @@ describe('component data-binding contracts', () => {
495
496
  deployment: { compute: { provider: 'local' }, runtime: { provider: 'minikube' } },
496
497
  },
497
498
  },
498
- apis: [
499
- {
499
+ apis: {
500
+ cms: {
500
501
  id: 'cms',
501
502
  origin: 'external',
502
503
  protocol: 'rest',
@@ -519,8 +520,8 @@ describe('component data-binding contracts', () => {
519
520
  },
520
521
  },
521
522
  },
522
- ],
523
- modules: [],
523
+ },
524
+ modules: {},
524
525
  },
525
526
  navigator: {
526
527
  type: 'stack',
@@ -566,6 +567,8 @@ describe('component data-binding contracts', () => {
566
567
 
567
568
  assertSerializable(manifest);
568
569
  expect(manifest.dataBindings?.['hero-title']?.props?.children?.source.kind).toBe('operation');
569
- expect(manifest.infra.apis?.[0]?.id).toBe('cms');
570
+ expect(manifest.infra.apis ? readOwnProperty(manifest.infra.apis, 'cms')?.id : undefined).toBe(
571
+ 'cms',
572
+ );
570
573
  });
571
574
  });
package/src/bindings.ts CHANGED
@@ -1,17 +1,10 @@
1
1
  import type { ApiId, EndpointId, OperationId } from './data';
2
+ import type { SerializableValue } from './serializable';
2
3
 
3
4
  export type ComponentInstanceId = string;
4
5
  export type ComponentTypeId = string;
5
6
 
6
- export type BindingValue =
7
- | string
8
- | number
9
- | boolean
10
- | null
11
- | readonly BindingValue[]
12
- | {
13
- readonly [key: string]: BindingValue;
14
- };
7
+ export type BindingValue = SerializableValue;
15
8
 
16
9
  export type BindingDataPath = string;
17
10
 
@@ -0,0 +1,8 @@
1
+ import { isRecord } from '@ankhorage/utility/object';
2
+
3
+ export type SerializableSet<T extends string = string> = Readonly<Partial<Record<T, true>>>;
4
+
5
+ /*** Validate the canonical JSON-safe representation for unordered string membership. */
6
+ export function isSerializableSet(value: unknown): value is SerializableSet {
7
+ return isRecord(value) && Object.values(value).every((member) => member === true);
8
+ }
@@ -260,10 +260,11 @@ describe('contracts', () => {
260
260
  deployment: { compute: { provider: 'local' }, runtime: { provider: 'minikube' } },
261
261
  },
262
262
  },
263
- modules: [],
264
- modulesConfig: {
265
- localization: {
266
- defaultLocale: 'en',
263
+ modules: {
264
+ 'expo-localization': {
265
+ config: {
266
+ defaultLocale: 'en',
267
+ },
267
268
  },
268
269
  },
269
270
  },
@@ -273,10 +274,11 @@ describe('contracts', () => {
273
274
  state,
274
275
  infra: {
275
276
  environments: manifest.infra.environments,
276
- modules: [],
277
- modulesConfig: {
278
- localization: {
279
- defaultLocale: 'en',
277
+ modules: {
278
+ 'expo-localization': {
279
+ config: {
280
+ defaultLocale: 'en',
281
+ },
280
282
  },
281
283
  },
282
284
  },
@@ -295,11 +297,12 @@ describe('contracts', () => {
295
297
  deployment: { compute: { provider: 'local' }, runtime: { provider: 'minikube' } },
296
298
  },
297
299
  },
298
- modules: ['expo-localization'],
299
- modulesConfig: {
300
+ modules: {
300
301
  'expo-localization': {
301
- defaultLocale: 'en',
302
- locales: ['en', 'de'],
302
+ config: {
303
+ defaultLocale: 'en',
304
+ locales: ['en', 'de'],
305
+ },
303
306
  },
304
307
  },
305
308
  };
@@ -311,7 +314,7 @@ describe('contracts', () => {
311
314
 
312
315
  expect(hasLegacyModules).toBe(false);
313
316
  expect(hasLegacyModulesConfig).toBe(false);
314
- expect(Object.keys(infra).sort()).toEqual(['environments', 'modules', 'modulesConfig']);
317
+ expect(Object.keys(infra).sort()).toEqual(['environments', 'modules']);
315
318
  expect(expoConfig.plugins).toEqual(['expo-router']);
316
319
  });
317
320
 
@@ -1,7 +1,8 @@
1
+ import { readOwnProperty } from '@ankhorage/utility/object';
1
2
  import { describe, expect, it } from 'bun:test';
2
3
 
3
4
  import type {
4
- ApiDefinitionList,
5
+ ApiDefinitionRegistry,
5
6
  ExternalRestApiDefinition,
6
7
  InternalRestApiDefinition,
7
8
  } from './apis';
@@ -56,11 +57,12 @@ describe('canonical API contracts', () => {
56
57
  expect(api.endpoints.products?.operations['products.list']?.method).toBe('GET');
57
58
  });
58
59
 
59
- it('stores canonical APIs as an ordered infra list', () => {
60
- const apis: ApiDefinitionList = [createNutritionApi()];
60
+ it('stores canonical APIs in an identity-keyed registry', () => {
61
+ const api = createNutritionApi();
62
+ const apis: ApiDefinitionRegistry = { [api.id]: api };
61
63
 
62
64
  assertSerializable(apis);
63
- expect(apis[0]?.id).toBe('nutrition');
65
+ expect(readOwnProperty<ApiDefinitionRegistry[string]>(apis, 'nutrition')?.id).toBe('nutrition');
64
66
  });
65
67
 
66
68
  it('reserves an internal REST API identity without persistence implementation', () => {
package/src/data/apis.ts CHANGED
@@ -60,4 +60,4 @@ export interface InternalRestApiDefinition extends ApiBaseDefinition {
60
60
  export type ApiDefinition =
61
61
  ExternalGraphQlApiDefinition | ExternalRestApiDefinition | InternalRestApiDefinition;
62
62
 
63
- export type ApiDefinitionList = readonly ApiDefinition[];
63
+ export type ApiDefinitionRegistry = Readonly<Record<ApiId, ApiDefinition>>;
@@ -1,11 +1,5 @@
1
- export type DataContractValue =
2
- | string
3
- | number
4
- | boolean
5
- | null
6
- | readonly DataContractValue[]
7
- | {
8
- readonly [key: string]: DataContractValue;
9
- };
1
+ import type { SerializableValue } from '../serializable';
2
+
3
+ export type DataContractValue = SerializableValue;
10
4
 
11
5
  export type DataPath = string;
@@ -17,14 +17,14 @@ function createManifest(deploy?: unknown): Record<string, unknown> {
17
17
  category: 'developer_tools',
18
18
  themeId: 'default',
19
19
  },
20
- themes: [
21
- {
20
+ themes: {
21
+ default: {
22
22
  id: 'default',
23
23
  name: 'Default',
24
24
  light: { primaryColor: '#3366ff', harmony: 'analogous' },
25
25
  dark: { primaryColor: '#6699ff', harmony: 'analogous' },
26
26
  },
27
- ],
27
+ },
28
28
  activeThemeId: 'default',
29
29
  ...(deploy === undefined ? {} : { deploy }),
30
30
  infra: {
@@ -33,7 +33,7 @@ function createManifest(deploy?: unknown): Record<string, unknown> {
33
33
  deployment: { compute: { provider: 'local' }, runtime: { provider: 'minikube' } },
34
34
  },
35
35
  },
36
- modules: [],
36
+ modules: {},
37
37
  },
38
38
  navigator: { type: 'stack', routes: [] },
39
39
  screens: {},
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from './appManifest';
2
2
  export * from './auth';
3
3
  export * from './bindings';
4
4
  export * from './cli';
5
+ export * from './collections';
5
6
  export * from './data';
6
7
  export * from './db';
7
8
  export * from './deploy';
@@ -14,6 +15,7 @@ export * from './repository';
14
15
  export * from './requirements';
15
16
  export * from './runtimeCallbacks';
16
17
  export * from './secrets';
18
+ export * from './serializable';
17
19
  export * from './state';
18
20
  export * from './storage';
19
21
  export * from './theme';
@@ -1,6 +1,7 @@
1
1
  import { isRecord } from '@ankhorage/utility/object';
2
2
  import { isNonEmptyString } from '@ankhorage/utility/string';
3
3
 
4
+ import { isSerializableSet } from '../collections';
4
5
  import type {
5
6
  InfraEnvironmentSpec,
6
7
  InfraObjectStorageSpec,
@@ -92,16 +93,14 @@ function hasNetworkingRelationships(value: InfraEnvironmentSpec): boolean {
92
93
  /*** Validate portable, unique Cerbos policy files without host paths or traversal. */
93
94
  function isPolicyFiles(value: unknown): boolean {
94
95
  return (
95
- Array.isArray(value) &&
96
- value.every((policy) =>
97
- isInfraShape(policy, {
98
- path: (path) =>
99
- isNonEmptyString(path) && !path.startsWith('/') && !path.split('/').includes('..'),
100
- content: isNonEmptyString,
101
- }),
102
- ) &&
103
- new Set(value.map((policy) => (isRecord(policy) ? policy.path : undefined))).size ===
104
- value.length
96
+ isRecord(value) &&
97
+ Object.entries(value).every(
98
+ ([path, content]) =>
99
+ isNonEmptyString(path) &&
100
+ !path.startsWith('/') &&
101
+ !path.split('/').includes('..') &&
102
+ isNonEmptyString(content),
103
+ )
105
104
  );
106
105
  }
107
106
 
@@ -133,7 +132,7 @@ function isObjectStorage(value: unknown): boolean {
133
132
  if (!isRecord(value)) return false;
134
133
  const common = {
135
134
  provider: (provider: unknown) => provider === value.provider,
136
- buckets: infraFields.optionalStrings,
135
+ buckets: (buckets: unknown) => buckets === undefined || isSerializableSet(buckets),
137
136
  };
138
137
  if (value.provider === 'supabase') {
139
138
  return isInfraShape(value, {
@@ -153,16 +152,17 @@ function isObjectStorage(value: unknown): boolean {
153
152
  /*** Duplicate desired workload identities cannot be reconciled safely. */
154
153
  function isWorkloads(value: unknown): boolean {
155
154
  return (
156
- Array.isArray(value) &&
157
- value.every(isInfraWorkloadSpec) &&
158
- new Set(value.map((workload) => workload.id)).size === value.length
155
+ isRecord(value) &&
156
+ Object.entries(value).every(
157
+ ([workloadId, workload]) => isInfraWorkloadSpec(workload) && workload.id === workloadId,
158
+ )
159
159
  );
160
160
  }
161
161
 
162
162
  /*** Prevent two workloads in one environment from claiming the same external listener. */
163
163
  function hasUniquePublishedPorts(value: InfraEnvironmentSpec): boolean {
164
- const ports = (value.workloads ?? []).flatMap((workload) =>
165
- (workload.ports ?? []).flatMap(({ publishedPort }) =>
164
+ const ports = Object.values(value.workloads ?? {}).flatMap((workload) =>
165
+ Object.values(workload.ports ?? {}).flatMap(({ publishedPort }) =>
166
166
  publishedPort === undefined ? [] : [publishedPort],
167
167
  ),
168
168
  );
@@ -1,10 +1,10 @@
1
1
  import { isRecord } from '@ankhorage/utility/object';
2
2
 
3
- import { isApiDefinitionList } from '../appManifest/apis';
3
+ import { isApiDefinitionRegistry } from '../appManifest/apis';
4
4
  import { APP_ENVIRONMENT_IDS } from '../environments';
5
- import type { InfraManifest } from '../types/infraManifest';
5
+ import { isSerializableValue } from '../serializable';
6
+ import type { InfraManifest, InfraModuleSpec } from '../types/infraManifest';
6
7
  import type { InfraShape } from '../types/infraValidation';
7
- import { infraFields } from './infraFields';
8
8
  import { isInfraEnvironmentSpec } from './isInfraEnvironmentSpec';
9
9
  import { isInfraShape } from './isInfraShape';
10
10
 
@@ -12,9 +12,8 @@ import { isInfraShape } from './isInfraShape';
12
12
  export function isInfraManifest(value: unknown): value is InfraManifest {
13
13
  return isInfraShape(value, {
14
14
  environments: isEnvironments,
15
- apis: (apis) => apis === undefined || isApiDefinitionList(apis),
16
- modules: infraFields.strings,
17
- modulesConfig: (config) => config === undefined || isRecord(config),
15
+ apis: (apis) => apis === undefined || isApiDefinitionRegistry(apis),
16
+ modules: isModuleRegistry,
18
17
  } satisfies InfraShape<InfraManifest>);
19
18
  }
20
19
 
@@ -27,3 +26,15 @@ function isEnvironments(value: unknown): boolean {
27
26
  ((environment === undefined && key !== 'local') || isInfraEnvironmentSpec(environment)),
28
27
  );
29
28
  }
29
+
30
+ /*** Validate installed module identity and optional serializable module-owned configuration. */
31
+ function isModuleRegistry(value: unknown): boolean {
32
+ return isRecord(value) && Object.values(value).every(isModuleSpec);
33
+ }
34
+
35
+ /*** Validate one module registry entry. */
36
+ function isModuleSpec(value: unknown): value is InfraModuleSpec {
37
+ return isInfraShape(value, {
38
+ config: (config) => config === undefined || isSerializableValue(config),
39
+ });
40
+ }