@reventlessdev/reventless-spec 3.0.0-alpha.84 → 3.0.0-alpha.86
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 +16 -0
- package/package.json +5 -3
- package/run-platform-generator.mjs +2 -0
- package/src/components/CapabilityManifest.res +67 -0
- package/src/components/CapabilityManifest.res.mjs +59 -0
- package/src/components/Plugin.res +28 -0
- package/src/components/Plugin.res.mjs +12 -1
- package/src/generator/PlatformCodegen.res +139 -0
- package/src/generator/PlatformCodegen.res.mjs +116 -0
- package/src/generator/PlatformGenerator.res +127 -0
- package/src/generator/PlatformGenerator.res.mjs +114 -0
- package/src/generator/PlatformManifests.res +203 -0
- package/src/generator/PlatformManifests.res.mjs +212 -0
- package/src/semantic/Bytes.res +54 -0
- package/src/semantic/Bytes.res.mjs +38 -0
- package/src/semantic/Color.res +51 -0
- package/src/semantic/Color.res.mjs +29 -0
- package/src/semantic/Duration.res +53 -0
- package/src/semantic/Duration.res.mjs +26 -0
- package/src/semantic/Email.res +51 -0
- package/src/semantic/Email.res.mjs +31 -0
- package/src/semantic/Percent.res +53 -0
- package/src/semantic/Percent.res.mjs +33 -0
- package/src/semantic/Phone.res +55 -0
- package/src/semantic/Phone.res.mjs +29 -0
- package/src/semantic/Semantic.res +34 -0
- package/src/semantic/Semantic.res.mjs +25 -1
- package/src/semantic/Url.res +66 -0
- package/src/semantic/Url.res.mjs +48 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.86 (2026-07-30)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **spec,local:** resolve capability manifests beyond the example topology ([8bfadd5](https://github.com/ReventlessDev/reventless-core/commit/8bfadd5beef90c0c9f6f6f3fbb753c8b789469ed))
|
|
11
|
+
* **spec:** semantic branded scalars — Email, Url, Phone, Percent, Color, Bytes, Duration ([2e64ff9](https://github.com/ReventlessDev/reventless-core/commit/2e64ff992e913c038a71659c731c25a104e3c5a9))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.85 (2026-07-30)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **spec,core,local:** emit capabilities.json from the plugin build ([57a3276](https://github.com/ReventlessDev/reventless-core/commit/57a3276a6b02af93490a8b460e3aa158b7e0e0f8))
|
|
19
|
+
* **spec,core:** derive the platform capability list — inference and generation (stage 3) ([8ab4d25](https://github.com/ReventlessDev/reventless-core/commit/8ab4d2504bd717487fbc87a0f5a053024c8258f9))
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
# 3.0.0-alpha.84 (2026-07-29)
|
|
7
23
|
|
|
8
24
|
### Features
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-spec",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.86",
|
|
4
4
|
"description": "Specifications for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
7
|
-
"generate-plugin": "./run-generator.mjs"
|
|
7
|
+
"generate-plugin": "./run-generator.mjs",
|
|
8
|
+
"generate-platform": "./run-platform-generator.mjs"
|
|
8
9
|
},
|
|
9
10
|
"jest": {
|
|
10
11
|
"testMatch": [
|
|
@@ -18,7 +19,8 @@
|
|
|
18
19
|
"dependencies": {
|
|
19
20
|
"jsonschema2graphql": "1.1.1",
|
|
20
21
|
"sury": "11.0.0-alpha.4",
|
|
21
|
-
"sury-ppx": "11.0.0-alpha.2"
|
|
22
|
+
"sury-ppx": "11.0.0-alpha.2",
|
|
23
|
+
"yaml": "^2.8.3"
|
|
22
24
|
},
|
|
23
25
|
"devDependencies": {
|
|
24
26
|
"rescript": "12.3.0",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
The per-plugin capability manifest — `capabilities.json`, written beside the
|
|
3
|
+
generated `Plugin.res` by the plugin package's build.
|
|
4
|
+
|
|
5
|
+
Each entry states one capability the plugin's fields declare they need, keyed
|
|
6
|
+
by the store's qualified `{plugin}.{store}` identity, with the declaring
|
|
7
|
+
`(component, field)` sites as provenance. The keys are taken verbatim from
|
|
8
|
+
`pluginStructure.requiredStores` — the manifest is a rendering of the
|
|
9
|
+
structure, never a second scan of the sources, so the two cannot spell one
|
|
10
|
+
fact differently.
|
|
11
|
+
|
|
12
|
+
The platform generator unions these files across a deployment's plugins and
|
|
13
|
+
emits the platform's capability list from them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
@schema
|
|
17
|
+
type kind = ObjectStore
|
|
18
|
+
|
|
19
|
+
/** The declaration site: the component's spec name and the field carrying the
|
|
20
|
+
`@storageRef` annotation. */
|
|
21
|
+
@schema
|
|
22
|
+
type provenance = {component: string, field: string}
|
|
23
|
+
|
|
24
|
+
@schema
|
|
25
|
+
type entry = {
|
|
26
|
+
kind: kind,
|
|
27
|
+
key: string,
|
|
28
|
+
declaredBy: array<provenance>,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@schema
|
|
32
|
+
type t = {capabilities: array<entry>}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
Build the manifest from a plugin's structure.
|
|
36
|
+
|
|
37
|
+
Keys iterate `requiredStores` itself, so a manifest's key set is byte-identical
|
|
38
|
+
to what the deployed plugin reports at runtime — the contract the deploy-time
|
|
39
|
+
coverage assertion checks against. A structure with no declarations yields an
|
|
40
|
+
empty `capabilities` list, not an absent file: "declares nothing" is a
|
|
41
|
+
statement, and the generator reading the manifests must be able to tell it
|
|
42
|
+
apart from "was never built".
|
|
43
|
+
*/
|
|
44
|
+
let fromStructure = (structure: Plugin.pluginStructure): t => {
|
|
45
|
+
let declarations = structure.requiredStoreDeclarations->Option.getOr([])
|
|
46
|
+
{
|
|
47
|
+
capabilities: structure.requiredStores
|
|
48
|
+
->Option.getOr([])
|
|
49
|
+
->Array.map(key => {
|
|
50
|
+
kind: ObjectStore,
|
|
51
|
+
key,
|
|
52
|
+
declaredBy: declarations->Array.filterMap(d =>
|
|
53
|
+
d.store == key ? Some({component: d.component, field: d.field}) : None
|
|
54
|
+
),
|
|
55
|
+
}),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Deterministic rendering: 2-space indent, trailing newline. Rebuilding with
|
|
60
|
+
no source change must produce a byte-identical file, so the committed
|
|
61
|
+
manifest never churns. */
|
|
62
|
+
let render = (manifest: t): string =>
|
|
63
|
+
JSON.stringify(manifest->S.reverseConvertToJsonOrThrow(schema), ~space=2) ++ "\n"
|
|
64
|
+
|
|
65
|
+
/** One call for the emit CLI: structure in, `capabilities.json` content out. */
|
|
66
|
+
let renderForStructure = (structure: Plugin.pluginStructure): string =>
|
|
67
|
+
structure->fromStructure->render
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
|
|
7
|
+
let kindSchema = S.literal("ObjectStore");
|
|
8
|
+
|
|
9
|
+
let provenanceSchema = S.schema(s => ({
|
|
10
|
+
component: s.m(S.string),
|
|
11
|
+
field: s.m(S.string)
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
let entrySchema = S.schema(s => ({
|
|
15
|
+
kind: s.m(kindSchema),
|
|
16
|
+
key: s.m(S.string),
|
|
17
|
+
declaredBy: s.m(S.array(provenanceSchema))
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
let schema = S.schema(s => ({
|
|
21
|
+
capabilities: s.m(S.array(entrySchema))
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
function fromStructure(structure) {
|
|
25
|
+
let declarations = Stdlib_Option.getOr(structure.requiredStoreDeclarations, []);
|
|
26
|
+
return {
|
|
27
|
+
capabilities: Stdlib_Option.getOr(structure.requiredStores, []).map(key => ({
|
|
28
|
+
kind: "ObjectStore",
|
|
29
|
+
key: key,
|
|
30
|
+
declaredBy: Stdlib_Array.filterMap(declarations, d => {
|
|
31
|
+
if (d.store === key) {
|
|
32
|
+
return {
|
|
33
|
+
component: d.component,
|
|
34
|
+
field: d.field
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}))
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function render(manifest) {
|
|
43
|
+
return JSON.stringify(S.reverseConvertToJsonOrThrow(manifest, schema), undefined, 2) + "\n";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderForStructure(structure) {
|
|
47
|
+
return render(fromStructure(structure));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
kindSchema,
|
|
52
|
+
provenanceSchema,
|
|
53
|
+
entrySchema,
|
|
54
|
+
schema,
|
|
55
|
+
fromStructure,
|
|
56
|
+
render,
|
|
57
|
+
renderForStructure,
|
|
58
|
+
}
|
|
59
|
+
/* kindSchema Not a pure module */
|
|
@@ -379,6 +379,26 @@ type extensionPointDef = {
|
|
|
379
379
|
// jsonableValidation inside the pluginStructure union variant payload.
|
|
380
380
|
let extensionPointDefArrayOptionSchema = _jsNullable(S.array(extensionPointDefSchema), ())
|
|
381
381
|
|
|
382
|
+
/**
|
|
383
|
+
One field's store requirement, with its provenance.
|
|
384
|
+
|
|
385
|
+
`store` is the same qualified `{plugin}.{store}` string `requiredStores`
|
|
386
|
+
carries; `component` and `field` name the declaration site. The site matters
|
|
387
|
+
because a requirement only ever changes by editing a field — when a rename
|
|
388
|
+
removes a store from the manifest, the diff has to say which field caused it.
|
|
389
|
+
*/
|
|
390
|
+
@schema
|
|
391
|
+
type requiredStoreDeclaration = {
|
|
392
|
+
store: string,
|
|
393
|
+
component: string,
|
|
394
|
+
field: string,
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let requiredStoreDeclarationArrayOptionSchema = _jsNullable(
|
|
398
|
+
S.array(requiredStoreDeclarationSchema),
|
|
399
|
+
(),
|
|
400
|
+
)
|
|
401
|
+
|
|
382
402
|
@schema
|
|
383
403
|
type pluginStructure = {
|
|
384
404
|
readModels: array<queryableDef>,
|
|
@@ -409,6 +429,14 @@ type pluginStructure = {
|
|
|
409
429
|
existed still decode (absent → None, read as []).
|
|
410
430
|
*/
|
|
411
431
|
requiredStores: @s.matches(stringArrayOptionSchema) option<array<string>>,
|
|
432
|
+
/**
|
|
433
|
+
Provenance for `requiredStores`: one entry per declaring `(component, field)`
|
|
434
|
+
site, with `store` matching the qualified key above. `requiredStores` is
|
|
435
|
+
derived from this list, so the two cannot disagree. Optional and js_nullable
|
|
436
|
+
for the same reason as `extensionPoints`.
|
|
437
|
+
*/
|
|
438
|
+
requiredStoreDeclarations: @s.matches(requiredStoreDeclarationArrayOptionSchema)
|
|
439
|
+
option<array<requiredStoreDeclaration>>,
|
|
412
440
|
}
|
|
413
441
|
|
|
414
442
|
let pluginStructureOptionSchema = _jsNullable(pluginStructureSchema, ())
|
|
@@ -178,6 +178,14 @@ let extensionPointDefSchema = S.schema(s => ({
|
|
|
178
178
|
|
|
179
179
|
let extensionPointDefArrayOptionSchema = SuryResMjs.js_nullable(S.array(extensionPointDefSchema));
|
|
180
180
|
|
|
181
|
+
let requiredStoreDeclarationSchema = S.schema(s => ({
|
|
182
|
+
store: s.m(S.string),
|
|
183
|
+
component: s.m(S.string),
|
|
184
|
+
field: s.m(S.string)
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
let requiredStoreDeclarationArrayOptionSchema = SuryResMjs.js_nullable(S.array(requiredStoreDeclarationSchema));
|
|
188
|
+
|
|
181
189
|
let pluginStructureSchema = S.schema(s => ({
|
|
182
190
|
readModels: s.m(S.array(queryableDefSchema)),
|
|
183
191
|
stateViewSlices: s.m(S.array(queryableDefSchema)),
|
|
@@ -188,7 +196,8 @@ let pluginStructureSchema = S.schema(s => ({
|
|
|
188
196
|
inboundTranslationSlices: s.m(S.array(inboundTranslationSliceDefSchema)),
|
|
189
197
|
extensions: s.m(S.array(extensionDefSchema)),
|
|
190
198
|
extensionPoints: s.m(extensionPointDefArrayOptionSchema),
|
|
191
|
-
requiredStores: s.m(stringArrayOptionSchema)
|
|
199
|
+
requiredStores: s.m(stringArrayOptionSchema),
|
|
200
|
+
requiredStoreDeclarations: s.m(requiredStoreDeclarationArrayOptionSchema)
|
|
192
201
|
}));
|
|
193
202
|
|
|
194
203
|
let pluginStructureOptionSchema = SuryResMjs.js_nullable(pluginStructureSchema);
|
|
@@ -244,6 +253,8 @@ export {
|
|
|
244
253
|
extensionDefSchema,
|
|
245
254
|
extensionPointDefSchema,
|
|
246
255
|
extensionPointDefArrayOptionSchema,
|
|
256
|
+
requiredStoreDeclarationSchema,
|
|
257
|
+
requiredStoreDeclarationArrayOptionSchema,
|
|
247
258
|
pluginStructureSchema,
|
|
248
259
|
pluginStructureOptionSchema,
|
|
249
260
|
pluginDefinitionSchema,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Pure rendering for the platform generator: union the plugins' capability
|
|
2
|
+
// manifests and emit the committed `PlatformCapabilities.res`.
|
|
3
|
+
//
|
|
4
|
+
// Only the derived list is generated — the platform root stays hand-written
|
|
5
|
+
// and reads `PlatformCapabilities.capabilities`. That keeps a requirement
|
|
6
|
+
// change small and entirely about capabilities in review, and it keeps the
|
|
7
|
+
// generator out of the business of expressing everything a real root does
|
|
8
|
+
// (bootstrap calls, capability handles, stack references).
|
|
9
|
+
//
|
|
10
|
+
// I/O-free so the exact bytes are testable; `PlatformGenerator` owns the file
|
|
11
|
+
// system.
|
|
12
|
+
|
|
13
|
+
/** One plugin's manifest, tagged with the name `deploy-manifest.yaml` lists it
|
|
14
|
+
under — the name provenance comments cite. */
|
|
15
|
+
type pluginManifest = {
|
|
16
|
+
pluginName: string,
|
|
17
|
+
manifest: CapabilityManifest.t,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One declaring site, keeping which plugin's manifest carried it. */
|
|
21
|
+
type provenance = {
|
|
22
|
+
pluginName: string,
|
|
23
|
+
component: string,
|
|
24
|
+
field: string,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One capability the platform must provision: the qualified `{plugin}.{store}`
|
|
28
|
+
key with every declaring site across every plugin. Identity is the key —
|
|
29
|
+
many fields, in several plugins, legitimately name one store. */
|
|
30
|
+
type unionEntry = {
|
|
31
|
+
kind: CapabilityManifest.kind,
|
|
32
|
+
key: string,
|
|
33
|
+
declaredBy: array<provenance>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Union the manifests: entries with one key collapse to one capability, their
|
|
37
|
+
provenance concatenated in manifest order. Sorted by key so the rendered
|
|
38
|
+
file is deterministic. */
|
|
39
|
+
let union = (manifests: array<pluginManifest>): array<unionEntry> => {
|
|
40
|
+
let byKey: dict<unionEntry> = Dict.make()
|
|
41
|
+
manifests->Array.forEach(({pluginName, manifest}) =>
|
|
42
|
+
manifest.capabilities->Array.forEach(entry => {
|
|
43
|
+
let sites =
|
|
44
|
+
entry.declaredBy->Array.map(site => {
|
|
45
|
+
pluginName,
|
|
46
|
+
component: site.component,
|
|
47
|
+
field: site.field,
|
|
48
|
+
})
|
|
49
|
+
switch byKey->Dict.get(entry.key) {
|
|
50
|
+
| Some(existing) =>
|
|
51
|
+
byKey->Dict.set(entry.key, {...existing, declaredBy: existing.declaredBy->Array.concat(sites)})
|
|
52
|
+
| None => byKey->Dict.set(entry.key, {kind: entry.kind, key: entry.key, declaredBy: sites})
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
byKey
|
|
57
|
+
->Dict.valuesToArray
|
|
58
|
+
->Array.toSorted((a, b) => String.compare(a.key, b.key))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let quote = (s: string): string => s->JSON.Encode.string->JSON.stringify
|
|
62
|
+
|
|
63
|
+
// A key is `{plugin}.{store}` by construction (`Plugin_Structure` qualifies
|
|
64
|
+
// every declaration). The split is at the first dot: the plugin's registered
|
|
65
|
+
// name cannot contain one, the store name could in principle.
|
|
66
|
+
let splitKey = (key: string): option<(string, string)> =>
|
|
67
|
+
key
|
|
68
|
+
->String.indexOfOpt(".")
|
|
69
|
+
->Option.map(i => (
|
|
70
|
+
key->String.slice(~start=0, ~end=i),
|
|
71
|
+
key->String.slice(~start=i + 1),
|
|
72
|
+
))
|
|
73
|
+
|
|
74
|
+
// The provenance comment quotes the annotation as its author wrote it: a store
|
|
75
|
+
// declared by its own plugin is unqualified, a foreign store keeps the
|
|
76
|
+
// qualified form. The manifest carries only the deploy-manifest plugin name
|
|
77
|
+
// (lowercase by convention) while the key carries the registered name, so the
|
|
78
|
+
// comparison is case-insensitive — and it only shapes a comment.
|
|
79
|
+
let annotationStore = (~pluginName, ~key): string =>
|
|
80
|
+
switch splitKey(key) {
|
|
81
|
+
| Some((keyPlugin, store)) if keyPlugin->String.toLowerCase == pluginName->String.toLowerCase => store
|
|
82
|
+
| _ => key
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let renderEntry = (entry: unionEntry): result<array<string>, string> =>
|
|
86
|
+
switch splitKey(entry.key) {
|
|
87
|
+
| None =>
|
|
88
|
+
Error(
|
|
89
|
+
`malformed capability key ${quote(entry.key)} — expected "{plugin}.{store}" (is the plugin's capabilities.json hand-edited?)`,
|
|
90
|
+
)
|
|
91
|
+
| Some((plugin, store)) => {
|
|
92
|
+
let comments =
|
|
93
|
+
entry.declaredBy->Array.map(site =>
|
|
94
|
+
` // ${site.pluginName}: ${site.component}.${site.field} @storageRef(${quote(
|
|
95
|
+
annotationStore(~pluginName=site.pluginName, ~key=entry.key),
|
|
96
|
+
)})`
|
|
97
|
+
)
|
|
98
|
+
let line = switch entry.kind {
|
|
99
|
+
| ObjectStore => ` ObjectStore({plugin: ${quote(plugin)}, store: ${quote(store)}}),`
|
|
100
|
+
}
|
|
101
|
+
Ok(comments->Array.concat([line]))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let header = [
|
|
106
|
+
"// AUTO-GENERATED — do not edit. Run `pnpm run generate:platform` to update.",
|
|
107
|
+
"//",
|
|
108
|
+
"// The platform's capability list, unioned from the committed",
|
|
109
|
+
"// `capabilities.json` manifests of the plugins deploy-manifest.yaml names.",
|
|
110
|
+
"// Each entry keeps its declaring fields as provenance, so when a capability",
|
|
111
|
+
"// disappears from this list, the diff says which field's change removed it.",
|
|
112
|
+
"",
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
/** Render the complete `PlatformCapabilities.res`. Deterministic and
|
|
116
|
+
newline-terminated: regenerating with no manifest change is byte-identical,
|
|
117
|
+
so the committed file never churns. */
|
|
118
|
+
let render = (manifests: array<pluginManifest>): result<string, string> => {
|
|
119
|
+
let entries = union(manifests)
|
|
120
|
+
let rendered = entries->Array.map(renderEntry)
|
|
121
|
+
switch rendered->Array.findMap(r =>
|
|
122
|
+
switch r {
|
|
123
|
+
| Error(e) => Some(e)
|
|
124
|
+
| Ok(_) => None
|
|
125
|
+
}
|
|
126
|
+
) {
|
|
127
|
+
| Some(e) => Error(e)
|
|
128
|
+
| None => {
|
|
129
|
+
let body = if entries->Array.length == 0 {
|
|
130
|
+
["let capabilities: array<ReventlessInfra.Platform.capability> = []"]
|
|
131
|
+
} else {
|
|
132
|
+
["let capabilities: array<ReventlessInfra.Platform.capability> = ["]
|
|
133
|
+
->Array.concat(rendered->Array.flatMap(r => r->Result.getOr([])))
|
|
134
|
+
->Array.concat(["]"])
|
|
135
|
+
}
|
|
136
|
+
Ok(header->Array.concat(body)->Array.join("\n") ++ "\n")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
6
|
+
import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
|
|
7
|
+
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
8
|
+
|
|
9
|
+
function union(manifests) {
|
|
10
|
+
let byKey = {};
|
|
11
|
+
manifests.forEach(param => {
|
|
12
|
+
let pluginName = param.pluginName;
|
|
13
|
+
param.manifest.capabilities.forEach(entry => {
|
|
14
|
+
let sites = entry.declaredBy.map(site => ({
|
|
15
|
+
pluginName: pluginName,
|
|
16
|
+
component: site.component,
|
|
17
|
+
field: site.field
|
|
18
|
+
}));
|
|
19
|
+
let existing = byKey[entry.key];
|
|
20
|
+
if (existing !== undefined) {
|
|
21
|
+
byKey[entry.key] = {
|
|
22
|
+
kind: existing.kind,
|
|
23
|
+
key: existing.key,
|
|
24
|
+
declaredBy: existing.declaredBy.concat(sites)
|
|
25
|
+
};
|
|
26
|
+
} else {
|
|
27
|
+
byKey[entry.key] = {
|
|
28
|
+
kind: entry.kind,
|
|
29
|
+
key: entry.key,
|
|
30
|
+
declaredBy: sites
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
return Object.values(byKey).toSorted((a, b) => Primitive_string.compare(a.key, b.key));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function quote(s) {
|
|
39
|
+
return JSON.stringify(s);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function splitKey(key) {
|
|
43
|
+
return Stdlib_Option.map(Stdlib_String.indexOfOpt(key, "."), i => [
|
|
44
|
+
key.slice(0, i),
|
|
45
|
+
key.slice(i + 1 | 0)
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function annotationStore(pluginName, key) {
|
|
50
|
+
let match = splitKey(key);
|
|
51
|
+
if (match !== undefined && match[0].toLowerCase() === pluginName.toLowerCase()) {
|
|
52
|
+
return match[1];
|
|
53
|
+
} else {
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderEntry(entry) {
|
|
59
|
+
let match = splitKey(entry.key);
|
|
60
|
+
if (match === undefined) {
|
|
61
|
+
return {
|
|
62
|
+
TAG: "Error",
|
|
63
|
+
_0: `malformed capability key ` + JSON.stringify(entry.key) + ` — expected "{plugin}.{store}" (is the plugin's capabilities.json hand-edited?)`
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
let comments = entry.declaredBy.map(site => ` // ` + site.pluginName + `: ` + site.component + `.` + site.field + ` @storageRef(` + JSON.stringify(annotationStore(site.pluginName, entry.key)) + `)`);
|
|
67
|
+
let line = ` ObjectStore({plugin: ` + JSON.stringify(match[0]) + `, store: ` + JSON.stringify(match[1]) + `}),`;
|
|
68
|
+
return {
|
|
69
|
+
TAG: "Ok",
|
|
70
|
+
_0: comments.concat([line])
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let header = [
|
|
75
|
+
"// AUTO-GENERATED — do not edit. Run `pnpm run generate:platform` to update.",
|
|
76
|
+
"//",
|
|
77
|
+
"// The platform's capability list, unioned from the committed",
|
|
78
|
+
"// `capabilities.json` manifests of the plugins deploy-manifest.yaml names.",
|
|
79
|
+
"// Each entry keeps its declaring fields as provenance, so when a capability",
|
|
80
|
+
"// disappears from this list, the diff says which field's change removed it.",
|
|
81
|
+
""
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
function render(manifests) {
|
|
85
|
+
let entries = union(manifests);
|
|
86
|
+
let rendered = entries.map(renderEntry);
|
|
87
|
+
let e = Stdlib_Array.findMap(rendered, r => {
|
|
88
|
+
if (r.TAG === "Ok") {
|
|
89
|
+
return;
|
|
90
|
+
} else {
|
|
91
|
+
return r._0;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
if (e !== undefined) {
|
|
95
|
+
return {
|
|
96
|
+
TAG: "Error",
|
|
97
|
+
_0: e
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
let body = entries.length === 0 ? ["let capabilities: array<ReventlessInfra.Platform.capability> = []"] : ["let capabilities: array<ReventlessInfra.Platform.capability> = ["].concat(rendered.flatMap(r => Stdlib_Result.getOr(r, []))).concat(["]"]);
|
|
101
|
+
return {
|
|
102
|
+
TAG: "Ok",
|
|
103
|
+
_0: header.concat(body).join("\n") + "\n"
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export {
|
|
108
|
+
union,
|
|
109
|
+
quote,
|
|
110
|
+
splitKey,
|
|
111
|
+
annotationStore,
|
|
112
|
+
renderEntry,
|
|
113
|
+
header,
|
|
114
|
+
render,
|
|
115
|
+
}
|
|
116
|
+
/* No side effect */
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Entry point for the platform generator.
|
|
2
|
+
// Usage: generate-platform <deploy-manifest.yaml>
|
|
3
|
+
//
|
|
4
|
+
// Reads the deploy manifest for the plugin list, unions the plugins' committed
|
|
5
|
+
// `capabilities.json` manifests, and writes the platform's committed
|
|
6
|
+
// `PlatformCapabilities.res` (see PlatformCodegen). The platform root stays
|
|
7
|
+
// hand-written and reads `PlatformCapabilities.capabilities`.
|
|
8
|
+
//
|
|
9
|
+
// A deploy manifest enumerates *deployables*, not plugins, and a plugin's
|
|
10
|
+
// manifest can sit in any of three places. `PlatformManifests` owns both facts —
|
|
11
|
+
// its head comment states the resolution order and why each arm exists; add a
|
|
12
|
+
// fourth arm there, not here.
|
|
13
|
+
|
|
14
|
+
@val external processExit: int => unit = "process.exit"
|
|
15
|
+
@module("yaml") external parseYaml: string => JSON.t = "parse"
|
|
16
|
+
|
|
17
|
+
let fail = (message: string) => {
|
|
18
|
+
Console.error("generate-platform: " ++ message)
|
|
19
|
+
processExit(1)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let asObject = (json: JSON.t): option<dict<JSON.t>> => json->JSON.Decode.object
|
|
23
|
+
let stringAt = (obj: dict<JSON.t>, field: string): option<string> =>
|
|
24
|
+
obj->Dict.get(field)->Option.flatMap(JSON.Decode.string)
|
|
25
|
+
|
|
26
|
+
let () = {
|
|
27
|
+
let manifestArg = Generator_Node.argv->Array.get(2)->Option.getOr("")
|
|
28
|
+
if manifestArg == "" {
|
|
29
|
+
Console.error("Usage: generate-platform <deploy-manifest.yaml>")
|
|
30
|
+
processExit(1)
|
|
31
|
+
} else {
|
|
32
|
+
let manifestPath = Generator_Node.resolve([manifestArg])
|
|
33
|
+
if !Generator_Node.existsSync(manifestPath) {
|
|
34
|
+
fail(`${manifestPath} not found`)
|
|
35
|
+
}
|
|
36
|
+
let manifestDir = Generator_Node.dirname(manifestPath)
|
|
37
|
+
let deployManifest = try Generator_Node.readFileSync(manifestPath)->parseYaml catch {
|
|
38
|
+
| _ => {
|
|
39
|
+
fail(`could not parse ${manifestPath} as YAML`)
|
|
40
|
+
JSON.Null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let platformPath =
|
|
45
|
+
deployManifest
|
|
46
|
+
->asObject
|
|
47
|
+
->Option.flatMap(m => m->Dict.get("platform"))
|
|
48
|
+
->Option.flatMap(asObject)
|
|
49
|
+
->Option.flatMap(p => p->stringAt("path"))
|
|
50
|
+
let plugins =
|
|
51
|
+
deployManifest
|
|
52
|
+
->asObject
|
|
53
|
+
->Option.flatMap(m => m->Dict.get("plugins"))
|
|
54
|
+
->Option.flatMap(JSON.Decode.array)
|
|
55
|
+
->Option.map(entries =>
|
|
56
|
+
entries->Array.filterMap(entry => {
|
|
57
|
+
let obj = entry->asObject
|
|
58
|
+
switch (
|
|
59
|
+
obj->Option.flatMap(o => o->stringAt("name")),
|
|
60
|
+
obj->Option.flatMap(o => o->stringAt("path")),
|
|
61
|
+
) {
|
|
62
|
+
| (Some(name), Some(path)) => Some((name, path))
|
|
63
|
+
| _ => None
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
switch (platformPath, plugins) {
|
|
69
|
+
| (Some(platformPath), Some(plugins)) => {
|
|
70
|
+
let pluginManifests = plugins->Array.flatMap(((pluginName, pluginPath)) => {
|
|
71
|
+
let pluginDir = Generator_Node.resolve([manifestDir, pluginPath])
|
|
72
|
+
if !Generator_Node.existsSync(pluginDir) {
|
|
73
|
+
fail(`plugin "${pluginName}" points at ${pluginDir}, which does not exist`)
|
|
74
|
+
}
|
|
75
|
+
switch PlatformManifests.resolve(~pluginDir) {
|
|
76
|
+
// A stack whose package graph holds no plugin composition — an
|
|
77
|
+
// SdkService, an SPA, a standalone Lambda — has no capabilities to
|
|
78
|
+
// contribute, which is not an error. Said out loud so a plugin
|
|
79
|
+
// skipped by mistake is visible in the generator's output.
|
|
80
|
+
| NotAPlugin => {
|
|
81
|
+
Console.log(`Skipped: ${pluginName} (${pluginDir}) — no plugin composition`)
|
|
82
|
+
[]
|
|
83
|
+
}
|
|
84
|
+
| Unbuilt({evidence, expected}) => {
|
|
85
|
+
fail(
|
|
86
|
+
`no capabilities.json found for plugin "${pluginName}" (${pluginDir}) — ` ++
|
|
87
|
+
`build the plugin first; its build emits src/capabilities.json beside Plugin.res ` ++
|
|
88
|
+
`(${evidence}; expected ${expected})`,
|
|
89
|
+
)
|
|
90
|
+
[]
|
|
91
|
+
}
|
|
92
|
+
| Manifests(manifests) =>
|
|
93
|
+
manifests->Array.map(({path, via}) => {
|
|
94
|
+
Console.log(
|
|
95
|
+
`Read: ${pluginName} — ${path} (via ${PlatformManifests.describeVia(via)})`,
|
|
96
|
+
)
|
|
97
|
+
let manifest = try Generator_Node.readFileSync(path)
|
|
98
|
+
->JSON.parseOrThrow
|
|
99
|
+
->S.parseOrThrow(CapabilityManifest.schema) catch {
|
|
100
|
+
| _ => {
|
|
101
|
+
fail(`could not parse ${path} as a capability manifest`)
|
|
102
|
+
({capabilities: []}: CapabilityManifest.t)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
({pluginName, manifest}: PlatformCodegen.pluginManifest)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
switch PlatformCodegen.render(pluginManifests) {
|
|
111
|
+
| Error(message) => fail(message)
|
|
112
|
+
| Ok(source) => {
|
|
113
|
+
let outputPath = Generator_Node.resolve([
|
|
114
|
+
manifestDir,
|
|
115
|
+
platformPath,
|
|
116
|
+
"src",
|
|
117
|
+
"PlatformCapabilities.res",
|
|
118
|
+
])
|
|
119
|
+
Generator_Node.writeFileSync(outputPath, source)
|
|
120
|
+
Console.log("Generated: " ++ outputPath)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
| _ => fail(`${manifestPath} needs a \`platform.path\` and a \`plugins\` list with name + path`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|