@reventlessdev/reventless-local 3.0.0-alpha.167 → 3.0.0-alpha.168

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +11 -11
  3. package/src/Platform.res +11 -1
  4. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +16 -8
  5. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs +7 -7
  6. package/src/adapter/DomainGraphQL_Server.res +96 -0
  7. package/src/adapter/DomainGraphQL_Server.res.mjs +84 -0
  8. package/src/adapter/GraphQL_Server.res.mjs +18 -0
  9. package/src/adapter/LocalObjectStore.res +64 -0
  10. package/src/adapter/LocalObjectStore.res.mjs +55 -0
  11. package/src/adapter/PgProjectionCatchup.res +6 -1
  12. package/src/adapter/PgProjectionCatchup.res.mjs +4 -2
  13. package/src/adapter/ProjectionCheckpoint.res +11 -2
  14. package/src/adapter/ProjectionCheckpoint.res.mjs +9 -3
  15. package/tests/PluginEventDecodeTest.res +2 -0
  16. package/tests/PluginEventDecodeTest.res.mjs +2 -2
  17. package/tests/adapter/GraphQL_SchemaInspectorTest.res +6 -0
  18. package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +6 -0
  19. package/tests/adapter/InboundTranslationMutationTest.res +139 -0
  20. package/tests/adapter/InboundTranslationMutationTest.res.mjs +132 -0
  21. package/tests/adapter/QueryDbListResolverTest.res +2 -0
  22. package/tests/adapter/QueryDbListResolverTest.res.mjs +2 -0
  23. package/tests/adapter/ServedBucketHttpTest.res +174 -0
  24. package/tests/adapter/ServedBucketHttpTest.res.mjs +177 -0
  25. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +13 -9
  26. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +21 -13
  27. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res +215 -0
  28. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +273 -0
  29. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformTest.res +38 -0
  30. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformTest.res.mjs +28 -0
  31. package/tests/components/stateviewslice/StateViewSliceFixtures.res +1 -1
  32. package/tests/components/stateviewslice/StateViewSliceFixtures.res.mjs +2 -1
  33. package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res +1 -1
  34. package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res.mjs +2 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,19 @@
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.168 (2026-07-26)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core:** decode the event envelope in OutboundTranslationSlice ([66008a4](https://github.com/ReventlessDev/reventless-core/commit/66008a4dff411c85470f60aac5424e7b3eda6f01))
11
+ * **graphql:** give InboundTranslationSlice mutations a resolvable result type ([381b545](https://github.com/ReventlessDev/reventless-core/commit/381b5458c16797404cb8ed95fa853fb1e1ca4199))
12
+ ### Features
13
+
14
+ * **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)
15
+ * **aws:** geocoder + upload presign Lambda Function URL services ([be43179](https://github.com/ReventlessDev/reventless-core/commit/be4317972e781dbe3c378f21a28975d1ac8b9add))
16
+ * **platform:** serve a private bucket to the UI under a same-origin prefix ([793c8b2](https://github.com/ReventlessDev/reventless-core/commit/793c8b25e13969b42ce331c698bad473be4c0c88))
17
+
18
+
6
19
  # 3.0.0-alpha.167 (2026-07-24)
7
20
 
8
21
  **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.167",
3
+ "version": "3.0.0-alpha.168",
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.30",
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",
37
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
38
- "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.24",
39
- "@reventlessdev/reventless-core": "3.0.0-alpha.180",
40
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.27",
41
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.127",
42
- "@reventlessdev/reventless-infra": "3.0.0-alpha.104",
43
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.44",
44
- "@reventlessdev/reventless-spec": "3.0.0-alpha.80"
37
+ "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
38
+ "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.25",
39
+ "@reventlessdev/reventless-core": "3.0.0-alpha.181",
40
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.28",
41
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.128",
42
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.45",
43
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.105",
44
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.81"
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.54"
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<JSON.t => promise<result<array<string>, string>>> = Dict.make()
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: result<array<string>, string> => unit,
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}: String!`]
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
- switch result {
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 = (~fieldName: string, ~receive: JSON.t => promise<result<array<string>, string>>) => {
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 + `: String!`];
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
- if (result.TAG === "Ok") {
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
- /* GraphQL_FragmentGenerator-ReventlessCore Not a pure module */
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 envelope$1 = ProjectionCheckpoint$ReventlessLocal.dcbCatchupEnvelope(match, match$1, JSON.stringify(match$2), JSON.stringify(match$3), firstTagValue(row$1));
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
  }