@reventlessdev/reventless-aws 3.0.0-alpha.174 → 3.0.0-alpha.176
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 +21 -0
- package/package.json +10 -10
- package/src/Platform.res +115 -31
- package/src/Platform.res.mjs +217 -44
- package/src/adapter/DcbEventLog/DcbBackend.res +2 -2
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res +4 -1
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res.mjs +1 -1
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res +14 -4
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res.mjs +19 -14
- package/src/adapter/EventLog/EventLogBackend.res +82 -0
- package/src/adapter/EventLog/EventLogBackend.res.mjs +53 -0
- package/src/adapter/EventLog/EventLogStorage.res +15 -0
- package/src/adapter/EventLog/EventLogStorage.res.mjs +20 -1
- package/src/adapter/EventLog/EventLogStorage_Postgres.res +37 -0
- package/src/adapter/EventLog/EventLogStorage_Postgres.res.mjs +21 -0
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +26 -11
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +15 -6
- package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +78 -7
- package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res.mjs +30 -2
- package/src/adapter/Postgres/PgProjectionFeed.res +100 -0
- package/src/adapter/Postgres/PgProjectionFeed.res.mjs +54 -0
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +192 -0
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +141 -0
- package/src/adapter/QueryDb/QueryDbBackend.res +52 -0
- package/src/adapter/QueryDb/QueryDbBackend.res.mjs +44 -0
- package/src/adapter/QueryDb/QueryDbResolvers.res +48 -0
- package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +17 -1
- package/src/adapter/QueryDb/QueryDbStorage.res +44 -0
- package/src/adapter/QueryDb/QueryDbStorage.res.mjs +34 -1
- package/src/adapter/QueryDb/QueryDbStorage_Postgres.res +43 -0
- package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +21 -0
- package/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res +19 -0
- package/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs +15 -0
- package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res +12 -1
- package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res.mjs +2 -1
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +83 -3
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +46 -3
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +83 -3
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +48 -3
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +130 -5
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +66 -6
- package/src/adapter/Runtime/PgChangeFeedRelayEntryPoint.mjs +15 -10
- package/src/adapter/Runtime/ReadModelEntryPoint.mjs +32 -15
- package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +26 -12
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +138 -8
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +76 -11
- package/src/components/Aggregate_Builder_Single.res +12 -5
- package/src/components/Aggregate_Builder_Single.res.mjs +12 -6
- package/src/components/Aggregate_Builder_Single_Async.res +12 -5
- package/src/components/Aggregate_Builder_Single_Async.res.mjs +12 -6
- package/src/components/ReadModel_Builder_NoResolver.res.mjs +1 -1
- package/src/components/ReadModel_Builder_NoResolver_Stream.res +13 -4
- package/src/components/ReadModel_Builder_NoResolver_Stream.res.mjs +14 -9
- package/src/components/ReadModel_Builder_Single.res +14 -5
- package/src/components/ReadModel_Builder_Single.res.mjs +15 -12
- package/src/components/ReadModel_Builder_Single_Stream.res +14 -5
- package/src/components/ReadModel_Builder_Single_Stream.res.mjs +15 -12
- package/src/components/StateViewSlice_Builder.res +10 -4
- package/src/components/StateViewSlice_Builder.res.mjs +8 -10
- package/src/components/StateViewSlice_Builder_Stream.res +10 -4
- package/src/components/StateViewSlice_Builder_Stream.res.mjs +8 -10
- package/src/plugin/runtime/PluginRuntime_Builder.res +12 -0
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +4 -0
- package/tests/EventCollectorChannelStreamRuntimeTest.res +55 -0
- package/tests/EventCollectorChannelStreamRuntimeTest.res.mjs +78 -0
- package/tests/PgChangeFeedRelay_IntegrationTest.res +259 -0
- package/tests/PgChangeFeedRelay_IntegrationTest.res.mjs +238 -0
- package/tests/PgPipeline_IntegrationTest.res +208 -0
- package/tests/PgPipeline_IntegrationTest.res.mjs +260 -0
- package/tests/PgQueryResolver_LambdaTest.res +206 -0
- package/tests/PgQueryResolver_LambdaTest.res.mjs +259 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// End-to-end Postgres projection pipeline (B1 + B3.0 + B3.1 composed), minus AWS.
|
|
2
|
+
//
|
|
3
|
+
// Skipped unless PG_URL is set. Exercises the full deployed classic path against
|
|
4
|
+
// a real database:
|
|
5
|
+
// classic append (`event_log`) → change-feed relay (`relayClassicWithPool`,
|
|
6
|
+
// emitting the EventCollector `{id, meta, event}` bodies) → feed-queue SQS
|
|
7
|
+
// records → `handleStreamEvent` decode → a real `ReadModel_Callback` projection
|
|
8
|
+
// (meta.service dispatch) → `QueryDbStorage_Postgres` write → `qdb_` row.
|
|
9
|
+
//
|
|
10
|
+
// This is the composition the first live deploy will exercise; the only pieces
|
|
11
|
+
// it can't cover are the AWS boundary itself (SQS delivery, ESMs, VPC).
|
|
12
|
+
|
|
13
|
+
open JestGlobals
|
|
14
|
+
open ReventlessCore
|
|
15
|
+
|
|
16
|
+
@val external processEnv: dict<string> = "process.env"
|
|
17
|
+
|
|
18
|
+
// --- Fixtures: one classic source, one read model, one projection mapping ---
|
|
19
|
+
|
|
20
|
+
module OrderSource = {
|
|
21
|
+
module Id = Reventless.Id.StringPure
|
|
22
|
+
let name = "Order"
|
|
23
|
+
|
|
24
|
+
@schema
|
|
25
|
+
type event = OrderPlaced({product: string})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module OrdersSpec = {
|
|
29
|
+
module Id = Reventless.Id.StringPure
|
|
30
|
+
let name = "PipelineOrders"
|
|
31
|
+
let moduleUrl = ""
|
|
32
|
+
|
|
33
|
+
@schema
|
|
34
|
+
type state = {id: string, product: string}
|
|
35
|
+
|
|
36
|
+
let config = Reventless.ReadModel.config()
|
|
37
|
+
let subIdConfig = None
|
|
38
|
+
let authorization: Reventless.Authorization.permission = AllowAuthenticated
|
|
39
|
+
let visibility: Reventless.Visibility.t = Public
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module OrderMapping = Reventless.Projection.Mapping.Make(
|
|
43
|
+
OrderSource,
|
|
44
|
+
OrdersSpec,
|
|
45
|
+
{
|
|
46
|
+
let project = ({id, event, _}: Reventless.Message.event'<string, OrderSource.event>) =>
|
|
47
|
+
switch event {
|
|
48
|
+
| OrderPlaced({product}) => Reventless.Projection.Set(id, ({id, product}: OrdersSpec.state))
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
module OrdersMappings = {
|
|
54
|
+
module Target = OrdersSpec
|
|
55
|
+
module M = Reventless.Projection.Mappings.Make(OrdersSpec)
|
|
56
|
+
module type Mapping = M.Mapping
|
|
57
|
+
let moduleUrl = ""
|
|
58
|
+
let mappings: array<module(Mapping)> = [module(OrderMapping)]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Helpers ---
|
|
62
|
+
|
|
63
|
+
// The flat on-disk item the classic append stores verbatim — same shape the
|
|
64
|
+
// DynamoDB path puts (id, position, event, data, decomposed meta). `service`
|
|
65
|
+
// must be the SOURCE name: ReadModel_Callback dispatches mappings by
|
|
66
|
+
// meta.service (the repeatedly-bitten projection dispatch key).
|
|
67
|
+
let flatItem = (~id, ~seq, ~service, ~event: OrderSource.event) => {
|
|
68
|
+
let (eventType, data) =
|
|
69
|
+
event
|
|
70
|
+
->S.reverseConvertToJsonOrThrow(OrderSource.eventSchema)
|
|
71
|
+
->Message.splitMessage
|
|
72
|
+
[
|
|
73
|
+
("id", JSON.Encode.string(id)),
|
|
74
|
+
("position", JSON.Encode.string(seq->Int.toString->String.padStart(9, "0"))),
|
|
75
|
+
("event", JSON.Encode.string(eventType)),
|
|
76
|
+
("data", JSON.Encode.object(data)),
|
|
77
|
+
]
|
|
78
|
+
->Array.concat(Message.generateMeta(~service)->Message.decomposeMeta)
|
|
79
|
+
->Dict.fromArray
|
|
80
|
+
->JSON.Encode.object
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Wrap relay bodies the way the deployed feed queue delivers them.
|
|
84
|
+
let asSqsEvent = (bodies: array<JSON.t>): PulumiAws.Lambda.CallbackFunction.event =>
|
|
85
|
+
Obj.magic({
|
|
86
|
+
"Records": bodies->Array.map(body =>
|
|
87
|
+
{
|
|
88
|
+
"eventSource": "aws:sqs",
|
|
89
|
+
"eventSourceARN": "arn:aws:sqs:eu-west-1:1:AllReadModelsFeed",
|
|
90
|
+
"body": JSON.stringify(body),
|
|
91
|
+
}
|
|
92
|
+
),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
switch processEnv->Dict.get("PG_URL") {
|
|
96
|
+
| None =>
|
|
97
|
+
testSync("Postgres pipeline integration (skipped — set PG_URL to run)", () =>
|
|
98
|
+
expect(true)->toBe(true)
|
|
99
|
+
)
|
|
100
|
+
| Some(url) =>
|
|
101
|
+
let pool = ReventlessPostgres.PgDriver.makePool({connectionString: url})
|
|
102
|
+
|
|
103
|
+
let qdbOps = ReventlessPostgres.QueryDbStorage_Postgres.makeOperations(
|
|
104
|
+
~pool,
|
|
105
|
+
~name=OrdersSpec.name,
|
|
106
|
+
~indexes=[],
|
|
107
|
+
~subIdField=None,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
module Callback = ReadModel_Callback.Make(
|
|
111
|
+
OrdersSpec,
|
|
112
|
+
OrdersMappings,
|
|
113
|
+
{
|
|
114
|
+
module ReadModelSpec = OrdersSpec
|
|
115
|
+
// Production passes the raw JSON op set (ReadModelEntryPoint) — actions are
|
|
116
|
+
// rewritten to JSON via stateSchema before handleAction, so the JSON-level
|
|
117
|
+
// ops satisfy the typed slot exactly as deployed.
|
|
118
|
+
let operations: QueryDb.operations<string, OrdersSpec.state> = Obj.magic(qdbOps)
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
beforeAllAsync(async () => {
|
|
123
|
+
await ReventlessPostgres.PgSchema.ensureSchema(pool)
|
|
124
|
+
await ReventlessPostgres.PgSchema.truncateAll(pool)
|
|
125
|
+
let _ = await pool->ReventlessPostgres.PgDriver.query(
|
|
126
|
+
`DROP TABLE IF EXISTS qdb_${OrdersSpec.name}`,
|
|
127
|
+
[],
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
afterAll(() => {
|
|
131
|
+
let _ = pool->ReventlessPostgres.PgDriver.endPool
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe("Postgres pipeline (append → relay → feed decode → projection → qdb row)", () => {
|
|
135
|
+
testPromise("projects a classic event end-to-end into the Postgres QueryDb", async () => {
|
|
136
|
+
let (_n, elOps, _s) =
|
|
137
|
+
ReventlessPostgres.EventLogStorage_Postgres.makeStorage(
|
|
138
|
+
~pool,
|
|
139
|
+
~name="PipelineOrderEventLog",
|
|
140
|
+
~opts=(),
|
|
141
|
+
)
|
|
142
|
+
let _ = await elOps.append(
|
|
143
|
+
0,
|
|
144
|
+
"o-1",
|
|
145
|
+
[flatItem(~id="o-1", ~seq=0, ~service=OrderSource.name, ~event=OrderPlaced({product: "boat"}))],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
// Relay drains event_log and emits the EventCollector bodies.
|
|
149
|
+
let sink = ref([])
|
|
150
|
+
let relayed = await PgChangeFeedRelay_Runtime.relayClassicWithPool(
|
|
151
|
+
~pool,
|
|
152
|
+
~logName="PipelineOrderEventLog",
|
|
153
|
+
~subscriber="aws-rm-feed:PipelineOrderEventLog",
|
|
154
|
+
~sendBatch=async jsons => sink := sink.contents->Array.concat(jsons),
|
|
155
|
+
)
|
|
156
|
+
expect(relayed)->toBe(1)
|
|
157
|
+
|
|
158
|
+
// Feed-queue delivery → entry-point decode → real projection callback.
|
|
159
|
+
let _ = await EventCollectorChannel_DynamoDbStream_Runtime.handleStreamEvent(
|
|
160
|
+
Callback.handleJsonEvents,
|
|
161
|
+
asSqsEvent(sink.contents),
|
|
162
|
+
(),
|
|
163
|
+
)->Effect.runPromise
|
|
164
|
+
|
|
165
|
+
// The projected row is in the Postgres QueryDb.
|
|
166
|
+
switch await qdbOps.load("o-1") {
|
|
167
|
+
| Ok(rows) =>
|
|
168
|
+
expect(rows->Array.length)->toBe(1)
|
|
169
|
+
let row = rows->Array.getUnsafe(0)->JSON.Decode.object->Option.getOrThrow
|
|
170
|
+
expect(row->Dict.get("product"))->toEqual(Some(JSON.Encode.string("boat")))
|
|
171
|
+
expect(row->Dict.get("id"))->toEqual(Some(JSON.Encode.string("o-1")))
|
|
172
|
+
| Error(_) => expect("load failed")->toBe("Ok")
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
testPromise("meta.service mismatch is a silent no-op (dispatch filter)", async () => {
|
|
177
|
+
let (_n, elOps, _s) =
|
|
178
|
+
ReventlessPostgres.EventLogStorage_Postgres.makeStorage(
|
|
179
|
+
~pool,
|
|
180
|
+
~name="PipelineWrongServiceEventLog",
|
|
181
|
+
~opts=(),
|
|
182
|
+
)
|
|
183
|
+
let _ = await elOps.append(
|
|
184
|
+
0,
|
|
185
|
+
"o-2",
|
|
186
|
+
[flatItem(~id="o-2", ~seq=0, ~service="NotTheSource", ~event=OrderPlaced({product: "x"}))],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
let sink = ref([])
|
|
190
|
+
let _ = await PgChangeFeedRelay_Runtime.relayClassicWithPool(
|
|
191
|
+
~pool,
|
|
192
|
+
~logName="PipelineWrongServiceEventLog",
|
|
193
|
+
~subscriber="aws-rm-feed:PipelineWrongServiceEventLog",
|
|
194
|
+
~sendBatch=async jsons => sink := sink.contents->Array.concat(jsons),
|
|
195
|
+
)
|
|
196
|
+
let _ = await EventCollectorChannel_DynamoDbStream_Runtime.handleStreamEvent(
|
|
197
|
+
Callback.handleJsonEvents,
|
|
198
|
+
asSqsEvent(sink.contents),
|
|
199
|
+
(),
|
|
200
|
+
)->Effect.runPromise
|
|
201
|
+
|
|
202
|
+
switch await qdbOps.load("o-2") {
|
|
203
|
+
| Ok(rows) => expect(rows->Array.length)->toBe(0)
|
|
204
|
+
| Error(_) => expect("load failed")->toBe("Ok")
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
6
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
|
|
9
|
+
import * as Projection$Reventless from "@reventlessdev/reventless-spec/src/types/Projection.res.mjs";
|
|
10
|
+
import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
|
|
11
|
+
import * as PgDriver$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/PgDriver.res.mjs";
|
|
12
|
+
import * as PgSchema$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/PgSchema.res.mjs";
|
|
13
|
+
import * as ReadModel_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/ReadModel/ReadModel_Callback.res.mjs";
|
|
14
|
+
import * as PgChangeFeedRelay_Runtime$ReventlessAws from "../src/adapter/Postgres/PgChangeFeedRelay_Runtime.res.mjs";
|
|
15
|
+
import * as QueryDbStorage_Postgres$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/QueryDbStorage_Postgres.res.mjs";
|
|
16
|
+
import * as EventLogStorage_Postgres$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/EventLogStorage_Postgres.res.mjs";
|
|
17
|
+
import * as EventCollectorChannel_DynamoDbStream_Runtime$ReventlessAws from "../src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res.mjs";
|
|
18
|
+
|
|
19
|
+
let name = "Order";
|
|
20
|
+
|
|
21
|
+
let eventSchema = S.schema(s => ({
|
|
22
|
+
TAG: "OrderPlaced",
|
|
23
|
+
product: s.m(S.string)
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
let OrderSource = {
|
|
27
|
+
Id: undefined,
|
|
28
|
+
name: name,
|
|
29
|
+
eventSchema: eventSchema
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let name$1 = "PipelineOrders";
|
|
33
|
+
|
|
34
|
+
let moduleUrl = "";
|
|
35
|
+
|
|
36
|
+
let stateSchema = S.schema(s => ({
|
|
37
|
+
id: s.m(S.string),
|
|
38
|
+
product: s.m(S.string)
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
let config = ReadModel$Reventless.config(undefined, undefined, undefined);
|
|
42
|
+
|
|
43
|
+
let OrdersSpec = {
|
|
44
|
+
Id: undefined,
|
|
45
|
+
name: name$1,
|
|
46
|
+
moduleUrl: moduleUrl,
|
|
47
|
+
stateSchema: stateSchema,
|
|
48
|
+
config: config,
|
|
49
|
+
subIdConfig: undefined,
|
|
50
|
+
authorization: "AllowAuthenticated",
|
|
51
|
+
visibility: "Public"
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function project(param) {
|
|
55
|
+
let id = param.id;
|
|
56
|
+
return {
|
|
57
|
+
TAG: "Set",
|
|
58
|
+
_0: id,
|
|
59
|
+
_1: {
|
|
60
|
+
id: id,
|
|
61
|
+
product: param.event.product
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let OrderMapping = Projection$Reventless.Mapping.Make({
|
|
67
|
+
Id: {
|
|
68
|
+
schema: Id$Reventless.StringPure.schema,
|
|
69
|
+
make: prim => prim,
|
|
70
|
+
makeFromString: prim => prim,
|
|
71
|
+
toString: prim => prim,
|
|
72
|
+
cmp: Id$Reventless.StringPure.cmp
|
|
73
|
+
},
|
|
74
|
+
name: name,
|
|
75
|
+
eventSchema: eventSchema
|
|
76
|
+
})({
|
|
77
|
+
Id: {
|
|
78
|
+
schema: Id$Reventless.StringPure.schema,
|
|
79
|
+
make: prim => prim,
|
|
80
|
+
makeFromString: prim => prim,
|
|
81
|
+
toString: prim => prim,
|
|
82
|
+
cmp: Id$Reventless.StringPure.cmp
|
|
83
|
+
},
|
|
84
|
+
name: name$1,
|
|
85
|
+
stateSchema: stateSchema,
|
|
86
|
+
subIdConfig: undefined
|
|
87
|
+
})({
|
|
88
|
+
project: project
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
let M = Projection$Reventless.Mappings.Make({
|
|
92
|
+
Id: {
|
|
93
|
+
schema: Id$Reventless.StringPure.schema,
|
|
94
|
+
make: prim => prim,
|
|
95
|
+
makeFromString: prim => prim,
|
|
96
|
+
toString: prim => prim,
|
|
97
|
+
cmp: Id$Reventless.StringPure.cmp
|
|
98
|
+
},
|
|
99
|
+
name: name$1,
|
|
100
|
+
stateSchema: stateSchema,
|
|
101
|
+
subIdConfig: undefined
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
let moduleUrl$1 = "";
|
|
105
|
+
|
|
106
|
+
let mappings = [OrderMapping];
|
|
107
|
+
|
|
108
|
+
let OrdersMappings = {
|
|
109
|
+
Target: undefined,
|
|
110
|
+
M: M,
|
|
111
|
+
moduleUrl: moduleUrl$1,
|
|
112
|
+
mappings: mappings
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function flatItem(id, seq, service, event) {
|
|
116
|
+
let match = Message$ReventlessCore.splitMessage(S.reverseConvertToJsonOrThrow(event, eventSchema));
|
|
117
|
+
return Object.fromEntries([
|
|
118
|
+
[
|
|
119
|
+
"id",
|
|
120
|
+
id
|
|
121
|
+
],
|
|
122
|
+
[
|
|
123
|
+
"position",
|
|
124
|
+
seq.toString().padStart(9, "0")
|
|
125
|
+
],
|
|
126
|
+
[
|
|
127
|
+
"event",
|
|
128
|
+
match[0]
|
|
129
|
+
],
|
|
130
|
+
[
|
|
131
|
+
"data",
|
|
132
|
+
match[1]
|
|
133
|
+
]
|
|
134
|
+
].concat(Message$ReventlessCore.decomposeMeta(Message$ReventlessCore.generateMeta(service, undefined, undefined, undefined, undefined, undefined, undefined, undefined))));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function asSqsEvent(bodies) {
|
|
138
|
+
return {
|
|
139
|
+
Records: bodies.map(body => ({
|
|
140
|
+
eventSource: "aws:sqs",
|
|
141
|
+
eventSourceARN: "arn:aws:sqs:eu-west-1:1:AllReadModelsFeed",
|
|
142
|
+
body: JSON.stringify(body)
|
|
143
|
+
}))
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let url = process.env["PG_URL"];
|
|
148
|
+
|
|
149
|
+
if (url !== undefined) {
|
|
150
|
+
let pool = PgDriver$ReventlessPostgres.makePool({
|
|
151
|
+
connectionString: url
|
|
152
|
+
});
|
|
153
|
+
let qdbOps = QueryDbStorage_Postgres$ReventlessPostgres.makeOperations(pool, name$1, [], undefined);
|
|
154
|
+
let Callback = ReadModel_Callback$ReventlessCore.Make({
|
|
155
|
+
Id: {
|
|
156
|
+
schema: Id$Reventless.StringPure.schema,
|
|
157
|
+
make: prim => prim,
|
|
158
|
+
makeFromString: prim => prim,
|
|
159
|
+
toString: prim => prim,
|
|
160
|
+
cmp: Id$Reventless.StringPure.cmp
|
|
161
|
+
},
|
|
162
|
+
name: name$1,
|
|
163
|
+
moduleUrl: moduleUrl,
|
|
164
|
+
stateSchema: stateSchema,
|
|
165
|
+
config: config,
|
|
166
|
+
subIdConfig: undefined,
|
|
167
|
+
authorization: "AllowAuthenticated",
|
|
168
|
+
visibility: "Public"
|
|
169
|
+
})({
|
|
170
|
+
moduleUrl: moduleUrl$1,
|
|
171
|
+
mappings: mappings
|
|
172
|
+
})({
|
|
173
|
+
ReadModelSpec: {
|
|
174
|
+
Id: {
|
|
175
|
+
schema: Id$Reventless.StringPure.schema,
|
|
176
|
+
make: prim => prim,
|
|
177
|
+
makeFromString: prim => prim,
|
|
178
|
+
toString: prim => prim,
|
|
179
|
+
cmp: Id$Reventless.StringPure.cmp
|
|
180
|
+
},
|
|
181
|
+
name: name$1,
|
|
182
|
+
moduleUrl: moduleUrl,
|
|
183
|
+
stateSchema: stateSchema,
|
|
184
|
+
config: config,
|
|
185
|
+
subIdConfig: undefined,
|
|
186
|
+
authorization: "AllowAuthenticated",
|
|
187
|
+
visibility: "Public"
|
|
188
|
+
},
|
|
189
|
+
operations: qdbOps
|
|
190
|
+
});
|
|
191
|
+
globalThis.beforeAll(async () => {
|
|
192
|
+
await PgSchema$ReventlessPostgres.ensureSchema(pool);
|
|
193
|
+
await PgSchema$ReventlessPostgres.truncateAll(pool);
|
|
194
|
+
await PgDriver$ReventlessPostgres.query(pool, `DROP TABLE IF EXISTS qdb_` + name$1, []);
|
|
195
|
+
});
|
|
196
|
+
globalThis.afterAll(() => {
|
|
197
|
+
PgDriver$ReventlessPostgres.endPool(pool);
|
|
198
|
+
});
|
|
199
|
+
globalThis.describe("Postgres pipeline (append → relay → feed decode → projection → qdb row)", () => {
|
|
200
|
+
globalThis.test("projects a classic event end-to-end into the Postgres QueryDb", async () => {
|
|
201
|
+
let match = EventLogStorage_Postgres$ReventlessPostgres.makeStorage(pool, "PipelineOrderEventLog", undefined, undefined);
|
|
202
|
+
await match[1].append(0, "o-1", [flatItem("o-1", 0, name, {
|
|
203
|
+
TAG: "OrderPlaced",
|
|
204
|
+
product: "boat"
|
|
205
|
+
})]);
|
|
206
|
+
let sink = {
|
|
207
|
+
contents: []
|
|
208
|
+
};
|
|
209
|
+
let relayed = await PgChangeFeedRelay_Runtime$ReventlessAws.relayClassicWithPool(pool, "PipelineOrderEventLog", "aws-rm-feed:PipelineOrderEventLog", async jsons => {
|
|
210
|
+
sink.contents = sink.contents.concat(jsons);
|
|
211
|
+
});
|
|
212
|
+
globalThis.expect(relayed).toBe(1);
|
|
213
|
+
await Effect.runPromise(EventCollectorChannel_DynamoDbStream_Runtime$ReventlessAws.handleStreamEvent(Callback.handleJsonEvents, asSqsEvent(sink.contents), undefined));
|
|
214
|
+
let rows = await qdbOps.load("o-1");
|
|
215
|
+
if (rows.TAG === "Ok") {
|
|
216
|
+
let rows$1 = rows._0;
|
|
217
|
+
globalThis.expect(rows$1.length).toBe(1);
|
|
218
|
+
let row = Stdlib_Option.getOrThrow(Stdlib_JSON.Decode.object(rows$1[0]), undefined);
|
|
219
|
+
globalThis.expect(row["product"]).toEqual("boat");
|
|
220
|
+
globalThis.expect(row["id"]).toEqual("o-1");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
globalThis.expect("load failed").toBe("Ok");
|
|
224
|
+
});
|
|
225
|
+
globalThis.test("meta.service mismatch is a silent no-op (dispatch filter)", async () => {
|
|
226
|
+
let match = EventLogStorage_Postgres$ReventlessPostgres.makeStorage(pool, "PipelineWrongServiceEventLog", undefined, undefined);
|
|
227
|
+
await match[1].append(0, "o-2", [flatItem("o-2", 0, "NotTheSource", {
|
|
228
|
+
TAG: "OrderPlaced",
|
|
229
|
+
product: "x"
|
|
230
|
+
})]);
|
|
231
|
+
let sink = {
|
|
232
|
+
contents: []
|
|
233
|
+
};
|
|
234
|
+
await PgChangeFeedRelay_Runtime$ReventlessAws.relayClassicWithPool(pool, "PipelineWrongServiceEventLog", "aws-rm-feed:PipelineWrongServiceEventLog", async jsons => {
|
|
235
|
+
sink.contents = sink.contents.concat(jsons);
|
|
236
|
+
});
|
|
237
|
+
await Effect.runPromise(EventCollectorChannel_DynamoDbStream_Runtime$ReventlessAws.handleStreamEvent(Callback.handleJsonEvents, asSqsEvent(sink.contents), undefined));
|
|
238
|
+
let rows = await qdbOps.load("o-2");
|
|
239
|
+
if (rows.TAG === "Ok") {
|
|
240
|
+
globalThis.expect(rows._0.length).toBe(0);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
globalThis.expect("load failed").toBe("Ok");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
} else {
|
|
247
|
+
globalThis.test("Postgres pipeline integration (skipped — set PG_URL to run)", () => {
|
|
248
|
+
globalThis.expect(true).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export {
|
|
253
|
+
OrderSource,
|
|
254
|
+
OrdersSpec,
|
|
255
|
+
OrderMapping,
|
|
256
|
+
OrdersMappings,
|
|
257
|
+
flatItem,
|
|
258
|
+
asSqsEvent,
|
|
259
|
+
}
|
|
260
|
+
/* eventSchema Not a pure module */
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Unit tests for the Postgres GraphQL-read Lambda dispatcher (B3.2a-2).
|
|
2
|
+
//
|
|
3
|
+
// `PgQueryResolver_Lambda.dispatch` is pure over a `binding`, so this exercises
|
|
4
|
+
// kind routing, arg extraction, spec-level authorization, the queryInterceptor
|
|
5
|
+
// hook, and the list push-down/fallback split with an IN-MEMORY mock binding —
|
|
6
|
+
// no Postgres. SQL correctness lives in QueryEnginePostgres and is covered by
|
|
7
|
+
// its PG_URL-gated parity test.
|
|
8
|
+
|
|
9
|
+
open JestGlobals
|
|
10
|
+
open ReventlessCore
|
|
11
|
+
|
|
12
|
+
let mk = (id, status, name) => {
|
|
13
|
+
let o = Dict.make()
|
|
14
|
+
o->Dict.set("id", JSON.Encode.string(id))
|
|
15
|
+
o->Dict.set("status", JSON.Encode.string(status))
|
|
16
|
+
o->Dict.set("name", JSON.Encode.string(name))
|
|
17
|
+
JSON.Encode.object(o)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let store = Dict.fromArray([
|
|
21
|
+
("p-1", mk("p-1", "active", "Alpha")),
|
|
22
|
+
("p-2", mk("p-2", "inactive", "Bravo")),
|
|
23
|
+
("p-3", mk("p-3", "active", "Charlie")),
|
|
24
|
+
])
|
|
25
|
+
let allItems = () => store->Dict.valuesToArray
|
|
26
|
+
|
|
27
|
+
let fieldEq = (item, field, value) =>
|
|
28
|
+
item
|
|
29
|
+
->JSON.Decode.object
|
|
30
|
+
->Option.flatMap(d => d->Dict.get(field))
|
|
31
|
+
->Option.flatMap(JSON.Decode.string)
|
|
32
|
+
->Option.mapOr(false, v => v == value)
|
|
33
|
+
|
|
34
|
+
// Records whether listPage was consulted, and what it returns (sentinel or None).
|
|
35
|
+
let listPageReturn: ref<option<JSON.t>> = ref(None)
|
|
36
|
+
|
|
37
|
+
let ops: QueryDb_Adapter.operations = {
|
|
38
|
+
load: async id => Ok(store->Dict.get(id)->Option.mapOr([], i => [i])),
|
|
39
|
+
loadStream: Obj.magic(0), // never called by dispatch
|
|
40
|
+
save: Obj.magic(0),
|
|
41
|
+
saveBatch: Obj.magic(0),
|
|
42
|
+
count: Obj.magic(0),
|
|
43
|
+
delete: Obj.magic(0),
|
|
44
|
+
deleteBatch: Obj.magic(0),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let pushdowns: PgQueryResolver_Lambda.pushdowns = {
|
|
48
|
+
indexLookup: async (~readModelName as _, field, value) =>
|
|
49
|
+
allItems()->Array.filter(i => fieldEq(i, field, value)),
|
|
50
|
+
byIds: async (~readModelName as _, ids) =>
|
|
51
|
+
ids->Array.filterMap(id => store->Dict.get(id)),
|
|
52
|
+
listPage: async (~readModelName as _, ~argsDict as _, ~capability as _, ~labelField as _) =>
|
|
53
|
+
listPageReturn.contents,
|
|
54
|
+
scanAll: async (~readModelName as _) => allItems(),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let capability: GraphQL_FragmentGenerator.serverCapability = {
|
|
58
|
+
filterFields: [{name: "status", gqlType: "String", range: false}],
|
|
59
|
+
sortFields: ["name", "status"],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let makeBinding = (~authorization=Reventless.Authorization.AllowAnonymous, ()): PgQueryResolver_Lambda.binding => {
|
|
63
|
+
ops,
|
|
64
|
+
pushdowns,
|
|
65
|
+
// "byStatus" index stored on the `status` field.
|
|
66
|
+
indexes: [
|
|
67
|
+
{index: "byStatus", type_: "S", idField: "status", projectionType: Reventless.ReadModel.ALL},
|
|
68
|
+
],
|
|
69
|
+
subIdField: None,
|
|
70
|
+
capability,
|
|
71
|
+
labelField: "name",
|
|
72
|
+
includeIdParam: true,
|
|
73
|
+
authorization,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let mkPayload = (~kind, ~index=?, ~args=JSON.Encode.object(Dict.make()), ()): PgQueryResolver_Lambda.payload => {
|
|
77
|
+
readModelName: "Things",
|
|
78
|
+
kind,
|
|
79
|
+
?index,
|
|
80
|
+
arguments: args,
|
|
81
|
+
identity: Reventless.Identity.anonymous,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let objArgs = pairs => JSON.Encode.object(Dict.fromArray(pairs))
|
|
85
|
+
let field = (j, k) => j->JSON.Decode.object->Option.flatMap(d => d->Dict.get(k))
|
|
86
|
+
let str = (j, k) => j->field(k)->Option.flatMap(JSON.Decode.string)
|
|
87
|
+
let ids = (arr: JSON.t) =>
|
|
88
|
+
arr
|
|
89
|
+
->JSON.Decode.array
|
|
90
|
+
->Option.getOr([])
|
|
91
|
+
->Array.filterMap(i => i->str("id"))
|
|
92
|
+
->Array.toSorted(String.compare)
|
|
93
|
+
|
|
94
|
+
describe("PgQueryResolver_Lambda.dispatch", () => {
|
|
95
|
+
testPromise("getById returns the item (id-carrying)", async () => {
|
|
96
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
97
|
+
~binding=makeBinding(),
|
|
98
|
+
~payload=mkPayload(~kind="getById", ~args=objArgs([("id", JSON.Encode.string("p-2"))]), ()),
|
|
99
|
+
)
|
|
100
|
+
expect(r->str("id"))->toEqual(Some("p-2"))
|
|
101
|
+
expect(r->str("name"))->toEqual(Some("Bravo"))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
testPromise("getById miss → null", async () => {
|
|
105
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
106
|
+
~binding=makeBinding(),
|
|
107
|
+
~payload=mkPayload(~kind="getById", ~args=objArgs([("id", JSON.Encode.string("absent"))]), ()),
|
|
108
|
+
)
|
|
109
|
+
expect(r)->toBe(JSON.Encode.null)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
testPromise("byIds returns matches, drops missing", async () => {
|
|
113
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
114
|
+
~binding=makeBinding(),
|
|
115
|
+
~payload=mkPayload(
|
|
116
|
+
~kind="byIds",
|
|
117
|
+
~args=objArgs([
|
|
118
|
+
("ids", JSON.Encode.array(["p-1", "absent", "p-3"]->Array.map(JSON.Encode.string))),
|
|
119
|
+
]),
|
|
120
|
+
(),
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
expect(r->ids)->toEqual(["p-1", "p-3"])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
testPromise("index routes to indexLookup on the idField", async () => {
|
|
127
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
128
|
+
~binding=makeBinding(),
|
|
129
|
+
~payload=mkPayload(
|
|
130
|
+
~kind="index",
|
|
131
|
+
~index="byStatus",
|
|
132
|
+
// arg name is the index name; value looked up against idField `status`
|
|
133
|
+
~args=objArgs([("byStatus", JSON.Encode.string("active"))]),
|
|
134
|
+
(),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
expect(r->ids)->toEqual(["p-1", "p-3"])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
testPromise("list with push-down → returns the connection listPage gives", async () => {
|
|
141
|
+
listPageReturn := Some(JSON.Encode.string("PUSHED"))
|
|
142
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
143
|
+
~binding=makeBinding(),
|
|
144
|
+
~payload=mkPayload(~kind="list", ()),
|
|
145
|
+
)
|
|
146
|
+
listPageReturn := None
|
|
147
|
+
expect(r)->toEqual(JSON.Encode.string("PUSHED"))
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
testPromise("list without push-down → falls back to the shared spec", async () => {
|
|
151
|
+
listPageReturn := None
|
|
152
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
153
|
+
~binding=makeBinding(),
|
|
154
|
+
~payload=mkPayload(~kind="list", ()),
|
|
155
|
+
)
|
|
156
|
+
// A connection object: edges present, all 3 items, sorted by id.
|
|
157
|
+
let edgeIds =
|
|
158
|
+
r
|
|
159
|
+
->field("edges")
|
|
160
|
+
->Option.flatMap(JSON.Decode.array)
|
|
161
|
+
->Option.getOr([])
|
|
162
|
+
->Array.filterMap(e => e->field("node")->Option.flatMap(n => n->str("id")))
|
|
163
|
+
expect(edgeIds)->toEqual(["p-1", "p-2", "p-3"])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
testPromise("authorization DenyAll → empty shape per kind", async () => {
|
|
167
|
+
let b = makeBinding(~authorization=DenyAll, ())
|
|
168
|
+
let single = await PgQueryResolver_Lambda.dispatch(
|
|
169
|
+
~binding=b,
|
|
170
|
+
~payload=mkPayload(~kind="getById", ~args=objArgs([("id", JSON.Encode.string("p-1"))]), ()),
|
|
171
|
+
)
|
|
172
|
+
expect(single)->toBe(JSON.Encode.null)
|
|
173
|
+
let list = await PgQueryResolver_Lambda.dispatch(~binding=b, ~payload=mkPayload(~kind="list", ()))
|
|
174
|
+
expect(list->field("edges")->Option.flatMap(JSON.Decode.array)->Option.getOr([])->Array.length)->toBe(0)
|
|
175
|
+
let byIds = await PgQueryResolver_Lambda.dispatch(
|
|
176
|
+
~binding=b,
|
|
177
|
+
~payload=mkPayload(~kind="byIds", ~args=objArgs([("ids", JSON.Encode.array([]))]), ()),
|
|
178
|
+
)
|
|
179
|
+
expect(byIds->JSON.Decode.array->Option.getOr([])->Array.length)->toBe(0)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
testPromise("queryInterceptor hook Deny → empty shape (auth allowed)", async () => {
|
|
183
|
+
QueryDb_Callback.registerQueryInterceptor(async (~identity as _, ~readModelName as _, ~args as _) =>
|
|
184
|
+
Deny("nope")
|
|
185
|
+
)
|
|
186
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
187
|
+
~binding=makeBinding(),
|
|
188
|
+
~payload=mkPayload(~kind="getById", ~args=objArgs([("id", JSON.Encode.string("p-1"))]), ()),
|
|
189
|
+
)
|
|
190
|
+
QueryDb_Callback.clearQueryInterceptor()
|
|
191
|
+
expect(r)->toBe(JSON.Encode.null)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
testPromise("unmapped kind throws", async () => {
|
|
195
|
+
let threw = try {
|
|
196
|
+
let _ = await PgQueryResolver_Lambda.dispatch(
|
|
197
|
+
~binding=makeBinding(),
|
|
198
|
+
~payload=mkPayload(~kind="frobnicate", ()),
|
|
199
|
+
)
|
|
200
|
+
false
|
|
201
|
+
} catch {
|
|
202
|
+
| _ => true
|
|
203
|
+
}
|
|
204
|
+
expect(threw)->toBe(true)
|
|
205
|
+
})
|
|
206
|
+
})
|