@reventlessdev/reventless-core 3.0.0-alpha.194 → 3.0.0-alpha.196
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 +22 -0
- package/package.json +12 -12
- package/rescript.json +1 -1
- package/src/components/Api/SchemaType.res +36 -0
- package/src/components/Api/SchemaType.res.mjs +22 -0
- package/src/components/Api/SuryToJsonSchema.res +18 -2
- package/src/components/Api/SuryToJsonSchema.res.mjs +8 -4
- package/src/plugin/component/Plugin_Helpers.res +7 -9
- package/src/plugin/component/Plugin_Structure.res +11 -2
- package/src/plugin/component/Plugin_Structure.res.mjs +2 -3
- package/src/util/FTPHandler.res.mjs +2 -2
- package/tests/api/SuryToJsonSchemaTest.res +65 -0
- package/tests/api/SuryToJsonSchemaTest.res.mjs +54 -0
- package/tests/logger/LogFormatTest.res +5 -6
- package/tests/plugin/PluginStructureTest.res +49 -16
- package/tests/plugin/PluginStructureTest.res.mjs +27 -16
- package/tests/util/CsvStreamTest.res +1 -4
- package/tests/util/CsvStreamTest.res.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,28 @@
|
|
|
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.196 (2026-07-31)
|
|
7
|
+
|
|
8
|
+
* feat(rescript)!: one Node bindings package, not two ([1258d8c](https://github.com/ReventlessDev/reventless-core/commit/1258d8c2b2ff2636b36a849fc5bdf9005c6fb0eb))
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **spec:** add Money and a closed ISO 4217 Currency ([d4852ab](https://github.com/ReventlessDev/reventless-core/commit/d4852ab63e823e39fac793c4fa5ac31470db9655))
|
|
12
|
+
|
|
13
|
+
### BREAKING CHANGES
|
|
14
|
+
|
|
15
|
+
* `@reventlessdev/rescript-node-streams` and
|
|
16
|
+
`@reventlessdev/rescript-node-zlib` are replaced by
|
|
17
|
+
`@reventlessdev/rescript-node`. Module names are unchanged.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# 3.0.0-alpha.195 (2026-07-30)
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* **core:** a command's field markers reach the wire, and its optional fields stay optional ([f1c1112](https://github.com/ReventlessDev/reventless-core/commit/f1c1112e9baa6b06e50097a5a618f49c9301cd0a))
|
|
26
|
+
|
|
27
|
+
|
|
6
28
|
# 3.0.0-alpha.194 (2026-07-30)
|
|
7
29
|
|
|
8
30
|
### Bug Fixes
|
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.196",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -28,17 +28,17 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"sury": "11.0.0-alpha.4",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
|
-
"@reventlessdev/rescript-
|
|
32
|
-
"@reventlessdev/rescript-
|
|
33
|
-
"@reventlessdev/rescript-
|
|
34
|
-
"@reventlessdev/rescript-
|
|
35
|
-
"@reventlessdev/rescript-
|
|
36
|
-
"@reventlessdev/rescript-
|
|
37
|
-
"@reventlessdev/rescript-
|
|
38
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
39
|
-
"@reventlessdev/rescript-
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
31
|
+
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
32
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
33
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.0",
|
|
34
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
35
|
+
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.0",
|
|
36
|
+
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.0",
|
|
37
|
+
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
38
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.114",
|
|
39
|
+
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
40
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.89",
|
|
41
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.30"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
package/rescript.json
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"@reventlessdev/rescript-effect",
|
|
30
30
|
"@reventlessdev/rescript-fast-csv",
|
|
31
31
|
"@reventlessdev/rescript-hash-object",
|
|
32
|
-
"@reventlessdev/rescript-node
|
|
32
|
+
"@reventlessdev/rescript-node",
|
|
33
33
|
"@reventlessdev/rescript-pulumi-pulumi",
|
|
34
34
|
"@reventlessdev/rescript-ssh2",
|
|
35
35
|
"@reventlessdev/rescript-uuid",
|
|
@@ -141,6 +141,42 @@ and shapeOf = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): s
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
The names of an object's optional properties, read from sury rather than from
|
|
146
|
+
the IR.
|
|
147
|
+
|
|
148
|
+
The IR cannot answer this for every field. `shapeOf` classifies a DCB-tagged or
|
|
149
|
+
`@ref` field as `EntityId` before it ever looks at the union sury wraps an
|
|
150
|
+
`option<…>` in — correctly, since those markers now describe the field through
|
|
151
|
+
that wrapper — so an optional reference reaches the IR as a plain `EntityId`
|
|
152
|
+
with its optionality spent. Asking the source schema keeps the two questions
|
|
153
|
+
apart: what a field *is*, and whether it has to be there.
|
|
154
|
+
|
|
155
|
+
Top-level properties only. A nested object's own fields are emitted by the
|
|
156
|
+
`ObjectRef` branch, which has no `required` of its own today.
|
|
157
|
+
*/
|
|
158
|
+
let optionalFieldNames = (schema: S.t<unknown>): array<string> =>
|
|
159
|
+
switch schema {
|
|
160
|
+
| Object({properties}) =>
|
|
161
|
+
properties
|
|
162
|
+
->Dict.toArray
|
|
163
|
+
->Array.filterMap(((propName, propSchema)) =>
|
|
164
|
+
switch propSchema {
|
|
165
|
+
| Union({anyOf}) =>
|
|
166
|
+
anyOf->Array.some(v =>
|
|
167
|
+
switch v {
|
|
168
|
+
| Null(_) | Undefined(_) => true
|
|
169
|
+
| _ => false
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
? Some(propName)
|
|
173
|
+
: None
|
|
174
|
+
| _ => None
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
| _ => []
|
|
178
|
+
}
|
|
179
|
+
|
|
144
180
|
let fromSuryObject = (~typeName: string, schema: S.t<unknown>): option<dict<schemaType>> =>
|
|
145
181
|
switch schema {
|
|
146
182
|
| Object({properties}) =>
|
|
@@ -150,6 +150,27 @@ function shapeOf(parentName, fieldName, schema) {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
function optionalFieldNames(schema) {
|
|
154
|
+
if (schema.type === "object") {
|
|
155
|
+
return Stdlib_Array.filterMap(Object.entries(schema.properties), param => {
|
|
156
|
+
let propSchema = param[1];
|
|
157
|
+
if (propSchema.type === "union" && propSchema.anyOf.some(v => {
|
|
158
|
+
switch (v.type) {
|
|
159
|
+
case "null" :
|
|
160
|
+
case "undefined" :
|
|
161
|
+
return true;
|
|
162
|
+
default:
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
})) {
|
|
166
|
+
return param[0];
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
} else {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
153
174
|
function fromSuryObject(typeName, schema) {
|
|
154
175
|
if (schema.type !== "object") {
|
|
155
176
|
return;
|
|
@@ -176,6 +197,7 @@ export {
|
|
|
176
197
|
isIdsFieldName,
|
|
177
198
|
fromSury,
|
|
178
199
|
shapeOf,
|
|
200
|
+
optionalFieldNames,
|
|
179
201
|
fromSuryObject,
|
|
180
202
|
}
|
|
181
203
|
/* DcbTag-Reventless Not a pure module */
|
|
@@ -179,8 +179,18 @@ and withSemantic = (fieldSchema: JSON.t, sem: Reventless.Semantic.t): JSON.t =>
|
|
|
179
179
|
JSON.Encode.object(obj)
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
// `optional` names the fields that may be absent; everything else is required.
|
|
183
|
+
// It comes from the sury schema (`SchemaType.optionalFieldNames`) rather than
|
|
184
|
+
// from the IR, which cannot answer for a reference or a DCB-tagged field — both
|
|
185
|
+
// classify as `EntityId` before the nullable wrapper is ever examined.
|
|
186
|
+
//
|
|
187
|
+
// Defaulting to "all required" mattered nowhere while only read-model state came
|
|
188
|
+
// through here: nothing validates a read schema. A command schema is the input
|
|
189
|
+
// of a form, and a field listed as required is one the form refuses to submit
|
|
190
|
+
// without — which turned `imageUrl?` into a picture every product had to have.
|
|
182
191
|
and objectRefToJsonSchema = (
|
|
183
192
|
~annotations: option<Reventless.StateAnnotations.stateAnnotationSpec>=?,
|
|
193
|
+
~optional: array<string>=[],
|
|
184
194
|
fields: dict<SchemaType.schemaType>,
|
|
185
195
|
): JSON.t => {
|
|
186
196
|
let props = Dict.make()
|
|
@@ -194,7 +204,9 @@ and objectRefToJsonSchema = (
|
|
|
194
204
|
| None => baseSchema
|
|
195
205
|
}
|
|
196
206
|
props->Dict.set(fieldName, withAnnotations)
|
|
197
|
-
|
|
207
|
+
if !(optional->Array.includes(fieldName)) {
|
|
208
|
+
required->Array.push(fieldName)
|
|
209
|
+
}
|
|
198
210
|
})
|
|
199
211
|
let entries: array<(string, JSON.t)> = [
|
|
200
212
|
("type", str("object")),
|
|
@@ -212,7 +224,11 @@ let deriveObjectSchema = (schema: S.t<unknown>): JSON.t =>
|
|
|
212
224
|
switch SchemaType.fromSuryObject(~typeName="", schema) {
|
|
213
225
|
| Some(fields) =>
|
|
214
226
|
let annotations = Reventless.StateAnnotations.getSpec(schema)
|
|
215
|
-
let objSchema = objectRefToJsonSchema(
|
|
227
|
+
let objSchema = objectRefToJsonSchema(
|
|
228
|
+
~annotations?,
|
|
229
|
+
~optional=SchemaType.optionalFieldNames(schema),
|
|
230
|
+
fields,
|
|
231
|
+
)
|
|
216
232
|
// Surface component-level visibility on the top-level object schema.
|
|
217
233
|
// Omitted entirely for the default `Public` (None) so schemas stay compact.
|
|
218
234
|
switch annotations {
|
|
@@ -177,7 +177,7 @@ function fromSchemaType(st) {
|
|
|
177
177
|
]
|
|
178
178
|
]);
|
|
179
179
|
case "ObjectRef" :
|
|
180
|
-
return objectRefToJsonSchema(undefined, st._1);
|
|
180
|
+
return objectRefToJsonSchema(undefined, undefined, st._1);
|
|
181
181
|
case "Enum" :
|
|
182
182
|
return Object.fromEntries([
|
|
183
183
|
[
|
|
@@ -221,7 +221,8 @@ function withSemantic(fieldSchema, sem) {
|
|
|
221
221
|
return obj;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
function objectRefToJsonSchema(annotations, fields) {
|
|
224
|
+
function objectRefToJsonSchema(annotations, optionalOpt, fields) {
|
|
225
|
+
let optional = optionalOpt !== undefined ? optionalOpt : [];
|
|
225
226
|
let props = {};
|
|
226
227
|
let required = [];
|
|
227
228
|
Object.entries(fields).forEach(param => {
|
|
@@ -229,7 +230,10 @@ function objectRefToJsonSchema(annotations, fields) {
|
|
|
229
230
|
let baseSchema = fromSchemaType(param[1]);
|
|
230
231
|
let withAnnotations = annotations !== undefined ? mergeAnnotations(baseSchema, fieldName, annotations) : baseSchema;
|
|
231
232
|
props[fieldName] = withAnnotations;
|
|
232
|
-
|
|
233
|
+
if (!optional.includes(fieldName)) {
|
|
234
|
+
required.push(fieldName);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
233
237
|
});
|
|
234
238
|
let entries = [
|
|
235
239
|
[
|
|
@@ -259,7 +263,7 @@ function deriveObjectSchema(schema) {
|
|
|
259
263
|
]]);
|
|
260
264
|
}
|
|
261
265
|
let annotations = StateAnnotations$Reventless.getSpec(schema);
|
|
262
|
-
let objSchema = objectRefToJsonSchema(annotations, fields);
|
|
266
|
+
let objSchema = objectRefToJsonSchema(annotations, SchemaType$ReventlessCore.optionalFieldNames(schema), fields);
|
|
263
267
|
if (annotations === undefined) {
|
|
264
268
|
return objSchema;
|
|
265
269
|
}
|
|
@@ -1165,16 +1165,14 @@ let getInteropMeta = (): Pulumi.Output.t<JSON.t> => {
|
|
|
1165
1165
|
// Stack metadata export — environment, region, timestamp, actor, git SHA.
|
|
1166
1166
|
// Called from both exportPluginOutputs and exportPlatformOutputs.
|
|
1167
1167
|
// ---------------------------------------------------------------------------
|
|
1168
|
-
@val external processEnv: dict<string> = "process.env"
|
|
1169
|
-
|
|
1170
1168
|
let exportDeploymentMetadata = () => {
|
|
1171
1169
|
let metadata =
|
|
1172
1170
|
[
|
|
1173
1171
|
("environment", Pulumi.Pulumi.getStackName()),
|
|
1174
1172
|
("region", Pulumi.Config.make(Some("aws"))->Pulumi.Config.get("region")->Option.getOr("unknown")),
|
|
1175
1173
|
("timestamp", Date.make()->Date.toISOString),
|
|
1176
|
-
("gitSha",
|
|
1177
|
-
("actor",
|
|
1174
|
+
("gitSha", NodeProcess.env->Dict.get("GITHUB_SHA")->Option.getOr("unknown")),
|
|
1175
|
+
("actor", NodeProcess.env->Dict.get("GITHUB_ACTOR")->Option.getOr("unknown")),
|
|
1178
1176
|
]
|
|
1179
1177
|
->Dict.fromArray
|
|
1180
1178
|
Pulumi.Pulumi.export(
|
|
@@ -1516,15 +1514,15 @@ let exportPluginOutputs = (pluginOutputs: Plugin.outputs) => {
|
|
|
1516
1514
|
| Some(hook) =>
|
|
1517
1515
|
// Read deployment provenance from env vars synchronously (before Output.apply).
|
|
1518
1516
|
let actor =
|
|
1519
|
-
|
|
1517
|
+
NodeProcess.env
|
|
1520
1518
|
->Dict.get("GITHUB_ACTOR")
|
|
1521
|
-
->Option.orElse(
|
|
1522
|
-
->Option.orElse(
|
|
1519
|
+
->Option.orElse(NodeProcess.env->Dict.get("CI_COMMIT_AUTHOR"))
|
|
1520
|
+
->Option.orElse(NodeProcess.env->Dict.get("USER"))
|
|
1523
1521
|
->Option.getOr("local")
|
|
1524
1522
|
let deploymentId =
|
|
1525
|
-
|
|
1523
|
+
NodeProcess.env
|
|
1526
1524
|
->Dict.get("GITHUB_SHA")
|
|
1527
|
-
->Option.orElse(
|
|
1525
|
+
->Option.orElse(NodeProcess.env->Dict.get("CI_COMMIT_SHA"))
|
|
1528
1526
|
->Option.getOr(Date.make()->Date.toISOString)
|
|
1529
1527
|
let schemaFor = name =>
|
|
1530
1528
|
componentSchemaRegistry->Dict.get(name)->Option.getOr({})
|
|
@@ -269,7 +269,14 @@ let make = (
|
|
|
269
269
|
}
|
|
270
270
|
({
|
|
271
271
|
Reventless.Plugin.name: variantName,
|
|
272
|
-
schema:
|
|
272
|
+
// The derived schema, not sury's raw one: `S.toJSONSchema` carries the
|
|
273
|
+
// shape and drops every `x-reventless-*` marker the PPX put on the
|
|
274
|
+
// fields, so a command's `@storageRef`/`@semantic`/`@ref` reached the
|
|
275
|
+
// wire on the read side and nowhere on the write side. A reader that
|
|
276
|
+
// matches a field against its setter — or picks the upload endpoint of
|
|
277
|
+
// the store a command argument declares — then has nothing to match on.
|
|
278
|
+
// `MCP_SchemaGenerator` already derives these same variant schemas.
|
|
279
|
+
schema: v->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
|
|
273
280
|
level,
|
|
274
281
|
aggregateIdField,
|
|
275
282
|
// A non-exposed (`@noApi`) variant has no callable mutation field. For a
|
|
@@ -343,7 +350,9 @@ let make = (
|
|
|
343
350
|
)
|
|
344
351
|
({
|
|
345
352
|
Reventless.Plugin.name: variantName,
|
|
346
|
-
schema
|
|
353
|
+
// Derived for the same reason as `commandDef.schema` above: an event's
|
|
354
|
+
// field markers are the write side's half of the same vocabulary.
|
|
355
|
+
schema: v->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
|
|
347
356
|
references,
|
|
348
357
|
}: Reventless.Plugin.eventDef)
|
|
349
358
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
-
import * as S from "sury/src/S.res.mjs";
|
|
4
3
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
4
|
import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
|
|
6
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
@@ -229,7 +228,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
|
|
|
229
228
|
}
|
|
230
229
|
return {
|
|
231
230
|
name: variantName,
|
|
232
|
-
schema: JSON.stringify(
|
|
231
|
+
schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(v)),
|
|
233
232
|
level: match[0],
|
|
234
233
|
aggregateIdField: match[1],
|
|
235
234
|
mutationField: apiExposed ? mutationFieldFor(variantName) : "",
|
|
@@ -281,7 +280,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
|
|
|
281
280
|
});
|
|
282
281
|
return {
|
|
283
282
|
name: variantName,
|
|
284
|
-
schema: JSON.stringify(
|
|
283
|
+
schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(v)),
|
|
285
284
|
references: references
|
|
286
285
|
};
|
|
287
286
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as SSH2 from "@reventlessdev/rescript-ssh2/src/SSH2.res.mjs";
|
|
4
4
|
import * as Ssh2 from "ssh2";
|
|
5
|
-
import * as
|
|
5
|
+
import * as Nodestream from "node:stream";
|
|
6
6
|
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
7
7
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
8
8
|
import * as Effect from "effect/Effect";
|
|
@@ -122,7 +122,7 @@ function ftp(connectionParams, ftpAction) {
|
|
|
122
122
|
let err$1 = new Error("FTPHandler: Error in Write Stream");
|
|
123
123
|
sftp.emit("error", err$1);
|
|
124
124
|
});
|
|
125
|
-
return await
|
|
125
|
+
return await Nodestream.promises.pipeline(ftpAction._0, ws);
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
}, () => {});
|
|
@@ -677,4 +677,69 @@ describe("SuryToJsonSchema:", () => {
|
|
|
677
677
|
))->toEqual((Some(JSON.Encode.string("type")), Some(JSON.Encode.string("type"))))
|
|
678
678
|
)
|
|
679
679
|
})
|
|
680
|
+
|
|
681
|
+
// The other half of that claim, stated so it cannot be confused with it.
|
|
682
|
+
// `Money` is the first semantic type that is *not* a brand: it turns a number
|
|
683
|
+
// into an object, so the wire shape changes and stored events written against
|
|
684
|
+
// the old field do not decode. The assertions below are the evidence for that
|
|
685
|
+
// — a schema diff before and after a retype is exactly this, and it is what
|
|
686
|
+
// says a deployment needs an upcaster before it can take the change.
|
|
687
|
+
describe("Money changes the field's shape, and says so:", () => {
|
|
688
|
+
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
689
|
+
S.schema(s =>
|
|
690
|
+
{
|
|
691
|
+
"price": s.matches(Reventless.Money.schema),
|
|
692
|
+
"quantity": s.matches(S.float),
|
|
693
|
+
}
|
|
694
|
+
)->S.castToUnknown,
|
|
695
|
+
)
|
|
696
|
+
let keyOf = (name, key) => getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, key))
|
|
697
|
+
|
|
698
|
+
testSync("the field is an object, not a number", () =>
|
|
699
|
+
expect(keyOf("price", "type"))->toEqual(Some(JSON.Encode.string("object")))
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
testSync("it carries the money id, sourced from the type", () =>
|
|
703
|
+
expect((keyOf("price", "x-reventless-semantic"), keyOf("price", "x-reventless-semantic-source")))
|
|
704
|
+
->toEqual((Some(JSON.Encode.string("money")), Some(JSON.Encode.string("type"))))
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
// The control: an ordinary `float` price is what the field looked like
|
|
708
|
+
// before, and it emits nothing at all. Without this the assertions above
|
|
709
|
+
// would pass just as well against a schema that had always said `money`.
|
|
710
|
+
testSync("a plain numeric field emits no semantic", () =>
|
|
711
|
+
expect((keyOf("quantity", "type"), keyOf("quantity", "x-reventless-semantic")))->toEqual((
|
|
712
|
+
Some(JSON.Encode.string("number")),
|
|
713
|
+
None,
|
|
714
|
+
))
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
testSync("the amount and the currency both reach the wire", () => {
|
|
718
|
+
let inner = getPropertyOf(json, "price")->Option.getOr(JSON.Encode.null)
|
|
719
|
+
expect((
|
|
720
|
+
getPropertyOf(inner, "amount")->Option.flatMap(s => getProperty(s, "type")),
|
|
721
|
+
getPropertyOf(inner, "currency")->Option.flatMap(s => getProperty(s, "type")),
|
|
722
|
+
))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("string"))))
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
// The closed currency arrives as an enum rather than a free string, which is
|
|
726
|
+
// what lets a consumer build a currency picker instead of a text box — and
|
|
727
|
+
// what makes `"eur"` unrepresentable on the wire as well as in the source.
|
|
728
|
+
testSync("the currency is an enum of ISO codes", () => {
|
|
729
|
+
let codes =
|
|
730
|
+
getPropertyOf(json, "price")
|
|
731
|
+
->Option.getOr(JSON.Encode.null)
|
|
732
|
+
->getPropertyOf("currency")
|
|
733
|
+
->Option.flatMap(s => getProperty(s, "enum"))
|
|
734
|
+
->Option.flatMap(JSON.Decode.array)
|
|
735
|
+
->Option.getOr([])
|
|
736
|
+
->Array.filterMap(JSON.Decode.string)
|
|
737
|
+
expect((
|
|
738
|
+
codes->Array.length == Reventless.Currency.all->Array.length,
|
|
739
|
+
codes->Array.includes("EUR"),
|
|
740
|
+
codes->Array.includes("JPY"),
|
|
741
|
+
codes->Array.includes("eur"),
|
|
742
|
+
))->toEqual((true, true, true, false))
|
|
743
|
+
})
|
|
744
|
+
})
|
|
680
745
|
})
|
|
@@ -5,9 +5,11 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
|
5
5
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
6
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
7
|
import * as Email$Reventless from "@reventlessdev/reventless-spec/src/semantic/Email.res.mjs";
|
|
8
|
+
import * as Money$Reventless from "@reventlessdev/reventless-spec/src/semantic/Money.res.mjs";
|
|
8
9
|
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
9
10
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
10
11
|
import * as Percent$Reventless from "@reventlessdev/reventless-spec/src/semantic/Percent.res.mjs";
|
|
12
|
+
import * as Currency$Reventless from "@reventlessdev/reventless-spec/src/semantic/Currency.res.mjs";
|
|
11
13
|
import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
|
|
12
14
|
import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
|
|
13
15
|
import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
|
|
@@ -981,6 +983,58 @@ globalThis.describe("SuryToJsonSchema:", () => {
|
|
|
981
983
|
]);
|
|
982
984
|
});
|
|
983
985
|
});
|
|
986
|
+
globalThis.describe("Money changes the field's shape, and says so:", () => {
|
|
987
|
+
let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(S.schema(s => ({
|
|
988
|
+
price: s.m(Money$Reventless.schema),
|
|
989
|
+
quantity: s.m(S.float)
|
|
990
|
+
})));
|
|
991
|
+
let keyOf = (name, key) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, key));
|
|
992
|
+
globalThis.test("the field is an object, not a number", () => {
|
|
993
|
+
globalThis.expect(keyOf("price", "type")).toEqual("object");
|
|
994
|
+
});
|
|
995
|
+
globalThis.test("it carries the money id, sourced from the type", () => {
|
|
996
|
+
globalThis.expect([
|
|
997
|
+
keyOf("price", "x-reventless-semantic"),
|
|
998
|
+
keyOf("price", "x-reventless-semantic-source")
|
|
999
|
+
]).toEqual([
|
|
1000
|
+
"money",
|
|
1001
|
+
"type"
|
|
1002
|
+
]);
|
|
1003
|
+
});
|
|
1004
|
+
globalThis.test("a plain numeric field emits no semantic", () => {
|
|
1005
|
+
globalThis.expect([
|
|
1006
|
+
keyOf("quantity", "type"),
|
|
1007
|
+
keyOf("quantity", "x-reventless-semantic")
|
|
1008
|
+
]).toEqual([
|
|
1009
|
+
"number",
|
|
1010
|
+
undefined
|
|
1011
|
+
]);
|
|
1012
|
+
});
|
|
1013
|
+
globalThis.test("the amount and the currency both reach the wire", () => {
|
|
1014
|
+
let inner = Stdlib_Option.getOr(getPropertyOf(json, "price"), null);
|
|
1015
|
+
globalThis.expect([
|
|
1016
|
+
Stdlib_Option.flatMap(getPropertyOf(inner, "amount"), s => getProperty(s, "type")),
|
|
1017
|
+
Stdlib_Option.flatMap(getPropertyOf(inner, "currency"), s => getProperty(s, "type"))
|
|
1018
|
+
]).toEqual([
|
|
1019
|
+
"number",
|
|
1020
|
+
"string"
|
|
1021
|
+
]);
|
|
1022
|
+
});
|
|
1023
|
+
globalThis.test("the currency is an enum of ISO codes", () => {
|
|
1024
|
+
let codes = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(Stdlib_Option.getOr(getPropertyOf(json, "price"), null), "currency"), s => getProperty(s, "enum")), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.string);
|
|
1025
|
+
globalThis.expect([
|
|
1026
|
+
codes.length === Currency$Reventless.all.length,
|
|
1027
|
+
codes.includes("EUR"),
|
|
1028
|
+
codes.includes("JPY"),
|
|
1029
|
+
codes.includes("eur")
|
|
1030
|
+
]).toEqual([
|
|
1031
|
+
true,
|
|
1032
|
+
true,
|
|
1033
|
+
true,
|
|
1034
|
+
false
|
|
1035
|
+
]);
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
984
1038
|
});
|
|
985
1039
|
|
|
986
1040
|
export {
|
|
@@ -7,8 +7,7 @@ S.enableJson()
|
|
|
7
7
|
// included — to JSON, which no-ops `bold`/`fmtComp`. The human-format
|
|
8
8
|
// assertions below expect ANSI, so force text mode for this file. The
|
|
9
9
|
// "JSON sink" block flips to json and restores text in its own setup.
|
|
10
|
-
|
|
11
|
-
_processEnv->Dict.set("REVENTLESS_LOG_FORMAT", "text")
|
|
10
|
+
NodeProcess.env->Dict.set("REVENTLESS_LOG_FORMAT", "text")
|
|
12
11
|
Reventless.AnsiStyle.reload()
|
|
13
12
|
|
|
14
13
|
describe("LogFormat", () => {
|
|
@@ -193,11 +192,11 @@ describe("JSON sink", () => {
|
|
|
193
192
|
// Flip to json for the whole block; restore text afterwards so a re-run of the
|
|
194
193
|
// file (or shared module state) keeps the human-format assertions valid.
|
|
195
194
|
beforeAll(() => {
|
|
196
|
-
|
|
195
|
+
NodeProcess.env->Dict.set("REVENTLESS_LOG_FORMAT", "json")
|
|
197
196
|
Reventless.AnsiStyle.reload()
|
|
198
197
|
})
|
|
199
198
|
afterAll(() => {
|
|
200
|
-
|
|
199
|
+
NodeProcess.env->Dict.set("REVENTLESS_LOG_FORMAT", "text")
|
|
201
200
|
Reventless.AnsiStyle.reload()
|
|
202
201
|
})
|
|
203
202
|
|
|
@@ -344,10 +343,10 @@ describe("JSON sink", () => {
|
|
|
344
343
|
})
|
|
345
344
|
|
|
346
345
|
testSync("service field is emitted when REVENTLESS_SERVICE is set", () => {
|
|
347
|
-
|
|
346
|
+
NodeProcess.env->Dict.set("REVENTLESS_SERVICE", "TestService")
|
|
348
347
|
let lines = captureLogs(() => log.info(~comp="Platform", "tick"))
|
|
349
348
|
let got = lines->Array.get(0)->Option.flatMap(l => l->fieldOf("service"))
|
|
350
|
-
|
|
349
|
+
NodeProcess.env->Dict.delete("REVENTLESS_SERVICE")
|
|
351
350
|
expect(got)->toEqual(Some("TestService"))
|
|
352
351
|
})
|
|
353
352
|
|
|
@@ -549,26 +549,28 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
549
549
|
})
|
|
550
550
|
})
|
|
551
551
|
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
| None => None
|
|
560
|
-
}
|
|
552
|
+
// Shared by the read-side and write-side schema cases below, which ask the
|
|
553
|
+
// same question of the same emitter.
|
|
554
|
+
let getProperty = (json: JSON.t, key: string): option<JSON.t> =>
|
|
555
|
+
switch json->JSON.Decode.object {
|
|
556
|
+
| Some(obj) => obj->Dict.get(key)
|
|
557
|
+
| None => None
|
|
558
|
+
}
|
|
561
559
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
| None => None
|
|
568
|
-
}
|
|
560
|
+
let getPropertyOf = (json: JSON.t, fieldName: string): option<JSON.t> =>
|
|
561
|
+
switch getProperty(json, "properties") {
|
|
562
|
+
| Some(props) =>
|
|
563
|
+
switch props->JSON.Decode.object {
|
|
564
|
+
| Some(obj) => obj->Dict.get(fieldName)
|
|
569
565
|
| None => None
|
|
570
566
|
}
|
|
567
|
+
| None => None
|
|
568
|
+
}
|
|
571
569
|
|
|
570
|
+
// Phase 4: queryableDef.schema must carry x-reventless-* extension keys for
|
|
571
|
+
// annotated state types. Plugin_Structure now uses SuryToJsonSchema.deriveObjectSchema
|
|
572
|
+
// (annotation-aware) instead of S.toJSONSchema (metadata-blind).
|
|
573
|
+
describe("queryableDef.schema propagates x-reventless-* annotations", () => {
|
|
572
574
|
let parseSchema = (svs: Reventless.Plugin.queryableDef): JSON.t =>
|
|
573
575
|
svs.schema->JSON.parseOrThrow
|
|
574
576
|
|
|
@@ -826,6 +828,37 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
826
828
|
]
|
|
827
829
|
expect(cmd.references)->toEqual(expected)
|
|
828
830
|
})
|
|
831
|
+
|
|
832
|
+
// What `requiredStores` knows, the wire has to carry. A reader deciding
|
|
833
|
+
// which command fills a storage-ref field, or which store's endpoint an
|
|
834
|
+
// upload goes to, has only the command schema to go on.
|
|
835
|
+
testSync("the command schema carries the field's storage-ref marker", () => {
|
|
836
|
+
let cmd = (withOptional.stateChangeSlices->Array.getUnsafe(0)).commands->Array.getUnsafe(0)
|
|
837
|
+
let target =
|
|
838
|
+
cmd.schema
|
|
839
|
+
->JSON.parseOrThrow
|
|
840
|
+
->getPropertyOf("avatarUrl")
|
|
841
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-semantic-target"))
|
|
842
|
+
->Option.flatMap(t => getProperty(t, "store"))
|
|
843
|
+
->Option.flatMap(JSON.Decode.string)
|
|
844
|
+
expect(target)->toBe(Some("avatars"))
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
// A form submits what a schema says is required. `avatarUrl?` is optional in
|
|
848
|
+
// the spec, so listing it would make the picture mandatory in every UI that
|
|
849
|
+
// renders the command — the failure this pairs with, since carrying the
|
|
850
|
+
// marker is worth nothing if the field cannot be left empty.
|
|
851
|
+
testSync("an optional command argument is not required", () => {
|
|
852
|
+
let cmd = (withOptional.stateChangeSlices->Array.getUnsafe(0)).commands->Array.getUnsafe(0)
|
|
853
|
+
let required =
|
|
854
|
+
cmd.schema
|
|
855
|
+
->JSON.parseOrThrow
|
|
856
|
+
->getProperty("required")
|
|
857
|
+
->Option.flatMap(JSON.Decode.array)
|
|
858
|
+
->Option.getOr([])
|
|
859
|
+
->Array.filterMap(JSON.Decode.string)
|
|
860
|
+
expect(required)->toEqual(["customerId"])
|
|
861
|
+
})
|
|
829
862
|
})
|
|
830
863
|
|
|
831
864
|
// Declarations are authoritative; the name heuristics are a lint. A
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as S from "sury/src/S.res.mjs";
|
|
4
4
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
6
|
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
6
7
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
8
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
@@ -676,23 +677,23 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
676
677
|
globalThis.expect(Plugin_Structure$ReventlessCore.statusFieldFromStateSchema("Test", schema)).toEqual(undefined);
|
|
677
678
|
});
|
|
678
679
|
});
|
|
680
|
+
let getProperty = (json, key) => {
|
|
681
|
+
let obj = Stdlib_JSON.Decode.object(json);
|
|
682
|
+
if (obj !== undefined) {
|
|
683
|
+
return obj[key];
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
let getPropertyOf = (json, fieldName) => {
|
|
687
|
+
let props = getProperty(json, "properties");
|
|
688
|
+
if (props === undefined) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
let obj = Stdlib_JSON.Decode.object(props);
|
|
692
|
+
if (obj !== undefined) {
|
|
693
|
+
return obj[fieldName];
|
|
694
|
+
}
|
|
695
|
+
};
|
|
679
696
|
globalThis.describe("queryableDef.schema propagates x-reventless-* annotations", () => {
|
|
680
|
-
let getProperty = (json, key) => {
|
|
681
|
-
let obj = Stdlib_JSON.Decode.object(json);
|
|
682
|
-
if (obj !== undefined) {
|
|
683
|
-
return obj[key];
|
|
684
|
-
}
|
|
685
|
-
};
|
|
686
|
-
let getPropertyOf = (json, fieldName) => {
|
|
687
|
-
let props = getProperty(json, "properties");
|
|
688
|
-
if (props === undefined) {
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
let obj = Stdlib_JSON.Decode.object(props);
|
|
692
|
-
if (obj !== undefined) {
|
|
693
|
-
return obj[fieldName];
|
|
694
|
-
}
|
|
695
|
-
};
|
|
696
697
|
let annotatedSchema = JSON.parse(structure.stateViewSlices[3].schema);
|
|
697
698
|
globalThis.test("itemId carries x-reventless-id", () => {
|
|
698
699
|
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(annotatedSchema, "itemId"), s => getProperty(s, "x-reventless-id")), Stdlib_JSON.Decode.bool)).toBe(true);
|
|
@@ -872,6 +873,16 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
872
873
|
}];
|
|
873
874
|
globalThis.expect(cmd.references).toEqual(expected);
|
|
874
875
|
});
|
|
876
|
+
globalThis.test("the command schema carries the field's storage-ref marker", () => {
|
|
877
|
+
let cmd = withOptional.stateChangeSlices[0].commands[0];
|
|
878
|
+
let target = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(JSON.parse(cmd.schema), "avatarUrl"), s => getProperty(s, "x-reventless-semantic-target")), t => getProperty(t, "store")), Stdlib_JSON.Decode.string);
|
|
879
|
+
globalThis.expect(target).toBe("avatars");
|
|
880
|
+
});
|
|
881
|
+
globalThis.test("an optional command argument is not required", () => {
|
|
882
|
+
let cmd = withOptional.stateChangeSlices[0].commands[0];
|
|
883
|
+
let required = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(getProperty(JSON.parse(cmd.schema), "required"), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.string);
|
|
884
|
+
globalThis.expect(required).toEqual(["customerId"]);
|
|
885
|
+
});
|
|
875
886
|
});
|
|
876
887
|
globalThis.describe("capability inference", () => {
|
|
877
888
|
let commandWarnings = Capability_Inference$ReventlessCore.scanSchema("ChangePhoto", PsChangePhoto$ReventlessCore.commandSchema);
|
|
@@ -4,16 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
open JestGlobals
|
|
6
6
|
|
|
7
|
-
// Write a UTF-8 string to a file synchronously (no return value).
|
|
8
|
-
@module("fs") external writeFileSync: (string, string) => unit = "writeFileSync"
|
|
9
|
-
|
|
10
7
|
// Counter for unique temp file names — avoids cross-test collisions.
|
|
11
8
|
let fileCounter = ref(0)
|
|
12
9
|
|
|
13
10
|
let writeTempCsv = (content: string): string => {
|
|
14
11
|
fileCounter := fileCounter.contents + 1
|
|
15
12
|
let path = `/tmp/reventless-csvstream-test-${Int.toString(fileCounter.contents)}.csv`
|
|
16
|
-
writeFileSync(path, content)
|
|
13
|
+
NodeFs.writeFileSync(path, content)
|
|
17
14
|
path
|
|
18
15
|
}
|
|
19
16
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
-
import * as Fs from "fs";
|
|
4
3
|
import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
|
|
4
|
+
import * as Nodefs from "node:fs";
|
|
5
5
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
6
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
7
|
import * as Effect from "effect/Effect";
|
|
@@ -15,7 +15,7 @@ let fileCounter = {
|
|
|
15
15
|
function writeTempCsv(content) {
|
|
16
16
|
fileCounter.contents = fileCounter.contents + 1 | 0;
|
|
17
17
|
let path = `/tmp/reventless-csvstream-test-` + fileCounter.contents.toString() + `.csv`;
|
|
18
|
-
|
|
18
|
+
Nodefs.writeFileSync(path, content, "utf8");
|
|
19
19
|
return path;
|
|
20
20
|
}
|
|
21
21
|
|