@reventlessdev/reventless-core 3.0.0-alpha.200 → 3.0.0-alpha.201
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 +8 -0
- package/package.json +7 -7
- package/src/adapter/Runtime/Runtime.res +9 -1
- package/src/adapter/Runtime/Runtime.res.mjs +1 -2
- package/src/admin/Platform_Admin.res +1 -1
- package/src/admin/Platform_Admin.res.mjs +1 -0
- package/src/admin/Platform_AdminApi.res +1 -1
- package/src/components/Api/StorageRefFields.res +124 -0
- package/src/components/Api/StorageRefFields.res.mjs +88 -0
- package/src/plugin/component/Plugin_Builder.res +1 -1
- package/src/plugin/component/Plugin_Builder.res.mjs +1 -0
- package/src/plugin/component/Plugin_Helpers.res +5 -0
- package/src/plugin/component/Plugin_Structure.res +5 -1
- package/src/plugin/component/Plugin_Structure.res.mjs +2 -1
- package/tests/api/StorageRefFieldsTest.res +148 -0
- package/tests/api/StorageRefFieldsTest.res.mjs +152 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
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.201 (2026-08-02)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **aws,core,doc:** environment-tiered CloudWatch log retention and levels ([8032023](https://github.com/ReventlessDev/reventless-core/commit/803202305a7d352484b8df5e58df03c92ca58b5f))
|
|
11
|
+
* **aws,core,spec,seed-aws:** expire uploads nobody committed a reference to ([f63e84c](https://github.com/ReventlessDev/reventless-core/commit/f63e84c1a11cc350b799a6f69a2e7427cf1ea6e9))
|
|
12
|
+
|
|
13
|
+
|
|
6
14
|
# 3.0.0-alpha.200 (2026-08-01)
|
|
7
15
|
|
|
8
16
|
* feat(aws,core,spec)!: qualify store prefixes by plugin and refuse name collisions ([da39405](https://github.com/ReventlessDev/reventless-core/commit/da394059d9f8f981bf7adc79e2c1ce2b429e0267))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.201",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -28,22 +28,22 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"sury": "11.0.0-alpha.4",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
|
+
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.0",
|
|
31
32
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
32
33
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
33
34
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
34
|
-
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
35
35
|
"@reventlessdev/rescript-node": "2.0.0-alpha.0",
|
|
36
|
-
"@reventlessdev/rescript-
|
|
36
|
+
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.0",
|
|
38
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
38
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.118",
|
|
39
39
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
40
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.30",
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.93"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
45
45
|
"sury-ppx": "11.0.0-alpha.2",
|
|
46
|
-
"@reventlessdev/reventless-ppx": "1.0.0-alpha.
|
|
46
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.60"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"rescript": "12.3.0"
|
|
@@ -200,6 +200,11 @@ type commandHandlerConfig = {
|
|
|
200
200
|
sqsBatchSize?: int,
|
|
201
201
|
ephemeralStorageMb?: int,
|
|
202
202
|
logRetentionDays?: int,
|
|
203
|
+
// Minimum log level for this handler ("silent" | "error" | "warn" | "info" |
|
|
204
|
+
// "debug"), mapped onto the `LOG_LEVEL` env var the logger reads. Overrides the
|
|
205
|
+
// per-environment tier default. Transport-neutral: the in-memory platform reads
|
|
206
|
+
// it the same way via the env var.
|
|
207
|
+
logLevel?: string,
|
|
203
208
|
envVars?: dict<string>,
|
|
204
209
|
}
|
|
205
210
|
|
|
@@ -220,5 +225,8 @@ module CommandHandlerDefaults = {
|
|
|
220
225
|
let timeout = 30
|
|
221
226
|
let sqsBatchSize = 10
|
|
222
227
|
let ephemeralStorageMb = 512
|
|
223
|
-
|
|
228
|
+
// No `logRetentionDays` / `logLevel` default here: both are resolved per
|
|
229
|
+
// environment tier by the AWS platform (`Util_LogRetention`), not by a flat
|
|
230
|
+
// framework constant. An unset field means "follow the stack's tier", which a
|
|
231
|
+
// single number could not express.
|
|
224
232
|
}
|
|
@@ -309,7 +309,7 @@ module Make = (
|
|
|
309
309
|
})
|
|
310
310
|
let eventLogEntries = Array.concat(eventLogEntriesFromAggregates, dcbResult.eventLogEntries)
|
|
311
311
|
Config.hooks.subscriptionInfraHook->Option.forEach(hook =>
|
|
312
|
-
hook({allQueryDbs, allEventTopics, eventLogEntries, opts})
|
|
312
|
+
hook({pluginName: name, allQueryDbs, allEventTopics, eventLogEntries, opts})
|
|
313
313
|
)
|
|
314
314
|
|
|
315
315
|
let queryEngine = QueryEngineAdapter.make(allQueryDbs)
|
|
@@ -150,6 +150,7 @@ function Make(RuntimeEnvironment) {
|
|
|
150
150
|
}));
|
|
151
151
|
let eventLogEntries = eventLogEntriesFromAggregates.concat(dcbResult.eventLogEntries);
|
|
152
152
|
Stdlib_Option.forEach(Config.hooks.subscriptionInfraHook, hook => hook({
|
|
153
|
+
pluginName: Platform_Admin_Structure$ReventlessCore.pluginId,
|
|
153
154
|
allQueryDbs: allQueryDbs,
|
|
154
155
|
allEventTopics: aggregateEventTopics,
|
|
155
156
|
eventLogEntries: eventLogEntries,
|
|
@@ -89,7 +89,7 @@ let pluginStatusSubscriptionSource: GraphQL_Stitcher.subscriptionSource = {
|
|
|
89
89
|
// (`injectAwsAuthAll(~group="Admin")`), and uploads are a regular authenticated-user
|
|
90
90
|
// operation. They are added instead to the **domain** API's platform-owned base
|
|
91
91
|
// (`domainBaseFragment` in the AWS/local Platform), which takes the default
|
|
92
|
-
// `AllowAuthenticated` auth. See [docs/plans/upload-release-path.md] § "Which API".
|
|
92
|
+
// `AllowAuthenticated` auth. See [docs/plans/done/upload-release-path.md] § "Which API".
|
|
93
93
|
let uploadTypes = [
|
|
94
94
|
`type Upload_Ticket {\n uploadUrl: String!\n storageRef: String!\n}`,
|
|
95
95
|
`type Upload_ReleaseResult {\n released: Boolean!\n reason: String\n}`,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Which fields of which committed events can hold a storage ref — read off the
|
|
2
|
+
// declarations rather than hand-listed.
|
|
3
|
+
//
|
|
4
|
+
// This is the deploy-time half of the claim mechanism (see
|
|
5
|
+
// [docs/plans/done/upload-pending-claim-and-expiry.md]). An uploaded object is minted
|
|
6
|
+
// carrying a "pending" tag and expires while it still has one, so something has
|
|
7
|
+
// to notice when an event finally references it. That something needs to know
|
|
8
|
+
// three things, and a `@storageRef` declaration already says all three:
|
|
9
|
+
//
|
|
10
|
+
// which events to observe only variants with at least one declared field
|
|
11
|
+
// where the refs sit the field name, and whether it holds one or many
|
|
12
|
+
// which store they're in the target, qualified to `{plugin}.{store}`
|
|
13
|
+
//
|
|
14
|
+
// Deriving it here rather than in the claimer means the runtime carries a flat
|
|
15
|
+
// lookup table and no schema machinery: the Lambda reads `{eventType → fields}`
|
|
16
|
+
// and never sees a sury schema. It also means an annotation added, moved or
|
|
17
|
+
// deleted changes what gets claimed on the next deploy, with nothing to keep in
|
|
18
|
+
// sync by hand.
|
|
19
|
+
//
|
|
20
|
+
// Read the same way `Plugin_Structure` reads the same declarations — top-level
|
|
21
|
+
// properties of each variant, via `StorageRef.getFieldStore`. A store nested
|
|
22
|
+
// inside an object is not provisioned there and so is not claimed here: one
|
|
23
|
+
// reading, consistently narrow, rather than two that could disagree about which
|
|
24
|
+
// fields exist.
|
|
25
|
+
|
|
26
|
+
/** One declared ref-bearing field of one event variant. */
|
|
27
|
+
type refField = {
|
|
28
|
+
/** The field's name on the event's payload (`data` on the wire). */
|
|
29
|
+
field: string,
|
|
30
|
+
/** Whether the field holds one ref or an array of them. */
|
|
31
|
+
arity: Reventless.StorageRef.arity,
|
|
32
|
+
/** The store the refs live in, qualified `{plugin}.{store}` — already
|
|
33
|
+
resolved against the declaring plugin, so a reader never has to know
|
|
34
|
+
whose plugin it came from. */
|
|
35
|
+
store: string,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The ref-bearing fields of one event variant. Only variants with at least
|
|
39
|
+
one are produced, so an empty result means "this event log carries no
|
|
40
|
+
storage refs" and the caller can skip it entirely. */
|
|
41
|
+
type eventRefFields = {
|
|
42
|
+
eventType: string,
|
|
43
|
+
fields: array<refField>,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A variant is `{TAG: "<Name>", …fields}` (payload-less variants are a bare
|
|
47
|
+
// string const and can hold nothing, so they drop out here).
|
|
48
|
+
let fromVariant = (~plugin: string, variant: S.t<unknown>): option<eventRefFields> =>
|
|
49
|
+
switch variant {
|
|
50
|
+
| S.Object({properties}) =>
|
|
51
|
+
switch properties->Dict.get("TAG") {
|
|
52
|
+
| Some(String({const: ?Some(eventType)})) =>
|
|
53
|
+
let fields =
|
|
54
|
+
properties
|
|
55
|
+
->Dict.toArray
|
|
56
|
+
->Array.filterMap(((field, fieldSchema)) =>
|
|
57
|
+
Reventless.StorageRef.getFieldStore(fieldSchema)->Option.map(((target, arity)) => {
|
|
58
|
+
field,
|
|
59
|
+
arity,
|
|
60
|
+
// An unqualified store belongs to the declaring plugin — the same
|
|
61
|
+
// resolution `Plugin_Structure` makes, so the key the claimer looks
|
|
62
|
+
// up is the key the deploy provisioned.
|
|
63
|
+
store: target.plugin->Option.getOr(plugin) ++ "." ++ target.store,
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
->Array.toSorted((a, b) => String.compare(a.field, b.field))
|
|
67
|
+
fields->Array.length == 0 ? None : Some({eventType, fields})
|
|
68
|
+
| _ => None
|
|
69
|
+
}
|
|
70
|
+
| _ => None
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
The ref-bearing fields of every variant in an event log's event schema.
|
|
75
|
+
|
|
76
|
+
`~plugin` is the registered name of the plugin the event log belongs to, used
|
|
77
|
+
to qualify a store the annotation left unqualified.
|
|
78
|
+
*/
|
|
79
|
+
let fromEventSchema = (~plugin: string, eventSchema: S.t<unknown>): array<eventRefFields> =>
|
|
80
|
+
switch eventSchema {
|
|
81
|
+
| Union({anyOf}) => anyOf->Array.filterMap(v => fromVariant(~plugin, v))
|
|
82
|
+
| other => fromVariant(~plugin, other)->Option.mapOr([], e => [e])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
The wire form the claimer reads: `{"<eventType>": [{"field", "arity", "store"}]}`.
|
|
87
|
+
|
|
88
|
+
A flat JSON object rather than a schema, because it crosses into a Lambda's
|
|
89
|
+
environment: the runtime side does a dictionary lookup and a field read, and
|
|
90
|
+
carries no sury dependency to do it.
|
|
91
|
+
*/
|
|
92
|
+
let toJson = (entries: array<eventRefFields>): JSON.t =>
|
|
93
|
+
entries
|
|
94
|
+
->Array.map(({eventType, fields}) => (
|
|
95
|
+
eventType,
|
|
96
|
+
fields
|
|
97
|
+
->Array.map(({field, arity, store}) =>
|
|
98
|
+
Dict.fromArray([
|
|
99
|
+
("field", JSON.Encode.string(field)),
|
|
100
|
+
(
|
|
101
|
+
"arity",
|
|
102
|
+
JSON.Encode.string(
|
|
103
|
+
switch arity {
|
|
104
|
+
| Single => "one"
|
|
105
|
+
| Multiple => "many"
|
|
106
|
+
},
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
("store", JSON.Encode.string(store)),
|
|
110
|
+
])->JSON.Encode.object
|
|
111
|
+
)
|
|
112
|
+
->JSON.Encode.array,
|
|
113
|
+
))
|
|
114
|
+
->Dict.fromArray
|
|
115
|
+
->JSON.Encode.object
|
|
116
|
+
|
|
117
|
+
/** The distinct stores an event log's declarations point at. What the claimer's
|
|
118
|
+
IAM has to cover for that log, and what a coverage check compares against
|
|
119
|
+
the set of stores the deploy actually provisioned. */
|
|
120
|
+
let storesOf = (entries: array<eventRefFields>): array<string> =>
|
|
121
|
+
entries
|
|
122
|
+
->Array.flatMap(({fields}) => fields->Array.map(({store}) => store))
|
|
123
|
+
->Belt.Set.String.fromArray
|
|
124
|
+
->Belt.Set.String.toArray
|
|
@@ -0,0 +1,88 @@
|
|
|
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 Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
|
|
6
|
+
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
7
|
+
import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
|
|
8
|
+
|
|
9
|
+
function fromVariant(plugin, variant) {
|
|
10
|
+
if (variant.type !== "object") {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
let properties = variant.properties;
|
|
14
|
+
let match = properties["TAG"];
|
|
15
|
+
if (match === undefined) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (match.type !== "string") {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
let eventType = match.const;
|
|
22
|
+
if (eventType === undefined) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
let fields = Stdlib_Array.filterMap(Object.entries(properties), param => {
|
|
26
|
+
let field = param[0];
|
|
27
|
+
return Stdlib_Option.map(StorageRef$Reventless.getFieldStore(param[1]), param => {
|
|
28
|
+
let target = param[0];
|
|
29
|
+
return {
|
|
30
|
+
field: field,
|
|
31
|
+
arity: param[1],
|
|
32
|
+
store: Stdlib_Option.getOr(target.plugin, plugin) + "." + target.store
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}).toSorted((a, b) => Primitive_string.compare(a.field, b.field));
|
|
36
|
+
if (fields.length === 0) {
|
|
37
|
+
return;
|
|
38
|
+
} else {
|
|
39
|
+
return {
|
|
40
|
+
eventType: eventType,
|
|
41
|
+
fields: fields
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fromEventSchema(plugin, eventSchema) {
|
|
47
|
+
if (eventSchema.type === "union") {
|
|
48
|
+
return Stdlib_Array.filterMap(eventSchema.anyOf, v => fromVariant(plugin, v));
|
|
49
|
+
} else {
|
|
50
|
+
return Stdlib_Option.mapOr(fromVariant(plugin, eventSchema), [], e => [e]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toJson(entries) {
|
|
55
|
+
return Object.fromEntries(entries.map(param => [
|
|
56
|
+
param.eventType,
|
|
57
|
+
param.fields.map(param => {
|
|
58
|
+
let tmp;
|
|
59
|
+
tmp = param.arity === "Single" ? "one" : "many";
|
|
60
|
+
return Object.fromEntries([
|
|
61
|
+
[
|
|
62
|
+
"field",
|
|
63
|
+
param.field
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
"arity",
|
|
67
|
+
tmp
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
"store",
|
|
71
|
+
param.store
|
|
72
|
+
]
|
|
73
|
+
]);
|
|
74
|
+
})
|
|
75
|
+
]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function storesOf(entries) {
|
|
79
|
+
return Belt_SetString.toArray(Belt_SetString.fromArray(entries.flatMap(param => param.fields.map(param => param.store))));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
fromVariant,
|
|
84
|
+
fromEventSchema,
|
|
85
|
+
toJson,
|
|
86
|
+
storesOf,
|
|
87
|
+
}
|
|
88
|
+
/* StorageRef-Reventless Not a pure module */
|
|
@@ -888,7 +888,7 @@ module Make = (
|
|
|
888
888
|
// Gives the AWS platform access to allQueryDbs, allEventTopics, and eventLogEntries
|
|
889
889
|
// without coupling reventless-core to reventless-aws.
|
|
890
890
|
Spec.hooks.subscriptionInfraHook->Option.forEach(hook =>
|
|
891
|
-
hook({allQueryDbs, allEventTopics, eventLogEntries, opts})
|
|
891
|
+
hook({pluginName: name, allQueryDbs, allEventTopics, eventLogEntries, opts})
|
|
892
892
|
)
|
|
893
893
|
|
|
894
894
|
module SpecificHeartbeat = Heartbeat_Builder.Make(HeartbeatRunner)
|
|
@@ -547,6 +547,7 @@ function Make(Spec) {
|
|
|
547
547
|
let tasksOutputs = Plugin_Helpers$ReventlessCore.createTasks(tasks, aggregatesOutputs, scheduler, schedulerRoleUrn, publishToAggregates, queryEngine, Spec.resourceNaming, componentRuntime, opts);
|
|
548
548
|
let resolvers = Plugin_Helpers$ReventlessCore.createResolvers(allQueryDbs);
|
|
549
549
|
Stdlib_Option.forEach(Spec.hooks.subscriptionInfraHook, hook => hook({
|
|
550
|
+
pluginName: extra$1,
|
|
550
551
|
allQueryDbs: allQueryDbs,
|
|
551
552
|
allEventTopics: aggregateEventTopics,
|
|
552
553
|
eventLogEntries: eventLogEntries,
|
|
@@ -911,6 +911,11 @@ type eventQueryRegistrationParams = {
|
|
|
911
911
|
// are assembled inside builderOutputs. Lets the AWS platform wire StateTopic and
|
|
912
912
|
// EventLogSubscription Lambdas without touching reventless-core.
|
|
913
913
|
type subscriptionInfraParams = {
|
|
914
|
+
/** The plugin's registered name. Carried because infrastructure wired from
|
|
915
|
+
here can be per plugin rather than per component — and because a
|
|
916
|
+
declaration that names a store without qualifying it means "this plugin's
|
|
917
|
+
own", which is unreadable without knowing whose plugin it is. */
|
|
918
|
+
pluginName: string,
|
|
914
919
|
allQueryDbs: ReventlessInfra.QueryDb.allOutputs,
|
|
915
920
|
allEventTopics: dict<ReventlessInfra.EventTopic.outputs>,
|
|
916
921
|
eventLogEntries: array<ReventlessInfra.Api.eventLogSchemaEntry>,
|
|
@@ -503,7 +503,11 @@ let make = (
|
|
|
503
503
|
properties
|
|
504
504
|
->Dict.toArray
|
|
505
505
|
->Array.filterMap(((field, fieldSchema)) =>
|
|
506
|
-
|
|
506
|
+
// `getFieldStore`, not `getStore`: a `@storageRef("s") urls: array<string>`
|
|
507
|
+
// field carries the marker on its *element*, so reading the field schema
|
|
508
|
+
// directly answered `None` and the store went unprovisioned — the same
|
|
509
|
+
// silence as never having written the annotation.
|
|
510
|
+
Reventless.StorageRef.getFieldStore(fieldSchema)->Option.map(((target, _arity)) => {
|
|
507
511
|
Reventless.Plugin.store: target.plugin->Option.getOr(name) ++ "." ++ target.store,
|
|
508
512
|
component,
|
|
509
513
|
field,
|
|
@@ -446,7 +446,8 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
|
|
|
446
446
|
let properties = v.properties;
|
|
447
447
|
return Stdlib_Array.filterMap(Object.entries(properties), param => {
|
|
448
448
|
let field = param[0];
|
|
449
|
-
return Stdlib_Option.map(StorageRef$Reventless.
|
|
449
|
+
return Stdlib_Option.map(StorageRef$Reventless.getFieldStore(param[1]), param => {
|
|
450
|
+
let target = param[0];
|
|
450
451
|
let plugin = target.plugin;
|
|
451
452
|
return {
|
|
452
453
|
store: Stdlib_Option.getOr(target.plugin, name) + "." + target.store,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// What the claim component ever gets to see, decided here.
|
|
2
|
+
//
|
|
3
|
+
// `StorageRefFields.fromEventSchema` is the deploy-time reading of the
|
|
4
|
+
// `@storageRef` declarations that becomes the claimer's entire input: an event
|
|
5
|
+
// variant it does not produce is an event the claimer never reads a record for,
|
|
6
|
+
// and a field it does not list is a ref that never gets claimed — so the object
|
|
7
|
+
// keeps its pending tag and, on a store with expiry enabled, is eventually
|
|
8
|
+
// deleted while something still references it.
|
|
9
|
+
//
|
|
10
|
+
// Which makes the interesting assertions the negative ones: what is *absent*
|
|
11
|
+
// from this output is what silently rots.
|
|
12
|
+
|
|
13
|
+
open JestGlobals
|
|
14
|
+
|
|
15
|
+
module Fields = StorageRefFields
|
|
16
|
+
|
|
17
|
+
// An event union, shaped the way sury encodes a ReScript variant: one object per
|
|
18
|
+
// variant, discriminated by a `TAG` string constant.
|
|
19
|
+
let variant = (~tag: string, fields: array<(string, S.t<unknown>)>): S.t<unknown> =>
|
|
20
|
+
S.object(s => {
|
|
21
|
+
let _ = s.field("TAG", S.literal(tag)->S.castToUnknown)
|
|
22
|
+
fields->Array.forEach(((name, schema)) => {
|
|
23
|
+
let _ = s.field(name, schema)
|
|
24
|
+
})
|
|
25
|
+
()
|
|
26
|
+
})->S.castToUnknown
|
|
27
|
+
|
|
28
|
+
let ref_ = (~plugin: option<string>=?, ~store: string) =>
|
|
29
|
+
Reventless.StorageRef.forStore(~plugin?, ~store)->S.castToUnknown
|
|
30
|
+
|
|
31
|
+
describe("StorageRefFields.fromEventSchema", () => {
|
|
32
|
+
testSync("finds a declared single-valued ref field and qualifies its store", () => {
|
|
33
|
+
let schema = S.union([variant(~tag="ProductImageChanged", [("imageUrl", ref_(~store="productImages"))])])->S.castToUnknown
|
|
34
|
+
expect(Fields.fromEventSchema(~plugin="Catalog", schema))->toEqual([
|
|
35
|
+
{
|
|
36
|
+
Fields.eventType: "ProductImageChanged",
|
|
37
|
+
fields: [
|
|
38
|
+
{Fields.field: "imageUrl", arity: Single, store: "Catalog.productImages"},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// The annotation's own qualification wins: a field pointing at another
|
|
45
|
+
// plugin's store must not be re-attributed to the plugin that wrote it, or the
|
|
46
|
+
// claimer resolves the wrong bucket and refuses the ref as out-of-store.
|
|
47
|
+
testSync("a qualified declaration keeps the store it names", () => {
|
|
48
|
+
let schema =
|
|
49
|
+
S.union([
|
|
50
|
+
variant(~tag="OrderPhotoAttached", [("photo", ref_(~plugin="Catalog", ~store="productImages"))]),
|
|
51
|
+
])->S.castToUnknown
|
|
52
|
+
expect(
|
|
53
|
+
Fields.fromEventSchema(~plugin="Ordering", schema)
|
|
54
|
+
->Array.flatMap(e => e.fields->Array.map(f => f.store)),
|
|
55
|
+
)->toEqual(["Catalog.productImages"])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// `@storageRef` on an `array<string>` attaches the marker to the ELEMENT, so
|
|
59
|
+
// reading the field schema directly answers None. The claimer needs the arity
|
|
60
|
+
// to know whether to read one value or iterate.
|
|
61
|
+
testSync("finds a multi-valued ref field and records its arity", () => {
|
|
62
|
+
let schema =
|
|
63
|
+
S.union([
|
|
64
|
+
variant(~tag="GalleryReplaced", [("imageUrls", S.array(ref_(~store="productImages"))->S.castToUnknown)]),
|
|
65
|
+
])->S.castToUnknown
|
|
66
|
+
expect(
|
|
67
|
+
Fields.fromEventSchema(~plugin="Catalog", schema)->Array.flatMap(e => e.fields),
|
|
68
|
+
)->toEqual([{Fields.field: "imageUrls", arity: Multiple, store: "Catalog.productImages"}])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Narrow input by construction: a platform whose plugins declare no store
|
|
72
|
+
// provisions no claimer at all, and an event log carrying no declaration is
|
|
73
|
+
// never subscribed to.
|
|
74
|
+
testSync("a variant with no declared field is omitted entirely", () => {
|
|
75
|
+
let schema =
|
|
76
|
+
S.union([
|
|
77
|
+
variant(~tag="ProductRenamed", [("name", S.string->S.castToUnknown)]),
|
|
78
|
+
variant(~tag="ProductImageChanged", [("imageUrl", ref_(~store="productImages"))]),
|
|
79
|
+
])->S.castToUnknown
|
|
80
|
+
expect(Fields.fromEventSchema(~plugin="Catalog", schema)->Array.map(e => e.eventType))->toEqual([
|
|
81
|
+
"ProductImageChanged",
|
|
82
|
+
])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
testSync("an event schema with no declaration anywhere yields nothing", () => {
|
|
86
|
+
let schema = S.union([variant(~tag="ProductRenamed", [("name", S.string->S.castToUnknown)])])->S.castToUnknown
|
|
87
|
+
expect(Fields.fromEventSchema(~plugin="Catalog", schema))->toEqual([])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// A string field that merely looks like a ref is not one. Declaration
|
|
91
|
+
// outranks inference everywhere else in the framework, and this is the reading
|
|
92
|
+
// that has to agree with `Plugin_Structure`'s — the claimer can only reach a
|
|
93
|
+
// store the deploy actually provisioned.
|
|
94
|
+
testSync("an unannotated string field is not treated as a ref", () => {
|
|
95
|
+
let schema =
|
|
96
|
+
S.union([variant(~tag="ProductImported", [("imageUrl", S.string->S.castToUnknown)])])->S.castToUnknown
|
|
97
|
+
expect(Fields.fromEventSchema(~plugin="Catalog", schema))->toEqual([])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
testSync("a single-variant (non-union) event schema is read the same way", () => {
|
|
101
|
+
let schema = variant(~tag="ProductImageChanged", [("imageUrl", ref_(~store="productImages"))])
|
|
102
|
+
expect(Fields.fromEventSchema(~plugin="Catalog", schema)->Array.map(e => e.eventType))->toEqual([
|
|
103
|
+
"ProductImageChanged",
|
|
104
|
+
])
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe("StorageRefFields.toJson", () => {
|
|
109
|
+
// The wire form crosses into a Lambda's environment, where the handler decodes
|
|
110
|
+
// it with plain JSON reads. Both ends are pinned here because a drift is
|
|
111
|
+
// silent: an unreadable entry is indistinguishable from an absent one, and an
|
|
112
|
+
// absent one means "nothing to claim".
|
|
113
|
+
testSync("emits field, arity and store per event type", () => {
|
|
114
|
+
let entries: array<Fields.eventRefFields> = [
|
|
115
|
+
{
|
|
116
|
+
eventType: "ProductImageChanged",
|
|
117
|
+
fields: [{field: "imageUrl", arity: Single, store: "Catalog.productImages"}],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
eventType: "GalleryReplaced",
|
|
121
|
+
fields: [{field: "imageUrls", arity: Multiple, store: "Catalog.productImages"}],
|
|
122
|
+
},
|
|
123
|
+
]
|
|
124
|
+
expect(Fields.toJson(entries)->JSON.stringify)->toBe(
|
|
125
|
+
`{"ProductImageChanged":[{"field":"imageUrl","arity":"one","store":"Catalog.productImages"}],` ++
|
|
126
|
+
`"GalleryReplaced":[{"field":"imageUrls","arity":"many","store":"Catalog.productImages"}]}`,
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe("StorageRefFields.storesOf", () => {
|
|
132
|
+
testSync("collects the distinct stores an event log's declarations reach", () => {
|
|
133
|
+
let entries: array<Fields.eventRefFields> = [
|
|
134
|
+
{
|
|
135
|
+
eventType: "A",
|
|
136
|
+
fields: [{field: "a", arity: Single, store: "Catalog.productImages"}],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
eventType: "B",
|
|
140
|
+
fields: [
|
|
141
|
+
{field: "b", arity: Single, store: "Catalog.productImages"},
|
|
142
|
+
{field: "c", arity: Multiple, store: "Ordering.receipts"},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
]
|
|
146
|
+
expect(Fields.storesOf(entries))->toEqual(["Catalog.productImages", "Ordering.receipts"])
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
|
|
5
|
+
import * as StorageRefFields$ReventlessCore from "../../src/components/Api/StorageRefFields.res.mjs";
|
|
6
|
+
|
|
7
|
+
function variant(tag, fields) {
|
|
8
|
+
return S.object(s => {
|
|
9
|
+
s.f("TAG", S.literal(tag));
|
|
10
|
+
fields.forEach(param => {
|
|
11
|
+
s.f(param[0], param[1]);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let ref_ = StorageRef$Reventless.forStore;
|
|
17
|
+
|
|
18
|
+
globalThis.describe("StorageRefFields.fromEventSchema", () => {
|
|
19
|
+
globalThis.test("finds a declared single-valued ref field and qualifies its store", () => {
|
|
20
|
+
let schema = S.union([variant("ProductImageChanged", [[
|
|
21
|
+
"imageUrl",
|
|
22
|
+
StorageRef$Reventless.forStore(undefined, "productImages")
|
|
23
|
+
]])]);
|
|
24
|
+
globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema)).toEqual([{
|
|
25
|
+
eventType: "ProductImageChanged",
|
|
26
|
+
fields: [{
|
|
27
|
+
field: "imageUrl",
|
|
28
|
+
arity: "Single",
|
|
29
|
+
store: "Catalog.productImages"
|
|
30
|
+
}]
|
|
31
|
+
}]);
|
|
32
|
+
});
|
|
33
|
+
globalThis.test("a qualified declaration keeps the store it names", () => {
|
|
34
|
+
let schema = S.union([variant("OrderPhotoAttached", [[
|
|
35
|
+
"photo",
|
|
36
|
+
StorageRef$Reventless.forStore("Catalog", "productImages")
|
|
37
|
+
]])]);
|
|
38
|
+
globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Ordering", schema).flatMap(e => e.fields.map(f => f.store))).toEqual(["Catalog.productImages"]);
|
|
39
|
+
});
|
|
40
|
+
globalThis.test("finds a multi-valued ref field and records its arity", () => {
|
|
41
|
+
let schema = S.union([variant("GalleryReplaced", [[
|
|
42
|
+
"imageUrls",
|
|
43
|
+
S.array(StorageRef$Reventless.forStore(undefined, "productImages"))
|
|
44
|
+
]])]);
|
|
45
|
+
globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema).flatMap(e => e.fields)).toEqual([{
|
|
46
|
+
field: "imageUrls",
|
|
47
|
+
arity: "Multiple",
|
|
48
|
+
store: "Catalog.productImages"
|
|
49
|
+
}]);
|
|
50
|
+
});
|
|
51
|
+
globalThis.test("a variant with no declared field is omitted entirely", () => {
|
|
52
|
+
let schema = S.union([
|
|
53
|
+
variant("ProductRenamed", [[
|
|
54
|
+
"name",
|
|
55
|
+
S.string
|
|
56
|
+
]]),
|
|
57
|
+
variant("ProductImageChanged", [[
|
|
58
|
+
"imageUrl",
|
|
59
|
+
StorageRef$Reventless.forStore(undefined, "productImages")
|
|
60
|
+
]])
|
|
61
|
+
]);
|
|
62
|
+
globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema).map(e => e.eventType)).toEqual(["ProductImageChanged"]);
|
|
63
|
+
});
|
|
64
|
+
globalThis.test("an event schema with no declaration anywhere yields nothing", () => {
|
|
65
|
+
let schema = S.union([variant("ProductRenamed", [[
|
|
66
|
+
"name",
|
|
67
|
+
S.string
|
|
68
|
+
]])]);
|
|
69
|
+
globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema)).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
globalThis.test("an unannotated string field is not treated as a ref", () => {
|
|
72
|
+
let schema = S.union([variant("ProductImported", [[
|
|
73
|
+
"imageUrl",
|
|
74
|
+
S.string
|
|
75
|
+
]])]);
|
|
76
|
+
globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema)).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
globalThis.test("a single-variant (non-union) event schema is read the same way", () => {
|
|
79
|
+
let schema = variant("ProductImageChanged", [[
|
|
80
|
+
"imageUrl",
|
|
81
|
+
StorageRef$Reventless.forStore(undefined, "productImages")
|
|
82
|
+
]]);
|
|
83
|
+
globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema).map(e => e.eventType)).toEqual(["ProductImageChanged"]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
globalThis.describe("StorageRefFields.toJson", () => {
|
|
88
|
+
globalThis.test("emits field, arity and store per event type", () => {
|
|
89
|
+
let entries = [
|
|
90
|
+
{
|
|
91
|
+
eventType: "ProductImageChanged",
|
|
92
|
+
fields: [{
|
|
93
|
+
field: "imageUrl",
|
|
94
|
+
arity: "Single",
|
|
95
|
+
store: "Catalog.productImages"
|
|
96
|
+
}]
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
eventType: "GalleryReplaced",
|
|
100
|
+
fields: [{
|
|
101
|
+
field: "imageUrls",
|
|
102
|
+
arity: "Multiple",
|
|
103
|
+
store: "Catalog.productImages"
|
|
104
|
+
}]
|
|
105
|
+
}
|
|
106
|
+
];
|
|
107
|
+
globalThis.expect(JSON.stringify(StorageRefFields$ReventlessCore.toJson(entries))).toBe(`{"ProductImageChanged":[{"field":"imageUrl","arity":"one","store":"Catalog.productImages"}],"GalleryReplaced":[{"field":"imageUrls","arity":"many","store":"Catalog.productImages"}]}`);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
globalThis.describe("StorageRefFields.storesOf", () => {
|
|
112
|
+
globalThis.test("collects the distinct stores an event log's declarations reach", () => {
|
|
113
|
+
let entries = [
|
|
114
|
+
{
|
|
115
|
+
eventType: "A",
|
|
116
|
+
fields: [{
|
|
117
|
+
field: "a",
|
|
118
|
+
arity: "Single",
|
|
119
|
+
store: "Catalog.productImages"
|
|
120
|
+
}]
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
eventType: "B",
|
|
124
|
+
fields: [
|
|
125
|
+
{
|
|
126
|
+
field: "b",
|
|
127
|
+
arity: "Single",
|
|
128
|
+
store: "Catalog.productImages"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
field: "c",
|
|
132
|
+
arity: "Multiple",
|
|
133
|
+
store: "Ordering.receipts"
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
];
|
|
138
|
+
globalThis.expect(StorageRefFields$ReventlessCore.storesOf(entries)).toEqual([
|
|
139
|
+
"Catalog.productImages",
|
|
140
|
+
"Ordering.receipts"
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
let Fields;
|
|
146
|
+
|
|
147
|
+
export {
|
|
148
|
+
Fields,
|
|
149
|
+
variant,
|
|
150
|
+
ref_,
|
|
151
|
+
}
|
|
152
|
+
/* Not a pure module */
|