@reventlessdev/reventless-aws 3.0.0-alpha.302 → 3.0.0-alpha.304
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 +17 -0
- package/package.json +8 -8
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +21 -0
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +20 -0
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +41 -3
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +27 -6
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +34 -4
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +16 -5
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +12 -0
- package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res +10 -0
- package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res.mjs +4 -1
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +16 -2
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +4 -2
- package/src/adapter/Runtime/StateTopicPublish.mjs +38 -5
- package/src/adapter/StateTopic/StateTopic_AppSync.res +49 -1
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +28 -3
- package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res +73 -2
- package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res.mjs +39 -4
- package/tests/PgQueryResolver_LambdaTest.res +16 -1
- package/tests/PgQueryResolver_LambdaTest.res.mjs +46 -32
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res +65 -0
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res.mjs +36 -0
- package/tests/QueryDbResolvers_AppSyncTest.res.mjs +3 -3
- package/tests/StateChangeDescriptorParityTest.res +82 -1
- package/tests/StateChangeDescriptorParityTest.res.mjs +97 -1
- package/tests/stateChangeDescriptorParity.mjs +72 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
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.304 (2026-08-16)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **aws:** ship the DCB slice registry in the archive, not the environment ([3cda4cb](https://github.com/ReventlessDev/reventless-core/commit/3cda4cb780848598a436ab8e80843cf15a4d22b6))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.303 (2026-08-16)
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **core:** exclude retired rows from reads a caller may not widen ([662f31a](https://github.com/ReventlessDev/reventless-core/commit/662f31abb717bda5154199d349da6dcf8e2d3e78))
|
|
18
|
+
* **core:** let [@retired](https://github.com/retired) name a lifecycle state, not only a boolean ([6bb346b](https://github.com/ReventlessDev/reventless-core/commit/6bb346b4f6a5f33826fc24537953482a76067177))
|
|
19
|
+
* **core:** mark the state that retires a row, and allow more than one ([cb1461f](https://github.com/ReventlessDev/reventless-core/commit/cb1461f024d3ca3b53fd9c8b010a054e3fcc4555))
|
|
20
|
+
* **core:** withhold a retired row's payload from the live channel ([82c62db](https://github.com/ReventlessDev/reventless-core/commit/82c62db00b87f7135378496e36601b544d4d2b62))
|
|
21
|
+
|
|
22
|
+
|
|
6
23
|
# 3.0.0-alpha.302 (2026-08-15)
|
|
7
24
|
|
|
8
25
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-aws",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.304",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
"@aws-sdk/s3-request-presigner": "3.970.0",
|
|
13
13
|
"sury": "11.0.0-alpha.4",
|
|
14
14
|
"uuid": "^13.0.0",
|
|
15
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
16
15
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
17
|
-
"@reventlessdev/rescript-
|
|
16
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
18
17
|
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
|
|
19
|
-
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.
|
|
18
|
+
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.77",
|
|
19
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.7",
|
|
20
20
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
21
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.237",
|
|
21
22
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
22
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
23
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.236",
|
|
23
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.143",
|
|
24
24
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.31",
|
|
25
|
-
"@reventlessdev/reventless-postgres": "3.0.0-alpha.
|
|
26
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
25
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.101",
|
|
26
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.115"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"rescript": "12.3.0",
|
|
@@ -90,6 +90,24 @@ let mapMembers = (obj: dict<JSON.t>, key: string, f: dict<JSON.t> => unit): unit
|
|
|
90
90
|
)
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// Read-path shim for the `statusField` → `lifecycleField` rename. A structure
|
|
94
|
+
// persisted before the rename carries only the old key, and this handler serves
|
|
95
|
+
// raw JSON, so the resolver would find no `lifecycleField` and answer null until
|
|
96
|
+
// the plugin re-registers. That is not a crash — the SDL declares a nullable
|
|
97
|
+
// `String`, not a `[T!]!` that would null-propagate to the root — but it is a
|
|
98
|
+
// silent degradation: command-menu filtering, board columns, group sections,
|
|
99
|
+
// progress trackers and state diagrams go quiet while lists keep rendering.
|
|
100
|
+
//
|
|
101
|
+
// Delete a release later, once no structure predating the rename can be reached.
|
|
102
|
+
// It closes a window; it does not support two names.
|
|
103
|
+
let fillLifecycleField = (component: dict<JSON.t>): unit =>
|
|
104
|
+
switch (component->Dict.get("lifecycleField"), component->Dict.get("statusField")) {
|
|
105
|
+
| (None | Some(JSON.Null), Some(legacy)) => component->Dict.set("lifecycleField", legacy)
|
|
106
|
+
| _ => ()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let readSideCollections = ["readModels", "stateViewSlices"]
|
|
110
|
+
|
|
93
111
|
let healStructure = (structure: dict<JSON.t>): dict<JSON.t> => {
|
|
94
112
|
let out = Dict.fromArray(structure->Dict.toArray)
|
|
95
113
|
// The collections themselves are `[T!]!` too, so an absent one is the same
|
|
@@ -103,6 +121,9 @@ let healStructure = (structure: dict<JSON.t>): dict<JSON.t> => {
|
|
|
103
121
|
)
|
|
104
122
|
})
|
|
105
123
|
)
|
|
124
|
+
readSideCollections->Array.forEach(collection =>
|
|
125
|
+
out->mapMembers(collection, fillLifecycleField)
|
|
126
|
+
)
|
|
106
127
|
out
|
|
107
128
|
}
|
|
108
129
|
|
|
@@ -148,6 +148,23 @@ function mapMembers(obj, key, f) {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
function fillLifecycleField(component) {
|
|
152
|
+
let match = component["lifecycleField"];
|
|
153
|
+
let match$1 = component["statusField"];
|
|
154
|
+
if (match !== undefined && match !== null) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (match$1 !== undefined) {
|
|
158
|
+
component["lifecycleField"] = match$1;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let readSideCollections = [
|
|
164
|
+
"readModels",
|
|
165
|
+
"stateViewSlices"
|
|
166
|
+
];
|
|
167
|
+
|
|
151
168
|
function healStructure(structure) {
|
|
152
169
|
let out = Object.fromEntries(Object.entries(structure));
|
|
153
170
|
fillLists(out, healByCollection.map(param => param[0]));
|
|
@@ -161,6 +178,7 @@ function healStructure(structure) {
|
|
|
161
178
|
});
|
|
162
179
|
});
|
|
163
180
|
});
|
|
181
|
+
readSideCollections.forEach(collection => mapMembers(out, collection, fillLifecycleField));
|
|
164
182
|
return out;
|
|
165
183
|
}
|
|
166
184
|
|
|
@@ -483,6 +501,8 @@ export {
|
|
|
483
501
|
healByCollection,
|
|
484
502
|
fillLists,
|
|
485
503
|
mapMembers,
|
|
504
|
+
fillLifecycleField,
|
|
505
|
+
readSideCollections,
|
|
486
506
|
healStructure,
|
|
487
507
|
isPublicQueryable,
|
|
488
508
|
filterStructure,
|
|
@@ -68,6 +68,7 @@ type pushdowns = {
|
|
|
68
68
|
~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
|
|
69
69
|
~labelField: string,
|
|
70
70
|
~ownerScope: (string, string)=?,
|
|
71
|
+
~retiredScope: Reventless.OwnerScope.retiredScope=?,
|
|
71
72
|
) => promise<option<JSON.t>>,
|
|
72
73
|
// Sub-id connection ({single}Items) — keyset over sub_key within a partition.
|
|
73
74
|
itemsPage: (
|
|
@@ -76,6 +77,7 @@ type pushdowns = {
|
|
|
76
77
|
~id: string,
|
|
77
78
|
~argsDict: dict<JSON.t>,
|
|
78
79
|
~ownerScope: (string, string)=?,
|
|
80
|
+
~retiredScope: Reventless.OwnerScope.retiredScope=?,
|
|
79
81
|
) => promise<JSON.t>,
|
|
80
82
|
// Full materialisation for the list fallback (shapes listPage declines).
|
|
81
83
|
scanAll: (~readModelName: string) => promise<array<JSON.t>>,
|
|
@@ -95,6 +97,8 @@ type binding = {
|
|
|
95
97
|
from the same schema `capability` comes from, so the two cannot disagree
|
|
96
98
|
about which fields this read model has. */
|
|
97
99
|
ownerField: option<string>,
|
|
100
|
+
retiredField: option<string>,
|
|
101
|
+
retiredValues: option<array<string>>,
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
// -- arg helpers -------------------------------------------------------------
|
|
@@ -178,6 +182,35 @@ let dispatch = async (
|
|
|
178
182
|
| ScopeTo(field, required) =>
|
|
179
183
|
item->argStr(field)->Option.mapOr(false, v => v == required)
|
|
180
184
|
}
|
|
185
|
+
// The caller's request to see the archive, honoured only where the
|
|
186
|
+
// classification says it counts.
|
|
187
|
+
let askedForRetired =
|
|
188
|
+
payload.arguments
|
|
189
|
+
->argObj
|
|
190
|
+
->Dict.get("includeRetired")
|
|
191
|
+
->Option.flatMap(JSON.Decode.bool)
|
|
192
|
+
->Option.getOr(false)
|
|
193
|
+
let retiredScope =
|
|
194
|
+
payload.identity
|
|
195
|
+
->Reventless.OwnerScope.decideRetired(
|
|
196
|
+
~retiredField=binding.retiredField,
|
|
197
|
+
~retiredValues=?binding.retiredValues,
|
|
198
|
+
~asked=askedForRetired,
|
|
199
|
+
)
|
|
200
|
+
->Reventless.OwnerScope.retiredScopeOf
|
|
201
|
+
// Absent keeps the row, as in `QueryDbListQuery`. The two forms of `@retired`
|
|
202
|
+
// differ only inside `isRetiredValue`, so nothing here branches on which one
|
|
203
|
+
// the view declared.
|
|
204
|
+
let retiredAllows = (item: JSON.t) =>
|
|
205
|
+
switch retiredScope {
|
|
206
|
+
| None => true
|
|
207
|
+
| Some(scope) =>
|
|
208
|
+
!(
|
|
209
|
+
scope->Reventless.OwnerScope.isRetiredValue(
|
|
210
|
+
item->JSON.Decode.object->Option.flatMap(d => d->Dict.get(scope.field)),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
}
|
|
181
214
|
switch payload.kind {
|
|
182
215
|
| "getById" =>
|
|
183
216
|
let id = payload.arguments->argStr("id")->Option.getOr("")
|
|
@@ -199,7 +232,7 @@ let dispatch = async (
|
|
|
199
232
|
switch found {
|
|
200
233
|
// A row the caller does not own answers as "not found". Saying "not yours"
|
|
201
234
|
// instead would make this door an oracle for which ids exist.
|
|
202
|
-
| Some(item) if !ownerAllows(item) => JSON.Encode.null
|
|
235
|
+
| Some(item) if !ownerAllows(item) || !retiredAllows(item) => JSON.Encode.null
|
|
203
236
|
| Some(item) => binding.includeIdParam ? withId(item, resolvedKey) : item
|
|
204
237
|
| None => JSON.Encode.null
|
|
205
238
|
}
|
|
@@ -216,7 +249,9 @@ let dispatch = async (
|
|
|
216
249
|
let extra = missing->Array.length > 0
|
|
217
250
|
? await binding.pushdowns.byIds(~readModelName=rm, missing)
|
|
218
251
|
: []
|
|
219
|
-
JSON.Encode.array(
|
|
252
|
+
JSON.Encode.array(
|
|
253
|
+
Array.concat(found, extra)->Array.filter(item => ownerAllows(item) && retiredAllows(item)),
|
|
254
|
+
)
|
|
220
255
|
|
|
221
256
|
| "items" =>
|
|
222
257
|
// Sub-id connection: {single}Items(id, filter, first/after/last/before).
|
|
@@ -233,6 +268,7 @@ let dispatch = async (
|
|
|
233
268
|
~id,
|
|
234
269
|
~argsDict=payload.arguments->argObj,
|
|
235
270
|
~ownerScope=?Reventless.OwnerScope.scopeOf(decision),
|
|
271
|
+
~retiredScope?,
|
|
236
272
|
)
|
|
237
273
|
}
|
|
238
274
|
| None => emptyConnection()
|
|
@@ -274,7 +310,7 @@ let dispatch = async (
|
|
|
274
310
|
if authorized {
|
|
275
311
|
JSON.Encode.array(
|
|
276
312
|
(await binding.pushdowns.indexLookup(~readModelName=rm, field, value))->Array.filter(
|
|
277
|
-
ownerAllows,
|
|
313
|
+
item => ownerAllows(item) && retiredAllows(item),
|
|
278
314
|
),
|
|
279
315
|
)
|
|
280
316
|
} else {
|
|
@@ -293,6 +329,7 @@ let dispatch = async (
|
|
|
293
329
|
~capability=binding.capability,
|
|
294
330
|
~labelField=binding.labelField,
|
|
295
331
|
~ownerScope?,
|
|
332
|
+
~retiredScope?,
|
|
296
333
|
) {
|
|
297
334
|
| Some(conn) => conn
|
|
298
335
|
| None =>
|
|
@@ -306,6 +343,7 @@ let dispatch = async (
|
|
|
306
343
|
~capability=binding.capability,
|
|
307
344
|
~labelField=binding.labelField,
|
|
308
345
|
~ownerScope?,
|
|
346
|
+
~retiredScope?,
|
|
309
347
|
)
|
|
310
348
|
}
|
|
311
349
|
}
|
|
@@ -80,6 +80,15 @@ async function dispatch(binding, lookupBindingOpt, payload) {
|
|
|
80
80
|
let required = match._1;
|
|
81
81
|
return Stdlib_Option.mapOr(argStr(item, match._0), false, v => v === required);
|
|
82
82
|
};
|
|
83
|
+
let askedForRetired = Stdlib_Option.getOr(Stdlib_Option.flatMap(argObj(payload.arguments)["includeRetired"], Stdlib_JSON.Decode.bool), false);
|
|
84
|
+
let retiredScope = OwnerScope$Reventless.retiredScopeOf(OwnerScope$Reventless.decideRetired(payload.identity, binding.retiredField, binding.retiredValues, askedForRetired, undefined));
|
|
85
|
+
let retiredAllows = item => {
|
|
86
|
+
if (retiredScope !== undefined) {
|
|
87
|
+
return !OwnerScope$Reventless.isRetiredValue(retiredScope, Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[retiredScope.field]));
|
|
88
|
+
} else {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
83
92
|
let other = payload.kind;
|
|
84
93
|
switch (other) {
|
|
85
94
|
case "byIds" :
|
|
@@ -87,7 +96,13 @@ async function dispatch(binding, lookupBindingOpt, payload) {
|
|
|
87
96
|
let found = await binding.pushdowns.byIds(rm, ids);
|
|
88
97
|
let missing = found.length < ids.length ? Stdlib_Array.filterMap(ids, Api_Ids$ReventlessCore.alternateKey) : [];
|
|
89
98
|
let extra = missing.length !== 0 ? await binding.pushdowns.byIds(rm, missing) : [];
|
|
90
|
-
return found.concat(extra).filter(
|
|
99
|
+
return found.concat(extra).filter(item => {
|
|
100
|
+
if (ownerAllows(item)) {
|
|
101
|
+
return retiredAllows(item);
|
|
102
|
+
} else {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
91
106
|
case "getById" :
|
|
92
107
|
let id = Stdlib_Option.getOr(argStr(payload.arguments, "id"), "");
|
|
93
108
|
let loadKey = async key => {
|
|
@@ -106,7 +121,7 @@ async function dispatch(binding, lookupBindingOpt, payload) {
|
|
|
106
121
|
await loadKey(match$2)
|
|
107
122
|
];
|
|
108
123
|
let found$1 = match$3[1];
|
|
109
|
-
if (found$1 !== undefined && ownerAllows(found$1)) {
|
|
124
|
+
if (found$1 !== undefined && ownerAllows(found$1) && retiredAllows(found$1)) {
|
|
110
125
|
if (binding.includeIdParam) {
|
|
111
126
|
return withId(found$1, match$3[0]);
|
|
112
127
|
} else {
|
|
@@ -134,7 +149,13 @@ async function dispatch(binding, lookupBindingOpt, payload) {
|
|
|
134
149
|
authorized = true;
|
|
135
150
|
}
|
|
136
151
|
if (authorized) {
|
|
137
|
-
return (await binding.pushdowns.indexLookup(rm, field, value)).filter(
|
|
152
|
+
return (await binding.pushdowns.indexLookup(rm, field, value)).filter(item => {
|
|
153
|
+
if (ownerAllows(item)) {
|
|
154
|
+
return retiredAllows(item);
|
|
155
|
+
} else {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
138
159
|
} else {
|
|
139
160
|
return [];
|
|
140
161
|
}
|
|
@@ -148,7 +169,7 @@ async function dispatch(binding, lookupBindingOpt, payload) {
|
|
|
148
169
|
if (typeof decision !== "object" && decision !== "Unscoped") {
|
|
149
170
|
return emptyConnection();
|
|
150
171
|
}
|
|
151
|
-
return await binding.pushdowns.itemsPage(rm, subIdField, id$1, argObj(payload.arguments), OwnerScope$Reventless.scopeOf(decision));
|
|
172
|
+
return await binding.pushdowns.itemsPage(rm, subIdField, id$1, argObj(payload.arguments), OwnerScope$Reventless.scopeOf(decision), retiredScope);
|
|
152
173
|
break;
|
|
153
174
|
case "list" :
|
|
154
175
|
let argsDict = argObj(payload.arguments);
|
|
@@ -157,12 +178,12 @@ async function dispatch(binding, lookupBindingOpt, payload) {
|
|
|
157
178
|
return emptyConnection();
|
|
158
179
|
}
|
|
159
180
|
let ownerScope = OwnerScope$Reventless.scopeOf(decision$1);
|
|
160
|
-
let conn = await binding.pushdowns.listPage(rm, argsDict, binding.capability, binding.labelField, ownerScope);
|
|
181
|
+
let conn = await binding.pushdowns.listPage(rm, argsDict, binding.capability, binding.labelField, ownerScope, retiredScope);
|
|
161
182
|
if (conn !== undefined) {
|
|
162
183
|
return conn;
|
|
163
184
|
}
|
|
164
185
|
let items$1 = await binding.pushdowns.scanAll(rm);
|
|
165
|
-
return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, binding.capability, binding.labelField, undefined, ownerScope);
|
|
186
|
+
return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, binding.capability, binding.labelField, undefined, ownerScope, retiredScope);
|
|
166
187
|
break;
|
|
167
188
|
case "resolveMany" :
|
|
168
189
|
let target = Stdlib_Option.getOr(payload.target, rm);
|
|
@@ -256,12 +256,12 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
256
256
|
~view=name,
|
|
257
257
|
~ownerField,
|
|
258
258
|
)
|
|
259
|
+
let isIndexed = f =>
|
|
260
|
+
indexes->Array.some(ic => ic.idField->Option.getOr(ic.index) == f) ||
|
|
261
|
+
subIdField->Option.getOr("") == f
|
|
259
262
|
switch ownerField {
|
|
260
263
|
| Some(f) =>
|
|
261
|
-
|
|
262
|
-
indexes->Array.some(ic => ic.idField->Option.getOr(ic.index) == f) ||
|
|
263
|
-
subIdField->Option.getOr("") == f
|
|
264
|
-
if !indexed {
|
|
264
|
+
if !isIndexed(f) {
|
|
265
265
|
log.warn(
|
|
266
266
|
~comp="QueryDbResolvers_AppSync",
|
|
267
267
|
`${name}: @owner field "${f}" is not the key of any index on this table. ` ++
|
|
@@ -271,6 +271,34 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
271
271
|
}
|
|
272
272
|
| None => ()
|
|
273
273
|
}
|
|
274
|
+
let retiredSpec =
|
|
275
|
+
stateSchemaOpt
|
|
276
|
+
->Option.flatMap(Reventless.StateAnnotations.getSpec)
|
|
277
|
+
->Option.flatMap(spec => spec.retired)
|
|
278
|
+
let retiredField = retiredSpec->Option.map(r => r.field)
|
|
279
|
+
let retiredValues = retiredSpec->Option.flatMap(r => r.values)
|
|
280
|
+
// The same class of "works, but scans" mistake as the owner warning above,
|
|
281
|
+
// and the retirement case degrades the same way: the FilterExpression is
|
|
282
|
+
// applied after the page is read, so pages shrink as the archive's share of
|
|
283
|
+
// the table grows.
|
|
284
|
+
//
|
|
285
|
+
// `@scan` deliberately does not satisfy this. It adds no index and removes
|
|
286
|
+
// no read unit — it only widens the client's filter surface — so accepting it
|
|
287
|
+
// here would make the warning dismissible by an annotation that changes
|
|
288
|
+
// nothing about the cost being warned about.
|
|
289
|
+
switch retiredField {
|
|
290
|
+
| Some(f) =>
|
|
291
|
+
if !isIndexed(f) {
|
|
292
|
+
log.warn(
|
|
293
|
+
~comp="QueryDbResolvers_AppSync",
|
|
294
|
+
`${name}: @retired field "${f}" is not the key of any index on this table. ` ++
|
|
295
|
+
"Reads that exclude retired rows will Scan and filter, so pages shrink as " ++
|
|
296
|
+
"the archive's share of the rows grows. Add an @index on that field before " ++
|
|
297
|
+
"this read model grows.",
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
| None => ()
|
|
301
|
+
}
|
|
274
302
|
let resolverAll = makeQueryResolver(
|
|
275
303
|
~resolverName=fieldNameForAll->String.capitalize,
|
|
276
304
|
~field=fieldNameForAll->Pulumi.Input.make,
|
|
@@ -283,6 +311,8 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
283
311
|
~requireAttribute?,
|
|
284
312
|
~ownerField?,
|
|
285
313
|
~elevatedGroups=Reventless.OwnerScope.elevatedGroups(),
|
|
314
|
+
~retiredField?,
|
|
315
|
+
~retiredValues?,
|
|
286
316
|
)
|
|
287
317
|
} else {
|
|
288
318
|
Resolver.Functions.listAllItems
|
|
@@ -9,6 +9,7 @@ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types
|
|
|
9
9
|
import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
|
|
10
10
|
import * as AppSync_Function$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_Function.res.mjs";
|
|
11
11
|
import * as Util_AppSync$ReventlessAws from "../../util/Util_AppSync.res.mjs";
|
|
12
|
+
import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
|
|
12
13
|
import * as Util_DynamoDb$ReventlessAws from "../../util/Util_DynamoDb.res.mjs";
|
|
13
14
|
import * as Util_QueryDb$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_QueryDb.res.mjs";
|
|
14
15
|
import * as AppSync_DataSource$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_DataSource.res.mjs";
|
|
@@ -111,13 +112,23 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
|
|
|
111
112
|
let requireAttribute = internalRowRequiredAttr(name$1);
|
|
112
113
|
let ownerField = stateSchemaOpt !== undefined ? Owner$Reventless.fieldNames(stateSchemaOpt)[0] : undefined;
|
|
113
114
|
OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("QueryDbResolvers_AppSync", name$1, ownerField);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
let isIndexed = f => {
|
|
116
|
+
if (indexes.some(ic => Stdlib_Option.getOr(ic.idField, ic.index) === f)) {
|
|
117
|
+
return true;
|
|
118
|
+
} else {
|
|
119
|
+
return Stdlib_Option.getOr(subIdField, "") === f;
|
|
118
120
|
}
|
|
121
|
+
};
|
|
122
|
+
if (ownerField !== undefined && !isIndexed(ownerField)) {
|
|
123
|
+
log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @owner field "` + ownerField + `" is not the key of any index on this table. ` + "Owner-scoped reads will Scan and filter, so pages shrink as the caller's share of the rows falls. Add an @index on that field before this read model grows.");
|
|
124
|
+
}
|
|
125
|
+
let retiredSpec = Stdlib_Option.flatMap(Stdlib_Option.flatMap(stateSchemaOpt, StateAnnotations$Reventless.getSpec), spec => spec.retired);
|
|
126
|
+
let retiredField = Stdlib_Option.map(retiredSpec, r => r.field);
|
|
127
|
+
let retiredValues = Stdlib_Option.flatMap(retiredSpec, r => r.values);
|
|
128
|
+
if (retiredField !== undefined && !isIndexed(retiredField)) {
|
|
129
|
+
log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @retired field "` + retiredField + `" is not the key of any index on this table. ` + "Reads that exclude retired rows will Scan and filter, so pages shrink as the archive's share of the rows grows. Add an @index on that field before this read model grows.");
|
|
119
130
|
}
|
|
120
|
-
let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, OwnerScope$Reventless.elevatedGroups()) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
|
|
131
|
+
let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, OwnerScope$Reventless.elevatedGroups(), retiredField, retiredValues) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
|
|
121
132
|
let resolversByIndex = indexes.map(indexConfig => {
|
|
122
133
|
let index = indexConfig.index;
|
|
123
134
|
let stripLeadingBy = s => {
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// builds shared DcbEventLog operations and per-slice handlers keyed by command type name.
|
|
4
4
|
// Dual routing: AppSync direct invocation and SQS CommandTopic events.
|
|
5
5
|
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
6
8
|
import * as Chunk from "effect/Chunk";
|
|
7
9
|
import * as Effect from "effect/Effect";
|
|
8
10
|
import * as Stream from "effect/Stream";
|
|
@@ -245,6 +247,16 @@ async function buildHandler() {
|
|
|
245
247
|
// Lambda always sets HANDLER_CONFIG; the production path is unchanged.
|
|
246
248
|
if (!configStr) return [null, null, undefined, undefined];
|
|
247
249
|
const config = JSON.parse(configStr);
|
|
250
|
+
// The slice registry rides in the archive, not the environment — two module
|
|
251
|
+
// specifiers per slice outgrow Lambda's 4KB env-var ceiling (see the asset in
|
|
252
|
+
// StateChangeSliceRuntime_Builder_Single). An inline registry still wins, so a
|
|
253
|
+
// caller that supplies one — every test that drives a config directly — needs
|
|
254
|
+
// no asset on disk. process.cwd() is /var/task, where the archive unpacks.
|
|
255
|
+
if (config.stateChangeSliceModules === undefined) {
|
|
256
|
+
config.stateChangeSliceModules = JSON.parse(
|
|
257
|
+
readFileSync(join(process.cwd(), "sliceModules.json"), "utf-8"),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
248
260
|
return buildHandlersForConfig(config, { loadModule: dynamicImport });
|
|
249
261
|
}
|
|
250
262
|
|
|
@@ -153,6 +153,16 @@ let registerBinding = (
|
|
|
153
153
|
// From the same schema `capability` is derived from, one line above, so the
|
|
154
154
|
// two cannot end up disagreeing about this read model's fields.
|
|
155
155
|
ownerField: Reventless.Owner.fieldNames(spec.stateSchema)->Array.get(0),
|
|
156
|
+
retiredField: spec.stateSchema
|
|
157
|
+
->Reventless.StateAnnotations.getSpec
|
|
158
|
+
->Option.flatMap(a => a.retired)
|
|
159
|
+
->Option.map(r => r.field),
|
|
160
|
+
// The state form's other half. Carried beside the field rather than
|
|
161
|
+
// re-derived at predicate time, so one place decides which rows are retired.
|
|
162
|
+
retiredValues: spec.stateSchema
|
|
163
|
+
->Reventless.StateAnnotations.getSpec
|
|
164
|
+
->Option.flatMap(a => a.retired)
|
|
165
|
+
->Option.flatMap(r => r.values),
|
|
156
166
|
},
|
|
157
167
|
)
|
|
158
168
|
logDebug("registered resolver binding for " ++ entry.readModelName, {comp: "PgQueryResolver"})
|
|
@@ -6,6 +6,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
6
6
|
import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
|
|
7
7
|
import * as PgRuntime$ReventlessAws from "../Postgres/PgRuntime.res.mjs";
|
|
8
8
|
import * as HandlerFactoryHelpersMjs from "./HandlerFactoryHelpers.mjs";
|
|
9
|
+
import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
|
|
9
10
|
import * as PgQueryResolver_Lambda$ReventlessAws from "../QueryDb/PgQueryResolver_Lambda.res.mjs";
|
|
10
11
|
import * as ProjectionEntryPoint_Ops$ReventlessAws from "./ProjectionEntryPoint_Ops.res.mjs";
|
|
11
12
|
import * as QueryEnginePostgres$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/QueryEnginePostgres.res.mjs";
|
|
@@ -74,7 +75,9 @@ function registerBinding(pushdowns, pgConnection, entry, spec) {
|
|
|
74
75
|
labelField: entry.labelField,
|
|
75
76
|
includeIdParam: entry.includeIdParam,
|
|
76
77
|
authorization: spec.authorization,
|
|
77
|
-
ownerField: Owner$Reventless.fieldNames(spec.stateSchema)[0]
|
|
78
|
+
ownerField: Owner$Reventless.fieldNames(spec.stateSchema)[0],
|
|
79
|
+
retiredField: Stdlib_Option.map(Stdlib_Option.flatMap(StateAnnotations$Reventless.getSpec(spec.stateSchema), a => a.retired), r => r.field),
|
|
80
|
+
retiredValues: Stdlib_Option.flatMap(Stdlib_Option.flatMap(StateAnnotations$Reventless.getSpec(spec.stateSchema), a => a.retired), r => r.values)
|
|
78
81
|
});
|
|
79
82
|
HandlerFactoryHelpersMjs.log.debug("registered resolver binding for " + entry.readModelName, {
|
|
80
83
|
comp: "PgQueryResolver"
|
|
@@ -113,9 +113,16 @@ let forDcbCommandTopic = (
|
|
|
113
113
|
envVars->Dict.set("LOG_LEVEL", level->Pulumi.Output.make->Pulumi.Output.asInput)
|
|
114
114
|
)
|
|
115
115
|
|
|
116
|
-
//
|
|
116
|
+
// The slice registry: an array of {spec, behavior} objects so the entry point
|
|
117
117
|
// can dynamically import both modules and apply the curried StateChangeSlice_Callback.Make
|
|
118
118
|
// functor (Make(Spec)(Behavior)).
|
|
119
|
+
//
|
|
120
|
+
// Shipped as a `sliceModules.json` asset rather than inline in HANDLER_CONFIG.
|
|
121
|
+
// Two absolute module specifiers per slice is the one term here that grows
|
|
122
|
+
// with the plugin, and a plugin with a dozen-odd slices carries enough of
|
|
123
|
+
// them to pass Lambda's 4KB UpdateFunctionConfiguration ceiling on the whole
|
|
124
|
+
// environment — the same wall `pluginDefinition` hit in PluginRuntime_Builder,
|
|
125
|
+
// and it fails at deploy with the registry already committed to the stack.
|
|
119
126
|
let sliceModulesJson =
|
|
120
127
|
slicePaths
|
|
121
128
|
->Array.map(((specPath, behaviorPath)) => {
|
|
@@ -172,7 +179,7 @@ let forDcbCommandTopic = (
|
|
|
172
179
|
Pulumi.Output.all4((dcbTableName, queue.id, pgConnectionFragment, inboundFragment))
|
|
173
180
|
->Pulumi.Output.apply(((table, queueUrl, pgFragment, inbFragment)) => {
|
|
174
181
|
let pluginNameJson = pluginName->JSON.stringifyAny->Option.getOr(`""`)
|
|
175
|
-
`{"dcbEventLogTableName":"${table}","queueUrl":"${queueUrl}","pluginName":${pluginNameJson}
|
|
182
|
+
`{"dcbEventLogTableName":"${table}","queueUrl":"${queueUrl}","pluginName":${pluginNameJson}${pgFragment}${inbFragment}}`
|
|
176
183
|
})
|
|
177
184
|
envVars->Dict.set("HANDLER_CONFIG", handlerConfigJson->Pulumi.Output.asInput)
|
|
178
185
|
|
|
@@ -205,9 +212,16 @@ let forDcbCommandTopic = (
|
|
|
205
212
|
Util_Bundle.resolvePackageRoot("@reventlessdev/reventless-core"),
|
|
206
213
|
)
|
|
207
214
|
|
|
215
|
+
// Carries no Pulumi Output, so the archive is still built synchronously —
|
|
216
|
+
// the audit table names that do are in the inbound fragment, which stays in
|
|
217
|
+
// HANDLER_CONFIG.
|
|
218
|
+
let extraStringAssets = Dict.make()
|
|
219
|
+
extraStringAssets->Dict.set("sliceModules.json", `[${sliceModulesJson}]`)
|
|
220
|
+
|
|
208
221
|
let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
|
|
209
222
|
~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs",
|
|
210
223
|
~packageDirs,
|
|
224
|
+
~extraStringAssets,
|
|
211
225
|
)
|
|
212
226
|
|
|
213
227
|
cfg.sqsBatchSize->Option.forEach(CommandTopicChannel.SQS.setBatchSize)
|
|
@@ -79,7 +79,7 @@ function forDcbCommandTopic(slicePaths, inboundSlicesOpt, dcbTableName, pluginNa
|
|
|
79
79
|
inboundFragment
|
|
80
80
|
]).apply(param => {
|
|
81
81
|
let pluginNameJson = Stdlib_Option.getOr(JSON.stringify(pluginName), `""`);
|
|
82
|
-
return `{"dcbEventLogTableName":"` + param[0] + `","queueUrl":"` + param[1] + `","pluginName":` + pluginNameJson +
|
|
82
|
+
return `{"dcbEventLogTableName":"` + param[0] + `","queueUrl":"` + param[1] + `","pluginName":` + pluginNameJson + param[2] + param[3] + `}`;
|
|
83
83
|
});
|
|
84
84
|
envVars["HANDLER_CONFIG"] = handlerConfigJson;
|
|
85
85
|
let packageDirs = {};
|
|
@@ -97,7 +97,9 @@ function forDcbCommandTopic(slicePaths, inboundSlicesOpt, dcbTableName, pluginNa
|
|
|
97
97
|
});
|
|
98
98
|
packageDirs["@reventlessdev/reventless-aws"] = Util_Bundle$ReventlessAws.resolvePackageRoot(undefined, "@reventlessdev/reventless-aws");
|
|
99
99
|
packageDirs["@reventlessdev/reventless-core"] = Util_Bundle$ReventlessAws.resolvePackageRoot(undefined, "@reventlessdev/reventless-core");
|
|
100
|
-
let
|
|
100
|
+
let extraStringAssets = {};
|
|
101
|
+
extraStringAssets["sliceModules.json"] = `[` + sliceModulesJson + `]`;
|
|
102
|
+
let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs", packageDirs, extraStringAssets, undefined);
|
|
101
103
|
Stdlib_Option.forEach(cfg.sqsBatchSize, CommandTopicChannel_SQS$ReventlessAws.setBatchSize);
|
|
102
104
|
let vpcConfig = pgSelection !== undefined ? pgSelection.securityGroupId.apply(sgId => ({
|
|
103
105
|
subnetIds: pgSelection.subnetIds,
|
|
@@ -98,14 +98,43 @@ export function nextSequence() {
|
|
|
98
98
|
// implementations. Over the size cap the state is dropped and the downgrade logged:
|
|
99
99
|
// a metadata-only descriptor still tells the client to refetch, where a publish
|
|
100
100
|
// rejected for size would tell it nothing.
|
|
101
|
-
|
|
101
|
+
//
|
|
102
|
+
// `retiredField` names the row's `@retired` flag, when the read model declares
|
|
103
|
+
// one. A row whose flag is true publishes as metadata only — no state, and no
|
|
104
|
+
// sortKeyValue either, since that is a timestamp off a row the subscriber may
|
|
105
|
+
// not read. The channel is shared by every subscriber of the view and cannot be
|
|
106
|
+
// scoped per caller, so a payload here would hand the row to exactly the callers
|
|
107
|
+
// the resolvers refuse it to. `Updated` with no state is the shape an oversized
|
|
108
|
+
// row already takes, and it asks the client to do the one thing that enforces
|
|
109
|
+
// the rule: refetch, and let the query layer answer.
|
|
110
|
+
//
|
|
111
|
+
// `retiredValues` is the state form: the row is retired when the field holds any
|
|
112
|
+
// of those states. Absent is the boolean form, where the flag is `true`. This
|
|
113
|
+
// mirrors `OwnerScope.isRetiredValue`, which the other two implementations call
|
|
114
|
+
// — this one cannot, being hand-written JS outside the ReScript graph, so the
|
|
115
|
+
// three are held together by StateChangeDescriptorParityTest instead. The strict
|
|
116
|
+
// readings matter and are the ones that file asserts: a non-boolean is not
|
|
117
|
+
// `true`, a non-string is in no set, and an absent field is neither.
|
|
118
|
+
export function makeDescriptor({ changeKind, entityKey, state, seq, retiredField, retiredValues }) {
|
|
102
119
|
const descriptor = { changeKind, id: entityKey };
|
|
103
|
-
|
|
120
|
+
const cell =
|
|
121
|
+
retiredField !== undefined &&
|
|
122
|
+
retiredField !== null &&
|
|
123
|
+
state !== undefined &&
|
|
124
|
+
state !== null &&
|
|
125
|
+
typeof state === "object"
|
|
126
|
+
? state[retiredField]
|
|
127
|
+
: undefined;
|
|
128
|
+
const retired =
|
|
129
|
+
retiredValues === undefined || retiredValues === null
|
|
130
|
+
? cell === true
|
|
131
|
+
: typeof cell === "string" && retiredValues.indexOf(cell) >= 0;
|
|
132
|
+
if (state !== undefined && !retired) {
|
|
104
133
|
const sortKeyValue = pickSortKeyValue(state);
|
|
105
134
|
if (sortKeyValue !== undefined) descriptor.sortKeyValue = sortKeyValue;
|
|
106
135
|
}
|
|
107
136
|
descriptor.seq = seq;
|
|
108
|
-
if (state !== undefined) {
|
|
137
|
+
if (state !== undefined && !retired) {
|
|
109
138
|
const encoded = JSON.stringify(state);
|
|
110
139
|
if (encoded.length <= MAX_STATE_CHARS) {
|
|
111
140
|
descriptor.state = state;
|
|
@@ -126,12 +155,16 @@ export function makeDescriptor({ changeKind, entityKey, state, seq }) {
|
|
|
126
155
|
// - changeKind: "Updated" (save) | "Removed" (delete).
|
|
127
156
|
// - state: the full new row for a save; omitted for a delete.
|
|
128
157
|
// - dedupeId: AppSync publish `id` (idempotency hint).
|
|
129
|
-
|
|
158
|
+
// - retiredField: the row's `@retired` flag name, when the read model declares
|
|
159
|
+
// one; a retired row publishes metadata-only.
|
|
160
|
+
// - retiredValues: the states that retire the row, for the state form. Absent is
|
|
161
|
+
// the boolean form, where the flag is `true`.
|
|
162
|
+
export async function publishStateChange({ endpoint, region, topicName, entityKey, changeKind, state, dedupeId, retiredField, retiredValues }) {
|
|
130
163
|
if (!endpoint || !topicName) return; // not live-enabled — no-op
|
|
131
164
|
try {
|
|
132
165
|
const url = new URL(endpoint);
|
|
133
166
|
const channel = "/default/" + pathSegment(topicName) + "/" + pathSegment(entityKey);
|
|
134
|
-
const descriptor = makeDescriptor({ changeKind, entityKey, state, seq: nextSequence() });
|
|
167
|
+
const descriptor = makeDescriptor({ changeKind, entityKey, state, seq: nextSequence(), retiredField, retiredValues });
|
|
135
168
|
const body = JSON.stringify({
|
|
136
169
|
id: dedupeId || (topicName + ":" + entityKey + ":" + changeKind),
|
|
137
170
|
channel,
|