@reventlessdev/reventless-aws 3.0.0-alpha.266 → 3.0.0-alpha.268
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 +15 -0
- package/package.json +8 -8
- package/src/Platform.res +1 -0
- package/src/Platform.res.mjs +2 -2
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +36 -0
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +14 -1
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +37 -6
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +19 -6
- package/src/adapter/Runtime/StateTopicPublish.mjs +51 -8
- package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res +66 -12
- package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res.mjs +29 -6
- package/src/util/Util_StoreLayout.res +1 -1
- package/src/util/Util_StoreLayout.res.mjs +3 -4
- package/tests/StateChangeDescriptorParityTest.res +58 -0
- package/tests/StateChangeDescriptorParityTest.res.mjs +60 -0
- package/tests/StateTopicPublishTest.res +29 -5
- package/tests/StateTopicPublishTest.res.mjs +32 -5
- package/tests/stateChangeDescriptorParity.mjs +102 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
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.268 (2026-08-05)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **local,aws:** carry the changed row in the live-update descriptor ([9272e2e](https://github.com/ReventlessDev/reventless-core/commit/9272e2e18e9e1d740f9a2c5aa83eccbcb41feff7))
|
|
11
|
+
* **local:** scoped seed:reset for the local platform ([a4dd003](https://github.com/ReventlessDev/reventless-core/commit/a4dd0033b7d01be23be91bac3f01d38ebeab7d45))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.267 (2026-08-04)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **admin:** serve the complete plugin structure to developer tooling ([e99cda0](https://github.com/ReventlessDev/reventless-core/commit/e99cda02a2f862b36bcfdb018a0fb34bb7426a2b))
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.266 (2026-08-04)
|
|
7
22
|
|
|
8
23
|
### Features
|
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.268",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
@@ -11,18 +11,18 @@
|
|
|
11
11
|
"@aws-sdk/s3-request-presigner": "3.970.0",
|
|
12
12
|
"sury": "11.0.0-alpha.4",
|
|
13
13
|
"uuid": "^13.0.0",
|
|
14
|
-
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.
|
|
14
|
+
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.4",
|
|
15
15
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
16
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.
|
|
16
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.2",
|
|
17
17
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
18
|
-
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.
|
|
18
|
+
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.66",
|
|
19
19
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
20
20
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
21
|
-
"@reventlessdev/reventless-
|
|
22
|
-
"@reventlessdev/reventless-
|
|
23
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.100",
|
|
21
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.127",
|
|
22
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.213",
|
|
24
23
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.30",
|
|
25
|
-
"@reventlessdev/reventless-postgres": "3.0.0-alpha.
|
|
24
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.77",
|
|
25
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.101"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"rescript": "12.3.0",
|
package/src/Platform.res
CHANGED
package/src/Platform.res.mjs
CHANGED
|
@@ -861,7 +861,7 @@ function MakeWithConfig(Config) {
|
|
|
861
861
|
Pulumi$Pulumi.$$export("offloadBucket", offloadBucketName);
|
|
862
862
|
if (pluginReadModelTableName !== undefined) {
|
|
863
863
|
AggregateRuntime_Builder_Single$ReventlessAws.setPluginReadModelTable(pluginReadModelTableName);
|
|
864
|
-
Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, {});
|
|
864
|
+
Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, admin.adminSchemaPushed, {});
|
|
865
865
|
}
|
|
866
866
|
let rm = admin.stateViewSlicesOutputs["UiFragments"];
|
|
867
867
|
if (rm !== undefined) {
|
|
@@ -2096,7 +2096,7 @@ function Make($star) {
|
|
|
2096
2096
|
Pulumi$Pulumi.$$export("offloadBucket", offloadBucketName);
|
|
2097
2097
|
if (pluginReadModelTableName !== undefined) {
|
|
2098
2098
|
AggregateRuntime_Builder_Single$ReventlessAws.setPluginReadModelTable(pluginReadModelTableName);
|
|
2099
|
-
Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, {});
|
|
2099
|
+
Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, admin.adminSchemaPushed, {});
|
|
2100
2100
|
}
|
|
2101
2101
|
let rm = admin.stateViewSlicesOutputs["UiFragments"];
|
|
2102
2102
|
if (rm !== undefined) {
|
|
@@ -24,10 +24,29 @@ export function response(ctx) {
|
|
|
24
24
|
}
|
|
25
25
|
`->Pulumi.Input.make
|
|
26
26
|
|
|
27
|
+
// `Platform_PluginStructures` — the same scan, unfiltered. The `complete` flag is
|
|
28
|
+
// the whole difference; see the handler's `toEntryWith`.
|
|
29
|
+
let completeResolverCode = `
|
|
30
|
+
import { util } from '@aws-appsync/utils';
|
|
31
|
+
export function request(ctx) {
|
|
32
|
+
return { operation: 'Invoke', payload: { complete: true } };
|
|
33
|
+
}
|
|
34
|
+
export function response(ctx) {
|
|
35
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);
|
|
36
|
+
return ctx.result;
|
|
37
|
+
}
|
|
38
|
+
`->Pulumi.Input.make
|
|
39
|
+
|
|
27
40
|
let make = (
|
|
28
41
|
~api: Pulumi.Output.t<AppSync.GraphQLApi.t>,
|
|
29
42
|
~pluginReadModelTableName: Pulumi.Output.t<string>,
|
|
30
43
|
~offloadBucketName: Pulumi.Output.t<string>,
|
|
44
|
+
// Resolves once the admin schema push is ACTIVE (Platform_Admin.adminSchemaPushed).
|
|
45
|
+
// Only the `Platform_PluginStructures` resolver is gated on it: that field is new,
|
|
46
|
+
// so CreateResolver would otherwise race StartSchemaCreation on the deploy that
|
|
47
|
+
// first ships it. `Platform_ComponentDefinitions` predates the gate and is left
|
|
48
|
+
// ungated so this change adds no dependency edge to an already-deployed resource.
|
|
49
|
+
~schemaReady: Pulumi.Output.t<unit>,
|
|
31
50
|
~opts: Pulumi.ComponentResource.options,
|
|
32
51
|
) => {
|
|
33
52
|
let opts = opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
|
|
@@ -215,4 +234,21 @@ let make = (
|
|
|
215
234
|
~code=resolverCode,
|
|
216
235
|
~opts,
|
|
217
236
|
)
|
|
237
|
+
|
|
238
|
+
// Second field, same DataSource and same Lambda — the developer-tooling read of
|
|
239
|
+
// the identical scan. Sharing the data source is what keeps the two answers from
|
|
240
|
+
// drifting: there is one place that decides what a deployed plugin's structure is.
|
|
241
|
+
// Created only after the schema carrying `Platform_PluginStructures` is ACTIVE.
|
|
242
|
+
let _structuresResolver =
|
|
243
|
+
schemaReady->Pulumi.Output.apply(() =>
|
|
244
|
+
AppSync_Resolver_Native.makeUnitJsResolver(
|
|
245
|
+
~name=name ++ "StructuresResolver",
|
|
246
|
+
~api,
|
|
247
|
+
~dataSourceName=dataSource.name->Pulumi.Output.asInput,
|
|
248
|
+
~type_="Query"->Pulumi.Input.make,
|
|
249
|
+
~field="Platform_PluginStructures"->Pulumi.Input.make,
|
|
250
|
+
~code=completeResolverCode,
|
|
251
|
+
~opts,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
218
254
|
}
|
|
@@ -27,7 +27,18 @@ export function response(ctx) {
|
|
|
27
27
|
}
|
|
28
28
|
`;
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
let completeResolverCode = `
|
|
31
|
+
import { util } from '@aws-appsync/utils';
|
|
32
|
+
export function request(ctx) {
|
|
33
|
+
return { operation: 'Invoke', payload: { complete: true } };
|
|
34
|
+
}
|
|
35
|
+
export function response(ctx) {
|
|
36
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);
|
|
37
|
+
return ctx.result;
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opts) {
|
|
31
42
|
let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
|
|
32
43
|
let name = "PlatformUIDefinitions";
|
|
33
44
|
let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "Lambda", Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name + "Lambda", "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
|
|
@@ -131,10 +142,12 @@ function make(api, pluginReadModelTableName, offloadBucketName, opts) {
|
|
|
131
142
|
serviceRoleArn: dataSourceRole.arn
|
|
132
143
|
}, opts$1);
|
|
133
144
|
AppSync_Resolver_Native$ReventlessAws.makeUnitJsResolver(name + "Resolver", api, dataSource.name, "Query", "Platform_ComponentDefinitions", resolverCode, opts$1);
|
|
145
|
+
schemaReady.apply(() => AppSync_Resolver_Native$ReventlessAws.makeUnitJsResolver(name + "StructuresResolver", api, dataSource.name, "Query", "Platform_PluginStructures", completeResolverCode, opts$1));
|
|
134
146
|
}
|
|
135
147
|
|
|
136
148
|
export {
|
|
137
149
|
resolverCode,
|
|
150
|
+
completeResolverCode,
|
|
138
151
|
make,
|
|
139
152
|
}
|
|
140
153
|
/* @pulumi/aws Not a pure module */
|
|
@@ -43,11 +43,21 @@ let filterStructure = (structure: JSON.t): option<dict<JSON.t>> =>
|
|
|
43
43
|
Some(out)
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// `{pluginId: name, ...
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
// `{pluginId: name, ...structure}` — pluginId first so the structure's fields win
|
|
47
|
+
// on the (never-expected) key collision, matching the JS spread. Drops a row with
|
|
48
|
+
// no decodable structure (former `!item.structure`).
|
|
49
|
+
//
|
|
50
|
+
// `~filter` is what separates the two fields this handler serves: AutoUI
|
|
51
|
+
// (`Platform_ComponentDefinitions`) gets the public-queryable subset, developer
|
|
52
|
+
// tooling (`Platform_PluginStructures`) gets the structure exactly as persisted,
|
|
53
|
+
// including Internal components and the producer-side `extensionPoints` that the
|
|
54
|
+
// AutoUI entry type does not declare.
|
|
55
|
+
let toEntryWith = (~filter: bool, item: dict<JSON.t>, ~name: string): option<JSON.t> => {
|
|
56
|
+
let structure =
|
|
57
|
+
item
|
|
58
|
+
->Dict.get("structure")
|
|
59
|
+
->Option.flatMap(s => filter ? filterStructure(s) : s->JSON.Decode.object)
|
|
60
|
+
switch structure {
|
|
51
61
|
| None => None
|
|
52
62
|
| Some(structureObj) =>
|
|
53
63
|
let entry = Dict.make()
|
|
@@ -55,10 +65,17 @@ let toEntry = (item: dict<JSON.t>, ~name: string): option<JSON.t> =>
|
|
|
55
65
|
structureObj->Dict.toArray->Array.forEach(((k, v)) => entry->Dict.set(k, v))
|
|
56
66
|
Some(JSON.Encode.object(entry))
|
|
57
67
|
}
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
|
|
59
71
|
// The built-in Platform_Admin entry, injected at deploy time as the
|
|
60
72
|
// ADMIN_ENTRY_JSON env var (the admin never Connects to itself, so its structure
|
|
61
73
|
// never enters the Plugin read model).
|
|
74
|
+
//
|
|
75
|
+
// Shared by both fields. The admin structure has no Internal components and no
|
|
76
|
+
// extension points, so its filtered and complete encodings carry the same
|
|
77
|
+
// components; the structure-level fields the complete entry adds are absent from
|
|
78
|
+
// this JSON and resolve to null, which is what `extensionPoints: None` means.
|
|
62
79
|
let adminEntry: option<JSON.t> =
|
|
63
80
|
switch NodeProcess.env->Dict.get("ADMIN_ENTRY_JSON") {
|
|
64
81
|
| Some(s) if s != "" => Some(JSON.parseOrThrow(s))
|
|
@@ -94,7 +111,21 @@ let resolveStructure = (
|
|
|
94
111
|
}
|
|
95
112
|
}
|
|
96
113
|
|
|
97
|
-
|
|
114
|
+
// One Lambda serves both fields — the scan, the offload resolution and the
|
|
115
|
+
// latest-version-per-plugin collapse are identical work, and duplicating them into
|
|
116
|
+
// a second function would mean a second cold start and a second chance for the two
|
|
117
|
+
// to disagree about what "the deployed structure" is. The resolver for
|
|
118
|
+
// `Platform_PluginStructures` invokes with `{complete: true}`.
|
|
119
|
+
let isComplete = (event: JSON.t): bool =>
|
|
120
|
+
event
|
|
121
|
+
->JSON.Decode.object
|
|
122
|
+
->Option.flatMap(o => o->Dict.get("complete"))
|
|
123
|
+
->Option.flatMap(JSON.Decode.bool)
|
|
124
|
+
->Option.getOr(false)
|
|
125
|
+
|
|
126
|
+
let handler = async (event: JSON.t): array<JSON.t> => {
|
|
127
|
+
let complete = isComplete(event)
|
|
128
|
+
let toEntry = (item, ~name) => toEntryWith(~filter=!complete, item, ~name)
|
|
98
129
|
let admin = adminEntry->Option.mapOr([], e => [e])
|
|
99
130
|
switch NodeProcess.env->Dict.get("PLUGIN_RM_TABLE") {
|
|
100
131
|
| None | Some("") =>
|
|
@@ -31,14 +31,20 @@ function filterStructure(structure) {
|
|
|
31
31
|
return out;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function
|
|
35
|
-
let
|
|
36
|
-
|
|
34
|
+
function toEntryWith(filter, item, name) {
|
|
35
|
+
let structure = Stdlib_Option.flatMap(item["structure"], s => {
|
|
36
|
+
if (filter) {
|
|
37
|
+
return filterStructure(s);
|
|
38
|
+
} else {
|
|
39
|
+
return Stdlib_JSON.Decode.object(s);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
if (structure === undefined) {
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
45
|
let entry = {};
|
|
40
46
|
entry["pluginId"] = name;
|
|
41
|
-
Object.entries(
|
|
47
|
+
Object.entries(structure).forEach(param => {
|
|
42
48
|
entry[param[0]] = param[1];
|
|
43
49
|
});
|
|
44
50
|
return entry;
|
|
@@ -65,7 +71,13 @@ function resolveStructure(fetch, item) {
|
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
function isComplete(event) {
|
|
75
|
+
return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => o["complete"]), Stdlib_JSON.Decode.bool), false);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function handler(event) {
|
|
79
|
+
let complete = isComplete(event);
|
|
80
|
+
let toEntry = (item, name) => toEntryWith(!complete, item, name);
|
|
69
81
|
let admin = Stdlib_Option.mapOr(adminEntry, [], e => [e]);
|
|
70
82
|
let table = process.env["PLUGIN_RM_TABLE"];
|
|
71
83
|
if (table !== undefined) {
|
|
@@ -94,9 +106,10 @@ export {
|
|
|
94
106
|
str,
|
|
95
107
|
isPublicQueryable,
|
|
96
108
|
filterStructure,
|
|
97
|
-
|
|
109
|
+
toEntryWith,
|
|
98
110
|
adminEntry,
|
|
99
111
|
resolveStructure,
|
|
112
|
+
isComplete,
|
|
100
113
|
handler,
|
|
101
114
|
}
|
|
102
115
|
/* s Not a pure module */
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
//
|
|
13
13
|
// Channel: /default/{pathSegment(topicName)}/{pathSegment(entityKey)}
|
|
14
14
|
// Descriptor: { changeKind: "Updated" | "Removed", id: <entityKey>,
|
|
15
|
-
// sortKeyValue?: <updatedAt | createdAt if present
|
|
15
|
+
// sortKeyValue?: <updatedAt | createdAt if present>,
|
|
16
|
+
// seq: <monotonic ordering token>,
|
|
17
|
+
// state?: <the full new row, saves only> }
|
|
16
18
|
//
|
|
17
19
|
// changeKind is fixed per operation (save→"Updated", delete→"Removed"): a
|
|
18
20
|
// Postgres upsert doesn't distinguish insert vs update, and the Sqlite/InMemory
|
|
@@ -64,8 +66,8 @@ function pathSegment(value) {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
// Pick the natural sort timestamp from the saved state (updatedAt preferred,
|
|
67
|
-
// createdAt fallback). Mirrors
|
|
68
|
-
//
|
|
69
|
+
// createdAt fallback). Mirrors StateTopic_AppSync_Ops.pickSortKeyValue and
|
|
70
|
+
// LocalStateChangeDescriptor.pickSortKeyValue.
|
|
69
71
|
function pickSortKeyValue(state) {
|
|
70
72
|
if (state && typeof state === "object" && !Array.isArray(state)) {
|
|
71
73
|
if (typeof state.updatedAt === "string") return state.updatedAt;
|
|
@@ -74,21 +76,62 @@ function pickSortKeyValue(state) {
|
|
|
74
76
|
return undefined;
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
// Cap on the serialised state payload, in characters. Must match
|
|
80
|
+
// LocalStateChangeDescriptor.maxStateChars and StateTopic_AppSync_Ops.maxStateChars
|
|
81
|
+
// — see the local module for the reasoning.
|
|
82
|
+
const MAX_STATE_CHARS = 60 * 1024;
|
|
83
|
+
|
|
84
|
+
// Monotonic ordering token, seeded from the wall clock so it keeps rising across
|
|
85
|
+
// container restarts. Per Lambda instance: two instances writing the same entity
|
|
86
|
+
// within one millisecond have no defined order. This is an ordering hint, not a
|
|
87
|
+
// lock, and it is sparse — the client rule is "greater than what I hold", never
|
|
88
|
+
// "exactly one more". See docs/analysis/live-update-descriptor-sequencing.md.
|
|
89
|
+
let lastSequence = 0;
|
|
90
|
+
export function nextSequence() {
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
lastSequence = now > lastSequence ? now : lastSequence + 1;
|
|
93
|
+
return String(lastSequence);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Assemble the descriptor. `state` is the full new row for a save, undefined for a
|
|
97
|
+
// delete — which carries neither `state` nor `sortKeyValue`, matching the other two
|
|
98
|
+
// implementations. Over the size cap the state is dropped and the downgrade logged:
|
|
99
|
+
// a metadata-only descriptor still tells the client to refetch, where a publish
|
|
100
|
+
// rejected for size would tell it nothing.
|
|
101
|
+
export function makeDescriptor({ changeKind, entityKey, state, seq }) {
|
|
102
|
+
const descriptor = { changeKind, id: entityKey };
|
|
103
|
+
if (state !== undefined) {
|
|
104
|
+
const sortKeyValue = pickSortKeyValue(state);
|
|
105
|
+
if (sortKeyValue !== undefined) descriptor.sortKeyValue = sortKeyValue;
|
|
106
|
+
}
|
|
107
|
+
descriptor.seq = seq;
|
|
108
|
+
if (state !== undefined) {
|
|
109
|
+
const encoded = JSON.stringify(state);
|
|
110
|
+
if (encoded.length <= MAX_STATE_CHARS) {
|
|
111
|
+
descriptor.state = state;
|
|
112
|
+
} else {
|
|
113
|
+
console.warn(
|
|
114
|
+
"STATE_PAYLOAD_DOWNGRADED id=" + entityKey + " chars=" + encoded.length,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return descriptor;
|
|
119
|
+
}
|
|
120
|
+
|
|
77
121
|
// Build the descriptor + channel and POST to {endpoint}/event.
|
|
78
122
|
// - endpoint: base AppSync Events HTTP endpoint (no trailing /event).
|
|
79
123
|
// - region: AWS region for the SigV4 credential scope.
|
|
80
124
|
// - topicName: the plugin-prefixed query LIST field name (channel root).
|
|
81
125
|
// - entityKey: ORIGINAL entity key (id or `id-subKey`) — descriptor body + path.
|
|
82
126
|
// - changeKind: "Updated" (save) | "Removed" (delete).
|
|
83
|
-
// -
|
|
127
|
+
// - state: the full new row for a save; omitted for a delete.
|
|
84
128
|
// - dedupeId: AppSync publish `id` (idempotency hint).
|
|
85
|
-
export async function publishStateChange({ endpoint, region, topicName, entityKey, changeKind,
|
|
129
|
+
export async function publishStateChange({ endpoint, region, topicName, entityKey, changeKind, state, dedupeId }) {
|
|
86
130
|
if (!endpoint || !topicName) return; // not live-enabled — no-op
|
|
87
131
|
try {
|
|
88
132
|
const url = new URL(endpoint);
|
|
89
133
|
const channel = "/default/" + pathSegment(topicName) + "/" + pathSegment(entityKey);
|
|
90
|
-
const descriptor = { changeKind,
|
|
91
|
-
if (sortKeyValue !== undefined) descriptor.sortKeyValue = sortKeyValue;
|
|
134
|
+
const descriptor = makeDescriptor({ changeKind, entityKey, state, seq: nextSequence() });
|
|
92
135
|
const body = JSON.stringify({
|
|
93
136
|
id: dedupeId || (topicName + ":" + entityKey + ":" + changeKind),
|
|
94
137
|
channel,
|
|
@@ -154,7 +197,7 @@ export function withLiveUpdates(ops, liveConfig) {
|
|
|
154
197
|
endpoint, region, topicName,
|
|
155
198
|
entityKey: entityKeyFor(id, state, subIdField),
|
|
156
199
|
changeKind: "Updated",
|
|
157
|
-
|
|
200
|
+
state,
|
|
158
201
|
});
|
|
159
202
|
|
|
160
203
|
const publishRemovedKey = async (entityKey) =>
|
|
@@ -6,9 +6,16 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Triggered by DynamoDB streams (one shared Lambda; routing is per-record via
|
|
8
8
|
// STATE_TOPIC_MAP). For each changed row it derives the entity channel and a
|
|
9
|
-
// `{changeKind, id, sortKeyValue?}` descriptor and publishes it. 4xx
|
|
10
|
-
// are logged and skipped; 5xx / network failures are recorded and
|
|
11
|
-
// the batch so the EventSourceMapping retries
|
|
9
|
+
// `{changeKind, id, sortKeyValue?, seq, state?}` descriptor and publishes it. 4xx
|
|
10
|
+
// failures are logged and skipped; 5xx / network failures are recorded and
|
|
11
|
+
// rethrown after the batch so the EventSourceMapping retries
|
|
12
|
+
// (bisectBatchOnFunctionError).
|
|
13
|
+
//
|
|
14
|
+
// The descriptor is one of three implementations of a shared wire format (the
|
|
15
|
+
// others: `LocalStateChangeDescriptor` in reventless-local, `StateTopicPublish.mjs`
|
|
16
|
+
// for Postgres read models). They share no code — this module stays Pulumi-free so
|
|
17
|
+
// a core import can't drag deploy-time code into the Lambda's import graph — so
|
|
18
|
+
// `StateChangeDescriptorParityTest` drives all three and asserts they agree.
|
|
12
19
|
|
|
13
20
|
|
|
14
21
|
let endpoint = NodeProcess.env->Dict.get("APPSYNC_ENDPOINT")->Option.getOr("")
|
|
@@ -31,6 +38,10 @@ type streamRecord = {
|
|
|
31
38
|
@as("Keys") keys: dict<attributeValue>,
|
|
32
39
|
@as("NewImage") newImage?: dict<attributeValue>,
|
|
33
40
|
@as("OldImage") oldImage?: dict<attributeValue>,
|
|
41
|
+
// Monotonic within the entity's shard lineage — records for one partition key
|
|
42
|
+
// always land on the same shard — so it orders an entity's changes without
|
|
43
|
+
// anything being written to the table. Sparse, so it detects staleness, not gaps.
|
|
44
|
+
@as("SequenceNumber") sequenceNumber?: string,
|
|
34
45
|
}
|
|
35
46
|
type record = {
|
|
36
47
|
eventID: string,
|
|
@@ -103,6 +114,52 @@ let pickSortKeyValue = (image: dict<JSON.t>): option<string> =>
|
|
|
103
114
|
| None => image->Dict.get("createdAt")->Option.flatMap(JSON.Decode.string)
|
|
104
115
|
}
|
|
105
116
|
|
|
117
|
+
/** Cap on the serialised state payload, in characters. Must match
|
|
118
|
+
`LocalStateChangeDescriptor.maxStateChars` and `StateTopicPublish.mjs`'s
|
|
119
|
+
MAX_STATE_CHARS — see the local module for the reasoning. */
|
|
120
|
+
let maxStateChars = 60 * 1024
|
|
121
|
+
|
|
122
|
+
/** Build the change descriptor. Split out of `processRecord` so the wire format
|
|
123
|
+
can be asserted against the other two implementations without a network call.
|
|
124
|
+
|
|
125
|
+
`image` is the unmarshalled NewImage for a save, the OldImage for a REMOVE.
|
|
126
|
+
A REMOVE carries neither `state` (there is no new row) nor `sortKeyValue`
|
|
127
|
+
(a sort position for a deleted row has no consumer, and the other two
|
|
128
|
+
implementations cannot produce one). */
|
|
129
|
+
let makeDescriptor = (
|
|
130
|
+
~changeKind: string,
|
|
131
|
+
~entityKey: string,
|
|
132
|
+
~image: dict<JSON.t>,
|
|
133
|
+
~seq: option<string>,
|
|
134
|
+
): JSON.t => {
|
|
135
|
+
let removed = changeKind == "Removed"
|
|
136
|
+
let descriptor = Dict.make()
|
|
137
|
+
descriptor->Dict.set("changeKind", JSON.Encode.string(changeKind))
|
|
138
|
+
descriptor->Dict.set("id", JSON.Encode.string(entityKey))
|
|
139
|
+
if !removed {
|
|
140
|
+
pickSortKeyValue(image)->Option.forEach(v =>
|
|
141
|
+
descriptor->Dict.set("sortKeyValue", JSON.Encode.string(v))
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
seq->Option.forEach(s => descriptor->Dict.set("seq", JSON.Encode.string(s)))
|
|
145
|
+
if !removed {
|
|
146
|
+
let state = image->JSON.Encode.object
|
|
147
|
+
let encoded = state->JSON.stringify
|
|
148
|
+
if encoded->String.length <= maxStateChars {
|
|
149
|
+
descriptor->Dict.set("state", state)
|
|
150
|
+
} else {
|
|
151
|
+
// Metadata-only still tells the client to refetch; a publish rejected for
|
|
152
|
+
// size would tell it nothing at all.
|
|
153
|
+
Console.warn(
|
|
154
|
+
`STATE_PAYLOAD_DOWNGRADED id=${entityKey} chars=${encoded
|
|
155
|
+
->String.length
|
|
156
|
+
->Int.toString}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
descriptor->JSON.Encode.object
|
|
161
|
+
}
|
|
162
|
+
|
|
106
163
|
// Publish one record; returns Some(errorMessage) on a transient (5xx / network)
|
|
107
164
|
// failure so the caller can rethrow after the batch, None otherwise.
|
|
108
165
|
let processRecord = async (
|
|
@@ -129,20 +186,17 @@ let processRecord = async (
|
|
|
129
186
|
let entityKey = entityKeyFromRecord(dynamodb)
|
|
130
187
|
let channel = `/default/${topicRoot}/${AppSyncEventsSigner_Ops.pathSegment(entityKey)}`
|
|
131
188
|
let unmarshalled: dict<JSON.t> = AwsSdk.DynamoDb_Util_Helpers.unmarshallDict(image)
|
|
132
|
-
let descriptor =
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
189
|
+
let descriptor = makeDescriptor(
|
|
190
|
+
~changeKind=changeKindFor(record.eventName),
|
|
191
|
+
~entityKey,
|
|
192
|
+
~image=unmarshalled,
|
|
193
|
+
~seq=dynamodb.sequenceNumber,
|
|
137
194
|
)
|
|
138
195
|
let body =
|
|
139
196
|
Dict.fromArray([
|
|
140
197
|
("id", JSON.Encode.string(record.eventID)),
|
|
141
198
|
("channel", JSON.Encode.string(channel)),
|
|
142
|
-
(
|
|
143
|
-
"events",
|
|
144
|
-
JSON.Encode.array([JSON.Encode.string(descriptor->JSON.Encode.object->JSON.stringify)]),
|
|
145
|
-
),
|
|
199
|
+
("events", JSON.Encode.array([JSON.Encode.string(descriptor->JSON.stringify)])),
|
|
146
200
|
])
|
|
147
201
|
->JSON.Encode.object
|
|
148
202
|
->JSON.stringify
|
|
@@ -89,6 +89,30 @@ function pickSortKeyValue(image) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
function makeDescriptor(changeKind, entityKey, image, seq) {
|
|
93
|
+
let removed = changeKind === "Removed";
|
|
94
|
+
let descriptor = {};
|
|
95
|
+
descriptor["changeKind"] = changeKind;
|
|
96
|
+
descriptor["id"] = entityKey;
|
|
97
|
+
if (!removed) {
|
|
98
|
+
Stdlib_Option.forEach(pickSortKeyValue(image), v => {
|
|
99
|
+
descriptor["sortKeyValue"] = v;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
Stdlib_Option.forEach(seq, s => {
|
|
103
|
+
descriptor["seq"] = s;
|
|
104
|
+
});
|
|
105
|
+
if (!removed) {
|
|
106
|
+
let encoded = JSON.stringify(image);
|
|
107
|
+
if (encoded.length <= 61440) {
|
|
108
|
+
descriptor["state"] = image;
|
|
109
|
+
} else {
|
|
110
|
+
console.warn(`STATE_PAYLOAD_DOWNGRADED id=` + entityKey + ` chars=` + encoded.length.toString());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return descriptor;
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
async function processRecord(record, region, creds) {
|
|
93
117
|
let topicRoot = topicRootFromEventSourceArn(record.eventSourceARN);
|
|
94
118
|
if (topicRoot !== undefined) {
|
|
@@ -103,12 +127,7 @@ async function processRecord(record, region, creds) {
|
|
|
103
127
|
let entityKey = entityKeyFromRecord(dynamodb);
|
|
104
128
|
let channel = `/default/` + topicRoot + `/` + AppSyncEventsSigner_Ops$ReventlessAws.pathSegment(entityKey);
|
|
105
129
|
let unmarshalled = DynamoDb_Util_Helpers$AwsSdk.unmarshallDict(undefined, image);
|
|
106
|
-
let descriptor =
|
|
107
|
-
descriptor["changeKind"] = changeKindFor(record.eventName);
|
|
108
|
-
descriptor["id"] = entityKey;
|
|
109
|
-
Stdlib_Option.forEach(pickSortKeyValue(unmarshalled), v => {
|
|
110
|
-
descriptor["sortKeyValue"] = v;
|
|
111
|
-
});
|
|
130
|
+
let descriptor = makeDescriptor(changeKindFor(record.eventName), entityKey, unmarshalled, dynamodb.SequenceNumber);
|
|
112
131
|
let body = JSON.stringify(Object.fromEntries([
|
|
113
132
|
[
|
|
114
133
|
"id",
|
|
@@ -169,6 +188,8 @@ async function handler(event) {
|
|
|
169
188
|
}
|
|
170
189
|
}
|
|
171
190
|
|
|
191
|
+
let maxStateChars = 61440;
|
|
192
|
+
|
|
172
193
|
export {
|
|
173
194
|
endpoint,
|
|
174
195
|
topicMap,
|
|
@@ -177,6 +198,8 @@ export {
|
|
|
177
198
|
entityKeyFromRecord,
|
|
178
199
|
changeKindFor,
|
|
179
200
|
pickSortKeyValue,
|
|
201
|
+
maxStateChars,
|
|
202
|
+
makeDescriptor,
|
|
180
203
|
processRecord,
|
|
181
204
|
handler,
|
|
182
205
|
}
|
|
@@ -138,7 +138,7 @@ machinery: a stack that predates a change to this function empties its stores
|
|
|
138
138
|
(`seed:reset`, which wipes per plugin) and re-seeds. Carrying a permanent prefix
|
|
139
139
|
set to spare a disposable stack one wipe is the worse trade.
|
|
140
140
|
*/
|
|
141
|
-
let keyPrefixFor =
|
|
141
|
+
let keyPrefixFor = ReventlessCore.StoreLayout.keyPrefixFor
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
144
|
Who serves the declared stores — and the answer is never "both".
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
|
|
4
4
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as StoreLayout$ReventlessCore from "@reventlessdev/reventless-core/src/util/StoreLayout.res.mjs";
|
|
6
7
|
|
|
7
8
|
let defaultEphemeralPrefixes = ["pr-"];
|
|
8
9
|
|
|
@@ -31,10 +32,6 @@ function bucketNameFor(layout, stack, plugin, store) {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
function keyPrefixFor(plugin, store) {
|
|
35
|
-
return plugin + `/` + store;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
35
|
function servingFor(hasHostUiBundle, declaredBucketCount) {
|
|
39
36
|
if (declaredBucketCount !== 0) {
|
|
40
37
|
if (hasHostUiBundle) {
|
|
@@ -81,6 +78,8 @@ function pendingExpiryFor(config, store) {
|
|
|
81
78
|
})[0];
|
|
82
79
|
}
|
|
83
80
|
|
|
81
|
+
let keyPrefixFor = StoreLayout$ReventlessCore.keyPrefixFor;
|
|
82
|
+
|
|
84
83
|
export {
|
|
85
84
|
defaultEphemeralPrefixes,
|
|
86
85
|
layoutFor,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Parity of the live-update change descriptor across its three implementations:
|
|
2
|
+
//
|
|
3
|
+
// - LocalStateChangeDescriptor (reventless-local, both backends)
|
|
4
|
+
// - StateTopic_AppSync_Ops (DynamoDB stream relay)
|
|
5
|
+
// - StateTopicPublish.mjs (Postgres projection-side publisher)
|
|
6
|
+
//
|
|
7
|
+
// They share no code on purpose — the relay module stays Pulumi-free so a core
|
|
8
|
+
// import can't pull deploy-time code into its Lambda graph — which makes drift
|
|
9
|
+
// the standing risk: three sites, one wire format, and a browser that cannot tell
|
|
10
|
+
// which one produced a frame. This test is the guard.
|
|
11
|
+
//
|
|
12
|
+
// `seq` is normalised away by the harness because the three take it from different
|
|
13
|
+
// monotonic sources by design (a DynamoDB stream SequenceNumber vs a wall-clock
|
|
14
|
+
// counter); the harness still asserts each produced a non-empty string.
|
|
15
|
+
|
|
16
|
+
open JestGlobals
|
|
17
|
+
|
|
18
|
+
type case = {
|
|
19
|
+
name: string,
|
|
20
|
+
local: JSON.t,
|
|
21
|
+
relay: JSON.t,
|
|
22
|
+
postgres: JSON.t,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@module("./stateChangeDescriptorParity.mjs")
|
|
26
|
+
external buildAll: unit => promise<array<case>> = "buildAll"
|
|
27
|
+
|
|
28
|
+
describe("state-change descriptor parity", () => {
|
|
29
|
+
testAsync("every implementation builds the same descriptor", async () => {
|
|
30
|
+
let cases = await buildAll()
|
|
31
|
+
// A silent zero-case run would pass while asserting nothing.
|
|
32
|
+
expect(cases->Array.length)->toBe(6)
|
|
33
|
+
cases->Array.forEach(c => {
|
|
34
|
+
expect((c.name, c.relay))->toEqual((c.name, c.local))
|
|
35
|
+
expect((c.name, c.postgres))->toEqual((c.name, c.local))
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
testAsync("a save carries the row, a delete carries none", async () => {
|
|
40
|
+
let cases = await buildAll()
|
|
41
|
+
let stateOf = (d: JSON.t) =>
|
|
42
|
+
d->JSON.Decode.object->Option.flatMap(o => o->Dict.get("state"))
|
|
43
|
+
let byName = name => cases->Array.find(c => c.name == name)
|
|
44
|
+
|
|
45
|
+
switch byName("single-key save with updatedAt") {
|
|
46
|
+
| Some(c) => expect(stateOf(c.local)->Option.isSome)->toBe(true)
|
|
47
|
+
| None => expect("save case missing")->toBe("present")
|
|
48
|
+
}
|
|
49
|
+
switch byName("delete carries no row") {
|
|
50
|
+
| Some(c) => expect(stateOf(c.local))->toEqual(None)
|
|
51
|
+
| None => expect("delete case missing")->toBe("present")
|
|
52
|
+
}
|
|
53
|
+
switch byName("oversized row degrades to metadata only") {
|
|
54
|
+
| Some(c) => expect(stateOf(c.local))->toEqual(None)
|
|
55
|
+
| None => expect("oversized case missing")->toBe("present")
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as StateChangeDescriptorParityMjs from "./stateChangeDescriptorParity.mjs";
|
|
6
|
+
|
|
7
|
+
function buildAll(prim) {
|
|
8
|
+
return StateChangeDescriptorParityMjs.buildAll();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
globalThis.describe("state-change descriptor parity", () => {
|
|
12
|
+
globalThis.test("every implementation builds the same descriptor", async () => {
|
|
13
|
+
let cases = await StateChangeDescriptorParityMjs.buildAll();
|
|
14
|
+
globalThis.expect(cases.length).toBe(6);
|
|
15
|
+
cases.forEach(c => {
|
|
16
|
+
globalThis.expect([
|
|
17
|
+
c.name,
|
|
18
|
+
c.relay
|
|
19
|
+
]).toEqual([
|
|
20
|
+
c.name,
|
|
21
|
+
c.local
|
|
22
|
+
]);
|
|
23
|
+
globalThis.expect([
|
|
24
|
+
c.name,
|
|
25
|
+
c.postgres
|
|
26
|
+
]).toEqual([
|
|
27
|
+
c.name,
|
|
28
|
+
c.local
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
globalThis.test("a save carries the row, a delete carries none", async () => {
|
|
33
|
+
let cases = await StateChangeDescriptorParityMjs.buildAll();
|
|
34
|
+
let stateOf = d => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(d), o => o["state"]);
|
|
35
|
+
let byName = name => cases.find(c => c.name === name);
|
|
36
|
+
let c = byName("single-key save with updatedAt");
|
|
37
|
+
if (c !== undefined) {
|
|
38
|
+
globalThis.expect(Stdlib_Option.isSome(stateOf(c.local))).toBe(true);
|
|
39
|
+
} else {
|
|
40
|
+
globalThis.expect("save case missing").toBe("present");
|
|
41
|
+
}
|
|
42
|
+
let c$1 = byName("delete carries no row");
|
|
43
|
+
if (c$1 !== undefined) {
|
|
44
|
+
globalThis.expect(stateOf(c$1.local)).toEqual(undefined);
|
|
45
|
+
} else {
|
|
46
|
+
globalThis.expect("delete case missing").toBe("present");
|
|
47
|
+
}
|
|
48
|
+
let c$2 = byName("oversized row degrades to metadata only");
|
|
49
|
+
if (c$2 !== undefined) {
|
|
50
|
+
globalThis.expect(stateOf(c$2.local)).toEqual(undefined);
|
|
51
|
+
} else {
|
|
52
|
+
globalThis.expect("oversized case missing").toBe("present");
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
buildAll,
|
|
59
|
+
}
|
|
60
|
+
/* Not a pure module */
|
|
@@ -17,9 +17,14 @@ open JestGlobals
|
|
|
17
17
|
let run: string => promise<JSON.t> = %raw(`
|
|
18
18
|
async function(scenario) {
|
|
19
19
|
const mod = await import("../src/adapter/Runtime/StateTopicPublish.mjs");
|
|
20
|
-
const { withLiveUpdates } = mod;
|
|
20
|
+
const { withLiveUpdates, makeDescriptor } = mod;
|
|
21
21
|
const captured = [];
|
|
22
|
-
|
|
22
|
+
// Record the descriptor the real publisher would build alongside the call, with
|
|
23
|
+
// a fixed seq so assertions stay deterministic. sortKeyValue/state derivation
|
|
24
|
+
// lives in makeDescriptor, so this keeps it under test at the wrapper level.
|
|
25
|
+
const publish = async (call) => {
|
|
26
|
+
captured.push({ ...call, descriptor: makeDescriptor({ ...call, seq: "1" }) });
|
|
27
|
+
};
|
|
23
28
|
const ok = async () => ({ TAG: "Ok", _0: undefined });
|
|
24
29
|
|
|
25
30
|
// Mock ops. load returns the composite partition's rows for the enumerate case.
|
|
@@ -53,6 +58,9 @@ async function(scenario) {
|
|
|
53
58
|
case "save-no-sortkey":
|
|
54
59
|
await w(undefined).save("p1", { id: "p1" }, "Insert", undefined);
|
|
55
60
|
break;
|
|
61
|
+
case "save-oversized":
|
|
62
|
+
await w(undefined).save("p1", { id: "p1", blob: "x".repeat(70 * 1024) }, "Insert", undefined);
|
|
63
|
+
break;
|
|
56
64
|
case "saveBatch":
|
|
57
65
|
await w(undefined).saveBatch([
|
|
58
66
|
["a", { id: "a", updatedAt: "t1" }, undefined],
|
|
@@ -86,6 +94,11 @@ let getField = (call: JSON.t, key: string): option<JSON.t> =>
|
|
|
86
94
|
|
|
87
95
|
let str = (call, key) => getField(call, key)->Option.flatMap(JSON.Decode.string)
|
|
88
96
|
|
|
97
|
+
let descriptorField = (call: JSON.t, key: string): option<JSON.t> =>
|
|
98
|
+
getField(call, "descriptor")->Option.flatMap(d => getField(d, key))
|
|
99
|
+
|
|
100
|
+
let descriptorStr = (call, key) => descriptorField(call, key)->Option.flatMap(JSON.Decode.string)
|
|
101
|
+
|
|
89
102
|
let calls = (j: JSON.t): array<JSON.t> => j->JSON.Decode.array->Option.getOr([])
|
|
90
103
|
|
|
91
104
|
describe("withLiveUpdates (B3.3a Postgres live updates)", () => {
|
|
@@ -96,8 +109,11 @@ describe("withLiveUpdates (B3.3a Postgres live updates)", () => {
|
|
|
96
109
|
let call = arr->Array.getUnsafe(0)
|
|
97
110
|
expect(str(call, "entityKey"))->toEqual(Some("p1"))
|
|
98
111
|
expect(str(call, "changeKind"))->toEqual(Some("Updated"))
|
|
99
|
-
expect(
|
|
112
|
+
expect(descriptorStr(call, "sortKeyValue"))->toEqual(Some("2024-01-02"))
|
|
100
113
|
expect(str(call, "topicName"))->toEqual(Some("Shop_Orders"))
|
|
114
|
+
// The saved row rides along so a subscriber can apply it without refetching.
|
|
115
|
+
expect(descriptorField(call, "state"))->toEqual(getField(call, "state"))
|
|
116
|
+
expect(descriptorStr(call, "seq"))->toEqual(Some("1"))
|
|
101
117
|
})
|
|
102
118
|
|
|
103
119
|
testAsync("save on a composite table uses id-subKey and createdAt fallback", async () => {
|
|
@@ -105,13 +121,21 @@ describe("withLiveUpdates (B3.3a Postgres live updates)", () => {
|
|
|
105
121
|
let call = calls(c)->Array.getUnsafe(0)
|
|
106
122
|
expect(str(call, "entityKey"))->toEqual(Some("o1-L2"))
|
|
107
123
|
expect(str(call, "changeKind"))->toEqual(Some("Updated"))
|
|
108
|
-
expect(
|
|
124
|
+
expect(descriptorStr(call, "sortKeyValue"))->toEqual(Some("2024-03-04"))
|
|
109
125
|
})
|
|
110
126
|
|
|
111
127
|
testAsync("save without updatedAt/createdAt omits sortKeyValue", async () => {
|
|
112
128
|
let c = await run("save-no-sortkey")
|
|
113
129
|
let call = calls(c)->Array.getUnsafe(0)
|
|
114
|
-
expect(
|
|
130
|
+
expect(descriptorField(call, "sortKeyValue"))->toEqual(None)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
testAsync("an oversized row degrades to a metadata-only descriptor", async () => {
|
|
134
|
+
let c = await run("save-oversized")
|
|
135
|
+
let call = calls(c)->Array.getUnsafe(0)
|
|
136
|
+
expect(descriptorField(call, "state"))->toEqual(None)
|
|
137
|
+
expect(descriptorStr(call, "changeKind"))->toEqual(Some("Updated"))
|
|
138
|
+
expect(descriptorStr(call, "seq"))->toEqual(Some("1"))
|
|
115
139
|
})
|
|
116
140
|
|
|
117
141
|
testAsync("saveBatch publishes one Updated per item", async () => {
|
|
@@ -5,9 +5,14 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
5
5
|
|
|
6
6
|
let run = (async function(scenario) {
|
|
7
7
|
const mod = await import("../src/adapter/Runtime/StateTopicPublish.mjs");
|
|
8
|
-
const { withLiveUpdates } = mod;
|
|
8
|
+
const { withLiveUpdates, makeDescriptor } = mod;
|
|
9
9
|
const captured = [];
|
|
10
|
-
|
|
10
|
+
// Record the descriptor the real publisher would build alongside the call, with
|
|
11
|
+
// a fixed seq so assertions stay deterministic. sortKeyValue/state derivation
|
|
12
|
+
// lives in makeDescriptor, so this keeps it under test at the wrapper level.
|
|
13
|
+
const publish = async (call) => {
|
|
14
|
+
captured.push({ ...call, descriptor: makeDescriptor({ ...call, seq: "1" }) });
|
|
15
|
+
};
|
|
11
16
|
const ok = async () => ({ TAG: "Ok", _0: undefined });
|
|
12
17
|
|
|
13
18
|
// Mock ops. load returns the composite partition's rows for the enumerate case.
|
|
@@ -41,6 +46,9 @@ let run = (async function(scenario) {
|
|
|
41
46
|
case "save-no-sortkey":
|
|
42
47
|
await w(undefined).save("p1", { id: "p1" }, "Insert", undefined);
|
|
43
48
|
break;
|
|
49
|
+
case "save-oversized":
|
|
50
|
+
await w(undefined).save("p1", { id: "p1", blob: "x".repeat(70 * 1024) }, "Insert", undefined);
|
|
51
|
+
break;
|
|
44
52
|
case "saveBatch":
|
|
45
53
|
await w(undefined).saveBatch([
|
|
46
54
|
["a", { id: "a", updatedAt: "t1" }, undefined],
|
|
@@ -76,6 +84,14 @@ function str(call, key) {
|
|
|
76
84
|
return Stdlib_Option.flatMap(getField(call, key), Stdlib_JSON.Decode.string);
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
function descriptorField(call, key) {
|
|
88
|
+
return Stdlib_Option.flatMap(getField(call, "descriptor"), d => getField(d, key));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function descriptorStr(call, key) {
|
|
92
|
+
return Stdlib_Option.flatMap(descriptorField(call, key), Stdlib_JSON.Decode.string);
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
function calls(j) {
|
|
80
96
|
return Stdlib_Option.getOr(Stdlib_JSON.Decode.array(j), []);
|
|
81
97
|
}
|
|
@@ -88,20 +104,29 @@ globalThis.describe("withLiveUpdates (B3.3a Postgres live updates)", () => {
|
|
|
88
104
|
let call = arr[0];
|
|
89
105
|
globalThis.expect(str(call, "entityKey")).toEqual("p1");
|
|
90
106
|
globalThis.expect(str(call, "changeKind")).toEqual("Updated");
|
|
91
|
-
globalThis.expect(
|
|
107
|
+
globalThis.expect(descriptorStr(call, "sortKeyValue")).toEqual("2024-01-02");
|
|
92
108
|
globalThis.expect(str(call, "topicName")).toEqual("Shop_Orders");
|
|
109
|
+
globalThis.expect(descriptorField(call, "state")).toEqual(getField(call, "state"));
|
|
110
|
+
globalThis.expect(descriptorStr(call, "seq")).toEqual("1");
|
|
93
111
|
});
|
|
94
112
|
globalThis.test("save on a composite table uses id-subKey and createdAt fallback", async () => {
|
|
95
113
|
let c = await run("save-composite");
|
|
96
114
|
let call = calls(c)[0];
|
|
97
115
|
globalThis.expect(str(call, "entityKey")).toEqual("o1-L2");
|
|
98
116
|
globalThis.expect(str(call, "changeKind")).toEqual("Updated");
|
|
99
|
-
globalThis.expect(
|
|
117
|
+
globalThis.expect(descriptorStr(call, "sortKeyValue")).toEqual("2024-03-04");
|
|
100
118
|
});
|
|
101
119
|
globalThis.test("save without updatedAt/createdAt omits sortKeyValue", async () => {
|
|
102
120
|
let c = await run("save-no-sortkey");
|
|
103
121
|
let call = calls(c)[0];
|
|
104
|
-
globalThis.expect(
|
|
122
|
+
globalThis.expect(descriptorField(call, "sortKeyValue")).toEqual(undefined);
|
|
123
|
+
});
|
|
124
|
+
globalThis.test("an oversized row degrades to a metadata-only descriptor", async () => {
|
|
125
|
+
let c = await run("save-oversized");
|
|
126
|
+
let call = calls(c)[0];
|
|
127
|
+
globalThis.expect(descriptorField(call, "state")).toEqual(undefined);
|
|
128
|
+
globalThis.expect(descriptorStr(call, "changeKind")).toEqual("Updated");
|
|
129
|
+
globalThis.expect(descriptorStr(call, "seq")).toEqual("1");
|
|
105
130
|
});
|
|
106
131
|
globalThis.test("saveBatch publishes one Updated per item", async () => {
|
|
107
132
|
let c = await run("saveBatch");
|
|
@@ -153,6 +178,8 @@ export {
|
|
|
153
178
|
run,
|
|
154
179
|
getField,
|
|
155
180
|
str,
|
|
181
|
+
descriptorField,
|
|
182
|
+
descriptorStr,
|
|
156
183
|
calls,
|
|
157
184
|
}
|
|
158
185
|
/* run Not a pure module */
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Harness for StateChangeDescriptorParityTest.
|
|
2
|
+
//
|
|
3
|
+
// The live-update change descriptor has three independent implementations and no
|
|
4
|
+
// shared code (the DynamoDB relay's module is deliberately Pulumi-free, so it
|
|
5
|
+
// cannot import a core builder without risking deploy-time code in its Lambda
|
|
6
|
+
// graph). This harness drives all three through the same logical changes and
|
|
7
|
+
// hands the results back for comparison.
|
|
8
|
+
//
|
|
9
|
+
// Plain ESM rather than ReScript because two of the three modules live outside
|
|
10
|
+
// this package and one is hand-written JS; the assertions stay in the .res test.
|
|
11
|
+
|
|
12
|
+
const LOCAL = "../../local/src/adapter/LocalStateChangeDescriptor.res.mjs";
|
|
13
|
+
const RELAY = "../src/adapter/StateTopic/StateTopic_AppSync_Ops.res.mjs";
|
|
14
|
+
const POSTGRES = "../src/adapter/Runtime/StateTopicPublish.mjs";
|
|
15
|
+
|
|
16
|
+
// Each case is one logical change expressed three ways. `changeKind` is already
|
|
17
|
+
// normalised: the three sites derive it differently (local compares against the
|
|
18
|
+
// stored row, the relay maps the stream eventName, Postgres has only upserts), and
|
|
19
|
+
// that derivation is covered by their own tests — what is asserted here is that
|
|
20
|
+
// the same logical change produces the same descriptor.
|
|
21
|
+
const CASES = [
|
|
22
|
+
{
|
|
23
|
+
name: "single-key save with updatedAt",
|
|
24
|
+
changeKind: "Updated",
|
|
25
|
+
entityKey: "p1",
|
|
26
|
+
state: { id: "p1", name: "Widget", updatedAt: "2026-05-19T12:00:00Z" },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "composite-key save falling back to createdAt",
|
|
30
|
+
changeKind: "Updated",
|
|
31
|
+
entityKey: "o1-L2",
|
|
32
|
+
state: { id: "o1", lineId: "L2", createdAt: "2026-03-04T08:00:00Z" },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "save with no sort timestamp",
|
|
36
|
+
changeKind: "Updated",
|
|
37
|
+
entityKey: "p1",
|
|
38
|
+
state: { id: "p1", name: "Widget" },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "first save of a key",
|
|
42
|
+
changeKind: "Added",
|
|
43
|
+
entityKey: "p2",
|
|
44
|
+
state: { id: "p2", name: "Gadget", updatedAt: "2026-05-19T12:00:00Z" },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "oversized row degrades to metadata only",
|
|
48
|
+
changeKind: "Updated",
|
|
49
|
+
entityKey: "p3",
|
|
50
|
+
state: { id: "p3", blob: "x".repeat(70 * 1024), updatedAt: "2026-05-19T12:00:00Z" },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "delete carries no row",
|
|
54
|
+
changeKind: "Removed",
|
|
55
|
+
entityKey: "p1",
|
|
56
|
+
state: undefined,
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build every case with every implementation.
|
|
62
|
+
*
|
|
63
|
+
* Returns `[{ name, local, relay, postgres }]`, each descriptor a plain object
|
|
64
|
+
* with `seq` replaced by the literal "<seq>" — the three sites take their
|
|
65
|
+
* sequence from different monotonic sources by design (stream SequenceNumber vs
|
|
66
|
+
* a wall-clock counter), so the values differ while the field's presence and
|
|
67
|
+
* type must not.
|
|
68
|
+
*/
|
|
69
|
+
export async function buildAll() {
|
|
70
|
+
const local = await import(LOCAL);
|
|
71
|
+
const relay = await import(RELAY);
|
|
72
|
+
const postgres = await import(POSTGRES);
|
|
73
|
+
|
|
74
|
+
const normalise = (descriptor) => {
|
|
75
|
+
const seq = descriptor.seq;
|
|
76
|
+
if (typeof seq !== "string" || seq.length === 0) {
|
|
77
|
+
throw new Error("descriptor is missing a string seq: " + JSON.stringify(descriptor));
|
|
78
|
+
}
|
|
79
|
+
return { ...descriptor, seq: "<seq>" };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return CASES.map(({ name, changeKind, entityKey, state }) => ({
|
|
83
|
+
name,
|
|
84
|
+
local: normalise(
|
|
85
|
+
local.make(changeKind, entityKey, state === undefined ? undefined : state, local.nextSequence()),
|
|
86
|
+
),
|
|
87
|
+
relay: normalise(
|
|
88
|
+
relay.makeDescriptor(
|
|
89
|
+
changeKind,
|
|
90
|
+
entityKey,
|
|
91
|
+
// The relay unmarshalls the stream image before building the descriptor,
|
|
92
|
+
// so it takes the same plain row the other two do. A REMOVE is handed the
|
|
93
|
+
// OldImage, which makeDescriptor then drops.
|
|
94
|
+
state === undefined ? { id: entityKey } : state,
|
|
95
|
+
"49590300000000016818000000",
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
postgres: normalise(
|
|
99
|
+
postgres.makeDescriptor({ changeKind, entityKey, state, seq: postgres.nextSequence() }),
|
|
100
|
+
),
|
|
101
|
+
}));
|
|
102
|
+
}
|