@ankhorage/contracts 7.1.0 → 7.2.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.
- package/CHANGELOG.md +6 -0
- package/dist/appManifest/bindings.d.ts +4 -0
- package/dist/appManifest/bindings.d.ts.map +1 -0
- package/dist/appManifest/bindings.js +96 -0
- package/dist/appManifest/bindings.js.map +1 -0
- package/dist/appManifest/dataSources.d.ts +2 -0
- package/dist/appManifest/dataSources.d.ts.map +1 -0
- package/dist/appManifest/dataSources.js +133 -0
- package/dist/appManifest/dataSources.js.map +1 -0
- package/dist/appManifest/generatedApis.d.ts +2 -0
- package/dist/appManifest/generatedApis.d.ts.map +1 -0
- package/dist/appManifest/generatedApis.js +87 -0
- package/dist/appManifest/generatedApis.js.map +1 -0
- package/dist/appManifest/infra.d.ts +2 -0
- package/dist/appManifest/infra.d.ts.map +1 -0
- package/dist/appManifest/infra.js +136 -0
- package/dist/appManifest/infra.js.map +1 -0
- package/dist/appManifest/screens.d.ts +6 -0
- package/dist/appManifest/screens.d.ts.map +1 -0
- package/dist/appManifest/screens.js +119 -0
- package/dist/appManifest/screens.js.map +1 -0
- package/dist/appManifest/shared.d.ts +8 -0
- package/dist/appManifest/shared.d.ts.map +1 -0
- package/dist/appManifest/shared.js +31 -0
- package/dist/appManifest/shared.js.map +1 -0
- package/dist/appManifest.d.ts +19 -0
- package/dist/appManifest.d.ts.map +1 -0
- package/dist/appManifest.js +64 -0
- package/dist/appManifest.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/appManifest/bindings.ts +135 -0
- package/src/appManifest/dataSources.ts +175 -0
- package/src/appManifest/generatedApis.ts +122 -0
- package/src/appManifest/infra.ts +199 -0
- package/src/appManifest/screens.ts +171 -0
- package/src/appManifest/shared.ts +40 -0
- package/src/appManifest.test.ts +185 -0
- package/src/appManifest.ts +86 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { isAppManifest, parseAppManifest } from './appManifest';
|
|
4
|
+
|
|
5
|
+
function createManifest(): Record<string, unknown> {
|
|
6
|
+
return {
|
|
7
|
+
metadata: {
|
|
8
|
+
name: 'Example',
|
|
9
|
+
slug: 'example',
|
|
10
|
+
version: '1.0.0',
|
|
11
|
+
category: 'developer_tools',
|
|
12
|
+
themeId: 'default',
|
|
13
|
+
},
|
|
14
|
+
themes: [
|
|
15
|
+
{
|
|
16
|
+
id: 'default',
|
|
17
|
+
name: 'Default',
|
|
18
|
+
light: { primaryColor: '#3366ff', harmony: 'analogous' },
|
|
19
|
+
dark: { primaryColor: '#6699ff', harmony: 'analogous' },
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
activeThemeId: 'default',
|
|
23
|
+
activeThemeMode: 'light',
|
|
24
|
+
splashScreen: {
|
|
25
|
+
image: './assets/splash.png',
|
|
26
|
+
resizeMode: 'contain',
|
|
27
|
+
backgroundColor: '#ffffff',
|
|
28
|
+
dark: { backgroundColor: '#000000' },
|
|
29
|
+
},
|
|
30
|
+
infra: {
|
|
31
|
+
deployment: { target: 'minikube', monitoring: true },
|
|
32
|
+
database: { provider: 'supabase', tier: 'dev' },
|
|
33
|
+
storage: { provider: 'auto', buckets: ['media'] },
|
|
34
|
+
state: { provider: 'legend', persistence: 'local' },
|
|
35
|
+
networking: { domain: 'example.test', cdn: false },
|
|
36
|
+
modules: ['expo-localization'],
|
|
37
|
+
modulesConfig: { localization: { defaultLocale: 'en' } },
|
|
38
|
+
},
|
|
39
|
+
navigator: {
|
|
40
|
+
type: 'stack',
|
|
41
|
+
initialRouteName: 'home',
|
|
42
|
+
routes: [
|
|
43
|
+
{
|
|
44
|
+
name: 'home',
|
|
45
|
+
path: '/',
|
|
46
|
+
screenId: 'home',
|
|
47
|
+
showInPrimaryNavigation: true,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
screens: {
|
|
52
|
+
home: {
|
|
53
|
+
id: 'home',
|
|
54
|
+
name: 'Home',
|
|
55
|
+
root: {
|
|
56
|
+
id: 'root',
|
|
57
|
+
type: 'Stack',
|
|
58
|
+
children: [
|
|
59
|
+
{
|
|
60
|
+
id: 'title',
|
|
61
|
+
type: 'Text',
|
|
62
|
+
repeat: { source: { kind: 'state', path: 'items' }, itemAlias: 'item' },
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
dataLoaders: [
|
|
67
|
+
{
|
|
68
|
+
kind: 'operation',
|
|
69
|
+
id: 'load-users',
|
|
70
|
+
operation: { dataSourceId: 'external', endpointId: 'main', operationId: 'list' },
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
requires: {
|
|
74
|
+
permissions: [{ permission: 'camera' }],
|
|
75
|
+
capabilities: [{ capability: 'barcodeScanner' }],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
generatedApis: {
|
|
80
|
+
users: {
|
|
81
|
+
id: 'users',
|
|
82
|
+
protocol: 'rest',
|
|
83
|
+
basePath: '/users',
|
|
84
|
+
database: { id: 'primary', kind: 'database' },
|
|
85
|
+
resources: [
|
|
86
|
+
{
|
|
87
|
+
id: 'users',
|
|
88
|
+
path: '/users',
|
|
89
|
+
collection: {
|
|
90
|
+
name: 'users',
|
|
91
|
+
fields: [{ name: 'id', type: 'uuid', required: true }],
|
|
92
|
+
primaryKey: 'id',
|
|
93
|
+
},
|
|
94
|
+
operations: ['list', 'read'],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
dataSources: {
|
|
100
|
+
external: {
|
|
101
|
+
id: 'external',
|
|
102
|
+
kind: 'api',
|
|
103
|
+
origin: 'external',
|
|
104
|
+
protocol: 'rest',
|
|
105
|
+
baseUrl: 'https://example.test',
|
|
106
|
+
endpoints: {
|
|
107
|
+
main: {
|
|
108
|
+
id: 'main',
|
|
109
|
+
kind: 'http',
|
|
110
|
+
operations: {
|
|
111
|
+
list: { id: 'list', protocol: 'rest', intent: 'read', path: '/users' },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
dataBindings: {
|
|
118
|
+
title: {
|
|
119
|
+
componentId: 'title',
|
|
120
|
+
props: { text: { source: { kind: 'state', path: 'title' } } },
|
|
121
|
+
events: {
|
|
122
|
+
press: [{ target: { kind: 'action', type: 'console' } }],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
settings: {
|
|
127
|
+
apiBaseUrl: 'https://example.test/api',
|
|
128
|
+
localization: { defaultLocale: 'en', locales: ['en', 'de'] },
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe('AppManifest runtime parsing', () => {
|
|
134
|
+
it('accepts the canonical manifest including optional nested sections', () => {
|
|
135
|
+
const manifest = createManifest();
|
|
136
|
+
|
|
137
|
+
expect(isAppManifest(manifest)).toBe(true);
|
|
138
|
+
expect(parseAppManifest(manifest)).toEqual({ ok: true, manifest });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('rejects missing required top-level sections', () => {
|
|
142
|
+
const manifest = createManifest();
|
|
143
|
+
delete manifest.settings;
|
|
144
|
+
|
|
145
|
+
expect(isAppManifest(manifest)).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('rejects malformed nested canonical structures', () => {
|
|
149
|
+
const manifest = createManifest();
|
|
150
|
+
const themes = manifest.themes as Record<string, unknown>[];
|
|
151
|
+
const light = themes[0]?.light as Record<string, unknown>;
|
|
152
|
+
light.harmony = 'not-a-harmony';
|
|
153
|
+
|
|
154
|
+
expect(parseAppManifest(manifest)).toEqual({
|
|
155
|
+
ok: false,
|
|
156
|
+
message: 'Value is not a canonical AppManifest.',
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('rejects legacy infra plugin state', () => {
|
|
161
|
+
const manifest = createManifest();
|
|
162
|
+
const infra = manifest.infra as Record<string, unknown>;
|
|
163
|
+
infra.plugins = ['legacy'];
|
|
164
|
+
|
|
165
|
+
expect(isAppManifest(manifest)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('rejects screen registries whose key disagrees with screen id', () => {
|
|
169
|
+
const manifest = createManifest();
|
|
170
|
+
const screens = manifest.screens as Record<string, unknown>;
|
|
171
|
+
screens.other = screens.home;
|
|
172
|
+
delete screens.home;
|
|
173
|
+
|
|
174
|
+
expect(isAppManifest(manifest)).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('rejects invalid data-binding source kinds', () => {
|
|
178
|
+
const manifest = createManifest();
|
|
179
|
+
const bindings = manifest.dataBindings as Record<string, Record<string, unknown>>;
|
|
180
|
+
const props = bindings.title?.props as Record<string, Record<string, unknown>>;
|
|
181
|
+
props.text = { source: { kind: 'provider-specific', path: 'title' } };
|
|
182
|
+
|
|
183
|
+
expect(isAppManifest(manifest)).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { isComponentDataBindingRegistry } from './appManifest/bindings';
|
|
2
|
+
import { isDataSourceRegistry } from './appManifest/dataSources';
|
|
3
|
+
import { isGeneratedApiRegistry } from './appManifest/generatedApis';
|
|
4
|
+
import { isInfraManifest } from './appManifest/infra';
|
|
5
|
+
import {
|
|
6
|
+
isManifestMetadata,
|
|
7
|
+
isNavigatorSpec,
|
|
8
|
+
isScreenRegistry,
|
|
9
|
+
isSplashScreenSpec,
|
|
10
|
+
isThemeConfig,
|
|
11
|
+
} from './appManifest/screens';
|
|
12
|
+
import { isOptionalString, isRecord, isStringArray } from './appManifest/shared';
|
|
13
|
+
import type { AppManifest } from './types';
|
|
14
|
+
|
|
15
|
+
export type AppManifestParseResult =
|
|
16
|
+
| { readonly ok: true; readonly manifest: AppManifest }
|
|
17
|
+
| { readonly ok: false; readonly message: string };
|
|
18
|
+
|
|
19
|
+
const APP_MANIFEST_KEY_POLICY = {
|
|
20
|
+
metadata: 'required',
|
|
21
|
+
themes: 'required',
|
|
22
|
+
activeThemeId: 'required',
|
|
23
|
+
activeThemeMode: 'optional',
|
|
24
|
+
splashScreen: 'optional',
|
|
25
|
+
infra: 'required',
|
|
26
|
+
navigator: 'required',
|
|
27
|
+
screens: 'required',
|
|
28
|
+
generatedApis: 'optional',
|
|
29
|
+
dataSources: 'optional',
|
|
30
|
+
dataBindings: 'optional',
|
|
31
|
+
settings: 'required',
|
|
32
|
+
} as const satisfies Record<keyof AppManifest, 'optional' | 'required'>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse unknown JSON-compatible input at the canonical AppManifest boundary.
|
|
36
|
+
*
|
|
37
|
+
* Contracts owns structural manifest validation. Consumers may add semantic
|
|
38
|
+
* diagnostics after this parser succeeds, but should not reconstruct the
|
|
39
|
+
* AppManifest shape in their own packages.
|
|
40
|
+
*/
|
|
41
|
+
export function parseAppManifest(value: unknown): AppManifestParseResult {
|
|
42
|
+
return isAppManifest(value)
|
|
43
|
+
? { ok: true, manifest: value }
|
|
44
|
+
: { ok: false, message: 'Value is not a canonical AppManifest.' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Return whether an unknown value satisfies the canonical AppManifest shape. */
|
|
48
|
+
export function isAppManifest(value: unknown): value is AppManifest {
|
|
49
|
+
return (
|
|
50
|
+
isRecord(value) &&
|
|
51
|
+
hasRequiredManifestKeys(value) &&
|
|
52
|
+
isManifestMetadata(value.metadata) &&
|
|
53
|
+
Array.isArray(value.themes) &&
|
|
54
|
+
value.themes.every(isThemeConfig) &&
|
|
55
|
+
typeof value.activeThemeId === 'string' &&
|
|
56
|
+
isActiveThemeMode(value.activeThemeMode) &&
|
|
57
|
+
(value.splashScreen === undefined || isSplashScreenSpec(value.splashScreen)) &&
|
|
58
|
+
isInfraManifest(value.infra) &&
|
|
59
|
+
isNavigatorSpec(value.navigator) &&
|
|
60
|
+
isScreenRegistry(value.screens) &&
|
|
61
|
+
(value.generatedApis === undefined || isGeneratedApiRegistry(value.generatedApis)) &&
|
|
62
|
+
(value.dataSources === undefined || isDataSourceRegistry(value.dataSources)) &&
|
|
63
|
+
(value.dataBindings === undefined || isComponentDataBindingRegistry(value.dataBindings)) &&
|
|
64
|
+
isAppSettings(value.settings)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasRequiredManifestKeys(value: Record<string, unknown>): boolean {
|
|
69
|
+
return Object.entries(APP_MANIFEST_KEY_POLICY).every(
|
|
70
|
+
([key, policy]) => policy === 'optional' || key in value,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isActiveThemeMode(value: unknown): boolean {
|
|
75
|
+
return value === undefined || value === 'dark' || value === 'light';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isAppSettings(value: unknown): boolean {
|
|
79
|
+
return (
|
|
80
|
+
isRecord(value) &&
|
|
81
|
+
isRecord(value.localization) &&
|
|
82
|
+
typeof value.localization.defaultLocale === 'string' &&
|
|
83
|
+
isStringArray(value.localization.locales) &&
|
|
84
|
+
isOptionalString(value.apiBaseUrl)
|
|
85
|
+
);
|
|
86
|
+
}
|
package/src/index.ts
CHANGED