@reventlessdev/reventless-core 3.0.0-alpha.243 → 3.0.0-alpha.245
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 +14 -0
- package/package.json +2 -2
- package/src/components/Api/GraphQL_Stitcher.res +11 -9
- package/src/components/Api/GraphQL_Stitcher.res.mjs +12 -3
- package/src/components/QueryDb/QueryDb_Operations.res +45 -1
- package/src/components/QueryDb/QueryDb_Operations.res.mjs +26 -4
- package/tests/api/GraphQL_StitcherTest.res +45 -0
- package/tests/api/GraphQL_StitcherTest.res.mjs +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
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.245 (2026-08-20)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **core:** report a union value stored without its member type ([3a834b5](https://github.com/ReventlessDev/reventless-core/commit/3a834b51d1d9441f5dfcaebec5fac365063c905e))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.244 (2026-08-20)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **api:** name a union after itself, not after the keyword ([58ab956](https://github.com/ReventlessDev/reventless-core/commit/58ab956b8fa310c60f118595768e40e750d61784))
|
|
18
|
+
|
|
19
|
+
|
|
6
20
|
# 3.0.0-alpha.243 (2026-08-20)
|
|
7
21
|
|
|
8
22
|
### Features
|
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.245",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
|
|
38
|
+
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
38
39
|
"@reventlessdev/reventless-infra": "3.0.0-alpha.148",
|
|
39
40
|
"@reventlessdev/reventless-spec": "3.0.0-alpha.120",
|
|
40
|
-
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
41
41
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.32"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
@@ -122,17 +122,19 @@ let collectSubscriptionSources = (
|
|
|
122
122
|
// Extract the first identifier from a SDL field/type string.
|
|
123
123
|
// e.g. " fieldName(args): Type" → "fieldName"
|
|
124
124
|
// "type TypeName {" → "TypeName"
|
|
125
|
+
// Every SDL keyword that introduces a named definition. `union` was missing while
|
|
126
|
+
// `CommandResult` was the only union in the schema: a keyword left in place makes
|
|
127
|
+
// every declaration of that kind name itself after the keyword, so the second one
|
|
128
|
+
// deduped against the first and vanished — AppSync then rejected the schema for a
|
|
129
|
+
// type that was never emitted.
|
|
130
|
+
let definitionKeywords = ["type ", "enum ", "input ", "union ", "interface ", "scalar "]
|
|
131
|
+
|
|
125
132
|
let extractLeadingName = (str: string): string => {
|
|
126
133
|
let trimmed = str->String.trim
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
trimmed->String.slice(~start=
|
|
130
|
-
|
|
131
|
-
trimmed->String.slice(~start=5, ~end=trimmed->String.length)->String.trim
|
|
132
|
-
} else if trimmed->String.startsWith("input ") {
|
|
133
|
-
trimmed->String.slice(~start=6, ~end=trimmed->String.length)->String.trim
|
|
134
|
-
} else {
|
|
135
|
-
trimmed
|
|
134
|
+
let afterType = switch definitionKeywords->Array.find(kw => trimmed->String.startsWith(kw)) {
|
|
135
|
+
| Some(kw) =>
|
|
136
|
+
trimmed->String.slice(~start=kw->String.length, ~end=trimmed->String.length)->String.trim
|
|
137
|
+
| None => trimmed
|
|
136
138
|
}
|
|
137
139
|
// Split on "(" first to remove arg list, then on " ", "{", and ":" for the name.
|
|
138
140
|
// The ":" split is load-bearing for ARG-LESS fields: `Foo: [Bar!]!` has no "(" to
|
|
@@ -115,11 +115,19 @@ function collectSubscriptionSources(baseFragment, pluginFragments) {
|
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
let definitionKeywords = [
|
|
119
|
+
"type ",
|
|
120
|
+
"enum ",
|
|
121
|
+
"input ",
|
|
122
|
+
"union ",
|
|
123
|
+
"interface ",
|
|
124
|
+
"scalar "
|
|
125
|
+
];
|
|
126
|
+
|
|
118
127
|
function extractLeadingName(str) {
|
|
119
128
|
let trimmed = str.trim();
|
|
120
|
-
let
|
|
121
|
-
|
|
122
|
-
);
|
|
129
|
+
let kw = definitionKeywords.find(kw => trimmed.startsWith(kw));
|
|
130
|
+
let afterType = kw !== undefined ? trimmed.slice(kw.length, trimmed.length).trim() : trimmed;
|
|
123
131
|
return Stdlib_Option.getOr(Stdlib_Option.getOr(Stdlib_Option.getOr(Stdlib_Option.getOr(afterType.split("(")[0], "").trim().split(" ")[0], "").trim().split("{")[0], "").trim().split(":")[0], "").trim();
|
|
124
132
|
}
|
|
125
133
|
|
|
@@ -231,6 +239,7 @@ export {
|
|
|
231
239
|
encode,
|
|
232
240
|
decode,
|
|
233
241
|
collectSubscriptionSources,
|
|
242
|
+
definitionKeywords,
|
|
234
243
|
extractLeadingName,
|
|
235
244
|
relayBaseTypes,
|
|
236
245
|
relayBaseQueries,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
let log = Logger.fromEnv()
|
|
2
|
+
|
|
1
3
|
module type Ops = {
|
|
2
4
|
let jsonOps: QueryDb.operations<string, JSON.t>
|
|
3
5
|
}
|
|
@@ -72,11 +74,53 @@ module Make = (ReadModelSpec: Reventless.ReadModel.Spec, Ops: Ops) => {
|
|
|
72
74
|
// backends — and, unlike a read-time stamp, it is also on the row the live
|
|
73
75
|
// change channel hands over as raw JSON. A view with no union field is walked
|
|
74
76
|
// and left exactly as it was.
|
|
75
|
-
|
|
77
|
+
// The union fields this view declares, read once. Empty for most views, and
|
|
78
|
+
// the whole diagnostic below costs nothing when it is.
|
|
79
|
+
let declaredUnionFields =
|
|
80
|
+
Reventless.TaggedUnion.fieldsOf(ReadModelSpec.stateSchema->S.castToUnknown)
|
|
81
|
+
|
|
82
|
+
// Logged once per instantiation, and unconditionally: "none" has to be
|
|
83
|
+
// distinguishable from "never instantiated", because a schema that resolves no
|
|
84
|
+
// union fields and a stamp that failed to run leave the same stored row.
|
|
85
|
+
log.debug(
|
|
86
|
+
~comp="QueryDb",
|
|
87
|
+
`${ReadModelSpec.name}: union fields ${switch declaredUnionFields {
|
|
88
|
+
| [] => "none"
|
|
89
|
+
| fields =>
|
|
90
|
+
fields->Array.map(((f, n)) => `${f}:${n->Option.getOr("<unnamed>")}`)->Array.join(", ")
|
|
91
|
+
}}`,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
// A union value stored without `__typename` is the failure D1 exists to
|
|
95
|
+
// prevent, and it is silent: the member resolves to null, the null takes its
|
|
96
|
+
// non-nullable parent, and a list loses the row rather than erroring. Nothing
|
|
97
|
+
// downstream can tell that from "no such row", so it is reported here — the
|
|
98
|
+
// one place that knows both the schema and the row.
|
|
99
|
+
let reportUnstamped = dict =>
|
|
100
|
+
declaredUnionFields->Array.forEach(((field, unionName)) =>
|
|
101
|
+
switch dict->Dict.get(field)->Option.flatMap(JSON.Decode.object) {
|
|
102
|
+
| Some(value) if value->Dict.get("TAG")->Option.isSome =>
|
|
103
|
+
if value->Dict.get(Reventless.TaggedUnion.typenameKey)->Option.isNone {
|
|
104
|
+
log.warn(
|
|
105
|
+
~comp="QueryDb",
|
|
106
|
+
`${ReadModelSpec.name}.${field}: stored a union value with no ${Reventless.TaggedUnion.typenameKey} ` ++
|
|
107
|
+
`(union ${unionName->Option.getOr("<unnamed>")}). The field will resolve to null and take its ` ++
|
|
108
|
+
`parent with it. Union fields seen on this spec: ${declaredUnionFields
|
|
109
|
+
->Array.map(((f, _)) => f)
|
|
110
|
+
->Array.join(", ")}`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
| _ => ()
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
let stampUnionMembers = dict => {
|
|
76
118
|
Reventless.TaggedUnion.stampInto(
|
|
77
119
|
~schema=ReadModelSpec.stateSchema->S.castToUnknown,
|
|
78
120
|
JSON.Encode.object(dict),
|
|
79
121
|
)
|
|
122
|
+
reportUnstamped(dict)
|
|
123
|
+
}
|
|
80
124
|
|
|
81
125
|
let save = async (id, state, saveMode, ttl) =>
|
|
82
126
|
switch state->Message.encode(ReadModelSpec.stateSchema)->JSON.Decode.object {
|
|
@@ -7,9 +7,12 @@ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
|
7
7
|
import * as Effect from "effect/Effect";
|
|
8
8
|
import * as Stream from "effect/Stream";
|
|
9
9
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
10
|
+
import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
10
11
|
import * as Message$ReventlessCore from "../../Message.res.mjs";
|
|
11
12
|
import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
|
|
12
13
|
|
|
14
|
+
let log = Logger$ReventlessCore.fromEnv();
|
|
15
|
+
|
|
13
16
|
function Make(ReadModelSpec) {
|
|
14
17
|
return Ops => {
|
|
15
18
|
let decode = (id, stateJson) => {
|
|
@@ -77,14 +80,30 @@ function Make(ReadModelSpec) {
|
|
|
77
80
|
dict[attrName$1] = value$1;
|
|
78
81
|
});
|
|
79
82
|
};
|
|
80
|
-
let
|
|
83
|
+
let declaredUnionFields = TaggedUnion$Reventless.fieldsOf(ReadModelSpec.stateSchema);
|
|
84
|
+
log.debug("QueryDb", undefined, ReadModelSpec.name + `: union fields ` + (
|
|
85
|
+
declaredUnionFields.length !== 0 ? declaredUnionFields.map(param => param[0] + `:` + Stdlib_Option.getOr(param[1], "<unnamed>")).join(", ") : "none"
|
|
86
|
+
));
|
|
87
|
+
let reportUnstamped = dict => {
|
|
88
|
+
declaredUnionFields.forEach(param => {
|
|
89
|
+
let field = param[0];
|
|
90
|
+
let value = Stdlib_Option.flatMap(dict[field], Stdlib_JSON.Decode.object);
|
|
91
|
+
if (value !== undefined && Stdlib_Option.isSome(value["TAG"]) && Stdlib_Option.isNone(value[TaggedUnion$Reventless.typenameKey])) {
|
|
92
|
+
return log.warn("QueryDb", undefined, ReadModelSpec.name + `.` + field + `: stored a union value with no ` + TaggedUnion$Reventless.typenameKey + ` ` + (`(union ` + Stdlib_Option.getOr(param[1], "<unnamed>") + `). The field will resolve to null and take its `) + (`parent with it. Union fields seen on this spec: ` + declaredUnionFields.map(param => param[0]).join(", ")));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
let stampUnionMembers = dict => {
|
|
97
|
+
TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
|
|
98
|
+
reportUnstamped(dict);
|
|
99
|
+
};
|
|
81
100
|
let save = async (id, state, saveMode, ttl) => {
|
|
82
101
|
let dict = Stdlib_JSON.Decode.object(Message$ReventlessCore.encode(state, ReadModelSpec.stateSchema));
|
|
83
102
|
if (dict !== undefined) {
|
|
84
103
|
dict["id"] = Message$ReventlessCore.encode(id, ReadModelSpec.Id.schema);
|
|
85
104
|
injectSubId(dict, state);
|
|
86
105
|
injectCompositeIndexAttrs(dict);
|
|
87
|
-
|
|
106
|
+
stampUnionMembers(dict);
|
|
88
107
|
return await Ops.jsonOps.save(ReadModelSpec.Id.toString(id), dict, saveMode, ttl);
|
|
89
108
|
} else {
|
|
90
109
|
return {
|
|
@@ -110,7 +129,7 @@ function Make(ReadModelSpec) {
|
|
|
110
129
|
dict["id"] = Message$ReventlessCore.encode(id, ReadModelSpec.Id.schema);
|
|
111
130
|
injectSubId(dict, state);
|
|
112
131
|
injectCompositeIndexAttrs(dict);
|
|
113
|
-
|
|
132
|
+
stampUnionMembers(dict);
|
|
114
133
|
return {
|
|
115
134
|
TAG: "Ok",
|
|
116
135
|
_0: batch.concat([[
|
|
@@ -154,6 +173,8 @@ function Make(ReadModelSpec) {
|
|
|
154
173
|
load: load,
|
|
155
174
|
injectSubId: injectSubId,
|
|
156
175
|
injectCompositeIndexAttrs: injectCompositeIndexAttrs,
|
|
176
|
+
declaredUnionFields: declaredUnionFields,
|
|
177
|
+
reportUnstamped: reportUnstamped,
|
|
157
178
|
stampUnionMembers: stampUnionMembers,
|
|
158
179
|
save: save,
|
|
159
180
|
saveBatch: saveBatch,
|
|
@@ -165,6 +186,7 @@ function Make(ReadModelSpec) {
|
|
|
165
186
|
}
|
|
166
187
|
|
|
167
188
|
export {
|
|
189
|
+
log,
|
|
168
190
|
Make,
|
|
169
191
|
}
|
|
170
|
-
/*
|
|
192
|
+
/* log Not a pure module */
|
|
@@ -64,6 +64,51 @@ describe("GraphQL_Stitcher.subscriptionSources", () => {
|
|
|
64
64
|
})
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
+
// Two unions in one schema is what caught this: every definition keyword the name
|
|
68
|
+
// extractor does not strip makes each declaration of that kind name itself after
|
|
69
|
+
// the keyword, so the second dedupes against the first and is dropped. With
|
|
70
|
+
// `CommandResult` the only union, nothing showed it. The local path does not use
|
|
71
|
+
// this dedup, so a golden carrying both unions still passed while AppSync rejected
|
|
72
|
+
// the schema for a type that was never emitted.
|
|
73
|
+
describe("GraphQL_Stitcher.extractLeadingName", () => {
|
|
74
|
+
testSync("names a union after the union, not after the keyword", () => {
|
|
75
|
+
expect(extractLeadingName("union Geolocation = GeolocationLocated | GeolocationPending"))->toBe(
|
|
76
|
+
"Geolocation",
|
|
77
|
+
)
|
|
78
|
+
expect(extractLeadingName("union CommandResult = CommandAccepted | CommandRejected"))->toBe(
|
|
79
|
+
"CommandResult",
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
testSync("names an interface and a scalar after themselves", () => {
|
|
84
|
+
expect(extractLeadingName("interface Node { id: ID! }"))->toBe("Node")
|
|
85
|
+
expect(extractLeadingName("scalar AWSDateTime"))->toBe("AWSDateTime")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// The cases that already worked, kept so a rewrite of the keyword list has to
|
|
89
|
+
// keep them working.
|
|
90
|
+
testSync("still names types, enums and inputs", () => {
|
|
91
|
+
expect(extractLeadingName("type Ordering_Customer implements Node { id: ID! }"))->toBe(
|
|
92
|
+
"Ordering_Customer",
|
|
93
|
+
)
|
|
94
|
+
expect(extractLeadingName("enum SortOrder { ASC DESC }"))->toBe("SortOrder")
|
|
95
|
+
expect(extractLeadingName("input Ordering_CustomerFilter { id: ID }"))->toBe(
|
|
96
|
+
"Ordering_CustomerFilter",
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Fields go through the same extractor, and an arg-less one has no "(" to cleave
|
|
101
|
+
// the type off.
|
|
102
|
+
testSync("still names fields, with and without args", () => {
|
|
103
|
+
expect(extractLeadingName(" Catalog_AddProduct(input: AddInput): CommandResult"))->toBe(
|
|
104
|
+
"Catalog_AddProduct",
|
|
105
|
+
)
|
|
106
|
+
expect(extractLeadingName(" Platform_ApiFragments: [Fragment!]!"))->toBe(
|
|
107
|
+
"Platform_ApiFragments",
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
67
112
|
describe("GraphQL_Stitcher.stitchStandalone", () => {
|
|
68
113
|
let pluginFragment = encode({
|
|
69
114
|
types: [
|
|
@@ -77,6 +77,26 @@ globalThis.describe("GraphQL_Stitcher.subscriptionSources", () => {
|
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
globalThis.describe("GraphQL_Stitcher.extractLeadingName", () => {
|
|
81
|
+
globalThis.test("names a union after the union, not after the keyword", () => {
|
|
82
|
+
globalThis.expect(GraphQL_Stitcher$ReventlessCore.extractLeadingName("union Geolocation = GeolocationLocated | GeolocationPending")).toBe("Geolocation");
|
|
83
|
+
globalThis.expect(GraphQL_Stitcher$ReventlessCore.extractLeadingName("union CommandResult = CommandAccepted | CommandRejected")).toBe("CommandResult");
|
|
84
|
+
});
|
|
85
|
+
globalThis.test("names an interface and a scalar after themselves", () => {
|
|
86
|
+
globalThis.expect(GraphQL_Stitcher$ReventlessCore.extractLeadingName("interface Node { id: ID! }")).toBe("Node");
|
|
87
|
+
globalThis.expect(GraphQL_Stitcher$ReventlessCore.extractLeadingName("scalar AWSDateTime")).toBe("AWSDateTime");
|
|
88
|
+
});
|
|
89
|
+
globalThis.test("still names types, enums and inputs", () => {
|
|
90
|
+
globalThis.expect(GraphQL_Stitcher$ReventlessCore.extractLeadingName("type Ordering_Customer implements Node { id: ID! }")).toBe("Ordering_Customer");
|
|
91
|
+
globalThis.expect(GraphQL_Stitcher$ReventlessCore.extractLeadingName("enum SortOrder { ASC DESC }")).toBe("SortOrder");
|
|
92
|
+
globalThis.expect(GraphQL_Stitcher$ReventlessCore.extractLeadingName("input Ordering_CustomerFilter { id: ID }")).toBe("Ordering_CustomerFilter");
|
|
93
|
+
});
|
|
94
|
+
globalThis.test("still names fields, with and without args", () => {
|
|
95
|
+
globalThis.expect(GraphQL_Stitcher$ReventlessCore.extractLeadingName(" Catalog_AddProduct(input: AddInput): CommandResult")).toBe("Catalog_AddProduct");
|
|
96
|
+
globalThis.expect(GraphQL_Stitcher$ReventlessCore.extractLeadingName(" Platform_ApiFragments: [Fragment!]!")).toBe("Platform_ApiFragments");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
80
100
|
globalThis.describe("GraphQL_Stitcher.stitchStandalone", () => {
|
|
81
101
|
let pluginFragment = GraphQL_Stitcher$ReventlessCore.encode({
|
|
82
102
|
types: [
|