@ankhorage/contracts 20.0.0 → 22.0.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/appManifest/apis.d.ts +3 -3
  3. package/dist/appManifest/apis.d.ts.map +1 -1
  4. package/dist/appManifest/apis.js +4 -6
  5. package/dist/appManifest/apis.js.map +1 -1
  6. package/dist/appManifest/bindings.d.ts.map +1 -1
  7. package/dist/appManifest/bindings.js +2 -1
  8. package/dist/appManifest/bindings.js.map +1 -1
  9. package/dist/appManifest/data.d.ts.map +1 -1
  10. package/dist/appManifest/data.js +5 -4
  11. package/dist/appManifest/data.js.map +1 -1
  12. package/dist/appManifest/isAppManifest.js +3 -3
  13. package/dist/appManifest/isAppManifest.js.map +1 -1
  14. package/dist/appManifest/isManifestValue.d.ts +1 -1
  15. package/dist/appManifest/isManifestValue.d.ts.map +1 -1
  16. package/dist/appManifest/isManifestValue.js +3 -12
  17. package/dist/appManifest/isManifestValue.js.map +1 -1
  18. package/dist/appManifest/screens.d.ts +3 -2
  19. package/dist/appManifest/screens.d.ts.map +1 -1
  20. package/dist/appManifest/screens.js +15 -7
  21. package/dist/appManifest/screens.js.map +1 -1
  22. package/dist/bindings.d.ts +2 -3
  23. package/dist/bindings.d.ts.map +1 -1
  24. package/dist/bindings.js.map +1 -1
  25. package/dist/collections.d.ts +4 -0
  26. package/dist/collections.d.ts.map +1 -0
  27. package/dist/collections.js +6 -0
  28. package/dist/collections.js.map +1 -0
  29. package/dist/data/apis.d.ts +1 -1
  30. package/dist/data/apis.d.ts.map +1 -1
  31. package/dist/data/apis.js.map +1 -1
  32. package/dist/data/values.d.ts +2 -3
  33. package/dist/data/values.d.ts.map +1 -1
  34. package/dist/data/values.js.map +1 -1
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/infra/isInfraEnvironmentSpec.d.ts.map +1 -1
  40. package/dist/infra/isInfraEnvironmentSpec.js +10 -12
  41. package/dist/infra/isInfraEnvironmentSpec.js.map +1 -1
  42. package/dist/infra/isInfraManifest.d.ts.map +1 -1
  43. package/dist/infra/isInfraManifest.js +14 -5
  44. package/dist/infra/isInfraManifest.js.map +1 -1
  45. package/dist/infra/isInfraWorkloadSpec.d.ts.map +1 -1
  46. package/dist/infra/isInfraWorkloadSpec.js +26 -20
  47. package/dist/infra/isInfraWorkloadSpec.js.map +1 -1
  48. package/dist/requirements.d.ts +5 -10
  49. package/dist/requirements.d.ts.map +1 -1
  50. package/dist/requirements.js.map +1 -1
  51. package/dist/serializable.d.ts +6 -0
  52. package/dist/serializable.d.ts.map +1 -0
  53. package/dist/serializable.js +14 -0
  54. package/dist/serializable.js.map +1 -0
  55. package/dist/state.d.ts +2 -3
  56. package/dist/state.d.ts.map +1 -1
  57. package/dist/state.js.map +1 -1
  58. package/dist/types/infraAdapters.d.ts +12 -3
  59. package/dist/types/infraAdapters.d.ts.map +1 -1
  60. package/dist/types/infraAdapters.js.map +1 -1
  61. package/dist/types/infraManifest.d.ts +14 -13
  62. package/dist/types/infraManifest.d.ts.map +1 -1
  63. package/dist/types/infraManifest.js.map +1 -1
  64. package/dist/types/infraWorkload.d.ts +11 -11
  65. package/dist/types/infraWorkload.d.ts.map +1 -1
  66. package/dist/types/infraWorkload.js.map +1 -1
  67. package/dist/types.d.ts +9 -8
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js.map +1 -1
  70. package/package.json +10 -9
  71. package/src/appManifest/apis.ts +9 -6
  72. package/src/appManifest/bindings.ts +7 -1
  73. package/src/appManifest/data.ts +13 -4
  74. package/src/appManifest/isAppManifest.ts +3 -3
  75. package/src/appManifest/isManifestValue.ts +3 -16
  76. package/src/appManifest/screens.ts +21 -10
  77. package/src/appManifest.test.ts +21 -16
  78. package/src/bindings.test.ts +11 -8
  79. package/src/bindings.ts +2 -9
  80. package/src/collections.ts +8 -0
  81. package/src/contracts.test.ts +16 -13
  82. package/src/data/apis.test.ts +6 -4
  83. package/src/data/apis.ts +1 -1
  84. package/src/data/values.ts +3 -9
  85. package/src/deploy.test.ts +4 -4
  86. package/src/index.ts +2 -0
  87. package/src/infra/isInfraEnvironmentSpec.ts +16 -16
  88. package/src/infra/isInfraManifest.ts +17 -6
  89. package/src/infra/isInfraWorkloadSpec.ts +39 -24
  90. package/src/infra-published-port.test.ts +8 -8
  91. package/src/infra-types.test.ts +27 -0
  92. package/src/infra-workload-persistence.test.ts +4 -4
  93. package/src/infra-workload.test.ts +24 -22
  94. package/src/infra.test.ts +16 -20
  95. package/src/infraPersistence.test.ts +1 -1
  96. package/src/infraServices.test.ts +1 -1
  97. package/src/requirements.test.ts +2 -2
  98. package/src/requirements.ts +6 -12
  99. package/src/secrets.test.ts +1 -1
  100. package/src/serializable.ts +25 -0
  101. package/src/state.ts +3 -6
  102. package/src/types/infraAdapters.ts +20 -5
  103. package/src/types/infraManifest.ts +17 -13
  104. package/src/types/infraWorkload.ts +12 -11
  105. package/src/types.ts +11 -12
