@reventlessdev/reventless-core 3.0.0-alpha.212 → 3.0.0-alpha.213
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 +12 -0
- package/package.json +8 -8
- package/src/Projection.res +5 -2
- package/src/Projection.res.mjs +2 -1
- package/src/components/AutomationSlice/AutomationSlice_Callback.res +30 -0
- package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +12 -0
- package/src/components/Dcb/Dcb_Builder.res +6 -2
- package/src/components/Dcb/Dcb_Builder.res.mjs +2 -2
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res +32 -0
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs +13 -0
- package/src/plugin/component/Plugin_Builder.res +6 -13
- package/src/plugin/component/Plugin_Builder.res.mjs +2 -16
- package/src/plugin/component/Plugin_Helpers.res +21 -3
- package/src/plugin/component/Plugin_Helpers.res.mjs +16 -0
- package/src/util/LogFormat.res +52 -1
- package/src/util/LogFormat.res.mjs +21 -2
- package/src/util/StoreLayout.res +38 -0
- package/src/util/StoreLayout.res.mjs +22 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res +40 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +35 -0
- package/tests/logger/LogFormatTest.res +42 -0
- package/tests/logger/LogFormatTest.res.mjs +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
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.213 (2026-08-05)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **core:** describe slice TODO items by their own schema in the API ([d863056](https://github.com/ReventlessDev/reventless-core/commit/d863056ba734ba3dc76721c32bfb766d777ceb9a))
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **core:** cap rendered values in command, event and action logs ([8e07752](https://github.com/ReventlessDev/reventless-core/commit/8e077527ad3c8258582951544ee53d2722876191))
|
|
14
|
+
* **local:** offload large pluginDefinition fields to the object store ([84c5fab](https://github.com/ReventlessDev/reventless-core/commit/84c5fab339856b5d64f0c05110b1fa7f38646f5e))
|
|
15
|
+
* **local:** scoped seed:reset for the local platform ([a4dd003](https://github.com/ReventlessDev/reventless-core/commit/a4dd0033b7d01be23be91bac3f01d38ebeab7d45))
|
|
16
|
+
|
|
17
|
+
|
|
6
18
|
# 3.0.0-alpha.212 (2026-08-04)
|
|
7
19
|
|
|
8
20
|
### 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.213",
|
|
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-fast-csv": "2.0.0-alpha.1",
|
|
32
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
33
|
-
"@reventlessdev/rescript-
|
|
34
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.1",
|
|
32
|
+
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.2",
|
|
35
33
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
34
|
+
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
35
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.2",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
37
|
-
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.
|
|
38
|
-
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
39
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.126",
|
|
37
|
+
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.2",
|
|
40
38
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.30",
|
|
41
|
-
"@reventlessdev/
|
|
39
|
+
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
40
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.127",
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.101"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
package/src/Projection.res
CHANGED
|
@@ -126,9 +126,12 @@ let applyChanges = async (
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
// Best-effort: a state that can't be JSON-serialized (e.g. carries a function)
|
|
129
|
-
// must not crash the projection just to produce a debug line.
|
|
129
|
+
// must not crash the projection just to produce a debug line. Capped at the
|
|
130
|
+
// shared log budget — an action line names which row changed, and a read model
|
|
131
|
+
// holding a large field (a plugin structure, an inline offload payload) would
|
|
132
|
+
// otherwise print the whole row twice, once per side of the `=>`.
|
|
130
133
|
let stateToString: 'a => string = state =>
|
|
131
|
-
state->JSON.stringifyAny->Option.getOr("<unserializable>")
|
|
134
|
+
state->JSON.stringifyAny->Option.getOr("<unserializable>")->LogFormat.truncate
|
|
132
135
|
let statesToString: array<'a> => string = states =>
|
|
133
136
|
states->Array.map(stateToString)->Array.joinUnsafe(", ")
|
|
134
137
|
|
package/src/Projection.res.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import * as DisplayName$Reventless from "@reventlessdev/reventless-spec/src/comp
|
|
|
16
16
|
import * as Message$ReventlessCore from "./Message.res.mjs";
|
|
17
17
|
import * as QueryDb$ReventlessCore from "./components/QueryDb/QueryDb.res.mjs";
|
|
18
18
|
import * as QueryDb$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/QueryDb.res.mjs";
|
|
19
|
+
import * as LogFormat$ReventlessCore from "./util/LogFormat.res.mjs";
|
|
19
20
|
|
|
20
21
|
let log = Logger$ReventlessCore.fromEnv();
|
|
21
22
|
|
|
@@ -195,7 +196,7 @@ async function applyChanges(action, id, beforeStates, afterStates, param, param$
|
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
function stateToString(state) {
|
|
198
|
-
return Stdlib_Option.getOr(JSON.stringify(state), "<unserializable>");
|
|
199
|
+
return LogFormat$ReventlessCore.truncate(Stdlib_Option.getOr(JSON.stringify(state), "<unserializable>"));
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
function statesToString(states) {
|
|
@@ -31,6 +31,36 @@ type todoRow = {
|
|
|
31
31
|
retryCount: int,
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** The item schema seen through the `JSON.t` the row actually stores.
|
|
35
|
+
|
|
36
|
+
`item` holds `Spec.todoItemSchema`-encoded JSON, so the item schema already
|
|
37
|
+
describes the stored value exactly — only the ReScript types disagree. */
|
|
38
|
+
external itemAsJson: S.t<'item> => S.t<JSON.t> = "%identity"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
The row schema as the API describes it: `item` carried by the slice's own item
|
|
42
|
+
schema instead of as opaque JSON.
|
|
43
|
+
|
|
44
|
+
`todoRowSchema` types `item` as `JSON.t`, which the schema→GraphQL IR can only
|
|
45
|
+
classify as `Unknown` — and `Unknown` renders as `String!`. The field then
|
|
46
|
+
serves a stored object through a String, which fails at execution ("String
|
|
47
|
+
cannot represent value: { … }") the moment a client selects it. Describing the
|
|
48
|
+
item shape gives the field a real object type instead.
|
|
49
|
+
|
|
50
|
+
Shape only: never encode or parse a row with this. The runtime field is `JSON.t`
|
|
51
|
+
and the identity cast above would have `S.parseOrThrow` hand back an item where
|
|
52
|
+
the row's own code expects JSON.
|
|
53
|
+
*/
|
|
54
|
+
let todoRowSchemaFor = (itemSchema: S.t<'item>): S.t<todoRow> =>
|
|
55
|
+
S.schema(s => {
|
|
56
|
+
item: s.matches(itemSchema->itemAsJson),
|
|
57
|
+
status: s.matches(todoStatusSchema),
|
|
58
|
+
createdAt: s.matches(S.string),
|
|
59
|
+
processedAt: ?s.matches(S.option(S.string)),
|
|
60
|
+
completedAt: ?s.matches(S.option(S.string)),
|
|
61
|
+
retryCount: s.matches(S.int),
|
|
62
|
+
})
|
|
63
|
+
|
|
34
64
|
/**
|
|
35
65
|
The runtime protocol between a built callback and whoever drives it — the same
|
|
36
66
|
arrangement as `OutboundTranslationSlice_Callback`, and for the same reason. The
|
|
@@ -30,6 +30,17 @@ let todoRowSchema = S.schema(s => ({
|
|
|
30
30
|
retryCount: s.m(S.int)
|
|
31
31
|
}));
|
|
32
32
|
|
|
33
|
+
function todoRowSchemaFor(itemSchema) {
|
|
34
|
+
return S.schema(s => ({
|
|
35
|
+
item: s.m(itemSchema),
|
|
36
|
+
status: s.m(todoStatusSchema),
|
|
37
|
+
createdAt: s.m(S.string),
|
|
38
|
+
processedAt: s.m(S.option(S.string)),
|
|
39
|
+
completedAt: s.m(S.option(S.string)),
|
|
40
|
+
retryCount: s.m(S.int)
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
function Make(Spec) {
|
|
34
45
|
return Automation => {
|
|
35
46
|
let todoItems = {};
|
|
@@ -184,6 +195,7 @@ function Make(Spec) {
|
|
|
184
195
|
export {
|
|
185
196
|
todoStatusSchema,
|
|
186
197
|
todoRowSchema,
|
|
198
|
+
todoRowSchemaFor,
|
|
187
199
|
Make,
|
|
188
200
|
}
|
|
189
201
|
/* Not a pure module */
|
|
@@ -1009,7 +1009,9 @@ module Make = (
|
|
|
1009
1009
|
ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
|
|
1010
1010
|
listFieldName: qn.listFieldName,
|
|
1011
1011
|
returnTypeName: qn.returnTypeName,
|
|
1012
|
-
stateSchema: AutomationSlice_Callback.
|
|
1012
|
+
stateSchema: AutomationSlice_Callback.todoRowSchemaFor(
|
|
1013
|
+
A.Spec.todoItemSchema,
|
|
1014
|
+
)->S.castToUnknown,
|
|
1013
1015
|
authorization: None,
|
|
1014
1016
|
connectionSpec: true,
|
|
1015
1017
|
}
|
|
@@ -1023,7 +1025,9 @@ module Make = (
|
|
|
1023
1025
|
ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
|
|
1024
1026
|
listFieldName: qn.listFieldName,
|
|
1025
1027
|
returnTypeName: qn.returnTypeName,
|
|
1026
|
-
stateSchema: OutboundTranslationSlice_Callback.
|
|
1028
|
+
stateSchema: OutboundTranslationSlice_Callback.todoRowSchemaFor(
|
|
1029
|
+
O.Spec.outboundItemSchema,
|
|
1030
|
+
)->S.castToUnknown,
|
|
1027
1031
|
authorization: None,
|
|
1028
1032
|
connectionSpec: true,
|
|
1029
1033
|
}
|
|
@@ -531,7 +531,7 @@ function Make(DcbEventLogStorage) {
|
|
|
531
531
|
singleFieldName: qn.singleFieldName,
|
|
532
532
|
listFieldName: qn.listFieldName,
|
|
533
533
|
returnTypeName: qn.returnTypeName,
|
|
534
|
-
stateSchema: AutomationSlice_Callback$ReventlessCore.
|
|
534
|
+
stateSchema: AutomationSlice_Callback$ReventlessCore.todoRowSchemaFor(A.Spec.todoItemSchema),
|
|
535
535
|
authorization: undefined,
|
|
536
536
|
connectionSpec: true
|
|
537
537
|
};
|
|
@@ -542,7 +542,7 @@ function Make(DcbEventLogStorage) {
|
|
|
542
542
|
singleFieldName: qn.singleFieldName,
|
|
543
543
|
listFieldName: qn.listFieldName,
|
|
544
544
|
returnTypeName: qn.returnTypeName,
|
|
545
|
-
stateSchema: OutboundTranslationSlice_Callback$ReventlessCore.
|
|
545
|
+
stateSchema: OutboundTranslationSlice_Callback$ReventlessCore.todoRowSchemaFor(O.Spec.outboundItemSchema),
|
|
546
546
|
authorization: undefined,
|
|
547
547
|
connectionSpec: true
|
|
548
548
|
};
|
|
@@ -22,6 +22,38 @@ type todoRow = {
|
|
|
22
22
|
lastError?: string,
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** The item schema seen through the `JSON.t` the row actually stores.
|
|
26
|
+
|
|
27
|
+
`item` holds `Spec.outboundItemSchema`-encoded JSON, so the item schema
|
|
28
|
+
already describes the stored value exactly — only the ReScript types
|
|
29
|
+
disagree. */
|
|
30
|
+
external itemAsJson: S.t<'item> => S.t<JSON.t> = "%identity"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
The row schema as the API describes it: `item` carried by the slice's own item
|
|
34
|
+
schema instead of as opaque JSON.
|
|
35
|
+
|
|
36
|
+
`todoRowSchema` types `item` as `JSON.t`, which the schema→GraphQL IR can only
|
|
37
|
+
classify as `Unknown` — and `Unknown` renders as `String!`. The field then
|
|
38
|
+
serves a stored object through a String, which fails at execution ("String
|
|
39
|
+
cannot represent value: { … }") the moment a client selects it. Describing the
|
|
40
|
+
item shape gives the field a real object type instead.
|
|
41
|
+
|
|
42
|
+
Shape only: never encode or parse a row with this. The runtime field is `JSON.t`
|
|
43
|
+
and the identity cast above would have `S.parseOrThrow` hand back an item where
|
|
44
|
+
the row's own code expects JSON.
|
|
45
|
+
*/
|
|
46
|
+
let todoRowSchemaFor = (itemSchema: S.t<'item>): S.t<todoRow> =>
|
|
47
|
+
S.schema(s => {
|
|
48
|
+
item: s.matches(itemSchema->itemAsJson),
|
|
49
|
+
status: s.matches(todoStatusSchema),
|
|
50
|
+
createdAt: s.matches(S.string),
|
|
51
|
+
processedAt: ?s.matches(S.option(S.string)),
|
|
52
|
+
completedAt: ?s.matches(S.option(S.string)),
|
|
53
|
+
retryCount: s.matches(S.int),
|
|
54
|
+
lastError: ?s.matches(S.option(S.string)),
|
|
55
|
+
})
|
|
56
|
+
|
|
25
57
|
/**
|
|
26
58
|
The runtime protocol between a built callback and whoever drives it.
|
|
27
59
|
|
|
@@ -29,6 +29,18 @@ let todoRowSchema = S.schema(s => ({
|
|
|
29
29
|
lastError: s.m(S.option(S.string))
|
|
30
30
|
}));
|
|
31
31
|
|
|
32
|
+
function todoRowSchemaFor(itemSchema) {
|
|
33
|
+
return S.schema(s => ({
|
|
34
|
+
item: s.m(itemSchema),
|
|
35
|
+
status: s.m(todoStatusSchema),
|
|
36
|
+
createdAt: s.m(S.string),
|
|
37
|
+
processedAt: s.m(S.option(S.string)),
|
|
38
|
+
completedAt: s.m(S.option(S.string)),
|
|
39
|
+
retryCount: s.m(S.int),
|
|
40
|
+
lastError: s.m(S.option(S.string))
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
function Make(Spec) {
|
|
33
45
|
return Translation => {
|
|
34
46
|
let todoItems = {};
|
|
@@ -168,6 +180,7 @@ function Make(Spec) {
|
|
|
168
180
|
export {
|
|
169
181
|
todoStatusSchema,
|
|
170
182
|
todoRowSchema,
|
|
183
|
+
todoRowSchemaFor,
|
|
171
184
|
Make,
|
|
172
185
|
}
|
|
173
186
|
/* Not a pure module */
|
|
@@ -621,20 +621,13 @@ module Make = (
|
|
|
621
621
|
|
|
622
622
|
// Offload the two large pluginDefinition fields at deploy time when a
|
|
623
623
|
// provider offers an object store (AWS writes a content-addressed object
|
|
624
|
-
// and returns a reference)
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
624
|
+
// and returns a reference). Computed HERE, at graph-construction time —
|
|
625
|
+
// both values are concrete and the hook (which creates the object resource)
|
|
626
|
+
// MUST run outside the Pulumi.Output.apply below: a resource cannot be
|
|
627
|
+
// created inside .apply, and an apply callback is skipped entirely on
|
|
628
|
+
// `pulumi preview` when its inputs are still unknown.
|
|
629
629
|
// Unconditional: these fields are always large enough to be worth offloading.
|
|
630
|
-
let toPayload =
|
|
631
|
-
switch Plugin_Helpers.offloadHook.contents {
|
|
632
|
-
| Some(hook) =>
|
|
633
|
-
Reventless.Offload.Offloaded(
|
|
634
|
-
hook(~store, ~bytes=value->S.reverseConvertToJsonStringOrThrow(schema)),
|
|
635
|
-
)
|
|
636
|
-
| None => Reventless.Offload.Inline(value)
|
|
637
|
-
}
|
|
630
|
+
let toPayload = Plugin_Helpers.offloadPayload
|
|
638
631
|
let apiSchemaFragmentPayload = toPayload(
|
|
639
632
|
apiSchemaFragment,
|
|
640
633
|
~schema=Reventless.Plugin.apiSchemaFragmentSchema,
|
|
@@ -425,22 +425,8 @@ function Make(Spec) {
|
|
|
425
425
|
});
|
|
426
426
|
let pushSchema = Spec.hooks.preResolversSchemaHook;
|
|
427
427
|
let schemaPushed = pushSchema !== undefined ? pushSchema(extra$1, version, apiSchemaFragment) : Pulumi.output();
|
|
428
|
-
let
|
|
429
|
-
|
|
430
|
-
if (hook !== undefined) {
|
|
431
|
-
return {
|
|
432
|
-
TAG: "Offloaded",
|
|
433
|
-
_0: hook(store, S.reverseConvertToJsonStringOrThrow(value, schema, undefined))
|
|
434
|
-
};
|
|
435
|
-
} else {
|
|
436
|
-
return {
|
|
437
|
-
TAG: "Inline",
|
|
438
|
-
_0: value
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
};
|
|
442
|
-
let apiSchemaFragmentPayload = toPayload(apiSchemaFragment, Plugin$Reventless.apiSchemaFragmentSchema, "pluginApiFragments");
|
|
443
|
-
let structurePayload = Stdlib_Option.map(pluginStructure, s => toPayload(s, Plugin$Reventless.pluginStructureSchema, "pluginStructures"));
|
|
428
|
+
let apiSchemaFragmentPayload = Plugin_Helpers$ReventlessCore.offloadPayload(apiSchemaFragment, Plugin$Reventless.apiSchemaFragmentSchema, "pluginApiFragments");
|
|
429
|
+
let structurePayload = Stdlib_Option.map(pluginStructure, s => Plugin_Helpers$ReventlessCore.offloadPayload(s, Plugin$Reventless.pluginStructureSchema, "pluginStructures"));
|
|
444
430
|
let builderOutputs = Pulumi.all([
|
|
445
431
|
Pulumi.all([
|
|
446
432
|
interstackAdminExtensionPoints,
|
|
@@ -788,9 +788,10 @@ let clearOnPluginDeployed = () => {
|
|
|
788
788
|
// ---------------------------------------------------------------------------
|
|
789
789
|
// Deploy-time offload hook — content-addresses a large pluginDefinition field
|
|
790
790
|
// to an object store and returns the reference. Set by a provider that can
|
|
791
|
-
// write objects (AWS creates a BucketObject
|
|
792
|
-
// the fields stay Inline. `bytes` is the
|
|
793
|
-
// the provider hashes it, stores it at
|
|
791
|
+
// write objects (AWS creates a BucketObject, local writes to its process-local
|
|
792
|
+
// object store); when unset the fields stay Inline. `bytes` is the
|
|
793
|
+
// already-serialized JSON of the value; the provider hashes it, stores it at
|
|
794
|
+
// `sha256/<hash>`, and returns the ref.
|
|
794
795
|
// Fired synchronously at graph-construction time (the value is concrete and a
|
|
795
796
|
// resource cannot be created inside a Pulumi.Output.apply).
|
|
796
797
|
// ---------------------------------------------------------------------------
|
|
@@ -806,6 +807,23 @@ let clearOffload = () => {
|
|
|
806
807
|
offloadHook.contents = None
|
|
807
808
|
}
|
|
808
809
|
|
|
810
|
+
/**
|
|
811
|
+
Carry a large `pluginDefinition` field by reference when a provider registered an
|
|
812
|
+
offload hook, inline otherwise.
|
|
813
|
+
|
|
814
|
+
One implementation for both construction sites — `Plugin_Builder`'s deploy-time
|
|
815
|
+
definition and the local platform's synthetic one — so a provider that offloads
|
|
816
|
+
cannot end up offloading through one path and inlining through the other.
|
|
817
|
+
*/
|
|
818
|
+
let offloadPayload = (value: 'a, ~schema: S.t<'a>, ~store: string): Reventless.Offload.payload<'a> =>
|
|
819
|
+
switch offloadHook.contents {
|
|
820
|
+
| Some(hook) =>
|
|
821
|
+
Reventless.Offload.Offloaded(
|
|
822
|
+
hook(~store, ~bytes=value->S.reverseConvertToJsonStringOrThrow(schema)),
|
|
823
|
+
)
|
|
824
|
+
| None => Reventless.Offload.Inline(value)
|
|
825
|
+
}
|
|
826
|
+
|
|
809
827
|
// ---------------------------------------------------------------------------
|
|
810
828
|
// Platform-deployed hook — fires after deployPlatform / makePlatform
|
|
811
829
|
// completes, providing platform-level metadata with resolved values.
|
|
@@ -430,6 +430,21 @@ function clearOffload() {
|
|
|
430
430
|
offloadHook.contents = undefined;
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
function offloadPayload(value, schema, store) {
|
|
434
|
+
let hook = offloadHook.contents;
|
|
435
|
+
if (hook !== undefined) {
|
|
436
|
+
return {
|
|
437
|
+
TAG: "Offloaded",
|
|
438
|
+
_0: hook(store, S.reverseConvertToJsonStringOrThrow(value, schema, undefined))
|
|
439
|
+
};
|
|
440
|
+
} else {
|
|
441
|
+
return {
|
|
442
|
+
TAG: "Inline",
|
|
443
|
+
_0: value
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
433
448
|
let onPlatformDeployedHook = {
|
|
434
449
|
contents: undefined
|
|
435
450
|
};
|
|
@@ -1030,6 +1045,7 @@ export {
|
|
|
1030
1045
|
offloadHook,
|
|
1031
1046
|
registerOffload,
|
|
1032
1047
|
clearOffload,
|
|
1048
|
+
offloadPayload,
|
|
1033
1049
|
onPlatformDeployedHook,
|
|
1034
1050
|
lastPlatformDeployedInfo,
|
|
1035
1051
|
registerOnPlatformDeployed,
|
package/src/util/LogFormat.res
CHANGED
|
@@ -28,6 +28,41 @@
|
|
|
28
28
|
// styling primitive through `LogFormat.bold` rather than the spec namespace.
|
|
29
29
|
let bold = Reventless.AnsiStyle.bold
|
|
30
30
|
|
|
31
|
+
// ─── Value truncation ────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
@val external logEnv: dict<string> = "process.env"
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
Per-value character budget for rendered payloads, before the rest is elided.
|
|
37
|
+
|
|
38
|
+
A detail line exists to say *which* command or event this is, not to reproduce
|
|
39
|
+
it: a single field can be arbitrarily large (a plugin structure, a pasted
|
|
40
|
+
document, an inline offload payload), and one of those turns a log into
|
|
41
|
+
something no one reads. Truncation is per value rather than per line so a large
|
|
42
|
+
field costs its neighbours nothing — the small fields that identify the message
|
|
43
|
+
stay visible next to it.
|
|
44
|
+
|
|
45
|
+
`REVENTLESS_LOG_MAX_VALUE` overrides the budget; `0` disables truncation for a
|
|
46
|
+
session that genuinely needs the whole payload.
|
|
47
|
+
*/
|
|
48
|
+
let maxValueChars =
|
|
49
|
+
logEnv
|
|
50
|
+
->Dict.get("REVENTLESS_LOG_MAX_VALUE")
|
|
51
|
+
->Option.flatMap(v => Int.fromString(v))
|
|
52
|
+
->Option.getOr(512)
|
|
53
|
+
|
|
54
|
+
/** `s` capped at `maxValueChars`, with what was dropped named rather than
|
|
55
|
+
silently missing — a truncated line must not read like a complete one. */
|
|
56
|
+
let truncate = (s: string): string => {
|
|
57
|
+
let len = s->String.length
|
|
58
|
+
if maxValueChars <= 0 || len <= maxValueChars {
|
|
59
|
+
s
|
|
60
|
+
} else {
|
|
61
|
+
`${s->String.slice(~start=0, ~end=maxValueChars)}…(+${(len - maxValueChars)
|
|
62
|
+
->Int.toString} chars)`
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
// ─── Commands ────────────────────────────────────────────────────────────────
|
|
32
67
|
|
|
33
68
|
// "Add" (bold)
|
|
@@ -46,6 +81,16 @@ let cmdSummary = (msg: Message.commandJson): string =>
|
|
|
46
81
|
let cmdSummaries = (msgs: array<Message.commandJson>): string =>
|
|
47
82
|
`[${msgs->Array.map(cmdSummary)->Array.join(",")}]`
|
|
48
83
|
|
|
84
|
+
/**
|
|
85
|
+
`JSON.stringify` is typed as total but returns `undefined` — not a string — for
|
|
86
|
+
the one value `JSON.t` cannot represent: the `undefined` a ReScript record's
|
|
87
|
+
*absent* optional field leaves behind as an own property of the JS object. Read
|
|
88
|
+
the result as nullable so an absent field is dropped instead of reaching a
|
|
89
|
+
string function as `undefined`. An explicit `null` still stringifies to `"null"`
|
|
90
|
+
and keeps printing.
|
|
91
|
+
*/
|
|
92
|
+
external stringifiedOrAbsent: string => Nullable.t<string> = "%identity"
|
|
93
|
+
|
|
49
94
|
// Strip TAG from a variant JSON, return remaining fields as a compact string.
|
|
50
95
|
// {"TAG":"Add","name":"Cat 1"} → {name:"Cat 1"}
|
|
51
96
|
// "Add" (bare string variant) → ""
|
|
@@ -56,7 +101,13 @@ let variantFields = (json: JSON.t): string =>
|
|
|
56
101
|
dict
|
|
57
102
|
->Dict.toArray
|
|
58
103
|
->Array.filter(((k, _)) => k != "TAG")
|
|
59
|
-
->Array.
|
|
104
|
+
->Array.filterMap(((k, v)) =>
|
|
105
|
+
v
|
|
106
|
+
->JSON.stringify
|
|
107
|
+
->stringifiedOrAbsent
|
|
108
|
+
->Nullable.toOption
|
|
109
|
+
->Option.map(s => `${k}:${s->truncate}`)
|
|
110
|
+
)
|
|
60
111
|
->Array.join(",")
|
|
61
112
|
fields == "" ? "" : `, {${fields}}`
|
|
62
113
|
| _ => ""
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
|
|
3
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";
|
|
4
6
|
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
5
7
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
8
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
6
9
|
import * as AnsiStyle$Reventless from "@reventlessdev/reventless-spec/src/AnsiStyle.res.mjs";
|
|
7
10
|
import * as Message$ReventlessCore from "../Message.res.mjs";
|
|
8
11
|
|
|
12
|
+
let maxValueChars = Stdlib_Option.getOr(Stdlib_Option.flatMap(process.env["REVENTLESS_LOG_MAX_VALUE"], v => Stdlib_Int.fromString(v, undefined)), 512);
|
|
13
|
+
|
|
14
|
+
function truncate(s) {
|
|
15
|
+
let len = s.length;
|
|
16
|
+
if (maxValueChars <= 0 || len <= maxValueChars) {
|
|
17
|
+
return s;
|
|
18
|
+
} else {
|
|
19
|
+
return s.slice(0, maxValueChars) + `…(+` + (len - maxValueChars | 0).toString() + ` chars)`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
function cmdName(msg) {
|
|
10
24
|
return AnsiStyle$Reventless.bold(Message$ReventlessCore.variantNameOfJson(msg.commandJson));
|
|
11
25
|
}
|
|
@@ -26,7 +40,10 @@ function variantFields(json) {
|
|
|
26
40
|
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
|
27
41
|
return "";
|
|
28
42
|
}
|
|
29
|
-
let fields = Object.entries(json).filter(param => param[0] !== "TAG")
|
|
43
|
+
let fields = Stdlib_Array.filterMap(Object.entries(json).filter(param => param[0] !== "TAG"), param => {
|
|
44
|
+
let k = param[0];
|
|
45
|
+
return Stdlib_Option.map(Primitive_option.fromNullable(JSON.stringify(param[1])), s => k + `:` + truncate(s));
|
|
46
|
+
}).join(",");
|
|
30
47
|
if (fields === "") {
|
|
31
48
|
return "";
|
|
32
49
|
} else {
|
|
@@ -220,6 +237,8 @@ let fmtActions = actionNames;
|
|
|
220
237
|
|
|
221
238
|
export {
|
|
222
239
|
bold,
|
|
240
|
+
maxValueChars,
|
|
241
|
+
truncate,
|
|
223
242
|
cmdName,
|
|
224
243
|
cmdNames,
|
|
225
244
|
cmdSummary,
|
|
@@ -254,4 +273,4 @@ export {
|
|
|
254
273
|
fmtAction,
|
|
255
274
|
fmtActions,
|
|
256
275
|
}
|
|
257
|
-
/*
|
|
276
|
+
/* maxValueChars Not a pure module */
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Where a declared object store's keys are rooted.
|
|
2
|
+
//
|
|
3
|
+
// Lives in core beside StorePrefixCollision, and for the same reason: the rule
|
|
4
|
+
// is about a namespace, not about a cloud. The deployed platform roots an S3
|
|
5
|
+
// store's keys here, the local platform roots its filesystem store's keys here,
|
|
6
|
+
// and a reset attributes an object to the plugin that declared its store by
|
|
7
|
+
// reading the prefix back off the key. Three readers of one rule — so it is one
|
|
8
|
+
// function, not three strings that happen to agree today.
|
|
9
|
+
//
|
|
10
|
+
// Qualifying by plugin is what makes the prefix an identity rather than a label:
|
|
11
|
+
// two plugins may both declare a store called `images`, and only the qualified
|
|
12
|
+
// form keeps their objects apart (which is the collision StorePrefixCollision
|
|
13
|
+
// refuses when it cannot).
|
|
14
|
+
|
|
15
|
+
/** The prefix a store's object keys are rooted at: `{plugin}/{store}`.
|
|
16
|
+
|
|
17
|
+
A stored ref is independent of whether the store got its own bucket or a
|
|
18
|
+
prefix inside a shared one — refs live in an append-only log, so one that
|
|
19
|
+
encoded its physical layout would be unrewritable and environment-specific. */
|
|
20
|
+
let keyPrefixFor = (~plugin: string, ~store: string): string => `${plugin}/${store}`
|
|
21
|
+
|
|
22
|
+
/** The same prefix from the qualified `{plugin}.{store}` key that a
|
|
23
|
+
`pluginStructure.requiredStores` entry carries and that the `Upload_Presign`
|
|
24
|
+
mutation's `store` argument names.
|
|
25
|
+
|
|
26
|
+
Splits on the FIRST `.` — a store name may contain dots, a plugin name may
|
|
27
|
+
not. An unqualified key (no `.`) is returned as-is rather than guessed at:
|
|
28
|
+
it is not a `{plugin}.{store}` and inventing a plugin for it would attribute
|
|
29
|
+
objects to a plugin that never declared them. */
|
|
30
|
+
let prefixOfQualified = (qualified: string): string =>
|
|
31
|
+
switch qualified->String.indexOfOpt(".") {
|
|
32
|
+
| Some(i) =>
|
|
33
|
+
keyPrefixFor(
|
|
34
|
+
~plugin=qualified->String.slice(~start=0, ~end=i),
|
|
35
|
+
~store=qualified->String.slice(~start=i + 1, ~end=qualified->String.length),
|
|
36
|
+
)
|
|
37
|
+
| None => qualified
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
|
|
4
|
+
|
|
5
|
+
function keyPrefixFor(plugin, store) {
|
|
6
|
+
return plugin + `/` + store;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function prefixOfQualified(qualified) {
|
|
10
|
+
let i = Stdlib_String.indexOfOpt(qualified, ".");
|
|
11
|
+
if (i !== undefined) {
|
|
12
|
+
return keyPrefixFor(qualified.slice(0, i), qualified.slice(i + 1 | 0, qualified.length));
|
|
13
|
+
} else {
|
|
14
|
+
return qualified;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
keyPrefixFor,
|
|
20
|
+
prefixOfQualified,
|
|
21
|
+
}
|
|
22
|
+
/* No side effect */
|
|
@@ -47,3 +47,43 @@ describe("GraphQL_FragmentGenerator.generate mutation id injection", () => {
|
|
|
47
47
|
expect(field->String.includes("id: ID!, orderId"))->toBe(true)
|
|
48
48
|
})
|
|
49
49
|
})
|
|
50
|
+
|
|
51
|
+
// A slice TODO row stores its work item as `JSON.t`, which the IR can only call
|
|
52
|
+
// `Unknown` — and `Unknown` renders as `String!`. Serving a stored object
|
|
53
|
+
// through a String field fails at execution ("String cannot represent value:
|
|
54
|
+
// { … }") as soon as a client selects it, so `Dcb_Builder` describes the row
|
|
55
|
+
// with `todoRowSchemaFor` and the item's own schema instead.
|
|
56
|
+
module GeocodeItem = {
|
|
57
|
+
@schema
|
|
58
|
+
type outboundItem = {customerId: string, address: string}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let typeDefFor = (fragment, needle) =>
|
|
62
|
+
GraphQL_Stitcher.decode(fragment).types->Array.find(t => t->String.includes(needle))
|
|
63
|
+
|
|
64
|
+
describe("GraphQL_FragmentGenerator slice TODO rows", () => {
|
|
65
|
+
testSync("item carries the slice's item type, not String", () => {
|
|
66
|
+
let fragment = GraphQL_FragmentGenerator.generate(
|
|
67
|
+
~mutationEntries=[],
|
|
68
|
+
~queryEntries=[
|
|
69
|
+
{
|
|
70
|
+
ReventlessInfra.Api.singleFieldName: "Ordering_GeocodeCustomerAddressTodo",
|
|
71
|
+
listFieldName: "Ordering_GeocodeCustomerAddressTodos",
|
|
72
|
+
returnTypeName: "Ordering_GeocodeCustomerAddressTodo",
|
|
73
|
+
stateSchema: OutboundTranslationSlice_Callback.todoRowSchemaFor(
|
|
74
|
+
GeocodeItem.outboundItemSchema,
|
|
75
|
+
)->S.castToUnknown,
|
|
76
|
+
authorization: None,
|
|
77
|
+
connectionSpec: true,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
let row = typeDefFor(fragment, "type Ordering_GeocodeCustomerAddressTodo ")->Option.getOr("")
|
|
82
|
+
expect(row->String.includes("item: Ordering_GeocodeCustomerAddressTodoItem!"))->toBe(true)
|
|
83
|
+
|
|
84
|
+
let item =
|
|
85
|
+
typeDefFor(fragment, "type Ordering_GeocodeCustomerAddressTodoItem ")->Option.getOr("")
|
|
86
|
+
expect(item->String.includes("customerId: ID!"))->toBe(true)
|
|
87
|
+
expect(item->String.includes("address: String!"))->toBe(true)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -4,6 +4,7 @@ import * as S from "sury/src/S.res.mjs";
|
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as GraphQL_Stitcher$ReventlessCore from "../../src/components/Api/GraphQL_Stitcher.res.mjs";
|
|
6
6
|
import * as GraphQL_FragmentGenerator$ReventlessCore from "../../src/components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
7
|
+
import * as OutboundTranslationSlice_Callback$ReventlessCore from "../../src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs";
|
|
7
8
|
|
|
8
9
|
let commandSchema = S.union([
|
|
9
10
|
S.schema(s => ({
|
|
@@ -50,8 +51,42 @@ globalThis.describe("GraphQL_FragmentGenerator.generate mutation id injection",
|
|
|
50
51
|
});
|
|
51
52
|
});
|
|
52
53
|
|
|
54
|
+
let outboundItemSchema = S.schema(s => ({
|
|
55
|
+
customerId: s.m(S.string),
|
|
56
|
+
address: s.m(S.string)
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
let GeocodeItem = {
|
|
60
|
+
outboundItemSchema: outboundItemSchema,
|
|
61
|
+
externalSystem: undefined
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function typeDefFor(fragment, needle) {
|
|
65
|
+
return GraphQL_Stitcher$ReventlessCore.decode(fragment).types.find(t => t.includes(needle));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
globalThis.describe("GraphQL_FragmentGenerator slice TODO rows", () => {
|
|
69
|
+
globalThis.test("item carries the slice's item type, not String", () => {
|
|
70
|
+
let fragment = GraphQL_FragmentGenerator$ReventlessCore.generate([], [{
|
|
71
|
+
singleFieldName: "Ordering_GeocodeCustomerAddressTodo",
|
|
72
|
+
listFieldName: "Ordering_GeocodeCustomerAddressTodos",
|
|
73
|
+
returnTypeName: "Ordering_GeocodeCustomerAddressTodo",
|
|
74
|
+
stateSchema: OutboundTranslationSlice_Callback$ReventlessCore.todoRowSchemaFor(outboundItemSchema),
|
|
75
|
+
authorization: undefined,
|
|
76
|
+
connectionSpec: true
|
|
77
|
+
}]);
|
|
78
|
+
let row = Stdlib_Option.getOr(typeDefFor(fragment, "type Ordering_GeocodeCustomerAddressTodo "), "");
|
|
79
|
+
globalThis.expect(row.includes("item: Ordering_GeocodeCustomerAddressTodoItem!")).toBe(true);
|
|
80
|
+
let item = Stdlib_Option.getOr(typeDefFor(fragment, "type Ordering_GeocodeCustomerAddressTodoItem "), "");
|
|
81
|
+
globalThis.expect(item.includes("customerId: ID!")).toBe(true);
|
|
82
|
+
globalThis.expect(item.includes("address: String!")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
53
86
|
export {
|
|
54
87
|
OrderSliceCmd,
|
|
55
88
|
mutationFor,
|
|
89
|
+
GeocodeItem,
|
|
90
|
+
typeDefFor,
|
|
56
91
|
}
|
|
57
92
|
/* commandSchema Not a pure module */
|
|
@@ -127,6 +127,48 @@ describe("LogFormat", () => {
|
|
|
127
127
|
},
|
|
128
128
|
)
|
|
129
129
|
})
|
|
130
|
+
|
|
131
|
+
// A ReScript record's absent optional field survives encoding as an own
|
|
132
|
+
// property whose value is `undefined` — the one thing `JSON.t` cannot hold and
|
|
133
|
+
// `JSON.stringify` answers with `undefined` instead of a string. Rendering a
|
|
134
|
+
// command carrying one must not reach a string function with that `undefined`:
|
|
135
|
+
// `variantFields` runs inside the CommandGenerator's log tap, so a throw there
|
|
136
|
+
// takes the command down with it and the slice never sees it.
|
|
137
|
+
describe("variantFields", () => {
|
|
138
|
+
module OptionalFieldCmd = {
|
|
139
|
+
@schema
|
|
140
|
+
type command = AddProduct({productId: string, name: string, imageUrl?: string})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
testSync("omits an absent optional field", () => {
|
|
144
|
+
let json =
|
|
145
|
+
OptionalFieldCmd.AddProduct({productId: "prd-1", name: "Cirrus"})
|
|
146
|
+
->Message.encode(OptionalFieldCmd.commandSchema)
|
|
147
|
+
expect(variantFields(json))->toEqual(`, {productId:"prd-1",name:"Cirrus"}`)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
testSync("keeps a present optional field", () => {
|
|
151
|
+
let json =
|
|
152
|
+
OptionalFieldCmd.AddProduct({productId: "prd-1", name: "Cirrus", imageUrl: "/u/a.png"})
|
|
153
|
+
->Message.encode(OptionalFieldCmd.commandSchema)
|
|
154
|
+
expect(variantFields(json))->toEqual(
|
|
155
|
+
`, {productId:"prd-1",name:"Cirrus",imageUrl:"/u/a.png"}`,
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
testSync("elides a value past the budget rather than dropping it", () => {
|
|
160
|
+
let long = String.repeat("x", maxValueChars + 20)
|
|
161
|
+
let json =
|
|
162
|
+
OptionalFieldCmd.AddProduct({productId: "prd-1", name: long})
|
|
163
|
+
->Message.encode(OptionalFieldCmd.commandSchema)
|
|
164
|
+
let rendered = variantFields(json)
|
|
165
|
+
expect(rendered->String.includes("…(+22 chars)"))->toBe(true)
|
|
166
|
+
// The oversized value is capped, not the line: the neighbouring field that
|
|
167
|
+
// says which product this is stays intact.
|
|
168
|
+
expect(rendered->String.includes(long))->toBe(false)
|
|
169
|
+
expect(rendered->String.includes(`productId:"prd-1"`))->toBe(true)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
130
172
|
})
|
|
131
173
|
|
|
132
174
|
// ─── JSON sink (non-TTY collector: CloudWatch / Datadog / …) ─────────────────
|
|
@@ -124,6 +124,43 @@ globalThis.describe("LogFormat", () => {
|
|
|
124
124
|
globalThis.expect(msg).toEqual(`\x1b[1mVersionDetected\x1b[0m(testId): {"event":{"TAG":"VersionDetected","_0":"0.0.0"},"meta":{"service":"testService","time":"testTime","ip":"testIp","user":"testUser","msgId":"testMsgId","correlationId":"testCorrelationId"},"id":"testId"}`);
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
|
+
globalThis.describe("variantFields", () => {
|
|
128
|
+
let commandSchema = S.schema(s => ({
|
|
129
|
+
TAG: "AddProduct",
|
|
130
|
+
productId: s.m(S.string),
|
|
131
|
+
name: s.m(S.string),
|
|
132
|
+
imageUrl: s.m(S.option(S.string))
|
|
133
|
+
}));
|
|
134
|
+
globalThis.test("omits an absent optional field", () => {
|
|
135
|
+
let json = Message$ReventlessCore.encode({
|
|
136
|
+
TAG: "AddProduct",
|
|
137
|
+
productId: "prd-1",
|
|
138
|
+
name: "Cirrus"
|
|
139
|
+
}, commandSchema);
|
|
140
|
+
globalThis.expect(LogFormat$ReventlessCore.variantFields(json)).toEqual(`, {productId:"prd-1",name:"Cirrus"}`);
|
|
141
|
+
});
|
|
142
|
+
globalThis.test("keeps a present optional field", () => {
|
|
143
|
+
let json = Message$ReventlessCore.encode({
|
|
144
|
+
TAG: "AddProduct",
|
|
145
|
+
productId: "prd-1",
|
|
146
|
+
name: "Cirrus",
|
|
147
|
+
imageUrl: "/u/a.png"
|
|
148
|
+
}, commandSchema);
|
|
149
|
+
globalThis.expect(LogFormat$ReventlessCore.variantFields(json)).toEqual(`, {productId:"prd-1",name:"Cirrus",imageUrl:"/u/a.png"}`);
|
|
150
|
+
});
|
|
151
|
+
globalThis.test("elides a value past the budget rather than dropping it", () => {
|
|
152
|
+
let long = "x".repeat(LogFormat$ReventlessCore.maxValueChars + 20 | 0);
|
|
153
|
+
let json = Message$ReventlessCore.encode({
|
|
154
|
+
TAG: "AddProduct",
|
|
155
|
+
productId: "prd-1",
|
|
156
|
+
name: long
|
|
157
|
+
}, commandSchema);
|
|
158
|
+
let rendered = LogFormat$ReventlessCore.variantFields(json);
|
|
159
|
+
globalThis.expect(rendered.includes("…(+22 chars)")).toBe(true);
|
|
160
|
+
globalThis.expect(rendered.includes(long)).toBe(false);
|
|
161
|
+
globalThis.expect(rendered.includes(`productId:"prd-1"`)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
127
164
|
});
|
|
128
165
|
|
|
129
166
|
function hasAnsi(s) {
|