@reventlessdev/reventless-local 3.0.0-alpha.167 → 3.0.0-alpha.169
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 +20 -0
- package/package.json +11 -11
- package/src/Platform.res +11 -1
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +16 -8
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs +7 -7
- package/src/adapter/DomainGraphQL_Server.res +96 -0
- package/src/adapter/DomainGraphQL_Server.res.mjs +84 -0
- package/src/adapter/GraphQL_Server.res.mjs +18 -0
- package/src/adapter/LocalObjectStore.res +64 -0
- package/src/adapter/LocalObjectStore.res.mjs +55 -0
- package/src/adapter/PgProjectionCatchup.res +6 -1
- package/src/adapter/PgProjectionCatchup.res.mjs +4 -2
- package/src/adapter/ProjectionCheckpoint.res +11 -2
- package/src/adapter/ProjectionCheckpoint.res.mjs +9 -3
- package/tests/PluginEventDecodeTest.res +2 -0
- package/tests/PluginEventDecodeTest.res.mjs +2 -2
- package/tests/adapter/GraphQL_SchemaInspectorTest.res +52 -1
- package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +39 -1
- package/tests/adapter/InboundTranslationMutationTest.res +139 -0
- package/tests/adapter/InboundTranslationMutationTest.res.mjs +132 -0
- package/tests/adapter/QueryDbListResolverTest.res +2 -0
- package/tests/adapter/QueryDbListResolverTest.res.mjs +2 -0
- package/tests/adapter/ServedBucketHttpTest.res +174 -0
- package/tests/adapter/ServedBucketHttpTest.res.mjs +177 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +13 -9
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +21 -13
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res +215 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +273 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformTest.res +38 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformTest.res.mjs +28 -0
- package/tests/components/stateviewslice/StateViewSliceFixtures.res +1 -1
- package/tests/components/stateviewslice/StateViewSliceFixtures.res.mjs +2 -1
- package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res +1 -1
- package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res.mjs +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
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.169 (2026-07-26)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **api:** preserve nullability for optional enum fields in generated GraphQL ([3110a4a](https://github.com/ReventlessDev/reventless-core/commit/3110a4af032440ba2639519f61f221960ac78fba))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.168 (2026-07-26)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **core:** decode the event envelope in OutboundTranslationSlice ([66008a4](https://github.com/ReventlessDev/reventless-core/commit/66008a4dff411c85470f60aac5424e7b3eda6f01))
|
|
18
|
+
* **graphql:** give InboundTranslationSlice mutations a resolvable result type ([381b545](https://github.com/ReventlessDev/reventless-core/commit/381b5458c16797404cb8ed95fa853fb1e1ca4199))
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* **auto-ui:** declare field semantics + dashboard metrics ([@semantic](https://github.com/semantic), [@metric](https://github.com/metric)) ([d74ff77](https://github.com/ReventlessDev/reventless-core/commit/d74ff7721e18e8638a82931a370a549b304dac94)), closes [#4](https://github.com/ReventlessDev/reventless-core/issues/4)
|
|
22
|
+
* **aws:** geocoder + upload presign Lambda Function URL services ([be43179](https://github.com/ReventlessDev/reventless-core/commit/be4317972e781dbe3c378f21a28975d1ac8b9add))
|
|
23
|
+
* **platform:** serve a private bucket to the UI under a same-origin prefix ([793c8b2](https://github.com/ReventlessDev/reventless-core/commit/793c8b25e13969b42ce331c698bad473be4c0c88))
|
|
24
|
+
|
|
25
|
+
|
|
6
26
|
# 3.0.0-alpha.167 (2026-07-24)
|
|
7
27
|
|
|
8
28
|
**Note:** Version bump only for package @reventlessdev/reventless-local
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.169",
|
|
4
4
|
"description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -31,22 +31,22 @@
|
|
|
31
31
|
"graphql-yoga": "^5.21.0",
|
|
32
32
|
"sury": "11.0.0-alpha.4",
|
|
33
33
|
"ws": "^8.18.0",
|
|
34
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.
|
|
35
|
-
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
|
|
34
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.31",
|
|
36
35
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.9",
|
|
36
|
+
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.25",
|
|
37
37
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
|
|
38
|
-
"@reventlessdev/rescript-
|
|
39
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
42
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
43
|
-
"@reventlessdev/reventless-
|
|
44
|
-
"@reventlessdev/reventless-
|
|
38
|
+
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
|
|
39
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.182",
|
|
40
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.129",
|
|
41
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.29",
|
|
42
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.105",
|
|
43
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.81",
|
|
44
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.46"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"rescript": "12.3.0",
|
|
48
48
|
"sury-ppx": "11.0.0-alpha.2",
|
|
49
|
-
"@reventlessdev/reventless-ppx": "1.0.0-alpha.
|
|
49
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.56"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"rescript": "12.3.0"
|
package/src/Platform.res
CHANGED
|
@@ -1909,10 +1909,20 @@ module MakeWithConfig = (
|
|
|
1909
1909
|
}
|
|
1910
1910
|
|
|
1911
1911
|
// In-memory ignores `~hostUiBundle` — the host shell is served by `vite dev`
|
|
1912
|
-
// against the running in-process GraphQL server, not from a CDN
|
|
1912
|
+
// against the running in-process GraphQL server, not from a CDN — including
|
|
1913
|
+
// `uiHintsFile` (the AWS deploy writes it as a BucketObject; local dev serves
|
|
1914
|
+
// `public/ui-hints.json` directly).
|
|
1913
1915
|
type hostUiBundleConfig = {
|
|
1914
1916
|
assetsDir: string,
|
|
1915
1917
|
bundleVersion: string,
|
|
1918
|
+
uiHintsFile?: string,
|
|
1919
|
+
// AWS host-ui deploy knobs — carried to satisfy the shared Platform.T
|
|
1920
|
+
// signature; the in-memory platform provisions no infrastructure and
|
|
1921
|
+
// ignores them.
|
|
1922
|
+
geocoderPlaceIndex?: Pulumi.Input.t<string>,
|
|
1923
|
+
enableUploads?: bool,
|
|
1924
|
+
uploadBucketName?: Pulumi.Input.t<string>,
|
|
1925
|
+
servedBuckets?: array<ReventlessInfra.Platform.servedBucket>,
|
|
1916
1926
|
}
|
|
1917
1927
|
let deployPlatform = (~version, ~hostUiBundle as _: option<hostUiBundleConfig>=?) => {
|
|
1918
1928
|
log.info(~comp="Platform", `deployPlatform v${version}`)
|
|
@@ -10,11 +10,13 @@ open ReventlessCore
|
|
|
10
10
|
// Pre-populated synchronously in Phase 1 via a queuing forwarder so callers
|
|
11
11
|
// (e.g. PlatformInspector's onPlatformDeployedHook) can invoke receive before
|
|
12
12
|
// Phase 2 bindReceive runs. Calls park in a Promise queue and drain once bound.
|
|
13
|
-
let receiveRegistry: dict<
|
|
13
|
+
let receiveRegistry: dict<
|
|
14
|
+
JSON.t => promise<ReventlessInfra.InboundTranslationSlice.receiveResult>,
|
|
15
|
+
> = Dict.make()
|
|
14
16
|
|
|
15
17
|
type pendingCall = {
|
|
16
18
|
inputJson: JSON.t,
|
|
17
|
-
resolve:
|
|
19
|
+
resolve: ReventlessInfra.InboundTranslationSlice.receiveResult => unit,
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
// Per-field pending-call queues — drained when bindReceive fires.
|
|
@@ -24,6 +26,12 @@ let pendingQueueRegistry: dict<ref<array<pendingCall>>> = Dict.make()
|
|
|
24
26
|
// Also pre-registers a queuing forwarder in receiveRegistry so callers can
|
|
25
27
|
// invoke receive immediately; calls are parked until bindReceive drains them.
|
|
26
28
|
let register = (~fieldName: string, ~externalInputSchema: S.t<unknown>, ~server: ReventlessGraphqlServer.GraphQL_ServerInstance.t) => {
|
|
29
|
+
// The mutation field returns CommandResult, so its union members must exist in
|
|
30
|
+
// this scope's document. Registered here rather than relying on a plugin's
|
|
31
|
+
// command handlers having registered them first — a plugin whose only mutation
|
|
32
|
+
// is an inbound translation must not depend on registration order.
|
|
33
|
+
CommandGeneratorResolvers_GraphQL.ensureCommandResultTypes(server)
|
|
34
|
+
|
|
27
35
|
let sdlFields = switch GraphQL_FragmentGenerator.deriveMutationFieldFromObject(
|
|
28
36
|
~fieldName,
|
|
29
37
|
~collectedTypes=[],
|
|
@@ -31,7 +39,7 @@ let register = (~fieldName: string, ~externalInputSchema: S.t<unknown>, ~server:
|
|
|
31
39
|
externalInputSchema,
|
|
32
40
|
) {
|
|
33
41
|
| Some(field) => [field]
|
|
34
|
-
| None => [` ${fieldName}:
|
|
42
|
+
| None => [` ${fieldName}: CommandResult!`]
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
// Queue of pending calls when receive is not yet bound.
|
|
@@ -49,10 +57,7 @@ let register = (~fieldName: string, ~externalInputSchema: S.t<unknown>, ~server:
|
|
|
49
57
|
let inputJson: JSON.t = args->Obj.magic
|
|
50
58
|
let receive = receiveRegistry->Dict.getUnsafe(fieldName)
|
|
51
59
|
let result = await receive(inputJson)
|
|
52
|
-
|
|
53
|
-
| Ok(targetIds) => targetIds->Array.map(JSON.Encode.string)->JSON.Encode.array
|
|
54
|
-
| Error(msg) => msg->JSON.Encode.string
|
|
55
|
-
}
|
|
60
|
+
result->InboundTranslationSlice.receiveResultToOutcome->CommandTopic.commandOutcomeToJson
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
let resolvers = Dict.make()
|
|
@@ -62,7 +67,10 @@ let register = (~fieldName: string, ~externalInputSchema: S.t<unknown>, ~server:
|
|
|
62
67
|
|
|
63
68
|
// Phase 2: Bind the real receive function once Output.apply resolves.
|
|
64
69
|
// Replaces the queuing forwarder in receiveRegistry and drains pending calls.
|
|
65
|
-
let bindReceive = (
|
|
70
|
+
let bindReceive = (
|
|
71
|
+
~fieldName: string,
|
|
72
|
+
~receive: JSON.t => promise<ReventlessInfra.InboundTranslationSlice.receiveResult>,
|
|
73
|
+
) => {
|
|
66
74
|
receiveRegistry->Dict.set(fieldName, receive)
|
|
67
75
|
switch pendingQueueRegistry->Dict.get(fieldName) {
|
|
68
76
|
| Some(pendingQueue) =>
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
|
|
4
|
+
import * as InboundTranslationSlice$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice.res.mjs";
|
|
3
5
|
import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
6
|
+
import * as CommandGeneratorResolvers_GraphQL$ReventlessLocal from "./CommandGeneratorResolvers_GraphQL.res.mjs";
|
|
4
7
|
|
|
5
8
|
let receiveRegistry = {};
|
|
6
9
|
|
|
7
10
|
let pendingQueueRegistry = {};
|
|
8
11
|
|
|
9
12
|
function register(fieldName, externalInputSchema, server) {
|
|
13
|
+
CommandGeneratorResolvers_GraphQL$ReventlessLocal.ensureCommandResultTypes(server);
|
|
10
14
|
let field = GraphQL_FragmentGenerator$ReventlessCore.deriveMutationFieldFromObject(fieldName, [], new Set(), externalInputSchema);
|
|
11
|
-
let sdlFields = field !== undefined ? [field] : [` ` + fieldName + `:
|
|
15
|
+
let sdlFields = field !== undefined ? [field] : [` ` + fieldName + `: CommandResult!`];
|
|
12
16
|
let pendingQueue = {
|
|
13
17
|
contents: []
|
|
14
18
|
};
|
|
@@ -23,11 +27,7 @@ function register(fieldName, externalInputSchema, server) {
|
|
|
23
27
|
let resolver = async (_root, args, _ctx) => {
|
|
24
28
|
let receive = receiveRegistry[fieldName];
|
|
25
29
|
let result = await receive(args);
|
|
26
|
-
|
|
27
|
-
return result._0.map(prim => prim);
|
|
28
|
-
} else {
|
|
29
|
-
return result._0;
|
|
30
|
-
}
|
|
30
|
+
return CommandTopic$ReventlessCore.commandOutcomeToJson(InboundTranslationSlice$ReventlessCore.receiveResultToOutcome(result));
|
|
31
31
|
};
|
|
32
32
|
let resolvers = {};
|
|
33
33
|
resolvers[fieldName] = resolver;
|
|
@@ -54,4 +54,4 @@ export {
|
|
|
54
54
|
register,
|
|
55
55
|
bindReceive,
|
|
56
56
|
}
|
|
57
|
-
/*
|
|
57
|
+
/* CommandTopic-ReventlessCore Not a pure module */
|
|
@@ -26,11 +26,18 @@ type nodeResponse
|
|
|
26
26
|
@send external writeHead: (nodeResponse, int, {..}) => unit = "writeHead"
|
|
27
27
|
@send external end_: (nodeResponse, string) => unit = "end"
|
|
28
28
|
@send external endEmpty: (nodeResponse, @as(json`null`) _) => unit = "end"
|
|
29
|
+
// Binary response body — the served-object GET streams stored bytes verbatim.
|
|
30
|
+
@send external endBuf: (nodeResponse, LocalObjectStore.buffer) => unit = "end"
|
|
29
31
|
|
|
30
32
|
// Streaming body collector — request data fires once per chunk, end once at EOF.
|
|
31
33
|
@send external _onData: (nodeRequest, @as("data") _, string => unit) => unit = "on"
|
|
32
34
|
@send external _onEnd: (nodeRequest, @as("end") _, unit => unit) => unit = "on"
|
|
33
35
|
@send external _setEncoding: (nodeRequest, string) => unit = "setEncoding"
|
|
36
|
+
// Binary chunk stream — used by the served-object PUT so raw bytes aren't
|
|
37
|
+
// mangled by utf8 decoding (setEncoding is deliberately NOT called).
|
|
38
|
+
@send external _onDataBuf: (nodeRequest, @as("data") _, LocalObjectStore.buffer => unit) => unit = "on"
|
|
39
|
+
|
|
40
|
+
@module("crypto") external randomUUID: unit => string = "randomUUID"
|
|
34
41
|
|
|
35
42
|
let readBody = (req: nodeRequest, onBody: string => unit): unit => {
|
|
36
43
|
let buf = ref("")
|
|
@@ -39,6 +46,14 @@ let readBody = (req: nodeRequest, onBody: string => unit): unit => {
|
|
|
39
46
|
req->_onEnd(() => onBody(buf.contents))
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
// Binary body collector — accumulates raw Buffer chunks and concatenates once
|
|
50
|
+
// at EOF. Kept separate from `readBody` (utf8) so file uploads stay byte-exact.
|
|
51
|
+
let readBodyBuf = (req: nodeRequest, onBody: LocalObjectStore.buffer => unit): unit => {
|
|
52
|
+
let chunks: array<LocalObjectStore.buffer> = []
|
|
53
|
+
req->_onDataBuf(chunk => chunks->Array.push(chunk))
|
|
54
|
+
req->_onEnd(() => onBody(LocalObjectStore.concatBuffers(chunks)))
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
type requestHandler = (nodeRequest, nodeResponse) => unit
|
|
43
58
|
@module("http") external createServerWithHandler: requestHandler => YG.httpServer = "createServer"
|
|
44
59
|
|
|
@@ -110,6 +125,76 @@ let handleLogout = (_req: nodeRequest, res: nodeResponse): unit => {
|
|
|
110
125
|
res->endEmpty
|
|
111
126
|
}
|
|
112
127
|
|
|
128
|
+
// -- Served-bucket routes (local analogue of the AWS CloudFront read path) ----
|
|
129
|
+
//
|
|
130
|
+
// The dev platform has no object bucket, so these three routes back the same
|
|
131
|
+
// UI contract locally against `LocalObjectStore`:
|
|
132
|
+
// POST /__inmemory/upload {fileName,contentType} → {uploadUrl, storageRef}
|
|
133
|
+
// PUT /{prefix}/{key} raw bytes → 200 (store)
|
|
134
|
+
// GET /{prefix}/{key} → the stored bytes
|
|
135
|
+
// `uploadUrl == storageRef == /{prefix}/{key}`, so the existing FileDropzone S3
|
|
136
|
+
// adapter (POST-presign → PUT-bytes) works unchanged — no presigning needed.
|
|
137
|
+
|
|
138
|
+
let uploadPresignPath = "/__inmemory/upload"
|
|
139
|
+
|
|
140
|
+
// CORS for the browser PUT (a cross-origin preflight when the dev origin isn't
|
|
141
|
+
// same-origin with the API); the seed uploads from Node and needs none.
|
|
142
|
+
let _corsWriteHeaders = {
|
|
143
|
+
"Access-Control-Allow-Origin": "*",
|
|
144
|
+
"Access-Control-Allow-Methods": "PUT,GET,OPTIONS",
|
|
145
|
+
"Access-Control-Allow-Headers": "*",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Presign-shaped: mint a fresh uuid key under the served prefix and hand back a
|
|
149
|
+
// same-origin `/{prefix}/{key}` for both the PUT target and the stored ref.
|
|
150
|
+
let handleUploadPresign = (req: nodeRequest, res: nodeResponse): unit =>
|
|
151
|
+
readBody(req, body => {
|
|
152
|
+
let parsed =
|
|
153
|
+
try body->JSON.parseOrThrow->JSON.Decode.object->Option.getOr(Dict.make()) catch {
|
|
154
|
+
| _ => Dict.make()
|
|
155
|
+
}
|
|
156
|
+
let fileName =
|
|
157
|
+
parsed->Dict.get("fileName")->Option.flatMap(JSON.Decode.string)->Option.getOr("upload")
|
|
158
|
+
let ref = `/${LocalObjectStore.defaultUploadPrefix}/${randomUUID()}/${fileName}`
|
|
159
|
+
_writeJson(
|
|
160
|
+
res,
|
|
161
|
+
~status=200,
|
|
162
|
+
JSON.Encode.object(
|
|
163
|
+
Dict.fromArray([
|
|
164
|
+
("uploadUrl", JSON.Encode.string(ref)),
|
|
165
|
+
("storageRef", JSON.Encode.string(ref)),
|
|
166
|
+
]),
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
let handleObjectPut = (req: nodeRequest, res: nodeResponse, ~key: string): unit => {
|
|
172
|
+
let contentType =
|
|
173
|
+
req.headers->Dict.get("content-type")->Option.getOr("application/octet-stream")
|
|
174
|
+
readBodyBuf(req, bytes => {
|
|
175
|
+
LocalObjectStore.put(~key, ~bytes, ~contentType)
|
|
176
|
+
res->writeHead(200, _corsWriteHeaders)
|
|
177
|
+
res->end_("")
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let handleObjectGet = (res: nodeResponse, ~key: string): unit =>
|
|
182
|
+
switch LocalObjectStore.get(~key) {
|
|
183
|
+
| Some({bytes, contentType}) =>
|
|
184
|
+
res->writeHead(
|
|
185
|
+
200,
|
|
186
|
+
{
|
|
187
|
+
"Content-Type": contentType,
|
|
188
|
+
"Access-Control-Allow-Origin": "*",
|
|
189
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
res->endBuf(bytes)
|
|
193
|
+
| None =>
|
|
194
|
+
res->writeHead(404, {"Access-Control-Allow-Origin": "*"})
|
|
195
|
+
res->endEmpty
|
|
196
|
+
}
|
|
197
|
+
|
|
113
198
|
// Returns true when the request carries an `Authorization: Bearer <token>`
|
|
114
199
|
// header whose token fails HMAC verification. No header (anonymous), or a
|
|
115
200
|
// non-Bearer scheme (e.g. X-User flows), returns false — only an *invalid*
|
|
@@ -147,6 +232,16 @@ let _dispatch = (req: nodeRequest, res: nodeResponse, yoga: YG.yoga, getSdl: uni
|
|
|
147
232
|
handleLogin(req, res)
|
|
148
233
|
} else if path == "/__inmemory/logout" && req.method == "POST" {
|
|
149
234
|
handleLogout(req, res)
|
|
235
|
+
} else if path == uploadPresignPath && req.method == "POST" {
|
|
236
|
+
handleUploadPresign(req, res)
|
|
237
|
+
} else if req.method == "OPTIONS" && LocalObjectStore.servedKey(path)->Option.isSome {
|
|
238
|
+
// Preflight for the cross-origin browser PUT to a served-object path.
|
|
239
|
+
res->writeHead(204, _corsWriteHeaders)
|
|
240
|
+
res->endEmpty
|
|
241
|
+
} else if req.method == "PUT" && LocalObjectStore.servedKey(path)->Option.isSome {
|
|
242
|
+
handleObjectPut(req, res, ~key=LocalObjectStore.servedKey(path)->Option.getOr(""))
|
|
243
|
+
} else if req.method == "GET" && LocalObjectStore.servedKey(path)->Option.isSome {
|
|
244
|
+
handleObjectGet(res, ~key=LocalObjectStore.servedKey(path)->Option.getOr(""))
|
|
150
245
|
} else {
|
|
151
246
|
(yoga->Obj.magic)(req, res)
|
|
152
247
|
}
|
|
@@ -618,6 +713,7 @@ let reset = () => {
|
|
|
618
713
|
nodeResolverCallback.contents = None
|
|
619
714
|
activeSchema.contents = None
|
|
620
715
|
lastFullSdl.contents = None
|
|
716
|
+
LocalObjectStore.reset()
|
|
621
717
|
}
|
|
622
718
|
|
|
623
719
|
// -- Inspection & diagnostics ---------------------------------------------
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as S from "sury/src/S.res.mjs";
|
|
4
4
|
import * as Http from "http";
|
|
5
|
+
import * as Crypto from "crypto";
|
|
5
6
|
import * as Graphql from "graphql";
|
|
6
7
|
import * as GraphqlYoga from "@reventlessdev/rescript-graphql-yoga/src/GraphqlYoga.res.mjs";
|
|
7
8
|
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
@@ -17,6 +18,7 @@ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_excep
|
|
|
17
18
|
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
18
19
|
import * as LocalAuth$ReventlessLocal from "./Auth/LocalAuth.res.mjs";
|
|
19
20
|
import * as GraphQL_Stitcher$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
|
|
21
|
+
import * as LocalObjectStore$ReventlessLocal from "./LocalObjectStore.res.mjs";
|
|
20
22
|
import * as Auth_GraphqlContext$ReventlessLocal from "./Auth/Auth_GraphqlContext.res.mjs";
|
|
21
23
|
|
|
22
24
|
let log = Logger$ReventlessCore.fromEnv();
|
|
@@ -32,6 +34,14 @@ function readBody(req, onBody) {
|
|
|
32
34
|
req.on("end", () => onBody(buf.contents));
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
function readBodyBuf(req, onBody) {
|
|
38
|
+
let chunks = [];
|
|
39
|
+
req.on("data", chunk => {
|
|
40
|
+
chunks.push(chunk);
|
|
41
|
+
});
|
|
42
|
+
req.on("end", () => onBody(Buffer.concat(chunks)));
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
function _writeJson(res, status, body) {
|
|
36
46
|
res.writeHead(status, {
|
|
37
47
|
"Content-Type": "application/json",
|
|
@@ -89,6 +99,63 @@ function handleLogout(_req, res) {
|
|
|
89
99
|
res.end(null);
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
let uploadPresignPath = "/__inmemory/upload";
|
|
103
|
+
|
|
104
|
+
let _corsWriteHeaders = {
|
|
105
|
+
"Access-Control-Allow-Origin": "*",
|
|
106
|
+
"Access-Control-Allow-Methods": "PUT,GET,OPTIONS",
|
|
107
|
+
"Access-Control-Allow-Headers": "*"
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function handleUploadPresign(req, res) {
|
|
111
|
+
readBody(req, body => {
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(JSON.parse(body)), {});
|
|
115
|
+
} catch (exn) {
|
|
116
|
+
parsed = {};
|
|
117
|
+
}
|
|
118
|
+
let fileName = Stdlib_Option.getOr(Stdlib_Option.flatMap(parsed["fileName"], Stdlib_JSON.Decode.string), "upload");
|
|
119
|
+
let ref = `/` + LocalObjectStore$ReventlessLocal.defaultUploadPrefix + `/` + Crypto.randomUUID() + `/` + fileName;
|
|
120
|
+
_writeJson(res, 200, Object.fromEntries([
|
|
121
|
+
[
|
|
122
|
+
"uploadUrl",
|
|
123
|
+
ref
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
"storageRef",
|
|
127
|
+
ref
|
|
128
|
+
]
|
|
129
|
+
]));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function handleObjectPut(req, res, key) {
|
|
134
|
+
let contentType = Stdlib_Option.getOr(req.headers["content-type"], "application/octet-stream");
|
|
135
|
+
readBodyBuf(req, bytes => {
|
|
136
|
+
LocalObjectStore$ReventlessLocal.put(key, bytes, contentType);
|
|
137
|
+
res.writeHead(200, _corsWriteHeaders);
|
|
138
|
+
res.end("");
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handleObjectGet(res, key) {
|
|
143
|
+
let match = LocalObjectStore$ReventlessLocal.get(key);
|
|
144
|
+
if (match !== undefined) {
|
|
145
|
+
res.writeHead(200, {
|
|
146
|
+
"Content-Type": match.contentType,
|
|
147
|
+
"Access-Control-Allow-Origin": "*",
|
|
148
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
149
|
+
});
|
|
150
|
+
res.end(match.bytes);
|
|
151
|
+
} else {
|
|
152
|
+
res.writeHead(404, {
|
|
153
|
+
"Access-Control-Allow-Origin": "*"
|
|
154
|
+
});
|
|
155
|
+
res.end(null);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
92
159
|
function _isInvalidBearer(req) {
|
|
93
160
|
let h = req.headers["authorization"];
|
|
94
161
|
if (h === undefined) {
|
|
@@ -119,6 +186,16 @@ function _dispatch(req, res, yoga, getSdl) {
|
|
|
119
186
|
return handleLogin(req, res);
|
|
120
187
|
} else if (path === "/__inmemory/logout" && req.method === "POST") {
|
|
121
188
|
return handleLogout(req, res);
|
|
189
|
+
} else if (path === uploadPresignPath && req.method === "POST") {
|
|
190
|
+
return handleUploadPresign(req, res);
|
|
191
|
+
} else if (req.method === "OPTIONS" && Stdlib_Option.isSome(LocalObjectStore$ReventlessLocal.servedKey(path))) {
|
|
192
|
+
res.writeHead(204, _corsWriteHeaders);
|
|
193
|
+
res.end(null);
|
|
194
|
+
return;
|
|
195
|
+
} else if (req.method === "PUT" && Stdlib_Option.isSome(LocalObjectStore$ReventlessLocal.servedKey(path))) {
|
|
196
|
+
return handleObjectPut(req, res, Stdlib_Option.getOr(LocalObjectStore$ReventlessLocal.servedKey(path), ""));
|
|
197
|
+
} else if (req.method === "GET" && Stdlib_Option.isSome(LocalObjectStore$ReventlessLocal.servedKey(path))) {
|
|
198
|
+
return handleObjectGet(res, Stdlib_Option.getOr(LocalObjectStore$ReventlessLocal.servedKey(path), ""));
|
|
122
199
|
} else {
|
|
123
200
|
return yoga(req, res);
|
|
124
201
|
}
|
|
@@ -515,6 +592,7 @@ function reset() {
|
|
|
515
592
|
nodeResolverCallback.contents = undefined;
|
|
516
593
|
activeSchema.contents = undefined;
|
|
517
594
|
lastFullSdl.contents = undefined;
|
|
595
|
+
LocalObjectStore$ReventlessLocal.reset();
|
|
518
596
|
}
|
|
519
597
|
|
|
520
598
|
function getRegisteredSdl() {
|
|
@@ -671,10 +749,16 @@ export {
|
|
|
671
749
|
YG,
|
|
672
750
|
log,
|
|
673
751
|
readBody,
|
|
752
|
+
readBodyBuf,
|
|
674
753
|
_writeJson,
|
|
675
754
|
_loginRejected,
|
|
676
755
|
handleLogin,
|
|
677
756
|
handleLogout,
|
|
757
|
+
uploadPresignPath,
|
|
758
|
+
_corsWriteHeaders,
|
|
759
|
+
handleUploadPresign,
|
|
760
|
+
handleObjectPut,
|
|
761
|
+
handleObjectGet,
|
|
678
762
|
_isInvalidBearer,
|
|
679
763
|
_dispatch,
|
|
680
764
|
buildAuthContext,
|
|
@@ -8,6 +8,8 @@ let log = DomainGraphQL_Server$ReventlessLocal.log;
|
|
|
8
8
|
|
|
9
9
|
let readBody = DomainGraphQL_Server$ReventlessLocal.readBody;
|
|
10
10
|
|
|
11
|
+
let readBodyBuf = DomainGraphQL_Server$ReventlessLocal.readBodyBuf;
|
|
12
|
+
|
|
11
13
|
let _writeJson = DomainGraphQL_Server$ReventlessLocal._writeJson;
|
|
12
14
|
|
|
13
15
|
let _loginRejected = DomainGraphQL_Server$ReventlessLocal._loginRejected;
|
|
@@ -16,6 +18,16 @@ let handleLogin = DomainGraphQL_Server$ReventlessLocal.handleLogin;
|
|
|
16
18
|
|
|
17
19
|
let handleLogout = DomainGraphQL_Server$ReventlessLocal.handleLogout;
|
|
18
20
|
|
|
21
|
+
let uploadPresignPath = DomainGraphQL_Server$ReventlessLocal.uploadPresignPath;
|
|
22
|
+
|
|
23
|
+
let _corsWriteHeaders = DomainGraphQL_Server$ReventlessLocal._corsWriteHeaders;
|
|
24
|
+
|
|
25
|
+
let handleUploadPresign = DomainGraphQL_Server$ReventlessLocal.handleUploadPresign;
|
|
26
|
+
|
|
27
|
+
let handleObjectPut = DomainGraphQL_Server$ReventlessLocal.handleObjectPut;
|
|
28
|
+
|
|
29
|
+
let handleObjectGet = DomainGraphQL_Server$ReventlessLocal.handleObjectGet;
|
|
30
|
+
|
|
19
31
|
let _isInvalidBearer = DomainGraphQL_Server$ReventlessLocal._isInvalidBearer;
|
|
20
32
|
|
|
21
33
|
let _dispatch = DomainGraphQL_Server$ReventlessLocal._dispatch;
|
|
@@ -120,10 +132,16 @@ export {
|
|
|
120
132
|
YG,
|
|
121
133
|
log,
|
|
122
134
|
readBody,
|
|
135
|
+
readBodyBuf,
|
|
123
136
|
_writeJson,
|
|
124
137
|
_loginRejected,
|
|
125
138
|
handleLogin,
|
|
126
139
|
handleLogout,
|
|
140
|
+
uploadPresignPath,
|
|
141
|
+
_corsWriteHeaders,
|
|
142
|
+
handleUploadPresign,
|
|
143
|
+
handleObjectPut,
|
|
144
|
+
handleObjectGet,
|
|
127
145
|
_isInvalidBearer,
|
|
128
146
|
_dispatch,
|
|
129
147
|
buildAuthContext,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Process-local object store backing the dev platform's served-bucket routes.
|
|
2
|
+
//
|
|
3
|
+
// The AWS path fronts a private S3 bucket through CloudFront (served-buckets
|
|
4
|
+
// plan); in dev there is no bucket, so uploaded and served objects live in this
|
|
5
|
+
// process-local map. Ephemeral by design — contents are lost on restart, which
|
|
6
|
+
// matches the plan's dev-only, non-durable stance. Held here (not in
|
|
7
|
+
// EventLog/QueryDb storage) because it is raw bytes, not event/query state, and
|
|
8
|
+
// has no SQLite arm — hence the `Local` prefix and no `_InMemory` suffix.
|
|
9
|
+
|
|
10
|
+
// Node Buffer, opaque here — produced by the PUT handler (concat of request
|
|
11
|
+
// chunks) and handed straight back to the response by the GET handler.
|
|
12
|
+
type buffer
|
|
13
|
+
|
|
14
|
+
@val @scope("Buffer") external concatBuffers: array<buffer> => buffer = "concat"
|
|
15
|
+
|
|
16
|
+
type entry = {
|
|
17
|
+
bytes: buffer,
|
|
18
|
+
contentType: string,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let objects: dict<entry> = Dict.make()
|
|
22
|
+
|
|
23
|
+
// The prefix the local upload route mints keys under. Mirrors the AWS presign
|
|
24
|
+
// `SERVED_PREFIX` (default "uploads"); the hybrid example uses "uploads".
|
|
25
|
+
let defaultUploadPrefix = "uploads"
|
|
26
|
+
|
|
27
|
+
// Prefixes the dev server serves at `/{prefix}/*` (PUT stores, GET reads).
|
|
28
|
+
// Seeded with the default upload prefix; a deployment serving another prefix
|
|
29
|
+
// registers it before the servers start.
|
|
30
|
+
let servedPrefixes: ref<array<string>> = ref([defaultUploadPrefix])
|
|
31
|
+
|
|
32
|
+
let registerServedPrefix = (prefix: string): unit =>
|
|
33
|
+
if !(servedPrefixes.contents->Array.includes(prefix)) {
|
|
34
|
+
servedPrefixes.contents = servedPrefixes.contents->Array.concat([prefix])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A request path is a served-object path when its first segment is a registered
|
|
38
|
+
// served prefix and at least one key segment follows. Returns the storage key
|
|
39
|
+
// (the path without its leading slash), or None for GraphQL / other paths.
|
|
40
|
+
let servedKey = (path: string): option<string> => {
|
|
41
|
+
let trimmed = path->String.startsWith("/") ? path->String.slice(~start=1, ~end=path->String.length) : path
|
|
42
|
+
let prefix = trimmed->String.split("/")->Array.get(0)->Option.getOr("")
|
|
43
|
+
if (
|
|
44
|
+
prefix->String.length > 0 &&
|
|
45
|
+
servedPrefixes.contents->Array.includes(prefix) &&
|
|
46
|
+
trimmed->String.length > prefix->String.length + 1
|
|
47
|
+
) {
|
|
48
|
+
Some(trimmed)
|
|
49
|
+
} else {
|
|
50
|
+
None
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let put = (~key: string, ~bytes: buffer, ~contentType: string): unit =>
|
|
55
|
+
objects->Dict.set(key, {bytes, contentType})
|
|
56
|
+
|
|
57
|
+
let get = (~key: string): option<entry> => objects->Dict.get(key)
|
|
58
|
+
|
|
59
|
+
// Clear stored objects and restore the default served prefix — used between
|
|
60
|
+
// isolated test suites (called from DomainGraphQL_Server.reset).
|
|
61
|
+
let reset = (): unit => {
|
|
62
|
+
objects->Dict.keysToArray->Array.forEach(k => objects->Dict.delete(k))
|
|
63
|
+
servedPrefixes.contents = [defaultUploadPrefix]
|
|
64
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
|
|
6
|
+
let objects = {};
|
|
7
|
+
|
|
8
|
+
let defaultUploadPrefix = "uploads";
|
|
9
|
+
|
|
10
|
+
let servedPrefixes = {
|
|
11
|
+
contents: [defaultUploadPrefix]
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function registerServedPrefix(prefix) {
|
|
15
|
+
if (!servedPrefixes.contents.includes(prefix)) {
|
|
16
|
+
servedPrefixes.contents = servedPrefixes.contents.concat([prefix]);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function servedKey(path) {
|
|
22
|
+
let trimmed = path.startsWith("/") ? path.slice(1, path.length) : path;
|
|
23
|
+
let prefix = Stdlib_Option.getOr(trimmed.split("/")[0], "");
|
|
24
|
+
if (prefix.length > 0 && servedPrefixes.contents.includes(prefix) && trimmed.length > (prefix.length + 1 | 0)) {
|
|
25
|
+
return trimmed;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function put(key, bytes, contentType) {
|
|
30
|
+
objects[key] = {
|
|
31
|
+
bytes: bytes,
|
|
32
|
+
contentType: contentType
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function get(key) {
|
|
37
|
+
return objects[key];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function reset() {
|
|
41
|
+
Object.keys(objects).forEach(k => Stdlib_Dict.$$delete(objects, k));
|
|
42
|
+
servedPrefixes.contents = [defaultUploadPrefix];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
objects,
|
|
47
|
+
defaultUploadPrefix,
|
|
48
|
+
servedPrefixes,
|
|
49
|
+
registerServedPrefix,
|
|
50
|
+
servedKey,
|
|
51
|
+
put,
|
|
52
|
+
get,
|
|
53
|
+
reset,
|
|
54
|
+
}
|
|
55
|
+
/* No side effect */
|
|
@@ -104,7 +104,7 @@ let runCatchup = async (
|
|
|
104
104
|
|
|
105
105
|
// --- DCB axis: reconstruct the published envelope, position order ---
|
|
106
106
|
let dcbRows = await pool->Pg.query(
|
|
107
|
-
"SELECT log_name, event_type, data, meta, tags FROM dcb_event WHERE position <= $1::bigint ORDER BY position ASC",
|
|
107
|
+
"SELECT log_name, event_type, data, meta, tags, recorded_at FROM dcb_event WHERE position <= $1::bigint ORDER BY position ASC",
|
|
108
108
|
[JSON.Encode.int(bounds.dcbBound)],
|
|
109
109
|
)
|
|
110
110
|
for i in 0 to dcbRows->Array.length - 1 {
|
|
@@ -116,11 +116,16 @@ let runCatchup = async (
|
|
|
116
116
|
row->Dict.get("meta"),
|
|
117
117
|
) {
|
|
118
118
|
| (Some(JSON.String(logName)), Some(JSON.String(eventType)), Some(data), Some(meta)) =>
|
|
119
|
+
let recordedAt = switch row->Dict.get("recorded_at") {
|
|
120
|
+
| Some(JSON.String(v)) => v
|
|
121
|
+
| _ => ""
|
|
122
|
+
}
|
|
119
123
|
switch ProjectionCheckpoint.dcbCatchupEnvelope(
|
|
120
124
|
~logName,
|
|
121
125
|
~eventType,
|
|
122
126
|
~dataText=JSON.stringify(data),
|
|
123
127
|
~metaText=JSON.stringify(meta),
|
|
128
|
+
~recordedAt,
|
|
124
129
|
~firstTagValue=firstTagValue(row),
|
|
125
130
|
) {
|
|
126
131
|
| Some(envelope) => await deliver(handlers, envelope)
|
|
@@ -77,7 +77,7 @@ async function runCatchup(pool, bounds, handlers) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
let dcbRows = await PgDriver$ReventlessPostgres.query(pool, "SELECT log_name, event_type, data, meta, tags FROM dcb_event WHERE position <= $1::bigint ORDER BY position ASC", [bounds.dcbBound]);
|
|
80
|
+
let dcbRows = await PgDriver$ReventlessPostgres.query(pool, "SELECT log_name, event_type, data, meta, tags, recorded_at FROM dcb_event WHERE position <= $1::bigint ORDER BY position ASC", [bounds.dcbBound]);
|
|
81
81
|
for (let i$1 = 0, i_finish$1 = dcbRows.length; i$1 < i_finish$1; ++i$1) {
|
|
82
82
|
let row$1 = dcbRows[i$1];
|
|
83
83
|
let match = row$1["log_name"];
|
|
@@ -85,7 +85,9 @@ async function runCatchup(pool, bounds, handlers) {
|
|
|
85
85
|
let match$2 = row$1["data"];
|
|
86
86
|
let match$3 = row$1["meta"];
|
|
87
87
|
if (typeof match === "string" && typeof match$1 === "string" && match$2 !== undefined && match$3 !== undefined) {
|
|
88
|
-
let
|
|
88
|
+
let match$4 = row$1["recorded_at"];
|
|
89
|
+
let recordedAt = typeof match$4 === "string" ? match$4 : "";
|
|
90
|
+
let envelope$1 = ProjectionCheckpoint$ReventlessLocal.dcbCatchupEnvelope(match, match$1, JSON.stringify(match$2), JSON.stringify(match$3), recordedAt, firstTagValue(row$1));
|
|
89
91
|
if (envelope$1 !== undefined) {
|
|
90
92
|
await deliver(handlers, envelope$1);
|
|
91
93
|
}
|