@@ -1,19 +1,6 @@
1
- import { isRecord } from '@ankhorage/utility/object';
1
+ import { isSerializableValue } from '../serializable';
2
2
 
3
- /*** Validate the existing recursive ManifestValue domain used by authored bindings and API metadata. */
3
+ /*** Validate authored values through the canonical serializable-value contract. */
4
4
  export function isManifestValue(value: unknown): boolean {
5
- if (
6
- value === null ||
7
- typeof value === 'string' ||
8
- typeof value === 'number' ||
9
- typeof value === 'boolean'
10
- ) {
11
- return true;
12
- }
13
-
14
- if (Array.isArray(value)) {
15
- return value.every(isManifestValue);
16
- }
17
-
18
- return isRecord(value) && Object.values(value).every(isManifestValue);
5
+ return isSerializableValue(value);
19
6
  }
@@ -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
  }
@@ -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
+ }
@@ -2,6 +2,7 @@ import { isStringArray } from '@ankhorage/utility/array';
2
2
  import { isRecord } from '@ankhorage/utility/object';
3
3
  import { isNonEmptyString } from '@ankhorage/utility/string';
4
4
 
5
+ import { isSerializableSet } from '../collections';
5
6
  import type { InfraShape } from '../types/infraValidation';
6
7
  import type { InfraWorkloadSpec } from '../types/infraWorkload';
7
8
  import { infraFields } from './infraFields';
@@ -18,15 +19,11 @@ export function isInfraWorkloadSpec(value: unknown): value is InfraWorkloadSpec
18
19
  isInfraShape(artifact, { kind: (kind) => kind === 'image', image: isNonEmptyString }),
19
20
  command: infraFields.optionalStrings,
20
21
  args: (args) => args === undefined || isStringArray(args),
21
- ports: (ports) =>
22
- ports === undefined ||
23
- (Array.isArray(ports) && ports.every(isPort) && hasUniqueField(ports, 'name')),
22
+ ports: (ports) => ports === undefined || isPortRegistry(ports),
24
23
  environment: (environment) =>
25
24
  environment === undefined ||
26
25
  (isRecord(environment) && Object.values(environment).every(isInfraWorkloadValue)),
27
- files: (files) =>
28
- files === undefined ||
29
- (Array.isArray(files) && files.every(isFile) && hasUniqueField(files, 'path')),
26
+ files: (files) => files === undefined || isFileMap(files),
30
27
  health: (health) => health === undefined || isInfraWorkloadHealth(health),
31
28
  resources: (resources) =>
32
29
  resources === undefined ||
@@ -34,16 +31,11 @@ export function isInfraWorkloadSpec(value: unknown): value is InfraWorkloadSpec
34
31
  cpuMillis: infraFields.optionalPositiveInteger,
35
32
  memoryMiB: infraFields.optionalPositiveInteger,
36
33
  }),
37
- persistence: (volumes) =>
38
- volumes === undefined ||
39
- (Array.isArray(volumes) &&
40
- volumes.every(isVolume) &&
41
- hasUniqueField(volumes, 'id') &&
42
- hasUniqueField(volumes, 'mountPath')),
34
+ persistence: (volumes) => volumes === undefined || isVolumeRegistry(volumes),
43
35
  exposure: (exposure) =>
44
36
  exposure === undefined || exposure === 'internal' || exposure === 'public',
45
37
  replicas: infraFields.optionalNonnegativeInteger,
46
- dependsOn: infraFields.optionalStrings,
38
+ dependsOn: (dependsOn) => dependsOn === undefined || isSerializableSet(dependsOn),
47
39
  } satisfies InfraShape<InfraWorkloadSpec>)
48
40
  ) {
49
41
  return false;
@@ -51,10 +43,17 @@ export function isInfraWorkloadSpec(value: unknown): value is InfraWorkloadSpec
51
43
  return hasValidPublishedPorts(value);
52
44
  }
53
45
 
46
+ /*** Validate named transport ports keyed by their stable authored name. */
47
+ function isPortRegistry(value: unknown): boolean {
48
+ return (
49
+ isRecord(value) &&
50
+ Object.entries(value).every(([name, port]) => isNonEmptyString(name) && isPort(port))
51
+ );
52
+ }
53
+
54
54
  /*** Named transport ports stay independent from runtime resource types. */
55
55
  function isPort(value: unknown): boolean {
56
56
  return isInfraShape(value, {
57
- name: isNonEmptyString,
58
57
  port: infraFields.port,
59
58
  protocol: (protocol) => protocol === undefined || protocol === 'tcp' || protocol === 'udp',
60
59
  publishedPort: infraFields.optionalPort,
@@ -64,7 +63,7 @@ function isPort(value: unknown): boolean {
64
63
  /*** Require fixed external listeners to belong to one public workload replica. */
65
64
  function hasValidPublishedPorts(workload: unknown): boolean {
66
65
  if (!isRecord(workload)) return false;
67
- const ports = Array.isArray(workload.ports) ? workload.ports : [];
66
+ const ports = isRecord(workload.ports) ? Object.values(workload.ports) : [];
68
67
  const published = ports.flatMap((port) => {
69
68
  if (!isRecord(port) || typeof port.publishedPort !== 'number') return [];
70
69
  return [port.publishedPort];
@@ -78,9 +77,31 @@ function hasValidPublishedPorts(workload: unknown): boolean {
78
77
  );
79
78
  }
80
79
 
81
- /*** Portable files model policy/config materialization without host filesystem access. */
82
- function isFile(value: unknown): boolean {
83
- return isInfraShape(value, { path: isAbsoluteWorkloadPath, content: isInfraWorkloadValue });
80
+ /*** Validate portable config/policy files keyed by their absolute container path. */
81
+ function isFileMap(value: unknown): boolean {
82
+ return (
83
+ isRecord(value) &&
84
+ Object.entries(value).every(
85
+ ([filePath, content]) => isAbsoluteWorkloadPath(filePath) && isInfraWorkloadValue(content),
86
+ )
87
+ );
88
+ }
89
+
90
+ /*** Validate persistence volumes by stable ID and keep mount paths unique within one workload. */
91
+ function isVolumeRegistry(value: unknown): boolean {
92
+ if (!isRecord(value)) return false;
93
+ const entries = Object.entries(value);
94
+ if (
95
+ !entries.every(
96
+ ([volumeId, volume]) => isVolume(volume) && isRecord(volume) && volume.id === volumeId,
97
+ )
98
+ ) {
99
+ return false;
100
+ }
101
+ const mountPaths = Object.values(value).map((volume) =>
102
+ isRecord(volume) ? volume.mountPath : undefined,
103
+ );
104
+ return new Set(mountPaths).size === mountPaths.length;
84
105
  }
85
106
 
86
107
  /*** Persistence has explicit ownership-local identity, optional initialization and retention policy. */
@@ -103,9 +124,3 @@ function isAbsoluteWorkloadPath(value: unknown): boolean {
103
124
  !value.split('/').includes('..')
104
125
  );
105
126
  }
106
-
107
- /*** Duplicate workload-local identities would make runtime projection ambiguous. */
108
- function hasUniqueField(values: readonly unknown[], field: string): boolean {
109
- const ids = values.map((value) => (isRecord(value) ? Reflect.get(value, field) : undefined));
110
- return new Set(ids).size === ids.length;
111
- }